Compare commits
9 Commits
527d9642bc
...
main
Author | SHA1 | Date | |
---|---|---|---|
e82f7224c5 | |||
cdeca9498c | |||
2d90b57235 | |||
53cb75f1d5 | |||
54222eb8cc | |||
af33203932 | |||
93b0a314d4 | |||
d8fa4e1dcb | |||
e8b8150dd5 |
@ -1,4 +1,4 @@
|
|||||||
FROM python:3.10-alpine
|
FROM python:3.13-alpine
|
||||||
|
|
||||||
RUN adduser -S fabcal
|
RUN adduser -S fabcal
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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
26
fabcal/legend.py
Normal 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
|
@ -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
2557
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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>
|
||||||
|
Reference in New Issue
Block a user