@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.
Files changed (44) hide show
  1. package/index.js +2 -1
  2. package/package.json +1 -1
  3. package/template/CLAUDE.md +218 -0
  4. package/template/Dockerfile +32 -0
  5. package/template/Jenkinsfile +28 -0
  6. package/template/README.md +493 -16
  7. package/template/_env.example +14 -0
  8. package/template/backend/app.py +642 -49
  9. package/template/backend/db_config.py +16 -0
  10. package/template/backend/extensions.py +3 -0
  11. package/template/backend/init_db.py +75 -0
  12. package/template/backend/migrations/README +1 -0
  13. package/template/backend/migrations/alembic.ini +50 -0
  14. package/template/backend/migrations/env.py +113 -0
  15. package/template/backend/migrations/script.py.mako +24 -0
  16. package/template/backend/migrations/versions/001_add_favourite_organisations.py +31 -0
  17. package/template/backend/migrations/versions/002_add_org_daily_stats.py +36 -0
  18. package/template/backend/models.py +31 -0
  19. package/template/backend/requirements.txt +8 -2
  20. package/template/eslint.config.js +6 -2
  21. package/template/index.html +11 -8
  22. package/template/infra/docker-compose.yml +15 -0
  23. package/template/openapi.json +40357 -0
  24. package/template/package.json +8 -1
  25. package/template/scripts/create-ecr-repo.sh +42 -0
  26. package/template/src/App.jsx +30 -33
  27. package/template/src/AppConfigContext.jsx +45 -0
  28. package/template/src/hooks/useFavourites.js +44 -0
  29. package/template/src/i18n/index.js +3 -0
  30. package/template/src/i18n/messages.js +8 -14
  31. package/template/src/i18n/translations/de.js +72 -0
  32. package/template/src/i18n/translations/en.js +79 -0
  33. package/template/src/i18n/translations/es.js +72 -0
  34. package/template/src/i18n/translations/fr.js +72 -0
  35. package/template/src/i18n/translations/nl.js +72 -0
  36. package/template/src/main.jsx +2 -6
  37. package/template/src/pages/Home.jsx +170 -0
  38. package/template/src/pages/OrganisationDetail.jsx +263 -0
  39. package/template/src/pages/OrganisationList.jsx +457 -0
  40. package/template/src/pages/Overview.jsx +199 -0
  41. package/template/src/pages/WidgetShowcase.jsx +522 -0
  42. package/template/src/styles/app.css +543 -4
  43. package/template/start-backend.sh +4 -0
  44. package/template/start-frontend.sh +3 -0
@@ -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 flask import Flask, jsonify, request, send_from_directory
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
- app = Flask(__name__, static_folder=os.path.join(PROJECT_ROOT, "dist"))
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 _auth(params, headers):
27
- """Apply Sensolus auth to outgoing request parts. Cookie beats API key."""
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["Authorization"] = f"Bearer {token}"
31
- elif SENSOLUS_API_KEY:
32
- params["apiKey"] = SENSOLUS_API_KEY
33
- return params, headers
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
- resp = requests.get(f"{SENSOLUS_BASE_URL}/loginInfo", params=params, headers=headers, timeout=10)
42
- if resp.ok:
43
- return jsonify(resp.json())
44
- except requests.RequestException:
45
- pass
46
- return jsonify({}) # kit falls back to defaults
47
-
48
-
49
- @app.route("/api/<path:subpath>")
50
- def api_proxy(subpath):
51
- """Generic GET proxy to the Sensolus API."""
52
- params, headers = _auth(dict(request.args), {})
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
- resp = requests.get(f"{SENSOLUS_BASE_URL}/{subpath}", params=params, headers=headers, timeout=30)
55
- return (resp.content, resp.status_code, {"Content-Type": resp.headers.get("Content-Type", "application/json")})
56
- except requests.RequestException as exc:
57
- return jsonify({"error": str(exc)}), 502
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
- if __name__ == "__main__":
70
- app.run(host="0.0.0.0", port=5000, debug=True)
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)