Skip to content

Commit 9e34057

Browse files
authored
Merge pull request #11 from bb-Ricardo/development
add support for icalendar subscription of runs
2 parents 310ad99 + c178ed0 commit 9e34057

12 files changed

Lines changed: 214 additions & 26 deletions

File tree

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ You need to have a running Wordpress site with 'WP Event Manager' Plugin install
1818
* phpserialize
1919
* mysql-connector
2020
* psutil
21+
* icalendar
22+
* beautifulsoup4
2123

2224
### WP Event Manager Plugin
2325
* WP Event Manager >= 3.1.21
@@ -123,6 +125,17 @@ to your server block configuration. Make sure to adjust your IP and port accordi
123125
#proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
124126
#proxy_set_header X-Forwarded-Proto $scheme;
125127
}
128+
# expose icalender evets
129+
location /events.ics {
130+
proxy_pass http://127.0.0.1:8000/runs/calendar;
131+
proxy_set_header Host $host;
132+
proxy_set_header X-Real-IP $remote_addr;
133+
# activate to see the actual remote IP and not just your reverse proxy
134+
# attention: in Europe this has implications on your GDPR statements on your page
135+
# as you log IP addresses.
136+
#proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
137+
#proxy_set_header X-Forwarded-Proto $scheme;
138+
}
126139
```
127140

128141
## Setup

api/factory/runs.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ def compare_attributes(value_a, value_b):
7373

7474
matches = list()
7575
for key, value in params.dict().items():
76+
77+
# skip unsupported keys like: __pydantic_initialised__
78+
if key.startswith("__"):
79+
continue
80+
7681
if value is None or key in ["id", "limit"]:
7782
continue
7883

api/routers/runs.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,20 @@
1010
from typing import List
1111

1212
from 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

1420
from api.security import api_key_valid
1521
from api.models.run import Hash, HashParams
1622
from api.models.exceptions import APITokenValidationFailed
1723
from api.factory.runs import get_hash_runs
24+
from config.api import BasicAPISettings
25+
from common.misc import format_slug
26+
import config
1827

1928
router_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\nHash 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")
43168
async def get_run(id: int, key_valid: bool = Depends(api_key_valid)):

config-example.ini

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,23 @@ hash_kennels = Nerd H3, Nerd Full Moon H3
129129
# the id or ids of the list to send this campaign to
130130
#list_ids =
131131

132-
[database]
132+
133+
###
134+
### [calendar]
135+
###
136+
### settings for exposing runs as icalendar events
137+
###
138+
139+
[calendar]
140+
141+
# define a default name for the calendar
142+
#name = Nerd H3 events
143+
144+
# Adds an alarm of one hour before the event to each event
145+
#enable_event_alarm = true
146+
147+
# Define the number weeks of past events to add to calendar data
148+
#num_past_weeks_exposed = 2
133149

134150

135151
###

config/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@
1313
from pydantic import ValidationError
1414

1515
from config.models.app import AppSettings
16+
from config.models.calendar import CalendarConfigSettings
1617
from common.log import get_logger
1718

1819
logger = get_logger()
1920

2021

2122
app_settings = AppSettings(hash_kennels="EMPTY")
23+
calendar_settings = CalendarConfigSettings()
2224

2325

2426
def validate_config_object(config_class, settings):

config/api.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@
1111

1212

1313
class BasicAPISettings(BaseModel):
14-
description = 'Hash Run API for WordPress Event Manager'
15-
title = 'Kennel Runs API'
16-
openapi_url = "/openapi.json"
17-
root_path = "/api/v1"
18-
version = '1.0.1'
19-
debug = False
14+
description: str = 'Hash Run API for WordPress Event Manager'
15+
title: str = 'Kennel Runs API'
16+
openapi_url: str = "/openapi.json"
17+
root_path: str = "/api/v1"
18+
version: str = '1.0.1'
19+
debug: bool = False
2020

2121
# EOF

config/models/api.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
# repository or visit: <https://opensource.org/licenses/MIT>.
99

1010
from config.models import EnvOverridesBaseSettings
11+
from typing import Union
1112

1213

1314
class APIConfigSettings(EnvOverridesBaseSettings):
14-
token: str = None
15+
token: Union[str, None] = None
1516
root_path: str = "/api/v1"
1617

1718
class Config:

config/models/app.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,12 @@
2323
# noinspection PyMethodParameters
2424
class AppSettings(EnvOverridesBaseSettings):
2525
hash_kennels: Union[str, List]
26-
default_hash_cash: int = None
27-
default_hash_cash_non_members: int = None
26+
default_hash_cash: Union[int, None] = None
27+
default_hash_cash_non_members: Union[int, None] = None
2828
default_run_type: str = "Regular Run"
29-
default_currency: str = None
30-
default_facebook_group_id: int = None
31-
timezone_string: str = None
29+
default_currency: Union[str, None] = None
30+
default_facebook_group_id: Union[int, None] = None
31+
timezone_string: Union[str, None] = None
3232
maps_url_template: AnyHttpUrl = maps_url_template
3333

3434
# currently not implemented in WP Event manager
@@ -65,7 +65,7 @@ def split_hash_kennels(cls, value):
6565
def check_maps_url_formatting(cls, value):
6666

6767
try:
68-
value.format(lat=123, long=456)
68+
str(value).format(lat=123, long=456)
6969
except KeyError as e:
7070
log.error(f"Unable to parse 'maps_url_template' formatting, KeyError: {e}. Using default value.")
7171
return maps_url_template

config/models/calendar.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright (c) 2022 Ricardo Bartels. All rights reserved.
3+
#
4+
# wordpress-hash-event-api
5+
#
6+
# This work is licensed under the terms of the MIT license.
7+
# For a copy, see file LICENSE.txt included in this
8+
# repository or visit: <https://opensource.org/licenses/MIT>.
9+
10+
from config.models import EnvOverridesBaseSettings
11+
12+
13+
class CalendarConfigSettings(EnvOverridesBaseSettings):
14+
name: str = "Hash events"
15+
enable_event_alarm: bool = False
16+
num_past_weeks_exposed: int = 2
17+
18+
class Config:
19+
env_prefix = f"{__name__.split('.')[-1]}_"

listmonk/handler.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
# repository or visit: <https://opensource.org/licenses/MIT>.
99

1010
import json
11-
import requests
11+
from requests import session, Request
12+
from requests.exceptions import ConnectionError, ReadTimeout, JSONDecodeError as RequestsJSONDecodeError
1213
from typing import Union
1314

1415
from config.api import BasicAPISettings
@@ -46,7 +47,7 @@ def init_session(self):
4647
"Content-Type": "application/json"
4748
}
4849

49-
self.session = requests.session()
50+
self.session = session()
5051
self.session.auth = (self.config.username, self.config.password)
5152
self.session.headers.update(header)
5253

@@ -124,21 +125,21 @@ def request(self, endpoint, req_type="GET", data=None, params=None):
124125

125126
# prepare request
126127
this_request = self.session.prepare_request(
127-
requests.Request(req_type, request_url, params=params, json=data)
128+
Request(req_type, request_url, params=params, json=data)
128129
)
129130

130131
# issue request
131132
try:
132133
response = self.session.send(this_request, timeout=5)
133-
except (ConnectionError, requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout) as e:
134+
except (ConnectionError, ReadTimeout) as e:
134135
log.error(f"Request failed, trying again: {e}")
135136
return
136137

137138
log.debug("Received HTTP Status %s.", response.status_code)
138139

139140
try:
140141
result = response.json()
141-
except (json.decoder.JSONDecodeError, requests.exceptions.JSONDecodeError) as e:
142+
except (json.decoder.JSONDecodeError, RequestsJSONDecodeError) as e:
142143
pass
143144

144145
# token issues

0 commit comments

Comments
 (0)