feat: allow to reset to rotation

This commit is contained in:
2026-10-06 02:12:18 +01:00
parent 2c3e038161
commit 048e80ddba
15 changed files with 549 additions and 74 deletions
+5 -2
View File
@@ -3,6 +3,9 @@
__pycache__
.pytest_cache
.env
data/*.db
data/*.sqlite3
data
*.db
*.db-*
*.sqlite3
*.sqlite3-*
*.pyc
+1
View File
@@ -2,6 +2,7 @@ APP_NAME=CRR Bar Rota
SECRET_KEY=replace-with-a-long-random-value
DATABASE_URL=sqlite:///./data/bar_rota.db
BASE_URL=http://localhost:8000
PORT=8000
# Host directory bind-mounted at /app/data (used by docker compose only).
DATA_DIR=./data
+2
View File
@@ -4,6 +4,8 @@ __pycache__/
.venv/
.env
*.db
*.db-*
*.sqlite3
*.sqlite3-*
data/*
!data/.gitkeep
+3
View File
@@ -19,3 +19,6 @@ FROM base AS dev
COPY requirements-dev.txt ./
RUN pip install --no-cache-dir -r requirements-dev.txt
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
# Keep plain `docker build .` on the production runtime as well.
FROM base AS production
+43 -16
View File
@@ -1,25 +1,48 @@
COMPOSE ?= docker compose
COMPOSE_DEV = $(COMPOSE) -f docker-compose.dev.yml
COMPOSE_DEV = PORT=$(PORT) $(COMPOSE) -f docker-compose.dev.yml
PYTHON ?= .venv/bin/python
PORT ?= 8000
RUNTIME ?= local
.PHONY: dev-setup dev dev-up dev-down dev-logs check test-docker migrate revision init-db seed-demo reminders
ifeq ($(RUNTIME),docker)
RUN_PYTHON = $(COMPOSE_DEV) run --rm --build app python
else ifeq ($(RUNTIME),local)
RUN_PYTHON = $(PYTHON)
else
$(error RUNTIME must be local or docker)
endif
.DEFAULT_GOAL := help
.PHONY: help setup dev-setup dev dev-up dev-down dev-logs check test-docker migrate revision init-db seed-demo db-shell reminders
help:
@echo "Local: make dev-setup && make seed-demo && make dev"
@echo "Docker: make seed-demo RUNTIME=docker && make dev-up"
@echo "Checks: make check (isolated from your development database)"
@echo "Data: make db-shell; make migrate; make revision m='description'"
@echo " Add RUNTIME=docker to run database commands in Docker."
@echo "Port: make dev PORT=8080 or make dev-up PORT=8080"
# Preserve an existing configuration and any populated database.
setup:
@test -f .env || cp .env.example .env
@mkdir -p data
# Host virtualenv, used for local debugging only. Checks always run in Docker.
dev-setup:
dev-setup: setup
@python3 -c 'import sys; sys.exit("Python 3.12+ is required; use Docker or install a newer Python.") if sys.version_info < (3, 12) else None'
python3 -m venv .venv
$(PYTHON) -m pip install --upgrade pip
$(PYTHON) -m pip install -r requirements-dev.txt
# Host run with auto-reload, for fast debugging.
dev:
dev: setup
@test -x $(PYTHON) || (echo "Run 'make dev-setup' first." && exit 1)
@mkdir -p data
$(PYTHON) -m uvicorn app.main:app --reload --port $(PORT)
# Full app in Docker with bind mounts and auto-reload.
dev-up:
dev-up: setup
$(COMPOSE_DEV) up --build
dev-down:
@@ -36,18 +59,22 @@ test-docker:
$(MAKE) check
# Apply database migrations (also runs automatically on app start).
migrate:
$(PYTHON) -m app.cli migrate
migrate: setup
$(RUN_PYTHON) -m app.cli migrate
# Autogenerate a migration: make revision m="add something"
revision:
$(PYTHON) -m alembic revision --autogenerate -m "$(m)"
revision: setup
@test -n "$(m)" || (echo 'Use make revision m="short description"' && exit 1)
$(RUN_PYTHON) -m alembic revision --autogenerate -m "$(m)"
init-db:
$(PYTHON) -m app.cli init-db
init-db: setup
$(RUN_PYTHON) -m app.cli init-db
seed-demo:
$(PYTHON) -m app.cli seed-demo
seed-demo: setup
$(RUN_PYTHON) -m app.cli seed-demo
reminders:
$(PYTHON) -m app.cli send-reminders
db-shell: setup
$(RUN_PYTHON) -m app.cli db-shell
reminders: setup
$(RUN_PYTHON) -m app.cli send-reminders
+63 -27
View File
@@ -1,4 +1,4 @@
# Bar Rota
# CRR Bar Rota
Small self-hosted rota application for a non-profit bar/club.
@@ -14,55 +14,69 @@ Small self-hosted rota application for a non-profit bar/club.
- Daily reminder command suitable for cron.
- SQLite by default, packaged for Docker.
## Quick start with Docker
## Start locally and fill data
`docker-compose.yml` is the service stack (production/self-hosting):
Requirements: Python **3.12+**, `make`, and Docker Compose **2.24+** for checks. Run commands from the project root.
```bash
cp .env.example .env
# Edit SECRET_KEY and INITIAL_ADMIN_PASSWORD before first start.
docker compose up -d --build
make dev-setup # creates .env if missing, .venv, and installs dependencies
make seed-demo # optional: populate example people and schedules before opening the app
make dev # auto-reload on http://localhost:8000
```
Open `http://localhost:8000` and sign in with the values in `.env`.
No virtualenv activation is needed. `make setup` preserves an existing `.env` and database. Run `make` to see the main commands; use `make dev PORT=8080` for another port.
`docker-compose.dev.yml` is the development stack (bind mounts, auto-reload, no restart policy):
Open `http://localhost:8000`. With the example configuration, the administrator login is **admin / change-me-now**. Existing installations use their existing credentials.
```bash
docker compose -f docker-compose.dev.yml up --build
# or
make dev-up
```
To fill your own data, skip `seed-demo`, sign in as administrator, and:
1. Open **People** (`/admin/users`) and add members. Contact details are optional; credentials are optional as a pair.
2. Open **Schedules** (`/admin/schedules`) and configure each schedule: Night's repeating day-of-month pattern, Saturday's rotation, and Sunday's independent rotation.
3. View the rota (`/`) and use each schedule's configuration page to edit assignments.
4. Sign in as a member to request a change, as another member to accept it, then as admin to approve it.
On either lunch schedule page, **Apply rotation** updates the displayed month's automatic shifts from the current rotation order, keeping manual assignments and completed swaps. **Use rotation** on a manual row removes that override and auto-assigns the date again. If the rotation has no active members, the shift becomes unassigned. Ownership changes cancel any open/pending change request for that shift.
Demo seeding creates **alice, bruno, carla, diogo**, each with password **demo-password** for newly created accounts, both lunch rotations, and all 31 days of the repeating Night pattern. It generates this month and next so there are future shifts available for testing changes. Re-running it preserves existing accounts, configured schedules, and assignments, including intentionally unassigned shifts. If you already opened a month before seeding, its existing rows stay as they were; use the next month or edit them in the admin UI.
**Important:** the initial admin is only created when the database has no users. Changing `INITIAL_ADMIN_PASSWORD` later does not reset that account; reset it from the People screen.
## Local development
## Develop in Docker
Run it on the host for fast debugging (checks still run in Docker, see [Tests](#tests)):
Requirements: Docker with Compose **2.24+** and `make`; host Python is optional.
```bash
make dev-setup # creates .venv and installs requirements-dev.txt
make dev # uvicorn with auto-reload on http://localhost:8000
make seed-demo RUNTIME=docker # optional; initializes and seeds the persistent database
make dev-up # builds and starts the app with auto-reload
```
`make dev` uses `./.venv` directly, so no activation is needed; use `make dev PORT=8080` for another port. The SQLite database lives in `./data` and is created on first run. Equivalent manual steps:
Open `http://localhost:8000` with the same logins as local development. App code, templates, static files, and migration files are bind-mounted; Python changes reload automatically. Refresh the browser after template or CSS edits. Dependency changes need an image rebuild: stop with Ctrl-C and run `make dev-up` again.
```bash
python -m venv .venv
. .venv/bin/activate
pip install -r requirements-dev.txt
cp .env.example .env
python -m app.cli init-db
uvicorn app.main:app --reload
make dev-up PORT=8080 # use another host port
make dev-logs # follow app logs from another terminal
make dev-down # remove containers; database files are kept
```
Demo data:
`docker-compose.dev.yml` contains the development app and isolated checks service. Database commands support `RUNTIME=docker` and work even when the app is stopped.
## Move to Docker deployment
The default host and Docker configurations both use **`data/bar_rota.db`**. Stop the local app (or development container), then start the deployment stack. On the same machine, your populated database is reused without an import or conversion.
```bash
make seed-demo
make setup
# Set SECRET_KEY and INITIAL_ADMIN_PASSWORD for deployment in .env.
# Set BASE_URL to the deployed URL; PORT defaults to 8000.
docker compose up -d --build
docker compose ps # includes the HTTP healthcheck status
```
Demo member password: `demo-password`.
`docker-compose.yml` explicitly builds the production image, with persistent data and a restart policy. It runs without auto-reload. Rebuild after code changes with `docker compose up -d --build`.
To move to another machine, stop the app, copy the **entire `data/` directory** and your `.env` configuration to the new project checkout, then run the deployment command there. Keep these private files out of Git and Docker images. Stopping the app before copying also avoids copying an active SQLite database mid-write.
`DATA_DIR` changes the Docker host directory mounted at `/app/data`. Keep `DATABASE_URL=sqlite:///./data/bar_rota.db` in Docker: this path is relative to `/app` inside the container. For seamless switching between host and Docker, use the default `DATA_DIR=./data`; a custom directory requires a matching host `DATABASE_URL` when running locally. The port for Make development commands is selected with `PORT=...`; direct Compose commands use `.env`'s `PORT`. Update `BASE_URL` when changing the URL used in notification links.
## Email
@@ -87,8 +101,28 @@ The schema is managed with Alembic. The app runs `alembic upgrade head` on start
```bash
make migrate # apply pending migrations
make revision m="add phone field" # generate a revision after editing app/models.py
make db-shell # inspect/edit data with SQLAlchemy
# The same commands without host Python:
make migrate RUNTIME=docker
make revision RUNTIME=docker m="add phone field"
make db-shell RUNTIME=docker
```
A console session exposes `db`, `select`, `models`, and all model classes:
```python
people = db.scalars(select(User).order_by(User.name)).all()
[(person.id, person.name) for person in people]
db.add(User(name="New member", is_active=True)) # rota-only person
db.commit() # explicitly save edits
# db.rollback() # discard uncommitted edits
```
Exit with Ctrl-D. Use the admin UI for normal rota edits and swaps so workflow validation and notifications run. For schema changes, edit `app/models.py`, generate and review an Alembic revision, then run `make migrate` and `make check`. Docker-generated revisions appear directly in `migrations/versions/` through the bind mount. Migrate while the app is stopped, then restart it.
The SQLite file can also be opened with a SQLite database editor. With the default configuration it is `data/bar_rota.db`; use Alembic for schema edits so code and database stay in sync.
A database created before Alembic existed has no version stamp; it is adopted at the baseline revision `0001_initial` and upgraded from there. `tests/test_migrations.py` fails if the models and the migrations drift apart.
## Tests
@@ -103,6 +137,8 @@ docker compose -f docker-compose.dev.yml --profile check run --rm checks
`make check` runs `python -m compileall -q app && pytest -ra` against the working tree.
Keep `make dev` or `make dev-up` running in one terminal and run `make check` in another. You can keep adding people and editing the rota while checks run: the checks container does **not** load `.env` or mount your `data/` directory. Its database is disposable, and individual tests use temporary/in-memory databases. Stopping or rebuilding the development app does not delete your populated database.
## Project documentation
- `AGENTS.md` — instructions and guardrails for coding agents/LLMs.
+40 -14
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import code
import sys
from datetime import date, timedelta
@@ -15,7 +16,8 @@ from .database import (
run_migrations,
stamp,
)
from .models import Assignment, NotificationLog, RotationMember, Schedule, User
from . import models
from .models import Assignment, MonthlyPattern, NotificationLog, RotationMember, Schedule, User
from .notifications import send_email_notification
from .scheduling import ensure_month_assignments
from .security import hash_password
@@ -63,25 +65,48 @@ def seed_demo() -> None:
)
db.commit()
people = db.scalars(select(User).where(User.username.in_([x[1] for x in demo_people])).order_by(User.name)).all()
schedules = db.scalars(select(Schedule).where(Schedule.schedule_type == "rotation").order_by(Schedule.id)).all()
schedules = db.scalars(
select(Schedule).where(Schedule.slug.in_(["saturday-lunch", "sunday-lunch"])).order_by(Schedule.id)
).all()
for schedule in schedules:
if not db.scalar(select(RotationMember.id).where(RotationMember.schedule_id == schedule.id)):
for pos, person in enumerate(people, start=1):
db.add(RotationMember(schedule_id=schedule.id, user_id=person.id, position=pos))
night = db.scalar(select(Schedule).where(Schedule.slug == "night"))
if night is not None and not db.scalar(
select(MonthlyPattern.id).where(MonthlyPattern.schedule_id == night.id)
):
for day in range(1, 32):
db.add(MonthlyPattern(
schedule_id=night.id,
day_of_month=day,
user_id=people[(day - 1) % len(people)].id,
))
db.commit()
today = date.today()
assignments = ensure_month_assignments(db, today.year, today.month)
night_people = people or []
if night_people:
idx = 0
for assignment in assignments:
if assignment.schedule.slug == "night" and assignment.user_id is None:
assignment.user_id = night_people[idx % len(night_people)].id
assignment.source = "manual"
idx += 1
db.commit()
print("Demo data seeded. Demo member password: demo-password")
next_month = (today.replace(day=28) + timedelta(days=4)).replace(day=1)
for month in (today, next_month):
ensure_month_assignments(db, month.year, month.month)
print("Demo data seeded for this month and next. Existing assignments and configuration preserved.")
print("Demo logins: alice, bruno, carla, diogo. Password: demo-password (new accounts only).")
def db_shell() -> None:
"""An interactive SQLAlchemy session against the configured application database."""
init_db()
with SessionLocal() as db:
namespace = {"db": db, "select": select, "models": models}
namespace.update({name: getattr(models, name) for name in (
"User", "Schedule", "MonthlyPattern", "RotationMember", "Assignment", "SwapRequest", "NotificationLog"
)})
code.interact(
banner="Database console: db, select, models and model classes are available.\n"
"Example: db.scalars(select(User)).all()\n"
"Use db.commit() to save edits, db.rollback() to discard them; Ctrl-D to exit.",
local=namespace,
)
def send_reminders() -> None:
@@ -128,10 +153,11 @@ def main() -> None:
"init-db": init_db,
"migrate": migrate,
"seed-demo": seed_demo,
"db-shell": db_shell,
"send-reminders": send_reminders,
}
if command not in commands:
print("Usage: python -m app.cli [init-db|migrate|seed-demo|send-reminders]")
print("Usage: python -m app.cli [init-db|migrate|seed-demo|db-shell|send-reminders]")
raise SystemExit(2)
commands[command]()
+16 -3
View File
@@ -24,16 +24,29 @@
<a class="button secondary" href="/admin/schedules/{{ schedule.id }}?year={{ nxt[0] }}&month={{ nxt[1] }}">Next →</a>
</div>
</div>
<form method="post" action="/admin/schedules/{{ schedule.id }}/apply-rotation" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="year" value="{{ year }}"><input type="hidden" name="month" value="{{ month }}">
<button class="secondary" type="submit">Apply rotation to {{ month_name }}</button>
</form>
<p class="muted">Apply the current order to automatic shifts in this month. Manual assignments and completed swaps are kept. Use rotation removes a manual override for one date. An empty rotation leaves automatic shifts unassigned.</p>
<form method="post" action="/admin/schedules/{{ schedule.id }}/assignments">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="year" value="{{ year }}"><input type="hidden" name="month" value="{{ month }}">
<div class="table-wrap"><table><thead><tr><th>Date</th><th>Person</th><th>Source</th></tr></thead><tbody>
<div class="table-wrap"><table><thead><tr><th>Date</th><th>Person</th><th>Source</th><th>Action</th></tr></thead><tbody>
{% for assignment in assignments %}
<tr><td>{{ assignment.date.strftime('%a %d %b') }}</td><td><select name="assignment_{{ assignment.id }}"><option value="">Unassigned</option>{% for person in users %}<option value="{{ person.id }}" {% if assignment.user_id == person.id %}selected{% endif %}>{{ person.name }}</option>{% endfor %}</select></td><td><span class="badge">{{ assignment.source }}</span></td></tr>
<tr><td>{{ assignment.date.strftime('%a %d %b') }}</td><td><select name="assignment_{{ assignment.id }}"><option value="">Unassigned</option>{% for person in users %}<option value="{{ person.id }}" {% if assignment.user_id == person.id %}selected{% endif %}>{{ person.name }}</option>{% endfor %}</select></td><td><span class="badge">{{ assignment.source }}</span></td><td>{% if assignment.source == 'manual' %}<button class="secondary small" type="submit" form="use-rotation-{{ assignment.id }}" aria-label="Use rotation for {{ assignment.date.strftime('%a %d %b') }}">Use rotation</button>{% endif %}</td></tr>
{% endfor %}
</tbody></table></div>
<div class="sticky-actions"><button class="primary" type="submit">Save {{ month_name }}</button></div>
</form>
{% for assignment in assignments %}
{% if assignment.source == 'manual' %}
<form id="use-rotation-{{ assignment.id }}" method="post" action="/admin/schedules/{{ schedule.id }}/assignments/{{ assignment.id }}/use-rotation">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
</form>
{% endif %}
{% endfor %}
</section>
<section class="panel">
<h3>Next dates</h3>
@@ -41,7 +54,7 @@
</section>
<section class="panel">
<h3>Rotation order</h3>
<p class="muted">Independent rotation, filled in order. Reordering never rewrites assignments that already exist.</p>
<p class="muted">Independent rotation, filled in order. After changing the order, use Apply rotation above to update this month's automatic shifts.</p>
<ol class="rotation-list">
{% for member in schedule.rotation_members %}
<li>
+91 -2
View File
@@ -21,7 +21,7 @@ from .models import (
User,
)
from .notifications import send_email_notification, send_many
from .scheduling import assignments_by_date, ensure_month_assignments, month_bounds
from .scheduling import assignments_by_date, ensure_month_assignments, iter_month_dates, month_bounds, rotation_user_for_date
from .security import hash_password, new_csrf_token, verify_password
router = APIRouter()
@@ -956,6 +956,11 @@ def _save_assignments(db: Session, assignments: Sequence[Assignment], form) -> i
changed.append((assignment, old_user, new_user))
db.commit()
_notify_assignment_changes(db, changed)
return len(changed)
def _notify_assignment_changes(db: Session, changed: Sequence[tuple[Assignment, User | None, User | None]]) -> None:
for assignment, old_user, new_user in changed:
subject = f"Bar rota updated: {assignment.date:%d %b} – {assignment.schedule.name}"
if new_user:
@@ -976,7 +981,6 @@ def _save_assignments(db: Session, assignments: Sequence[Assignment], form) -> i
f"You are no longer assigned to {assignment.schedule.name} on {assignment.date:%A, %d %B %Y}.\n\n"
f"View the rota: {settings.normalized_base_url}/",
)
return len(changed)
def _month_assignments(db: Session, year: int, month: int) -> list[Assignment]:
@@ -1009,6 +1013,91 @@ async def admin_schedule_assignments(
return _redirect(f"/admin/schedules/{schedule.id}?year={year}&month={month}")
def _apply_rotation_to_rows(db: Session, schedule: Schedule, assignments: Sequence[Assignment]) -> int:
"""Explicit admin edit: retain assignment IDs and invalidate requests when ownership changes."""
changed: list[tuple[Assignment, User | None, User | None]] = []
touched = 0
for assignment in assignments:
new_user = rotation_user_for_date(db, schedule, assignment.date)
new_user_id = new_user.id if new_user else None
if assignment.user_id == new_user_id and assignment.source == "generated":
continue
if assignment.user_id != new_user_id:
old_user = db.get(User, assignment.user_id) if assignment.user_id is not None else None
changed.append((assignment, old_user, new_user))
if assignment.id is not None:
requests = db.scalars(select(SwapRequest).where(
SwapRequest.assignment_id == assignment.id,
SwapRequest.status.in_(["open", "pending_approval"]),
)).all()
for swap in requests:
swap.status = "cancelled"
assignment.user_id = new_user_id
assignment.user = new_user
assignment.source = "generated"
touched += 1
db.commit()
_notify_assignment_changes(db, changed)
return touched
@router.post("/admin/schedules/{schedule_id}/apply-rotation")
def admin_apply_rotation(
schedule_id: int,
request: Request,
year: int = Form(...),
month: int = Form(...),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
_require_admin(request, db)
_check_csrf(request, csrf_token)
schedule = _get_schedule(db, schedule_id)
if schedule.schedule_type != "rotation":
raise HTTPException(status_code=400, detail="This schedule does not use a rotation")
year, month = _parse_month(year, month)
rows = {row.date: row for row in _month_assignments(db, year, month) if row.schedule_id == schedule.id}
assignments = []
created = 0
for day in iter_month_dates(year, month):
if day.weekday() not in schedule.weekday_set:
continue
row = rows.get(day)
if row is None:
row = Assignment(schedule=schedule, date=day, source="generated")
db.add(row)
created += 1
if row.source == "generated":
assignments.append(row)
changed = _apply_rotation_to_rows(db, schedule, assignments)
_flash(request, f"Applied rotation for {calendar.month_name[month]} {year}: "
f"created {created} shift(s), updated {changed} assignment(s). Manual assignments and swaps were kept.")
return _redirect(f"/admin/schedules/{schedule.id}?year={year}&month={month}")
@router.post("/admin/schedules/{schedule_id}/assignments/{assignment_id}/use-rotation")
def admin_assignment_use_rotation(
schedule_id: int,
assignment_id: int,
request: Request,
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
_require_admin(request, db)
_check_csrf(request, csrf_token)
schedule = _get_schedule(db, schedule_id)
if schedule.schedule_type != "rotation":
raise HTTPException(status_code=400, detail="This schedule does not use a rotation")
assignment = db.get(Assignment, assignment_id)
if assignment is None or assignment.schedule_id != schedule.id:
raise HTTPException(status_code=404, detail="Assignment not found")
if assignment.source != "manual" or assignment.date.weekday() not in schedule.weekday_set:
raise HTTPException(status_code=400, detail="Only manual rotation assignments can be reset")
_apply_rotation_to_rows(db, schedule, [assignment])
_flash(request, "Manual assignment removed; this shift now uses the rotation.")
return _redirect(f"/admin/schedules/{schedule.id}?year={assignment.date.year}&month={assignment.date.month}")
@router.get("/admin/notifications", response_class=HTMLResponse)
def admin_notifications(request: Request, db: Session = Depends(get_db)):
_require_admin(request, db)
+7 -3
View File
@@ -6,17 +6,18 @@ services:
build:
context: .
target: dev
container_name: bar-rota-dev
env_file:
- path: .env
required: false
ports:
- "8000:8000"
- "${PORT:-8000}:8000"
volumes:
- ./app:/app/app
- ./tests:/app/tests
- ./migrations:/app/migrations
- ./alembic.ini:/app/alembic.ini:ro
- ${DATA_DIR:-./data}:/app/data
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload", "--reload-dir", "app", "--reload-dir", "migrations"]
checks:
build:
@@ -28,9 +29,12 @@ services:
SECRET_KEY: test-only-secret-key
DATABASE_URL: sqlite:///./data/test.db
INITIAL_ADMIN_PASSWORD: test-only-password
SMTP_HOST: ""
PYTHONPATH: /app
volumes:
- ./app:/app/app
- ./tests:/app/tests
- ./pyproject.toml:/app/pyproject.toml:ro
- ./migrations:/app/migrations:ro
- ./alembic.ini:/app/alembic.ini:ro
command: ["sh", "-c", "python -m compileall -q app && pytest -ra"]
+11 -3
View File
@@ -1,12 +1,20 @@
services:
bar-rota:
build: .
build:
context: .
target: production
container_name: bar-rota
restart: unless-stopped
env_file:
- path: .env
required: false
ports:
- "${PORT}:8000"
- "${PORT:-8000}:8000"
volumes:
- ${DATA_DIR}:/app/data
- ${DATA_DIR:-./data}:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)"]
interval: 30s
timeout: 10s
start_period: 10s
retries: 3
+3 -1
View File
@@ -16,7 +16,7 @@ Single FastAPI process with server-rendered HTML and a SQLite database volume. T
## Data lifecycle
1. Visiting a month calls `ensure_month_assignments`.
2. Missing Night rows are created unassigned.
2. Missing Night rows use the repeating monthly pattern, or remain unassigned for unconfigured days.
3. Missing lunch rows are created from each independent rotation.
4. Admin assigns/overrides rows on each schedule's own configuration page (the night pattern, or a lunch month view).
5. Member change requests operate on concrete assignment IDs.
@@ -26,4 +26,6 @@ Single FastAPI process with server-rendered HTML and a SQLite database volume. T
`docker-compose.yml` mounts `./data` at `/app/data`, so the SQLite DB persists across image rebuilds. It is the service stack only. `docker-compose.dev.yml` is the development stack: it builds the `dev` image target, bind-mounts `app/` and `tests/` for auto-reload, and hosts the containerized test runner.
The deployment stack builds the `production` target (also the default Docker image target), which inherits the non-reloading runtime from `base`. Development also mounts Alembic configuration and migrations so schema changes can be authored in a container. `make` database commands use the host virtualenv by default, or a one-off development container with `RUNTIME=docker`. Both use the same `data/` directory by default. The checks service has independent settings and no persistent data mount, allowing app data entry and automated tests to run concurrently.
For larger installations, the SQLAlchemy configuration can point to PostgreSQL, but the project intentionally ships only the SQLite deployment path today.
+4
View File
@@ -38,6 +38,10 @@ For a rotation schedule, the member is chosen from the ordered active rotation m
Only missing assignments are generated. Existing rows are never rewritten.
An administrator can explicitly **Apply rotation** on a lunch schedule's month page. This recalculates that month's `generated` assignments using the current active rotation members and fills missing dates for that schedule. Manual assignments and completed swaps are preserved. Simply editing the rotation order still does not rewrite any assignment.
An administrator can choose **Use rotation** on a `manual` assignment to remove that override and immediately recalculate that date from the current rotation. Its source becomes `generated`; if there are no active rotation members, it remains unassigned until an administrator applies a configured rotation. These actions preserve assignment IDs and notes. If ownership changes, open or pending-approval change requests for the assignment are cancelled, and assignment-change notifications are attempted after saving.
## People and logins
- `email` and `phone` are optional.
+182 -1
View File
@@ -9,7 +9,7 @@ from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.main import app
from app.models import Assignment, MonthlyPattern, RotationMember, Schedule, User
from app.models import Assignment, MonthlyPattern, NotificationLog, RotationMember, Schedule, SwapRequest, User
from app.scheduling import ensure_month_assignments
from app.security import hash_password
@@ -261,3 +261,184 @@ def test_month_page_redirects_to_the_schedules_hub(client, db):
assert response.status_code == 303
assert response.headers["location"] == "/admin/schedules"
def test_apply_rotation_updates_only_automatic_rows_in_selected_schedule_and_month(client, db):
night, sat, sun, ana, bruno, admin = seed(db)
token = sign_in(client)
ensure_month_assignments(db, YEAR, MONTH)
ensure_month_assignments(db, YEAR, MONTH + 1)
manual = assignment_on(db, sat, date(YEAR, MONTH, 10))
manual.user_id, manual.source = admin.id, "manual"
swapped = assignment_on(db, sat, date(YEAR, MONTH, 17))
swapped.user_id, swapped.source = bruno.id, "swap"
unassigned = assignment_on(db, sat, date(YEAR, MONTH, 24))
unassigned.user_id = None
first = assignment_on(db, sat, date(YEAR, MONTH, 3))
assert first.user_id == bruno.id
first.note = "Keep this note"
db.commit()
untouched = {
row.id: (row.user_id, row.source)
for row in db.scalars(select(Assignment)).all()
if row.schedule_id != sat.id or row.date.month != MONTH or row.source != "generated"
}
members = db.scalars(select(RotationMember).where(
RotationMember.schedule_id == sat.id
).order_by(RotationMember.position)).all()
members[0].position = 99
db.flush()
members[1].position = 0
db.flush()
members[0].position = 1
db.commit()
# Editing order alone leaves the published assignment stable.
ensure_month_assignments(db, YEAR, MONTH)
assert db.get(Assignment, first.id).user_id == bruno.id
response = client.post(f"/admin/schedules/{sat.id}/apply-rotation", data={
"csrf_token": token, "year": YEAR, "month": MONTH,
}, follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == f"/admin/schedules/{sat.id}?year={YEAR}&month={MONTH}"
db.expire_all()
assert db.get(Assignment, first.id).user_id == ana.id
assert db.get(Assignment, first.id).note == "Keep this note"
assert db.get(Assignment, unassigned.id).user_id == bruno.id
for assignment_id, expected in untouched.items():
row = db.get(Assignment, assignment_id)
assert (row.user_id, row.source) == expected
log_count = len(db.scalars(select(NotificationLog)).all())
client.post(f"/admin/schedules/{sat.id}/apply-rotation", data={
"csrf_token": token, "year": YEAR, "month": MONTH,
})
assert len(db.scalars(select(NotificationLog)).all()) == log_count
def test_apply_rotation_creates_only_missing_dates_for_selected_schedule(client, db):
_, sat, _, _, _, _ = seed(db)
token = sign_in(client)
response = client.post(f"/admin/schedules/{sat.id}/apply-rotation", data={
"csrf_token": token, "year": 2027, "month": 1,
}, follow_redirects=False)
assert response.status_code == 303
rows = db.scalars(select(Assignment).where(Assignment.date >= date(2027, 1, 1))).all()
assert len(rows) == 5
assert all(row.schedule_id == sat.id and row.date.weekday() == 5 and row.user_id is not None for row in rows)
@pytest.mark.parametrize("status", ["open", "pending_approval"])
@pytest.mark.parametrize("action", ["apply-rotation", "use-rotation"])
def test_rotation_actions_cancel_stale_requests_and_notify_after_reassignment(client, db, status, action):
_, sat, _, ana, bruno, _ = seed(db)
token = sign_in(client)
ensure_month_assignments(db, YEAR, MONTH)
row = assignment_on(db, sat, date(YEAR, MONTH, 3)) # Bruno's date in the seeded order
row.user_id = ana.id
row.source = "manual" if action == "use-rotation" else "generated"
row.note = "Keep assignment history"
db.add(SwapRequest(assignment_id=row.id, requester_id=ana.id, accepted_by_id=bruno.id, status=status))
db.commit()
url = f"/admin/schedules/{sat.id}/apply-rotation" if action == "apply-rotation" else (
f"/admin/schedules/{sat.id}/assignments/{row.id}/use-rotation"
)
response = client.post(url, data={"csrf_token": token, "year": YEAR, "month": MONTH}, follow_redirects=False)
assert response.status_code == 303
db.expire_all()
restored = db.get(Assignment, row.id)
assert (restored.user_id, restored.source, restored.note) == (bruno.id, "generated", "Keep assignment history")
request = db.scalar(select(SwapRequest).where(SwapRequest.assignment_id == row.id))
assert request.status == "cancelled"
logs = db.scalars(select(NotificationLog)).all()
assert {(log.user_id, log.event_type) for log in logs} == {
(ana.id, "assignment_changed"), (bruno.id, "assignment_updated"),
}
def test_use_rotation_removes_override_even_when_person_is_already_correct(client, db):
_, sat, _, ana, bruno, _ = seed(db)
token = sign_in(client)
ensure_month_assignments(db, YEAR, MONTH)
row = assignment_on(db, sat, date(YEAR, MONTH, 3))
row.source = "manual"
db.add(SwapRequest(assignment_id=row.id, requester_id=bruno.id, target_user_id=ana.id, status="open"))
db.commit()
client.post(f"/admin/schedules/{sat.id}/assignments/{row.id}/use-rotation", data={"csrf_token": token})
db.expire_all()
assert (row.user_id, row.source) == (bruno.id, "generated")
assert db.scalar(select(SwapRequest).where(SwapRequest.assignment_id == row.id)).status == "open"
assert not db.scalars(select(NotificationLog)).all()
def test_reset_unassigned_manual_row_and_apply_later_when_members_become_active(client, db):
_, sat, _, ana, bruno, _ = seed(db)
token = sign_in(client)
ensure_month_assignments(db, YEAR, MONTH)
row = assignment_on(db, sat, date(YEAR, MONTH, 3))
row.user_id, row.source = None, "manual"
ana.is_active = bruno.is_active = False
db.commit()
client.post(f"/admin/schedules/{sat.id}/assignments/{row.id}/use-rotation", data={"csrf_token": token})
db.expire_all()
assert (row.user_id, row.source) == (None, "generated")
ana.is_active = True
db.commit()
client.post(f"/admin/schedules/{sat.id}/apply-rotation", data={
"csrf_token": token, "year": YEAR, "month": MONTH,
})
db.expire_all()
assert (row.user_id, row.source) == (ana.id, "generated")
def test_rotation_controls_render_manual_rows_as_separate_forms(client, db):
night, sat, _, _, _, _ = seed(db)
sign_in(client)
ensure_month_assignments(db, YEAR, MONTH)
row = assignment_on(db, sat, date(YEAR, MONTH, 3))
row.source = "manual"
db.commit()
page = client.get(f"/admin/schedules/{sat.id}?year={YEAR}&month={MONTH}").text
assert "Apply rotation to October" in page
assert f'form="use-rotation-{row.id}"' in page
assert f'action="/admin/schedules/{sat.id}/assignments/{row.id}/use-rotation"' in page
assert page.index(f'id="use-rotation-{row.id}"') > page.index("Save October")
assert "Apply rotation" not in client.get(f"/admin/schedules/{night.id}").text
def test_rotation_actions_reject_invalid_schedule_row_and_month(client, db):
night, sat, sun, _, _, _ = seed(db)
token = sign_in(client)
ensure_month_assignments(db, YEAR, MONTH)
row = assignment_on(db, sat, date(YEAR, MONTH, 3))
base = {"csrf_token": token, "year": YEAR, "month": MONTH}
assert client.post(f"/admin/schedules/{night.id}/apply-rotation", data=base).status_code == 400
assert client.post(f"/admin/schedules/{sat.id}/apply-rotation", data={**base, "month": 13}).status_code == 400
assert client.post(f"/admin/schedules/{sun.id}/assignments/{row.id}/use-rotation", data=base).status_code == 404
assert client.post(f"/admin/schedules/{sat.id}/assignments/{row.id}/use-rotation", data=base).status_code == 400
row.source = "swap"
db.commit()
assert client.post(f"/admin/schedules/{sat.id}/assignments/{row.id}/use-rotation", data=base).status_code == 400
@pytest.mark.parametrize("action", ["apply-rotation", "use-rotation"])
def test_rotation_actions_require_admin_and_csrf(client, db, action):
_, sat, _, _, _, _ = seed(db)
ensure_month_assignments(db, YEAR, MONTH)
row = assignment_on(db, sat, date(YEAR, MONTH, 3))
row.source = "manual"
member = User(name="Member", username="member", password_hash=hash_password("member-password"), is_active=True)
db.add(member)
db.commit()
url = f"/admin/schedules/{sat.id}/apply-rotation" if action == "apply-rotation" else (
f"/admin/schedules/{sat.id}/assignments/{row.id}/use-rotation"
)
data = {"csrf_token": "invalid", "year": YEAR, "month": MONTH}
assert client.post(url, data=data).status_code == 401
token = sign_in(client)
assert client.post(url, data=data).status_code == 400
client.post("/logout", data={"csrf_token": token})
token = CSRF_RE.search(client.get("/login").text).group(1)
client.post("/login", data={"username": "member", "password": "member-password", "csrf_token": token})
assert client.post(url, data={**data, "csrf_token": token}).status_code == 403
+76
View File
@@ -0,0 +1,76 @@
from datetime import date, timedelta
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from app import cli, database
from app.models import Assignment, MonthlyPattern, RotationMember, Schedule, User
from app.security import verify_password
@pytest.fixture()
def sessions(tmp_path, monkeypatch):
url = f"sqlite:///{tmp_path / 'demo.db'}"
engine = create_engine(url)
factory = sessionmaker(bind=engine, expire_on_commit=False)
monkeypatch.setattr(database.settings, "database_url", url)
monkeypatch.setattr(database, "engine", engine)
monkeypatch.setattr(cli, "SessionLocal", factory)
yield factory
engine.dispose()
def test_demo_seeds_repeating_schedules_and_is_repeatable(sessions):
cli.seed_demo()
today = date.today()
next_month = (today.replace(day=28) + timedelta(days=4)).replace(day=1)
with sessions() as db:
people = db.scalars(select(User).where(User.username.in_(["alice", "bruno", "carla", "diogo"]))).all()
assert len(people) == 4
assert all(verify_password("demo-password", person.password_hash) for person in people)
assert len(db.scalars(select(MonthlyPattern)).all()) == 31
assert len(db.scalars(select(RotationMember)).all()) == 8
assignments = db.scalars(select(Assignment)).all()
assert all(row.user_id is not None for row in assignments)
assert {row.date.replace(day=1) for row in assignments} == {today.replace(day=1), next_month}
original = [(row.id, row.date, row.user_id, row.source) for row in assignments]
cli.seed_demo()
with sessions() as db:
assert len(db.scalars(select(User)).all()) == 5
assert len(db.scalars(select(MonthlyPattern)).all()) == 31
assert len(db.scalars(select(RotationMember)).all()) == 8
assert [(row.id, row.date, row.user_id, row.source) for row in db.scalars(select(Assignment)).all()] == original
def test_demo_preserves_existing_people_configuration_and_assignments(sessions):
cli.init_db()
today = date.today()
assigned_day = today.replace(day=1) if today.day != 1 else today + timedelta(days=1)
with sessions() as db:
night = db.scalar(select(Schedule).where(Schedule.slug == "night"))
saturday = db.scalar(select(Schedule).where(Schedule.slug == "saturday-lunch"))
alice = User(name="My Alice", username="alice", password_hash="keep-this-password", is_active=True)
db.add(alice)
db.flush()
db.add_all([
MonthlyPattern(schedule_id=night.id, day_of_month=1, user_id=alice.id),
RotationMember(schedule_id=saturday.id, position=10, user_id=alice.id),
Assignment(schedule_id=night.id, date=today, user_id=None, source="manual", note="Keep unassigned"),
Assignment(schedule_id=night.id, date=assigned_day, user_id=alice.id, source="swap"),
])
db.commit()
original = [(row.id, row.user_id, row.source, row.note) for row in db.scalars(select(Assignment)).all()]
cli.seed_demo()
with sessions() as db:
alice = db.scalar(select(User).where(User.username == "alice"))
assert (alice.name, alice.password_hash) == ("My Alice", "keep-this-password")
pattern = db.scalars(select(MonthlyPattern).where(MonthlyPattern.schedule_id == night.id)).all()
assert [(row.day_of_month, row.user_id) for row in pattern] == [(1, alice.id)]
rotation = db.scalars(select(RotationMember).where(RotationMember.schedule_id == saturday.id)).all()
assert [(row.position, row.user_id) for row in rotation] == [(10, alice.id)]
for assignment_id, user_id, source, note in original:
row = db.get(Assignment, assignment_id)
assert (row.user_id, row.source, row.note) == (user_id, source, note)