Initial commit

This commit is contained in:
Fabian Müller 2022-03-27 23:24:59 +02:00
commit 1dc60bbd8f
11 changed files with 1985 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
*.py[c|o]
__pycache__/
.idea/
*.*swp*
docker-compose.yml

21
Dockerfile Normal file
View File

@ -0,0 +1,21 @@
FROM python:3.8-alpine
RUN adduser -S fabcal
RUN apk add --no-cache build-base libuv-dev libffi-dev && \
pip3 install poetry
USER fabcal
COPY poetry.lock pyproject.toml /app/
WORKDIR /app/
RUN pip install -U poetry && \
poetry install
COPY main.py /app/
COPY static/ /app/static/
COPY templates/ /app/templates/
ENTRYPOINT ["poetry", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "5000", "--use-colors"]

5
LICENSE.txt Normal file
View File

@ -0,0 +1,5 @@
Copyright (C) 2022 by Fabian Müller <fabian@fablab-altmuehlfranken.de>
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.[20]

4
README.md Normal file
View File

@ -0,0 +1,4 @@
# FabCal
Renders a web calendar, sanitizing the input and serving it from a static URL. Also renders HTML pages for embedding
in homepages.

View File

@ -0,0 +1,13 @@
version: "2"
services:
fabcal:
build: .
restart: unless-stopped
ports:
- 5000:5000
environment:
- CALENDAR_URL=webcal://some.host/somecal
# use the line below when running this behind reverse proxy (only if it sets the proxy headers, though)
#command: --proxy-headers --forwarded-allow-ips="*"

224
main.py Normal file
View File

@ -0,0 +1,224 @@
import base64
import locale
import os
from collections import OrderedDict
from datetime import date, datetime, timedelta, timezone
from typing import NamedTuple, List
import aiohttp
import babel.dates
import recurring_ical_events
from icalendar import Calendar, vText
from fastapi import FastAPI
from fastapi_cache import FastAPICache
from fastapi_cache.backends.inmemory import InMemoryBackend
from fastapi_cache.decorator import cache
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.requests import Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import Response
app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://fablab-altmuehlfranken.de",
"https://www.fablab-altmuehlfranken.de",
],
)
locale.setlocale(locale.LC_TIME, locale.getlocale())
def get_calendar_url():
url = os.environ["CALENDAR_URL"]
# convenience feature
url = url.replace("webcal://", "https://")
return url
def sanitize(data: str):
# sanitize input from random source
cal = Calendar.from_ical(data)
# name needs to be fixed
cal["X-WR-CALNAME"] = vText(b"FabLab-Termine")
return cal.to_ical()
# 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()
assert response.content_type.lower() == "text/calendar"
return sanitize(await response.text())
class CalendarEvent(NamedTuple):
start: datetime
end: datetime
# just a convenience thing, we want to keep start/date as datetime and save the client from guessing this themselves
all_day_event: bool
summary: str
description: str
location: str
color: str
uid: str
def get_tzinfo():
return timezone(timedelta(hours=1))
def get_events_from_calendar_string(cal: Calendar) -> List[CalendarEvent]:
"""
Generate list of events from calendar vevents.
Expands recurring events for +- one year.
Note that there is no validation, e.g., checking for values required (by other code in this application).
:param cal: calendar to fetch events from
:return: events
"""
now = datetime.now()
td = timedelta(days=365)
past_year = now - td
next_year = now + td
events = []
for vevent in recurring_ical_events.of(cal).between(past_year, next_year):
# I'm pessimistic here. Prove me wrong!
all_day_event = False
start = vevent.get("DTSTART", None)
if start is not None:
start = start.dt
if not isinstance(start, datetime):
all_day_event = True
start = datetime.combine(start, datetime.min.time(), tzinfo=get_tzinfo())
end = vevent.get("DTEND", None)
if end is not None:
end = end.dt
if not isinstance(end, datetime):
all_day_event = True
end = datetime.combine(end, datetime.max.time(), tzinfo=get_tzinfo())
def get_str(key: str):
value = vevent.get(key, None)
if value is not None:
return str(value)
return value
summary = get_str("SUMMARY")
description = get_str("DESCRIPTION")
location = get_str("LOCATION")
color = get_str("COLOR")
uid = get_str("UID")
event = CalendarEvent(start, end, all_day_event, summary, description, location, color, uid)
events.append(event)
events.sort(key=lambda e: e.start)
return events
async def get_future_events():
cal = Calendar.from_ical(await get_data())
events = get_events_from_calendar_string(cal)
today = datetime.today().date()
future_events = []
for event in events:
if event.start.date() <= today:
continue
future_events.append(event)
return future_events
@app.get("/events.ics")
async def ics():
return Response(
await get_data(),
headers={
"content-type": "text/calendar",
},
)
def group_by_date(events: List[CalendarEvent]):
grouped_events: OrderedDict[date, List[CalendarEvent]] = OrderedDict()
for event in events:
start_date = event.start.date()
if start_date not in grouped_events:
grouped_events[start_date] = []
grouped_events[start_date].append(event)
return grouped_events
@app.get("/embed-sidebar.html")
async def embed(request: Request, max_width: str = None):
# await asyncio.sleep(1)
events = await get_future_events()
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()
return templates.TemplateResponse(
"embed-sidebar.html",
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,
"max_width": max_width,
},
)
@app.on_event("startup")
async def startup():
FastAPICache.init(InMemoryBackend())

