First commit

This commit is contained in:
Anders Knutsen 2025-11-28 09:10:38 +01:00
commit fb82b99e0e
5 changed files with 1528 additions and 0 deletions

View File

@ -0,0 +1,499 @@
{
"info": {
"_postman_id": "d549670f-756d-49fa-925b-dd82e8e9cc0c",
"name": "Patriot API",
"description": "Common Status Codes\n\n- \\- 200 OK: Request successful (e.g.,UPSERT update, reads)\n \n- \\- 201 Created: Resource created (e.g., UPSERT create, add user, upsert zone)\n \n- \\- 204 No Content: Deleted successfully\n \n- \\- 401 Unauthorized: Missing/invalid/disabled/expired API key\n \n- \\- 404 Not Found: Client or resource not found\n \n- \\- 409 Conflict: Duplicate user number\n \n- \\- 422 Unprocessable Entity: Validation error (e.g., zone_id out of range)",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_exporter_id": "18764749"
},
"item": [
{
"name": "GET",
"item": [
{
"name": "Client info",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer {{apiKey}}",
"type": "text"
},
{
"key": "X-Client-Id",
"value": "{{clientId}}",
"type": "text"
}
],
"url": {
"raw": "{{baseUrl}}/clients",
"host": [
"{{baseUrl}}"
],
"path": [
"clients"
]
},
"description": "Gets all client information for selected client.\n\nClient ID must be set in the \"X-Client-Id\" header."
},
"response": []
},
{
"name": "Zones",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer {{apiKey}}",
"type": "text"
},
{
"key": "X-Client-Id",
"value": "{{clientId}}",
"type": "text"
}
],
"url": {
"raw": "{{baseUrl}}/clients/zones",
"host": [
"{{baseUrl}}"
],
"path": [
"clients",
"zones"
]
},
"description": "Gets zone info for selected zone\n\nClient ID must be set in the \"X-Client-Id\" header."
},
"response": []
},
{
"name": "All Users",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer {{apiKey}}",
"type": "text"
},
{
"key": "X-Client-Id",
"value": "{{clientId}}",
"type": "text"
}
],
"url": {
"raw": "{{baseUrl}}/clients/users",
"host": [
"{{baseUrl}}"
],
"path": [
"clients",
"users"
]
},
"description": "Gets all users for client\n\nClient ID must be set in the \"X-Client-Id\" header."
},
"response": []
},
{
"name": "Specific user",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer {{apiKey}}",
"type": "text"
},
{
"key": "X-Client-Id",
"value": "{{clientId}}",
"type": "text"
},
{
"key": "X-User-No",
"value": "1",
"type": "text"
}
],
"url": {
"raw": "{{baseUrl}}/clients/users",
"host": [
"{{baseUrl}}"
],
"path": [
"clients",
"users"
]
},
"description": "Get a specific user\n\nClient ID must be set in the \"X-Client-Id\" header.\n\nUser ID must be set in the \"X-User-Id\" header."
},
"response": []
}
],
"description": "Get stored information from API"
},
{
"name": "POST/PUT/PATCH",
"item": [
{
"name": "Create client",
"request": {
"method": "PUT",
"header": [
{
"key": "Authorization",
"value": "Bearer {{apiKey}}",
"type": "text"
}
],
"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}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseUrl}}/clients",
"host": [
"{{baseUrl}}"
],
"path": [
"clients"
]
},
"description": "Expected response: 201 Created on first call, 200 OK on updates."
},
"response": []
},
{
"name": "Update client",
"request": {
"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"
]
},
"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}"
},
"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",
"request": {
"method": "PUT",
"header": [
{
"key": "Authorization",
"value": "Bearer {{apiKey}}",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"client_id\": 1234,\n \"zone_id\": 1,\n \"Zone_area\": \"Entrance\",\n \"ModuleNo\": 0\n}\n",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseUrl}}/clients/zones",
"host": [
"{{baseUrl}}"
],
"path": [
"clients",
"zones"
]
},
"description": "Creates or updates a zone"
},
"response": []
},
{
"name": "Create users",
"request": {
"method": "POST",
"header": [
{
"key": "Authorization",
"value": "Bearer {{apiKey}}",
"type": "text"
}
],
"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}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseUrl}}/clients/users",
"host": [
"{{baseUrl}}"
],
"path": [
"clients",
"users"
]
},
"description": "Create users, all JSON data in example must be present."
},
"response": []
},
{
"name": "Update user",
"request": {
"method": "PUT",
"header": [
{
"key": "Authorization",
"value": "Bearer {{apiKey}}",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"client_id\": {{clientId}},\n \"user\": {\n \"User_Name\": \"Changed Name\",\n \"MobileNo\": \"+4798765432\",\n \"MobileNoOrder\": 1,\n \"Email\": \"new@email.com\",\n \"Type\": \"U\",\n \"UserNo\": 1,\n \"Instructions\": \"New instructions\",\n \"CallOrder\": 0\n }\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseUrl}}/clients/users",
"host": [
"{{baseUrl}}"
],
"path": [
"clients",
"users"
]
},
"description": "Update users, all JSON data in example must be present."
},
"response": []
}
]
},
{
"name": "DELETE",
"item": [
{
"name": "Zones",
"request": {
"method": "DELETE",
"header": [
{
"key": "Authorization",
"value": "Bearer {{apiKey}}",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"client_id\": {{clientId}},\n \"zone_id\": 1\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseUrl}}/clients/zones",
"host": [
"{{baseUrl}}"
],
"path": [
"clients",
"zones"
]
},
"description": "Select zone number to delete, use GET /clients/zones to list zone numbers and names."
},
"response": []
},
{
"name": "User",
"request": {
"method": "DELETE",
"header": [
{
"key": "Authorization",
"value": "Bearer {{apiKey}}",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"client_id\": {{clientId}},\n \"user_no\": 1\n}\n",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseUrl}}/clients/users",
"host": [
"{{baseUrl}}"
],
"path": [
"clients",
"users"
]
},
"description": "Deletes the selected user, use GET /clients/users to get correct user id."
},
"response": []
},
{
"name": "Client",
"request": {
"method": "DELETE",
"header": [
{
"key": "Authorization",
"value": "Bearer {{apiKey}}",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"client_id\": {{clientId}}\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseUrl}}/clients",
"host": [
"{{baseUrl}}"
],
"path": [
"clients"
]
},
"description": "Deletes entire client!\n\nWARNING! Data will be permanently deleted!"
},
"response": []
}
],
"description": "Deletes the records requested. WARNING! Data will be permanently deleted!"
}
],
"event": [
{
"listen": "prerequest",
"script": {
"type": "text/javascript",
"packages": {},
"requests": {},
"exec": [
""
]
}
},
{
"listen": "test",
"script": {
"type": "text/javascript",
"packages": {},
"requests": {},
"exec": [
""
]
}
}
],
"variable": [
{
"key": "baseUrl",
"value": ""
},
{
"key": "clientId",
"value": ""
},
{
"key": "apiKeyf",
"value": ""
},
{
"key": "apiKey",
"value": ""
}
]
}

