Compare commits
11 Commits
691f9181e1
...
main
Author | SHA1 | Date | |
---|---|---|---|
e82f7224c5 | |||
cdeca9498c | |||
2d90b57235 | |||
53cb75f1d5 | |||
54222eb8cc | |||
af33203932 | |||
93b0a314d4 | |||
d8fa4e1dcb | |||
e8b8150dd5 | |||
527d9642bc | |||
f2b25ce868 |
@ -1,4 +1,4 @@
|
||||
FROM python:3.10-alpine
|
||||
FROM python:3.13-alpine
|
||||
|
||||
RUN adduser -S fabcal
|
||||
|
||||
|
@ -8,8 +8,10 @@ services:
|
||||
- 5000:5000
|
||||
environment:
|
||||
- FABCAL_CACHE_EXPIRE=120
|
||||
- LANG=de
|
||||
- LC_ALL=de_DE.UTF-8
|
||||
volumes:
|
||||
- ./config.yml:/app/config.yml
|
||||
- ./config.yml:/app/config.yml:ro
|
||||
# use the line below when running this behind reverse proxy (only if it sets the proxy headers, though)
|
||||
#command: --proxy-headers --forwarded-allow-ips="*"
|
||||
|
||||
|
@ -1,5 +1,7 @@
|
||||
import locale
|
||||
import os
|
||||
|
||||
import sentry_sdk
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
@ -7,6 +9,21 @@ from fastapi.staticfiles import StaticFiles
|
||||
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.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
@ -27,3 +44,8 @@ app.add_middleware(
|
||||
|
||||
|
||||
locale.setlocale(locale.LC_TIME, locale.getlocale())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, debug=True)
|
||||
|
@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
from collections import OrderedDict
|
||||
@ -6,6 +7,7 @@ from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Dict, List, NamedTuple
|
||||
|
||||
import aiohttp
|
||||
import pytz
|
||||
import recurring_ical_events
|
||||
import yaml
|
||||
|
||||
@ -20,6 +22,9 @@ class ConfiguredCalendar(NamedTuple):
|
||||
name: str
|
||||
url: str
|
||||
default_color: str = None
|
||||
overwrite_summary: str = None
|
||||
overwrite_description: str = None
|
||||
overwrite_location: str = None
|
||||
|
||||
|
||||
class CalendarClient:
|
||||
@ -54,11 +59,25 @@ class CombinedCalendarClient:
|
||||
self.configured_calendars = configured_calendars
|
||||
|
||||
async def fetch_calendars(self) -> Dict[ConfiguredCalendar, str]:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
calendar_clients = [CalendarClient(calendar.url) for calendar in self.configured_calendars]
|
||||
responses = await asyncio.gather(*[calendar.get(session) for calendar in calendar_clients])
|
||||
async with aiohttp.ClientSession(headers={
|
||||
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0",
|
||||
}) 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
|
||||
def combine_calendars(data: Dict[ConfiguredCalendar, str]) -> Calendar:
|
||||
@ -83,6 +102,11 @@ class CombinedCalendarClient:
|
||||
except KeyError:
|
||||
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
|
||||
for event in cal.walk("VEVENT"):
|
||||
# if no color has been configured in the event, we
|
||||
@ -90,6 +114,15 @@ class CombinedCalendarClient:
|
||||
if "color" not in event:
|
||||
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)
|
||||
|
||||
return combined_calendar
|
||||
@ -137,6 +170,8 @@ def get_events_from_calendar(cal: Calendar) -> List[CalendarEvent]:
|
||||
all_day_event = True
|
||||
start = datetime.combine(start, datetime.min.time(), tzinfo=get_tzinfo())
|
||||
|
||||
start = start.astimezone(pytz.timezone("Europe/Berlin"))
|
||||
|
||||
end = vevent.get("DTEND", None)
|
||||
if end is not None:
|
||||
end = end.dt
|
||||
@ -145,6 +180,8 @@ def get_events_from_calendar(cal: Calendar) -> List[CalendarEvent]:
|
||||
all_day_event = True
|
||||
end = datetime.combine(end, datetime.max.time(), tzinfo=get_tzinfo())
|
||||
|
||||
end = end.astimezone(pytz.timezone("Europe/Berlin"))
|
||||
|
||||
def get_str(key: str):
|
||||
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
|
@ -1,5 +1,6 @@
|
||||
import re
|
||||
|
||||
from babel.dates import format_datetime
|
||||
from jinja2 import pass_eval_context
|
||||
from markupsafe import Markup, escape
|
||||
from starlette.templating import Jinja2Templates
|
||||
@ -7,6 +8,9 @@ from starlette.templating import Jinja2Templates
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
# custom filters
|
||||
templates.env.filters["format_datetime"] = lambda value, format: format_datetime(value, format) #, locale="de_DE")
|
||||
|
||||
|
||||
@pass_eval_context
|
||||
def nl2br(eval_ctx, value):
|
||||
|
@ -7,6 +7,7 @@ from fastapi.requests import Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -18,19 +19,10 @@ async def generate_response(request: Request, template_name: str, **additional_c
|
||||
|
||||
grouped_events = list(group_by_date(events).items())
|
||||
|
||||
# couple of helpers
|
||||
def localized_abbreviated_month(dt: datetime):
|
||||
return babel.dates.format_datetime(dt, format="%b", locale="de_DE")
|
||||
|
||||
# couple of helpers
|
||||
def localized_abbreviated_weekday(dt: datetime):
|
||||
return babel.dates.format_datetime(dt, format="%b", locale="de_DE")
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"grouped_events": grouped_events,
|
||||
"localized_abbreviated_month": localized_abbreviated_month,
|
||||
"localized_abbreviated_weekday": localized_abbreviated_weekday,
|
||||
"legend": read_legend_from_config_file(),
|
||||
}
|
||||
|
||||
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]
|
||||
name = "fabcal"
|
||||
version = "0.0.1"
|
||||
description = ""
|
||||
authors = ["Fabian Müller <fabian@fablab-altmuehlfranken.de>"]
|
||||
include = [
|
||||
{ path = "static", format = ["sdist", "wheel"] },
|
||||
{ path = "templates", format = ["sdist", "wheel"] },
|
||||
]
|
||||
package-mode = false
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.8"
|
||||
python = "^3.10"
|
||||
icalendar = "^4.0.9"
|
||||
fastapi = "^0.75.0"
|
||||
fastapi = "^0.115.0"
|
||||
uvicorn = {extras = ["standard"], version = "^0.17.6"}
|
||||
Jinja2 = "^3.1.0"
|
||||
Babel = "^2.9.1"
|
||||
@ -19,6 +19,8 @@ aiohttp = {extras = ["speedups"], version = "^3.8.1"}
|
||||
recurring-ical-events = "^1.0.1-beta.0"
|
||||
pyyaml = "^6.0"
|
||||
asyncache = "^0.3.1"
|
||||
pytz = "^2025.1"
|
||||
sentry-sdk = {extras = ["fastapi"], version = "^2.22.0"}
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
black = "^22.1.0"
|
||||
|
@ -167,3 +167,44 @@ details.calendar-event-details[open] summary ~ * {
|
||||
0% {opacity: 0; transform: translateY(-10px)}
|
||||
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;
|
||||
}
|
||||
|
@ -6,22 +6,22 @@
|
||||
<div class="calendar-date">
|
||||
<div class="calendar-date-date">
|
||||
<div class="calendar-date-month">
|
||||
{{ start_date.strftime("%b") }}
|
||||
{{ start_date | format_datetime("MMM") }}
|
||||
</div>
|
||||
<div class="calendar-date-day">
|
||||
{{ start_date.strftime("%d") }}
|
||||
{{ start_date | format_datetime("d") }}
|
||||
</div>
|
||||
<div class="calendar-date-weekday">
|
||||
{{ start_date.strftime("%a") }}
|
||||
{{ start_date | format_datetime("EEE") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="calendar-events">
|
||||
{% for event in events %}
|
||||
<div class="calendar-event" title="{{ event.summary }}{% if event.description %} — {{ event.description }}{% endif %}">
|
||||
<div class="calendar-event-time">
|
||||
<div class="calendar-event-starttime">{{ event.start.strftime("%H:%M") }}</div>
|
||||
<div class="calendar-event-starttime">{{ event.start | format_datetime("HH:mm") }}</div>
|
||||
<div class="calendar-event-timesep"></div>
|
||||
<div class="calendar-event-endtime">{{ event.end.strftime("%H:%M") }}</div>
|
||||
<div class="calendar-event-endtime">{{ event.end | format_datetime("HH:mm") }}</div>
|
||||
</div>
|
||||
<div class="calendar-event-description">
|
||||
{% if event.description or event.location %}
|
||||
|
@ -1,3 +1,23 @@
|
||||
<div class="calendar-subscription-buttons">
|
||||
<a href="{{ url_for('events_ics') }}"><i class="fa-solid fa-calendar"></i> Kalender abonnieren</a>
|
||||
</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