From 9f67d87e9d95c2bba5594df9fc6345ebf442970c Mon Sep 17 00:00:00 2001 From: Anders Knutsen Date: Fri, 28 Nov 2025 13:37:25 +0100 Subject: [PATCH] v.012 --- Patriot API.postman_collection.json | 50 +-- keys.json | 80 ++++- main.py | 487 ++++++++++++++++++---------- 3 files changed, 401 insertions(+), 216 deletions(-) diff --git a/Patriot API.postman_collection.json b/Patriot API.postman_collection.json index d60147b..d5cf0a2 100644 --- a/Patriot API.postman_collection.json +++ b/Patriot API.postman_collection.json @@ -153,7 +153,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"client_id\": 1234,\n \"info\": {\n \"Name\": \"Customer Name\",\n \"Location\": \"Address line 1\",\n \"area_code\": \"area/postal code\",\n \"area\": \"City\",\n \"AltLookup\": true,\n \"AltAlarmNo\": \"SIA1000101\",\n \"ConvertType\": \"None\",\n \"NoSigsMon\": \"None\",\n \"SinceDays\": 0,\n \"SinceHrs\": 0,\n \"SinceMins\": 0,\n \"ResetNosigsIgnored\": true,\n \"ResetNosigsDays\": 0,\n \"ResetNosigsHrs\": 0,\n \"ResetNosigsMins\": 0,\n \"PanelName\": \"Panel Type\",\n \"PanelSite\": \"Panel location\"\n }\n}", + "raw": "{\n \"client_id\": 123456789,\n \"info\": {\n \"Name\": \"Anders Knutsen\",\n \"Alias\": \"000FD267\",\n \"Location\": \"Lislebyveien 58\",\n \"area_code\": \"1604\",\n \"area\": \"Fredrikstad\",\n \"BusPhone\": \"69310000\",\n \"Email\": \"post@ostsik.no\",\n \"OKPassword\": \"franzjager\",\n \"SpecRequest\": \"Dette skal gjøres ved alarm på denne kunden.\",\n \"NoSigsMon\": \"ActiveAny\",\n \"SinceDays\": 1,\n \"SinceHrs\": 0,\n \"SinceMins\": 30,\n \"ResetNosigsIgnored\": true,\n \"ResetNosigsDays\": 7,\n \"ResetNosigsHrs\": 0,\n \"ResetNosigsMins\": 0,\n \"InstallDateTime\": \"2023-02-20\",\n \"PanelName\": \"Ajax\",\n \"PanelSite\": \"Stue\",\n \"KeypadLocation\": \"Inngang\",\n \"SPPage\": \"Ekstra informasjon som kan være relevant.\"\n }\n}", "options": { "raw": { "language": "json" @@ -186,7 +186,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"client_id\": 1234,\n \"info\": {\n \"Location\": \"New street 99\"\n }\n}", + "raw": "{\n \"client_id\": 123456789,\n \"info\": {\n \"Name\": \"optional\"\n }\n}", "options": { "raw": { "language": "json" @@ -202,45 +202,9 @@ "clients" ] }, - "description": "Updates specific fields on an existing client.\n\nPossible keys:\n\n\"Name\": \"Customer Name\", \n\"Location\": \"New street 99\", \n\"area_code\": \"area/postal code\", \n\"area\": \"City\", \n\"AltLookup\": true, \n\"AltAlarmNo\": \"SIA1000101\", \n\"ConvertType\": \"None\", \n\"NoSigsMon\": \"None\", \n\"SinceDays\": 0, \n\"SinceHrs\": 0, \n\"SinceMins\": 0, \n\"ResetNosigsIgnored\": true, \n\"ResetNosigsDays\": 0, \n\"ResetNosigsHrs\": 0, \n\"ResetNosigsMins\": 0, \n\"PanelName\": \"Panel Type\", \n\"PanelSite\": \"Panel location\"\n\nExample:\n\n{ \n\"client_id\": 1234, \n\"info\": { \n\"Location\": \"New street 99\" \n} \n}" + "description": "Updates specific fields on an existing client.\n\nPossible keys: \n\"Alias\": \"optional\", \n\"Location\": \"optional\", \n\"area_code\": \"optional\", \n\"area\": \"optional\", \n\"BusPhone\": \"optional\", \n\"Email\": \"optional\", \n\"OKPassword\": \"optional\", \n\"SpecRequest\": \"optional\", \n\"NoSigsMon\": \"optional\", \n\"SinceDays\": 0, \n\"SinceHrs\": 0, \n\"SinceMins\": 0, \n\"ResetNosigsIgnored\": true, \n\"ResetNosigsDays\": 0, \n\"ResetNosigsHrs\": 0, \n\"ResetNosigsMins\": 0, \n\"InstallDateTime\": \"optional\", \n\"PanelName\": \"optional\", \n\"PanelSite\": \"optional\", \n\"KeypadLocation\": \"optional\", \n\"SPPage\": \"optional\"\n\nExample:\n\n{ \n\"client_id\": 1234, \n\"info\": { \n\"Location\": \"New street 99\" \n} \n}" }, - "response": [ - { - "name": "Update client", - "originalRequest": { - "method": "PATCH", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{apiKey}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"client_id\": 1234,\n \"info\": {\n \"Location\": \"New street 99\"\n }\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/clients", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "clients" - ] - } - }, - "_postman_previewlanguage": "", - "header": [], - "cookie": [], - "body": "" - } - ] + "response": [] }, { "name": "Create/Update zone", @@ -255,7 +219,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"client_id\": 1234,\n \"zone_id\": 1,\n \"Zone_area\": \"Entrance\",\n \"ModuleNo\": 0\n}\n", + "raw": "{\n \"client_id\": 123456789,\n \"zone_id\": 3,\n \"Zone_area\": \"RD Stue\",\n \"ModuleNo\": 0\n}\n", "options": { "raw": { "language": "json" @@ -289,7 +253,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"client_id\": {{clientId}},\n \"user\": {\n \"User_Name\": \"User Number 1\",\n \"MobileNo\": \"+4712345678\",\n \"MobileNoOrder\": 1,\n \"Email\": \"user@email.com\",\n \"Type\": \"U\",\n \"UserNo\": 1,\n \"Instructions\": \"Optional\",\n \"CallOrder\": 0\n }\n}", + "raw": "{\n \"client_id\": {{clientId}},\n \"user\": {\n \"User_Name\": \"Anders Knutsen\",\n \"MobileNo\": \"+4740642018\",\n \"MobileNoOrder\": 1,\n \"Email\": \"anders@ostsik.no\",\n \"Type\": \"U\",\n \"UserNo\": 1,\n \"Instructions\": \"\",\n \"CallOrder\": 1\n }\n}", "options": { "raw": { "language": "json" @@ -396,7 +360,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"client_id\": {{clientId}},\n \"user_no\": 1\n}\n", + "raw": "{\n \"client_id\": {{clientId}},\n \"user_no\": 199\n}\n", "options": { "raw": { "language": "json" diff --git a/keys.json b/keys.json index b434d96..9fa3c0a 100644 --- a/keys.json +++ b/keys.json @@ -1,6 +1,76 @@ [ - { "key_name": "anders", "key": "_2sW6roe2ZQ4V6Cldo0v295fakHT8vBHqHScfliX445tZuzxDwMRqjPeCE7FDcVVr", "enabled": true, "valid_to": "2099-12-31T23:59:59Z" }, - { "key_name": "knut", "key": "_JCiGDfmgyoZGFiz6mtEC2YBCoxkmu5apvtGrCeDw0SWTui2B9kAIoSCK0wK85UIy", "enabled": true, "valid_to": "2099-12-31T23:59:59Z" }, - { "key_name": "novus", "key": "_6uOesirJ6QfdACcm2uQIddsstTvXRIP8YjhcKwMuyp9W7mzV2jI1mDisVip1hay5", "enabled": true, "valid_to": "2099-12-31T23:59:59Z" }, - { "key_name": "futurehome", "key": "_8942WfJxRPA9SiblpR4pD8slkK1jCWlmEJ7XCDmzpJDiG4KlBIbyl5syXxS3RHya", "enabled": true, "valid_to": "2099-01-01T00:00:00Z" } -] + { + "key_name": "anders", + "key": "_2sW6roe2ZQ4V6Cldo0v295fakHT8vBHqHScfliX445tZuzxDwMRqjPeCE7FDcVVr", + "enabled": true, + "valid_to": "2030-01-01T00:00:00Z", + + "port": "BASE11", + + "installer_name": "Østfold Sikkerhetsservice AS", + "installer_email": "service@ostsik.no", + + "use_glob_callouts": true, + "show_on_callouts": false, + "glob_callouts": "TLF01BASE01", + + "use_glob_callouts2": true, + "show_on_callouts2": false, + "glob_callouts2": "TLF02BASE01", + + "alt_lookup": true, + "alt_alarm_no": "CID4BASE01", + "convert_type": "None", + "siginterpret": "None", + + "client_groupings": [ + { + "description": "Alarm24", + "grouping_type_description": "Alarm24 Tilgang", + "grouping_allow_multiple": true + }, + { + "description": "Import", + "grouping_type_description": "Østfold Sikkerhetsservice AS", + "grouping_allow_multiple": true + } + ] + }, + { + "key_name": "futurehome", + "key": "_8942WfJxRPA9SiblpR4pD8slkK1jCWlmEJ7XCDmzpJDiG4KlBIbyl5syXxS3RHya", + "enabled": true, + "valid_to": "2030-01-01T00:00:00Z", + + "port": "BASE88", + + "installer_name": "Futurehome", + "installer_email": "anders@ostsik.no", + + "use_glob_callouts": true, + "show_on_callouts": false, + "glob_callouts": "TLF01BASE01", + + "use_glob_callouts2": true, + "show_on_callouts2": false, + "glob_callouts2": "TLF02BASE01", + + "alt_lookup": true, + "alt_alarm_no": "SIA1000101", + "convert_type": "None", + "siginterpret": "SIADecimal", + + "client_groupings": [ + { + "description": "Alarm24", + "grouping_type_description": "Alarm24 Tilgang", + "grouping_allow_multiple": true + }, + { + "description": "Alarm", + "grouping_type_description": "Futurehome", + "grouping_allow_multiple": true + } + ] + } +] \ No newline at end of file diff --git a/main.py b/main.py index 682f52f..bb0bb8a 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,7 @@ from __future__ import annotations import json, os, threading, re, logging from datetime import datetime, timezone -from typing import Dict, Optional +from typing import Dict, Optional, List from time import time from fastapi import FastAPI, HTTPException, Depends, Header, Request, Response, status @@ -9,22 +9,59 @@ from pydantic import BaseModel, Field, EmailStr, field_validator SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]") + def _safe_folder(name: str) -> str: cleaned = SAFE_NAME.sub("_", name.strip()) return cleaned or "unknown" + # ------------ Config ------------ DATA_FILE = os.getenv("DATA_FILE", "data.json") -KEY_FILE = os.getenv("KEY_FILE", "keys.json") -XML_DIR = os.getenv("XML_DIR", "out/clients") +KEY_FILE = os.getenv("KEY_FILE", "keys.json") +XML_DIR = os.getenv("XML_DIR", "out/clients") + +# ------------ Models used in keys.json ------------ + + +class ClientGroupingConfig(BaseModel): + description: str + grouping_type_description: str + grouping_allow_multiple: bool = True + # ------------ Auth (hot-reloaded key store) ------------ + + class KeyRecord(BaseModel): key_name: str key: str enabled: bool = True valid_to: str # ISO-8601 + # Used for __id in XML: <__id>{client_id}{port} + port: str = "" + + # Per-key forced XML fields + installer_name: str = "" # , user 199 name, grouping #2 + installer_email: str = "" # user 199 email + + use_glob_callouts: bool = True # + show_on_callouts: bool = False # + glob_callouts: str = "" # + + use_glob_callouts2: bool = True # + show_on_callouts2: bool = False # + glob_callouts2: str = "" # + + alt_lookup: bool = True # + alt_alarm_no: str = "CID4BASE01" # + convert_type: str = "None" # + siginterpret: str = "SIADecimal" # + + # NEW: per-key client groupings (rendered to ) + client_groupings: List[ClientGroupingConfig] = Field(default_factory=list) + + class KeyStore: def __init__(self, path: str): self.path = path @@ -80,39 +117,55 @@ class KeyStore: raise HTTPException(status_code=401, detail="Token expired") return rec + _key_store = KeyStore(KEY_FILE) + def require_api_key(authorization: Optional[str] = Header(None)) -> KeyRecord: if not authorization or not authorization.startswith("Bearer "): raise HTTPException(status_code=401, detail="Missing bearer token") token = authorization.split(" ", 1)[1].strip() return _key_store.validate_bearer(token) -# ------------ Models ------------ + +# ------------ Models (client payloads) ------------ + + class ClientInfo(BaseModel): + # From API / client section Name: str = Field(..., min_length=1, max_length=200) + Alias: Optional[str] = Field(None, max_length=200) Location: str = Field(..., min_length=1, max_length=200) - # split fields used by the API + # split fields used by the API → XML loc2 = "{area_code} {area}" area_code: str = Field(..., min_length=1, max_length=20) area: str = Field(..., min_length=1, max_length=200) - AltLookup: bool = True - AltAlarmNo: str | None = None - ConvertType: str = "None" - NoSigsMon: str = "None" + BusPhone: Optional[str] = Field(None, max_length=50) + Email: Optional[EmailStr] = None + OKPassword: Optional[str] = Field(None, max_length=100) + SpecRequest: Optional[str] = Field(None, max_length=1000) - SinceDays: int = Field(0, ge=0) + # No-signal monitoring + NoSigsMon: str = "ActiveAny" + SinceDays: int = Field(1, ge=0) SinceHrs: int = Field(0, ge=0) - SinceMins: int = Field(0, ge=0) + SinceMins: int = Field(30, ge=0) ResetNosigsIgnored: bool = True - ResetNosigsDays: int = Field(0, ge=0) + ResetNosigsDays: int = Field(7, ge=0) ResetNosigsHrs: int = Field(0, ge=0) ResetNosigsMins: int = Field(0, ge=0) + InstallDateTime: Optional[str] = Field( + None, description="Installation date/time, e.g. '2023-02-20'" + ) + PanelName: str = "Panel Type" PanelSite: str = "Panel location" + KeypadLocation: Optional[str] = Field(None, max_length=200) + SPPage: Optional[str] = Field(None, max_length=2000) + class User(BaseModel): User_Name: str = Field(..., min_length=1, max_length=120) @@ -130,22 +183,27 @@ class User(BaseModel): raise ValueError("MobileNo must contain digits") return v.strip() + class ClientCreate(BaseModel): client_id: int = Field(..., ge=1) info: ClientInfo + class ClientInfoPatch(BaseModel): + # all optional, for PATCH Name: Optional[str] = Field(None, min_length=1, max_length=200) + Alias: Optional[str] = Field(None, max_length=200) Location: Optional[str] = Field(None, min_length=1, max_length=200) area_code: Optional[str] = Field(None, min_length=1, max_length=20) area: Optional[str] = Field(None, min_length=1, max_length=200) - AltLookup: Optional[bool] = None - AltAlarmNo: Optional[str] = None - ConvertType: Optional[str] = None - NoSigsMon: Optional[str] = None + BusPhone: Optional[str] = Field(None, max_length=50) + Email: Optional[EmailStr] = None + OKPassword: Optional[str] = Field(None, max_length=100) + SpecRequest: Optional[str] = Field(None, max_length=1000) + NoSigsMon: Optional[str] = None SinceDays: Optional[int] = Field(None, ge=0) SinceHrs: Optional[int] = Field(None, ge=0) SinceMins: Optional[int] = Field(None, ge=0) @@ -155,99 +213,103 @@ class ClientInfoPatch(BaseModel): ResetNosigsHrs: Optional[int] = Field(None, ge=0) ResetNosigsMins: Optional[int] = Field(None, ge=0) + InstallDateTime: Optional[str] = None + PanelName: Optional[str] = Field(None, min_length=1, max_length=200) PanelSite: Optional[str] = Field(None, min_length=1, max_length=200) + KeypadLocation: Optional[str] = Field(None, max_length=200) + SPPage: Optional[str] = Field(None, max_length=2000) + class ClientPatch(BaseModel): client_id: int = Field(..., ge=1) info: Optional[ClientInfoPatch] = None + class ClientID(BaseModel): client_id: int = Field(..., ge=1) + class ZoneUpdate(BaseModel): client_id: int = Field(..., ge=1) zone_id: int = Field(..., ge=1, le=999) Zone_area: str = Field(..., min_length=1, max_length=200) ModuleNo: int = Field(0, ge=0) + class ZoneDelete(BaseModel): client_id: int = Field(..., ge=1) zone_id: int = Field(..., ge=1, le=999) + class UserWithClient(BaseModel): client_id: int = Field(..., ge=1) user: User + class UserKey(BaseModel): client_id: int = Field(..., ge=1) user_no: int = Field(..., ge=1, le=999) + # ------------ In-memory store + persistence ------------ +# Store is namespaced per API key: "key_name:client_id" + + _store_lock = threading.Lock() -# Multi-tenant: {"keys": { key_name: { "clients": { client_id_str: {...} } } } } -_store: Dict = {"keys": {}} +_store: Dict = {"clients": {}} -def _client_key(client_id: int) -> str: - return str(client_id) - -def _tenant_store(key_name: str) -> Dict: - """Return the per-key tenant store, creating if needed.""" - tenants = _store.setdefault("keys", {}) - if key_name not in tenants: - tenants[key_name] = {"clients": {}} - return tenants[key_name] - -def _clients_for(key_name: str) -> Dict[str, Dict]: - """Return dict of clients for a given key_name.""" - tenant = _tenant_store(key_name) - return tenant.setdefault("clients", {}) - -def _require_client_for(key_name: str, client_id: int) -> Dict: - """Fetch a client for this key only; 404 if not found.""" - clients = _clients_for(key_name) - cli = clients.get(_client_key(client_id)) - if not cli: - raise HTTPException(status_code=404, detail="Client not found") - return cli def _load_store(): global _store if os.path.exists(DATA_FILE): with open(DATA_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - # Backwards compatibility: old single-tenant shape {"clients": {...}} - if isinstance(data, dict) and "keys" in data: - _store = data - elif isinstance(data, dict) and "clients" in data: - # Legacy: put all clients under a pseudo-tenant - _store = {"keys": {"__legacy__": {"clients": data["clients"]}}} - else: - _store = {"keys": {}} + _store = json.load(f) else: - _store = {"keys": {}} _flush_store() + def _flush_store(): tmp = DATA_FILE + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(_store, f, ensure_ascii=False, indent=2) os.replace(tmp, DATA_FILE) + +def _store_key(client_id: int, keyrec: KeyRecord) -> str: + return f"{keyrec.key_name}:{client_id}" + + +def _require_client(client_id: int, keyrec: KeyRecord) -> Dict: + skey = _store_key(client_id, keyrec) + cli = _store["clients"].get(skey) + if not cli: + raise HTTPException(status_code=404, detail="Client not found") + return cli + + # ------------ XML writer ------------ + + def _ensure_dir(path: str): os.makedirs(path, exist_ok=True) -def _xml_path_for(client_id: int, key_name: str) -> str: - client_base = os.path.join(XML_DIR, _safe_folder(key_name)) + +def _xml_path_for(client_id: int, keyrec: KeyRecord) -> str: + client_base = os.path.join(XML_DIR, _safe_folder(keyrec.key_name)) _ensure_dir(client_base) return os.path.join(client_base, f"{client_id}.xml") -def write_client_xml(client_id: int, key_name: str): + +def _bool_text(val: bool) -> str: + return "True" if val else "False" + + +def write_client_xml(client_id: int, keyrec: KeyRecord): from xml.etree.ElementTree import Element, SubElement, ElementTree - clients = _clients_for(key_name) - key = _client_key(client_id) - cli = clients.get(key) + + skey = _store_key(client_id, keyrec) + cli = _store["clients"].get(skey) if not cli: return @@ -255,28 +317,71 @@ def write_client_xml(client_id: int, key_name: str): root = Element("Clients") row = SubElement(root, "Row") - SubElement(row, "__id").text = str(client_id) + # __id = client_id + port, e.g. "9998BASE11" + combined_id = f"{client_id}{keyrec.port}" if keyrec.port else str(client_id) + SubElement(row, "__id").text = combined_id + + # Client fields from API SubElement(row, "Name").text = info.Name + SubElement(row, "Alias").text = info.Alias or "" SubElement(row, "Location").text = info.Location SubElement(row, "loc2").text = f"{info.area_code} {info.area}".strip() - SubElement(row, "AltLookup").text = "True" if info.AltLookup else "False" - SubElement(row, "AltAlarmNo").text = info.AltAlarmNo or "" - SubElement(row, "ConvertType").text = info.ConvertType - SubElement(row, "NoSigsMon").text = info.NoSigsMon + SubElement(row, "BusPhone").text = info.BusPhone or "" + SubElement(row, "Email").text = info.Email or "" + SubElement(row, "OKPassword").text = info.OKPassword or "" + SubElement(row, "SpecRequest").text = info.SpecRequest or "" + SubElement(row, "NoSigsMon").text = info.NoSigsMon SubElement(row, "SinceDays").text = str(info.SinceDays) SubElement(row, "SinceHrs").text = str(info.SinceHrs) SubElement(row, "SinceMins").text = str(info.SinceMins) - SubElement(row, "ResetNosigsIgnored").text = "True" if info.ResetNosigsIgnored else "False" + SubElement(row, "ResetNosigsIgnored").text = _bool_text(info.ResetNosigsIgnored) SubElement(row, "ResetNosigsDays").text = str(info.ResetNosigsDays) SubElement(row, "ResetNosigsHrs").text = str(info.ResetNosigsHrs) SubElement(row, "ResetNosigsMins").text = str(info.ResetNosigsMins) + SubElement(row, "InstallDateTime").text = info.InstallDateTime or "" + SubElement(row, "PanelName").text = info.PanelName SubElement(row, "PanelSite").text = info.PanelSite + SubElement(row, "KeypadLocation").text = info.KeypadLocation or "" + SubElement(row, "SPPage").text = info.SPPage or "" + + # Per-key forced fields (from keys.json) + SubElement(row, "Installer").text = keyrec.installer_name or "" + + SubElement(row, "UseGlobCallOuts").text = _bool_text(keyrec.use_glob_callouts) + SubElement(row, "ShowOnCallOuts").text = _bool_text(keyrec.show_on_callouts) + SubElement(row, "GlobCallOuts").text = keyrec.glob_callouts or "" + + SubElement(row, "UseGlobCallOuts2").text = _bool_text(keyrec.use_glob_callouts2) + SubElement(row, "ShowOnCallOuts2").text = _bool_text(keyrec.show_on_callouts2) + SubElement(row, "GlobCallOuts2").text = keyrec.glob_callouts2 or "" + + SubElement(row, "AltLookup").text = _bool_text(keyrec.alt_lookup) + SubElement(row, "AltAlarmNo").text = keyrec.alt_alarm_no or "" + SubElement(row, "ConvertType").text = keyrec.convert_type or "None" + SubElement(row, "SIGINTERPRET").text = keyrec.siginterpret or "SIADecimal" + + # ClientGroupings from keys.json + cgs_el = SubElement(row, "ClientGroupings") + if keyrec.client_groupings: + for cg in keyrec.client_groupings: + cg_el = SubElement(cgs_el, "ClientGrouping") + SubElement(cg_el, "Description").text = cg.description + SubElement(cg_el, "GroupingTypeDescription").text = cg.grouping_type_description + SubElement(cg_el, "GroupingAllowMultiple").text = _bool_text( + cg.grouping_allow_multiple + ) + else: + # Fallback default if nothing configured (optional) + cg_el = SubElement(cgs_el, "ClientGrouping") + SubElement(cg_el, "Description").text = "Alarm24" + SubElement(cg_el, "GroupingTypeDescription").text = "Alarm24 Tilgang" + SubElement(cg_el, "GroupingAllowMultiple").text = "True" # Zones zones_el = SubElement(row, "Zones") @@ -288,11 +393,13 @@ def write_client_xml(client_id: int, key_name: str): SubElement(z_el, "Zone_No").text = zid_str SubElement(z_el, "ModuleNo").text = str(z.get("ModuleNo", 0)) - # Users + # Users (from API) users_el = SubElement(row, "Users") users = cli.get("users") or {} + def _sort_user(u: dict): return (u.get("CallOrder", 0), u.get("MobileNoOrder", 0), u.get("UserNo", 0)) + for _, u in sorted(users.items(), key=lambda kv: _sort_user(kv[1])): u_el = SubElement(users_el, "User") SubElement(u_el, "User_Name").text = u.get("User_Name", "") @@ -304,9 +411,18 @@ def write_client_xml(client_id: int, key_name: str): SubElement(u_el, "Instructions").text = u.get("Instructions", "") or "" SubElement(u_el, "CallOrder").text = str(u.get("CallOrder", 0)) - path = _xml_path_for(client_id, key_name) + # Extra forced installer user (type "N", UserNo 199) + inst_user_el = SubElement(users_el, "User") + SubElement(inst_user_el, "User_Name").text = keyrec.installer_name or "" + SubElement(inst_user_el, "Email").text = keyrec.installer_email or "" + SubElement(inst_user_el, "UserNo").text = "199" + SubElement(inst_user_el, "CallOrder").text = "0" + SubElement(inst_user_el, "Type").text = "N" + + path = _xml_path_for(client_id, keyrec) try: import xml.dom.minidom as minidom + tmp_path = path + ".tmp" ElementTree(root).write(tmp_path, encoding="utf-8", xml_declaration=True) with open(tmp_path, "rb") as rf: @@ -318,9 +434,10 @@ def write_client_xml(client_id: int, key_name: str): except Exception: ElementTree(root).write(path, encoding="utf-8", xml_declaration=True) + # ------------ App & logging middleware ------------ _load_store() -app = FastAPI(title="Client Registry API", version="3.1.0") +app = FastAPI(title="Client Registry API", version="3.3.0") logging.basicConfig( filename="api_requests.log", @@ -331,6 +448,7 @@ logging.basicConfig( REDACT_KEYS = {"authorization", "password", "secret", "key"} MAX_BODY_CHARS = 4000 + def _redact(obj): try: if isinstance(obj, dict): @@ -341,6 +459,7 @@ def _redact(obj): pass return obj + @app.middleware("http") async def log_requests(request: Request, call_next): start = time() @@ -350,8 +469,10 @@ async def log_requests(request: Request, call_next): if method in {"POST", "PUT", "PATCH", "DELETE"}: try: raw = await request.body() + async def receive(): return {"type": "http.request", "body": raw, "more_body": False} + request._receive = receive try: @@ -367,11 +488,16 @@ async def log_requests(request: Request, call_next): logging.info( "%s %s - %s - %.3fs - body=%s", - method, request.url.path, response.status_code, duration, body_text + method, + request.url.path, + response.status_code, + duration, + body_text, ) return response -# --------- Routes --------- + +# --------- Routes (unchanged logic, but keyrec now has client_groupings) --------- # UPSERT CLIENT (create if missing, update if exists) @app.put("/clients", status_code=200) @@ -384,45 +510,33 @@ def upsert_client( logging.info( "AUDIT upsert_client key=%s client_id=%s payload=%s", - keyrec.key_name, client_id, payload.model_dump() + keyrec.key_name, + client_id, + payload.model_dump(), ) info = payload.info.model_dump() - - # Auto-defaults - alt = info.get("AltAlarmNo") - if alt is None or (isinstance(alt, str) and not alt.strip()): - info["AltAlarmNo"] = "SIA1000101" - - pn = info.get("PanelName") - if pn is None or (isinstance(pn, str) and not pn.strip()): - info["PanelName"] = "STANDARD" - - key = _client_key(client_id) + skey = _store_key(client_id, keyrec) with _store_lock: - clients = _clients_for(keyrec.key_name) - existed = key in clients + existed = skey in _store["clients"] if existed: - cli = clients[key] + cli = _store["clients"][skey] cli["info"] = info else: - cli = { - "info": info, - "zones": {}, - "users": {} - } - clients[key] = cli + cli = {"info": info, "zones": {}, "users": {}} + _store["clients"][skey] = cli if response is not None: response.status_code = status.HTTP_200_OK if existed else status.HTTP_201_CREATED _flush_store() - write_client_xml(client_id, keyrec.key_name) + write_client_xml(client_id, keyrec) return {"client_id": client_id} + # PATCH CLIENT INFO (partial replace of info) @app.patch("/clients", status_code=200) def patch_client( @@ -432,41 +546,30 @@ def patch_client( client_id = payload.client_id logging.info( "AUDIT patch_client key=%s client_id=%s payload=%s", - keyrec.key_name, client_id, payload.model_dump() + keyrec.key_name, + client_id, + payload.model_dump(), ) with _store_lock: - cli = _require_client_for(keyrec.key_name, client_id) + cli = _require_client(client_id, keyrec) if payload.info is not None: - # Current stored info (must be valid ClientInfo shape) current_info = cli.get("info", {}) base = ClientInfo(**current_info).model_dump() - - # Only the fields actually sent in the request updates = payload.info.model_dump(exclude_unset=True) - - # Merge: updates override base merged = {**base, **updates} - # Auto-defaults AFTER merge - alt = merged.get("AltAlarmNo") - if alt is None or (isinstance(alt, str) and not alt.strip()): - merged["AltAlarmNo"] = "SIA1000101" - - pn = merged.get("PanelName") - if pn is None or (isinstance(pn, str) and not pn.strip()): - merged["PanelName"] = "STANDARD" - merged_valid = ClientInfo(**merged).model_dump() cli["info"] = merged_valid _flush_store() - write_client_xml(client_id, keyrec.key_name) + write_client_xml(client_id, keyrec) return {"client_id": client_id, **cli} -# GET CLIENT (header-based) + +# GET CLIENT (with header X-Client-Id, auto-regens XML if missing) @app.get("/clients") def get_client( x_client_id: int = Header(..., alias="X-Client-Id"), @@ -475,72 +578,86 @@ def get_client( client_id = x_client_id logging.info("AUDIT get_client key=%s client_id=%s", keyrec.key_name, client_id) - cli = _require_client_for(keyrec.key_name, client_id) + cli = _require_client(client_id, keyrec) - xml_path = _xml_path_for(client_id, keyrec.key_name) + xml_path = _xml_path_for(client_id, keyrec) if not os.path.exists(xml_path): - logging.info("XML missing for client_id=%s (key=%s). Regenerating...", client_id, keyrec.key_name) - write_client_xml(client_id, keyrec.key_name) + logging.info( + "XML missing for client_id=%s (key=%s). Regenerating...", + client_id, + keyrec.key_name, + ) + write_client_xml(client_id, keyrec) return {"client_id": client_id, **cli} + @app.post("/clients/get", status_code=200) -def get_client_post(payload: ClientID, keyrec: KeyRecord = Depends(require_api_key)): +def get_client_body(payload: ClientID, keyrec: KeyRecord = Depends(require_api_key)): client_id = payload.client_id - logging.info("AUDIT get_client (POST) key=%s client_id=%s", keyrec.key_name, client_id) + logging.info("AUDIT get_client key=%s client_id=%s", keyrec.key_name, client_id) + cli = _require_client(client_id, keyrec) - cli = _require_client_for(keyrec.key_name, client_id) - - xml_path = _xml_path_for(client_id, keyrec.key_name) + xml_path = _xml_path_for(client_id, keyrec) if not os.path.exists(xml_path): - logging.info("XML missing for client_id=%s (key=%s). Regenerating...", client_id, keyrec.key_name) - write_client_xml(client_id, keyrec.key_name) + logging.info( + "XML missing for client_id=%s (key=%s). Regenerating...", + client_id, + keyrec.key_name, + ) + write_client_xml(client_id, keyrec) return {"client_id": client_id, **cli} -# REGEN XML explicitly (for this key only) + +# REGEN XML explicitly @app.post("/clients/regen-xml", status_code=200) def regen_xml(payload: ClientID, keyrec: KeyRecord = Depends(require_api_key)): client_id = payload.client_id logging.info("AUDIT regen_xml key=%s client_id=%s", keyrec.key_name, client_id) - _require_client_for(keyrec.key_name, client_id) - write_client_xml(client_id, keyrec.key_name) + _require_client(client_id, keyrec) + write_client_xml(client_id, keyrec) return {"client_id": client_id, "xml": "regenerated"} + # DELETE ENTIRE CLIENT (for this key only) @app.delete("/clients", status_code=204) def delete_client(payload: ClientID, keyrec: KeyRecord = Depends(require_api_key)): client_id = payload.client_id logging.info("AUDIT delete_client key=%s client_id=%s", keyrec.key_name, client_id) - key = _client_key(client_id) + skey = _store_key(client_id, keyrec) with _store_lock: - clients = _clients_for(keyrec.key_name) - if key not in clients: + if skey not in _store["clients"]: raise HTTPException(status_code=404, detail="Client not found") - del clients[key] + del _store["clients"][skey] _flush_store() - # Remove XML only for *this* key try: - xml_path = _xml_path_for(client_id, keyrec.key_name) + xml_path = _xml_path_for(client_id, keyrec) if os.path.exists(xml_path): os.remove(xml_path) except Exception as e: - logging.warning("Failed to remove XML for client %s key %s: %s", client_id, keyrec.key_name, e) + logging.warning( + "Failed to remove XML file for client %s (key=%s): %s", + client_id, + keyrec.key_name, + e, + ) return + # ---- Zones ---- @app.get("/clients/zones") def list_zones( x_client_id: int = Header(..., alias="X-Client-Id"), - keyrec: KeyRecord = Depends(require_api_key) + keyrec: KeyRecord = Depends(require_api_key), ): client_id = x_client_id logging.info("AUDIT list_zones key=%s client_id=%s", keyrec.key_name, client_id) - cli = _require_client_for(keyrec.key_name, client_id) + cli = _require_client(client_id, keyrec) zones_obj = cli.get("zones", {}) zones = [ @@ -550,14 +667,14 @@ def list_zones( zones.sort(key=lambda z: z["Zone_No"]) return {"client_id": client_id, "zones": zones} + @app.post("/clients/zones/list", status_code=200) -def list_zones_post(payload: ClientID, keyrec: KeyRecord = Depends(require_api_key)): +def list_zones_body(payload: ClientID, keyrec: KeyRecord = Depends(require_api_key)): client_id = payload.client_id - logging.info("AUDIT list_zones (POST) key=%s client_id=%s", keyrec.key_name, client_id) + logging.info("AUDIT list_zones key=%s client_id=%s", keyrec.key_name, client_id) - cli = _require_client_for(keyrec.key_name, client_id) + cli = _require_client(client_id, keyrec) zones_obj = cli.get("zones", {}) - zones = [ {"Zone_No": int(zid), "Zone_area": data.get("Zone_area"), "ModuleNo": data.get("ModuleNo", 0)} for zid, data in zones_obj.items() @@ -565,78 +682,93 @@ def list_zones_post(payload: ClientID, keyrec: KeyRecord = Depends(require_api_k zones.sort(key=lambda z: z["Zone_No"]) return {"client_id": client_id, "zones": zones} + @app.put("/clients/zones", status_code=201) def upsert_zone(payload: ZoneUpdate, keyrec: KeyRecord = Depends(require_api_key)): client_id = payload.client_id zone_id = payload.zone_id logging.info( "AUDIT upsert_zone key=%s client_id=%s zone_id=%s body=%s", - keyrec.key_name, client_id, zone_id, payload.model_dump() + keyrec.key_name, + client_id, + zone_id, + payload.model_dump(), ) if not (1 <= zone_id <= 999): raise HTTPException(status_code=422, detail="zone_id must be 1..999") with _store_lock: - cli = _require_client_for(keyrec.key_name, client_id) + cli = _require_client(client_id, keyrec) cli.setdefault("zones", {}) cli["zones"][str(zone_id)] = { "Zone_area": payload.Zone_area, - "ModuleNo": payload.ModuleNo + "ModuleNo": payload.ModuleNo, } _flush_store() - write_client_xml(client_id, keyrec.key_name) + write_client_xml(client_id, keyrec) return { "client_id": client_id, - "zone": {"Zone_No": zone_id, "Zone_area": payload.Zone_area, "ModuleNo": payload.ModuleNo} + "zone": {"Zone_No": zone_id, "Zone_area": payload.Zone_area, "ModuleNo": payload.ModuleNo}, } + @app.delete("/clients/zones", status_code=204) def delete_zone(payload: ZoneDelete, keyrec: KeyRecord = Depends(require_api_key)): client_id = payload.client_id zone_id = payload.zone_id - logging.info("AUDIT delete_zone key=%s client_id=%s zone_id=%s", - keyrec.key_name, client_id, zone_id) + logging.info( + "AUDIT delete_zone key=%s client_id=%s zone_id=%s", + keyrec.key_name, + client_id, + zone_id, + ) with _store_lock: - cli = _require_client_for(keyrec.key_name, client_id) + cli = _require_client(client_id, keyrec) zones = cli.get("zones", {}) if str(zone_id) not in zones: raise HTTPException(status_code=404, detail="Zone not found") del zones[str(zone_id)] _flush_store() - write_client_xml(client_id, keyrec.key_name) + write_client_xml(client_id, keyrec) return + # ---- Users ---- @app.get("/clients/users") def list_users( x_client_id: int = Header(..., alias="X-Client-Id"), - keyrec: KeyRecord = Depends(require_api_key) + keyrec: KeyRecord = Depends(require_api_key), ): client_id = x_client_id logging.info("AUDIT list_users key=%s client_id=%s", keyrec.key_name, client_id) - cli = _require_client_for(keyrec.key_name, client_id) + cli = _require_client(client_id, keyrec) users = list(cli.get("users", {}).values()) users.sort(key=lambda u: (u.get("CallOrder", 0), u.get("MobileNoOrder", 0), u.get("UserNo", 0))) return {"client_id": client_id, "users": users} + @app.get("/clients/users/single") def get_user_header( x_client_id: int = Header(..., alias="X-Client-Id"), x_user_no: int = Header(..., alias="X-User-No"), - keyrec: KeyRecord = Depends(require_api_key) + keyrec: KeyRecord = Depends(require_api_key), ): client_id = x_client_id user_no = x_user_no - logging.info("AUDIT get_user (header) key=%s client_id=%s user_no=%s", - keyrec.key_name, client_id, user_no) + logging.info( + "AUDIT get_user key=%s client_id=%s user_no=%s", + keyrec.key_name, + client_id, + user_no, + ) - cli = _require_client_for(keyrec.key_name, client_id) + cli = _require_client(client_id, keyrec) u = cli.get("users", {}).get(str(user_no)) if not u: @@ -644,49 +776,60 @@ def get_user_header( return {"client_id": client_id, "user": u} -@app.post("/clients/users/list", status_code=200) -def list_users_post(payload: ClientID, keyrec: KeyRecord = Depends(require_api_key)): - client_id = payload.client_id - logging.info("AUDIT list_users (POST) key=%s client_id=%s", keyrec.key_name, client_id) - cli = _require_client_for(keyrec.key_name, client_id) +@app.post("/clients/users/list", status_code=200) +def list_users_body(payload: ClientID, keyrec: KeyRecord = Depends(require_api_key)): + client_id = payload.client_id + logging.info("AUDIT list_users key=%s client_id=%s", keyrec.key_name, client_id) + + cli = _require_client(client_id, keyrec) users = list(cli.get("users", {}).values()) users.sort(key=lambda u: (u.get("CallOrder", 0), u.get("MobileNoOrder", 0), u.get("UserNo", 0))) return {"client_id": client_id, "users": users} + @app.post("/clients/users", status_code=201) def add_user(payload: UserWithClient, keyrec: KeyRecord = Depends(require_api_key)): client_id = payload.client_id user = payload.user logging.info( "AUDIT add_user key=%s client_id=%s UserNo=%s user=%s", - keyrec.key_name, client_id, user.UserNo, user.model_dump() + keyrec.key_name, + client_id, + user.UserNo, + user.model_dump(), ) with _store_lock: - cli = _require_client_for(keyrec.key_name, client_id) + cli = _require_client(client_id, keyrec) cli.setdefault("users", {}) if str(user.UserNo) in cli["users"]: raise HTTPException(status_code=409, detail="UserNo already exists") cli["users"][str(user.UserNo)] = user.model_dump() _flush_store() - write_client_xml(client_id, keyrec.key_name) + write_client_xml(client_id, keyrec) return {"client_id": client_id, "user": user} + @app.post("/clients/users/get", status_code=200) -def get_user(payload: UserKey, keyrec: KeyRecord = Depends(require_api_key)): +def get_user_body(payload: UserKey, keyrec: KeyRecord = Depends(require_api_key)): client_id = payload.client_id user_no = payload.user_no - logging.info("AUDIT get_user (POST) key=%s client_id=%s user_no=%s", - keyrec.key_name, client_id, user_no) + logging.info( + "AUDIT get_user key=%s client_id=%s user_no=%s", + keyrec.key_name, + client_id, + user_no, + ) - cli = _require_client_for(keyrec.key_name, client_id) + cli = _require_client(client_id, keyrec) u = cli.get("users", {}).get(str(user_no)) if not u: raise HTTPException(status_code=404, detail="User not found") return {"client_id": client_id, "user": u} + @app.put("/clients/users", status_code=200) def replace_user(payload: UserWithClient, keyrec: KeyRecord = Depends(require_api_key)): client_id = payload.client_id @@ -695,31 +838,39 @@ def replace_user(payload: UserWithClient, keyrec: KeyRecord = Depends(require_ap logging.info( "AUDIT replace_user key=%s client_id=%s user_no=%s user=%s", - keyrec.key_name, client_id, user_no, user.model_dump() + keyrec.key_name, + client_id, + user_no, + user.model_dump(), ) with _store_lock: - cli = _require_client_for(keyrec.key_name, client_id) + cli = _require_client(client_id, keyrec) cli.setdefault("users", {}) cli["users"][str(user_no)] = user.model_dump() _flush_store() - write_client_xml(client_id, keyrec.key_name) + write_client_xml(client_id, keyrec) return {"client_id": client_id, "user": user} + @app.delete("/clients/users", status_code=204) def delete_user(payload: UserKey, keyrec: KeyRecord = Depends(require_api_key)): client_id = payload.client_id user_no = payload.user_no - logging.info("AUDIT delete_user key=%s client_id=%s user_no=%s", - keyrec.key_name, client_id, user_no) + logging.info( + "AUDIT delete_user key=%s client_id=%s user_no=%s", + keyrec.key_name, + client_id, + user_no, + ) with _store_lock: - cli = _require_client_for(keyrec.key_name, client_id) + cli = _require_client(client_id, keyrec) users = cli.get("users", {}) if str(user_no) not in users: raise HTTPException(status_code=404, detail="User not found") del users[str(user_no)] _flush_store() - write_client_xml(client_id, keyrec.key_name) + write_client_xml(client_id, keyrec) return