From 4d270aea053432723b67a11c3196ba78c235074d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Murat=20=C3=96ZDEM=C4=B0R?= Date: Fri, 23 Oct 2026 19:14:08 +0300 Subject: [PATCH] feat: add nowcast point alarm unregister client with confirmation and backup Shows environment, count and an irreversibility warning, requires typing the confirmation word (plus the environment name on prod), backs every registration up through get-by-id before deleting and aborts the whole run if the backup cannot be completed. --- common/config.py | 2 + nowcast-point-unregister.py | 86 +++++++++++++++++++++++++++++++++++++ point_alarm.py | 75 ++++++++++++++++++++++++++++++++ tests/test_point_alarm.py | 45 +++++++++++++++++++ 4 files changed, 208 insertions(+) create mode 100644 nowcast-point-unregister.py diff --git a/common/config.py b/common/config.py index 5fcd3e7..575631f 100644 --- a/common/config.py +++ b/common/config.py @@ -68,6 +68,7 @@ class Settings: token_expiry_skew_seconds: int max_retries: int retry_base_seconds: float + environment: str = "" @classmethod def from_env(cls, env_file: Path = DEFAULT_ENV_FILE) -> "Settings": @@ -96,6 +97,7 @@ class Settings: return cls( env_file=env_file, base_url=base_url, + environment=environment, hmac_secret=_required("IKLIM_HMAC_SECRET"), accept_language=language, timeout_seconds=_positive_float("IKLIM_TIMEOUT_SECONDS", "30"), diff --git a/nowcast-point-unregister.py b/nowcast-point-unregister.py new file mode 100644 index 0000000..d3c3233 --- /dev/null +++ b/nowcast-point-unregister.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Delete point alarm registrations listed in a CSV/TXT file, with confirmation and backup.""" + +from __future__ import annotations + +import argparse +import logging +import sys +from pathlib import Path + +from common import ApiError, ConfigurationError, IklimClient, configure_logging +from point_alarm import ( + PointAlarmService, + PointUnregisterProcessor, + read_id_list, + timestamped_path, + write_records, +) + + +BASE_DIR = Path(__file__).resolve().parent + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Dosyadaki registrationId'leri siler (DELETE /v1/alarms/points/unregister/{id}). " + "Girdi CSV (registrationId sütunu) veya TXT (satır başına bir ID). Başlamadan önce " + "ortam, silinecek sayı ve 'geri alınamaz' uyarısı gösterilir; onay için SİL yazılmalıdır " + "(prod ortamında ek olarak 'prod' yazılır). Bir hesabın son kaydı silinirse webhook da " + "silinir. Silmeden önce her kayıt get-by-id ile yedeklenir; yedek yazılamazsa hiçbir " + "kayıt silinmez." + ) + ) + parser.add_argument("input_file", type=Path, help="ID listesi (.csv veya .txt)") + parser.add_argument( + "--backup", + type=Path, + default=BASE_DIR / "nowcast-point-unregister-backup.csv", + help="Yedek dosyası (.csv/.txt, zaman soneki eklenir; varsayılan: nowcast-point-unregister-backup.csv)", + ) + parser.add_argument("--no-backup", action="store_true", help="Silmeden önce yedek almayı kapatır") + parser.add_argument( + "--output", + type=Path, + help="Sonuç dosyası (.csv/.txt, zaman soneki eklenir); verilmezse yalnızca özet yazılır", + ) + parser.add_argument( + "--debug-log", + type=Path, + default=BASE_DIR / "logs" / "iklim-api-debug.log", + help="Maskelenmiş HTTP debug log dosyası", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + configure_logging(args.debug_log) + try: + ids = read_id_list(args.input_file, "registrationId") + client = IklimClient.from_env(BASE_DIR / ".env") + processor = PointUnregisterProcessor( + PointAlarmService(client), + ids=ids, + environment=client.settings.environment, + base_url=client.settings.base_url, + backup_path=None if args.no_backup else timestamped_path(args.backup), + ) + results = processor.run() + if not results: + return 1 + failed = sum(1 for row in results if row["status"] == "FAILED") + print(f"{len(results) - failed} silindi, {failed} başarısız.") + if args.output: + path = timestamped_path(args.output) + write_records(path, results, [], columns=["registrationId", "status", "httpStatus", "errorBody"]) + print(f"Sonuçlar yazıldı: {path.resolve()}") + return 1 if failed else 0 + except (ConfigurationError, ApiError, OSError) as exc: + logging.error("İşlem durduruldu: %s", exc) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/point_alarm.py b/point_alarm.py index 261d685..965692e 100644 --- a/point_alarm.py +++ b/point_alarm.py @@ -943,3 +943,78 @@ class PointUpdateProcessor: row["status"] = status row["httpStatus"] = http_status row["errorBody"] = error_body + + +CONFIRM_WORD = "SİL" + + +class PointUnregisterProcessor: + """Delete registrations listed in a CSV/TXT after a typed confirmation and a backup.""" + + def __init__( + self, + service: PointAlarmService, + *, + ids: list[str], + environment: str, + base_url: str, + backup_path: Path | None, + input_fn: Callable[[str], str] = input, + out: TextIO | None = None, + ) -> None: + self.service = service + self.ids = ids + self.environment = environment + self.base_url = base_url + self.backup_path = backup_path + self.input_fn = input_fn + self.out = out or sys.stdout + + def confirm(self) -> bool: + say = lambda text: self.out.write(text + "\n") + say(f"Ortam: {self.environment or '-'} ({self.base_url})") + say(f"Silinecek kayıt sayısı: {len(self.ids)}") + say("Bu işlem geri alınamaz. Bir hesabın SON kaydı silinirse hesabın webhook tanımı da silinir.") + if self.environment == "prod": + if self.input_fn("PROD ortamında işlem yapıyorsunuz. Onaylamak için 'prod' yazın: ").strip() != "prod": + say("Ortam adı eşleşmedi; işlem iptal edildi.") + return False + if self.input_fn(f"Devam etmek için '{CONFIRM_WORD}' yazın: ").strip() != CONFIRM_WORD: + say("Onay verilmedi; işlem iptal edildi.") + return False + return True + + def run(self) -> list[dict[str, Any]]: + if not self.confirm(): + return [] + backups: list[dict[str, Any]] = [] + results: list[dict[str, Any]] = [] + webhooks: list[dict[str, Any]] = [] + if self.backup_path: + for registration_id in self.ids: + try: + data = self.service.get_by_id(registration_id).data + except ApiError as exc: + if exc.status_code == 404: + LOGGER.warning("%s bulunamadı; yedeklenemedi.", registration_id) + continue + # An unreadable record cannot be backed up, so nothing is deleted. + raise + if isinstance(data, dict): + backups.append(flatten_registration(data)) + webhook = registration_webhook(data) + if webhook and webhook not in webhooks: + webhooks.append(webhook) + # A backup that cannot be written must stop the run before anything is deleted. + write_records(self.backup_path, backups, webhooks) + LOGGER.info("Yedek yazıldı: %s (%s kayıt)", self.backup_path, len(backups)) + for registration_id in self.ids: + try: + response = self.service.unregister(registration_id) + except ApiError as exc: + results.append({"registrationId": registration_id, "status": "FAILED", "httpStatus": exc.status_code, "errorBody": error_body_text(exc)}) + LOGGER.error("%s silinemedi: %s", registration_id, exc) + else: + results.append({"registrationId": registration_id, "status": "SUCCESS", "httpStatus": response.status_code, "errorBody": ""}) + LOGGER.info("%s silindi.", registration_id) + return results diff --git a/tests/test_point_alarm.py b/tests/test_point_alarm.py index a5ba9e4..a715aef 100644 --- a/tests/test_point_alarm.py +++ b/tests/test_point_alarm.py @@ -1,6 +1,7 @@ from __future__ import annotations import csv +import io import json import os import tempfile @@ -15,6 +16,7 @@ from common import ApiError, ConfigurationError from common.client import ApiResponse from point_alarm import ( PointRegistrationProcessor, + PointUnregisterProcessor, PointUpdateProcessor, build_update_payload, PointStore, @@ -294,5 +296,48 @@ class UpdateTest(unittest.TestCase): self.assertEqual((row["status"], row["httpStatus"]), ("SUCCESS", "HTTP_200")) +class UnregisterTest(unittest.TestCase): + class Svc: + def __init__(self) -> None: + self.deleted: list[str] = [] + + def get_by_id(self, value): + return ApiResponse(200, dict(registration(1), registrationId=value)) + + def unregister(self, value): + self.deleted.append(value) + return ApiResponse(204, None) + + def make(self, svc, answers, env="test", backup=None): + queue = list(answers) + return PointUnregisterProcessor( + svc, ids=[REG_ID], environment=env, base_url="https://x", + backup_path=backup, input_fn=lambda _: queue.pop(0), out=io.StringIO(), + ) + + def test_wrong_confirmation_deletes_nothing(self) -> None: + svc = self.Svc() + self.assertEqual(self.make(svc, ["sil"]).run(), []) + self.assertEqual(svc.deleted, []) + + def test_prod_needs_environment_name(self) -> None: + svc = self.Svc() + self.assertEqual(self.make(svc, ["test"], env="prod").run(), []) + self.assertEqual(svc.deleted, []) + self.assertEqual(len(self.make(svc, ["prod", "SİL"], env="prod").run()), 1) + + def test_backup_written_before_delete_and_failure_blocks(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + svc = self.Svc() + backup = Path(tmp) / "b.csv" + results = self.make(svc, ["SİL"], backup=backup).run() + self.assertEqual(results[0]["status"], "SUCCESS") + self.assertIn(REG_ID, backup.read_text(encoding="utf-8-sig")) + svc2 = self.Svc() + with self.assertRaises(OSError): + self.make(svc2, ["SİL"], backup=Path(tmp) / "no" / "b.csv").run() + self.assertEqual(svc2.deleted, []) + + if __name__ == "__main__": unittest.main()