262
README.md Normal file
View File

@ -0,0 +1,262 @@
# Patriot API
The **Patriot API** is a multi-tenant REST API used to manage clients, zones, and users and automatically generate XML files in the format required by Patriot.
Each API key has **its own fully isolated dataset**.
## 🔐 Authentication
All requests require:
```
Authorization: Bearer <API_KEY>
```
If the key is missing, invalid, disabled, or expired → **401 Unauthorized**
---
## 📘 Common Status Codes
| Status | Meaning |
|--------|---------|
| **200 OK** | Request successful (read or update) |
| **201 Created** | Resource successfully created |
| **204 No Content** | Resource deleted |
| **401 Unauthorized** | Invalid/missing API key |
| **404 Not Found** | Client, zone, or user not found |
| **409 Conflict** | Duplicate user number |
| **422 Unprocessable Entity** | Validation failed |
---
## 🧩 Required Headers
Some GET requests require client/user IDs in headers:
| Header | Purpose |
|--------|---------|
| **X-Client-Id** | Selects the client |
| **X-User-No** | Selects a specific user |
---
# 📂 API Endpoints
## =======================
## 🔹 CLIENTS
## =======================
## **GET /clients**
Get full client info.
Requires header:
```
X-Client-Id: <client_id>
```
---
## **PUT /clients**
Create or update a client (UPSERT).
Returns:
- `201 Created` if new
- `200 OK` if updated
Example JSON:
```
{
"client_id": 1234,
"info": {
"Name": "Customer Name",
"Location": "Address line 1",
"area_code": "1604",
"area": "City",
"AltLookup": true,
"AltAlarmNo": "SIA1000101",
"ConvertType": "None",
"NoSigsMon": "None",
"SinceDays": 0,
"SinceHrs": 0,
"SinceMins": 0,
"ResetNosigsIgnored": true,
"ResetNosigsDays": 0,
"ResetNosigsHrs": 0,
"ResetNosigsMins": 0,
"PanelName": "Panel Type",
"PanelSite": "Panel location"
}
}
```
---
## **PATCH /clients**
Partial update of a client.
Example updating only the location:
```
{
"client_id": 1234,
"info": {
"Location": "New Street 99"
}
}
```
---
## **DELETE /clients**
Delete entire client and its XML file.
```
{
"client_id": 1234
}
```
---
# =======================
# 🔹 ZONES
# =======================
## **GET /clients/zones**
List all zones for a client.
Requires:
```
X-Client-Id: <client_id>
```
---
## **PUT /clients/zones**
Create or update a zone.
Example:
```
{
"client_id": 1234,
"zone_id": 1,
"Zone_area": "Entrance",
"ModuleNo": 0
}
```
---
## **DELETE /clients/zones**
Delete a zone.
```
{
"client_id": 1234,
"zone_id": 1
}
```
---
# =======================
# 🔹 USERS
# =======================
## **GET /clients/users**
List all users for a client.
Requires:
```
X-Client-Id: <client_id>
```
---
## **GET /clients/users/single**
Get one user.
Headers required:
```
X-Client-Id: <client_id>
X-User-No: <user_no>
```
---
## **POST /clients/users**
Create a new user.
```
{
"client_id": 1234,
"user": {
"User_Name": "User Number 1",
"MobileNo": "+4712345678",
"MobileNoOrder": 1,
"Email": "user@email.com",
"Type": "U",
"UserNo": 1,
"Instructions": "Optional",
"CallOrder": 0
}
}
```
---
## **PUT /clients/users**
Update a user (full replace).
```
{
"client_id": 1234,
"user": {
"User_Name": "Changed Name",
"MobileNo": "+4798765432",
"MobileNoOrder": 1,
"Email": "new@email.com",
"Type": "U",
"UserNo": 1,
"Instructions": "Updated",
"CallOrder": 0
}
}
```
---
## **DELETE /clients/users**
Delete a user.
```
{
"client_id": 1234,
"user_no": 1
}
```
---
# 🚀 Notes
### ✔ Every API key has completely isolated data
Two different keys can both store client `1234`, but they are **stored separately**.
### ✔ XML files stored per key
Example output directory:
```
out/clients/KeyA/1234.xml
out/clients/KeyB/1234.xml
```
### ✔ Deleting a client only deletes it for that API key
---
# 📄 End of README

