Compare commits
8 Commits
9efccccc21
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c4ac44271a | |||
| 4b0057859e | |||
| 78066b77ae | |||
| 491a7154e2 | |||
| a7d6d45a78 | |||
| 994624af46 | |||
| b687a260c5 | |||
| a2722d5f9f |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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
9
justfile
Normal 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'
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 ###
|
||||
@@ -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 ###
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
grist.add_records("Transactions", new_records)
|
||||
if new_records:
|
||||
grist.add_records("Transactions", new_records)
|
||||
return len(new_records)
|
||||
else:
|
||||
return 0
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
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:
|
||||
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_id}")
|
||||
print(f"DRY RUN: Would process event {event.title} ({event.id})")
|
||||
else:
|
||||
print(f"Processing event {event_id}")
|
||||
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()
|
||||
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)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<label for="eventName" class="form-label">Event Name</label>
|
||||
<input type="text" class="form-control" id="eventName" name="eventName" required {% if edit_mode %}value="{{ event.title }}"{% endif %}>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="eventTime" class="form-label">Datum und Uhrzeit</label>
|
||||
<input type="datetime-local" class="form-control" id="eventTime" name="eventTime" required {% if edit_mode %}value="{{ event.event_time }}"{% endif %}>
|
||||
@@ -20,17 +20,39 @@
|
||||
<input type="datetime-local" class="form-control" id="registrationDeadline" name="registrationDeadline" {% if edit_mode %}value="{{ event.registration_deadline }}"{% endif %}>
|
||||
<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 %}>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="eventDescription" class="form-label">Beschreibung</label>
|
||||
<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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -28,7 +29,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="container">
|
||||
|
||||
|
||||
<p class="h3">Anmeldungen</p>
|
||||
{% if not user %}
|
||||
<div class="alert alert-warning m-2">
|
||||
@@ -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>
|
||||
|
||||
@@ -33,18 +33,25 @@
|
||||
|
||||
|
||||
{% for event in events %}
|
||||
<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>
|
||||
<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 class="col">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
|
||||
19
tests/conftest.py
Normal file
19
tests/conftest.py
Normal 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()
|
||||
56
tests/test_nightly_tasks.py
Normal file
56
tests/test_nightly_tasks.py
Normal 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
38
uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user