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()