diff --git a/README.md b/README.md index fd6694d..bfc67d2 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,8 @@ also require a valid session cookie. Cross-origin access is not enabled. Report summaries load attachment-presence flags and batch asset names; photo bytes are loaded only when an authorized user opens the attachment. +Asset lists and CSV exports batch open-report severity totals, so their database +query count stays constant as the asset register grows. Uploads accept actual JPEG, PNG, or WebP images up to 3 MB. Images are decoded, validated, resized, and re-encoded as JPEG without original metadata. A submitted diff --git a/main.py b/main.py index f4e7244..15ddfea 100644 --- a/main.py +++ b/main.py @@ -164,14 +164,15 @@ def activity(db, account, kind, message, asset=None): ) -def asset_score(db, account, asset): - severity = ( - db.query(func.coalesce(func.sum(dbm.Report.severity), 0)) - .filter_by(workspace_id=account.id, asset_id=asset.id, status="Open") - .scalar() - ) +def asset_score(db, account, asset, *, open_severity=None): + if open_severity is None: + open_severity = ( + db.query(func.coalesce(func.sum(dbm.Report.severity), 0)) + .filter_by(workspace_id=account.id, asset_id=asset.id, status="Open") + .scalar() + ) flood = 15 if account.flood_active and asset.asset_type == "Road" else 0 - return round(max(0.0, asset.condition_score - severity * 1.5 - flood), 1) + return round(max(0.0, asset.condition_score - open_severity * 1.5 - flood), 1) def owned_asset(db, account, asset_id, include_archived=False): @@ -186,8 +187,8 @@ def owned_asset(db, account, asset_id, include_archived=False): return asset -def asset_data(db, account, asset): - score = asset_score(db, account, asset) +def asset_data(db, account, asset, *, open_severity=None): + score = asset_score(db, account, asset, open_severity=open_severity) return { "id": asset.id, "name": asset.name, @@ -205,6 +206,22 @@ def asset_data(db, account, asset): } +def asset_register(db, account, archived=False): + """Load the register and its open-report totals in two queries.""" + totals = dict( + db.query(dbm.Report.asset_id, func.sum(dbm.Report.severity)) + .filter_by(workspace_id=account.id, status="Open") + .group_by(dbm.Report.asset_id) + .all() + ) + return [ + asset_data(db, account, asset, open_severity=totals.get(asset.id, 0)) + for asset in db.query(dbm.Asset) + .filter_by(workspace_id=account.id, archived=archived) + .order_by(dbm.Asset.id) + ] + + def iso(value): return value.isoformat() + "Z" if value else None @@ -306,12 +323,7 @@ def logout(request: Request, db: Session = Depends(get_db)): @app.get("/assets", include_in_schema=False) @app.get("/api/assets") def assets(archived: bool = False, account=Depends(workspace), db: Session = Depends(get_db)): - return [ - asset_data(db, account, a) - for a in db.query(dbm.Asset) - .filter_by(workspace_id=account.id, archived=archived) - .order_by(dbm.Asset.id) - ] + return asset_register(db, account, archived=archived) @app.post("/assets", include_in_schema=False) @@ -638,19 +650,18 @@ def export(account=Depends(workspace), db: Session = Depends(get_db)): "Last maintenance", ] ) - for asset in db.query(dbm.Asset).filter_by(workspace_id=account.id, archived=False): - data = asset_data(db, account, asset) - name = asset.name + for data in asset_register(db, account): + name = data["name"] if name.lstrip().startswith(("=", "+", "-", "@")) or name.startswith(("\t", "\r", "\n")): name = "'" + name writer.writerow( [ - asset.id, + data["id"], name, - asset.asset_type, - asset.construction_year, - asset.latitude, - asset.longitude, + data["asset_type"], + data["construction_year"], + data["latitude"], + data["longitude"], data["health_score"], data["maintenance_priority"], data["last_service_at"] or "", diff --git a/tests/test_api.py b/tests/test_api.py index 908ed9e..5f49807 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -316,6 +316,73 @@ def test_export_is_scoped_and_protects_spreadsheet_formulas(self): self.assertTrue(rows[-1][1].startswith("'=")) self.assertNotIn(self.other_account["share_token"], response.text) + def test_asset_list_and_export_query_counts_do_not_grow_with_register(self): + from sqlalchemy import event + + def query_count(endpoint): + statements = [] + + def capture(_conn, _cursor, statement, _params, _context, _many): + if statement.lstrip().upper().startswith("SELECT"): + statements.append(statement) + + event.listen(database.engine, "before_cursor_execute", capture) + try: + response = self.client.get(endpoint) + finally: + event.remove(database.engine, "before_cursor_execute", capture) + self.assertEqual(response.status_code, 200, response.text) + return len(statements) + + endpoints = ["/api/assets", "/api/export"] + before = {endpoint: query_count(endpoint) for endpoint in endpoints} + for index in range(12): + response = self.client.post( + "/api/assets", json={**ASSET, "name": f"Additional bridge {index}"} + ) + self.assertEqual(response.status_code, 201, response.text) + for endpoint in endpoints: + with self.subTest(endpoint=endpoint): + self.assertEqual(query_count(endpoint), before[endpoint]) + + def test_batched_scores_preserve_report_status_flood_and_workspace_scope(self): + road = self.client.post( + "/api/assets", json={**ASSET, "asset_type": "Road", "construction_year": 2025} + ).json() + self.upload(severity="5") + resolved = self.upload(severity="15").json() + self.client.post( + f"/api/reports/{resolved['id']}/resolve", json={"note": "Inspected and repaired"} + ) + self.upload(asset_id=road["id"], severity="10") + other_asset = self.other.get("/api/assets").json()[0] + response = self.other.post( + "/api/reports", + data={ + "asset_id": other_asset["id"], + "description": "Observation in a separate workspace", + "severity": "15", + }, + ) + self.assertEqual(response.status_code, 201, response.text) + self.client.post("/api/scenarios/flood", json={"active": True}) + + listed = {asset["id"]: asset for asset in self.client.get("/api/assets").json()} + self.assertNotIn(other_asset["id"], listed) + self.assertEqual(listed[self.asset["id"]]["health_score"], self.asset["health_score"] - 7.5) + self.assertEqual(listed[road["id"]]["health_score"], max(0, road["health_score"] - 30)) + for asset in listed.values(): + if asset["id"] not in {self.asset["id"], road["id"]}: + flood = 15 if asset["asset_type"] == "Road" else 0 + self.assertEqual(asset["health_score"], max(0, asset["condition_score"] - flood)) + + exported = list(csv.DictReader(io.StringIO(self.client.get("/api/export").text))) + self.assertEqual({int(row["ID"]) for row in exported}, set(listed)) + for row in exported: + asset = listed[int(row["ID"])] + self.assertEqual(float(row["Demo health score"]), asset["health_score"]) + self.assertEqual(row["Priority"], asset["maintenance_priority"]) + def test_session_revocation_expiry_and_persistence_across_clients(self): resumed = TestClient(app, headers=HEADERS) resumed.cookies.update(self.client.cookies)