Compare commits

..

9 Commits

Author SHA1 Message Date
e82f7224c5 Report errors to Glitchtip 2025-02-26 01:10:52 +01:00
cdeca9498c Handle HTTP errors gracefully 2025-02-23 17:18:56 +01:00
2d90b57235 fix docker image build by using python 3.13 base image
I'm not sure why this fixes the image build, but I found others on the internet suggesting that using "the current" version image fixes this kind of issue and it indeed fixed the issue and I was able to build the image with this adjustment.
2025-02-07 23:27:24 +01:00
53cb75f1d5 Add configurable legend to footer 2025-02-06 01:01:45 +01:00
54222eb8cc Support overwriting details for all events
Useful for calendars which contain events of the same kind and the same location only but need some adjustments to improve the usability of the calendar.
2025-02-05 23:40:05 +01:00
af33203932 Add support for feeds from reparatur-initiativen.de 2025-02-05 23:07:48 +01:00
93b0a314d4 Make app easier to run in debug mode 2025-02-05 23:07:15 +01:00
d8fa4e1dcb Disable poetry's package-mode 2025-02-03 23:48:51 +01:00
e8b8150dd5 Update dependencies 2023-09-18 16:15:21 +02:00
9 changed files with 1676 additions and 1047 deletions

View File

@ -1,4 +1,4 @@
FROM python:3.10-alpine FROM python:3.13-alpine
RUN adduser -S fabcal RUN adduser -S fabcal

View File

@ -1,5 +1,7 @@
import locale import locale
import os
import sentry_sdk
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@ -7,6 +9,21 @@ from fastapi.staticfiles import StaticFiles
from fabcal.routers import api_v1, frontend from fabcal.routers import api_v1, frontend
sentry_sdk.init(
dsn=os.environ.get("SENTRY_DSN"),
# Add data like request headers and IP for users, if applicable;
# see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info
send_default_pii=True,
# Set traces_sample_rate to 1.0 to capture 100%
# of transactions for tracing.
traces_sample_rate=1.0,
# Set profiles_sample_rate to 1.0 to profile 100%
# of sampled transactions.
# We recommend adjusting this value in production.
profiles_sample_rate=1.0,
)
app = FastAPI() app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")
@ -27,3 +44,8 @@ app.add_middleware(
locale.setlocale(locale.LC_TIME, locale.getlocale()) locale.setlocale(locale.LC_TIME, locale.getlocale())
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, debug=True)

View File

@ -1,4 +1,5 @@
import asyncio import asyncio
import logging
import os import os
from collections import OrderedDict from collections import OrderedDict
@ -6,6 +7,7 @@ from datetime import date, datetime, timedelta, timezone
from typing import Dict, List, NamedTuple from typing import Dict, List, NamedTuple
import aiohttp import aiohttp
import pytz
import recurring_ical_events import recurring_ical_events
import yaml import yaml
@ -20,6 +22,9 @@ class ConfiguredCalendar(NamedTuple):
name: str name: str
url: str url: str
default_color: str = None default_color: str = None
overwrite_summary: str = None
overwrite_description: str = None
overwrite_location: str = None
class CalendarClient: class CalendarClient:
@ -54,11 +59,25 @@ class CombinedCalendarClient:
self.configured_calendars = configured_calendars self.configured_calendars = configured_calendars
async def fetch_calendars(self) -> Dict[ConfiguredCalendar, str]: async def fetch_calendars(self) -> Dict[ConfiguredCalendar, str]:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession(headers={
calendar_clients = [CalendarClient(calendar.url) for calendar in self.configured_calendars] "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0",
responses = await asyncio.gather(*[calendar.get(session) for calendar in calendar_clients]) }) as session:
# need to make sure we return the calendar and the gathered values together
async def coro(calendar):
print("argh", calendar)
return calendar, await CalendarClient(calendar.url).get(session)
return dict(zip(self.configured_calendars, responses)) rv = {}
for coro in asyncio.as_completed([coro(calendar) for calendar in self.configured_calendars]):
try:
cal, response = await coro
rv[cal] = response
except aiohttp.client_exceptions.ClientResponseError:
logging.exception("Failed to fetch data for calendar, skipping")
return rv
@staticmethod @staticmethod
def combine_calendars(data: Dict[ConfiguredCalendar, str]) -> Calendar: def combine_calendars(data: Dict[ConfiguredCalendar, str]) -> Calendar:
@ -83,6 +102,11 @@ class CombinedCalendarClient:
except KeyError: except KeyError:
default_color = None default_color = None
# if the user wishes to overwrite some data on every item in a calendar, we should do so
overwrite_summary = configured_calendar.overwrite_summary
overwrite_description = configured_calendar.overwrite_description
overwrite_location = configured_calendar.overwrite_location
# we don't copy anything but events from the # we don't copy anything but events from the
for event in cal.walk("VEVENT"): for event in cal.walk("VEVENT"):
# if no color has been configured in the event, we # if no color has been configured in the event, we
@ -90,6 +114,15 @@ class CombinedCalendarClient:
if "color" not in event: if "color" not in event:
event["color"] = default_color event["color"] = default_color
if overwrite_summary:
event["SUMMARY"] = overwrite_summary
if overwrite_description:
event["DESCRIPTION"] = overwrite_description
if overwrite_location:
event["LOCATION"] = overwrite_location
combined_calendar.add_component(event) combined_calendar.add_component(event)
return combined_calendar return combined_calendar
@ -137,6 +170,8 @@ def get_events_from_calendar(cal: Calendar) -> List[CalendarEvent]:
all_day_event = True all_day_event = True
start = datetime.combine(start, datetime.min.time(), tzinfo=get_tzinfo()) start = datetime.combine(start, datetime.min.time(), tzinfo=get_tzinfo())
start = start.astimezone(pytz.timezone("Europe/Berlin"))
end = vevent.get("DTEND", None) end = vevent.get("DTEND", None)
if end is not None: if end is not None:
end = end.dt end = end.dt
@ -145,6 +180,8 @@ def get_events_from_calendar(cal: Calendar) -> List[CalendarEvent]:
all_day_event = True all_day_event = True
end = datetime.combine(end, datetime.max.time(), tzinfo=get_tzinfo()) end = datetime.combine(end, datetime.max.time(), tzinfo=get_tzinfo())
end = end.astimezone(pytz.timezone("Europe/Berlin"))
def get_str(key: str): def get_str(key: str):
value = vevent.get(key, None) value = vevent.get(key, None)

