feat: allow to reset to rotation
This commit is contained in:
+5
-2
@@ -3,6 +3,9 @@
|
|||||||
__pycache__
|
__pycache__
|
||||||
.pytest_cache
|
.pytest_cache
|
||||||
.env
|
.env
|
||||||
data/*.db
|
data
|
||||||
data/*.sqlite3
|
*.db
|
||||||
|
*.db-*
|
||||||
|
*.sqlite3
|
||||||
|
*.sqlite3-*
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ APP_NAME=CRR Bar Rota
|
|||||||
SECRET_KEY=replace-with-a-long-random-value
|
SECRET_KEY=replace-with-a-long-random-value
|
||||||
DATABASE_URL=sqlite:///./data/bar_rota.db
|
DATABASE_URL=sqlite:///./data/bar_rota.db
|
||||||
BASE_URL=http://localhost:8000
|
BASE_URL=http://localhost:8000
|
||||||
|
PORT=8000
|
||||||
|
|
||||||
# Host directory bind-mounted at /app/data (used by docker compose only).
|
# Host directory bind-mounted at /app/data (used by docker compose only).
|
||||||
DATA_DIR=./data
|
DATA_DIR=./data
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ __pycache__/
|
|||||||
.venv/
|
.venv/
|
||||||
.env
|
.env
|
||||||
*.db
|
*.db
|
||||||
|
*.db-*
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
*.sqlite3-*
|
||||||
data/*
|
data/*
|
||||||
!data/.gitkeep
|
!data/.gitkeep
|
||||||
|
|||||||
@@ -19,3 +19,6 @@ FROM base AS dev
|
|||||||
COPY requirements-dev.txt ./
|
COPY requirements-dev.txt ./
|
||||||
RUN pip install --no-cache-dir -r 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"]
|
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
|
||||||
|
|||||||
@@ -1,25 +1,48 @@
|
|||||||
COMPOSE ?= docker compose
|
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
|
PYTHON ?= .venv/bin/python
|
||||||
PORT ?= 8000
|
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.
|
# 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
|
python3 -m venv .venv
|
||||||
$(PYTHON) -m pip install --upgrade pip
|
$(PYTHON) -m pip install --upgrade pip
|
||||||
$(PYTHON) -m pip install -r requirements-dev.txt
|
$(PYTHON) -m pip install -r requirements-dev.txt
|
||||||
|
|
||||||
# Host run with auto-reload, for fast debugging.
|
# Host run with auto-reload, for fast debugging.
|
||||||
dev:
|
dev: setup
|
||||||
@test -x $(PYTHON) || (echo "Run 'make dev-setup' first." && exit 1)
|
@test -x $(PYTHON) || (echo "Run 'make dev-setup' first." && exit 1)
|
||||||
@mkdir -p data
|
|
||||||
$(PYTHON) -m uvicorn app.main:app --reload --port $(PORT)
|
$(PYTHON) -m uvicorn app.main:app --reload --port $(PORT)
|
||||||
|
|
||||||
# Full app in Docker with bind mounts and auto-reload.
|
# Full app in Docker with bind mounts and auto-reload.
|
||||||
dev-up:
|
dev-up: setup
|
||||||
$(COMPOSE_DEV) up --build
|
$(COMPOSE_DEV) up --build
|
||||||
|
|
||||||
dev-down:
|
dev-down:
|
||||||
@@ -36,18 +59,22 @@ test-docker:
|
|||||||
$(MAKE) check
|
$(MAKE) check
|
||||||
|
|
||||||
# Apply database migrations (also runs automatically on app start).
|
# Apply database migrations (also runs automatically on app start).
|
||||||
migrate:
|
migrate: setup
|
||||||
$(PYTHON) -m app.cli migrate
|
$(RUN_PYTHON) -m app.cli migrate
|
||||||
|
|
||||||
# Autogenerate a migration: make revision m="add something"
|
# Autogenerate a migration: make revision m="add something"
|
||||||
revision:
|
revision: setup
|
||||||
$(PYTHON) -m alembic revision --autogenerate -m "$(m)"
|
@test -n "$(m)" || (echo 'Use make revision m="short description"' && exit 1)
|
||||||
|
$(RUN_PYTHON) -m alembic revision --autogenerate -m "$(m)"
|
||||||
|
|
||||||
init-db:
|
init-db: setup
|
||||||
$(PYTHON) -m app.cli init-db
|
$(RUN_PYTHON) -m app.cli init-db
|
||||||
|
|
||||||
seed-demo:
|
seed-demo: setup
|
||||||
$(PYTHON) -m app.cli seed-demo
|
$(RUN_PYTHON) -m app.cli seed-demo
|
||||||
|
|
||||||
reminders:
|
db-shell: setup
|
||||||
$(PYTHON) -m app.cli send-reminders
|
$(RUN_PYTHON) -m app.cli db-shell
|
||||||
|
|
||||||
|
reminders: setup
|
||||||
|
$(RUN_PYTHON) -m app.cli send-reminders
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Bar Rota
|
# CRR Bar Rota
|
||||||
|
|
||||||
Small self-hosted rota application for a non-profit bar/club.
|
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.
|
- Daily reminder command suitable for cron.
|
||||||
- SQLite by default, packaged for Docker.
|
- 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
|
```bash
|
||||||
cp .env.example .env
|
make dev-setup # creates .env if missing, .venv, and installs dependencies
|
||||||
# Edit SECRET_KEY and INITIAL_ADMIN_PASSWORD before first start.
|
make seed-demo # optional: populate example people and schedules before opening the app
|
||||||
docker compose up -d --build
|
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
|
To fill your own data, skip `seed-demo`, sign in as administrator, and:
|
||||||
docker compose -f docker-compose.dev.yml up --build
|
|
||||||
# or
|
1. Open **People** (`/admin/users`) and add members. Contact details are optional; credentials are optional as a pair.
|
||||||
make dev-up
|
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.
|
**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
|
```bash
|
||||||
make dev-setup # creates .venv and installs requirements-dev.txt
|
make seed-demo RUNTIME=docker # optional; initializes and seeds the persistent database
|
||||||
make dev # uvicorn with auto-reload on http://localhost:8000
|
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
|
```bash
|
||||||
python -m venv .venv
|
make dev-up PORT=8080 # use another host port
|
||||||
. .venv/bin/activate
|
make dev-logs # follow app logs from another terminal
|
||||||
pip install -r requirements-dev.txt
|
make dev-down # remove containers; database files are kept
|
||||||
cp .env.example .env
|
|
||||||
python -m app.cli init-db
|
|
||||||
uvicorn app.main:app --reload
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
```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
|
## Email
|
||||||
|
|
||||||
@@ -87,8 +101,28 @@ The schema is managed with Alembic. The app runs `alembic upgrade head` on start
|
|||||||
```bash
|
```bash
|
||||||
make migrate # apply pending migrations
|
make migrate # apply pending migrations
|
||||||
make revision m="add phone field" # generate a revision after editing app/models.py
|
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.
|
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
|
## 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.
|
`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
|
## Project documentation
|
||||||
|
|
||||||
- `AGENTS.md` — instructions and guardrails for coding agents/LLMs.
|
- `AGENTS.md` — instructions and guardrails for coding agents/LLMs.
|
||||||
|
|||||||
+40
-14
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import code
|
||||||
import sys
|
import sys
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
@@ -15,7 +16,8 @@ from .database import (
|
|||||||
run_migrations,
|
run_migrations,
|
||||||
stamp,
|
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 .notifications import send_email_notification
|
||||||
from .scheduling import ensure_month_assignments
|
from .scheduling import ensure_month_assignments
|
||||||
from .security import hash_password
|
from .security import hash_password
|
||||||
@@ -63,25 +65,48 @@ def seed_demo() -> None:
|
|||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
people = db.scalars(select(User).where(User.username.in_([x[1] for x in demo_people])).order_by(User.name)).all()
|
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:
|
for schedule in schedules:
|
||||||
if not db.scalar(select(RotationMember.id).where(RotationMember.schedule_id == schedule.id)):
|
if not db.scalar(select(RotationMember.id).where(RotationMember.schedule_id == schedule.id)):
|
||||||
for pos, person in enumerate(people, start=1):
|
for pos, person in enumerate(people, start=1):
|
||||||
db.add(RotationMember(schedule_id=schedule.id, user_id=person.id, position=pos))
|
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()
|
db.commit()
|
||||||
|
|
||||||
today = date.today()
|
today = date.today()
|
||||||
assignments = ensure_month_assignments(db, today.year, today.month)
|
next_month = (today.replace(day=28) + timedelta(days=4)).replace(day=1)
|
||||||
night_people = people or []
|
for month in (today, next_month):
|
||||||
if night_people:
|
ensure_month_assignments(db, month.year, month.month)
|
||||||
idx = 0
|
print("Demo data seeded for this month and next. Existing assignments and configuration preserved.")
|
||||||
for assignment in assignments:
|
print("Demo logins: alice, bruno, carla, diogo. Password: demo-password (new accounts only).")
|
||||||
if assignment.schedule.slug == "night" and assignment.user_id is None:
|
|
||||||
assignment.user_id = night_people[idx % len(night_people)].id
|
|
||||||
assignment.source = "manual"
|
def db_shell() -> None:
|
||||||
idx += 1
|
"""An interactive SQLAlchemy session against the configured application database."""
|
||||||
db.commit()
|
init_db()
|
||||||
print("Demo data seeded. Demo member password: demo-password")
|
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:
|
def send_reminders() -> None:
|
||||||
@@ -128,10 +153,11 @@ def main() -> None:
|
|||||||
"init-db": init_db,
|
"init-db": init_db,
|
||||||
"migrate": migrate,
|
"migrate": migrate,
|
||||||
"seed-demo": seed_demo,
|
"seed-demo": seed_demo,
|
||||||
|
"db-shell": db_shell,
|
||||||
"send-reminders": send_reminders,
|
"send-reminders": send_reminders,
|
||||||
}
|
}
|
||||||
if command not in commands:
|
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)
|
raise SystemExit(2)
|
||||||
commands[command]()
|
commands[command]()
|
||||||
|
|
||||||
|
|||||||
@@ -24,16 +24,29 @@
|
|||||||
<a class="button secondary" href="/admin/schedules/{{ schedule.id }}?year={{ nxt[0] }}&month={{ nxt[1] }}">Next →</a>
|
<a class="button secondary" href="/admin/schedules/{{ schedule.id }}?year={{ nxt[0] }}&month={{ nxt[1] }}">Next →</a>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<form method="post" action="/admin/schedules/{{ schedule.id }}/assignments">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<input type="hidden" name="year" value="{{ year }}"><input type="hidden" name="month" value="{{ month }}">
|
<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 %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</tbody></table></div>
|
</tbody></table></div>
|
||||||
<div class="sticky-actions"><button class="primary" type="submit">Save {{ month_name }}</button></div>
|
<div class="sticky-actions"><button class="primary" type="submit">Save {{ month_name }}</button></div>
|
||||||
</form>
|
</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>
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h3>Next dates</h3>
|
<h3>Next dates</h3>
|
||||||
@@ -41,7 +54,7 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h3>Rotation order</h3>
|
<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">
|
<ol class="rotation-list">
|
||||||
{% for member in schedule.rotation_members %}
|
{% for member in schedule.rotation_members %}
|
||||||
<li>
|
<li>
|
||||||
@@ -93,4 +106,4 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+91
-2
@@ -21,7 +21,7 @@ from .models import (
|
|||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from .notifications import send_email_notification, send_many
|
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
|
from .security import hash_password, new_csrf_token, verify_password
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -956,6 +956,11 @@ def _save_assignments(db: Session, assignments: Sequence[Assignment], form) -> i
|
|||||||
changed.append((assignment, old_user, new_user))
|
changed.append((assignment, old_user, new_user))
|
||||||
db.commit()
|
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:
|
for assignment, old_user, new_user in changed:
|
||||||
subject = f"Bar rota updated: {assignment.date:%d %b} – {assignment.schedule.name}"
|
subject = f"Bar rota updated: {assignment.date:%d %b} – {assignment.schedule.name}"
|
||||||
if new_user:
|
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"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}/",
|
f"View the rota: {settings.normalized_base_url}/",
|
||||||
)
|
)
|
||||||
return len(changed)
|
|
||||||
|
|
||||||
|
|
||||||
def _month_assignments(db: Session, year: int, month: int) -> list[Assignment]:
|
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}")
|
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)
|
@router.get("/admin/notifications", response_class=HTMLResponse)
|
||||||
def admin_notifications(request: Request, db: Session = Depends(get_db)):
|
def admin_notifications(request: Request, db: Session = Depends(get_db)):
|
||||||
_require_admin(request, db)
|
_require_admin(request, db)
|
||||||
|
|||||||
@@ -6,17 +6,18 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
target: dev
|
target: dev
|
||||||
container_name: bar-rota-dev
|
|
||||||
env_file:
|
env_file:
|
||||||
- path: .env
|
- path: .env
|
||||||
required: false
|
required: false
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "${PORT:-8000}:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./app:/app/app
|
- ./app:/app/app
|
||||||
- ./tests:/app/tests
|
- ./tests:/app/tests
|
||||||
|
- ./migrations:/app/migrations
|
||||||
|
- ./alembic.ini:/app/alembic.ini:ro
|
||||||
- ${DATA_DIR:-./data}:/app/data
|
- ${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:
|
checks:
|
||||||
build:
|
build:
|
||||||
@@ -28,9 +29,12 @@ services:
|
|||||||
SECRET_KEY: test-only-secret-key
|
SECRET_KEY: test-only-secret-key
|
||||||
DATABASE_URL: sqlite:///./data/test.db
|
DATABASE_URL: sqlite:///./data/test.db
|
||||||
INITIAL_ADMIN_PASSWORD: test-only-password
|
INITIAL_ADMIN_PASSWORD: test-only-password
|
||||||
|
SMTP_HOST: ""
|
||||||
PYTHONPATH: /app
|
PYTHONPATH: /app
|
||||||
volumes:
|
volumes:
|
||||||
- ./app:/app/app
|
- ./app:/app/app
|
||||||
- ./tests:/app/tests
|
- ./tests:/app/tests
|
||||||
- ./pyproject.toml:/app/pyproject.toml:ro
|
- ./pyproject.toml:/app/pyproject.toml:ro
|
||||||
command: ["sh", "-c", "python -m compileall -q app && pytest -ra"]
|
- ./migrations:/app/migrations:ro
|
||||||
|
- ./alembic.ini:/app/alembic.ini:ro
|
||||||
|
command: ["sh", "-c", "python -m compileall -q app && pytest -ra"]
|
||||||
|
|||||||
+11
-3
@@ -1,12 +1,20 @@
|
|||||||
services:
|
services:
|
||||||
bar-rota:
|
bar-rota:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
target: production
|
||||||
container_name: bar-rota
|
container_name: bar-rota
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- path: .env
|
- path: .env
|
||||||
required: false
|
required: false
|
||||||
ports:
|
ports:
|
||||||
- "${PORT}:8000"
|
- "${PORT:-8000}:8000"
|
||||||
volumes:
|
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
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Single FastAPI process with server-rendered HTML and a SQLite database volume. T
|
|||||||
## Data lifecycle
|
## Data lifecycle
|
||||||
|
|
||||||
1. Visiting a month calls `ensure_month_assignments`.
|
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.
|
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).
|
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.
|
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.
|
`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.
|
For larger installations, the SQLAlchemy configuration can point to PostgreSQL, but the project intentionally ships only the SQLite deployment path today.
|
||||||
|
|||||||
@@ -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.
|
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
|
## People and logins
|
||||||
|
|
||||||
- `email` and `phone` are optional.
|
- `email` and `phone` are optional.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlalchemy.pool import StaticPool
|
|||||||
|
|
||||||
from app.database import Base, get_db
|
from app.database import Base, get_db
|
||||||
from app.main import app
|
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.scheduling import ensure_month_assignments
|
||||||
from app.security import hash_password
|
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.status_code == 303
|
||||||
assert response.headers["location"] == "/admin/schedules"
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user