Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 34 additions & 23 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 "",
Expand Down
67 changes: 67 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading