Compare commits

...

5 Commits

9 changed files with 344 additions and 18 deletions

View File

@@ -1,7 +1,7 @@
import locale
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Annotated, Union
from datetime import datetime, timedelta
from typing import Annotated
import starlette.status as status
from fastapi import Depends, FastAPI, Request
@@ -10,7 +10,8 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlmodel import Session, SQLModel, create_engine, select
from models import Event, Household, Registration, TeamRegistration
from models import (Event, Household, Registration, Subscription,
TeamRegistration)
sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"
@@ -45,16 +46,109 @@ SessionDep = Annotated[Session, Depends(get_session)]
@app.get("/")
async def read_root(request: Request, session: SessionDep):
statement = select(Event).order_by(Event.event_time)
async def index(request: Request, session: SessionDep):
"""Displays coming events and a button to register new ones"""
now = datetime.now()
# TODO: Once we refactored to use SQLAlchemy directly, we can probably do a nicer filtering on the date alone
statement = (
select(Event)
.order_by(Event.event_time)
.where(Event.event_time >= now - timedelta(days=1))
)
events = session.exec(statement).all()
return templates.TemplateResponse(
request=request,
name="index.html",
context={"events": events, "current_page": "home", "now": datetime.now()},
context={"events": events, "current_page": "home", "now": now},
)
@app.get("/past_events")
async def past_events(request: Request, session: SessionDep):
now = datetime.now()
# TODO: Once we refactored to use SQLAlchemy directly, we can probably do a nicer filtering on the date alone
statement = (
select(Event)
.order_by(Event.event_time)
.where(Event.event_time < now - timedelta(days=1))
)
events = session.exec(statement).all()
return templates.TemplateResponse(
request=request,
name="index.html",
context={"events": events, "current_page": "past", "now": now},
)
@app.get("/subscribe")
async def subscribe(request: Request, session: SessionDep):
statement = select(Household)
households = session.exec(statement).all()
# filter out households with existing registrations
# households = [
# h
# for h in households
# if h.id not in [reg.household_id for reg in event.registrations]
# ]
subscriptions = session.exec(select(Subscription)).all()
return templates.TemplateResponse(
request=request,
name="subscribe.html",
context={"households": households, "subscriptions": subscriptions},
)
@app.post("/subscribe")
async def add_subscribe(request: Request, session: SessionDep):
form_data = await request.form()
# TODO: Make this return a nicer error message
try:
num_adult_meals = int(form_data["numAdults"]) if form_data["numAdults"] else 0
num_children_meals = int(form_data["numKids"]) if form_data["numKids"] else 0
num_small_children_meals = (
int(form_data["numSmallKids"]) if form_data["numSmallKids"] else 0
)
except ValueError:
raise ValueError("All number fields must be integers")
subscription = Subscription(
household_id=form_data["household"],
num_adult_meals=num_adult_meals,
num_children_meals=num_children_meals,
num_small_children_meals=num_small_children_meals,
)
selected_days = form_data.getlist("days")
if selected_days:
subscription.monday = "1" in selected_days
subscription.tuesday = "2" in selected_days
subscription.wednesday = "3" in selected_days
subscription.thursday = "4" in selected_days
subscription.friday = "5" in selected_days
subscription.saturday = "6" in selected_days
subscription.sunday = "7" in selected_days
session.add(subscription)
session.commit()
return RedirectResponse(url="/subscribe", status_code=status.HTTP_302_FOUND)
@app.get("/subscribe/{household_id}/delete")
async def delete_subscription(request: Request, session: SessionDep, household_id: int):
statement = select(Subscription).where(Subscription.household_id == household_id)
sub = session.exec(statement).one()
session.delete(sub)
session.commit()
return RedirectResponse(url="/subscribe", status_code=status.HTTP_302_FOUND)
@app.get("/event/add")
async def add_event_form(request: Request, session: SessionDep):
return templates.TemplateResponse(request=request, name="add_event.html")
@@ -107,7 +201,7 @@ async def read_event(request: Request, event_id: int, session: SessionDep):
return templates.TemplateResponse(
request=request,
name="event.html",
context={"event": event, "households": households},
context={"event": event, "households": households, "now": datetime.now()},
)

