Skip to content

Commit 573e590

Browse files
committed
Add employment details to officer search results
WIP
1 parent 6fe9d35 commit 573e590

File tree

3 files changed

+118
-2
lines changed

3 files changed

+118
-2
lines changed

backend/database/models/officer.py

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,9 @@ def search(
176176
name: str | None = None,
177177
rank: str | None = None,
178178
unit: list[str] | None = None,
179+
unit_uid: str | None = None,
179180
agency: list[str] | None = None,
181+
agency_uid: str | None = None,
180182
badge_number: list[str] | None = None,
181183
ethnicity: list[str] | None = None,
182184
active_after: date | None = None,
@@ -215,10 +217,10 @@ def search(
215217

216218
match_clauses.append("MATCH (o:Officer)")
217219

218-
if unit or active_after or active_before or badge_number or agency:
220+
if unit or unit_uid or active_after or active_before or badge_number or agency:
219221
match_clauses.append("MATCH (o)-[]-(e:Employment)-[]-(u:Unit)")
220222

221-
if agency:
223+
if agency or agency_uid:
222224
match_clauses.append("MATCH (u)-[:ESTABLISHED_BY]->(a:Agency)")
223225

224226
# Build WHERE clauses and params
@@ -237,6 +239,10 @@ def search(
237239
where_clauses.append("ANY(n IN $agency WHERE a.name CONTAINS n)")
238240
params["agency"] = agency
239241

242+
if agency_uid:
243+
where_clauses.append("a.uid = $agency_uid")
244+
params["agency_uid"] = agency_uid
245+
240246
if badge_number:
241247
where_clauses.append(
242248
"ANY(n IN $badge_number WHERE e.badge_number CONTAINS n)")
@@ -249,6 +255,10 @@ def search(
249255
if unit:
250256
where_clauses.append("ANY(n IN $unit WHERE u.name CONTAINS n)")
251257
params["unit"] = unit
258+
259+
if unit_uid:
260+
where_clauses.append("u.uid = $unit_uid")
261+
params["unit_uid"] = unit_uid
252262

253263
# Combine query
254264
match_str = "\n".join(match_clauses)
@@ -275,3 +285,68 @@ def search(
275285
rows, _ = db.cypher_query(cypher_query, params,
276286
resolve_objects=inflate)
277287
return [row[0] for row in rows]
288+
289+
@classmethod
290+
def include_employment(
291+
cls,
292+
uids: list[str],
293+
unit_uid: str | None = None,
294+
agency_uid: str | None = None,
295+
):
296+
"""
297+
Include employment details for a list of officers.
298+
299+
Coalesces employment at the agency level:
300+
- earliest_date / latest_date come from all matching employments
301+
for the officer at that agency
302+
- other values come from the most recent matching employment
303+
"""
304+
305+
cy = """
306+
UNWIND $uids AS officer_uid
307+
MATCH (o:Officer {uid: officer_uid})-[:HELD_BY]-(e:Employment)-[:IN_UNIT]-(u:Unit)-[:ESTABLISHED_BY]-(a:Agency)
308+
WHERE ($unit_uid IS NULL OR u.uid = $unit_uid)
309+
AND ($agency_uid IS NULL OR a.uid = $agency_uid)
310+
311+
WITH o, a, u, e
312+
ORDER BY coalesce(e.latest_date, e.earliest_date) DESC
313+
314+
WITH
315+
o,
316+
a,
317+
head(collect({e: e, u: u})) AS rep,
318+
min(e.earliest_date) AS earliest_date,
319+
max(e.latest_date) AS latest_date
320+
321+
RETURN
322+
o.uid AS officer_uid,
323+
{
324+
agency_uid: a.uid,
325+
agency_name: a.name,
326+
state: a.hq_state,
327+
unit_uid: rep.u.uid,
328+
unit_name: rep.u.name,
329+
badge_number: rep.e.badge_number,
330+
highest_rank: rep.e.highest_rank,
331+
salary: rep.e.salary,
332+
earliest_date: earliest_date,
333+
latest_date: latest_date
334+
} AS item
335+
"""
336+
logging.warning("Cypher query for employment:\n%s", cy)
337+
338+
params = {
339+
"uids": uids,
340+
"unit_uid": unit_uid,
341+
"agency_uid": agency_uid,
342+
}
343+
344+
results, _ = db.cypher_query(cy, params)
345+
346+
employment_map: dict[str, list[dict]] = {}
347+
for row in results:
348+
officer_uid = row[0]
349+
item = row[1]
350+
employment_map.setdefault(officer_uid, []).append(item)
351+
352+
return employment_map

backend/dto/officer.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ class OfficerSearchParams(PaginatedRequest):
1616
middle_name: Optional[str] = None
1717
last_name: Optional[str] = None
1818
suffix: Optional[str] = None
19+
unit_uid: Optional[str] = None
20+
agency_uid: Optional[str] = None
1921

2022
# Other filters
2123
rank: List[str] = []
@@ -26,6 +28,25 @@ class OfficerSearchParams(PaginatedRequest):
2628
badge_number: List[str] = Field([], alias="badge_number")
2729
ethnicity: List[str] = []
2830

31+
# Include params
32+
include: Optional[List[str]] = Field(
33+
None, description="Related entities to include in the response."
34+
)
35+
36+
@field_validator("include")
37+
def validate_include(cls, v):
38+
allowed_includes = {
39+
"employment"
40+
}
41+
if v:
42+
invalid = set(v) - allowed_includes
43+
if invalid:
44+
raise ValueError(
45+
f"Invalid include parameters: {', '.join(invalid)}")
46+
return v
47+
48+
49+
2950
# Derived fields (computed below)
3051
@field_validator("unit", mode="before")
3152
def ensure_list(cls, v):

backend/routes/officers.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,7 @@ def get_all_officers():
457457
"rank": request.args.getlist("rank"),
458458
"badge_number": request.args.getlist("badge_number"),
459459
"ethnicity": request.args.getlist("ethnicity"),
460+
"include": request.args.getlist("include"),
460461
}
461462
try:
462463
params = OfficerSearchParams(**raw)
@@ -487,7 +488,9 @@ def get_all_officers():
487488
name=params.officer_name,
488489
rank=params.officer_rank,
489490
unit=params.unit,
491+
unit_uid=params.unit_uid,
490492
agency=params.agency,
493+
agency_uid=params.agency_uid,
491494
badge_number=params.badge_number,
492495
ethnicity=params.ethnicity,
493496
active_after=params.active_after,
@@ -506,6 +509,23 @@ def get_all_officers():
506509
page = [item.model_dump() for item in all_officers if item]
507510
return_func = jsonify
508511
else:
512+
if params.include:
513+
if "employment" in params.include:
514+
employment_map = Officer.include_employment(
515+
uids = [row.get("uid") for row in results],
516+
unit_uid = params.unit_uid if params.unit_uid else None,
517+
agency_uid = params.agency_uid if params.agency_uid else None,
518+
)
519+
logging.warning("Employment map: %s", employment_map)
520+
# build page
521+
page = []
522+
for row in results:
523+
item = {
524+
**row.to_dict(),
525+
"employment_history": employment_map.get(row.get("uid"), [])
526+
}
527+
page.append(item)
528+
else:
509529
page = [row.to_dict() for row in results]
510530
return_func = ordered_jsonify
511531
# logging.warning('response is --------------------------------\n%s', page)

0 commit comments

Comments
 (0)