First commit
This commit is contained in:
commit
fb82b99e0e
|
|
@ -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": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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" }
|
||||||
|
]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue