1010from typing import List
1111
1212from fastapi import APIRouter , HTTPException , Depends
13+ from fastapi .responses import Response
14+ from datetime import datetime , timedelta
15+ from icalendar import Calendar , Event , vText , Alarm
16+ from pytz import utc
17+ from bs4 import BeautifulSoup
18+ import re
1319
1420from api .security import api_key_valid
1521from api .models .run import Hash , HashParams
1622from api .models .exceptions import APITokenValidationFailed
1723from api .factory .runs import get_hash_runs
24+ from config .api import BasicAPISettings
25+ from common .misc import format_slug
26+ import config
1827
1928router_runs = APIRouter (
2029 prefix = "/runs" ,
@@ -38,6 +47,122 @@ async def get_runs(params: HashParams = Depends(HashParams), key_valid: bool = D
3847 return result
3948
4049
50+ @router_runs .get ("/calendar" , summary = "List of runs as iCal events" , description = "Returns Hash runs as iCal events" ,
51+ # Set what the media type will be in the autogenerated OpenAPI specification.
52+ # fastapi.tiangolo.com/advanced/additional-responses/#additional-media-types-for-the-main-response
53+ responses = {
54+ 200 : {
55+ "content" : {"text/calendar" : {
56+ "example" : "\n " .join (
57+ ["BEGIN:VCALENDAR" ,
58+ "VERSION:2.0" ,
59+ f"PRODID:-//wordpress-hash-event-api/{ BasicAPISettings ().version } //" ,
60+ "CALSCALE:GREGORIAN" ,
61+ "METHOD:PUBLISH" ,
62+ f"X-WR-CALNAME:{ config .calendar_settings .name } " ,
63+ "X-WR-TIMEZONE:Europe/Berlin" ,
64+ "BEGIN:VEVENT" ,
65+ "SUMMARY:Nerd H3 Run #1234" ,
66+ "DTSTART;TZID=Europe/Berlin:20231105T144500" ,
67+ "DTEND;TZID=Europe/Berlin:20231105T164500" ,
68+ "UID:wordpress-hash-event-api-event/676" ,
69+ "DESCRIPTION:More details to follow soon!\\ n\\ nHash Cash: 4€\\ nLocation URL:" ,
70+ " https://www.openstreetmap.org/?mlat=52.4811867&mlon=13.525649#map=17/52.4" ,
71+ " 811867/13.525649" ,
72+ "LAST-MODIFIED:20231026T085048Z" ,
73+ "LOCATION:Karlshorst\, 10318 Berlin\, Germany" ,
74+ "NAME:Nerd H3 Run #1234" ,
75+ "END:VEVENT" ,
76+ "ENV:VCALENDAR"
77+ ])
78+ },
79+ },
80+ "description" : "OK - Returns a list of calender events in icalendar format"
81+ }
82+ },
83+ # Prevent FastAPI from adding "application/json" as an additional
84+ # response media type in the autogenerated OpenAPI specification.
85+ # https://github.com/tiangolo/fastapi/issues/3258
86+ response_class = Response
87+ )
88+ async def get_runs_as_icalendar (params : HashParams = Depends (HashParams ), key_valid : bool = Depends (api_key_valid )):
89+
90+ if key_valid is False :
91+ raise APITokenValidationFailed
92+
93+ main_config = BasicAPISettings ()
94+
95+ if params .start_date__gt is None :
96+ params .start_date__gt = datetime .now (tz = utc ) - timedelta (weeks = config .calendar_settings .num_past_weeks_exposed )
97+
98+ params .last_update__gt = datetime .now () - timedelta (weeks = config .calendar_settings .num_past_weeks_exposed )
99+
100+ # init the calendar
101+ cal = Calendar ()
102+
103+ # Some properties are required to be compliant
104+ cal .add ('prodid' , f'-//wordpress-hash-event-api/{ main_config .version } //' )
105+ cal .add ('version' , '2.0' )
106+ cal .add ('X-WR-CALNAME' , config .calendar_settings .name )
107+ cal .add ('X-WR-TIMEZONE' , config .app_settings .timezone_string )
108+ cal .add ('CALSCALE' , 'GREGORIAN' )
109+ cal .add ('METHOD' , 'PUBLISH' )
110+
111+ for run in get_hash_runs (params ) or list ():
112+
113+ # hide runs which are deleted or meant to not show up
114+ if run .deleted or run .event_hidden :
115+ continue
116+
117+ if run .end_date is None :
118+ run .end_date = run .start_date + timedelta (hours = 2 )
119+
120+ event_description = ""
121+ # parse html data back to strings
122+ if run .event_description is not None :
123+ event_description = BeautifulSoup (run .event_description .replace ("<br>" , "\n " ),
124+ features = "html.parser" ).get_text ()
125+
126+ # reduce too many new lines
127+ event_description = re .sub (r'\n(\n)+' , '\n \n ' , event_description ).strip ()
128+
129+ # add has cash and location line
130+ event_description += f'\n \n Hash Cash: { run .hash_cash_members } { run .event_currency } \n '
131+ event_description += f'Location URL: { run .geo_map_url } '
132+
133+ event = Event ()
134+ event .add ('uid' , f"wordpress-hash-event-api-event/{ run .id } " )
135+ event .add ('name' , run .event_name )
136+ event .add ('summary' , run .event_name )
137+ event .add ('description' , event_description )
138+ event .add ('dtstart' , run .start_date )
139+ event .add ('dtend' , run .end_date )
140+ event .add ('last-modified' , run .last_update )
141+ event .add ('location' , vText (run .geo_location_name ))
142+ event .add ('url' , run .event_url , {"VALUE" : "URI" })
143+
144+ if run .geo_lat and run .geo_long :
145+ event .add ('X-APPLE-STRUCTURED-LOCATION' , f'geo:{ run .geo_lat } ,{ run .geo_long } ' ,
146+ {"X-TITLE" : vText (run .geo_location_name )})
147+
148+ if config .calendar_settings .enable_event_alarm :
149+ alarm = Alarm ()
150+ alarm .add ("trigger" , timedelta (hours = - 1 ))
151+ alarm .add ("uid" , f"wordpress-hash-event-api-event-alarm/{ run .id } " )
152+ alarm .add ("description" , run .event_name )
153+ alarm .add ("action" , "AUDIO" )
154+ alarm .add ("ATTACH" , "Chord" , {"VALUE" : "URI" })
155+
156+ event .add_component (alarm )
157+
158+ cal .add_component (event )
159+
160+ return Response (content = cal .to_ical (),
161+ media_type = "text/calendar" ,
162+ headers = {"content-disposition" :
163+ f"attachment; filename={ format_slug (config .calendar_settings .name )} .ics" })
164+
165+
41166# noinspection PyShadowingBuiltins
42167@router_runs .get ("/{id}" , response_model = Hash , summary = "Returns a single Hash run" )
43168async def get_run (id : int , key_valid : bool = Depends (api_key_valid )):
0 commit comments