Skip to content

Commit 1f2f551

Browse files
authored
Add Agency Detail page (#545)
* Add Agency Detail page * Flake8 Fix
1 parent 338ffb7 commit 1f2f551

File tree

15 files changed

+379
-40
lines changed

15 files changed

+379
-40
lines changed

backend/dto/agency.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ def validate_include(cls, v):
5656
"officers",
5757
"complaints",
5858
"allegations",
59-
"most_complaints",
59+
"reported_units",
60+
"location",
6061
}
6162
if v:
6263
invalid = set(v) - allowed_includes

backend/routes/agencies.py

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
from backend.mixpanel.mix import track_to_mp
88
from backend.database.models.user import UserRole
99
from backend.database.models.agency import Agency
10-
from backend.routes.search import fetch_details, build_agency_result
10+
from backend.routes.search import (
11+
fetch_details, build_agency_result, build_unit_result)
1112
from .tmp.pydantic.agencies import CreateAgency, UpdateAgency
1213
from flask import Blueprint, abort, request, jsonify
1314
from flask_jwt_extended.view_decorators import jwt_required
@@ -23,22 +24,29 @@
2324
}
2425
"""
2526

27+
LOCATION_CYPHER = """
28+
CALL (a) {
29+
MATCH (a)-[]-(city:CityNode)-[]-(:CountyNode)
30+
-[]-(state:StateNode)
31+
RETURN {
32+
coords: city.coordinates,
33+
city: city.name,
34+
state: state.name
35+
} AS location
36+
}
37+
"""
38+
2639
TOP_UNITS_BY_COMPLAINTS_CYPHER = """
2740
CALL (a) {
2841
MATCH (a)-[]-(u:Unit)<-[]-(:Employment)-[]->(o:Officer)
2942
-[:ACCUSED_OF]->(:Allegation)-[:ALLEGED]-(c:Complaint)
3043
WITH
3144
u,
3245
count(DISTINCT c) AS complaint_count,
33-
count(DISTINCT o) AS officer_count
34-
ORDER BY complaint_count DESC, coalesce(u.name, "") ASC
46+
count(DISTINCT a) AS allegation_count
47+
ORDER BY complaint_count DESC, allegation_count DESC
3548
LIMIT 3
36-
RETURN collect({
37-
unit_uid: u.uid,
38-
unit_name: u.name,
39-
complaint_count: complaint_count,
40-
officer_count: officer_count
41-
}) AS top_units_by_complaints
49+
RETURN collect(u) AS most_reported_units
4250
}
4351
"""
4452

@@ -175,9 +183,12 @@ def get_agency(agency_uid: str):
175183
if "allegations" in params.include:
176184
subqueries += ALLEGATION_CYPHER
177185
return_clause += ", collect(type_summary) AS allegation_summary"
178-
if "most_complaints" in params.include:
186+
if "reported_units" in params.include:
179187
subqueries += TOP_UNITS_BY_COMPLAINTS_CYPHER
180-
return_clause += ", top_units_by_complaints"
188+
return_clause += ", most_reported_units"
189+
if "location" in params.include:
190+
subqueries += LOCATION_CYPHER
191+
return_clause += ", location"
181192
cy = match_clause + subqueries + return_clause
182193

183194
rows, _ = db.cypher_query(cy, {"agency_uid": agency_uid})
@@ -200,9 +211,28 @@ def get_agency(agency_uid: str):
200211
agency_data["allegation_summary"] = format_allegation_summary(
201212
row[idx])
202213
idx += 1
203-
if "most_complaints" in params.include:
204-
agency_data["most_complaints"] = row[idx]
214+
if "reported_units" in params.include:
215+
details = fetch_details(
216+
[u.get("uid") for u in row[idx]], "Unit")
217+
units = [build_unit_result(
218+
u, details.get(u.get("uid"), {})) for u in row[idx]]
219+
item_dump = [
220+
item.model_dump() for item in units if item
221+
]
222+
for item in item_dump:
223+
item["last_updated"] = item[
224+
"last_updated"].isoformat() if item.get(
225+
"last_updated", None) else None
226+
agency_data["most_reported_units"] = item_dump
205227
idx += 1
228+
if "location" in params.include:
229+
loc = row[idx]
230+
agency_data["location"] = {
231+
"latitude": loc["coords"].y,
232+
"longitude": loc["coords"].x,
233+
"city": loc["city"],
234+
"state": loc["state"]
235+
} if loc and loc.get("coords", None) else None
206236
return ordered_jsonify(agency_data)
207237

208238

frontend/app/agency/[uid]/page.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"use client"
2+
3+
import { useEffect, useState } from "react"
4+
import { useAuth } from "@/providers/AuthProvider"
5+
import { apiFetch } from "@/utils/apiFetch"
6+
import API_ROUTES, { apiBaseUrl } from "@/utils/apiRoutes"
7+
import { useParams } from "next/navigation"
8+
import { Agency } from "@/utils/api"
9+
import DetailsLayout from "@/components/Details/DetailsLayout"
10+
import AgencyIdentityCard from "@/components/Details/IdentityCard/AgencyIdentityCard"
11+
import AgencyDetailsTabs from "@/components/Details/tabs/AgencyDetailsTabs"
12+
import AgencyContentDetails from "@/components/Details/ContentDetails/AgencyContentDetails"
13+
14+
export default function AgencyDetailsPage() {
15+
const params = useParams<{ uid: string }>()
16+
const uid = params.uid
17+
18+
const [agency, setAgency] = useState<Agency | null>(null)
19+
const { accessToken } = useAuth()
20+
const [loading, setLoading] = useState(true)
21+
22+
useEffect(() => {
23+
if (!accessToken || !uid) return
24+
25+
setLoading(true)
26+
27+
apiFetch(
28+
`${apiBaseUrl}${API_ROUTES.agencies.profile(uid)}?include=complaints&include=officers&include=units&include=reported_units&include=location`,
29+
{
30+
headers: {
31+
Authorization: `Bearer ${accessToken}`
32+
}
33+
}
34+
)
35+
.then((res) => res.json())
36+
.then((data) => setAgency(data.results || data))
37+
.finally(() => setLoading(false))
38+
}, [accessToken, uid])
39+
40+
if (loading) return <div>Loading...</div>
41+
if (!agency) return <div>Agency not found</div>
42+
43+
return (
44+
<DetailsLayout sidebar={<AgencyContentDetails agency={agency} />}>
45+
<AgencyIdentityCard agency={agency} />
46+
<AgencyDetailsTabs {...agency} />
47+
</DetailsLayout>
48+
)
49+
}

frontend/app/officer/[uid]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export default function OfficerDetailsPage() {
4242

4343
return (
4444
<DetailsLayout sidebar={<OfficerContentDetails officer={officer} />}>
45-
<OfficerIdentityCard {...officer} />
45+
<OfficerIdentityCard officer={officer} />
4646
<OfficerDetailsTabs {...officer} />
4747
</DetailsLayout>
4848
)

frontend/app/unit/[uid]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export default function UnitDetailsPage() {
4242

4343
return (
4444
<DetailsLayout sidebar={<UnitContentDetails unit={unit} />}>
45-
<UnitIdentityCard {...unit} />
45+
<UnitIdentityCard unit={unit} />
4646
<UnitDetailsTabs {...unit} />
4747
</DetailsLayout>
4848
)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import ContentDetails from "./ContentDetails"
2+
import { Agency } from "@/utils/api"
3+
4+
type AgencyContentDetailsProps = {
5+
agency: Agency
6+
}
7+
8+
export default function AgencyContentDetails({ agency }: AgencyContentDetailsProps) {
9+
const totalComplaints = agency.total_complaints || 0
10+
11+
const totalOfficers = agency.total_officers || 0
12+
13+
const dataSources =
14+
agency.sources?.map((source) => source.name).filter((name): name is string => Boolean(name)) ||
15+
[]
16+
17+
return (
18+
<ContentDetails
19+
contentType="Agency"
20+
dataSources={dataSources}
21+
lastUpdatedText="Nov 1, 2024 by"
22+
lastUpdatedBy="Adam Zelitzky"
23+
summaryItems={[
24+
{ label: "Officers", value: String(totalOfficers) },
25+
{ label: "Complaints", value: String(totalComplaints) },
26+
{ label: "Related Articles", value: "0" }
27+
]}
28+
associatedTitle="Associated officers"
29+
associatedPeople={[
30+
{ name: "Adam Zelitzky" },
31+
{ name: "Andrew Damora" },
32+
{ name: "John Dadamo" }
33+
]}
34+
associatedHref="#"
35+
/>
36+
)
37+
}

frontend/components/Details/IdentityCard/AgencyIdentityCard.tsx

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,62 @@
11
import IdentityCard from "./IdentityCard"
22
import { Agency } from "@/utils/api"
33
import { US_STATES } from "@/utils/constants"
4+
import type { ReactNode } from "react"
5+
import Link from "@mui/material/Link"
46

57
function getStateName(abbreviation: string | undefined): string {
68
if (!abbreviation) return ""
79
const state = US_STATES.find((s) => s.abbreviation === abbreviation)
810
return state ? state.name : abbreviation
911
}
1012

11-
function getAgencyLocation(agency: Agency): string | undefined {
12-
const parts = [agency.hq_city, agency.hq_state ? getStateName(agency.hq_state) : null].filter(
13-
Boolean
14-
)
13+
function getAgencySubtitle(agency: Agency): string | undefined {
14+
const locationParts = [
15+
agency.hq_city,
16+
agency.hq_state ? getStateName(agency.hq_state) : null
17+
].filter(Boolean)
18+
19+
if (!agency.jurisdiction) return undefined
20+
21+
if (agency.jurisdiction.toLowerCase() === "municipal") {
22+
return `Municipal Police Department${
23+
locationParts.length > 0 ? ` - ${locationParts.join(", ")}` : ""
24+
}`
25+
}
26+
27+
return locationParts.length > 0 ? `Headquartered in ${locationParts.join(", ")}` : undefined
28+
}
29+
30+
function getAgencyDetail(agency: Agency): ReactNode {
31+
const officers = agency.total_officers ?? "No"
32+
const units = agency.total_units ?? "No"
33+
const complaints = agency.total_complaints ?? "No"
1534

16-
return parts.length > 0 ? parts.join(", ") : undefined
35+
return (
36+
<>
37+
{officers} known officers, {units} known units, and {complaints} registered complaints
38+
{agency.website_url && (
39+
<>
40+
<br />
41+
Website:{" "}
42+
<Link href={agency.website_url} target="_blank" rel="noopener noreferrer">
43+
Visit website
44+
</Link>
45+
</>
46+
)}
47+
</>
48+
)
1749
}
1850

19-
function getAgencyDetail(agency: Agency): string | undefined {
20-
if (agency.jurisdiction) return agency.jurisdiction
21-
if (agency.description) return agency.description
22-
if (agency.website_url) return agency.website_url
23-
return undefined
51+
type AgencyIdentityCardProps = {
52+
agency: Agency
2453
}
2554

26-
export default function AgencyIdentityCard(agency: Agency) {
55+
export default function AgencyIdentityCard({ agency }: AgencyIdentityCardProps) {
2756
return (
2857
<IdentityCard
2958
title={agency.name}
30-
subtitle={getAgencyLocation(agency)}
59+
subtitle={getAgencySubtitle(agency)}
3160
detail={getAgencyDetail(agency)}
3261
/>
3362
)

frontend/components/Details/IdentityCard/IdentityCard.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { Avatar, IconButton, Typography } from "@mui/material"
2+
import type { ReactNode } from "react"
23
import styles from "./identityCard.module.css"
34
import AddToPhotosOutlinedIcon from "@mui/icons-material/AddToPhotosOutlined"
45

56
type IdentityCardProps = {
67
title: string
78
subtitle?: string
8-
detail?: string
9+
detail?: ReactNode
910
imageSrc?: string
1011
onAddToCollection?: () => void
1112
}

frontend/components/Details/IdentityCard/OfficerIdentityCard.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import IdentityCard from "./IdentityCard"
22
import { Officer } from "@/utils/api"
33
import { US_STATES } from "@/utils/constants"
44

5+
type OfficerIdentityCardProps = {
6+
officer: Officer
7+
}
8+
59
function getAgeFromBirthYear(yearOfBirth: string | number): number {
610
return new Date().getFullYear() - Number(yearOfBirth)
711
}
@@ -22,7 +26,7 @@ function getOfficerFullName(officer: Officer): string {
2226
return [officer.first_name, middleName, officer.last_name].filter(Boolean).join(" ")
2327
}
2428

25-
export default function OfficerIdentityCard(officer: Officer) {
29+
export default function OfficerIdentityCard({ officer }: OfficerIdentityCardProps) {
2630
const currentEmployment = officer.employment_history?.find((emp) => !emp.latest_date)
2731

2832
const subtitleParts = [

frontend/components/Details/IdentityCard/UnitIdentityCard.tsx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,28 @@ import IdentityCard from "./IdentityCard"
22
import { Unit } from "@/utils/api"
33
import { US_STATES } from "@/utils/constants"
44

5+
type UnitIdentityCardProps = {
6+
unit: Unit
7+
}
8+
59
function getStateName(abbreviation: string | undefined): string {
610
if (!abbreviation) return ""
711
const state = US_STATES.find((s) => s.abbreviation === abbreviation)
812
return state ? state.name : abbreviation
913
}
1014

11-
export default function UnitIdentityCard(unit: Unit) {
12-
const titleStr =
13-
unit.name +
14-
(unit.location && unit.location.city && unit.location.state
15+
export default function UnitIdentityCard({ unit }: UnitIdentityCardProps) {
16+
const location =
17+
unit.location?.city && unit.location?.state
1518
? ` - ${unit.location.city}, ${getStateName(unit.location.state)}`
16-
: "")
17-
18-
const subtitlestr = "Unit of " + (unit.agency ? unit.agency.name : "Unknown Agency")
19+
: ""
1920

20-
const detailStr =
21+
const title = `${unit.name}${location}`
22+
const subtitle = `Unit of ${unit.agency?.name ?? "Unknown Agency"}`
23+
const detail =
2124
unit.total_officers !== undefined
2225
? `${unit.total_officers} known officers`
2326
: "Officer count not available"
2427

25-
return <IdentityCard title={titleStr} subtitle={subtitlestr} detail={detailStr} />
28+
return <IdentityCard title={title} subtitle={subtitle} detail={detail} />
2629
}

0 commit comments

Comments
 (0)