Skip to content

Commit 88d6540

Browse files
committed
document id validation with dep injection too
1 parent beff425 commit 88d6540

3 files changed

Lines changed: 93 additions & 58 deletions

File tree

backend/app/core/dependencies.py

Lines changed: 30 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from fastapi import Depends, HTTPException, status
22
from sqlmodel import select, Session, literal
3+
from app.core.models import DocumentID
34
from app.models import Document, DocumentPublic, DistrictrMap
45
from app.save_share.models import (
56
DocumentShareStatus,
@@ -17,10 +18,20 @@
1718
logging.basicConfig(level=logging.INFO)
1819

1920

20-
def get_document(document_id: str, session: Session = Depends(get_session)) -> Document:
21+
def parse_document_id(document_id: str) -> DocumentID:
22+
try:
23+
return DocumentID(document_id=document_id)
24+
except ValueError:
25+
raise HTTPException(status_code=400, detail="Invalid document ID")
26+
27+
28+
def get_document(
29+
document_id: DocumentID = Depends(parse_document_id),
30+
session: Session = Depends(get_session),
31+
) -> Document:
2132
try:
2233
document = session.exec(
23-
select(Document).where(Document.document_id == document_id)
34+
select(Document).where(Document.document_id == document_id.value)
2435
).one()
2536
except NoResultFound:
2637
raise HTTPException(status_code=404, detail="Document not found")
@@ -32,7 +43,8 @@ def get_document(document_id: str, session: Session = Depends(get_session)) -> D
3243

3344

3445
def get_protected_document(
35-
document_id: str | int, session: Session = Depends(get_session)
46+
document_id: DocumentID = Depends(parse_document_id),
47+
session: Session = Depends(get_session),
3648
) -> Document:
3749
"""
3850
Always returns a document even if the public_id was used instead of the document_id. This function
@@ -44,21 +56,14 @@ def get_protected_document(
4456
- UUID document IDs
4557
- Public IDs (numeric, for public sharing)
4658
"""
47-
id_is_public = isinstance(document_id, int) or (
48-
isinstance(document_id, str) and document_id.isdigit()
49-
)
50-
51-
if id_is_public:
52-
document_id = int(document_id)
53-
5459
stmt = select(Document)
5560

56-
if id_is_public:
57-
stmt = stmt.where(Document.public_id == document_id).where(
61+
if document_id.is_public:
62+
stmt = stmt.where(Document.public_id == document_id.value).where(
5863
text("map_metadata->>'draft_status' = 'ready_to_share'")
5964
)
6065
else:
61-
stmt = stmt.where(Document.document_id == document_id)
66+
stmt = stmt.where(Document.document_id == document_id.value)
6267

6368
try:
6469
document = session.exec(stmt).one()
@@ -73,33 +78,21 @@ def get_protected_document(
7378

7479
def get_document_public(
7580
session: Session,
76-
document_id: str | int,
81+
document_id: DocumentID = Depends(parse_document_id),
7782
user_id: str | None = None,
7883
shared: bool = False,
7984
lock_status: DocumentEditStatus | None = None,
8085
) -> DocumentPublic:
81-
id_is_public = isinstance(document_id, int) or (
82-
isinstance(document_id, str) and document_id.isdigit()
83-
)
84-
85-
if id_is_public:
86-
document_id = int(document_id)
87-
88-
if not document_id:
89-
raise HTTPException(status_code=404, detail="Document not found")
90-
document = get_protected_document(document_id=document_id, session=session)
91-
# TODO: Rather than being a separate query, this should be part of the main query
92-
9386
access_type = DocumentShareStatus.read
9487
# Store if lock_status was explicitly provided
9588
lock_status_provided = lock_status is not None
9689

97-
if document.document_id == document_id and not id_is_public:
90+
if not document_id.is_public:
9891
access_type = DocumentShareStatus.edit
9992
# Only check map lock if no lock_status was explicitly provided
10093
if not lock_status_provided:
10194
lock_status = check_map_lock(
102-
document.document_id, user_id=user_id, session=session
95+
document_id.document_id, user_id=user_id, session=session
10396
)
10497

10598
# Set default lock_status if not provided and not already set
@@ -108,7 +101,9 @@ def get_document_public(
108101

109102
stmt = select(
110103
# Obsured document ID
111-
literal("anonymous" if id_is_public else document_id).label("document_id"),
104+
literal(
105+
"anonymous" if document_id.is_public else document_id.document_id
106+
).label("document_id"),
112107
Document.created_at,
113108
Document.districtr_map_slug,
114109
Document.gerrydb_table,
@@ -139,20 +134,20 @@ def get_document_public(
139134
isouter=True,
140135
)
141136

142-
if id_is_public:
143-
stmt = stmt.where(Document.public_id == document_id).where(
137+
if document_id.is_public:
138+
stmt = stmt.where(Document.public_id == document_id.value).where(
144139
text("map_metadata->>'draft_status' = 'ready_to_share'")
145140
)
146141
else:
147-
stmt = stmt.where(Document.document_id == document_id)
142+
stmt = stmt.where(Document.document_id == document_id.value)
148143

149144
result = session.exec(stmt)
150145

151146
return result.one()
152147

153148

154149
def get_districtr_map(
155-
document_id: str,
150+
document_id: DocumentID = Depends(parse_document_id),
156151
session: Session = Depends(get_session),
157152
) -> DistrictrMap:
158153
stmt = (
@@ -169,8 +164,8 @@ def get_districtr_map(
169164
)
170165
.filter(
171166
or_(
172-
Document.document_id == document_id,
173-
MapDocumentToken.document_id == document_id,
167+
Document.document_id == document_id.document_id,
168+
MapDocumentToken.document_id == document_id.document_id,
174169
)
175170
) # pyright: ignore
176171
)

backend/app/core/models.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
from datetime import datetime
2+
from pydantic import BaseModel, computed_field, model_validator
3+
from uuid import UUID
24
from sqlmodel import (
35
Field,
46
SQLModel,
5-
UUID,
67
TIMESTAMP,
8+
UUID as UUIDField,
79
text,
810
)
911

1012

11-
class UUIDType(UUID):
13+
class UUIDType(UUIDField):
1214
def __init__(self, *args, **kwargs):
1315
kwargs["as_uuid"] = False
1416
super().__init__(*args, **kwargs)
@@ -32,3 +34,44 @@ class TimeStampMixin(SQLModel):
3234
nullable=False,
3335
default=None,
3436
)
37+
38+
39+
class DocumentID(BaseModel):
40+
"""
41+
Represents a document identifier.
42+
43+
Attributes:
44+
document_id (str): The unique identifier of the document.
45+
is_public (bool): Indicates whether the document is public.
46+
value (int | str): The value of the document identifier.
47+
48+
Raises:
49+
ValueError: If the private document_id is not a valid UUID.
50+
"""
51+
52+
document_id: str
53+
54+
@computed_field
55+
@property
56+
def is_public(self) -> bool:
57+
return isinstance(self.document_id, int) or (
58+
isinstance(self.document_id, str) and self.document_id.isdigit()
59+
)
60+
61+
@computed_field
62+
@property
63+
def value(self) -> int | str:
64+
if self.is_public:
65+
return int(self.document_id)
66+
return self.document_id
67+
68+
@model_validator(mode="after")
69+
def validate_private_uuid(self) -> "DocumentID":
70+
if not self.is_public:
71+
try:
72+
UUID(self.document_id)
73+
except ValueError:
74+
raise ValueError(
75+
f"Private document_id must be a valid UUID, got: {self.document_id}"
76+
)
77+
return self

backend/app/main.py

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@
3131
get_document_public,
3232
get_protected_document,
3333
get_districtr_map,
34+
parse_document_id,
3435
)
36+
from app.core.models import DocumentID
3537
from app.core.config import settings
3638
import app.contiguity.main as contiguity
3739
import app.cms.main as cms
@@ -155,7 +157,9 @@ async def db_is_alive(session: Session = Depends(get_session)):
155157

156158
@app.post("/api/document/{document_id}/unload", status_code=status.HTTP_200_OK)
157159
async def unlock_map(
158-
document_id: str, user_id: str = Form(...), session: Session = Depends(get_session)
160+
document_id: DocumentID = Depends(parse_document_id),
161+
user_id: str = Form(...),
162+
session: Session = Depends(get_session),
159163
):
160164
"""
161165
unlock map when tab is unloaded
@@ -169,7 +173,7 @@ async def unlock_map(
169173
bindparam(key="document_id", type_=UUIDType),
170174
bindparam(key="user_id", type_=String),
171175
),
172-
{"document_id": document_id, "user_id": user_id},
176+
{"document_id": document_id.value, "user_id": user_id},
173177
)
174178
session.commit()
175179
return {"status": DocumentEditStatus.unlocked}
@@ -461,7 +465,9 @@ async def reset_map(
461465
response_model=ColorsSetResult,
462466
)
463467
async def update_colors(
464-
document_id: str, colors: list[str], session: Session = Depends(get_session)
468+
colors: list[str],
469+
document_id: DocumentID = Depends(parse_document_id),
470+
session: Session = Depends(get_session),
465471
):
466472
districtr_map = session.exec(
467473
select(DistrictrMap)
@@ -470,7 +476,7 @@ async def update_colors(
470476
Document.districtr_map_slug == DistrictrMap.districtr_map_slug, # pyright: ignore
471477
isouter=True,
472478
)
473-
.where(Document.document_id == document_id)
479+
.where(Document.document_id == document_id.value)
474480
).one()
475481

476482
if districtr_map.num_districts != len(colors):
@@ -487,7 +493,7 @@ async def update_colors(
487493
bindparam(key="document_id", type_=UUIDType),
488494
bindparam(key="colors", type_=ARRAY(String)),
489495
)
490-
session.execute(stmt, {"document_id": document_id, "colors": colors})
496+
session.execute(stmt, {"document_id": document_id.value, "colors": colors})
491497
session.commit()
492498
return ColorsSetResult(colors=colors)
493499

@@ -521,7 +527,7 @@ async def get_assignments(
521527

522528
@app.get("/api/document/{document_id}", response_model=DocumentPublic)
523529
async def get_document_object(
524-
document_id: str | int,
530+
document_id: DocumentID = Depends(parse_document_id),
525531
user_id: str | None = None,
526532
session: Session = Depends(get_session),
527533
):
@@ -611,18 +617,14 @@ async def _get_graph(gerrydb_name: str) -> Graph:
611617

612618
@app.get("/api/document/{document_id}/contiguity")
613619
async def check_document_contiguity(
614-
document_id: str,
615620
document: Annotated[Document, Depends(get_protected_document)],
621+
districtr_map: Annotated[DistrictrMap, Depends(get_districtr_map)],
616622
zone: list[int] = Query(default=[]),
617623
session: Session = Depends(get_session),
618624
):
619-
assert document.document_id is not None
620-
621-
districtr_map = get_districtr_map(session=session, document_id=document.document_id)
622-
623625
if districtr_map.child_layer is not None:
624626
logger.info(
625-
f"Using child layer {districtr_map.child_layer} for document {document_id}"
627+
f"Using child layer {districtr_map.child_layer} for document {document.document_id}"
626628
)
627629
gerrydb_name = districtr_map.child_layer
628630
kwargs = {"zones": zone} if len(zone) > 0 else {}
@@ -632,7 +634,7 @@ async def check_document_contiguity(
632634
else:
633635
gerrydb_name = districtr_map.parent_layer
634636
logger.info(
635-
f"No child layer configured for document. Defauling to parent layer {gerrydb_name} for document {document_id}"
637+
f"No child layer configured for document. Defauling to parent layer {gerrydb_name} for document {document.document_id}"
636638
)
637639
sql = text(
638640
"""
@@ -666,18 +668,14 @@ async def check_document_contiguity(
666668

667669
@app.get("/api/document/{document_id}/contiguity/{zone}/connected_component_bboxes")
668670
async def get_connected_component_bboxes(
669-
document_id: str,
670671
zone: int,
671672
document: Annotated[Document, Depends(get_protected_document)],
673+
districtr_map: Annotated[DistrictrMap, Depends(get_districtr_map)],
672674
session: Session = Depends(get_session),
673675
):
674-
assert document.document_id is not None
675-
676-
districtr_map = get_districtr_map(session=session, document_id=document.document_id)
677-
678676
if districtr_map.child_layer is not None:
679677
logger.info(
680-
f"Using child layer {districtr_map.child_layer} for document {document_id}"
678+
f"Using child layer {districtr_map.child_layer} for document {document.document_id}"
681679
)
682680
gerrydb_name = districtr_map.child_layer
683681
zone_assignments = contiguity.get_block_assignments_bboxes(
@@ -691,7 +689,7 @@ async def get_connected_component_bboxes(
691689
else:
692690
gerrydb_name = districtr_map.parent_layer
693691
logger.info(
694-
f"No child layer configured for document. Defauling to parent layer {gerrydb_name} for document {document_id}"
692+
f"No child layer configured for document. Defauling to parent layer {gerrydb_name} for document {document.document_id}"
695693
)
696694
sql = text(
697695
f"""
@@ -820,7 +818,6 @@ async def update_districtrmap_metadata(
820818
# response_model=list[DistrictrMapPublic]
821819
)
822820
async def get_projects(
823-
*,
824821
session: Session = Depends(get_session),
825822
group: str = Query(default="states"),
826823
offset: int = Query(default=0, ge=0),

0 commit comments

Comments
 (0)