View File

@@ -1,5 +1,6 @@
import typing
from datetime import datetime
from xmlrpc.client import DateTime
from sqlmodel import Field, Relationship, SQLModel, String
@@ -79,3 +80,51 @@ class Registration(SQLModel, table=True):
comment: str | None
household: Household = Relationship()
class Subscription(SQLModel, table=True):
household_id: int | None = Field(
default=None, foreign_key="household.id", primary_key=True
)
num_adult_meals: int
num_children_meals: int
num_small_children_meals: int
comment: str | None
last_modified: datetime = Field(default_factory=datetime.now, nullable=False)
monday: bool = True
tuesday: bool = True
wednesday: bool = True
thursday: bool = True
friday: bool = True
saturday: bool = True
sunday: bool = True
household: Household = Relationship()
def day_string_de(self) -> str:
"""
Generates a string representation of selected days in German short form.
"""
result = []
if self.monday:
result.append("Mo")
if self.tuesday:
result.append("Di")
if self.wednesday:
result.append("Mi")
if self.thursday:
result.append("Do")
if self.friday:
result.append("Fr")
if self.saturday:
result.append("Sa")
if self.sunday:
result.append("So")
if len(result) < 7:
return ", ".join(result)
else:
return "Alle"

View File

@@ -7,6 +7,7 @@ requires-python = ">=3.13"
dependencies = [
"fastapi[standard]>=0.116.0",
"sqlmodel>=0.0.24",
"uvicorn[standard]>=0.35.0",
]
[dependency-groups]

View File

@@ -0,0 +1,29 @@
/* theme.css */
/* Green Bootstrap Theme */
:root {
--bs-primary: #198754;
--bs-primary-rgb: 25, 135, 84;
--bs-success: #198754;
--bs-success-rgb: 25, 135, 84;
--bs-link-color: var(--bs-primary);
--bs-link-hover-color: #146c43;
}
/* Explicit fallback overrides for older versions (<=5.2) */
.btn-primary {
color: #fff;
background-color: #198754;
border-color: #198754;
}
.btn-primary:hover {
color: #fff;
background-color: #157347;
border-color: #146c43;
}
.btn-primary:focus,
.btn-primary:active {
color: #fff;
background-color: #146c43;
border-color: #125c39;
}

View File

@@ -6,6 +6,7 @@
<title>Allmende Essen</title>
<link href="/static/css/bootstrap.min.css" rel="stylesheet">
<link href="/static/icons/bootstrap-icons.min.css" rel="stylesheet">
<link href="/static/css/allmende.css" rel="stylesheet">
</head>
<body class="p-3 m-0 border-0 bd-example">
<nav class="navbar navbar-expand-lg bg-body-tertiary">
@@ -21,14 +22,14 @@
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link {% if current_page == 'home' %}active{% endif %}" {% if current_page == 'home' %}aria-current="page"{% endif %} href="/">Home</a>
<a class="nav-link {% if current_page == 'home' %}active{% endif %}" {% if current_page == 'home' %}aria-current="page"{% endif %} href="/">Kommende</a>
</li>
<li class="nav-item">
<a class="nav-link {% if current_page == 'vergangene' %}active{% endif %}" {% if current_page == 'vergangene' %}aria-current="page"{% endif %} href="/vergangene">Vergangene</a>
</li>
<li class="nav-item">
<a class="nav-link {% if current_page == 'preise' %}active{% endif %}" {% if current_page == 'preise' %}aria-current="page"{% endif %} href="/preise">Preise</a>
<a class="nav-link {% if current_page == 'past' %}active{% endif %}" {% if current_page == 'past' %}aria-current="page"{% endif %} href="/past_events">Vergangene</a>
</li>
<!-- <li class="nav-item">-->
<!-- <a class="nav-link {% if current_page == 'preise' %}active{% endif %}" {% if current_page == 'preise' %}aria-current="page"{% endif %} href="/preise">Preise</a>-->
<!-- </li>-->
</ul>
</div>
</div>

