Compare commits
21 Commits
9b8da8c2b6
...
main
Author | SHA1 | Date | |
---|---|---|---|
e82f7224c5 | |||
cdeca9498c | |||
2d90b57235 | |||
53cb75f1d5 | |||
54222eb8cc | |||
af33203932 | |||
93b0a314d4 | |||
d8fa4e1dcb | |||
e8b8150dd5 | |||
527d9642bc | |||
f2b25ce868 | |||
691f9181e1 | |||
b432503ac9 | |||
00b74e8782 | |||
70a069fe95 | |||
5d58ba89c4 | |||
eab91b7272 | |||
276110b2a2 | |||
bb2f840cec | |||
42153f2e27 | |||
3dc8a7f232 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -4,3 +4,6 @@ __pycache__/
|
|||||||
*.*swp*
|
*.*swp*
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
dist/
|
dist/
|
||||||
|
node_modules/
|
||||||
|
yarn*.log
|
||||||
|
*.yml
|
||||||
|
14
Dockerfile
14
Dockerfile
@ -1,18 +1,20 @@
|
|||||||
FROM python:3.8-alpine
|
FROM python:3.13-alpine
|
||||||
|
|
||||||
RUN adduser -S fabcal
|
RUN adduser -S fabcal
|
||||||
|
|
||||||
RUN apk add --no-cache build-base libuv-dev libffi-dev && \
|
RUN apk add --no-cache build-base libuv-dev libffi-dev yarn && \
|
||||||
pip3 install poetry
|
pip3 install -U poetry
|
||||||
|
|
||||||
|
RUN install -d -o fabcal /app
|
||||||
|
|
||||||
USER fabcal
|
USER fabcal
|
||||||
|
|
||||||
COPY poetry.lock pyproject.toml /app/
|
COPY poetry.lock pyproject.toml package.json yarn.lock /app/
|
||||||
|
|
||||||
WORKDIR /app/
|
WORKDIR /app/
|
||||||
|
|
||||||
RUN pip install -U poetry && \
|
RUN poetry install --only main && \
|
||||||
poetry install --only main
|
yarn install
|
||||||
|
|
||||||
COPY fabcal /app/fabcal/
|
COPY fabcal /app/fabcal/
|
||||||
COPY static/ /app/static/
|
COPY static/ /app/static/
|
||||||
|
7
config.yml.example
Normal file
7
config.yml.example
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
calendars:
|
||||||
|
- name: "Events"
|
||||||
|
url: "https://example.org/events.ics"
|
||||||
|
|
||||||
|
- name: "More events"
|
||||||
|
url: "https://example.com/more-events.ics"
|
||||||
|
default_color: "#aabbcc"
|
@ -7,7 +7,11 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 5000:5000
|
- 5000:5000
|
||||||
environment:
|
environment:
|
||||||
- CALENDAR_URL=webcal://some.host/somecal
|
- FABCAL_CACHE_EXPIRE=120
|
||||||
|
- LANG=de
|
||||||
|
- LC_ALL=de_DE.UTF-8
|
||||||
|
volumes:
|
||||||
|
- ./config.yml:/app/config.yml:ro
|
||||||
# use the line below when running this behind reverse proxy (only if it sets the proxy headers, though)
|
# use the line below when running this behind reverse proxy (only if it sets the proxy headers, though)
|
||||||
#command: --proxy-headers --forwarded-allow-ips="*"
|
#command: --proxy-headers --forwarded-allow-ips="*"
|
||||||
|
|
||||||
|
@ -1,18 +1,36 @@
|
|||||||
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
|
||||||
from fastapi_cache import FastAPICache
|
|
||||||
from fastapi_cache.backends.inmemory import InMemoryBackend
|
|
||||||
|
|
||||||
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")
|
||||||
|
|
||||||
|
for framework in ["normalize.css", "milligram", "@fortawesome/fontawesome-free"]:
|
||||||
|
app.mount(f"/assets/{framework}", StaticFiles(directory=f"node_modules/{framework}"), name=framework)
|
||||||
|
|
||||||
app.include_router(api_v1.router, prefix="/api/v1")
|
app.include_router(api_v1.router, prefix="/api/v1")
|
||||||
app.include_router(frontend.router, prefix="")
|
app.include_router(frontend.router, prefix="")
|
||||||
|
|
||||||
@ -28,6 +46,6 @@ app.add_middleware(
|
|||||||
locale.setlocale(locale.LC_TIME, locale.getlocale())
|
locale.setlocale(locale.LC_TIME, locale.getlocale())
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
if __name__ == "__main__":
|
||||||
async def startup():
|
import uvicorn
|
||||||
FastAPICache.init(InMemoryBackend())
|
uvicorn.run(app, debug=True)
|
||||||
|
@ -1,53 +1,148 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from typing import List
|
from typing import Dict, List, NamedTuple
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
import pytz
|
||||||
import recurring_ical_events
|
import recurring_ical_events
|
||||||
|
import yaml
|
||||||
|
|
||||||
from fastapi_cache.decorator import cache
|
from asyncache import cached
|
||||||
from icalendar import Calendar, vText
|
from cachetools import TTLCache, FIFOCache
|
||||||
|
from icalendar import Calendar
|
||||||
|
|
||||||
from fabcal.models import CalendarEvent
|
from fabcal.models import CalendarEvent
|
||||||
|
|
||||||
|
|
||||||
def sanitize(data: str):
|
class ConfiguredCalendar(NamedTuple):
|
||||||
# sanitize input from random source
|
name: str
|
||||||
cal = Calendar.from_ical(data)
|
url: str
|
||||||
|
default_color: str = None
|
||||||
# name needs to be fixed
|
overwrite_summary: str = None
|
||||||
cal["X-WR-CALNAME"] = vText(b"FabLab-Termine")
|
overwrite_description: str = None
|
||||||
|
overwrite_location: str = None
|
||||||
return cal.to_ical()
|
|
||||||
|
|
||||||
|
|
||||||
def get_calendar_url():
|
class CalendarClient:
|
||||||
url = os.environ["CALENDAR_URL"]
|
def __init__(self, url: str):
|
||||||
|
self.url = url
|
||||||
|
|
||||||
# convenience feature
|
async def get(self, session):
|
||||||
url = url.replace("webcal://", "https://")
|
async with session.get(self.url) as response:
|
||||||
|
|
||||||
return url
|
|
||||||
|
|
||||||
|
|
||||||
# caching strings works better than caching calendar objects
|
|
||||||
@cache(expire=120)
|
|
||||||
async def get_data() -> str:
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.get(get_calendar_url()) as response:
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
assert response.content_type.lower() == "text/calendar"
|
assert response.content_type.lower() == "text/calendar"
|
||||||
|
|
||||||
return sanitize(await response.text())
|
return await response.text()
|
||||||
|
|
||||||
|
|
||||||
|
# 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_calendars_from_config_file() -> List[ConfiguredCalendar]:
|
||||||
|
rv = []
|
||||||
|
|
||||||
|
with open("config.yml") as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
|
||||||
|
for calendar in data["calendars"]:
|
||||||
|
rv.append(ConfiguredCalendar(**calendar))
|
||||||
|
|
||||||
|
return rv
|
||||||
|
|
||||||
|
|
||||||
|
class CombinedCalendarClient:
|
||||||
|
def __init__(self, configured_calendars: List[ConfiguredCalendar]):
|
||||||
|
self.configured_calendars = configured_calendars
|
||||||
|
|
||||||
|
async def fetch_calendars(self) -> Dict[ConfiguredCalendar, str]:
|
||||||
|
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)
|
||||||
|
|
||||||
|
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:
|
||||||
|
combined_calendar = Calendar()
|
||||||
|
|
||||||
|
combined_calendar.add("prodid", "-//FabCal//NONSGML//EN")
|
||||||
|
combined_calendar.add("version", "2.0")
|
||||||
|
combined_calendar.add("x-wr-calname", "FabLab Altmühlfranken e.V.")
|
||||||
|
# TODO: normalize timezones of calendar events
|
||||||
|
|
||||||
|
for configured_calendar, ical_str in data.items():
|
||||||
|
cal = Calendar.from_ical(ical_str)
|
||||||
|
|
||||||
|
# check for a calendar color (e.g., Nextcloud has such a feature)
|
||||||
|
# events that don't have a color assigned will be assigned this color unless a color was configured
|
||||||
|
default_color = configured_calendar.default_color
|
||||||
|
|
||||||
|
if not default_color:
|
||||||
|
try:
|
||||||
|
# note: for some reason, getattr doesn't work
|
||||||
|
default_color = cal["x-apple-calendar-color"]
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
async def fetch_and_combine_calendars(self) -> Calendar:
|
||||||
|
return self.combine_calendars(await self.fetch_calendars())
|
||||||
|
|
||||||
|
|
||||||
|
@cached(TTLCache(maxsize=100, ttl=int(os.environ.get("FABCAL_CACHE_EXPIRE", 120))))
|
||||||
|
async def get_data() -> Calendar:
|
||||||
|
client = CombinedCalendarClient(read_calendars_from_config_file())
|
||||||
|
combined_calendar = await client.fetch_and_combine_calendars()
|
||||||
|
return combined_calendar
|
||||||
|
|
||||||
|
|
||||||
def get_tzinfo():
|
def get_tzinfo():
|
||||||
return timezone(timedelta(hours=1))
|
return timezone(timedelta(hours=1))
|
||||||
|
|
||||||
|
|
||||||
def get_events_from_calendar_string(cal: Calendar) -> List[CalendarEvent]:
|
def get_events_from_calendar(cal: Calendar) -> List[CalendarEvent]:
|
||||||
"""
|
"""
|
||||||
Generate list of events from calendar vevents.
|
Generate list of events from calendar vevents.
|
||||||
Expands recurring events for +- one year.
|
Expands recurring events for +- one year.
|
||||||
@ -75,6 +170,8 @@ def get_events_from_calendar_string(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
|
||||||
@ -83,6 +180,8 @@ def get_events_from_calendar_string(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)
|
||||||
|
|
||||||
@ -106,9 +205,7 @@ def get_events_from_calendar_string(cal: Calendar) -> List[CalendarEvent]:
|
|||||||
|
|
||||||
|
|
||||||
async def get_future_events():
|
async def get_future_events():
|
||||||
cal = Calendar.from_ical(await get_data())
|
events = get_events_from_calendar(await get_data())
|
||||||
|
|
||||||
events = get_events_from_calendar_string(cal)
|
|
||||||
|
|
||||||
today = datetime.today().date()
|
today = datetime.today().date()
|
||||||
|
|
||||||
|
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
|
import re
|
||||||
|
|
||||||
|
from babel.dates import format_datetime
|
||||||
from jinja2 import pass_eval_context
|
from jinja2 import pass_eval_context
|
||||||
from markupsafe import Markup, escape
|
from markupsafe import Markup, escape
|
||||||
from starlette.templating import Jinja2Templates
|
from starlette.templating import Jinja2Templates
|
||||||
@ -7,6 +8,9 @@ from starlette.templating import Jinja2Templates
|
|||||||
|
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
# custom filters
|
||||||
|
templates.env.filters["format_datetime"] = lambda value, format: format_datetime(value, format) #, locale="de_DE")
|
||||||
|
|
||||||
|
|
||||||
@pass_eval_context
|
@pass_eval_context
|
||||||
def nl2br(eval_ctx, value):
|
def nl2br(eval_ctx, value):
|
||||||
|
@ -10,7 +10,7 @@ router = APIRouter()
|
|||||||
@router.get("/events.ics")
|
@router.get("/events.ics")
|
||||||
async def events_ics():
|
async def events_ics():
|
||||||
return Response(
|
return Response(
|
||||||
await get_data(),
|
(await get_data()).to_ical(True),
|
||||||
headers={
|
headers={
|
||||||
"content-type": "text/calendar",
|
"content-type": "text/calendar",
|
||||||
},
|
},
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import base64
|
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import babel.dates
|
import babel.dates
|
||||||
@ -9,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
|
||||||
|
|
||||||
|
|
||||||
@ -20,41 +19,32 @@ async def generate_response(request: Request, template_name: str, **additional_c
|
|||||||
|
|
||||||
grouped_events = list(group_by_date(events).items())
|
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")
|
|
||||||
|
|
||||||
def base64_encode(s: str):
|
|
||||||
return base64.b64encode(s.encode()).decode()
|
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"request": request,
|
"request": request,
|
||||||
"grouped_events": grouped_events,
|
"grouped_events": grouped_events,
|
||||||
"dir": dir,
|
"legend": read_legend_from_config_file(),
|
||||||
"localized_abbreviated_month": localized_abbreviated_month,
|
|
||||||
"localized_abbreviated_weekday": localized_abbreviated_weekday,
|
|
||||||
"base64_encode": base64_encode,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
context.update(additional_context)
|
context.update(additional_context)
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(template_name, context=context)
|
||||||
template_name,
|
|
||||||
context={
|
|
||||||
"request": request,
|
|
||||||
"grouped_events": grouped_events,
|
|
||||||
"dir": dir,
|
|
||||||
"localized_abbreviated_month": localized_abbreviated_month,
|
|
||||||
"localized_abbreviated_weekday": localized_abbreviated_weekday,
|
|
||||||
"base64_encode": base64_encode,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sidebar/embed.html", response_class=HTMLResponse)
|
@router.get("/sidebar/embed.html", response_class=HTMLResponse)
|
||||||
async def embed_sidebar(request: Request):
|
async def embed_sidebar(request: Request, max_days: int = None):
|
||||||
return await generate_response(request, "sidebar/embed.html")
|
try:
|
||||||
|
max_days = int(max_days)
|
||||||
|
except TypeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return await generate_response(request, "sidebar/embed.html", max_days=max_days)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sidebar/demo.html", response_class=HTMLResponse)
|
||||||
|
async def sidebar_demo(request: Request, max_days: int = None):
|
||||||
|
try:
|
||||||
|
max_days = int(max_days)
|
||||||
|
except TypeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return await generate_response(request, "sidebar/demo.html", max_days=max_days)
|
||||||
|
10
package.json
Normal file
10
package.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "fabcal",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"author": "Fabian Müller <fabian@fablab-altmuehlfranken.de>",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-free": "^6.2.1",
|
||||||
|
"milligram": "^1.4.1"
|
||||||
|
}
|
||||||
|
}
|
2830
poetry.lock
generated
2830
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,23 +1,26 @@
|
|||||||
[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"
|
||||||
fastapi-cache2 = "^0.1.8"
|
|
||||||
aiohttp = {extras = ["speedups"], version = "^3.8.1"}
|
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"
|
||||||
|
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"
|
||||||
|
@ -45,7 +45,7 @@ body {
|
|||||||
padding: 3px 0;
|
padding: 3px 0;
|
||||||
}
|
}
|
||||||
.calendar-date:nth-of-type(2n) .calendar-date-month {
|
.calendar-date:nth-of-type(2n) .calendar-date-month {
|
||||||
/*background-color: var(--calendar-fablab-blue);*/
|
background-color: var(--calendar-fablab-blue);
|
||||||
}
|
}
|
||||||
.calendar-date-day {
|
.calendar-date-day {
|
||||||
font-size: 13pt;
|
font-size: 13pt;
|
||||||
@ -145,6 +145,7 @@ body {
|
|||||||
|
|
||||||
details.calendar-event-details > summary {
|
details.calendar-event-details > summary {
|
||||||
text-decoration: underline dotted black;
|
text-decoration: underline dotted black;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.calendar-popover-content > * {
|
.calendar-popover-content > * {
|
||||||
margin: 5px 0;
|
margin: 5px 0;
|
||||||
@ -166,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;
|
||||||
|
}
|
||||||
|
@ -2,10 +2,9 @@
|
|||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
{% block header %}
|
{% block header %}
|
||||||
{#- TODO: replace with locally served files #}
|
<link rel="stylesheet" href="{{ url_for('normalize.css', path='normalize.css') }}">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.css">
|
<link rel="stylesheet" href="{{ url_for('milligram', path='dist/milligram.min.css') }}">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/milligram/1.4.1/milligram.css">
|
<link rel="stylesheet" href="{{ url_for('@fortawesome/fontawesome-free', path='css/all.min.css') }}">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.1/css/all.min.css">
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='style.css') }}" type="text/css"/>
|
<link rel="stylesheet" href="{{ url_for('static', path='style.css') }}" type="text/css"/>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='roboto.css') }}" type="text/css"/>
|
<link rel="stylesheet" href="{{ url_for('static', path='roboto.css') }}" type="text/css"/>
|
||||||
|
64
templates/sidebar/demo.html
Normal file
64
templates/sidebar/demo.html
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
{% extends "sidebar/base.html" %}
|
||||||
|
|
||||||
|
{% block header %}
|
||||||
|
{{ super() }}
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
max-width: 1200px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
}
|
||||||
|
.fake-content {
|
||||||
|
margin: 50px 30px 0 30px;
|
||||||
|
padding: 0;
|
||||||
|
background-color: white;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.calendar {
|
||||||
|
min-width: 250px;
|
||||||
|
margin: 50px 30px 0 30px;
|
||||||
|
border: 1px solid red;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
body {
|
||||||
|
flex-wrap: wrap-reverse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="fake-content">
|
||||||
|
<h2>Fake content</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et
|
||||||
|
dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet
|
||||||
|
clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet,
|
||||||
|
consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat,
|
||||||
|
sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea
|
||||||
|
takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed
|
||||||
|
diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et
|
||||||
|
accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum
|
||||||
|
dolor sit amet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu
|
||||||
|
feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril
|
||||||
|
delenit augue duis dolore te feugait nulla facilisi.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="calendar">
|
||||||
|
{% include "sidebar/includes/header.html" %}
|
||||||
|
{% include "sidebar/includes/events-list.html" %}
|
||||||
|
{% include "sidebar/includes/footer.html" %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
@ -1,10 +1,9 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
{#- TODO: replace with locally served files #}
|
<link rel="stylesheet" href="{{ url_for('normalize.css', path='normalize.css') }}">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.css">
|
<link rel="stylesheet" href="{{ url_for('milligram', path='dist/milligram.min.css') }}">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/milligram/1.4.1/milligram.css">
|
<link rel="stylesheet" href="{{ url_for('@fortawesome/fontawesome-free', path='css/all.min.css') }}">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.1/css/all.min.css">
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='style.css') }}" type="text/css"/>
|
<link rel="stylesheet" href="{{ url_for('static', path='style.css') }}" type="text/css"/>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='roboto.css') }}" type="text/css"/>
|
<link rel="stylesheet" href="{{ url_for('static', path='roboto.css') }}" type="text/css"/>
|
||||||
|
@ -1,23 +1,27 @@
|
|||||||
|
{% if max_days %}
|
||||||
|
{% set grouped_events = grouped_events[:max_days] %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% for start_date, events in grouped_events %}
|
{% for start_date, events in grouped_events %}
|
||||||
<div class="calendar-date">
|
<div class="calendar-date">
|
||||||
<div class="calendar-date-date">
|
<div class="calendar-date-date">
|
||||||
<div class="calendar-date-month">
|
<div class="calendar-date-month">
|
||||||
{{ start_date.strftime("%b") }}
|
{{ start_date | format_datetime("MMM") }}
|
||||||
</div>
|
</div>
|
||||||
<div class="calendar-date-day">
|
<div class="calendar-date-day">
|
||||||
{{ start_date.strftime("%d") }}
|
{{ start_date | format_datetime("d") }}
|
||||||
</div>
|
</div>
|
||||||
<div class="calendar-date-weekday">
|
<div class="calendar-date-weekday">
|
||||||
{{ start_date.strftime("%a") }}
|
{{ start_date | format_datetime("EEE") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="calendar-events">
|
<div class="calendar-events">
|
||||||
{% for event in events %}
|
{% for event in events %}
|
||||||
<div class="calendar-event" title="{{ event.summary }}{% if event.description %} — {{ event.description }}{% endif %}">
|
<div class="calendar-event" title="{{ event.summary }}{% if event.description %} — {{ event.description }}{% endif %}">
|
||||||
<div class="calendar-event-time">
|
<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-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>
|
||||||
<div class="calendar-event-description">
|
<div class="calendar-event-description">
|
||||||
{% if event.description or event.location %}
|
{% if event.description or event.location %}
|
||||||
|
@ -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>
|
||||||
|
20
yarn.lock
Normal file
20
yarn.lock
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||||
|
# yarn lockfile v1
|
||||||
|
|
||||||
|
|
||||||
|
"@fortawesome/fontawesome-free@^6.2.1":
|
||||||
|
version "6.2.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-6.2.1.tgz#344baf6ff9eaad7a73cff067d8c56bfc11ae5304"
|
||||||
|
integrity sha512-viouXhegu/TjkvYQoiRZK3aax69dGXxgEjpvZW81wIJdxm5Fnvp3VVIP4VHKqX4SvFw6qpmkILkD4RJWAdrt7A==
|
||||||
|
|
||||||
|
milligram@^1.4.1:
|
||||||
|
version "1.4.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/milligram/-/milligram-1.4.1.tgz#6c8c781541b0d994ccca784c60f0aca1f7104b42"
|
||||||
|
integrity sha512-RCgh/boHhcXWOUfKJWm3RJRoUeaEguoipDg0mJ31G0tFfvcpWMUlO1Zlqqr12K4kAXfDlllaidu0x7PaL2PTFg==
|
||||||
|
dependencies:
|
||||||
|
normalize.css "~8.0.1"
|
||||||
|
|
||||||
|
normalize.css@~8.0.1:
|
||||||
|
version "8.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/normalize.css/-/normalize.css-8.0.1.tgz#9b98a208738b9cc2634caacbc42d131c97487bf3"
|
||||||
|
integrity sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg==
|
Reference in New Issue
Block a user