Compare commits

...

5 Commits

9 changed files with 344 additions and 18 deletions

View File

@@ -1,7 +1,7 @@
import locale import locale
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime, timedelta
from typing import Annotated, Union from typing import Annotated
import starlette.status as status import starlette.status as status
from fastapi import Depends, FastAPI, Request from fastapi import Depends, FastAPI, Request
@@ -10,7 +10,8 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlmodel import Session, SQLModel, create_engine, select 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_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}" sqlite_url = f"sqlite:///{sqlite_file_name}"
@@ -45,16 +46,109 @@ SessionDep = Annotated[Session, Depends(get_session)]
@app.get("/") @app.get("/")
async def read_root(request: Request, session: SessionDep): async def index(request: Request, session: SessionDep):
statement = select(Event).order_by(Event.event_time) """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() events = session.exec(statement).all()
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="index.html", 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") @app.get("/event/add")
async def add_event_form(request: Request, session: SessionDep): async def add_event_form(request: Request, session: SessionDep):
return templates.TemplateResponse(request=request, name="add_event.html") 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( return templates.TemplateResponse(
request=request, request=request,
name="event.html", name="event.html",
context={"event": event, "households": households}, context={"event": event, "households": households, "now": datetime.now()},
) )

View File

@@ -1,5 +1,6 @@
import typing import typing
from datetime import datetime from datetime import datetime
from xmlrpc.client import DateTime
from sqlmodel import Field, Relationship, SQLModel, String from sqlmodel import Field, Relationship, SQLModel, String
@@ -79,3 +80,51 @@ class Registration(SQLModel, table=True):
comment: str | None comment: str | None
household: Household = Relationship() 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 = [ dependencies = [
"fastapi[standard]>=0.116.0", "fastapi[standard]>=0.116.0",
"sqlmodel>=0.0.24", "sqlmodel>=0.0.24",
"uvicorn[standard]>=0.35.0",
] ]
[dependency-groups] [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> <title>Allmende Essen</title>
<link href="/static/css/bootstrap.min.css" rel="stylesheet"> <link href="/static/css/bootstrap.min.css" rel="stylesheet">
<link href="/static/icons/bootstrap-icons.min.css" rel="stylesheet"> <link href="/static/icons/bootstrap-icons.min.css" rel="stylesheet">
<link href="/static/css/allmende.css" rel="stylesheet">
</head> </head>
<body class="p-3 m-0 border-0 bd-example"> <body class="p-3 m-0 border-0 bd-example">
<nav class="navbar navbar-expand-lg bg-body-tertiary"> <nav class="navbar navbar-expand-lg bg-body-tertiary">
@@ -21,14 +22,14 @@
<div class="collapse navbar-collapse" id="navbarNav"> <div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav"> <ul class="navbar-nav">
<li class="nav-item"> <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>
<li class="nav-item"> <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> <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> </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> </ul>
</div> </div>
</div> </div>

View File

@@ -20,11 +20,12 @@
<div class="container"> <div class="container">
<div class="row m-2"> <div class="row m-2">
<div class="col-md-4 d-flex justify-content-center align-items-center flex-column"> <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 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 Anmeldung hinzufügen
</button> </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 Dienst übernehmen
</button> </button>
{% if event.recipe_link %} {% if event.recipe_link %}
@@ -162,15 +163,14 @@
<div class="modal-body"> <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> <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"> <div class="mb-3">
<select name="household" class="form-select" aria-label="Multiple select example"> <select name="household" class="form-select" aria-label="Multiple select example" required>
<option selected>Wer?</option> <option value="" disabled selected hidden>Wer?</option>
{% for household in households %} {% for household in households %}
<option value="{{household.id}}">{{household.name}}</option> <option value="{{household.id}}">{{household.name}}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<label for="InputAdults" class="form-label">Anzahl Erwachsene</label> <label for="InputAdults" class="form-label">Anzahl Erwachsene</label>

View File

@@ -2,13 +2,29 @@
{% block content %} {% block content %}
<div class="row mt-4 mb-3"> <div class="row mt-4 mb-3">
<div class="col d-flex justify-content-between align-items-center"> <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"> <a href="/event/add" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Neues Event erstellen <i class="bi bi-plus-circle"></i> Neues Event erstellen
</a> </a>
</div> </div>
</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"> <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
{% for event in events %} {% for event in events %}
<div class="col"> <div class="col">
<div class="card h-100 shadow-sm"> <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 = [ dependencies = [
{ name = "fastapi", extra = ["standard"] }, { name = "fastapi", extra = ["standard"] },
{ name = "sqlmodel" }, { name = "sqlmodel" },
{ name = "uvicorn", extra = ["standard"] },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@@ -341,6 +342,7 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "fastapi", extras = ["standard"], specifier = ">=0.116.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.116.0" },
{ name = "sqlmodel", specifier = ">=0.0.24" }, { name = "sqlmodel", specifier = ">=0.0.24" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35.0" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]