@sensolus/create-snt-agent-app 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +2 -1
- package/package.json +1 -1
- package/template/CLAUDE.md +218 -0
- package/template/Dockerfile +32 -0
- package/template/Jenkinsfile +28 -0
- package/template/README.md +493 -16
- package/template/_env.example +14 -0
- package/template/backend/app.py +642 -49
- package/template/backend/db_config.py +16 -0
- package/template/backend/extensions.py +3 -0
- package/template/backend/init_db.py +75 -0
- package/template/backend/migrations/README +1 -0
- package/template/backend/migrations/alembic.ini +50 -0
- package/template/backend/migrations/env.py +113 -0
- package/template/backend/migrations/script.py.mako +24 -0
- package/template/backend/migrations/versions/001_add_favourite_organisations.py +31 -0
- package/template/backend/migrations/versions/002_add_org_daily_stats.py +36 -0
- package/template/backend/models.py +31 -0
- package/template/backend/requirements.txt +8 -2
- package/template/eslint.config.js +6 -2
- package/template/index.html +11 -8
- package/template/infra/docker-compose.yml +15 -0
- package/template/openapi.json +40357 -0
- package/template/package.json +8 -1
- package/template/scripts/create-ecr-repo.sh +42 -0
- package/template/src/App.jsx +30 -33
- package/template/src/AppConfigContext.jsx +45 -0
- package/template/src/hooks/useFavourites.js +44 -0
- package/template/src/i18n/index.js +3 -0
- package/template/src/i18n/messages.js +8 -14
- package/template/src/i18n/translations/de.js +72 -0
- package/template/src/i18n/translations/en.js +79 -0
- package/template/src/i18n/translations/es.js +72 -0
- package/template/src/i18n/translations/fr.js +72 -0
- package/template/src/i18n/translations/nl.js +72 -0
- package/template/src/main.jsx +2 -6
- package/template/src/pages/Home.jsx +170 -0
- package/template/src/pages/OrganisationDetail.jsx +263 -0
- package/template/src/pages/OrganisationList.jsx +457 -0
- package/template/src/pages/Overview.jsx +199 -0
- package/template/src/pages/WidgetShowcase.jsx +522 -0
- package/template/src/styles/app.css +543 -4
- package/template/start-backend.sh +4 -0
- package/template/start-frontend.sh +3 -0
package/template/backend/app.py
CHANGED
|
@@ -1,70 +1,663 @@
|
|
|
1
|
-
"""
|
|
2
|
-
{{APP_NAME}} backend — Flask proxy to the Sensolus public API.
|
|
3
|
-
|
|
4
|
-
Avoids CORS by forwarding /api/* to https://<SENSOLUS_DOMAIN>/rest/api/v2/*.
|
|
5
|
-
|
|
6
|
-
Auth (same contract as sample_micro_app):
|
|
7
|
-
1. Session cookie `prod-sensolus-token` -> Authorization: Bearer <token>
|
|
8
|
-
2. SENSOLUS_API_KEY env var -> ?apiKey=<key> query parameter
|
|
9
|
-
Cookie wins if both are present.
|
|
10
|
-
"""
|
|
11
1
|
import os
|
|
12
|
-
|
|
2
|
+
import sys
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from flask import Flask, request, jsonify, send_from_directory, session
|
|
6
|
+
from flask_apscheduler import APScheduler
|
|
7
|
+
from flask_socketio import SocketIO
|
|
8
|
+
from flask_migrate import Migrate
|
|
13
9
|
import requests
|
|
14
|
-
from
|
|
10
|
+
from dotenv import load_dotenv
|
|
11
|
+
|
|
12
|
+
from db_config import get_database_uri
|
|
13
|
+
from extensions import db
|
|
15
14
|
|
|
15
|
+
# Get the project root (parent of backend folder)
|
|
16
16
|
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
17
17
|
|
|
18
|
+
# Load .env from project root (env vars take precedence over .env file)
|
|
19
|
+
load_dotenv(os.path.join(PROJECT_ROOT, '.env'))
|
|
20
|
+
|
|
21
|
+
# Configure logging
|
|
22
|
+
logging.basicConfig(
|
|
23
|
+
level=logging.INFO,
|
|
24
|
+
format='%(asctime)s [%(levelname)s] [%(name)s] %(message)s',
|
|
25
|
+
datefmt='%Y-%m-%d %H:%M:%S',
|
|
26
|
+
force=True,
|
|
27
|
+
)
|
|
28
|
+
logger = logging.getLogger("{{APP_NAME}}")
|
|
29
|
+
logger.setLevel(logging.INFO)
|
|
30
|
+
|
|
31
|
+
# Serve from 'dist' in production (Vite build output)
|
|
32
|
+
static_folder = os.path.join(PROJECT_ROOT, 'dist')
|
|
33
|
+
app = Flask(__name__, static_folder=static_folder)
|
|
34
|
+
app.secret_key = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
|
|
35
|
+
|
|
36
|
+
# Database configuration
|
|
37
|
+
app.config['SQLALCHEMY_DATABASE_URI'] = get_database_uri()
|
|
38
|
+
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
|
39
|
+
|
|
40
|
+
# Initialize extensions
|
|
41
|
+
db.init_app(app)
|
|
42
|
+
socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading')
|
|
43
|
+
|
|
44
|
+
# Initialize Flask-Migrate
|
|
45
|
+
import models # noqa: F401
|
|
46
|
+
migrations_dir = os.path.join(os.path.dirname(__file__), 'migrations')
|
|
47
|
+
Migrate(app, db, directory=migrations_dir)
|
|
48
|
+
|
|
18
49
|
SENSOLUS_DOMAIN = os.environ.get("SENSOLUS_DOMAIN", "cloud.sensolus.com")
|
|
19
50
|
SENSOLUS_BASE_URL = f"https://{SENSOLUS_DOMAIN}/rest/api/v2"
|
|
20
51
|
SENSOLUS_COOKIE_NAME = os.environ.get("SENSOLUS_COOKIE_NAME", "prod-sensolus-token")
|
|
21
|
-
SENSOLUS_API_KEY = os.environ.get("SENSOLUS_API_KEY")
|
|
22
52
|
|
|
23
|
-
|
|
53
|
+
MANAGER_AUTH_KEY = os.environ.get("MANAGER_AUTH_KEY")
|
|
54
|
+
|
|
55
|
+
# Manager-provided headers — typos in string literals silently 401, so route everything through these.
|
|
56
|
+
HEADER_MANAGER_AUTH = "X-Sensolus-Manager-Auth"
|
|
57
|
+
HEADER_SENSOLUS_AUTH = "X-Sensolus-Auth"
|
|
58
|
+
|
|
59
|
+
APP_DESCRIPTOR = {
|
|
60
|
+
"schemaVersion": 1,
|
|
61
|
+
"app": {
|
|
62
|
+
"name": "{{APP_NAME}}",
|
|
63
|
+
"version": "1.0.0",
|
|
64
|
+
},
|
|
65
|
+
"landingPages": [
|
|
66
|
+
{
|
|
67
|
+
"id": "overview",
|
|
68
|
+
"path": "/",
|
|
69
|
+
"title": "Organisations",
|
|
70
|
+
"description": "Browse Sensolus organisations",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"id": "organisation-detail",
|
|
74
|
+
"path": "/organisation/{orgId}",
|
|
75
|
+
"title": "Organisation detail",
|
|
76
|
+
"context": "org",
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
"secrets": {
|
|
80
|
+
"reverseGeocoding": True,
|
|
81
|
+
},
|
|
82
|
+
"database": True,
|
|
83
|
+
"cron": [
|
|
84
|
+
{
|
|
85
|
+
"id": "collect-org-stats",
|
|
86
|
+
"path": "/cron/collect-org-stats",
|
|
87
|
+
"method": "POST",
|
|
88
|
+
"description": "Snapshots tracker and user counts per organisation (max one point per day per org)",
|
|
89
|
+
"schedule": "0 3 * * *",
|
|
90
|
+
"timezone": "Europe/Brussels",
|
|
91
|
+
"timeoutSeconds": 120,
|
|
92
|
+
"apiKey": {
|
|
93
|
+
"type": "ro",
|
|
94
|
+
"scope": "system",
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _mask(value):
|
|
102
|
+
"""Mask a secret for logging — show length and first/last 2 chars only."""
|
|
103
|
+
if not value:
|
|
104
|
+
return "<empty>"
|
|
105
|
+
if len(value) <= 4:
|
|
106
|
+
return f"<len={len(value)} value=***>"
|
|
107
|
+
return f"<len={len(value)} {value[:2]}...{value[-2:]}>"
|
|
24
108
|
|
|
25
109
|
|
|
26
|
-
def
|
|
27
|
-
"""
|
|
110
|
+
def _deny(reason, **fields):
|
|
111
|
+
"""Log a manager-auth denial and also print to stderr so it cannot be
|
|
112
|
+
silently swallowed by a misconfigured logging chain."""
|
|
113
|
+
parts = " ".join(f"{k}={v}" for k, v in fields.items())
|
|
114
|
+
msg = f"Manager auth denied: {reason} {parts}".strip()
|
|
115
|
+
logger.warning(msg)
|
|
116
|
+
print(f"[manager-auth] {msg}", file=sys.stderr, flush=True)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def require_manager_auth():
|
|
120
|
+
"""Return a 401 response if the request lacks a valid manager auth key."""
|
|
121
|
+
presented = request.headers.get(HEADER_MANAGER_AUTH)
|
|
122
|
+
common = dict(
|
|
123
|
+
method=request.method,
|
|
124
|
+
path=request.path,
|
|
125
|
+
remote=request.remote_addr,
|
|
126
|
+
presented=_mask(presented),
|
|
127
|
+
)
|
|
128
|
+
if not MANAGER_AUTH_KEY:
|
|
129
|
+
_deny("MANAGER_AUTH_KEY env var is not set", **common)
|
|
130
|
+
return jsonify({"error": "Manager auth not configured"}), 401
|
|
131
|
+
if presented is None:
|
|
132
|
+
_deny(
|
|
133
|
+
f"{HEADER_MANAGER_AUTH} header missing",
|
|
134
|
+
expected=_mask(MANAGER_AUTH_KEY),
|
|
135
|
+
headers=",".join(request.headers.keys()),
|
|
136
|
+
**common,
|
|
137
|
+
)
|
|
138
|
+
return jsonify({"error": "Unauthorized"}), 401
|
|
139
|
+
if presented != MANAGER_AUTH_KEY:
|
|
140
|
+
_deny(
|
|
141
|
+
f"{HEADER_MANAGER_AUTH} value mismatch",
|
|
142
|
+
expected=_mask(MANAGER_AUTH_KEY),
|
|
143
|
+
**common,
|
|
144
|
+
)
|
|
145
|
+
return jsonify({"error": "Unauthorized"}), 401
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@app.route('/.well-known/sensolus-app')
|
|
150
|
+
def app_descriptor():
|
|
151
|
+
unauthorized = require_manager_auth()
|
|
152
|
+
if unauthorized:
|
|
153
|
+
return unauthorized
|
|
154
|
+
return jsonify(APP_DESCRIPTOR)
|
|
155
|
+
|
|
156
|
+
# --- Background scheduler ---
|
|
157
|
+
scheduler = APScheduler()
|
|
158
|
+
scheduler.init_app(app)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@scheduler.task('interval', id='heartbeat', minutes=10)
|
|
162
|
+
def heartbeat():
|
|
163
|
+
logger.info("Scheduled heartbeat: the background scheduler is alive and well.")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_auth_headers_and_params():
|
|
167
|
+
"""
|
|
168
|
+
Get authentication headers and params for Sensolus API calls.
|
|
169
|
+
|
|
170
|
+
Priority:
|
|
171
|
+
1. Session cookie (SENSOLUS_COOKIE_NAME) -> passed as Bearer token
|
|
172
|
+
2. API key from query parameter -> passed as apiKey query param
|
|
173
|
+
3. Stored API key from session -> passed as apiKey query param
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
tuple: (headers dict, params dict, error message or None)
|
|
177
|
+
"""
|
|
178
|
+
headers = {}
|
|
179
|
+
params = {}
|
|
180
|
+
|
|
181
|
+
# Check for session cookie first - pass as Bearer token
|
|
28
182
|
token = request.cookies.get(SENSOLUS_COOKIE_NAME)
|
|
29
183
|
if token:
|
|
30
|
-
headers[
|
|
31
|
-
|
|
32
|
-
params
|
|
33
|
-
|
|
184
|
+
headers['Authorization'] = f'Bearer {token}'
|
|
185
|
+
logger.info("Using session cookie as Bearer token for authentication")
|
|
186
|
+
return headers, params, None
|
|
187
|
+
|
|
188
|
+
# Check for API key in query parameter
|
|
189
|
+
api_key = request.args.get('apiKey')
|
|
190
|
+
if api_key:
|
|
191
|
+
# Store API key in session for future requests
|
|
192
|
+
session['api_key'] = api_key
|
|
193
|
+
params['apiKey'] = api_key
|
|
194
|
+
logger.info("Using API key from query parameter")
|
|
195
|
+
return headers, params, None
|
|
34
196
|
|
|
197
|
+
# Check for useCookie flag without actual cookie
|
|
198
|
+
if request.args.get('useCookie') == 'true':
|
|
199
|
+
logger.warning("useCookie=true but no session token found")
|
|
200
|
+
return None, None, "No session token found in cookies"
|
|
201
|
+
|
|
202
|
+
# Try to use stored API key from session
|
|
203
|
+
stored_api_key = session.get('api_key')
|
|
204
|
+
if stored_api_key:
|
|
205
|
+
params['apiKey'] = stored_api_key
|
|
206
|
+
logger.info("Using stored API key from session")
|
|
207
|
+
return headers, params, None
|
|
208
|
+
|
|
209
|
+
logger.warning("No authentication method available")
|
|
210
|
+
return None, None, "API key is required"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def make_sensolus_request(endpoint, extra_params=None, method='GET', json_body=None):
|
|
214
|
+
"""
|
|
215
|
+
Make an authenticated request to the Sensolus API.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
endpoint: API endpoint (e.g., '/organisations')
|
|
219
|
+
extra_params: Additional query parameters
|
|
220
|
+
method: HTTP method ('GET' or 'POST')
|
|
221
|
+
json_body: JSON body for POST requests
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
tuple: (response_data, status_code)
|
|
225
|
+
"""
|
|
226
|
+
headers, params, error = get_auth_headers_and_params()
|
|
227
|
+
|
|
228
|
+
if error:
|
|
229
|
+
return {"error": error}, 401 if "session token" in error else 400
|
|
230
|
+
|
|
231
|
+
if extra_params:
|
|
232
|
+
params.update(extra_params)
|
|
233
|
+
|
|
234
|
+
url = f"{SENSOLUS_BASE_URL}{endpoint}"
|
|
235
|
+
|
|
236
|
+
# Log the request (mask sensitive data)
|
|
237
|
+
log_params = {k: '***' if k == 'apiKey' else v for k, v in params.items()}
|
|
238
|
+
log_headers = {k: '***' if k == 'Authorization' else v for k, v in headers.items()}
|
|
239
|
+
logger.info(f"API Request: {method} {url}")
|
|
240
|
+
logger.info(f" Headers: {log_headers}")
|
|
241
|
+
logger.info(f" Params: {log_params}")
|
|
242
|
+
if json_body:
|
|
243
|
+
logger.info(f" Body: {json_body}")
|
|
35
244
|
|
|
36
|
-
@app.route("/api/loginInfo")
|
|
37
|
-
def login_info():
|
|
38
|
-
"""User preferences for the kit's LocaleProvider (language, timezone, units)."""
|
|
39
|
-
params, headers = _auth({}, {})
|
|
40
245
|
try:
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
246
|
+
if method == 'POST':
|
|
247
|
+
headers['Content-Type'] = 'application/json'
|
|
248
|
+
response = requests.post(
|
|
249
|
+
url,
|
|
250
|
+
params=params,
|
|
251
|
+
headers=headers,
|
|
252
|
+
json=json_body,
|
|
253
|
+
timeout=30
|
|
254
|
+
)
|
|
255
|
+
else:
|
|
256
|
+
response = requests.get(
|
|
257
|
+
url,
|
|
258
|
+
params=params,
|
|
259
|
+
headers=headers,
|
|
260
|
+
timeout=30
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
logger.info(f"API Response: {response.status_code} from {endpoint}")
|
|
264
|
+
|
|
265
|
+
if response.status_code >= 400:
|
|
266
|
+
logger.error(f"API Error: {response.status_code} - {response.text[:500]}")
|
|
267
|
+
|
|
268
|
+
response.raise_for_status()
|
|
269
|
+
return response.json(), 200
|
|
270
|
+
|
|
271
|
+
except requests.exceptions.HTTPError as e:
|
|
272
|
+
error_msg = f"API error: {e.response.status_code}"
|
|
273
|
+
if e.response.text:
|
|
274
|
+
try:
|
|
275
|
+
error_detail = e.response.json()
|
|
276
|
+
error_msg = f"{error_msg} - {error_detail.get('message', e.response.text[:200])}"
|
|
277
|
+
except Exception:
|
|
278
|
+
error_msg = f"{error_msg} - {e.response.text[:200]}"
|
|
279
|
+
logger.error(f"HTTP Error on {endpoint}: {error_msg}")
|
|
280
|
+
return {"error": error_msg}, e.response.status_code
|
|
281
|
+
|
|
282
|
+
except requests.exceptions.RequestException as e:
|
|
283
|
+
logger.error(f"Request Exception on {endpoint}: {str(e)}")
|
|
284
|
+
return {"error": str(e)}, 500
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@app.route('/')
|
|
288
|
+
def index():
|
|
289
|
+
return send_from_directory(app.static_folder, 'index.html')
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@app.route('/assets/<path:filename>')
|
|
293
|
+
def serve_assets(filename):
|
|
294
|
+
"""Serve Vite build assets."""
|
|
295
|
+
return send_from_directory(os.path.join(app.static_folder, 'assets'), filename)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@app.route('/api/auth/check')
|
|
299
|
+
def check_auth():
|
|
300
|
+
"""Check if the user has a valid session cookie or stored API key."""
|
|
301
|
+
token = request.cookies.get(SENSOLUS_COOKIE_NAME)
|
|
302
|
+
stored_api_key = session.get('api_key')
|
|
303
|
+
logger.info(f"Auth check: hasSessionToken={token is not None}, hasStoredApiKey={stored_api_key is not None}")
|
|
304
|
+
return jsonify({
|
|
305
|
+
"hasSessionToken": token is not None,
|
|
306
|
+
"hasStoredApiKey": stored_api_key is not None
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@app.route('/api/auth/api-key', methods=['POST'])
|
|
311
|
+
def set_api_key():
|
|
312
|
+
"""Validate and save an API key in the session.
|
|
313
|
+
|
|
314
|
+
Validates against /loginInfo so an invalid key is rejected before it gets stored.
|
|
315
|
+
"""
|
|
316
|
+
body = request.get_json(silent=True) or {}
|
|
317
|
+
api_key = (body.get('apiKey') or '').strip()
|
|
318
|
+
if not api_key:
|
|
319
|
+
return jsonify({"error": "API key is required"}), 400
|
|
320
|
+
|
|
53
321
|
try:
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
322
|
+
response = requests.get(
|
|
323
|
+
f"{SENSOLUS_BASE_URL}/loginInfo",
|
|
324
|
+
params={'apiKey': api_key},
|
|
325
|
+
timeout=10,
|
|
326
|
+
)
|
|
327
|
+
except requests.exceptions.RequestException as e:
|
|
328
|
+
logger.error(f"API key validation request failed: {e}")
|
|
329
|
+
return jsonify({"error": f"Validation failed: {e}"}), 502
|
|
330
|
+
|
|
331
|
+
if response.status_code in (401, 403):
|
|
332
|
+
return jsonify({"error": "Invalid API key"}), 401
|
|
333
|
+
if response.status_code >= 400:
|
|
334
|
+
return jsonify({"error": f"Validation failed: HTTP {response.status_code}"}), 502
|
|
58
335
|
|
|
336
|
+
session['api_key'] = api_key
|
|
337
|
+
# Invalidate cached user identity — it'll be re-resolved on next favourites call
|
|
338
|
+
session.pop('user_key', None)
|
|
339
|
+
logger.info("API key validated and stored in session")
|
|
340
|
+
return jsonify({"ok": True})
|
|
59
341
|
|
|
60
|
-
# --- Serve built frontend (production) ---------------------------------------
|
|
61
|
-
@app.route("/", defaults={"path": ""})
|
|
62
|
-
@app.route("/<path:path>")
|
|
63
|
-
def serve_frontend(path):
|
|
64
|
-
if path and os.path.exists(os.path.join(app.static_folder, path)):
|
|
65
|
-
return send_from_directory(app.static_folder, path)
|
|
66
|
-
return send_from_directory(app.static_folder, "index.html")
|
|
67
342
|
|
|
343
|
+
@app.route('/api/auth/api-key', methods=['DELETE'])
|
|
344
|
+
def clear_api_key():
|
|
345
|
+
"""Remove the stored API key from the session."""
|
|
346
|
+
session.pop('api_key', None)
|
|
347
|
+
session.pop('user_key', None)
|
|
348
|
+
return jsonify({"ok": True})
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@app.route('/api/loginInfo')
|
|
352
|
+
def get_login_info():
|
|
353
|
+
"""Get user login info and preferences (language, timezone, units)."""
|
|
354
|
+
logger.info("=== GET /api/loginInfo ===")
|
|
355
|
+
data, status_code = make_sensolus_request('/loginInfo')
|
|
356
|
+
return jsonify(data), status_code
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@app.route('/api/organisations')
|
|
360
|
+
def get_organisations():
|
|
361
|
+
"""Get list of organisations."""
|
|
362
|
+
logger.info("=== GET /api/organisations ===")
|
|
363
|
+
|
|
364
|
+
extra_params = {}
|
|
365
|
+
name_filter = request.args.get('nameFilter', '')
|
|
366
|
+
if name_filter:
|
|
367
|
+
extra_params['nameFilter'] = name_filter
|
|
368
|
+
|
|
369
|
+
data, status_code = make_sensolus_request('/organisations', extra_params)
|
|
370
|
+
return jsonify(data), status_code
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@app.route('/api/geozones')
|
|
374
|
+
def get_geozones():
|
|
375
|
+
"""Get all geozones for an organisation."""
|
|
376
|
+
logger.info("=== GET /api/geozones ===")
|
|
377
|
+
|
|
378
|
+
extra_params = {}
|
|
379
|
+
org_id = request.args.get('orgId')
|
|
380
|
+
if org_id:
|
|
381
|
+
extra_params['orgId'] = org_id
|
|
382
|
+
|
|
383
|
+
data, status_code = make_sensolus_request('/geozones', extra_params)
|
|
384
|
+
return jsonify(data), status_code
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@app.route('/api/devices/byFilter', methods=['POST'])
|
|
388
|
+
def get_devices_by_filter():
|
|
389
|
+
"""Get devices by filter (e.g. organisation filter)."""
|
|
390
|
+
logger.info("=== POST /api/devices/byFilter ===")
|
|
391
|
+
|
|
392
|
+
extra_params = {}
|
|
393
|
+
for param in ['fields']:
|
|
394
|
+
val = request.args.get(param)
|
|
395
|
+
if val is not None:
|
|
396
|
+
extra_params[param] = val
|
|
397
|
+
|
|
398
|
+
body = request.get_json()
|
|
399
|
+
data, status_code = make_sensolus_request('/devices/byFilter', extra_params, method='POST', json_body=body)
|
|
400
|
+
return jsonify(data), status_code
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
LOCATIONIQ_KEY = os.environ.get('LOCATIONIQ_KEY', '')
|
|
404
|
+
MAPBOX_KEY = os.environ.get('MAPBOX_KEY', '')
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
@app.route('/api/config')
|
|
408
|
+
def app_config():
|
|
409
|
+
"""Runtime config served to the frontend. Lets one Docker image deploy
|
|
410
|
+
across dev/demo/prod by reading keys from the container's env at request
|
|
411
|
+
time instead of baking them into the bundle at build time."""
|
|
412
|
+
return jsonify({
|
|
413
|
+
'mapboxKey': MAPBOX_KEY,
|
|
414
|
+
'locationiqKey': LOCATIONIQ_KEY,
|
|
415
|
+
}), 200
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@app.route('/api/geocode')
|
|
419
|
+
def geocode():
|
|
420
|
+
"""Proxy LocationIQ autocomplete to avoid CORS/domain restrictions."""
|
|
421
|
+
q = request.args.get('q', '')
|
|
422
|
+
if not q:
|
|
423
|
+
return jsonify([]), 200
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
response = requests.get(
|
|
427
|
+
'https://eu1.locationiq.com/v1/autocomplete.php',
|
|
428
|
+
params={'key': LOCATIONIQ_KEY, 'q': q},
|
|
429
|
+
timeout=10
|
|
430
|
+
)
|
|
431
|
+
response.raise_for_status()
|
|
432
|
+
return jsonify(response.json()), 200
|
|
433
|
+
except requests.exceptions.RequestException as e:
|
|
434
|
+
logger.error(f"Geocode error: {e}")
|
|
435
|
+
return jsonify([]), 200
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@app.route('/api/reverse-geocode')
|
|
439
|
+
def reverse_geocode():
|
|
440
|
+
"""Proxy LocationIQ reverse geocoding to avoid CORS/domain restrictions."""
|
|
441
|
+
lat = request.args.get('lat', '')
|
|
442
|
+
lng = request.args.get('lng', '')
|
|
443
|
+
if not lat or not lng:
|
|
444
|
+
return jsonify({'error': 'lat and lng are required'}), 400
|
|
445
|
+
|
|
446
|
+
try:
|
|
447
|
+
response = requests.get(
|
|
448
|
+
'https://eu1.locationiq.com/v1/reverse',
|
|
449
|
+
params={'key': LOCATIONIQ_KEY, 'lat': lat, 'lon': lng, 'format': 'json'},
|
|
450
|
+
timeout=10
|
|
451
|
+
)
|
|
452
|
+
response.raise_for_status()
|
|
453
|
+
return jsonify(response.json()), 200
|
|
454
|
+
except requests.exceptions.RequestException as e:
|
|
455
|
+
logger.error(f"Reverse geocode error: {e}")
|
|
456
|
+
return jsonify({'error': 'Reverse geocode failed'}), 502
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _get_user_key():
|
|
460
|
+
"""
|
|
461
|
+
Resolve a stable user key for favourites.
|
|
462
|
+
Uses cached loginInfo from session. If not cached yet, fetches it.
|
|
463
|
+
Priority: email > apiKeyName > hashed API key.
|
|
464
|
+
"""
|
|
465
|
+
# Check if we already resolved and cached it
|
|
466
|
+
cached = session.get('user_key')
|
|
467
|
+
if cached:
|
|
468
|
+
logger.debug(f"User key from cache: {cached}")
|
|
469
|
+
return cached
|
|
470
|
+
|
|
471
|
+
# Fetch loginInfo from Sensolus API
|
|
472
|
+
logger.info("Resolving user key from loginInfo...")
|
|
473
|
+
data, status = make_sensolus_request('/loginInfo')
|
|
474
|
+
if status == 200:
|
|
475
|
+
user_key = data.get('email') or data.get('username')
|
|
476
|
+
if not user_key:
|
|
477
|
+
# API key auth — use the key name if available
|
|
478
|
+
user_key = data.get('apiKeyName')
|
|
479
|
+
if user_key:
|
|
480
|
+
logger.info(f"Resolved user key: {user_key}")
|
|
481
|
+
session['user_key'] = user_key
|
|
482
|
+
return user_key
|
|
483
|
+
logger.warning(f"loginInfo returned 200 but no user identifier found: {list(data.keys())}")
|
|
484
|
+
else:
|
|
485
|
+
logger.warning(f"loginInfo returned status {status}: {data}")
|
|
486
|
+
|
|
487
|
+
# Last resort: use the stored API key itself (hashed)
|
|
488
|
+
import hashlib
|
|
489
|
+
api_key = session.get('api_key', '')
|
|
490
|
+
if api_key:
|
|
491
|
+
user_key = f"apikey:{hashlib.sha256(api_key.encode()).hexdigest()[:16]}"
|
|
492
|
+
logger.info(f"Falling back to hashed API key: {user_key}")
|
|
493
|
+
session['user_key'] = user_key
|
|
494
|
+
return user_key
|
|
495
|
+
|
|
496
|
+
logger.warning("Could not resolve user key — no auth available")
|
|
497
|
+
return None
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@app.route('/api/favourites')
|
|
501
|
+
def get_favourites():
|
|
502
|
+
"""Get all favourite organisation IDs for the current user."""
|
|
503
|
+
logger.info("=== GET /api/favourites ===")
|
|
504
|
+
try:
|
|
505
|
+
from models import FavouriteOrganisation
|
|
506
|
+
user_key = _get_user_key()
|
|
507
|
+
if not user_key:
|
|
508
|
+
logger.info("No user key — returning empty favourites")
|
|
509
|
+
return jsonify([])
|
|
510
|
+
favs = FavouriteOrganisation.query.filter_by(user_key=user_key).all()
|
|
511
|
+
logger.info(f"Returning {len(favs)} favourites for {user_key}")
|
|
512
|
+
return jsonify([f.org_id for f in favs])
|
|
513
|
+
except Exception as e:
|
|
514
|
+
logger.exception(f"Error in get_favourites: {e}")
|
|
515
|
+
return jsonify({'error': str(e)}), 500
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
@app.route('/api/favourites/<int:org_id>', methods=['PUT'])
|
|
519
|
+
def add_favourite(org_id):
|
|
520
|
+
"""Add an organisation to favourites for the current user."""
|
|
521
|
+
logger.info(f"=== PUT /api/favourites/{org_id} ===")
|
|
522
|
+
try:
|
|
523
|
+
from models import FavouriteOrganisation
|
|
524
|
+
user_key = _get_user_key()
|
|
525
|
+
if not user_key:
|
|
526
|
+
logger.warning("Cannot add favourite — no user key")
|
|
527
|
+
return jsonify({'error': 'Not authenticated'}), 401
|
|
528
|
+
existing = FavouriteOrganisation.query.filter_by(user_key=user_key, org_id=org_id).first()
|
|
529
|
+
if not existing:
|
|
530
|
+
fav = FavouriteOrganisation(user_key=user_key, org_id=org_id)
|
|
531
|
+
db.session.add(fav)
|
|
532
|
+
db.session.commit()
|
|
533
|
+
logger.info(f"Added favourite org {org_id} for {user_key}")
|
|
534
|
+
else:
|
|
535
|
+
logger.info(f"Favourite org {org_id} already exists for {user_key}")
|
|
536
|
+
return jsonify({'orgId': org_id, 'favourite': True})
|
|
537
|
+
except Exception as e:
|
|
538
|
+
db.session.rollback()
|
|
539
|
+
logger.exception(f"Error in add_favourite: {e}")
|
|
540
|
+
return jsonify({'error': str(e)}), 500
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
@app.route('/api/favourites/<int:org_id>', methods=['DELETE'])
|
|
544
|
+
def remove_favourite(org_id):
|
|
545
|
+
"""Remove an organisation from favourites for the current user."""
|
|
546
|
+
logger.info(f"=== DELETE /api/favourites/{org_id} ===")
|
|
547
|
+
try:
|
|
548
|
+
from models import FavouriteOrganisation
|
|
549
|
+
user_key = _get_user_key()
|
|
550
|
+
if not user_key:
|
|
551
|
+
logger.warning("Cannot remove favourite — no user key")
|
|
552
|
+
return jsonify({'error': 'Not authenticated'}), 401
|
|
553
|
+
fav = FavouriteOrganisation.query.filter_by(user_key=user_key, org_id=org_id).first()
|
|
554
|
+
if fav:
|
|
555
|
+
db.session.delete(fav)
|
|
556
|
+
db.session.commit()
|
|
557
|
+
logger.info(f"Removed favourite org {org_id} for {user_key}")
|
|
558
|
+
else:
|
|
559
|
+
logger.info(f"Favourite org {org_id} not found for {user_key}")
|
|
560
|
+
return jsonify({'orgId': org_id, 'favourite': False})
|
|
561
|
+
except Exception as e:
|
|
562
|
+
db.session.rollback()
|
|
563
|
+
logger.exception(f"Error in remove_favourite: {e}")
|
|
564
|
+
return jsonify({'error': str(e)}), 500
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
@app.route('/cron/collect-org-stats', methods=['POST'])
|
|
568
|
+
def collect_org_stats():
|
|
569
|
+
"""Snapshot tracker and user counts for every org reachable via the manager-provided API key.
|
|
570
|
+
|
|
571
|
+
Idempotent within a day: re-running overwrites the same (org_id, snapshot_date) row.
|
|
572
|
+
"""
|
|
573
|
+
unauthorized = require_manager_auth()
|
|
574
|
+
if unauthorized:
|
|
575
|
+
return unauthorized
|
|
576
|
+
|
|
577
|
+
api_key = request.headers.get(HEADER_SENSOLUS_AUTH)
|
|
578
|
+
if not api_key:
|
|
579
|
+
return jsonify({"error": f"Missing {HEADER_SENSOLUS_AUTH} header"}), 400
|
|
580
|
+
|
|
581
|
+
from datetime import date
|
|
582
|
+
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
583
|
+
from models import OrgDailyStat
|
|
584
|
+
|
|
585
|
+
logger.info("=== POST /cron/collect-org-stats ===")
|
|
586
|
+
try:
|
|
587
|
+
response = requests.get(
|
|
588
|
+
f"{SENSOLUS_BASE_URL}/organisations",
|
|
589
|
+
params={'apiKey': api_key},
|
|
590
|
+
timeout=60,
|
|
591
|
+
)
|
|
592
|
+
response.raise_for_status()
|
|
593
|
+
orgs = response.json()
|
|
594
|
+
except requests.exceptions.RequestException as e:
|
|
595
|
+
logger.exception(f"Failed to fetch organisations: {e}")
|
|
596
|
+
return jsonify({"error": f"Failed to fetch organisations: {e}"}), 502
|
|
597
|
+
|
|
598
|
+
today = date.today()
|
|
599
|
+
count = 0
|
|
600
|
+
for org in orgs:
|
|
601
|
+
metrics = (org.get('statistics') or {}).get('metrics') or {}
|
|
602
|
+
stmt = pg_insert(OrgDailyStat.__table__).values(
|
|
603
|
+
org_id=org['id'],
|
|
604
|
+
org_name=org.get('name'),
|
|
605
|
+
snapshot_date=today,
|
|
606
|
+
tracker_count=int(metrics.get('NUMBER_OF_TRACKERS') or 0),
|
|
607
|
+
user_count=int(metrics.get('NUMBER_OF_USERS') or 0),
|
|
608
|
+
).on_conflict_do_update(
|
|
609
|
+
constraint='uq_org_day',
|
|
610
|
+
set_={
|
|
611
|
+
'org_name': org.get('name'),
|
|
612
|
+
'tracker_count': int(metrics.get('NUMBER_OF_TRACKERS') or 0),
|
|
613
|
+
'user_count': int(metrics.get('NUMBER_OF_USERS') or 0),
|
|
614
|
+
'captured_at': datetime.now(timezone.utc),
|
|
615
|
+
},
|
|
616
|
+
)
|
|
617
|
+
db.session.execute(stmt)
|
|
618
|
+
count += 1
|
|
619
|
+
db.session.commit()
|
|
620
|
+
logger.info(f"Snapshotted {count} orgs for {today.isoformat()}")
|
|
621
|
+
return jsonify({"snapshotDate": today.isoformat(), "orgsSnapshotted": count})
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
@app.route('/api/org-stats/totals')
|
|
625
|
+
def get_org_stats_totals():
|
|
626
|
+
"""Daily totals across all orgs: org count, total trackers, total users."""
|
|
627
|
+
logger.info("=== GET /api/org-stats/totals ===")
|
|
628
|
+
from models import OrgDailyStat
|
|
629
|
+
rows = (
|
|
630
|
+
db.session.query(
|
|
631
|
+
OrgDailyStat.snapshot_date,
|
|
632
|
+
db.func.count(OrgDailyStat.id),
|
|
633
|
+
db.func.sum(OrgDailyStat.tracker_count),
|
|
634
|
+
db.func.sum(OrgDailyStat.user_count),
|
|
635
|
+
)
|
|
636
|
+
.group_by(OrgDailyStat.snapshot_date)
|
|
637
|
+
.order_by(OrgDailyStat.snapshot_date)
|
|
638
|
+
.all()
|
|
639
|
+
)
|
|
640
|
+
return jsonify([
|
|
641
|
+
{
|
|
642
|
+
'date': d.isoformat(),
|
|
643
|
+
'orgCount': int(orgs or 0),
|
|
644
|
+
'trackerTotal': int(trackers or 0),
|
|
645
|
+
'userTotal': int(users or 0),
|
|
646
|
+
}
|
|
647
|
+
for d, orgs, trackers, users in rows
|
|
648
|
+
])
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
if __name__ == '__main__':
|
|
652
|
+
# Run DB init (single process, no race)
|
|
653
|
+
from init_db import ensure_database, run_migrations
|
|
654
|
+
try:
|
|
655
|
+
ensure_database()
|
|
656
|
+
run_migrations()
|
|
657
|
+
except Exception as e:
|
|
658
|
+
logger.warning(f"DB init skipped: {e}")
|
|
68
659
|
|
|
69
|
-
|
|
70
|
-
|
|
660
|
+
scheduler.start()
|
|
661
|
+
logger.info("Starting server on port 5000 (scheduler active)")
|
|
662
|
+
socketio.run(app, debug=True, port=5000, host='0.0.0.0',
|
|
663
|
+
allow_unsafe_werkzeug=True, use_reloader=False)
|