v.0.9 Beta
This commit is contained in:
parent
9f67d87e9d
commit
c9a3292bdd
|
|
@ -1,10 +1,11 @@
|
|||
{
|
||||
"info": {
|
||||
"_postman_id": "d549670f-756d-49fa-925b-dd82e8e9cc0c",
|
||||
"_postman_id": "f7926ba4-6fae-4e5e-973b-402456b66ceb",
|
||||
"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"
|
||||
"_exporter_id": "50473838",
|
||||
"_collection_link": "https://mrnutsen-7f68d4fc-3205107.postman.co/workspace/A24_Patriot_API~882aa50c-5471-4e78-aa12-1811e10b3979/collection/50473838-f7926ba4-6fae-4e5e-973b-402456b66ceb?action=share&source=collection_link&creator=50473838"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
|
|
@ -56,12 +57,11 @@
|
|||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/clients/zones",
|
||||
"raw": "{{baseUrl}}/zones",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"clients",
|
||||
"zones"
|
||||
]
|
||||
},
|
||||
|
|
@ -86,12 +86,11 @@
|
|||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/clients/users",
|
||||
"raw": "{{baseUrl}}/users",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"clients",
|
||||
"users"
|
||||
]
|
||||
},
|
||||
|
|
@ -121,13 +120,12 @@
|
|||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/clients/users",
|
||||
"raw": "{{baseUrl}}/user",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"clients",
|
||||
"users"
|
||||
"user"
|
||||
]
|
||||
},
|
||||
"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."
|
||||
|
|
@ -153,7 +151,7 @@
|
|||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"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}",
|
||||
"raw": "{\n \"client_id\": 4848,\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\": \"1\",\n \"SinceDays\": 1,\n \"SinceHrs\": 0,\n \"SinceMins\": 30,\n \"ResetNosigsIgnored\": true,\n \"ResetNosigsDays\": 7,\n \"ResetNosigsHrs\": 0,\n \"ResetNosigsMins\": 0,\n \"InstallDateTime\": \"2023-02-20\",\n \"PanelName\": \"Ajax\",\n \"PanelSite\": \"Stue\",\n \"KeypadLocation\": \"Inngang\",\n \"SPPage\": \"Ekstra informasjon som kan være relevant.\"\n }\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
|
|
@ -186,7 +184,7 @@
|
|||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"client_id\": 123456789,\n \"info\": {\n \"Name\": \"optional\"\n }\n}",
|
||||
"raw": "{\n \"client_id\": {{clientId}},\n \"info\": {\n \"Name\": \"Anders Knutsen\",\n \"Alias\": \"000FD267\",\n \"Location\": \"Bergbyveien\",\n \"area_code\": \"1730\",\n \"area\": \"Ise\",\n \"BusPhone\": \"69310000\",\n \"Email\": \"post@ostsik.no\",\n \"OKPassword\": \"franzjager\",\n \"SpecRequest\": \"Dette skal gjøres ved alarm på denne kunden.\",\n \"NoSigsMon\": \"Disabled\",\n \"SinceDays\": 1,\n \"SinceHrs\": 0,\n \"SinceMins\": 30,\n \"ResetNosigsIgnored\": true,\n \"ResetNosigsDays\": 14,\n \"ResetNosigsHrs\": 0,\n \"ResetNosigsMins\": 0,\n \"InstallDateTime\": \"2025-12-01\",\n \"PanelName\": \"Ajax\",\n \"PanelSite\": \"Ved tv\",\n \"KeypadLocation\": \"Inngang\",\n \"SPPage\": \"Ekstra informasjon som kan være relevant.\"\n }\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
|
|
@ -207,9 +205,9 @@
|
|||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Create/Update zone",
|
||||
"name": "Create zone",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
|
|
@ -219,7 +217,7 @@
|
|||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"client_id\": 123456789,\n \"zone_id\": 3,\n \"Zone_area\": \"RD Stue\",\n \"ModuleNo\": 0\n}\n",
|
||||
"raw": "{\n \"client_id\": 123456789,\n \"zone\": {\n \"ZoneNo\": 62,\n \"ZoneText\": \"Tastatur\"\n }\n}\n",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
|
|
@ -227,12 +225,44 @@
|
|||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/clients/zones",
|
||||
"raw": "{{baseUrl}}/zones",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"zones"
|
||||
]
|
||||
},
|
||||
"description": "Creates or updates a zone"
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Update zone",
|
||||
"request": {
|
||||
"method": "PATCH",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{apiKey}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"client_id\": 123456789,\n \"zone\": {\n \"ZoneNo\": 62,\n \"ZoneText\": \"Tastaturrrrrrr\"\n }\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/zones",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"clients",
|
||||
"zones"
|
||||
]
|
||||
},
|
||||
|
|
@ -253,7 +283,7 @@
|
|||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"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}",
|
||||
"raw": "{\n \"client_id\": 5555,\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}\n",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
|
|
@ -261,12 +291,11 @@
|
|||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/clients/users",
|
||||
"raw": "{{baseUrl}}/users",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"clients",
|
||||
"users"
|
||||
]
|
||||
},
|
||||
|
|
@ -277,7 +306,7 @@
|
|||
{
|
||||
"name": "Update user",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"method": "PATCH",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
|
|
@ -287,7 +316,7 @@
|
|||
],
|
||||
"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}",
|
||||
"raw": "{\n \"client_id\": {{clientId}},\n \"user\": {\n \"User_Name\": \"Anders Knutsen\",\n \"MobileNo\": \"40642018\",\n \"MobileNoOrder\": 1,\n \"Email\": \"anders@ostsik.no\",\n \"Type\": \"U\",\n \"UserNo\": 1,\n \"Instructions\": \"Do this 3\",\n \"CallOrder\": 1\n }\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
|
|
@ -295,12 +324,11 @@
|
|||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/clients/users",
|
||||
"raw": "{{baseUrl}}/users",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"clients",
|
||||
"users"
|
||||
]
|
||||
},
|
||||
|
|
@ -326,7 +354,7 @@
|
|||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"client_id\": {{clientId}},\n \"zone_id\": 1\n}",
|
||||
"raw": "{\n \"client_id\": {{clientId}},\n \"zone_no\": 62\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
|
|
@ -334,12 +362,11 @@
|
|||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/clients/zones",
|
||||
"raw": "{{baseUrl}}/zones",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"clients",
|
||||
"zones"
|
||||
]
|
||||
},
|
||||
|
|
@ -368,12 +395,11 @@
|
|||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/clients/users",
|
||||
"raw": "{{baseUrl}}/users",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"clients",
|
||||
"users"
|
||||
]
|
||||
},
|
||||
|
|
@ -394,7 +420,7 @@
|
|||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"client_id\": {{clientId}}\n}",
|
||||
"raw": "{\n \"client_id\": 1234\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
"port": "BASE11",
|
||||
|
||||
"installer_name": "Østfold Sikkerhetsservice AS",
|
||||
"installer_email": "service@ostsik.no",
|
||||
"installer_email": "post@ostsik.no",
|
||||
|
||||
"use_glob_callouts": true,
|
||||
"show_on_callouts": false,
|
||||
|
|
@ -19,9 +19,9 @@
|
|||
"glob_callouts2": "TLF02BASE01",
|
||||
|
||||
"alt_lookup": true,
|
||||
"alt_alarm_no": "CID4BASE01",
|
||||
"alt_alarm_no": "SIA1000101",
|
||||
"convert_type": "None",
|
||||
"siginterpret": "None",
|
||||
"siginterpret": "SIADecimal",
|
||||
|
||||
"client_groupings": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ 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
|
||||
from main import app # import FastAPI app from main.py
|
||||
print("[launcher] imported main.app OK")
|
||||
except Exception as e:
|
||||
print(f"[launcher] FAILED to import main.app: {e}")
|
||||
|
|
@ -31,6 +31,6 @@ except Exception as 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] running uvicorn on 0.0.0.0:8082")
|
||||
uvicorn.run(app, host="0.0.0.0", port=8082, log_level="info")
|
||||
print("[launcher] uvicorn.run returned (server stopped)") # should only print on shutdown
|
||||
|
|
|
|||
|
|
@ -0,0 +1,306 @@
|
|||
#!/usr/bin/env python3
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import requests
|
||||
|
||||
# ================== CONFIG ==================
|
||||
|
||||
BASE_URL = "http://10.181.149.220:8081" # through nginx
|
||||
API_KEY = "_2sW6roe2ZQ4V6Cldo0v295fakHT8vBHqHScfliX445tZuzxDwMRqjPeCE7FDcVVr" # your real API key
|
||||
|
||||
# Auth style: choose one of these:
|
||||
USE_X_API_KEY = False # send X-API-Key header
|
||||
USE_BEARER = True # send Authorization: Bearer <API_KEY>
|
||||
|
||||
# <<< IMPORTANT >>>
|
||||
# List of existing 4-digit client IDs you want to mutate.
|
||||
# Fill this with IDs you know exist (created by your previous test/import).
|
||||
TARGET_CLIENT_IDS = [
|
||||
# Example: 1234, 5678, 9012
|
||||
]
|
||||
|
||||
# If TARGET_CLIENT_IDS is empty, we fallback to random 4-digit IDs (you'll get some 404s)
|
||||
FALLBACK_RANDOM_RANGE = (1235, 1244)
|
||||
|
||||
# How aggressive should this be?
|
||||
SLEEP_BETWEEN_OPS = 10 # seconds between operations
|
||||
|
||||
# Relative probabilities of operations
|
||||
OP_WEIGHTS = {
|
||||
"update_client": 0.4,
|
||||
"create_or_update_user": 0.3,
|
||||
"create_or_update_zone": 0.3,
|
||||
}
|
||||
|
||||
# ============================================
|
||||
|
||||
|
||||
def auth_headers():
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if USE_X_API_KEY:
|
||||
headers["X-API-Key"] = API_KEY
|
||||
if USE_BEARER:
|
||||
headers["Authorization"] = f"Bearer {API_KEY}"
|
||||
return headers
|
||||
|
||||
|
||||
def pick_client_id() -> int:
|
||||
if TARGET_CLIENT_IDS:
|
||||
return random.choice(TARGET_CLIENT_IDS)
|
||||
return random.randint(*FALLBACK_RANDOM_RANGE)
|
||||
|
||||
|
||||
def random_phone():
|
||||
return "+47" + "".join(random.choice("0123456789") for _ in range(8))
|
||||
|
||||
|
||||
def random_email(name_stub: str):
|
||||
domains = ["example.com", "test.com", "mailinator.com", "demo.net"]
|
||||
clean = "".join(c for c in name_stub.lower() if c.isalnum())
|
||||
return f"{clean or 'user'}@{random.choice(domains)}"
|
||||
|
||||
|
||||
def random_string(prefix: str, length: int = 8):
|
||||
tail = "".join(random.choice(string.ascii_letters + string.digits) for _ in range(length))
|
||||
return f"{prefix}{tail}"
|
||||
|
||||
|
||||
def random_client_info():
|
||||
# Random-ish basic client fields
|
||||
name = random_string("Client_", 5)
|
||||
alias = random_string("AL", 4)
|
||||
street_no = random.randint(1, 200)
|
||||
street_name = random.choice(["Gate", "Veien", "Stien", "Allé"])
|
||||
location = f"{random.choice(['Test', 'Demo', 'Fake'])} {street_name} {street_no}"
|
||||
|
||||
area_code = str(random.randint(1000, 9999))
|
||||
area = random.choice(["Oslo", "Sarpsborg", "Bergen", "Trondheim", "Stavanger"])
|
||||
|
||||
bus_phone = random_phone()
|
||||
email = random_email(name)
|
||||
|
||||
ok_password = random_string("pwd_", 6)
|
||||
spec_request = random.choice(
|
||||
[
|
||||
"Script-endret testkunde.",
|
||||
"Oppdatert spesialinstruks fra load-test.",
|
||||
"Ingen reelle tiltak, kun test.",
|
||||
]
|
||||
)
|
||||
|
||||
no_sigs_mon = random.choice(["ActiveAny", "Disabled"])
|
||||
since_days = random.randint(0, 7)
|
||||
since_hrs = random.randint(0, 23)
|
||||
since_mins = random.randint(0, 59)
|
||||
|
||||
reset_ignored = random.choice([True, False])
|
||||
reset_days = random.randint(0, 14)
|
||||
reset_hrs = random.randint(0, 23)
|
||||
reset_mins = random.randint(0, 59)
|
||||
|
||||
install_date = (datetime.now() - timedelta(days=random.randint(0, 365))).date().isoformat()
|
||||
|
||||
panel_name = random.choice(["Ajax", "Future"])
|
||||
panel_site = random.choice(["Stue", "Gang", "Kontor", "Lager"])
|
||||
keypad_location = random.choice(["Inngang", "Bakdør", "Garasje", "2. etg"])
|
||||
sp_page = random.choice(
|
||||
[
|
||||
"Ekstra info endret av load-test.",
|
||||
"Test entry, kan ignoreres.",
|
||||
"API performance-test.",
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
"Name": name,
|
||||
"Alias": alias,
|
||||
"Location": location,
|
||||
"area_code": area_code,
|
||||
"area": area,
|
||||
"BusPhone": bus_phone,
|
||||
"Email": email,
|
||||
"OKPassword": ok_password,
|
||||
"SpecRequest": spec_request,
|
||||
"NoSigsMon": no_sigs_mon,
|
||||
"SinceDays": since_days,
|
||||
"SinceHrs": since_hrs,
|
||||
"SinceMins": since_mins,
|
||||
"ResetNosigsIgnored": reset_ignored,
|
||||
"ResetNosigsDays": reset_days,
|
||||
"ResetNosigsHrs": reset_hrs,
|
||||
"ResetNosigsMins": reset_mins,
|
||||
"InstallDateTime": install_date,
|
||||
"PanelName": panel_name,
|
||||
"PanelSite": panel_site,
|
||||
"KeypadLocation": keypad_location,
|
||||
"SPPage": sp_page,
|
||||
}
|
||||
|
||||
|
||||
def random_user_payload(existing_user_no: int | None = None):
|
||||
first_names = ["Anders", "Per", "Lise", "Kari", "Ole", "Nina"]
|
||||
last_names = ["Knutsen", "Olsen", "Hansen", "Johansen", "Pedersen"]
|
||||
name = f"{random.choice(first_names)} {random.choice(last_names)}"
|
||||
|
||||
mobile = random_phone()
|
||||
email = random_email(name.replace(" ", "."))
|
||||
|
||||
user_type = "U"
|
||||
instructions = random.choice(
|
||||
[
|
||||
"Oppdatert instrukser.",
|
||||
"Ring ved alarm.",
|
||||
"Kun SMS.",
|
||||
"Kontakt vaktmester først.",
|
||||
]
|
||||
)
|
||||
|
||||
call_order = random.randint(0, 3)
|
||||
mobile_order = random.randint(1, 3)
|
||||
|
||||
if existing_user_no is None:
|
||||
user_no = random.randint(1, 5)
|
||||
else:
|
||||
user_no = existing_user_no
|
||||
|
||||
return {
|
||||
"User_Name": name,
|
||||
"MobileNo": mobile,
|
||||
"MobileNoOrder": mobile_order,
|
||||
"Email": email,
|
||||
"Type": user_type,
|
||||
"UserNo": user_no,
|
||||
"Instructions": instructions,
|
||||
"CallOrder": call_order,
|
||||
}
|
||||
|
||||
|
||||
def random_zone_payload(existing_zone_no: int | None = None):
|
||||
zone_names = [
|
||||
"Stue",
|
||||
"Kjøkken",
|
||||
"Gang",
|
||||
"Soverom",
|
||||
"Garasje",
|
||||
"Kontor",
|
||||
"Lager",
|
||||
"Uteområde",
|
||||
]
|
||||
if existing_zone_no is None:
|
||||
zone_no = random.randint(1, 20)
|
||||
else:
|
||||
zone_no = existing_zone_no
|
||||
|
||||
zone_text = random.choice(zone_names) + " " + random.choice(["1", "2", "A", "B"])
|
||||
return {"ZoneNo": zone_no, "ZoneText": zone_text}
|
||||
|
||||
|
||||
def do_update_client(client_id: int):
|
||||
url = f"{BASE_URL}/clients"
|
||||
payload = {
|
||||
"client_id": client_id,
|
||||
"info": random_client_info(),
|
||||
}
|
||||
r = requests.put(url, json=payload, headers=auth_headers(), timeout=10)
|
||||
print(f"[update_client] client_id={client_id} -> {r.status_code}")
|
||||
if r.status_code >= 400:
|
||||
print(f" body: {r.text[:500]}")
|
||||
return r.status_code
|
||||
|
||||
|
||||
def do_create_or_update_user(client_id: int):
|
||||
url = f"{BASE_URL}/users"
|
||||
# Randomly choose new or existing user no
|
||||
if random.random() < 0.5:
|
||||
user_no = random.randint(1, 3) # likely already created by your first script
|
||||
else:
|
||||
user_no = random.randint(4, 10) # maybe new user
|
||||
|
||||
user_payload = random_user_payload(existing_user_no=user_no)
|
||||
payload = {
|
||||
"client_id": client_id,
|
||||
"user": user_payload,
|
||||
}
|
||||
r = requests.post(url, json=payload, headers=auth_headers(), timeout=10)
|
||||
print(
|
||||
f"[user] client_id={client_id} UserNo={user_no} -> {r.status_code}"
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
print(f" body: {r.text[:500]}")
|
||||
return r.status_code
|
||||
|
||||
|
||||
def do_create_or_update_zone(client_id: int):
|
||||
url = f"{BASE_URL}/zones"
|
||||
# Same trick: sometimes hit likely existing zones, sometimes new
|
||||
if random.random() < 0.5:
|
||||
zone_no = random.randint(1, 10)
|
||||
else:
|
||||
zone_no = random.randint(11, 30)
|
||||
|
||||
zone_payload = random_zone_payload(existing_zone_no=zone_no)
|
||||
payload = {
|
||||
"client_id": client_id,
|
||||
"zone": zone_payload,
|
||||
}
|
||||
r = requests.post(url, json=payload, headers=auth_headers(), timeout=10)
|
||||
print(
|
||||
f"[zone] client_id={client_id} ZoneNo={zone_no} -> {r.status_code}"
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
print(f" body: {r.text[:500]}")
|
||||
return r.status_code
|
||||
|
||||
|
||||
def pick_operation():
|
||||
ops = list(OP_WEIGHTS.keys())
|
||||
weights = [OP_WEIGHTS[o] for o in ops]
|
||||
return random.choices(ops, weights=weights, k=1)[0]
|
||||
|
||||
|
||||
def main():
|
||||
if not TARGET_CLIENT_IDS:
|
||||
print(
|
||||
"WARNING: TARGET_CLIENT_IDS is empty.\n"
|
||||
" Script will use random 4-digit client IDs and you may see many 404s."
|
||||
)
|
||||
else:
|
||||
print(f"Targeting these client IDs: {TARGET_CLIENT_IDS}")
|
||||
|
||||
print("Starting random modification loop. Press Ctrl+C to stop.\n")
|
||||
|
||||
op_count = 0
|
||||
try:
|
||||
while True:
|
||||
client_id = pick_client_id()
|
||||
op = pick_operation()
|
||||
|
||||
print(f"\n=== op #{op_count} ===")
|
||||
print(f"Client: {client_id}, operation: {op}")
|
||||
|
||||
try:
|
||||
if op == "update_client":
|
||||
do_update_client(client_id)
|
||||
elif op == "create_or_update_user":
|
||||
do_create_or_update_user(client_id)
|
||||
elif op == "create_or_update_zone":
|
||||
do_create_or_update_zone(client_id)
|
||||
else:
|
||||
print(f"Unknown operation {op}, skipping.")
|
||||
except requests.RequestException as e:
|
||||
print(f"HTTP error: {e}")
|
||||
|
||||
op_count += 1
|
||||
time.sleep(SLEEP_BETWEEN_OPS)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopping on user request (Ctrl+C).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,434 @@
|
|||
#!/usr/bin/env python3
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
import logging
|
||||
import os
|
||||
import copy
|
||||
import time
|
||||
import shutil
|
||||
import socket
|
||||
from datetime import datetime, timedelta
|
||||
from io import BytesIO
|
||||
from smb.SMBConnection import SMBConnection # pip install pysmb
|
||||
import json
|
||||
|
||||
CLIENT_STATE_FILE = Path("/opt/patriot_api/client_state.json")
|
||||
os.makedirs(CLIENT_STATE_FILE.parent, exist_ok=True)
|
||||
|
||||
def load_client_state() -> dict:
|
||||
if CLIENT_STATE_FILE.is_file():
|
||||
try:
|
||||
return json.loads(CLIENT_STATE_FILE.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
logging.error("Failed to read client state file %s: %s", CLIENT_STATE_FILE, e)
|
||||
return {}
|
||||
return {}
|
||||
|
||||
def save_client_state(state: dict):
|
||||
try:
|
||||
tmp = CLIENT_STATE_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
os.replace(tmp, CLIENT_STATE_FILE)
|
||||
except Exception as e:
|
||||
logging.error("Failed to write client state file %s: %s", CLIENT_STATE_FILE, e)
|
||||
|
||||
def mark_clients_pending_import(client_ids: list[str]):
|
||||
if not client_ids:
|
||||
return
|
||||
state = load_client_state()
|
||||
now_ts = datetime.now().isoformat(timespec="seconds")
|
||||
for cid in client_ids:
|
||||
state[cid] = {
|
||||
"status": "pending_import",
|
||||
"last_batch": now_ts,
|
||||
}
|
||||
save_client_state(state)
|
||||
|
||||
|
||||
# --------- CONFIG ---------
|
||||
|
||||
XML_ROOT_PATH = Path("/opt/patriot_api/out/clients") # per-client XMLs (from main_v2)
|
||||
READY_DIR = Path("/opt/patriot_api/ready_for_import") # combined XML output
|
||||
COMBINED_FILENAME = "clients.xml"
|
||||
LOG_FILE = "/opt/patriot_api/xml_combine.log"
|
||||
ERROR_LOG_FILE = "/opt/patriot_api/import_errors.log"
|
||||
|
||||
# Script will run 5 minutes before every hour (hh:55), so no fixed RUN_INTERVAL needed
|
||||
RUN_INTERVAL = 3600 # still used as a safety fallback if needed
|
||||
MAX_CLIENTS_PER_RUN = 300
|
||||
|
||||
# SMB / Windows share config (no kernel mount needed)
|
||||
SMB_ENABLED = True
|
||||
|
||||
SMB_SERVER_IP = "10.181.149.83" # Windows server IP
|
||||
SMB_SERVER_NAME = "PATRIOT" # NetBIOS/hostname (can be anything if IP is used, but fill something)
|
||||
SMB_SHARE_NAME = "api_import" # Share name (from //IP/share)
|
||||
|
||||
# Guest access: empty username/password, use_ntlm_v2=False
|
||||
SMB_USERNAME = "administrator" # empty = guest
|
||||
SMB_PASSWORD = "wprs100qq!" # empty = guest
|
||||
SMB_DOMAIN = "WORKGROUP" # often empty for guest
|
||||
|
||||
# Remote path inside the share where clients.xml will be stored
|
||||
# e.g. "clients/clients.xml" or just "clients.xml"
|
||||
SMB_REMOTE_PATH = "clients.xml"
|
||||
SMB_RESULTS_FILENAME = "clients_Import_Results.txt"
|
||||
|
||||
# --------------------------
|
||||
|
||||
os.makedirs(READY_DIR, exist_ok=True)
|
||||
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
|
||||
os.makedirs(os.path.dirname(ERROR_LOG_FILE), exist_ok=True)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler(LOG_FILE, encoding="utf-8"),
|
||||
logging.StreamHandler(),
|
||||
],
|
||||
)
|
||||
|
||||
def log_missing_ids_to_error_log(missing_ids: list[str]):
|
||||
if not missing_ids:
|
||||
return
|
||||
try:
|
||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
with open(ERROR_LOG_FILE, "a", encoding="utf-8") as f:
|
||||
for cid in missing_ids:
|
||||
f.write(f"{ts} Missing or failed import for client __id={cid}\n")
|
||||
except Exception as e:
|
||||
logging.error("Failed to write to error log %s: %s", ERROR_LOG_FILE, e)
|
||||
|
||||
|
||||
def load_combined_root(path: Path) -> ET.Element:
|
||||
if not path.is_file():
|
||||
logging.info("Combined XML %s does not exist yet, creating new.", path)
|
||||
return ET.Element("Clients")
|
||||
|
||||
try:
|
||||
tree = ET.parse(path)
|
||||
root = tree.getroot()
|
||||
if root.tag != "Clients":
|
||||
logging.warning(
|
||||
"Combined XML %s has root <%s> instead of <Clients>; recreating.",
|
||||
path,
|
||||
root.tag,
|
||||
)
|
||||
return ET.Element("Clients")
|
||||
return root
|
||||
except Exception as e:
|
||||
logging.error("Failed to parse existing combined XML %s: %s; recreating.", path, e)
|
||||
return ET.Element("Clients")
|
||||
|
||||
|
||||
def rows_by_id(root: ET.Element) -> dict[str, ET.Element]:
|
||||
mapping = {}
|
||||
for row in root.findall("Row"):
|
||||
id_el = row.find("__id")
|
||||
if id_el is None or not (id_el.text or "").strip():
|
||||
continue
|
||||
cid = id_el.text.strip()
|
||||
mapping[cid] = row
|
||||
return mapping
|
||||
|
||||
|
||||
def upload_to_smb(local_path: Path) -> bool:
|
||||
"""
|
||||
Upload the combined XML to the Windows share using SMB.
|
||||
|
||||
Before uploading:
|
||||
- If an existing clients.xml and clients_Import_Results.txt are present
|
||||
on the SMB share, verify that each __id in that clients.xml has a line
|
||||
containing that id and the word "Successful" in the results file.
|
||||
- Any IDs not matching are logged to ERROR_LOG_FILE.
|
||||
|
||||
Returns:
|
||||
True -> upload considered successful (or SMB disabled)
|
||||
False -> upload failed, caller should NOT delete source XMLs
|
||||
"""
|
||||
if not SMB_ENABLED:
|
||||
logging.info("SMB upload disabled in config; skipping upload but treating as success.")
|
||||
return True
|
||||
|
||||
if not local_path.is_file():
|
||||
logging.error("SMB upload: local file %s does not exist", local_path)
|
||||
return False
|
||||
|
||||
try:
|
||||
my_name = socket.gethostname() or "patriot-api"
|
||||
conn = SMBConnection(
|
||||
SMB_USERNAME,
|
||||
SMB_PASSWORD,
|
||||
my_name,
|
||||
SMB_SERVER_NAME,
|
||||
domain=SMB_DOMAIN,
|
||||
use_ntlm_v2=False, # set True if you move to proper user+password auth
|
||||
is_direct_tcp=True, # connect directly to port 445
|
||||
)
|
||||
|
||||
logging.info("SMB: connecting to %s (%s)...", SMB_SERVER_NAME, SMB_SERVER_IP)
|
||||
if not conn.connect(SMB_SERVER_IP, 445, timeout=10):
|
||||
logging.error("SMB: failed to connect to %s", SMB_SERVER_IP)
|
||||
return False
|
||||
|
||||
# Split directory and filename in remote path
|
||||
remote_dir, remote_name = os.path.split(SMB_REMOTE_PATH)
|
||||
if not remote_dir:
|
||||
remote_dir = "/"
|
||||
|
||||
# Build full paths for existing clients.xml and the results file
|
||||
if remote_dir in ("", "/"):
|
||||
remote_clients_path = remote_name
|
||||
remote_results_path = SMB_RESULTS_FILENAME
|
||||
else:
|
||||
rd = remote_dir.rstrip("/")
|
||||
remote_clients_path = f"{rd}/{remote_name}"
|
||||
remote_results_path = f"{rd}/{SMB_RESULTS_FILENAME}"
|
||||
|
||||
# -------- PRE-UPLOAD CHECK vs clients_Import_Results.txt --------
|
||||
try:
|
||||
xml_buf = BytesIO()
|
||||
res_buf = BytesIO()
|
||||
|
||||
# Try to retrieve both files; if either is missing, skip the check
|
||||
try:
|
||||
conn.retrieveFile(SMB_SHARE_NAME, remote_clients_path, xml_buf)
|
||||
conn.retrieveFile(SMB_SHARE_NAME, remote_results_path, res_buf)
|
||||
xml_buf.seek(0)
|
||||
res_buf.seek(0)
|
||||
except Exception as e:
|
||||
logging.info(
|
||||
"SMB pre-upload check: could not retrieve existing clients.xml or "
|
||||
"results file (may not exist yet): %s", e
|
||||
)
|
||||
else:
|
||||
# Parse existing clients.xml to gather IDs
|
||||
try:
|
||||
tree_remote = ET.parse(xml_buf)
|
||||
root_remote = tree_remote.getroot()
|
||||
|
||||
remote_ids = set()
|
||||
for row in root_remote.findall(".//Row"):
|
||||
id_el = row.find("__id")
|
||||
if id_el is not None and id_el.text and id_el.text.strip():
|
||||
remote_ids.add(id_el.text.strip())
|
||||
except Exception as e:
|
||||
logging.error("SMB pre-upload check: failed to parse remote clients.xml: %s", e)
|
||||
remote_ids = set()
|
||||
|
||||
# Read results txt lines
|
||||
results_lines = res_buf.getvalue().decode("utf-8", errors="ignore").splitlines()
|
||||
|
||||
missing_ids = []
|
||||
if remote_ids:
|
||||
for cid in remote_ids:
|
||||
found_success = False
|
||||
for line in results_lines:
|
||||
# Very generic check: line contains id AND word "Completed processing client"
|
||||
if "Completed processing client" in line and cid in line:
|
||||
found_success = True
|
||||
break
|
||||
if not found_success:
|
||||
missing_ids.append(cid)
|
||||
|
||||
if missing_ids:
|
||||
log_missing_ids_to_error_log(missing_ids)
|
||||
logging.warning(
|
||||
"SMB pre-upload check: %d client(s) from existing clients.xml "
|
||||
"do not have 'Completed processing client' result lines. Logged to %s.",
|
||||
len(missing_ids),
|
||||
ERROR_LOG_FILE,
|
||||
)
|
||||
else:
|
||||
if remote_ids:
|
||||
logging.info(
|
||||
"SMB pre-upload check: all %d client IDs in existing clients.xml "
|
||||
"appear as 'Completed processing client' in results file.",
|
||||
len(remote_ids),
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error("SMB: unexpected error during pre-upload check: %s", e)
|
||||
|
||||
# -------- HANDLE EXISTING clients.xml (rotate/rename) --------
|
||||
try:
|
||||
files = conn.listPath(SMB_SHARE_NAME, remote_dir, pattern=remote_name)
|
||||
exists = any(f.filename == remote_name for f in files)
|
||||
except Exception:
|
||||
exists = False
|
||||
|
||||
if exists:
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
base, ext = os.path.splitext(remote_name)
|
||||
backup_name = f"{base}_{ts}{ext}"
|
||||
|
||||
if remote_dir in ("", "/"):
|
||||
old_path = remote_name
|
||||
new_path = backup_name
|
||||
else:
|
||||
old_path = f"{remote_dir.rstrip('/')}/{remote_name}"
|
||||
new_path = f"{remote_dir.rstrip('/')}/{backup_name}"
|
||||
|
||||
try:
|
||||
conn.rename(SMB_SHARE_NAME, old_path, new_path)
|
||||
logging.info("SMB: existing %s renamed to %s", old_path, new_path)
|
||||
except Exception as e:
|
||||
logging.error("SMB: failed to rename existing %s: %s", old_path, e)
|
||||
|
||||
# -------- UPLOAD NEW clients.xml --------
|
||||
with open(local_path, "rb") as f:
|
||||
logging.info(
|
||||
"SMB: uploading %s to //%s/%s/%s",
|
||||
local_path,
|
||||
SMB_SERVER_IP,
|
||||
SMB_SHARE_NAME,
|
||||
SMB_REMOTE_PATH,
|
||||
)
|
||||
conn.storeFile(SMB_SHARE_NAME, SMB_REMOTE_PATH, f)
|
||||
|
||||
logging.info("SMB: upload completed successfully.")
|
||||
conn.close()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logging.error("SMB: error during upload of %s: %s", local_path, e)
|
||||
return False
|
||||
|
||||
|
||||
|
||||
|
||||
def combine_xml_once():
|
||||
combined_path = READY_DIR / COMBINED_FILENAME
|
||||
|
||||
# Scan all per-client XMLs
|
||||
xml_files = sorted(XML_ROOT_PATH.rglob("*.xml")) # sorted for deterministic order
|
||||
|
||||
# Track files to delete after upload
|
||||
processed_files = []
|
||||
|
||||
combined_rows: dict[str, ET.Element] = {}
|
||||
|
||||
if xml_files:
|
||||
logging.info("Found %d new per-client XML file(s) to merge.", len(xml_files))
|
||||
|
||||
# Limit how many XMLs (clients) we handle per run
|
||||
limited_files = xml_files[:MAX_CLIENTS_PER_RUN]
|
||||
if len(xml_files) > MAX_CLIENTS_PER_RUN:
|
||||
logging.info(
|
||||
"Limiting this run to %d clients; %d will be processed in later runs.",
|
||||
MAX_CLIENTS_PER_RUN,
|
||||
len(xml_files) - MAX_CLIENTS_PER_RUN,
|
||||
)
|
||||
|
||||
for path in limited_files:
|
||||
logging.info(" Reading %s", path)
|
||||
try:
|
||||
tree = ET.parse(path)
|
||||
root = tree.getroot()
|
||||
except Exception as e:
|
||||
logging.error(" Failed to parse %s: %s", path, e)
|
||||
# Keep previous behavior: delete bad file
|
||||
processed_files.append(path)
|
||||
continue
|
||||
|
||||
processed_files.append(path)
|
||||
|
||||
# Extract rows
|
||||
if root.tag == "Clients":
|
||||
rows = root.findall("Row")
|
||||
elif root.tag == "Row":
|
||||
rows = [root]
|
||||
else:
|
||||
rows = root.findall(".//Row")
|
||||
|
||||
for row in rows:
|
||||
id_el = row.find("__id")
|
||||
if id_el is None or not (id_el.text or "").strip():
|
||||
logging.warning(" Skipping row without __id in %s", path)
|
||||
continue
|
||||
cid = id_el.text.strip()
|
||||
logging.info(" Including client __id=%s", cid)
|
||||
combined_rows[cid] = copy.deepcopy(row)
|
||||
|
||||
else:
|
||||
logging.info("No NEW client XMLs found locally — will upload an empty Clients file.")
|
||||
|
||||
|
||||
# Build XML root (may be empty)
|
||||
new_root = ET.Element("Clients")
|
||||
for cid, row in combined_rows.items():
|
||||
new_root.append(row)
|
||||
|
||||
# Always write combined XML (new or empty)
|
||||
tmp_path = combined_path.with_suffix(".tmp")
|
||||
try:
|
||||
ET.ElementTree(new_root).write(tmp_path, encoding="utf-8", xml_declaration=True)
|
||||
os.replace(tmp_path, combined_path)
|
||||
logging.info("Wrote combined (or empty) clients.xml to %s", combined_path)
|
||||
except Exception as e:
|
||||
logging.error("Failed writing combined XML: %s", e)
|
||||
if os.path.exists(tmp_path):
|
||||
os.remove(tmp_path)
|
||||
return
|
||||
|
||||
# Rotate & upload to SMB
|
||||
upload_ok = upload_to_smb(combined_path)
|
||||
|
||||
if not upload_ok:
|
||||
logging.warning(
|
||||
"SMB upload failed; keeping per-client XMLs so this batch can be retried next run."
|
||||
)
|
||||
return
|
||||
|
||||
# Mark all combined client IDs as pending_import
|
||||
try:
|
||||
client_ids_in_batch = list(combined_rows.keys()) # these are the __id values
|
||||
mark_clients_pending_import(client_ids_in_batch)
|
||||
logging.info(
|
||||
"Marked %d client(s) as pending_import in %s",
|
||||
len(client_ids_in_batch),
|
||||
CLIENT_STATE_FILE,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error("Failed to update client pending_import state: %s", e)
|
||||
|
||||
# Delete ONLY the processed new XMLs if upload succeeded
|
||||
for src in processed_files:
|
||||
try:
|
||||
src.unlink()
|
||||
logging.info("Deleted processed source XML: %s", src)
|
||||
except Exception as e:
|
||||
logging.error("Failed to delete %s: %s", src, e)
|
||||
|
||||
|
||||
def sleep_until_next_run():
|
||||
"""
|
||||
Sleep until 1 minute before the next full hour (hh:59).
|
||||
"""
|
||||
now = datetime.now()
|
||||
|
||||
# Top of the next hour
|
||||
next_hour = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
|
||||
run_time = next_hour - timedelta(minutes=1) # hh:59
|
||||
|
||||
# If we are already past that run_time (e.g., started at 10:59:30), move to the next hour
|
||||
if run_time <= now:
|
||||
next_hour = next_hour + timedelta(hours=1)
|
||||
run_time = next_hour - timedelta(minutes=1) # still hh:59, just next hour
|
||||
|
||||
delta = (run_time - now).total_seconds()
|
||||
logging.info("Next combine run scheduled at %s (in %.0f seconds)", run_time, delta)
|
||||
time.sleep(delta)
|
||||
|
||||
|
||||
def main():
|
||||
while True:
|
||||
sleep_until_next_run()
|
||||
logging.info("==== XML combine run ====")
|
||||
combine_xml_once()
|
||||
# Safety fallback: if anything goes wrong with time calc, we still avoid tight loop
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in New Issue