Skip to content
Merged
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ also require a valid session cookie. Cross-origin access is not enabled.
| `GET /api/track/{code}` | Minimal public report status |
| `GET /api/health` | Database connectivity and application version |

Report summaries load attachment-presence flags and batch asset names; photo bytes
are loaded only when an authorized user opens the attachment.

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
photo is retained for human review; the application does **not** diagnose cracks
Expand Down
5 changes: 4 additions & 1 deletion database.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
create_engine,
event,
)
from sqlalchemy.orm import declarative_base, sessionmaker
from sqlalchemy.orm import column_property, declarative_base, deferred, relationship, sessionmaker
from sqlalchemy.pool import NullPool


Expand Down Expand Up @@ -100,6 +100,9 @@ class Report(Base):
status = Column(String(16), default="Open", nullable=False)
resolution_note = Column(String(1000))
image = Column(LargeBinary)
has_image = column_property(image.is_not(None))
image = deferred(image)
asset = relationship(Asset)
created_at = Column(DateTime, default=utcnow, nullable=False)
resolved_at = Column(DateTime)

Expand Down
14 changes: 9 additions & 5 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from PIL import Image, ImageOps, UnidentifiedImageError
from sqlalchemy import func, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, selectinload

import database as dbm
from schemas import ArchiveState, AssetCreate, Credentials, Note, Registration, Scenario
Expand Down Expand Up @@ -210,7 +210,7 @@ def iso(value):


def report_data(db, report):
asset = db.get(dbm.Asset, report.asset_id)
asset = report.asset
return {
"id": report.id,
"asset_id": report.asset_id,
Expand All @@ -219,7 +219,7 @@ def report_data(db, report):
"severity": report.severity,
"status": report.status,
"tracking_code": report.tracking_code,
"has_image": bool(report.image),
"has_image": bool(report.has_image),
"resolution_note": report.resolution_note,
"created_at": iso(report.created_at),
"resolved_at": iso(report.resolved_at),
Expand Down Expand Up @@ -410,7 +410,11 @@ def reports(
account=Depends(workspace),
db: Session = Depends(get_db),
):
query = db.query(dbm.Report).filter_by(workspace_id=account.id)
query = (
db.query(dbm.Report)
.options(selectinload(dbm.Report.asset))
.filter_by(workspace_id=account.id)
)
if status:
query = query.filter_by(status=status)
return [report_data(db, r) for r in query.order_by(dbm.Report.id.desc())]
Expand Down Expand Up @@ -607,7 +611,7 @@ def track(tracking_code: str, request: Request, db: Session = Depends(get_db)):
owner = db.get(dbm.Workspace, report.workspace_id)
if not owner or (owner.is_demo and owner.created_at < dbm.utcnow() - timedelta(days=7)):
raise HTTPException(404, "This report has expired.")
asset = db.get(dbm.Asset, report.asset_id)
asset = report.asset
return {
"id": report.id,
"asset_name": asset.name,
Expand Down
25 changes: 25 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,31 @@ def health(self):
a for a in self.client.get("/api/assets").json() if a["id"] == self.asset["id"]
)["health_score"]

def test_report_summaries_do_not_load_photo_blobs(self):
from sqlalchemy import event, inspect

uploaded = self.upload().json()
with database.SessionLocal() as db:
report = db.get(database.Report, uploaded["id"])
self.assertTrue(report.has_image)
self.assertIn("image", inspect(report).unloaded)

statements = []
def capture(_conn, _cursor, statement, _params, _context, _many):
statements.append(statement)
event.listen(database.engine, "before_cursor_execute", capture)
try:
response = self.client.get("/api/reports")
finally:
event.remove(database.engine, "before_cursor_execute", capture)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json()[0]["has_image"])
self.assertFalse(any("incident_reports.image AS" in sql for sql in statements))
image = self.client.get(f"/api/reports/{uploaded['id']}/image")
self.assertEqual(image.status_code, 200)
self.assertEqual(image.headers["content-type"], "image/jpeg")
self.assertEqual(self.other.get(f"/api/reports/{uploaded['id']}/image").status_code, 404)

def test_workspace_isolation_and_anonymous_access(self):
self.assertEqual(len(self.client.get("/api/assets").json()), 10)
self.assertEqual(len(self.other.get("/api/assets").json()), 9)
Expand Down
Loading