View File

@@ -20,11 +20,12 @@
<div class="container">
<div class="row m-2">
<div class="col-md-4 d-flex justify-content-center align-items-center flex-column">
<p class="text-muted w-100 mb-2">Anmeldung schließt {{ event.registration_deadline.strftime('%A, %d.%m.%Y, %H:%M Uhr') }}</p>
<!-- Button trigger modal -->
<button type="button" class="btn btn-primary mb-2 w-100" data-bs-toggle="modal" data-bs-target="#registration">
<button type="button" class="btn btn-primary mb-2 w-100" {% if event.registration_deadline < now %}disabled{% endif%} data-bs-toggle="modal" data-bs-target="#registration">
Anmeldung hinzufügen
</button>
<button type="button" class="btn btn-primary mb-2 w-100" {% if event.all_teams_max() %}disabled{% endif %} data-bs-toggle="modal" data-bs-target="#teamRegistration">
<button type="button" class="btn btn-primary mb-2 w-100" {% if event.all_teams_max() or event.registration_deadline < now %}disabled{% endif %} data-bs-toggle="modal" data-bs-target="#teamRegistration">
Dienst übernehmen
</button>
{% if event.recipe_link %}
@@ -162,15 +163,14 @@
<div class="modal-body">
<p>Wenn dein Haushalt nicht auswählbar ist, existiert schon eine Anmeldung. Wenn du die Anmeldung ändern willst, lösche die bestehende Anmeldung und lege eine neue an.</p>
<div class="mb-3">
<select name="household" class="form-select" aria-label="Multiple select example">
<option selected>Wer?</option>
<select name="household" class="form-select" aria-label="Multiple select example" required>
<option value="" disabled selected hidden>Wer?</option>
{% for household in households %}
<option value="{{household.id}}">{{household.name}}</option>
{% endfor %}
</select>
</div>
<div class="row">
<div class="col">
<label for="InputAdults" class="form-label">Anzahl Erwachsene</label>

View File

@@ -2,13 +2,29 @@
{% block content %}
<div class="row mt-4 mb-3">
<div class="col d-flex justify-content-between align-items-center">
<h2>Kommende Events</h2>
<h2>{% if current_page == "home" %}Kommende{% else %}Vergangene{% endif %} Kochabende</h2>
<a href="/event/add" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Neues Event erstellen
</a>
</div>
</div>
<div class="mb-4">
<div class="card bg-success-subtle text-success-emphasis border-0 shadow-sm text-center">
<div class="card-body py-4">
<i class="bi bi-calendar-heart fs-3 mb-2"></i>
<h5 class="card-title mb-2">Nie wieder die Anmeldung vergessen</h5>
<p class="card-text small mb-3">
Die Dauerhafte Anmeldung gilt für alle kommenden Kochabende.
</p>
<a href="/subscribe" class="btn btn-light btn-sm fw-semibold px-3">
Jetzt dauerhaft Anmelden
</a>
</div>
</div>
</div>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
{% for event in events %}
<div class="col">
<div class="card h-100 shadow-sm">

View File

