diff --git a/.dockerignore b/.dockerignore index bc63248..1e79f3f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,9 @@ __pycache__ .pytest_cache .env -data/*.db -data/*.sqlite3 +data +*.db +*.db-* +*.sqlite3 +*.sqlite3-* *.pyc diff --git a/.env.example b/.env.example index ffa29c4..0b5e475 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 425c6f0..c118cda 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ __pycache__/ .venv/ .env *.db +*.db-* *.sqlite3 +*.sqlite3-* data/* !data/.gitkeep diff --git a/Dockerfile b/Dockerfile index d974745..6b1f3b5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/Makefile b/Makefile index 4975887..c72214b 100644 --- a/Makefile +++ b/Makefile @@ -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 \ No newline at end of file +db-shell: setup + $(RUN_PYTHON) -m app.cli db-shell + +reminders: setup + $(RUN_PYTHON) -m app.cli send-reminders diff --git a/README.md b/README.md index 1e2ac69..c8c841f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/cli.py b/app/cli.py index bd3149d..83527bb 100644 --- a/app/cli.py +++ b/app/cli.py @@ -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]() diff --git a/app/templates/admin_schedule.html b/app/templates/admin_schedule.html index 16354ac..c7a62b0 100644 --- a/app/templates/admin_schedule.html +++ b/app/templates/admin_schedule.html @@ -24,16 +24,29 @@ Next → +
+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.