1415
poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

26
pyproject.toml Normal file
View File

@ -0,0 +1,26 @@
[tool.poetry]
name = "fabcal"
version = "0.0.1"
description = ""
authors = ["Fabian Müller <fabian@fablab-altmuehlfranken.de>"]
[tool.poetry.dependencies]
python = "^3.8"
icalendar = "^4.0.9"
fastapi = "^0.75.0"
uvicorn = {extras = ["standard"], version = "^0.17.6"}
Jinja2 = "^3.1.0"
Babel = "^2.9.1"
fastapi-cache2 = "^0.1.8"
aiohttp = {extras = ["speedups"], version = "^3.8.1"}
recurring-ical-events = "^1.0.1-beta.0"
[tool.poetry.dev-dependencies]
black = "^22.1.0"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.black]
line-length = 120

47
static/roboto.css Normal file
View File

@ -0,0 +1,47 @@
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
src: url(https://static.fablab-altmuehlfranken.de/fonts/roboto-v20-latin-regular.eot);
src: local('Roboto'), local('Roboto-Regular'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-v20-latin-regular.eot?#iefix) format('embedded-opentype'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-v20-latin-regular.woff2) format('woff2'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-v20-latin-regular.woff) format('woff'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-v20-latin-regular.ttf) format('truetype'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-v20-latin-regular.svg#Roboto) format('svg')
}
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 400;
src: url(https://static.fablab-altmuehlfranken.de/fonts/roboto-v20-latin-italic.eot);
src: local('Roboto Italic'), local('Roboto-Italic'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-v20-latin-italic.eot?#iefix) format('embedded-opentype'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-v20-latin-italic.woff2) format('woff2'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-v20-latin-italic.woff) format('woff'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-v20-latin-italic.ttf) format('truetype'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-v20-latin-italic.svg#Roboto) format('svg')
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
src: url(https://static.fablab-altmuehlfranken.de/fonts/roboto-v20-latin-700.eot);
src: local('Roboto Bold'), local('Roboto-Bold'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-v20-latin-700.eot?#iefix) format('embedded-opentype'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-v20-latin-700.woff2) format('woff2'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-v20-latin-700.woff) format('woff'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-v20-latin-700.ttf) format('truetype'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-v20-latin-700.svg#Roboto) format('svg')
}
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 700;
src: url(https://static.fablab-altmuehlfranken.de/fonts/roboto-v20-latin-700italic.eot);
src: local('Roboto Bold Italic'), local('Roboto-BoldItalic'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-v20-latin-700italic.eot?#iefix) format('embedded-opentype'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-v20-latin-700italic.woff2) format('woff2'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-v20-latin-700italic.woff) format('woff'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-v20-latin-700italic.ttf) format('truetype'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-v20-latin-700italic.svg#Roboto) format('svg')
}
@font-face {
font-family: 'Roboto Slab';
font-style: normal;
font-weight: 400;
src: url(https://static.fablab-altmuehlfranken.de/fonts/roboto-slab-v9-latin-regular.eot);
src: local('Roboto Slab Regular'), local('RobotoSlab-Regular'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-slab-v9-latin-regular.eot?#iefix) format('embedded-opentype'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-slab-v9-latin-regular.woff2) format('woff2'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-slab-v9-latin-regular.woff) format('woff'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-slab-v9-latin-regular.ttf) format('truetype'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-slab-v9-latin-regular.svg#RobotoSlab) format('svg')
}
@font-face {
font-family: 'Roboto Slab';
font-style: normal;
font-weight: 700;
src: url(https://static.fablab-altmuehlfranken.de/fonts/roboto-slab-v9-latin-700.eot);
src: local('Roboto Slab Bold'), local('RobotoSlab-Bold'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-slab-v9-latin-700.eot?#iefix) format('embedded-opentype'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-slab-v9-latin-700.woff2) format('woff2'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-slab-v9-latin-700.woff) format('woff'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-slab-v9-latin-700.ttf) format('truetype'), url(//static.fablab-altmuehlfranken.de/fonts/roboto-slab-v9-latin-700.svg#RobotoSlab) format('svg')
}

157
static/style.css Normal file
View File

@ -0,0 +1,157 @@
:root {
--calendar-fablab-red: #D13F34;
--calendar-fablab-blue: #16A3C4;
--calendar-border: 1px solid #999;
}
body {
color: black;
/* inspired by fablab-altmuehlfranken.de */
font-weight: 100;
line-height: 1.3;
font-size: 9pt;
height: auto;
overflow: auto
}
.calendar {
width: 100%;
margin: 0 auto;
}
.calendar-date {
margin-bottom: 8px;
display: flex;
align-items: stretch;
flex-direction: row;
background-color: white;
}
.calendar-date-date {
border: var(--calendar-border);
text-align: center;
border-radius: 5px;
margin-right: 8px;
flex: 35px 0 0;
height: 100%;
}
.calendar-date-month {
background-color: var(--calendar-fablab-red);
color: white;
padding: 3px 0;
}
.calendar-date:nth-of-type(2n) .calendar-date-month {
/*background-color: var(--calendar-fablab-blue);*/
}
.calendar-date-day {
font-size: 13pt;
font-weight: 700;
padding-top: 3px;
}
.calendar-date-weekday {
padding-bottom: 3px;
}
.calendar-events {
flex-grow: 100;
vertical-align: top;
}
.calendar-event {
background-color: white;
border: var(--calendar-border);
border-radius: 5px;
padding: 5px;
margin-bottom: 5px;
filter: brightness(1);
display: flex;
align-items: stretch;
flex-direction: row;
justify-content: center;
}
.calendar-event-time {
text-align: center;
padding: 5px 8px 5px 5px;
margin: -5px 0 -5px -5px;
flex: 0 0 45px;
background-color: #ddd;
}
.calendar-event-timesep {
line-height: 1.2;
}
.calendar-event-timesep:before {
display: block;
content: "⋮";
}
.calendar-event-description {
flex-grow: 100;
margin: 0 5px;
word-break: break-word;
word-wrap: break-word;
hyphens: auto;
}
.calendar-event-description, .calendar-event-time {
display: flex;
flex-direction: column;
justify-content: center;
}
.calendar-event-type {
flex: 0 0 12px;
margin: -5px -5px -5px 0;
}
@media only screen and (max-width: 960px) {
.calendar-event {
flex-direction: column;
}
.calendar-event-time {
flex-direction: row;
padding: 5px 8px 5px 5px;
margin: -5px -5px 5px -5px;
flex: 0 0 12px;
align-content: center;
justify-content: center;
}
.calendar-event-type {
flex: 0 0 12px;
margin: 5px -5px -5px -5px;
}
.calendar-event-timesep {
display: block;
}
.calendar-event-timesep:before {
display: block;
content: "...";
padding: 0 3px;
}
}
.calendar-date-date, .calendar-event {
/* make sure border radius clips content */
overflow: hidden;
}
details.calendar-event-details > summary {
text-decoration: underline dotted black;
}
.calendar-popover-content > * {
margin: 5px 0;
}
.calendar-popover-entry {
display: flex;
margin: 5px 0;
}
.calendar-popover-entry-icon {
flex: 0 0 18px;
}
/* CSS-only "fake soft open", might be replaced something more sophisticated in the future */
details.calendar-event-details[open] summary ~ * {
animation: calendar-event-details-sweep .5s ease-in-out;
}
@keyframes calendar-event-details-sweep {
0% {opacity: 0; transform: translateY(-10px)}
100% {opacity: 1; transform: translateY(0)}
}

View File

@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="de">
<head>
{#- TODO: replace with locally served files #}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/milligram/1.4.1/milligram.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='roboto.css') }}" type="text/css"/>
<title>Embeddable calendar</title>
</head>
<body{% if max_width %} style="max-width: {{ max_width }}"{% endif %}>
<div class="calendar">
{% for start_date, events in grouped_events[:3] %}
<div class="calendar-date">
<div class="calendar-date-date">
<div class="calendar-date-month">
{{ start_date.strftime("%b") }}
</div>
<div class="calendar-date-day">
{{ start_date.strftime("%d") }}
</div>
<div class="calendar-date-weekday">
{{ start_date.strftime("%a") }}
</div>
</div>
<div class="calendar-events">
{% for event in events %}
<div class="calendar-event" title="{{ event.summary }}{% if event.description %} &mdash; {{ event.description }}{% endif %}">
<div class="calendar-event-time">
<div class="calendar-event-starttime">{{ event.start.strftime("%H:%M") }}</div>
<div class="calendar-event-timesep"></div>
<div class="calendar-event-endtime">{{ event.end.strftime("%H:%M") }}</div>
</div>
<div class="calendar-event-description">
{% if event.description or event.location %}
<details class="calendar-event-details">
<summary>{{ event.summary }}</summary>
<div class="calendar-popover-content">
{% if event.description %}
<div class="calendar-popover-entry">
{#- description doesn't need an icon, just wastes space #}
<div class="calendar-popover-entry-text">{{ event.description }}</div>
</div>
{% endif %}
{% if event.location %}
<div class="calendar-popover-entry">
<div class="calendar-popover-entry-icon"><i class="fa-solid fa-location-dot"></i></div>
<div class="calendar-popover-entry-text">{{ event.location }}</div>
</div>
{% endif %}
</div>
</details>
{% else %}
<span>{{ event.summary }}</span>
{% endif %}
</div>
<div class="calendar-event-type" style="background-color: {{ event.color }};"></div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</body>
</html>