This commit is contained in:
Anders Knutsen 2025-11-28 13:37:25 +01:00
parent fb82b99e0e
commit 9f67d87e9d
3 changed files with 401 additions and 216 deletions

View File

@ -153,7 +153,7 @@
], ],
"body": { "body": {
"mode": "raw", "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": { "options": {
"raw": { "raw": {
"language": "json" "language": "json"
@ -186,7 +186,7 @@
], ],
"body": { "body": {
"mode": "raw", "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": { "options": {
"raw": { "raw": {
"language": "json" "language": "json"
@ -202,45 +202,9 @@
"clients" "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": [ "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": ""
}
]
}, },
{ {
"name": "Create/Update zone", "name": "Create/Update zone",
@ -255,7 +219,7 @@
], ],
"body": { "body": {
"mode": "raw", "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": { "options": {
"raw": { "raw": {
"language": "json" "language": "json"
@ -289,7 +253,7 @@
], ],
"body": { "body": {
"mode": "raw", "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": { "options": {
"raw": { "raw": {
"language": "json" "language": "json"
@ -396,7 +360,7 @@
], ],
"body": { "body": {
"mode": "raw", "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": { "options": {
"raw": { "raw": {
"language": "json" "language": "json"

View File

@ -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": "anders",
{ "key_name": "novus", "key": "_6uOesirJ6QfdACcm2uQIddsstTvXRIP8YjhcKwMuyp9W7mzV2jI1mDisVip1hay5", "enabled": true, "valid_to": "2099-12-31T23:59:59Z" }, "key": "_2sW6roe2ZQ4V6Cldo0v295fakHT8vBHqHScfliX445tZuzxDwMRqjPeCE7FDcVVr",
{ "key_name": "futurehome", "key": "_8942WfJxRPA9SiblpR4pD8slkK1jCWlmEJ7XCDmzpJDiG4KlBIbyl5syXxS3RHya", "enabled": true, "valid_to": "2099-01-01T00:00:00Z" } "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
}
]
}
] ]

487
main.py
View File

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json, os, threading, re, logging import json, os, threading, re, logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Dict, Optional from typing import Dict, Optional, List
from time import time from time import time
from fastapi import FastAPI, HTTPException, Depends, Header, Request, Response, status 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._-]") SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]")
def _safe_folder(name: str) -> str: def _safe_folder(name: str) -> str:
cleaned = SAFE_NAME.sub("_", name.strip()) cleaned = SAFE_NAME.sub("_", name.strip())
return cleaned or "unknown" return cleaned or "unknown"
# ------------ Config ------------ # ------------ Config ------------
DATA_FILE = os.getenv("DATA_FILE", "data.json") DATA_FILE = os.getenv("DATA_FILE", "data.json")
KEY_FILE = os.getenv("KEY_FILE", "keys.json") KEY_FILE = os.getenv("KEY_FILE", "keys.json")
XML_DIR = os.getenv("XML_DIR", "out/clients") 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) ------------ # ------------ Auth (hot-reloaded key store) ------------
class KeyRecord(BaseModel): class KeyRecord(BaseModel):
key_name: str key_name: str
key: str key: str
enabled: bool = True enabled: bool = True
valid_to: str # ISO-8601 valid_to: str # ISO-8601
# Used for __id in XML: <__id>{client_id}{port}</__id>
port: str = ""
# Per-key forced XML fields
installer_name: str = "" # <Installer>, user 199 name, grouping #2
installer_email: str = "" # user 199 email
use_glob_callouts: bool = True # <UseGlobCallOuts>
show_on_callouts: bool = False # <ShowOnCallOuts>
glob_callouts: str = "" # <GlobCallOuts>
use_glob_callouts2: bool = True # <UseGlobCallOuts2>
show_on_callouts2: bool = False # <ShowOnCallOuts2>
glob_callouts2: str = "" # <GlobCallOuts2>
alt_lookup: bool = True # <AltLookup>
alt_alarm_no: str = "CID4BASE01" # <AltAlarmNo>
convert_type: str = "None" # <ConvertType>
siginterpret: str = "SIADecimal" # <SIGINTERPRET>
# NEW: per-key client groupings (rendered to <ClientGroupings>)
client_groupings: List[ClientGroupingConfig] = Field(default_factory=list)
class KeyStore: class KeyStore:
def __init__(self, path: str): def __init__(self, path: str):
self.path = path self.path = path
@ -80,39 +117,55 @@ class KeyStore:
raise HTTPException(status_code=401, detail="Token expired") raise HTTPException(status_code=401, detail="Token expired")
return rec return rec
_key_store = KeyStore(KEY_FILE) _key_store = KeyStore(KEY_FILE)
def require_api_key(authorization: Optional[str] = Header(None)) -> KeyRecord: def require_api_key(authorization: Optional[str] = Header(None)) -> KeyRecord:
if not authorization or not authorization.startswith("Bearer "): if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing bearer token") raise HTTPException(status_code=401, detail="Missing bearer token")
token = authorization.split(" ", 1)[1].strip() token = authorization.split(" ", 1)[1].strip()
return _key_store.validate_bearer(token) return _key_store.validate_bearer(token)
# ------------ Models ------------
# ------------ Models (client payloads) ------------
class ClientInfo(BaseModel): class ClientInfo(BaseModel):
# From API / client section
Name: str = Field(..., min_length=1, max_length=200) 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) 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_code: str = Field(..., min_length=1, max_length=20)
area: str = Field(..., min_length=1, max_length=200) area: str = Field(..., min_length=1, max_length=200)
AltLookup: bool = True BusPhone: Optional[str] = Field(None, max_length=50)
AltAlarmNo: str | None = None Email: Optional[EmailStr] = None
ConvertType: str = "None" OKPassword: Optional[str] = Field(None, max_length=100)
NoSigsMon: str = "None" 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) SinceHrs: int = Field(0, ge=0)
SinceMins: int = Field(0, ge=0) SinceMins: int = Field(30, ge=0)
ResetNosigsIgnored: bool = True ResetNosigsIgnored: bool = True
ResetNosigsDays: int = Field(0, ge=0) ResetNosigsDays: int = Field(7, ge=0)
ResetNosigsHrs: int = Field(0, ge=0) ResetNosigsHrs: int = Field(0, ge=0)
ResetNosigsMins: 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" PanelName: str = "Panel Type"
PanelSite: str = "Panel location" PanelSite: str = "Panel location"
KeypadLocation: Optional[str] = Field(None, max_length=200)
SPPage: Optional[str] = Field(None, max_length=2000)
class User(BaseModel): class User(BaseModel):
User_Name: str = Field(..., min_length=1, max_length=120) User_Name: str = Field(..., min_length=1, max_length=120)
@ -130,22 +183,27 @@ class User(BaseModel):
raise ValueError("MobileNo must contain digits") raise ValueError("MobileNo must contain digits")
return v.strip() return v.strip()
class ClientCreate(BaseModel): class ClientCreate(BaseModel):
client_id: int = Field(..., ge=1) client_id: int = Field(..., ge=1)
info: ClientInfo info: ClientInfo
class ClientInfoPatch(BaseModel): class ClientInfoPatch(BaseModel):
# all optional, for PATCH
Name: Optional[str] = Field(None, min_length=1, max_length=200) 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) Location: Optional[str] = Field(None, min_length=1, max_length=200)
area_code: Optional[str] = Field(None, min_length=1, max_length=20) area_code: Optional[str] = Field(None, min_length=1, max_length=20)
area: Optional[str] = Field(None, min_length=1, max_length=200) area: Optional[str] = Field(None, min_length=1, max_length=200)
AltLookup: Optional[bool] = None BusPhone: Optional[str] = Field(None, max_length=50)
AltAlarmNo: Optional[str] = None Email: Optional[EmailStr] = None
ConvertType: Optional[str] = None OKPassword: Optional[str] = Field(None, max_length=100)
NoSigsMon: Optional[str] = None SpecRequest: Optional[str] = Field(None, max_length=1000)
NoSigsMon: Optional[str] = None
SinceDays: Optional[int] = Field(None, ge=0) SinceDays: Optional[int] = Field(None, ge=0)
SinceHrs: Optional[int] = Field(None, ge=0) SinceHrs: Optional[int] = Field(None, ge=0)
SinceMins: 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) ResetNosigsHrs: Optional[int] = Field(None, ge=0)
ResetNosigsMins: 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) PanelName: Optional[str] = Field(None, min_length=1, max_length=200)
PanelSite: 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): class ClientPatch(BaseModel):
client_id: int = Field(..., ge=1) client_id: int = Field(..., ge=1)
info: Optional[ClientInfoPatch] = None info: Optional[ClientInfoPatch] = None
class ClientID(BaseModel): class ClientID(BaseModel):
client_id: int = Field(..., ge=1) client_id: int = Field(..., ge=1)
class ZoneUpdate(BaseModel): class ZoneUpdate(BaseModel):
client_id: int = Field(..., ge=1) client_id: int = Field(..., ge=1)
zone_id: int = Field(..., ge=1, le=999) zone_id: int = Field(..., ge=1, le=999)
Zone_area: str = Field(..., min_length=1, max_length=200) Zone_area: str = Field(..., min_length=1, max_length=200)
ModuleNo: int = Field(0, ge=0) ModuleNo: int = Field(0, ge=0)
class ZoneDelete(BaseModel): class ZoneDelete(BaseModel):
client_id: int = Field(..., ge=1) client_id: int = Field(..., ge=1)
zone_id: int = Field(..., ge=1, le=999) zone_id: int = Field(..., ge=1, le=999)
class UserWithClient(BaseModel): class UserWithClient(BaseModel):
client_id: int = Field(..., ge=1) client_id: int = Field(..., ge=1)
user: User user: User
class UserKey(BaseModel): class UserKey(BaseModel):
client_id: int = Field(..., ge=1) client_id: int = Field(..., ge=1)
user_no: int = Field(..., ge=1, le=999) user_no: int = Field(..., ge=1, le=999)
# ------------ In-memory store + persistence ------------ # ------------ In-memory store + persistence ------------
# Store is namespaced per API key: "key_name:client_id"
_store_lock = threading.Lock() _store_lock = threading.Lock()
# Multi-tenant: {"keys": { key_name: { "clients": { client_id_str: {...} } } } } _store: Dict = {"clients": {}}
_store: Dict = {"keys": {}}
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(): def _load_store():
global _store global _store
if os.path.exists(DATA_FILE): if os.path.exists(DATA_FILE):
with open(DATA_FILE, "r", encoding="utf-8") as f: with open(DATA_FILE, "r", encoding="utf-8") as f:
data = json.load(f) _store = 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": {}}
else: else:
_store = {"keys": {}}
_flush_store() _flush_store()
def _flush_store(): def _flush_store():
tmp = DATA_FILE + ".tmp" tmp = DATA_FILE + ".tmp"
with open(tmp, "w", encoding="utf-8") as f: with open(tmp, "w", encoding="utf-8") as f:
json.dump(_store, f, ensure_ascii=False, indent=2) json.dump(_store, f, ensure_ascii=False, indent=2)
os.replace(tmp, DATA_FILE) 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 ------------ # ------------ XML writer ------------
def _ensure_dir(path: str): def _ensure_dir(path: str):
os.makedirs(path, exist_ok=True) 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) _ensure_dir(client_base)
return os.path.join(client_base, f"{client_id}.xml") 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 from xml.etree.ElementTree import Element, SubElement, ElementTree
clients = _clients_for(key_name)
key = _client_key(client_id) skey = _store_key(client_id, keyrec)
cli = clients.get(key) cli = _store["clients"].get(skey)
if not cli: if not cli:
return return
@ -255,28 +317,71 @@ def write_client_xml(client_id: int, key_name: str):
root = Element("Clients") root = Element("Clients")
row = SubElement(root, "Row") 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, "Name").text = info.Name
SubElement(row, "Alias").text = info.Alias or ""
SubElement(row, "Location").text = info.Location SubElement(row, "Location").text = info.Location
SubElement(row, "loc2").text = f"{info.area_code} {info.area}".strip() SubElement(row, "loc2").text = f"{info.area_code} {info.area}".strip()
SubElement(row, "AltLookup").text = "True" if info.AltLookup else "False" SubElement(row, "BusPhone").text = info.BusPhone or ""
SubElement(row, "AltAlarmNo").text = info.AltAlarmNo or "" SubElement(row, "Email").text = info.Email or ""
SubElement(row, "ConvertType").text = info.ConvertType SubElement(row, "OKPassword").text = info.OKPassword or ""
SubElement(row, "NoSigsMon").text = info.NoSigsMon SubElement(row, "SpecRequest").text = info.SpecRequest or ""
SubElement(row, "NoSigsMon").text = info.NoSigsMon
SubElement(row, "SinceDays").text = str(info.SinceDays) SubElement(row, "SinceDays").text = str(info.SinceDays)
SubElement(row, "SinceHrs").text = str(info.SinceHrs) SubElement(row, "SinceHrs").text = str(info.SinceHrs)
SubElement(row, "SinceMins").text = str(info.SinceMins) 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, "ResetNosigsDays").text = str(info.ResetNosigsDays)
SubElement(row, "ResetNosigsHrs").text = str(info.ResetNosigsHrs) SubElement(row, "ResetNosigsHrs").text = str(info.ResetNosigsHrs)
SubElement(row, "ResetNosigsMins").text = str(info.ResetNosigsMins) SubElement(row, "ResetNosigsMins").text = str(info.ResetNosigsMins)
SubElement(row, "InstallDateTime").text = info.InstallDateTime or ""
SubElement(row, "PanelName").text = info.PanelName SubElement(row, "PanelName").text = info.PanelName
SubElement(row, "PanelSite").text = info.PanelSite 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
zones_el = SubElement(row, "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, "Zone_No").text = zid_str
SubElement(z_el, "ModuleNo").text = str(z.get("ModuleNo", 0)) SubElement(z_el, "ModuleNo").text = str(z.get("ModuleNo", 0))
# Users # Users (from API)
users_el = SubElement(row, "Users") users_el = SubElement(row, "Users")
users = cli.get("users") or {} users = cli.get("users") or {}
def _sort_user(u: dict): def _sort_user(u: dict):
return (u.get("CallOrder", 0), u.get("MobileNoOrder", 0), u.get("UserNo", 0)) 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])): for _, u in sorted(users.items(), key=lambda kv: _sort_user(kv[1])):
u_el = SubElement(users_el, "User") u_el = SubElement(users_el, "User")
SubElement(u_el, "User_Name").text = u.get("User_Name", "") 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, "Instructions").text = u.get("Instructions", "") or ""
SubElement(u_el, "CallOrder").text = str(u.get("CallOrder", 0)) 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: try:
import xml.dom.minidom as minidom import xml.dom.minidom as minidom
tmp_path = path + ".tmp" tmp_path = path + ".tmp"
ElementTree(root).write(tmp_path, encoding="utf-8", xml_declaration=True) ElementTree(root).write(tmp_path, encoding="utf-8", xml_declaration=True)
with open(tmp_path, "rb") as rf: with open(tmp_path, "rb") as rf:
@ -318,9 +434,10 @@ def write_client_xml(client_id: int, key_name: str):
except Exception: except Exception:
ElementTree(root).write(path, encoding="utf-8", xml_declaration=True) ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
# ------------ App & logging middleware ------------ # ------------ App & logging middleware ------------
_load_store() _load_store()
app = FastAPI(title="Client Registry API", version="3.1.0") app = FastAPI(title="Client Registry API", version="3.3.0")
logging.basicConfig( logging.basicConfig(
filename="api_requests.log", filename="api_requests.log",
@ -331,6 +448,7 @@ logging.basicConfig(
REDACT_KEYS = {"authorization", "password", "secret", "key"} REDACT_KEYS = {"authorization", "password", "secret", "key"}
MAX_BODY_CHARS = 4000 MAX_BODY_CHARS = 4000
def _redact(obj): def _redact(obj):
try: try:
if isinstance(obj, dict): if isinstance(obj, dict):
@ -341,6 +459,7 @@ def _redact(obj):
pass pass
return obj return obj
@app.middleware("http") @app.middleware("http")
async def log_requests(request: Request, call_next): async def log_requests(request: Request, call_next):
start = time() start = time()
@ -350,8 +469,10 @@ async def log_requests(request: Request, call_next):
if method in {"POST", "PUT", "PATCH", "DELETE"}: if method in {"POST", "PUT", "PATCH", "DELETE"}:
try: try:
raw = await request.body() raw = await request.body()
async def receive(): async def receive():
return {"type": "http.request", "body": raw, "more_body": False} return {"type": "http.request", "body": raw, "more_body": False}
request._receive = receive request._receive = receive
try: try:
@ -367,11 +488,16 @@ async def log_requests(request: Request, call_next):
logging.info( logging.info(
"%s %s - %s - %.3fs - body=%s", "%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 return response
# --------- Routes ---------
# --------- Routes (unchanged logic, but keyrec now has client_groupings) ---------
# UPSERT CLIENT (create if missing, update if exists) # UPSERT CLIENT (create if missing, update if exists)
@app.put("/clients", status_code=200) @app.put("/clients", status_code=200)
@ -384,45 +510,33 @@ def upsert_client(
logging.info( logging.info(
"AUDIT upsert_client key=%s client_id=%s payload=%s", "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() info = payload.info.model_dump()
skey = _store_key(client_id, keyrec)
# 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)
with _store_lock: with _store_lock:
clients = _clients_for(keyrec.key_name) existed = skey in _store["clients"]
existed = key in clients
if existed: if existed:
cli = clients[key] cli = _store["clients"][skey]
cli["info"] = info cli["info"] = info
else: else:
cli = { cli = {"info": info, "zones": {}, "users": {}}
"info": info, _store["clients"][skey] = cli
"zones": {},
"users": {}
}
clients[key] = cli
if response is not None: if response is not None:
response.status_code = status.HTTP_200_OK if existed else status.HTTP_201_CREATED response.status_code = status.HTTP_200_OK if existed else status.HTTP_201_CREATED
_flush_store() _flush_store()
write_client_xml(client_id, keyrec.key_name) write_client_xml(client_id, keyrec)
return {"client_id": client_id} return {"client_id": client_id}
# PATCH CLIENT INFO (partial replace of info) # PATCH CLIENT INFO (partial replace of info)
@app.patch("/clients", status_code=200) @app.patch("/clients", status_code=200)
def patch_client( def patch_client(
@ -432,41 +546,30 @@ def patch_client(
client_id = payload.client_id client_id = payload.client_id
logging.info( logging.info(
"AUDIT patch_client key=%s client_id=%s payload=%s", "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: with _store_lock:
cli = _require_client_for(keyrec.key_name, client_id) cli = _require_client(client_id, keyrec)
if payload.info is not None: if payload.info is not None:
# Current stored info (must be valid ClientInfo shape)
current_info = cli.get("info", {}) current_info = cli.get("info", {})
base = ClientInfo(**current_info).model_dump() base = ClientInfo(**current_info).model_dump()
# Only the fields actually sent in the request
updates = payload.info.model_dump(exclude_unset=True) updates = payload.info.model_dump(exclude_unset=True)
# Merge: updates override base
merged = {**base, **updates} 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() merged_valid = ClientInfo(**merged).model_dump()
cli["info"] = merged_valid cli["info"] = merged_valid
_flush_store() _flush_store()
write_client_xml(client_id, keyrec.key_name) write_client_xml(client_id, keyrec)
return {"client_id": client_id, **cli} 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") @app.get("/clients")
def get_client( def get_client(
x_client_id: int = Header(..., alias="X-Client-Id"), x_client_id: int = Header(..., alias="X-Client-Id"),
@ -475,72 +578,86 @@ def get_client(
client_id = x_client_id client_id = x_client_id
logging.info("AUDIT get_client 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_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): if not os.path.exists(xml_path):
logging.info("XML missing for client_id=%s (key=%s). Regenerating...", client_id, keyrec.key_name) logging.info(
write_client_xml(client_id, keyrec.key_name) "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} return {"client_id": client_id, **cli}
@app.post("/clients/get", status_code=200) @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 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)
xml_path = _xml_path_for(client_id, keyrec.key_name)
if not os.path.exists(xml_path): if not os.path.exists(xml_path):
logging.info("XML missing for client_id=%s (key=%s). Regenerating...", client_id, keyrec.key_name) logging.info(
write_client_xml(client_id, keyrec.key_name) "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} return {"client_id": client_id, **cli}
# REGEN XML explicitly (for this key only)
# REGEN XML explicitly
@app.post("/clients/regen-xml", status_code=200) @app.post("/clients/regen-xml", status_code=200)
def regen_xml(payload: ClientID, keyrec: KeyRecord = Depends(require_api_key)): def regen_xml(payload: ClientID, keyrec: KeyRecord = Depends(require_api_key)):
client_id = payload.client_id client_id = payload.client_id
logging.info("AUDIT regen_xml key=%s client_id=%s", keyrec.key_name, 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) _require_client(client_id, keyrec)
write_client_xml(client_id, keyrec.key_name) write_client_xml(client_id, keyrec)
return {"client_id": client_id, "xml": "regenerated"} return {"client_id": client_id, "xml": "regenerated"}
# DELETE ENTIRE CLIENT (for this key only) # DELETE ENTIRE CLIENT (for this key only)
@app.delete("/clients", status_code=204) @app.delete("/clients", status_code=204)
def delete_client(payload: ClientID, keyrec: KeyRecord = Depends(require_api_key)): def delete_client(payload: ClientID, keyrec: KeyRecord = Depends(require_api_key)):
client_id = payload.client_id client_id = payload.client_id
logging.info("AUDIT delete_client key=%s client_id=%s", keyrec.key_name, 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: with _store_lock:
clients = _clients_for(keyrec.key_name) if skey not in _store["clients"]:
if key not in clients:
raise HTTPException(status_code=404, detail="Client not found") raise HTTPException(status_code=404, detail="Client not found")
del clients[key] del _store["clients"][skey]
_flush_store() _flush_store()
# Remove XML only for *this* key
try: 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): if os.path.exists(xml_path):
os.remove(xml_path) os.remove(xml_path)
except Exception as e: 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 return
# ---- Zones ---- # ---- Zones ----
@app.get("/clients/zones") @app.get("/clients/zones")
def list_zones( def list_zones(
x_client_id: int = Header(..., alias="X-Client-Id"), 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 client_id = x_client_id
logging.info("AUDIT list_zones 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_obj = cli.get("zones", {})
zones = [ zones = [
@ -550,14 +667,14 @@ def list_zones(
zones.sort(key=lambda z: z["Zone_No"]) zones.sort(key=lambda z: z["Zone_No"])
return {"client_id": client_id, "zones": zones} return {"client_id": client_id, "zones": zones}
@app.post("/clients/zones/list", status_code=200) @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 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_obj = cli.get("zones", {})
zones = [ zones = [
{"Zone_No": int(zid), "Zone_area": data.get("Zone_area"), "ModuleNo": data.get("ModuleNo", 0)} {"Zone_No": int(zid), "Zone_area": data.get("Zone_area"), "ModuleNo": data.get("ModuleNo", 0)}
for zid, data in zones_obj.items() 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"]) zones.sort(key=lambda z: z["Zone_No"])
return {"client_id": client_id, "zones": zones} return {"client_id": client_id, "zones": zones}
@app.put("/clients/zones", status_code=201) @app.put("/clients/zones", status_code=201)
def upsert_zone(payload: ZoneUpdate, keyrec: KeyRecord = Depends(require_api_key)): def upsert_zone(payload: ZoneUpdate, keyrec: KeyRecord = Depends(require_api_key)):
client_id = payload.client_id client_id = payload.client_id
zone_id = payload.zone_id zone_id = payload.zone_id
logging.info( logging.info(
"AUDIT upsert_zone key=%s client_id=%s zone_id=%s body=%s", "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): if not (1 <= zone_id <= 999):
raise HTTPException(status_code=422, detail="zone_id must be 1..999") raise HTTPException(status_code=422, detail="zone_id must be 1..999")
with _store_lock: with _store_lock:
cli = _require_client_for(keyrec.key_name, client_id) cli = _require_client(client_id, keyrec)
cli.setdefault("zones", {}) cli.setdefault("zones", {})
cli["zones"][str(zone_id)] = { cli["zones"][str(zone_id)] = {
"Zone_area": payload.Zone_area, "Zone_area": payload.Zone_area,
"ModuleNo": payload.ModuleNo "ModuleNo": payload.ModuleNo,
} }
_flush_store() _flush_store()
write_client_xml(client_id, keyrec.key_name) write_client_xml(client_id, keyrec)
return { return {
"client_id": client_id, "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) @app.delete("/clients/zones", status_code=204)
def delete_zone(payload: ZoneDelete, keyrec: KeyRecord = Depends(require_api_key)): def delete_zone(payload: ZoneDelete, keyrec: KeyRecord = Depends(require_api_key)):
client_id = payload.client_id client_id = payload.client_id
zone_id = payload.zone_id zone_id = payload.zone_id
logging.info("AUDIT delete_zone key=%s client_id=%s zone_id=%s", logging.info(
keyrec.key_name, client_id, zone_id) "AUDIT delete_zone key=%s client_id=%s zone_id=%s",
keyrec.key_name,
client_id,
zone_id,
)
with _store_lock: with _store_lock:
cli = _require_client_for(keyrec.key_name, client_id) cli = _require_client(client_id, keyrec)
zones = cli.get("zones", {}) zones = cli.get("zones", {})
if str(zone_id) not in zones: if str(zone_id) not in zones:
raise HTTPException(status_code=404, detail="Zone not found") raise HTTPException(status_code=404, detail="Zone not found")
del zones[str(zone_id)] del zones[str(zone_id)]
_flush_store() _flush_store()
write_client_xml(client_id, keyrec.key_name) write_client_xml(client_id, keyrec)
return return
# ---- Users ---- # ---- Users ----
@app.get("/clients/users") @app.get("/clients/users")
def list_users( def list_users(
x_client_id: int = Header(..., alias="X-Client-Id"), 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 client_id = x_client_id
logging.info("AUDIT list_users key=%s client_id=%s", keyrec.key_name, 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 = list(cli.get("users", {}).values())
users.sort(key=lambda u: (u.get("CallOrder", 0), u.get("MobileNoOrder", 0), u.get("UserNo", 0))) users.sort(key=lambda u: (u.get("CallOrder", 0), u.get("MobileNoOrder", 0), u.get("UserNo", 0)))
return {"client_id": client_id, "users": users} return {"client_id": client_id, "users": users}
@app.get("/clients/users/single") @app.get("/clients/users/single")
def get_user_header( def get_user_header(
x_client_id: int = Header(..., alias="X-Client-Id"), x_client_id: int = Header(..., alias="X-Client-Id"),
x_user_no: int = Header(..., alias="X-User-No"), 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 client_id = x_client_id
user_no = x_user_no user_no = x_user_no
logging.info("AUDIT get_user (header) key=%s client_id=%s user_no=%s", logging.info(
keyrec.key_name, client_id, user_no) "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)) u = cli.get("users", {}).get(str(user_no))
if not u: if not u:
@ -644,49 +776,60 @@ def get_user_header(
return {"client_id": client_id, "user": u} 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 = list(cli.get("users", {}).values())
users.sort(key=lambda u: (u.get("CallOrder", 0), u.get("MobileNoOrder", 0), u.get("UserNo", 0))) users.sort(key=lambda u: (u.get("CallOrder", 0), u.get("MobileNoOrder", 0), u.get("UserNo", 0)))
return {"client_id": client_id, "users": users} return {"client_id": client_id, "users": users}
@app.post("/clients/users", status_code=201) @app.post("/clients/users", status_code=201)
def add_user(payload: UserWithClient, keyrec: KeyRecord = Depends(require_api_key)): def add_user(payload: UserWithClient, keyrec: KeyRecord = Depends(require_api_key)):
client_id = payload.client_id client_id = payload.client_id
user = payload.user user = payload.user
logging.info( logging.info(
"AUDIT add_user key=%s client_id=%s UserNo=%s user=%s", "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: with _store_lock:
cli = _require_client_for(keyrec.key_name, client_id) cli = _require_client(client_id, keyrec)
cli.setdefault("users", {}) cli.setdefault("users", {})
if str(user.UserNo) in cli["users"]: if str(user.UserNo) in cli["users"]:
raise HTTPException(status_code=409, detail="UserNo already exists") raise HTTPException(status_code=409, detail="UserNo already exists")
cli["users"][str(user.UserNo)] = user.model_dump() cli["users"][str(user.UserNo)] = user.model_dump()
_flush_store() _flush_store()
write_client_xml(client_id, keyrec.key_name) write_client_xml(client_id, keyrec)
return {"client_id": client_id, "user": user} return {"client_id": client_id, "user": user}
@app.post("/clients/users/get", status_code=200) @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 client_id = payload.client_id
user_no = payload.user_no user_no = payload.user_no
logging.info("AUDIT get_user (POST) key=%s client_id=%s user_no=%s", logging.info(
keyrec.key_name, client_id, user_no) "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)) u = cli.get("users", {}).get(str(user_no))
if not u: if not u:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
return {"client_id": client_id, "user": u} return {"client_id": client_id, "user": u}
@app.put("/clients/users", status_code=200) @app.put("/clients/users", status_code=200)
def replace_user(payload: UserWithClient, keyrec: KeyRecord = Depends(require_api_key)): def replace_user(payload: UserWithClient, keyrec: KeyRecord = Depends(require_api_key)):
client_id = payload.client_id client_id = payload.client_id
@ -695,31 +838,39 @@ def replace_user(payload: UserWithClient, keyrec: KeyRecord = Depends(require_ap
logging.info( logging.info(
"AUDIT replace_user key=%s client_id=%s user_no=%s user=%s", "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: with _store_lock:
cli = _require_client_for(keyrec.key_name, client_id) cli = _require_client(client_id, keyrec)
cli.setdefault("users", {}) cli.setdefault("users", {})
cli["users"][str(user_no)] = user.model_dump() cli["users"][str(user_no)] = user.model_dump()
_flush_store() _flush_store()
write_client_xml(client_id, keyrec.key_name) write_client_xml(client_id, keyrec)
return {"client_id": client_id, "user": user} return {"client_id": client_id, "user": user}
@app.delete("/clients/users", status_code=204) @app.delete("/clients/users", status_code=204)
def delete_user(payload: UserKey, keyrec: KeyRecord = Depends(require_api_key)): def delete_user(payload: UserKey, keyrec: KeyRecord = Depends(require_api_key)):
client_id = payload.client_id client_id = payload.client_id
user_no = payload.user_no user_no = payload.user_no
logging.info("AUDIT delete_user key=%s client_id=%s user_no=%s", logging.info(
keyrec.key_name, client_id, user_no) "AUDIT delete_user key=%s client_id=%s user_no=%s",
keyrec.key_name,
client_id,
user_no,
)
with _store_lock: with _store_lock:
cli = _require_client_for(keyrec.key_name, client_id) cli = _require_client(client_id, keyrec)
users = cli.get("users", {}) users = cli.get("users", {})
if str(user_no) not in users: if str(user_no) not in users:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
del users[str(user_no)] del users[str(user_no)]
_flush_store() _flush_store()
write_client_xml(client_id, keyrec.key_name) write_client_xml(client_id, keyrec)
return return