6
keys.json Normal file
View File

@ -0,0 +1,6 @@
[
{ "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" }
]

725
main.py Normal file
View File

@ -0,0 +1,725 @@
from __future__ import annotations
import json, os, threading, re, logging
from datetime import datetime, timezone
from typing import Dict, Optional
from time import time
from fastapi import FastAPI, HTTPException, Depends, Header, Request, Response, status
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")
# ------------ Auth (hot-reloaded key store) ------------
class KeyRecord(BaseModel):
key_name: str
key: str
enabled: bool = True
valid_to: str # ISO-8601
class KeyStore:
def __init__(self, path: str):
self.path = path
self._mtime = 0.0
self._keys: Dict[str, KeyRecord] = {}
self._lock = threading.Lock()
def _parse_time(self, s: str) -> datetime:
try:
if s.endswith("Z"):
s = s[:-1] + "+00:00"
dt = datetime.fromisoformat(s)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
except Exception:
return datetime.min.replace(tzinfo=timezone.utc)
def _load_if_changed(self):
try:
mtime = os.path.getmtime(self.path)
except FileNotFoundError:
mtime = 0.0
if mtime != self._mtime:
with self._lock:
try:
mtime2 = os.path.getmtime(self.path)
except FileNotFoundError:
self._keys = {}
self._mtime = 0.0
return
if mtime2 == self._mtime:
return
with open(self.path, "r", encoding="utf-8") as f:
raw = json.load(f)
new_map: Dict[str, KeyRecord] = {}
for item in raw:
rec = KeyRecord(**item)
new_map[rec.key] = rec
self._keys = new_map
self._mtime = mtime2
def validate_bearer(self, bearer: str) -> KeyRecord:
self._load_if_changed()
rec = self._keys.get(bearer)
if not rec:
raise HTTPException(status_code=401, detail="Invalid token")
if not rec.enabled:
raise HTTPException(status_code=401, detail="Token disabled")
now = datetime.now(timezone.utc)
valid_to = self._parse_time(rec.valid_to)
if now > valid_to:
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 ------------
class ClientInfo(BaseModel):
Name: str = Field(..., min_length=1, max_length=200)
Location: str = Field(..., min_length=1, max_length=200)
# split fields used by the API
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"
SinceDays: int = Field(0, ge=0)
SinceHrs: int = Field(0, ge=0)
SinceMins: int = Field(0, ge=0)
ResetNosigsIgnored: bool = True
ResetNosigsDays: int = Field(0, ge=0)
ResetNosigsHrs: int = Field(0, ge=0)
ResetNosigsMins: int = Field(0, ge=0)
PanelName: str = "Panel Type"
PanelSite: str = "Panel location"
class User(BaseModel):
User_Name: str = Field(..., min_length=1, max_length=120)
MobileNo: str = Field(..., min_length=1, max_length=40)
MobileNoOrder: int = Field(..., ge=1, le=999)
Email: EmailStr
Type: str = Field("U", min_length=1, max_length=2)
UserNo: int = Field(..., ge=1, le=999)
Instructions: str | None = Field(None, max_length=500)
CallOrder: int = Field(0, ge=0, le=999)
@field_validator("MobileNo")
def phone_has_digit(cls, v: str):
if not any(ch.isdigit() for ch in v):
raise ValueError("MobileNo must contain digits")
return v.strip()
class ClientCreate(BaseModel):
client_id: int = Field(..., ge=1)
info: ClientInfo
class ClientInfoPatch(BaseModel):
Name: 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: 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
SinceDays: Optional[int] = Field(None, ge=0)
SinceHrs: Optional[int] = Field(None, ge=0)
SinceMins: Optional[int] = Field(None, ge=0)
ResetNosigsIgnored: Optional[bool] = None
ResetNosigsDays: Optional[int] = Field(None, ge=0)
ResetNosigsHrs: Optional[int] = Field(None, ge=0)
ResetNosigsMins: Optional[int] = Field(None, ge=0)
PanelName: Optional[str] = Field(None, min_length=1, max_length=200)
PanelSite: Optional[str] = Field(None, min_length=1, max_length=200)
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_lock = threading.Lock()
# Multi-tenant: {"keys": { key_name: { "clients": { client_id_str: {...} } } } }
_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():
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": {}}
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)
# ------------ 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))
_ensure_dir(client_base)
return os.path.join(client_base, f"{client_id}.xml")
def write_client_xml(client_id: int, key_name: str):
from xml.etree.ElementTree import Element, SubElement, ElementTree
clients = _clients_for(key_name)
key = _client_key(client_id)
cli = clients.get(key)
if not cli:
return
info = ClientInfo(**cli.get("info", {}))
root = Element("Clients")
row = SubElement(root, "Row")
SubElement(row, "__id").text = str(client_id)
SubElement(row, "Name").text = info.Name
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, "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, "ResetNosigsDays").text = str(info.ResetNosigsDays)
SubElement(row, "ResetNosigsHrs").text = str(info.ResetNosigsHrs)
SubElement(row, "ResetNosigsMins").text = str(info.ResetNosigsMins)
SubElement(row, "PanelName").text = info.PanelName
SubElement(row, "PanelSite").text = info.PanelSite
# Zones
zones_el = SubElement(row, "Zones")
zones = cli.get("zones") or {}
for zid_str in sorted(zones.keys(), key=lambda x: int(x)):
z = zones[zid_str] or {}
z_el = SubElement(zones_el, "Zone")
SubElement(z_el, "Zone_area").text = z.get("Zone_area", "")
SubElement(z_el, "Zone_No").text = zid_str
SubElement(z_el, "ModuleNo").text = str(z.get("ModuleNo", 0))
# Users
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", "")
SubElement(u_el, "MobileNo").text = u.get("MobileNo", "")
SubElement(u_el, "MobileNoOrder").text = str(u.get("MobileNoOrder", 0))
SubElement(u_el, "Email").text = u.get("Email", "")
SubElement(u_el, "Type").text = u.get("Type", "U")
SubElement(u_el, "UserNo").text = str(u.get("UserNo", ""))
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)
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:
dom = minidom.parseString(rf.read())
pretty = dom.toprettyxml(indent=" ", encoding="utf-8")
with open(tmp_path, "wb") as wf:
wf.write(pretty)
os.replace(tmp_path, path)
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")
logging.basicConfig(
filename="api_requests.log",
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
)
REDACT_KEYS = {"authorization", "password", "secret", "key"}
MAX_BODY_CHARS = 4000
def _redact(obj):
try:
if isinstance(obj, dict):
return {k: ("***" if k.lower() in REDACT_KEYS else _redact(v)) for k, v in obj.items()}
if isinstance(obj, list):
return [_redact(v) for v in obj]
except Exception:
pass
return obj
@app.middleware("http")
async def log_requests(request: Request, call_next):
start = time()
method = request.method.upper()
body_text = ""
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:
parsed = json.loads(raw.decode("utf-8") if raw else "{}")
body_text = json.dumps(_redact(parsed))[:MAX_BODY_CHARS]
except Exception:
body_text = (raw.decode("utf-8", "replace") if raw else "")[:MAX_BODY_CHARS]
except Exception as e:
body_text = f"<<error reading body: {e}>>"
response = await call_next(request)
duration = time() - start
logging.info(
"%s %s - %s - %.3fs - body=%s",
method, request.url.path, response.status_code, duration, body_text
)
return response
# --------- Routes ---------
# UPSERT CLIENT (create if missing, update if exists)
@app.put("/clients", status_code=200)
def upsert_client(
payload: ClientCreate,
keyrec: KeyRecord = Depends(require_api_key),
response: Response = None,
):
client_id = payload.client_id
logging.info(
"AUDIT upsert_client key=%s client_id=%s payload=%s",
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)
with _store_lock:
clients = _clients_for(keyrec.key_name)
existed = key in clients
if existed:
cli = clients[key]
cli["info"] = info
else:
cli = {
"info": info,
"zones": {},
"users": {}
}
clients[key] = 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)
return {"client_id": client_id}
# PATCH CLIENT INFO (partial replace of info)
@app.patch("/clients", status_code=200)
def patch_client(
payload: ClientPatch,
keyrec: KeyRecord = Depends(require_api_key),
):
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()
)
with _store_lock:
cli = _require_client_for(keyrec.key_name, client_id)
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)
return {"client_id": client_id, **cli}
# GET CLIENT (header-based)
@app.get("/clients")
def get_client(
x_client_id: int = Header(..., alias="X-Client-Id"),
keyrec: KeyRecord = Depends(require_api_key),
):
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)
xml_path = _xml_path_for(client_id, keyrec.key_name)
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)
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)):
client_id = payload.client_id
logging.info("AUDIT get_client (POST) key=%s client_id=%s", keyrec.key_name, client_id)
cli = _require_client_for(keyrec.key_name, client_id)
xml_path = _xml_path_for(client_id, keyrec.key_name)
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)
return {"client_id": client_id, **cli}
# REGEN XML explicitly (for this key only)
@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)
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)
with _store_lock:
clients = _clients_for(keyrec.key_name)
if key not in clients:
raise HTTPException(status_code=404, detail="Client not found")
del clients[key]
_flush_store()
# Remove XML only for *this* key
try:
xml_path = _xml_path_for(client_id, keyrec.key_name)
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)
return
# ---- Zones ----
@app.get("/clients/zones")
def list_zones(
x_client_id: int = Header(..., alias="X-Client-Id"),
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)
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()
]
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)):
client_id = payload.client_id
logging.info("AUDIT list_zones (POST) key=%s client_id=%s", keyrec.key_name, client_id)
cli = _require_client_for(keyrec.key_name, client_id)
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()
]
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()
)
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.setdefault("zones", {})
cli["zones"][str(zone_id)] = {
"Zone_area": payload.Zone_area,
"ModuleNo": payload.ModuleNo
}
_flush_store()
write_client_xml(client_id, keyrec.key_name)
return {
"client_id": client_id,
"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)
with _store_lock:
cli = _require_client_for(keyrec.key_name, client_id)
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)
return
# ---- Users ----
@app.get("/clients/users")
def list_users(
x_client_id: int = Header(..., alias="X-Client-Id"),
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)
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)
):
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)
cli = _require_client_for(keyrec.key_name, client_id)
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.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)
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()
)
with _store_lock:
cli = _require_client_for(keyrec.key_name, client_id)
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)
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)):
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)
cli = _require_client_for(keyrec.key_name, client_id)
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
user = payload.user
user_no = user.UserNo
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()
)
with _store_lock:
cli = _require_client_for(keyrec.key_name, client_id)
cli.setdefault("users", {})
cli["users"][str(user_no)] = user.model_dump()
_flush_store()
write_client_xml(client_id, keyrec.key_name)
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)
with _store_lock:
cli = _require_client_for(keyrec.key_name, client_id)
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)
return

36
run_server.py Normal file
View File

@ -0,0 +1,36 @@
# run_server.py
import os, sys
from pathlib import Path
print("[launcher] starting…") # debug: proves the file is actually running
def base_dir():
if getattr(sys, "frozen", False):
return Path(sys.executable).parent
return Path(__file__).parent
bd = base_dir()
# Ensure paths exist and are visible to main.py
os.environ.setdefault("KEY_FILE", str(bd / "keys.json"))
os.environ.setdefault("DATA_FILE", str(bd / "data.json"))
os.environ.setdefault("XML_DIR", str(bd / "out" / "clients"))
os.makedirs(os.environ["XML_DIR"], exist_ok=True)
try:
from main import app # must succeed
print("[launcher] imported main.app OK")
except Exception as e:
print(f"[launcher] FAILED to import main.app: {e}")
sys.exit(1)
try:
import uvicorn
except Exception as e:
print(f"[launcher] FAILED to import uvicorn (install it in this venv): {e}")
sys.exit(1)
if __name__ == "__main__":
print("[launcher] running uvicorn on 0.0.0.0:7071")
uvicorn.run(app, host="0.0.0.0", port=8081, log_level="info")
print("[launcher] uvicorn.run returned (server stopped)") # should only print on shutdown