Skip to content

Commit 57eb1a8

Browse files
committed
add endpoint with police stations to api
1 parent 9675ef8 commit 57eb1a8

5 files changed

Lines changed: 338 additions & 0 deletions

File tree

app/api/police.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import json
2+
3+
from typing import List, Dict, Any, Optional
4+
5+
from fastapi import Depends, APIRouter, HTTPException, status
6+
from geojson import Feature, FeatureCollection
7+
from sqlalchemy.ext.asyncio import AsyncSession
8+
9+
from ..schemas.police import (
10+
PoliceGeometryResponse,
11+
PoliceResponse
12+
)
13+
from ..dependencies import get_session
14+
from ..services.police import (
15+
get_police_station_by_id,
16+
get_police_station_geometries_by_bbox,
17+
get_police_station_geometries_by_lat_lng
18+
)
19+
20+
21+
route_police = APIRouter(prefix='/police/v1')
22+
23+
24+
def create_geojson_from_rows(rows: List[Dict[str, Any]]) -> FeatureCollection:
25+
features = [
26+
Feature(
27+
id=row['id'],
28+
geometry=json.loads(row['geojson']),
29+
properties={'label': row['label']}
30+
)
31+
for row in rows
32+
]
33+
34+
crs = {'type': 'name', 'properties': {
35+
'name': 'urn:ogc:def:crs:OGC:1.3:CRS84'}}
36+
37+
return FeatureCollection(features, crs=crs)
38+
39+
40+
@route_police.get(
41+
'/details',
42+
response_model=PoliceResponse,
43+
tags=['Polizeidienststellen'],
44+
description=(
45+
'Retrieves police station details based on the provided station id.'
46+
)
47+
)
48+
async def fetch_police_station_by_id(
49+
station_id: int,
50+
session: AsyncSession = Depends(get_session)
51+
) -> List[PoliceResponse]:
52+
rows = await get_police_station_by_id(session, station_id)
53+
identifier = f'station_id {station_id}'
54+
55+
if not rows:
56+
raise HTTPException(
57+
status_code=status.HTTP_404_NOT_FOUND,
58+
detail=f'No matches found for {identifier}'
59+
)
60+
61+
return rows
62+
63+
64+
@route_police.get(
65+
'/bounds',
66+
response_model=PoliceGeometryResponse,
67+
tags=['Polizeidienststellen'],
68+
description=(
69+
'Retrieves police geometries based on the provided bounding box. '
70+
'The coordinates must be in the order: xmin, ymin, xmax, ymax.'
71+
)
72+
)
73+
async def fetch_police_station_geometries_by_bbox(
74+
xmin: float,
75+
ymin: float,
76+
xmax: float,
77+
ymax: float,
78+
session: AsyncSession = Depends(get_session)
79+
) -> FeatureCollection:
80+
rows = await get_police_station_geometries_by_bbox(
81+
session, xmin, ymin, xmax, ymax
82+
)
83+
84+
if not rows:
85+
raise HTTPException(
86+
status_code=status.HTTP_404_NOT_FOUND,
87+
detail='No matches found for the given bounding box'
88+
)
89+
90+
return create_geojson_from_rows(rows)
91+
92+
93+
@route_police.get(
94+
'/radius',
95+
response_model=PoliceGeometryResponse,
96+
responses={
97+
400: {
98+
'description': 'Bad Request',
99+
'content': {
100+
'application/json': {
101+
'example': {
102+
'detail': 'string'
103+
}
104+
}
105+
}
106+
},
107+
404: {
108+
'description': 'Not Found',
109+
'content': {
110+
'application/json': {
111+
'example': {
112+
'detail': 'string'
113+
}
114+
}
115+
}
116+
},
117+
422: {
118+
'description': 'Unprocessable Entity',
119+
'content': {
120+
'application/json': {
121+
'example': {
122+
'detail': [
123+
{
124+
'loc': ['query', 'station_id'],
125+
'msg': 'value is not a valid integer',
126+
'type': 'type_error.integer'
127+
}
128+
]
129+
}
130+
}
131+
}
132+
}
133+
},
134+
tags=['Polizeidienststellen'],
135+
description=(
136+
'Retrieves police geometries based on the provided latitude and longitude. '
137+
'The coordinates must be in the order: lat, lng.'
138+
)
139+
)
140+
async def fetch_police_station_geometries_by_lat_lng(
141+
lat: float,
142+
lng: float,
143+
session: AsyncSession = Depends(get_session)
144+
) -> FeatureCollection:
145+
"""
146+
Retrieve polices near a specific lat/lng point.
147+
148+
Args:
149+
lat: Latitude
150+
lng: Longitude
151+
session: Database session
152+
153+
Returns:
154+
GeoJSON FeatureCollection of polices
155+
156+
Raises:
157+
HTTPException: If no polices are found near the coordinates
158+
"""
159+
rows = await get_police_station_geometries_by_lat_lng(session, lat, lng)
160+
161+
if not rows:
162+
raise HTTPException(
163+
status_code=status.HTTP_404_NOT_FOUND,
164+
detail=f'No matches found for coordinates ({lat}, {lng})'
165+
)
166+
167+
return create_geojson_from_rows(rows)

app/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from .api.demographic import route_demographic
1717
from .api.energy import route_energy
1818
from .api.school import route_school
19+
from .api.police import route_police
1920
from .api.xplan import route_xplan
2021

2122

