Compare commits

...

8 Commits

Author SHA1 Message Date
c4ac44271a Add martin.k as admin 2025-12-12 13:10:54 +01:00
4b0057859e feat(events): Add billing info and organizer name 2025-12-12 12:55:40 +01:00
78066b77ae fix(grist): Don't send request to grist of nothing is written 2025-12-02 21:54:59 +01:00
491a7154e2 fix apply subscription 2025-11-04 11:25:24 +01:00
a7d6d45a78 Add db backup task
Close #2
2025-10-28 10:58:58 +01:00
994624af46 Add ignore_subscription functionality for events 2025-10-27 21:30:03 +01:00
b687a260c5 Improve apply_subscriptions
Events now have a subscriptions_applied flag. Using apply_subscriptions without passing an event now processes all events in the next week, which have not yet been processed
2025-10-27 15:32:00 +01:00
a2722d5f9f Add doc to grist sync 2025-10-27 11:35:52 +01:00
17 changed files with 386 additions and 50 deletions

7
.gitignore vendored
View File

@@ -1,5 +1,8 @@
config.php
test.php
melly-to-grist/.env
.env
**/__pycache__
new-registration-app/database.db
database.db*
.idea
db_fixtures
backups

9
justfile Normal file
View File

@@ -0,0 +1,9 @@
lint:
uv run isort src
uv run black src
reset_db:
rm -f database.db
touch database.db
alembic upgrade heads
bash -c 'shopt -s nullglob; for file in db_fixtures/*.sql; do sqlite3 database.db < "$file"; done'

View File

@@ -1,6 +1,6 @@
[project]
name = "meal-manager"
version = "0.1.0"
version = "0.2.0"
description = "Add your description here"
readme = "README.md"
requires-python = "~=3.13.0"
@@ -21,11 +21,12 @@ build-backend = "uv_build"
dev = [
"black>=25.1.0",
"isort>=6.0.1",
"pytest>=8.4.2",
]
[project.scripts]
apply-subscriptions = "meal_manager.scripts:apply_subscriptions"
apply-subscriptions = "meal_manager.scripts:apply_subscriptions_cli"
meal-manager-nightly = "meal_manager.scripts:run_nightly_tasks"
[tool.isort]
profile = "black"
@@ -36,7 +37,7 @@ profile = "black"
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = "%(here)s/alembic"
script_location = "%(here)s/src/meal_manager/alembic"
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time

View File

