From bb1a3d54ac956edde95c46f453ced760e9d2cac0 Mon Sep 17 00:00:00 2001 From: pralav-25 <174412353+pralav-25@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:18:19 +0530 Subject: [PATCH] Avoid loading photo blobs in report summaries --- README.md | 3 +++ database.py | 5 ++++- main.py | 14 +++++++++----- tests/test_api.py | 25 +++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e4befa6..fc37434 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/database.py b/database.py index b0def63..84e090a 100644 --- a/database.py +++ b/database.py @@ -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 @@ -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) diff --git a/main.py b/main.py index e11f55c..f4e7244 100644 --- a/main.py +++ b/main.py @@ -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 @@ -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, @@ -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), @@ -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())] @@ -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, diff --git a/tests/test_api.py b/tests/test_api.py index cf983c9..908ed9e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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)