26
fabcal/legend.py Normal file
View File

@ -0,0 +1,26 @@
from typing import NamedTuple, List
import yaml
from asyncache import cached
from cachetools import FIFOCache
class LegendItem(NamedTuple):
color: str
description: str
link: str = None
# this will be cached permanently, i.e., the server process needs to be restarted to apply config changes
# note that caching doesn't work at all with iterators (for obvious reasons)
@cached(FIFOCache(1))
def read_legend_from_config_file() -> List[LegendItem]:
rv = []
with open("config.yml") as f:
data = yaml.safe_load(f)
for item in data.get("legend", []):
rv.append(LegendItem(**item))
return rv

View File

@ -7,6 +7,7 @@ from fastapi.requests import Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fabcal.calendar_client import get_future_events, group_by_date from fabcal.calendar_client import get_future_events, group_by_date
from fabcal.legend import read_legend_from_config_file
from fabcal.routers import templates from fabcal.routers import templates
@ -21,6 +22,7 @@ async def generate_response(request: Request, template_name: str, **additional_c
context = { context = {
"request": request, "request": request,
"grouped_events": grouped_events, "grouped_events": grouped_events,
"legend": read_legend_from_config_file(),
} }
context.update(additional_context) context.update(additional_context)

2557
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,17 +1,17 @@
[tool.poetry] [tool.poetry]
name = "fabcal" name = "fabcal"
version = "0.0.1"
description = "" description = ""
authors = ["Fabian Müller <fabian@fablab-altmuehlfranken.de>"] authors = ["Fabian Müller <fabian@fablab-altmuehlfranken.de>"]
include = [ include = [
{ path = "static", format = ["sdist", "wheel"] }, { path = "static", format = ["sdist", "wheel"] },
{ path = "templates", format = ["sdist", "wheel"] }, { path = "templates", format = ["sdist", "wheel"] },
] ]
package-mode = false
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.8" python = "^3.10"
icalendar = "^4.0.9" icalendar = "^4.0.9"
fastapi = "^0.75.0" fastapi = "^0.115.0"
uvicorn = {extras = ["standard"], version = "^0.17.6"} uvicorn = {extras = ["standard"], version = "^0.17.6"}
Jinja2 = "^3.1.0" Jinja2 = "^3.1.0"
Babel = "^2.9.1" Babel = "^2.9.1"
@ -19,6 +19,8 @@ aiohttp = {extras = ["speedups"], version = "^3.8.1"}
recurring-ical-events = "^1.0.1-beta.0" recurring-ical-events = "^1.0.1-beta.0"
pyyaml = "^6.0" pyyaml = "^6.0"
asyncache = "^0.3.1" asyncache = "^0.3.1"
pytz = "^2025.1"
sentry-sdk = {extras = ["fastapi"], version = "^2.22.0"}
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
black = "^22.1.0" black = "^22.1.0"

View File

@ -167,3 +167,44 @@ details.calendar-event-details[open] summary ~ * {
0% {opacity: 0; transform: translateY(-10px)} 0% {opacity: 0; transform: translateY(-10px)}
100% {opacity: 1; transform: translateY(0)} 100% {opacity: 1; transform: translateY(0)}
} }
.calendar-legend {
padding: 5px;
border: var(--calendar-border);
border-radius: 5px;
background-color: #eee;
margin: 12px 2px 2px 2px;
}
.calendar-legend-list {
margin: 0;
}
.calendar-legend-heading {
font-size: 18px;
margin-bottom: 6px;
text-decoration: underline black;
}
.calendar-legend-item {
margin-bottom: 4px;
list-style: none;
font-size: 13px;
display: flex;
flex-direction: row;
justify-content: left;
}
.calendar-legend-item-color {
display: inline-block;
width: 18px;
height: 18px;
margin-right: 4px;
}
.calendar-legend-item-description {
word-break: break-word;
word-wrap: break-word;
hyphens: auto;
display: flex;
flex-direction: column;
justify-content: center;
}
.calendar-legend-item-link {
display: flex;
}

View File

@ -1,3 +1,23 @@
<div class="calendar-subscription-buttons"> <div class="calendar-subscription-buttons">
<a href="{{ url_for('events_ics') }}"><i class="fa-solid fa-calendar"></i> Kalender abonnieren</a> <a href="{{ url_for('events_ics') }}"><i class="fa-solid fa-calendar"></i> Kalender abonnieren</a>
</div> </div>
{% if legend %}
<div class="calendar-legend">
<p class="calendar-legend-heading">Legende:</p>
<ul class="calendar-legend-list">
{% for item in legend %}
<li class="calendar-legend-item">
{% if item.link %}
<a class="calendar-legend-item-link" href="{{ item.link }}">
{% endif %}
<span class="calendar-legend-item-color" style="background-color: {{ item.color }};"></span>
<span class="calendar-legend-item-description">{{ item.description }}</span>
{% if item.link %}
</a>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
</div>