2025-09-15 23:25:11 +02:00
"""
Event resource for the OpenEventDatabase .
"""
import json
import re
import falcon
import psycopg2
import psycopg2 . extras
2025-09-15 23:54:04 +02:00
import psycopg2 . errors
2025-09-15 23:25:11 +02:00
from oedb . models . event import BaseEvent
from oedb . utils . db import db_connect
from oedb . utils . serialization import dumps
from oedb . utils . logging import logger
class EventResource ( BaseEvent ) :
"""
Resource for managing events .
Handles the / event and / event / { id } endpoints .
"""
def maybe_insert_geometry ( self , geometry , cur ) :
"""
Insert a geometry into the geo table if it doesn ' t exist.
Args :
geometry : The GeoJSON geometry .
cur : The database cursor .
Returns :
tuple : The hash of the geometry .
"""
logger . debug ( " Inserting geometry if not exists " )
# Insert into geo table if not existing
cur . execute ( """ INSERT INTO geo
SELECT geom , md5 ( st_astext ( geom ) ) as hash , st_centroid ( geom ) as geom_center FROM
( SELECT st_setsrid ( st_geomfromgeojson ( % s ) , 4326 ) as geom ) as g
WHERE ST_IsValid ( geom )
ON CONFLICT DO NOTHING RETURNING hash ; """ ,
( geometry , ) )
# Get its id (md5 hash)
h = cur . fetchone ( )
if h is None :
cur . execute ( """ SELECT md5(st_asewkt(geom)),
ST_IsValid ( geom ) ,
ST_IsValidReason ( geom ) from ( SELECT st_geomfromgeojson ( % s ) as geom ) as g ; """ , (geometry,))
h = cur . fetchone ( )
return h
def relative_time ( self , when , cur ) :
"""
Convert a relative time string to database - friendly format .
Args :
when : The relative time string .
cur : The database cursor .
Returns :
tuple : The start and stop times .
"""
logger . debug ( f " Converting relative time: { when } " )
when = when . upper ( ) . replace ( ' ' , ' + ' )
event_start = cur . mogrify ( " %s " , ( when , ) ) . decode ( " utf-8 " )
event_stop = cur . mogrify ( " %s " , ( when , ) ) . decode ( " utf-8 " )
if when == ' NOW ' :
event_start = " now() "
event_stop = " now() "
if when == ' TODAY ' :
event_start = " CURRENT_DATE "
event_stop = " CURRENT_DATE + INTERVAL ' 1 DAY ' "
if when == ' TOMORROW ' :
event_start = " CURRENT_DATE + INTERVAL ' 1 DAY ' "
event_stop = " CURRENT_DATE + INTERVAL ' 2 DAY ' "
if when == ' YESTERDAY ' :
event_start = " CURRENT_DATE - INTERVAL ' 1 DAY ' "
event_stop = " CURRENT_DATE "
m = re . match ( ' (LAST|NEXT)(YEAR|MONTH|WEEK|DAY|HOUR|MINUTE) ' , when )
if m is not None :
when = m . group ( 1 ) + ' 1 ' + m . group ( 2 ) + ' S '
m = re . match ( ' (LAST|NEXT)([0-9]*)(YEAR|MONTH|WEEK|MINUTE|HOUR|DAY)S ' , when )
if m is not None :
if m . group ( 1 ) == ' LAST ' :
event_start = " now() - INTERVAL ' %s %s ' " % m . group ( 2 , 3 )
event_stop = " now() "
else :
event_start = " now() "
event_stop = " now() + INTERVAL ' %s %s ' " % m . group ( 2 , 3 )
return event_start , event_stop
def on_get ( self , req , resp , id = None , geom = None ) :
"""
Handle GET requests to the / event and / event / { id } endpoints .
Args :
req : The request object .
resp : The response object .
id : The event ID ( optional ) .
geom : The geometry to search with ( optional ) .
"""
logger . info ( f " Processing GET request to /event { f ' / { id } ' if id else ' ' } " )
db = db_connect ( )
cur = db . cursor ( cursor_factory = psycopg2 . extras . DictCursor )
try :
if id is None :
event_sort = " createdate DESC "
# Get query search parameters
if geom is not None :
# Convert our geojson geom to WKT
geoj = json . dumps ( geom )
# Buffer around geom?
if ' buffer ' in req . params :
buffer = float ( req . params [ ' buffer ' ] )
elif geom [ ' type ' ] == ' Linestring ' :
buffer = 1000 # 1km buffer by default around Linestrings
else :
buffer = 0
if buffer == 0 :
event_bbox = cur . mogrify ( " AND ST_Intersects(geom, ST_SetSRID(ST_GeomFromGeoJSON( %s ),4326)) " , ( geoj , ) ) . decode ( " utf-8 " )
else :
event_bbox = cur . mogrify ( " AND ST_Intersects(geom, ST_Buffer(ST_SetSRID(ST_GeomFromGeoJSON( %s ),4326)::geography, %s )::geometry) " , ( geoj , buffer ) ) . decode ( " utf-8 " )
event_dist = cur . mogrify ( " ST_Length(ST_ShortestLine(geom, ST_SetSRID(ST_GeomFromGeoJSON( %s ),4326))::geography)::integer as distance, " , ( geoj , ) ) . decode ( " utf-8 " )
event_sort = cur . mogrify ( " ST_Length(ST_ShortestLine(geom, ST_SetSRID(ST_GeomFromGeoJSON( %s ),4326))::geography)::integer, " , ( geoj , ) ) . decode ( " utf-8 " ) + event_sort
elif ' bbox ' in req . params :
# Limit search with bbox (E,S,W,N)
event_bbox = cur . mogrify ( " AND geom && ST_SetSRID(ST_MakeBox2D(ST_Point( %s , %s ),ST_Point( %s , %s )),4326) " , tuple ( req . params [ ' bbox ' ] ) ) . decode ( " utf-8 " )
event_dist = " "
elif ' near ' in req . params :
# Limit search with location+distance
# (long, lat, distance in meters)
if len ( req . params [ ' near ' ] ) < 3 :
dist = 1
else :
dist = req . params [ ' near ' ] [ 2 ]
event_bbox = cur . mogrify ( " AND ST_Intersects(geom, ST_Buffer(st_setsrid(st_makepoint( %s , %s ),4326)::geography, %s )::geometry) " , ( req . params [ ' near ' ] [ 0 ] , req . params [ ' near ' ] [ 1 ] , dist ) ) . decode ( " utf-8 " )
event_dist = cur . mogrify ( " ST_Length(ST_ShortestLine(geom, st_setsrid(st_makepoint( %s , %s ),4326))::geography)::integer as distance, " , ( req . params [ ' near ' ] [ 0 ] , req . params [ ' near ' ] [ 1 ] ) ) . decode ( " utf-8 " )
event_sort = cur . mogrify ( " ST_Length(ST_ShortestLine(geom, st_setsrid(st_makepoint( %s , %s ),4326))::geography)::integer, " , ( req . params [ ' near ' ] [ 0 ] , req . params [ ' near ' ] [ 1 ] ) ) . decode ( " utf-8 " ) + event_sort
elif ' polyline ' in req . params :
# Use encoded polyline as search geometry
if ' buffer ' in req . params :
buffer = float ( req . params [ ' buffer ' ] )
else :
buffer = 1000
if ' polyline_precision ' in req . params :
precision = int ( req . params [ ' polyline_precision ' ] )
else :
precision = 5
# ST_Scale is a workaround to postgis bug not taking precision into account in ST_LineFromEncodedPolyline
event_bbox = cur . mogrify ( " AND ST_Intersects(geom, ST_Buffer(ST_Scale(ST_LineFromEncodedPolyline( %s ),1/10^( %s -5),1/10^( %s -5))::geography, %s )::geometry) " , ( req . params [ ' polyline ' ] , precision , precision , buffer ) ) . decode ( " utf-8 " )
event_dist = cur . mogrify ( " ST_Length(ST_ShortestLine(geom, ST_Scale(ST_LineFromEncodedPolyline( %s ),1/10^( %s -5),1/10^( %s -5)))::geography)::integer as distance, " , ( req . params [ ' polyline ' ] , precision , precision ) ) . decode ( " utf-8 " )
elif ' where:osm ' in req . params :
event_bbox = cur . mogrify ( " AND events_tags ? ' where:osm ' AND events_tags->> ' where:osm ' = %s " , ( req . params [ ' where:osm ' ] , ) ) . decode ( " utf-8 " )
event_dist = " "
elif ' where:wikidata ' in req . params :
event_bbox = cur . mogrify ( " AND events_tags ? ' where:wikidata ' AND events_tags->> ' where:wikidata ' = %s " , ( req . params [ ' where:wikidata ' ] , ) ) . decode ( " utf-8 " )
event_dist = " "
else :
event_bbox = " "
event_dist = " "
if ' when ' in req . params :
# Limit search with fixed time
when = req . params [ ' when ' ] . upper ( )
event_when = " tstzrange( %s , %s , ' [] ' ) " % ( self . relative_time ( when , cur ) )
elif ' start ' in req . params and ' stop ' in req . params :
# Limit search with fixed time (start to stop)
event_start , unused = self . relative_time ( req . params [ ' start ' ] , cur )
unused , event_stop = self . relative_time ( req . params [ ' stop ' ] , cur )
event_when = " tstzrange( %s , %s , ' [] ' ) " % ( event_start , event_stop )
elif ' start ' in req . params and ' stop ' not in req . params :
# Limit search with fixed time (start to now)
event_start , unused = self . relative_time ( req . params [ ' start ' ] , cur )
event_when = " tstzrange( %s ,now(), ' [] ' ) " % event_start
elif ' start ' not in req . params and ' stop ' in req . params :
# Limit search with fixed time (now to stop)
unused , event_stop = self . relative_time ( req . params [ ' stop ' ] , cur )
event_when = " tstzrange(now(), %s , ' [] ' ) " % event_stop
else :
2025-09-16 00:46:09 +02:00
# Return events that are currently active (current time is between start and stop)
event_when = " now() "
2025-09-15 23:25:11 +02:00
if ' what ' in req . params :
# Limit search based on "what"
event_what = cur . mogrify ( " AND events_what LIKE %s AND events_what LIKE %s " , ( req . params [ ' what ' ] [ : 4 ] + " % " , req . params [ ' what ' ] + " % " ) ) . decode ( " utf-8 " )
else :
event_what = " "
if ' type ' in req . params :
# Limit search based on type (scheduled, forecast, unscheduled)
event_type = cur . mogrify ( " AND events_type = %s " , ( req . params [ ' type ' ] , ) ) . decode ( " utf-8 " )
else :
event_type = " "
if ' limit ' in req . params :
limit = cur . mogrify ( " LIMIT %s " , ( req . params [ ' limit ' ] , ) ) . decode ( " utf-8 " )
else :
limit = " LIMIT 200 "
event_geom = " geom_center "
geom_only = False
if ' geom ' in req . params :
if req . params [ ' geom ' ] == ' full ' :
event_geom = " geom "
elif req . params [ ' geom ' ] == ' only ' :
geom_only = True
else :
event_geom = cur . mogrify ( " ST_SnapToGrid(geom, %s ) " , ( req . params [ ' geom ' ] , ) ) . decode ( " utf-8 " )
# Search recent active events.
2025-09-16 00:46:09 +02:00
if event_when == " now() " :
# Use @> operator to check if events_when contains current time
sql = """ SELECT events_id, events_tags, createdate, lastupdate, {event_dist} st_asgeojson( {event_geom} ) as geometry, st_x(geom_center) as lon, st_y(geom_center) as lat
FROM events JOIN geo ON ( hash = events_geo )
WHERE events_when @ > { event_when } { event_what } { event_type } { event_bbox }
ORDER BY { event_sort } { limit } """
else :
# Use && operator to check if events_when overlaps with event_when
sql = """ SELECT events_id, events_tags, createdate, lastupdate, {event_dist} st_asgeojson( {event_geom} ) as geometry, st_x(geom_center) as lon, st_y(geom_center) as lat
FROM events JOIN geo ON ( hash = events_geo )
WHERE events_when & & { event_when } { event_what } { event_type } { event_bbox }
ORDER BY { event_sort } { limit } """
2025-09-15 23:25:11 +02:00
# No user generated content here, so format is safe.
sql = sql . format ( event_dist = event_dist , event_geom = event_geom ,
event_bbox = event_bbox , event_what = event_what ,
event_when = event_when , event_type = event_type ,
event_sort = event_sort , limit = limit )
logger . debug ( f " Executing SQL: { sql } " )
cur . execute ( sql )
2025-09-15 23:54:04 +02:00
resp . text = dumps ( self . rows_to_collection ( cur . fetchall ( ) , geom_only ) )
2025-09-15 23:25:11 +02:00
resp . status = falcon . HTTP_200
logger . success ( f " Successfully processed GET request to /event " )
else :
# Get single event geojson Feature by id.
logger . debug ( f " Fetching event with ID: { id } " )
cur . execute ( " SELECT events_id, events_tags, createdate, lastupdate, st_asgeojson(geom) as geometry, st_x(geom_center) as lon, st_y(geom_center) as lat FROM events JOIN geo ON (hash=events_geo) WHERE events_id= %s " , [ id ] )
e = cur . fetchone ( )
if e is not None :
2025-09-15 23:54:04 +02:00
resp . text = dumps ( self . row_to_feature ( e ) )
2025-09-15 23:25:11 +02:00
resp . status = falcon . HTTP_200
logger . success ( f " Successfully processed GET request to /event/ { id } " )
else :
resp . status = falcon . HTTP_404
logger . warning ( f " Event not found: { id } " )
2025-09-15 23:54:04 +02:00
except psycopg2 . errors . InsufficientPrivilege as e :
logger . error ( f " Permission denied for database table: { e } " )
resp . status = falcon . HTTP_500
resp . text = dumps ( {
" error " : " Database permission error " ,
" message " : " The server does not have permission to access the required database tables. Please contact the administrator to fix this issue. " ,
" details " : str ( e )
} )
2025-09-15 23:25:11 +02:00
except Exception as e :
logger . error ( f " Error processing GET request: { e } " )
resp . status = falcon . HTTP_500
2025-09-15 23:54:04 +02:00
resp . text = dumps ( { " error " : str ( e ) } )
2025-09-15 23:25:11 +02:00
finally :
db . close ( )
def insert_or_update ( self , req , resp , id , query ) :
"""
Insert or update an event .
Args :
req : The request object .
resp : The response object .
id : The event ID ( optional ) .
query : The SQL query to execute .
"""
logger . info ( f " Processing { ' UPDATE ' if id else ' INSERT ' } request for event { f ' { id } ' if id else ' ' } " )
# Get request body payload (geojson Feature)
try :
body = req . stream . read ( ) . decode ( ' utf-8 ' )
j = json . loads ( body )
except Exception as e :
logger . error ( f " Invalid JSON or bad encoding: { e } " )
2025-09-15 23:54:04 +02:00
resp . text = ' invalid json or bad encoding '
2025-09-15 23:25:11 +02:00
resp . status = falcon . HTTP_400
return
2025-09-15 23:54:04 +02:00
resp . text = ' '
2025-09-15 23:25:11 +02:00
if " properties " not in j :
2025-09-15 23:54:04 +02:00
resp . text = resp . text + " missing ' properties ' elements \n "
2025-09-15 23:25:11 +02:00
j [ ' properties ' ] = dict ( )
if " geometry " not in j :
2025-09-15 23:54:04 +02:00
resp . text = resp . text + " missing ' geometry ' elements \n "
2025-09-15 23:25:11 +02:00
j [ ' geometry ' ] = None
if " when " not in j [ ' properties ' ] and ( " start " not in j [ ' properties ' ] or " stop " not in j [ ' properties ' ] ) :
2025-09-15 23:54:04 +02:00
resp . text = resp . text + " missing ' when ' or ' start/stop ' in properties \n "
2025-09-15 23:25:11 +02:00
j [ ' properties ' ] [ ' when ' ] = None
if " type " not in j [ ' properties ' ] :
2025-09-15 23:54:04 +02:00
resp . text = resp . text + " missing ' type ' of event in properties \n "
2025-09-15 23:25:11 +02:00
j [ ' properties ' ] [ ' type ' ] = None
if " what " not in j [ ' properties ' ] :
2025-09-15 23:54:04 +02:00
resp . text = resp . text + " missing ' what ' in properties \n "
2025-09-15 23:25:11 +02:00
j [ ' properties ' ] [ ' what ' ] = None
if " type " in j and j [ ' type ' ] != ' Feature ' :
2025-09-15 23:54:04 +02:00
resp . text = resp . text + ' geojson must be " type " : " Feature " only \n '
if id is None and resp . text != ' ' :
2025-09-15 23:25:11 +02:00
resp . status = falcon . HTTP_400
resp . set_header ( ' Content-type ' , ' text/plain ' )
return
if ' when ' in j [ ' properties ' ] :
event_when = j [ ' properties ' ] [ ' when ' ]
if " start " not in j [ ' properties ' ] :
event_start = j [ ' properties ' ] [ ' when ' ]
else :
event_start = j [ ' properties ' ] [ ' start ' ]
if " stop " not in j [ ' properties ' ] :
event_stop = j [ ' properties ' ] [ ' when ' ]
else :
event_stop = j [ ' properties ' ] [ ' stop ' ]
if event_start == event_stop :
bounds = ' [] '
else :
bounds = ' [) '
# Connect to db and insert
db = db_connect ( )
cur = db . cursor ( )
# 'secret' based authentication
if ' secret ' in j [ ' properties ' ] :
secret = cur . mogrify ( " AND (events_tags->> ' secret ' = %s OR events_tags->> ' secret ' IS NULL) " , ( j [ ' properties ' ] [ ' secret ' ] , ) ) . decode ( " utf-8 " )
elif ' secret ' in req . params :
secret = cur . mogrify ( " AND (events_tags->> ' secret ' = %s OR events_tags->> ' secret ' IS NULL) " , ( req . params [ ' secret ' ] , ) ) . decode ( " utf-8 " )
else :
secret = " AND events_tags->> ' secret ' IS NULL "
# Get the geometry part
if j [ ' geometry ' ] is not None :
geometry = dumps ( j [ ' geometry ' ] )
h = self . maybe_insert_geometry ( geometry , cur )
if len ( h ) > 1 and h [ 1 ] is False :
2025-09-15 23:54:04 +02:00
resp . text = " invalid geometry: %s \n " % h [ 2 ]
2025-09-15 23:25:11 +02:00
resp . status = falcon . HTTP_400
resp . set_header ( ' Content-type ' , ' text/plain ' )
return
else :
h = [ None ]
params = ( j [ ' properties ' ] [ ' type ' ] , j [ ' properties ' ] [ ' what ' ] , event_start , event_stop , bounds , dumps ( j [ ' properties ' ] ) , h [ 0 ] )
if id :
params = params + ( id , )
e = None
rows = None
try :
sql = cur . mogrify ( query , params )
cur . execute ( query . format ( secret = secret ) , params )
rows = cur . rowcount
# Get newly created event id
e = cur . fetchone ( )
db . commit ( )
logger . debug ( f " SQL executed successfully: { sql } " )
except psycopg2 . Error as err :
logger . error ( f " Database error: { err } , SQL: { sql } , Error: { err . pgerror } " )
db . rollback ( )
pass
# Send back to client
if e is None :
if id is None :
cur . execute ( """ SELECT events_id FROM events WHERE events_what= %s
AND events_when = tstzrange ( % s , % s , % s ) AND events_geo = % s ; """ ,
( j [ ' properties ' ] [ ' what ' ] , event_start , event_stop , bounds , h [ 0 ] ) )
else :
if rows == 0 :
if ' secret ' in req . params or ' secret ' in j [ ' properties ' ] :
resp . status = ' 403 Unauthorized, secret does not match '
else :
resp . status = ' 403 Unauthorized, secret required '
return
else :
cur . execute ( """ END; WITH s AS (SELECT * FROM events WHERE events_id = %s ) SELECT e.events_id FROM events e, s WHERE e.events_what=coalesce( %s , s.events_what)
AND e . events_when = tstzrange ( coalesce ( % s , lower ( s . events_when ) ) , coalesce ( % s , upper ( s . events_when ) ) , % s ) AND e . events_geo = coalesce ( % s , s . events_geo ) ; """ ,
( id , j [ ' properties ' ] [ ' what ' ] , event_start , event_stop , bounds , h [ 0 ] ) )
dupe = cur . fetchone ( )
2025-09-15 23:54:04 +02:00
resp . text = """ { " duplicate " : " %s " } """ % ( dupe [ 0 ] )
2025-09-15 23:25:11 +02:00
resp . status = ' 409 Conflict with event %s ' % dupe [ 0 ]
logger . warning ( f " Duplicate event: { dupe [ 0 ] } " )
else :
2025-09-15 23:54:04 +02:00
resp . text = """ { " id " : " %s " } """ % ( e [ 0 ] )
2025-09-15 23:25:11 +02:00
if id is None :
resp . status = falcon . HTTP_201
logger . success ( f " Event created with ID: { e [ 0 ] } " )
else :
resp . status = falcon . HTTP_200
logger . success ( f " Event updated with ID: { e [ 0 ] } " )
cur . close ( )
db . close ( )
def on_post ( self , req , resp ) :
"""
Handle POST requests to the / event endpoint .
Creates a new event .
Args :
req : The request object .
resp : The response object .
"""
logger . info ( " Processing POST request to /event " )
self . insert_or_update ( req , resp , None , """ INSERT INTO events ( events_type, events_what, events_when, events_tags, events_geo) VALUES ( %s , %s , tstzrange( %s , %s , %s ) , %s , %s ) ON CONFLICT DO NOTHING RETURNING events_id; """ )
def on_put ( self , req , resp , id ) :
"""
Handle PUT requests to the / event / { id } endpoint .
Updates an existing event ( acts like PATCH ) .
Args :
req : The request object .
resp : The response object .
id : The event ID .
"""
logger . info ( f " Processing PUT request to /event/ { id } " )
self . on_patch ( req , resp , id )
def on_patch ( self , req , resp , id ) :
"""
Handle PATCH requests to the / event / { id } endpoint .
Updates an existing event .
Args :
req : The request object .
resp : The response object .
id : The event ID .
"""
logger . info ( f " Processing PATCH request to /event/ { id } " )
# Coalesce are used to PATCH the data (new value may be NULL to keep the old one)
self . insert_or_update ( req , resp , id , """ UPDATE events SET ( events_type, events_what, events_when, events_tags, events_geo) = (coalesce( %s , events_type), coalesce( %s , events_what), tstzrange(coalesce( %s , lower(events_when)),coalesce( %s , upper(events_when)), %s ) , events_tags::jsonb || ( %s ::jsonb - ' secret ' ) , coalesce( %s , events_geo))
WHERE events_id = % s { secret } RETURNING events_id ; """ )
def on_delete ( self , req , resp , id ) :
"""
Handle DELETE requests to the / event / { id } endpoint .
Deletes an existing event .
Args :
req : The request object .
resp : The response object .
id : The event ID .
"""
logger . info ( f " Processing DELETE request to /event/ { id } " )
db = db_connect ( )
cur = db . cursor ( )
try :
cur . execute ( """ INSERT INTO events_deleted SELECT events_id, createdate, lastupdate, events_type, events_what, events_when, events_geo, events_tags FROM events WHERE events_id = %s """ , ( id , ) )
rows_insert = cur . rowcount
# 'secret' based authentication, must be null or same as during POST
if ' secret ' in req . params :
cur . execute ( """ DELETE FROM events WHERE events_id = %s AND (events_tags->> ' secret ' = %s OR events_tags->> ' secret ' IS NULL) """ , ( id , req . params [ ' secret ' ] ) )
else :
cur . execute ( """ DELETE FROM events WHERE events_id = %s AND events_tags->> ' secret ' IS NULL; """ , ( id , ) )
if cur . rowcount == 1 :
resp . status = " 204 event deleted "
db . commit ( )
logger . success ( f " Event deleted: { id } " )
elif rows_insert == 1 : # INSERT ok but DELETE fails due to missing secret...
resp . status = " 403 Unauthorized, secret needed to delete this event "
db . rollback ( )
logger . warning ( f " Unauthorized deletion attempt for event: { id } " )
else :
resp . status = " 404 event not found "
logger . warning ( f " Event not found for deletion: { id } " )
except Exception as e :
logger . error ( f " Error deleting event { id } : { e } " )
resp . status = falcon . HTTP_500
2025-09-15 23:54:04 +02:00
resp . text = dumps ( { " error " : str ( e ) } )
2025-09-15 23:25:11 +02:00
db . rollback ( )
finally :
cur . close ( )
db . close ( )
# Create a global instance of EventResource
event = EventResource ( )