Compare commits

...

9 Commits

18 changed files with 988 additions and 736 deletions

1
.gitignore vendored
View File

@ -3,3 +3,4 @@ __pycache__/
.idea/
*.*swp*
docker-compose.yml
dist/

View File

@ -12,10 +12,10 @@ COPY poetry.lock pyproject.toml /app/
WORKDIR /app/
RUN pip install -U poetry && \
poetry install --no-dev
poetry install --only main
COPY main.py /app/
COPY fabcal /app/fabcal/
COPY static/ /app/static/
COPY templates/ /app/templates/
ENTRYPOINT ["poetry", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "5000", "--use-colors"]
ENTRYPOINT ["poetry", "run", "uvicorn", "fabcal.app:app", "--host", "0.0.0.0", "--port", "5000", "--use-colors"]

0
fabcal/__init__.py Normal file
View File

33
fabcal/app.py Normal file
View File

@ -0,0 +1,33 @@
import locale
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi_cache import FastAPICache
from fastapi_cache.backends.inmemory import InMemoryBackend
from fabcal.routers import api_v1, frontend
app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")
app.include_router(api_v1.router, prefix="/api/v1")
app.include_router(frontend.router, prefix="")
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://fablab-altmuehlfranken.de",
"https://www.fablab-altmuehlfranken.de",
],
)
locale.setlocale(locale.LC_TIME, locale.getlocale())
@app.on_event("startup")
async def startup():
FastAPICache.init(InMemoryBackend())

View File

@ -1,53 +1,16 @@
import base64
import locale
import os
from collections import OrderedDict
from datetime import date, datetime, timedelta, timezone
from typing import List, NamedTuple
from typing import List
import aiohttp
import babel.dates
import recurring_ical_events
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.requests import Request
from fastapi.responses import Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi_cache import FastAPICache
from fastapi_cache.backends.inmemory import InMemoryBackend
from fastapi_cache.decorator import cache
from icalendar import Calendar, vText
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
from fabcal.models import CalendarEvent
def sanitize(data: str):
@ -60,6 +23,15 @@ def sanitize(data: str):
return cal.to_ical()
def get_calendar_url():
url = os.environ["CALENDAR_URL"]
# convenience feature
url = url.replace("webcal://", "https://")
return url
# caching strings works better than caching calendar objects
@cache(expire=120)
async def get_data() -> str:
@ -71,18 +43,6 @@ async def get_data() -> str:
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))
@ -163,16 +123,6 @@ async def get_future_events():
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()
@ -185,41 +135,3 @@ def group_by_date(events: List[CalendarEvent]):
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())

14
fabcal/models.py Normal file
View File

@ -0,0 +1,14 @@
from datetime import datetime
from typing import NamedTuple
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

View File

@ -0,0 +1,31 @@
import re
from jinja2 import pass_eval_context
from markupsafe import Markup, escape
from starlette.templating import Jinja2Templates
templates = Jinja2Templates(directory="templates")
@pass_eval_context
def nl2br(eval_ctx, value):
br = "<br>\n"
if eval_ctx.autoescape:
value = escape(value)
br = Markup(br)
result = "\n\n".join(
# need to convert p to Markup again after applying re.split(...)
f"<p>{br.join(Markup(p).splitlines())}</p>"
for p in re.split(r"(?:\r\n|\r(?!\n)|\n){2,}", value)
)
if eval_ctx.autoescape:
result = Markup(result)
return result
templates.env.filters["nl2br"] = nl2br

17
fabcal/routers/api_v1.py Normal file
View File

@ -0,0 +1,17 @@
from fastapi import APIRouter
from starlette.responses import Response
from fabcal.calendar_client import get_data
router = APIRouter()
@router.get("/events.ics")
async def events_ics():
return Response(
await get_data(),
headers={
"content-type": "text/calendar",
},
)

View File

@ -0,0 +1,60 @@
import base64
from datetime import datetime
import babel.dates
from fastapi import APIRouter
from fastapi.requests import Request
from fastapi.responses import HTMLResponse
from fabcal.calendar_client import get_future_events, group_by_date
from fabcal.routers import templates
router = APIRouter()
async def generate_response(request: Request, template_name: str, **additional_context):
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()
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,
}
context.update(additional_context)
return templates.TemplateResponse(
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)
async def embed_sidebar(request: Request):
return await generate_response(request, "sidebar/embed.html")

1261
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,10 @@ 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"] },
]
[tool.poetry.dependencies]
python = "^3.8"
@ -31,4 +35,3 @@ profile = "black"
line_length = 120
lines_between_types = 1
lines_after_imports = 2
lines_between_sections = 2

View File

@ -13,7 +13,9 @@ body {
font-size: 9pt;
height: auto;
overflow: auto
overflow: auto;
/*background: repeating-linear-gradient(135deg, #eee, #eee 100px, #ccc 100px, #ccc 200px);*/
}
.calendar {
@ -26,7 +28,6 @@ body {
display: flex;
align-items: stretch;
flex-direction: row;
background-color: white;
}
.calendar-date-date {
@ -36,6 +37,7 @@ body {
margin-right: 8px;
flex: 35px 0 0;
height: 100%;
background-color: white;
}
.calendar-date-month {
background-color: var(--calendar-fablab-red);
@ -96,10 +98,19 @@ body {
flex-direction: column;
justify-content: center;
}
.calendar-event-description p {
margin-bottom: 0.5em;
}
.calendar-event-description p:last-child {
margin-bottom: 0;
}
.calendar-event-type {
flex: 0 0 12px;
margin: -5px -5px -5px 0;
}
.calendar-subscription-buttons {
text-align: center;
}
@media only screen and (max-width: 960px) {
.calendar-event {

View File

@ -1,68 +0,0 @@
<!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>

View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="de">
<head>
{% block header %}
{#- 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>
{% endblock %}
</head>
<body>
{% block body %}
{% endblock %}
</body>
</html>

View File

@ -0,0 +1,21 @@
<!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>
<div class="calendar">
{% include "sidebar/includes/header.html" %}
{% include "sidebar/includes/events-list.html" %}
{% include "sidebar/includes/footer.html" %}
</div>
</body>
</html>

View File

@ -0,0 +1,57 @@
{% for start_date, events in grouped_events %}
<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 | urlize }}</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 | urlize | nl2br }}</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 | urlize | nl2br }}</div>
</div>
{% endif %}
</div>
</details>
{% else %}
<span>{{ event.summary }}</span>
{% endif %}
</div>
{%- if "AusbauLab" in event.categories %}
<div class="calendar-event-type calendar-event-type-ausbau"></div>
{%- elif event.color %}
<div class="calendar-event-type" style="background-color: {{ event.color }};"></div>
{%- else %}
<div class="calendar-event-type calendar-event-type-unknown"></div>
{%- endif %}
</div>
{% endfor %}
</div>
</div>
{% endfor %}

View File

@ -0,0 +1,3 @@
<div class="calendar-subscription-buttons">
<a href="{{ url_for('events_ics') }}"><i class="fa-solid fa-calendar"></i> Kalender abonnieren</a>
</div>

View File

@ -0,0 +1 @@
<h3>Veranstaltungen</h3>