@@ -84,7 +84,7 @@ def upgrade() -> None:
sa.PrimaryKeyConstraint("household_id"),
)
op.create_table(
"team_registration",
"teamregistration",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("event_id", sa.Integer(), nullable=False),
sa.Column("person_name", sa.String(), nullable=False),

View File

@@ -0,0 +1,47 @@
"""Add subscriptions_applied column to Event
Revision ID: 13084c5c1f68
Revises: 299a83240036
Create Date: 2025-10-27 12:25:14.633641
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "13084c5c1f68"
down_revision: Union[str, Sequence[str], None] = "299a83240036"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"event",
sa.Column(
"subscriptions_applied",
sa.Boolean(),
nullable=False,
server_default="false",
),
)
op.add_column(
"event",
sa.Column(
"ignore_subscriptions", sa.Boolean(), nullable=False, server_default="true"
),
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("event", "subscriptions_applied")
op.drop_column("event", "ignore_subscriptions")
# ### end Alembic commands ###

View File

@@ -0,0 +1,49 @@
"""add billing info and organizer name to event
Revision ID: 914ebe23f071
Revises: 13084c5c1f68
Create Date: 2025-12-12 12:26:13.314293
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "914ebe23f071"
down_revision: Union[str, Sequence[str], None] = "13084c5c1f68"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"event",
sa.Column(
"billed", sa.Boolean(), nullable=False, server_default=sa.text("false")
),
)
op.add_column(
"event",
sa.Column(
"exclude_from_billing",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
)
op.add_column("event", sa.Column("organizer_name", sa.String(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("event", "organizer_name")
op.drop_column("event", "exclude_from_billing")
op.drop_column("event", "billed")
# ### end Alembic commands ###

View File

@@ -14,7 +14,11 @@ config = {
}
def sync_with_grist(event):
def sync_with_grist(event) -> int:
"""Writes the event's registrations to Grist.
If a registration already exists for a given household, it is not overwritten.
Returns the number of new records.
"""
grist = GristApi(config=config)
status_code, grist_response = grist.list_records(
@@ -53,4 +57,8 @@ def sync_with_grist(event):
"meal_manager_event_id": event.id,
}
)
if new_records:
grist.add_records("Transactions", new_records)
return len(new_records)
else:
return 0

View File

@@ -4,6 +4,7 @@ from contextlib import asynccontextmanager
from datetime import datetime, timedelta
from functools import partial
from typing import Annotated
from urllib.parse import quote_plus
import starlette.status as status
from fastapi import Depends, FastAPI, HTTPException, Request, Response
@@ -63,7 +64,7 @@ def get_user(request: Request, allow_none: bool = True) -> dict | None:
return {
"username": request.headers["ynh_user"],
# TODO: This should obviously be replaced with a role based check
"admin": request.headers["ynh_user"] == "niklas.m",
"admin": request.headers["ynh_user"] in ["niklas.m", "martin.k"]
}
if allow_none:
return None
@@ -239,6 +240,9 @@ async def edit_event(
event.registration_deadline = registration_deadline
event.description = form_data.get("eventDescription")
event.recipe_link = form_data.get("recipeLink")
event.ignore_subscriptions = form_data.get("ignoreSubscriptions") == "on"
event.organizer_name = form_data.get("organizerName")
event.exclude_from_billing = form_data.get("excludeFromBilling") == "on"
session.commit()
@@ -257,6 +261,9 @@ async def add_event(request: Request, session: SessionDep, user: StrictUserDep):
registration_deadline=registration_deadline,
description=form_data.get("eventDescription"),
recipe_link=form_data.get("recipeLink"),
ignore_subscriptions=form_data.get("ignoreSubscriptions") == "on",
organizer_name=form_data.get("organizerName"),
exclude_from_billing=form_data.get("excludeFromBilling") == "on",
)
session.add(event)
session.commit()
@@ -433,13 +440,25 @@ def get_event_attendance_pdf(event_id: int, session: SessionDep):
@app.get("/event/{event_id}/sync_with_grist")
def sync_with_grist_route(event_id: int, session: SessionDep, user: StrictUserDep):
"""
Synchronizes the specified event with Grist and redirects the user.
This function retrieves the event by its identifier, synchronizes it with Grist,
and then redirects the user to the event page with a success message.
TODO: Error handling
"""
statement = select(Event).where(Event.id == event_id)
event = session.scalars(statement).one()
sync_with_grist(event)
entries_written = sync_with_grist(event)
message = "Es wurden keine Einträge geschrieben."
if entries_written > 0:
event.billed = True
session.commit()
message = f"Erfolgreich {entries_written} Einträge geschrieben."
return RedirectResponse(
url=f"/event/{event_id}?message=Erfolgreich%20an%20Abrechnung%20%C3%BCbertragen",
url=f"/event/{event_id}?message={quote_plus(message)}",
status_code=status.HTTP_302_FOUND,
)

View File

@@ -19,8 +19,8 @@ class Event(Base):
title: Mapped[str] = mapped_column(nullable=False)
event_time: Mapped[datetime] = mapped_column(nullable=False)
registration_deadline: Mapped[datetime] = mapped_column(nullable=False)
description: Mapped[str] = mapped_column()
recipe_link: Mapped[str] = mapped_column()
description: Mapped[str] = mapped_column(nullable=True)
recipe_link: Mapped[str] = mapped_column(nullable=True)
# Min and max number of people needed for cooking, doing the dishes and preparing the tables
team_cooking_min: Mapped[int] = mapped_column(default=3, nullable=False)
@@ -33,6 +33,14 @@ class Event(Base):
team_prep_min: Mapped[int] = mapped_column(default=1, nullable=False)
team_prep_max: Mapped[int] = mapped_column(default=1, nullable=False)
subscriptions_applied: Mapped[bool] = mapped_column(default=False, nullable=False)
ignore_subscriptions: Mapped[bool] = mapped_column(default=False, nullable=False)
billed: Mapped[bool] = mapped_column(default=False, nullable=False)
exclude_from_billing: Mapped[bool] = mapped_column(default=False, nullable=False)
organizer_name: Mapped[str] = mapped_column(nullable=True)
registrations: Mapped[list["Registration"]] = relationship(
"Registration", cascade="all, delete"
)

View File

@@ -1,33 +1,62 @@
import argparse
import datetime
import os
import shutil
from pathlib import Path
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from meal_manager.main import engine
from meal_manager.main import engine, sqlite_file_name
from meal_manager.models import Event, Registration, Subscription
def apply_subscriptions():
parser = argparse.ArgumentParser(description="Apply subscriptions for an event")
parser.add_argument("event_id", type=int, help="Event ID (required)")
parser.add_argument(
"--dry-run", action="store_true", help="Run without making changes"
def backup_db():
backup_dir = Path(os.environ.get("MEAL_MANAGER_BACKUP_DIR", None))
shutil.copy2(
sqlite_file_name,
backup_dir / f"{sqlite_file_name}.{datetime.datetime.now().isoformat()}",
)
# TODO: Delete old backups
args = parser.parse_args()
# Access the arguments
event_id = args.event_id
dry_run = args.dry_run
def run_nightly_tasks():
print("Applying Subscriptions")
with Session(engine) as session:
subscriptions = session.scalars(select(Subscription)).all()
event = session.scalars(select(Event).where(Event.id == event_id)).one()
apply_subscriptions(session)
if dry_run:
print(f"DRY RUN: Would process event {event_id}")
print("Running db backup")
backup_db()
print("Done running nightly tasks.")
def apply_subscriptions(session: Session, event: Event = None, dry_run: bool = False):
subscriptions = session.scalars(select(Subscription)).all()
if event is not None:
events = [event]
else:
print(f"Processing event {event_id}")
today = datetime.date.today()
query = select(Event).where(
~Event.subscriptions_applied,
~Event.ignore_subscriptions,
func.strftime("%Y-%m-%d", Event.event_time) >= today.strftime("%Y-%m-%d"),
func.strftime("%Y-%m-%d", Event.event_time)
<= (today + datetime.timedelta(days=7)).strftime("%Y-%m-%d"),
)
events = session.scalars(query).all()
if len(events) == 0:
print("No events to process")
return
for event in events:
if dry_run:
print(f"DRY RUN: Would process event {event.title} ({event.id})")
else:
print(f"Processing event {event.title} ({event.id})")
print(f"There are {len(subscriptions)} subscriptions to process")
relevant_subscriptions = [
@@ -60,6 +89,28 @@ def apply_subscriptions():
else:
session.add(reg)
print(f"Registered {subscription.household.name}")
event.subscriptions_applied = True
if not dry_run:
session.commit()
def apply_subscriptions_cli():
parser = argparse.ArgumentParser(description="Apply subscriptions for an event")
parser.add_argument("--event_id", type=int, help="Event ID (required)")
parser.add_argument(
"--dry-run", action="store_true", help="Run without making changes"
)
args = parser.parse_args()
# Access the arguments
event_id = args.event_id
dry_run = args.dry_run
with Session(engine) as session:
if event_id is not None:
event = session.scalars(select(Event).where(Event.id == event_id)).one()
apply_subscriptions(session, event, dry_run)
else:
apply_subscriptions(session, dry_run=dry_run)

View File

@@ -21,6 +21,12 @@
<small class="form-text text-muted">Leer lassen für Sonntag Abend vor dem Event</small>
</div>
<div class="mb-3">
<label for="organizerName" class="form-label">Organisator*in</label>
<input type="text" class="form-control" id="organizerName" name="organizerName" {% if edit_mode %}value="{{ event.organizer_name }}"{% endif %}>
<small class="form-text text-muted">Name der Person, die das Event organisiert.</small>
</div>
<div class="mb-3">
<label for="recipeLink" class="form-label">Rezept-Link</label>
<input type="text" class="form-control" id="recipeLink" name="recipeLink" {% if edit_mode %}value="{{ event.recipe_link }}"{% endif %}>
@@ -31,6 +37,22 @@
<textarea class="form-control" id="eventDescription" name="eventDescription" rows="3" {% if edit_mode %}value="{{ event.description }}"{% endif %}></textarea>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="ignoreSubscriptions" name="ignoreSubscriptions" {% if (edit_mode and event.ignore_subscriptions) or not edit_mode %}checked{% endif %}>
<label class="form-check-label" for="ignoreSubscriptions">Dauerhafte Anmeldung ignorieren</label>
<small class="form-text text-muted">
Aktivieren, um dauerhafte Anmeldungen für dieses Event zu ignorieren. Das sollte für alle Events getan werden, die keine offiziellen AG Kochen Kochabende sind.
</small>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="excludeFromBilling" name="excludeFromBilling" {% if edit_mode and event.exclude_from_billing %}checked{% endif %}>
<label class="form-check-label" for="excludeFromBilling">Keine Abrechnung</label>
<small class="form-text text-muted">
Aktivieren, um dieses Event von der Abrechnung auszuschließen.
</small>
</div>
<button type="submit" class="btn btn-primary">Event {% if edit_mode %}bearbeiten{% else %}erstellen{% endif %}</button>
</form>
</div>

View File

@@ -18,6 +18,7 @@
{% block content %}
<p class="h1">{{ event.title }}</p>
<p class="text-muted">{{ event.event_time.strftime('%A, %d.%m.%Y') }}</p>
{% if event.organizer_name %}<p>Organisiert von {{ event.organizer_name }}</p>{% endif %}
<p>{{ event.description }}</p>
<hr class="hr"/>
{% if message %}
@@ -59,7 +60,7 @@
</div>
<div class="col-6 p-1">
{% if user -%}
<a href="/event/{{event.id}}/edit" class="btn btn-secondary w-100" target="_blank">
<a href="/event/{{event.id}}/edit" class="btn btn-secondary w-100">
<i class="bi bi-pen m-2"></i> Event bearbeiten
</a>
{% else -%}
@@ -77,7 +78,7 @@
</button>
</div>
<div class="col-6 p-1">
<a href="/event/{{event.id}}/sync_with_grist" class="btn btn-secondary w-100">
<a href="/event/{{event.id}}/sync_with_grist" class="btn btn-secondary w-100 {% if event.exclude_from_billing %}disabled{% endif %}">
<i class="bi bi-cash-coin m-2"></i> Abrechnen
</a>
</div>

View File

@@ -36,10 +36,17 @@
<div class="col">
<div class="card h-100 shadow-sm">
<div class="card-body">
<h5 class="card-title mb-1">{{ event.title }}</h5>
<p class="text-muted mb-3"><i class="bi bi-calendar"></i> {{ event.event_time.strftime('%A, %d.%m.%Y')
}}
</p>
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<h5 class="card-title mb-0">
{{ event.title }}
{% if event.billed %}<i class="bi bi-cash-coin" title="Abgerechnet"></i>{% endif %}
{% if event.exclude_from_billing %}<i class="bi bi-ban" title="Keine Abrechung"></i>{% endif %}
</h5>
</div>
{% if event.organizer_name %}<p class="text-muted small mb-0"><i class="bi bi-person"></i> {{ event.organizer_name }}</p>{% endif %}
</div>
<p class="text-muted mb-3"><i class="bi bi-calendar"></i> {{ event.event_time.strftime('%A, %d.%m.%Y') }}</p>
<p class="card-text">{{ event.description }}</p>
<a href="event/{{ event.id }}" class="btn btn-sm {% if event.registration_deadline > now %}btn-primary{% else %}btn-secondary{% endif %}">{% if event.registration_deadline > now %}Zur Anmeldung{% else %}Details ansehen{% endif %}</a>
</div>

19
tests/conftest.py Normal file
View File

@@ -0,0 +1,19 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from meal_manager.models import Base
import pytest
@pytest.fixture
def db_session():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine) # Create tables
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Provide a session and the engine
db = TestingSessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,56 @@
import datetime
from meal_manager.models import Event, Household, Subscription
from meal_manager.scripts import apply_subscriptions
def test_subscriptions(db_session):
now = datetime.datetime.now()
event1 = Event(
title="far future",
event_time=now + datetime.timedelta(days=8, hours=2),
registration_deadline=now + datetime.timedelta(days=3),
description="This event should not be proceesed.",
)
event2 = Event(
title="soon",
event_time=now + datetime.timedelta(days=7, hours=2),
registration_deadline=now + datetime.timedelta(days=2),
description="This event should be proceesed.",
)
ignore = Event(
title="ignore me",
event_time=now + datetime.timedelta(days=7, hours=2),
description=(
"This event should not be proceesed because it "
"has ignore_subscriptions set to True."
),
registration_deadline=now + datetime.timedelta(days=2, hours=2),
ignore_subscriptions=True,
)
db_session.add(event1)
db_session.add(event2)
db_session.add(ignore)
db_session.add(Household(name="Klaus", id=1))
db_session.add(
Subscription(
household_id=1,
num_adult_meals=2,
num_children_meals=1,
num_small_children_meals=0,
)
)
db_session.commit()
assert not event1.subscriptions_applied
assert not event2.subscriptions_applied
apply_subscriptions(db_session)
assert len(event1.registrations) == 0
assert len(event2.registrations) == 1
assert len(ignore.registrations) == 0
assert not event1.subscriptions_applied
assert not ignore.subscriptions_applied
assert event2.subscriptions_applied

38
uv.lock generated
View File

@@ -275,6 +275,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "isort"
version = "6.0.1"
@@ -359,7 +368,7 @@ wheels = [
[[package]]
name = "meal-manager"
version = "0.1.0"
version = "0.2.0"
source = { editable = "." }
dependencies = [
{ name = "alembic" },
@@ -375,6 +384,7 @@ dependencies = [
dev = [
{ name = "black" },
{ name = "isort" },
{ name = "pytest" },
]
[package.metadata]
@@ -392,6 +402,7 @@ requires-dist = [
dev = [
{ name = "black", specifier = ">=25.1.0" },
{ name = "isort", specifier = ">=6.0.1" },
{ name = "pytest", specifier = ">=8.4.2" },
]
[[package]]
@@ -463,6 +474,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.11.7"
@@ -533,6 +553,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/47/e43f2d8b88477a9b27b39d97e3c61534ae3cda4c99771f8b3c81d2469486/pygrister-0.8.0-py3-none-any.whl", hash = "sha256:b882a93db0aae642435d23f8e6f6a50f737befa35e3cce72f234bedd0ef4bee6", size = 31863, upload-time = "2025-08-10T10:53:46.345Z" },
]
[[package]]
name = "pytest"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
]
[[package]]
name = "python-dotenv"
version = "1.1.1"