@@ -0,0 +1,134 @@
{% extends "base.html" %}
{% block content %}
<div class="row mt-4">
<!-- Left column: subscription form -->
<div class="col-12 col-lg-6">
<div class="row justify-content-center">
<div class="col">
<div class="card shadow-sm mt-4">
<div class="card-header bg-primary text-white">
<h1 class="fs-5 mb-0">Dauerhafte Anmeldung zu allen Kochabenden</h1>
</div>
<form action="/subscribe" method="POST">
<div class="card-body">
<p>
Mit einer dauerhaften Anmeldung kannst du dich/euch für alle zukünftigen Kochabende
anmelden. Es ist möglich
diese Anmeldung auf bestimmte Wochentage zu beschränken.
</p>
<p>
Dauerhafte Anmeldungen werden eine Woche vor einem Kochabend als Anmeldungen für diesen
Abend eingetragen. Danach
können sie auch noch gelöscht bzw. bearbeitet werden.
</p>
<!-- Household selection -->
<div class="mb-3">
<select name="household" class="form-select" required>
<option value="" disabled selected hidden>Wer?</option>
{% for household in households %}
<option value="{{household.id}}">{{household.name}}</option>
{% endfor %}
</select>
</div>
<p class="text-muted">
Wenn dein Haushalt hier nicht auswählbar ist, besteht bereits eine dauerhafte Anmeldung.
Um Änderungen vorzunehmen, lösche die bestehende Anmeldung und lege eine neue an.
</p>
<!-- Person counts -->
<div class="row g-3 mb-3">
<div class="col">
<label for="InputAdults" class="form-label">Anzahl Erwachsene</label>
<input name="numAdults" id="InputAdults" type="number" class="form-control"
aria-label="Anzahl Erwachsene" min="0" step="1" inputmode="numeric">
</div>
<div class="col">
<label for="InputKids" class="form-label">Anzahl Kinder &gt;7</label>
<input name="numKids" id="InputKids" type="number" class="form-control"
aria-label="Anzahl Kinder &gt;7" min="0" step="1" inputmode="numeric">
</div>
<div class="col">
<label for="InputSmallKids" class="form-label">Anzahl Kinder &lt;7</label>
<input name="numSmallKids" id="InputSmallKids" type="number" class="form-control"
aria-label="Anzahl Kinder &lt;7" min="0" step="1" inputmode="numeric">
</div>
</div>
<!-- Days of the week -->
<div class="mb-3">
<label class="form-label">Wochentage auswählen (optional)</label>
<p class="text-muted small mb-2">
Wenn du nur an bestimmten Tagen teilnehmen möchtest, wähle sie hier aus.
</p>
<div class="d-flex flex-wrap gap-3">
{% set days = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag",
"Sonntag"] %}
{% for day in days %}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="days" value="{{loop.index}}"
id="day-{{loop.index}}">
<label class="form-check-label" for="day-{{loop.index}}">
{{day}}
</label>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Footer -->
<div class="card-footer d-flex justify-content-end gap-2">
<button type="submit" class="btn btn-primary">Dauerhaft anmelden</button>
<a href="/" class="btn btn-secondary">Abbrechen</a>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Right column: existing registrations -->
<div class="col-12 col-lg-6 mt-4 mt-lg-0">
<p class="h4 m-2">Bestehende dauerhafte Anmeldungen</p>
{% if subscriptions | length == 0 %}
<p class="m-2">Es gibt noch keine dauerhaften Anmeldungen</p>
{% else %}
{% for sub in subscriptions %}
<div class="card mb-2">
<div class="card-body py-2 px-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<h6 class="mb-0">{{ sub.household.name }}</h6>
<a href="/subscribe/{{sub.household.id}}/delete" class="text-danger">
<i class="bi bi-trash"></i>
</a>
</div>
<div class="row g-2">
<div class="col-3 text-center">
<div class="text-muted" style="font-size: 0.7rem;">Erwachsene</div>
<div class="fw-bold small">{{ sub.num_adult_meals }}</div>
</div>
<div class="col-3 text-center">
<div class="text-muted" style="font-size: 0.7rem;">Kinder</div>
<div class="fw-bold small">{{ sub.num_children_meals }}</div>
</div>
<div class="col-3 text-center">
<div class="text-muted" style="font-size: 0.7rem;">Kleinkinder</div>
<div class="fw-bold small">{{ sub.num_small_children_meals }}</div>
</div>
<div class="col-3 text-center">
<div class="text-muted" style="font-size: 0.7rem;">Tage</div>
<div class="fw-bold small">{{ sub.day_string_de() }}</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -329,6 +329,7 @@ source = { virtual = "." }
dependencies = [
{ name = "fastapi", extra = ["standard"] },
{ name = "sqlmodel" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.dev-dependencies]
@@ -341,6 +342,7 @@ dev = [
requires-dist = [
{ name = "fastapi", extras = ["standard"], specifier = ">=0.116.0" },
{ name = "sqlmodel", specifier = ">=0.0.24" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35.0" },
]
[package.metadata.requires-dev]