@@ -67,6 +68,7 @@ async def custom_validation_error_handler(
6768

6869

6970
app.include_router(route_xplan)
71+
app.include_router(route_police)
7072
app.include_router(route_school)
7173
app.include_router(route_biotope)
7274
app.include_router(route_climate)

app/models/police.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from sqlmodel import SQLModel, Field
2+
from typing import Optional
3+
from geoalchemy2 import Geometry
4+
from sqlalchemy import Column
5+
from sqlalchemy.dialects.postgresql import DOUBLE_PRECISION
6+
7+
8+
class SHPoliceStation(SQLModel, table=True):
9+
__tablename__ = "sh_police_station"
10+
11+
id: int = Field(primary_key=True)
12+
name: str
13+
city: str
14+
zipcode: str = Field(max_length=5)
15+
street: str
16+
house_number: str = Field(max_length=10)
17+
telephone: Optional[str] = None
18+
fax: Optional[str] = None
19+
email: Optional[str] = None
20+
website: Optional[str] = None
21+
longitude: Optional[float] = Field(sa_column=Column(DOUBLE_PRECISION))
22+
latitude: Optional[float] = Field(sa_column=Column(DOUBLE_PRECISION))
23+
wkb_geometry: Optional[str] = Field(
24+
sa_column=Column(Geometry(geometry_type="GEOMETRY", srid=4326))
25+
)

app/schemas/police.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from typing import List, Optional
2+
from pydantic import BaseModel, EmailStr, HttpUrl
3+
4+
5+
class GeoPoint(BaseModel):
6+
type: str = 'Point'
7+
coordinates: List[float]
8+
9+
10+
class PoliceResponse(BaseModel):
11+
id: int
12+
name: str
13+
city: str
14+
zipcode: str
15+
street: str
16+
house_number: str
17+
telephone: str
18+
fax: Optional[str] = None
19+
email: Optional[EmailStr] = None
20+
website: Optional[HttpUrl] = None
21+
geojson: Optional[GeoPoint] = None
22+
23+
24+
class CrsProperties(BaseModel):
25+
name: str
26+
27+
28+
class Crs(BaseModel):
29+
type: str
30+
properties: CrsProperties
31+
32+
33+
class PoliceProperties(BaseModel):
34+
label: str
35+
36+
37+
class PoliceFeature(BaseModel):
38+
type: str = 'Feature'
39+
id: int
40+
geometry: GeoPoint
41+
properties: PoliceProperties
42+
43+
44+
class PoliceGeometryResponse(BaseModel):
45+
type: str = 'FeatureCollection'
46+
crs: Crs
47+
features: List[PoliceFeature]

app/services/police.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
from sqlalchemy.sql import text
2+
from sqlalchemy.ext.asyncio import AsyncSession
3+
from fastapi import HTTPException
4+
5+
from ..utils.validators import validate_positive_int32, validate_utf8_string
6+
7+
8+
async def get_police_station_by_id(session: AsyncSession, station_id: int):
9+
try:
10+
validated_station_id = validate_positive_int32(station_id)
11+
except ValueError as e:
12+
raise HTTPException(status_code=422, detail=str(e))
13+
14+
stmt = text('''
15+
SELECT
16+
ST_AsGeoJSON(s.wkb_geometry, 15)::jsonb AS geojson,
17+
s.id,
18+
s.name,
19+
s.city,
20+
s.zipcode,
21+
s.street,
22+
s.house_number,
23+
s.telephone,
24+
s.fax,
25+
s.email,
26+
s.website
27+
FROM
28+
sh_police_station AS s
29+
WHERE
30+
s.id = :station_id
31+
''')
32+
33+
sql = stmt.bindparams(station_id=validated_station_id)
34+
result = await session.execute(sql)
35+
row = result.mappings().one_or_none()
36+
37+
return row
38+
39+
40+
async def get_police_station_geometries_by_bbox(
41+
session: AsyncSession,
42+
xmin: float,
43+
ymin: float,
44+
xmax: float,
45+
ymax: float
46+
):
47+
stmt = text('''
48+
SELECT
49+
id,
50+
ST_AsGeoJSON(wkb_geometry, 15) AS geojson,
51+
name AS label
52+
FROM
53+
sh_police_station
54+
WHERE
55+
ST_WITHIN(
56+
wkb_geometry,
57+
ST_MakeEnvelope(:xmin, :ymin, :xmax, :ymax, 4326)
58+
)
59+
AND
60+
ST_IsValid(wkb_geometry)
61+
''')
62+
63+
sql = stmt.bindparams(xmin=xmin, ymin=ymin, xmax=xmax, ymax=ymax)
64+
result = await session.execute(sql)
65+
rows = result.mappings().all()
66+
67+
return rows
68+
69+
70+
async def get_police_station_geometries_by_lat_lng(
71+
session: AsyncSession,
72+
lat: float,
73+
lng: float,
74+
radius: float = 1000
75+
):
76+
stmt = text('''
77+
SELECT
78+
id,
79+
ST_AsGeoJSON(wkb_geometry, 15) AS geojson,
80+
name AS label
81+
FROM
82+
sh_police_station
83+
WHERE
84+
ST_DWithin(
85+
wkb_geometry::geography,
86+
ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography,
87+
:radius
88+
)
89+
AND
90+
ST_IsValid(wkb_geometry)
91+
''')
92+
93+
sql = stmt.bindparams(lat=lat, lng=lng, radius=radius)
94+
result = await session.execute(sql)
95+
rows = result.mappings().all()
96+
97+
return rows

0 commit comments

Comments
 (0)