diff --git a/CHANGELOG.md b/CHANGELOG.md index 728d47e..bc0d00e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## [0.1.1] - 2026-04-28 +### Added + +- Optional SQLAlchemy-backed registration assistance and maintenance triage workpaper records via `CIVICPARKS_WORKPAPER_DB_URL`. +- Registration assistance and maintenance triage retrieval endpoints for persisted records. + ### Changed - Dependency-alignment release: moved CivicParks to `civiccore==0.3.0` while preserving the existing v0.1.0 runtime foundation behavior. diff --git a/PRODUCTION_DEPTH_PARKS_WORKPAPER_PERSISTENCE_DONE.md b/PRODUCTION_DEPTH_PARKS_WORKPAPER_PERSISTENCE_DONE.md new file mode 100644 index 0000000..7241d6c --- /dev/null +++ b/PRODUCTION_DEPTH_PARKS_WORKPAPER_PERSISTENCE_DONE.md @@ -0,0 +1,22 @@ +# Production Depth: Parks Workpaper Persistence + +## Summary + +CivicParks now supports optional SQLAlchemy-backed registration assistance and maintenance triage records through `CIVICPARKS_WORKPAPER_DB_URL`. + +## Shipped + +- `ParksWorkpaperRepository` with schema-aware SQLAlchemy tables. +- Persisted registration assistance records with `assistance_id`. +- Persisted maintenance triage records with `triage_id`. +- Retrieval endpoints for both persisted workpapers. +- Actionable `503` guidance when persistence is not configured. + +## Still Not Shipped + +- Payments. +- Registrations or participant records. +- Reservation writes. +- Crew dispatch. +- Live LLM calls. +- Parks connector runtime. diff --git a/README.md b/README.md index 2ffa7b1..75cbbca 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CivicParks -CivicParks v0.1.1 ships the municipal parks and recreation support foundation for CivicSuite: cited parks policy Q&A, program and facility Q&A, registration assistance that links to existing systems, maintenance request triage for Civic311-style handoff, FastAPI runtime, public sample UI, docs, tests, browser QA, and release gates. +CivicParks v0.1.1 ships the municipal parks and recreation support foundation for CivicSuite: cited parks policy Q&A, program and facility Q&A, registration assistance that links to existing systems, optional database-backed registration/maintenance workpapers, maintenance request triage for Civic311-style handoff, FastAPI runtime, public sample UI, docs, tests, browser QA, and release gates. It is not a registration system, payment processor, reservation system, participant-record store, work-order system, live LLM runtime, or parks connector. @@ -13,4 +13,6 @@ python -m uvicorn civicparks.main:app --host 127.0.0.1 --port 8143 CivicParks v0.1.1 is pinned to `civiccore==0.3.0`. +Set `CIVICPARKS_WORKPAPER_DB_URL` to persist registration assistance and maintenance triage records. Without it, CivicParks remains deterministic and stateless. + Apache 2.0 code. CC BY 4.0 docs. diff --git a/README.txt b/README.txt index 3cbc1bb..fbda718 100644 --- a/README.txt +++ b/README.txt @@ -1,6 +1,6 @@ CivicParks -CivicParks v0.1.1 ships municipal parks and recreation support foundations: cited parks policy Q&A, program and facility Q&A, registration assistance that links to existing systems, maintenance request triage for Civic311-style handoff, FastAPI runtime, public sample UI, docs, tests, browser QA, and release gates. +CivicParks v0.1.1 ships municipal parks and recreation support foundations: cited parks policy Q&A, program and facility Q&A, registration assistance that links to existing systems, optional database-backed registration/maintenance workpapers, maintenance request triage for Civic311-style handoff, FastAPI runtime, public sample UI, docs, tests, browser QA, and release gates. It is not a registration system, payment processor, reservation system, participant-record store, work-order system, live LLM runtime, or parks connector. @@ -10,4 +10,6 @@ python -m uvicorn civicparks.main:app --host 127.0.0.1 --port 8143 CivicParks v0.1.1 is pinned to civiccore==0.3.0. +Set CIVICPARKS_WORKPAPER_DB_URL to persist registration assistance and maintenance triage records. Without it, CivicParks remains deterministic and stateless. + Apache 2.0 code. CC BY 4.0 docs. diff --git a/USER-MANUAL.md b/USER-MANUAL.md index 1188d17..56f4256 100644 --- a/USER-MANUAL.md +++ b/USER-MANUAL.md @@ -2,7 +2,7 @@ ## Non-Technical Staff -CivicParks helps parks and recreation staff prepare cited policy answers, answer program and facility questions, draft registration guidance that links residents to existing systems, and triage maintenance requests for staff review. +CivicParks helps parks and recreation staff prepare cited policy answers, answer program and facility questions, draft registration guidance that links residents to existing systems, retrieve optional saved registration/maintenance workpapers, and triage maintenance requests for staff review. Staff remain responsible for every answer, registration note, facility rule, and maintenance handoff. CivicParks does not process payments, enroll participants, manage participant records, reserve facilities, dispatch crews, replace Civic311, or replace recreation registration systems. @@ -17,6 +17,8 @@ python -m uvicorn civicparks.main:app --host 127.0.0.1 --port 8143 Runtime dependency: `civiccore==0.3.0`. +Optional persistence: set `CIVICPARKS_WORKPAPER_DB_URL` to enable SQLAlchemy-backed registration assistance and maintenance triage records. Leave it unset for deterministic stateless operation. + Primary endpoints: - `GET /health` - service and CivicCore version. diff --git a/USER-MANUAL.txt b/USER-MANUAL.txt index ca91779..ab14b64 100644 --- a/USER-MANUAL.txt +++ b/USER-MANUAL.txt @@ -1,7 +1,7 @@ CivicParks User Manual -For staff: use CivicParks to draft cited parks policy answers, answer program and facility questions, prepare registration-link guidance, and triage maintenance requests. Staff approve every output before use. +For staff: use CivicParks to draft cited parks policy answers, answer program and facility questions, prepare registration-link guidance, retrieve optional saved registration/maintenance workpapers, and triage maintenance requests. Staff approve every output before use. Boundaries: CivicParks does not process payments, enroll participants, manage participant records, reserve facilities, dispatch crews, replace Civic311, or replace recreation registration systems. -For IT: install with python -m pip install -e ".[dev]" and run with python -m uvicorn civicparks.main:app --host 127.0.0.1 --port 8143. Depends on civiccore==0.3.0. +For IT: install with python -m pip install -e ".[dev]" and run with python -m uvicorn civicparks.main:app --host 127.0.0.1 --port 8143. Depends on civiccore==0.3.0. Set CIVICPARKS_WORKPAPER_DB_URL to enable SQLAlchemy-backed registration assistance and maintenance triage records. diff --git a/civicparks/main.py b/civicparks/main.py index a2eaa98..374410c 100644 --- a/civicparks/main.py +++ b/civicparks/main.py @@ -1,13 +1,16 @@ """FastAPI runtime foundation for CivicParks.""" +import os + from civiccore import __version__ as CIVICCORE_VERSION -from fastapi import FastAPI, Response +from fastapi import FastAPI, HTTPException, Response from fastapi.responses import HTMLResponse from pydantic import BaseModel from civicparks import __version__ from civicparks.maintenance import triage_maintenance_request from civicparks.policy import ParksPolicySource, answer_policy_question +from civicparks.persistence import ParksWorkpaperRepository, StoredMaintenanceTriage, StoredRegistrationAssistance from civicparks.programs import ParksProgram, answer_program_question from civicparks.public_ui import render_public_lookup_page from civicparks.registration import RecreationProgram, draft_registration_assistance @@ -18,6 +21,9 @@ description="Parks and recreation policy, program, registration-link, and maintenance-triage foundation.", ) +_workpaper_repository: ParksWorkpaperRepository | None = None +_workpaper_db_url: str | None = None + @app.get("/favicon.ico", include_in_schema=False) def favicon() -> Response: """Return an empty favicon response so browser QA has a clean console.""" @@ -60,7 +66,8 @@ def root() -> dict[str, str]: "status": "parks and recreation support foundation", "message": ( "CivicParks policy Q&A, program Q&A, registration-link assistance, " - "maintenance triage, and public UI foundation are online; payments, " + "maintenance triage, optional database-backed registration/maintenance workpapers, " + "and public UI foundation are online; payments, " "registrations, participant records, reservation writes, crew dispatch, " "live LLM calls, and connector runtime are not implemented yet." ), @@ -95,9 +102,61 @@ def program_answer(request: ProgramQuestionRequest) -> dict[str, object]: @app.post("/api/v1/civicparks/registration-assistance") def registration_assistance(request: RegistrationAssistanceRequest) -> dict[str, object]: - return draft_registration_assistance(request.program).__dict__ + if _workpaper_database_url() is not None: + return _stored_registration_response(_get_workpaper_repository().create_registration(program=request.program)) + payload = draft_registration_assistance(request.program).__dict__ + payload["assistance_id"] = None + return payload + +@app.get("/api/v1/civicparks/registration-assistance/{assistance_id}") +def get_registration_assistance(assistance_id: str) -> dict[str, object]: + if _workpaper_database_url() is None: + raise HTTPException(status_code=503, detail={"message":"CivicParks workpaper persistence is not configured.","fix":"Set CIVICPARKS_WORKPAPER_DB_URL to retrieve persisted registration assistance."}) + stored = _get_workpaper_repository().get_registration(assistance_id) + if stored is None: + raise HTTPException(status_code=404, detail={"message":"Registration assistance record not found.","fix":"Use an assistance_id returned by POST /api/v1/civicparks/registration-assistance."}) + return _stored_registration_response(stored) @app.post("/api/v1/civicparks/maintenance-triage") def maintenance_triage(request: MaintenanceTriageRequest) -> dict[str, object]: - return triage_maintenance_request(request.issue, request.location).__dict__ + if _workpaper_database_url() is not None: + return _stored_maintenance_response(_get_workpaper_repository().create_maintenance(issue=request.issue, location=request.location)) + payload = triage_maintenance_request(request.issue, request.location).__dict__ + payload["triage_id"] = None + return payload + +@app.get("/api/v1/civicparks/maintenance-triage/{triage_id}") +def get_maintenance_triage(triage_id: str) -> dict[str, object]: + if _workpaper_database_url() is None: + raise HTTPException(status_code=503, detail={"message":"CivicParks workpaper persistence is not configured.","fix":"Set CIVICPARKS_WORKPAPER_DB_URL to retrieve persisted maintenance triage records."}) + stored = _get_workpaper_repository().get_maintenance(triage_id) + if stored is None: + raise HTTPException(status_code=404, detail={"message":"Maintenance triage record not found.","fix":"Use a triage_id returned by POST /api/v1/civicparks/maintenance-triage."}) + return _stored_maintenance_response(stored) + +def _workpaper_database_url() -> str | None: + return os.environ.get("CIVICPARKS_WORKPAPER_DB_URL") + +def _get_workpaper_repository() -> ParksWorkpaperRepository: + global _workpaper_db_url, _workpaper_repository + db_url = _workpaper_database_url() + if db_url is None: + raise RuntimeError("CIVICPARKS_WORKPAPER_DB_URL is not configured.") + if _workpaper_repository is None or db_url != _workpaper_db_url: + _dispose_workpaper_repository() + _workpaper_db_url = db_url + _workpaper_repository = ParksWorkpaperRepository(db_url=db_url) + return _workpaper_repository + +def _dispose_workpaper_repository() -> None: + global _workpaper_repository + if _workpaper_repository is not None: + _workpaper_repository.engine.dispose() + _workpaper_repository = None + +def _stored_registration_response(stored: StoredRegistrationAssistance) -> dict[str, object]: + return {**stored.__dict__, "created_at": stored.created_at.isoformat()} + +def _stored_maintenance_response(stored: StoredMaintenanceTriage) -> dict[str, object]: + return {**stored.__dict__, "created_at": stored.created_at.isoformat()} diff --git a/civicparks/persistence.py b/civicparks/persistence.py new file mode 100644 index 0000000..cae4e19 --- /dev/null +++ b/civicparks/persistence.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from uuid import uuid4 + +import sqlalchemy as sa +from sqlalchemy import Engine, create_engine + +from civicparks.maintenance import triage_maintenance_request +from civicparks.registration import RecreationProgram, draft_registration_assistance + + +metadata = sa.MetaData() +registration_records = sa.Table("registration_records", metadata, sa.Column("assistance_id", sa.String(36), primary_key=True), sa.Column("program_title", sa.String(255), nullable=False), sa.Column("registration_url", sa.Text(), nullable=False), sa.Column("assistance_text", sa.Text(), nullable=False), sa.Column("staff_review_required", sa.Boolean(), nullable=False), sa.Column("boundary", sa.Text(), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), schema="civicparks") +maintenance_records = sa.Table("maintenance_records", metadata, sa.Column("triage_id", sa.String(36), primary_key=True), sa.Column("issue", sa.Text(), nullable=False), sa.Column("suggested_category", sa.String(160), nullable=False), sa.Column("civic311_handoff_ready", sa.Boolean(), nullable=False), sa.Column("staff_review_required", sa.Boolean(), nullable=False), sa.Column("boundary", sa.Text(), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), schema="civicparks") + + +@dataclass(frozen=True) +class StoredRegistrationAssistance: + assistance_id: str + program_title: str + registration_url: str + assistance_text: str + staff_review_required: bool + boundary: str + created_at: datetime + + +@dataclass(frozen=True) +class StoredMaintenanceTriage: + triage_id: str + issue: str + suggested_category: str + civic311_handoff_ready: bool + staff_review_required: bool + boundary: str + created_at: datetime + + +class ParksWorkpaperRepository: + def __init__(self, *, db_url: str | None = None, engine: Engine | None = None) -> None: + base_engine = engine or create_engine(db_url or "sqlite+pysqlite:///:memory:", future=True) + if base_engine.dialect.name == "sqlite": + self.engine = base_engine.execution_options(schema_translate_map={"civicparks": None}) + else: + self.engine = base_engine + with self.engine.begin() as connection: + connection.execute(sa.text("CREATE SCHEMA IF NOT EXISTS civicparks")) + metadata.create_all(self.engine) + + def create_registration(self, *, program: RecreationProgram) -> StoredRegistrationAssistance: + draft = draft_registration_assistance(program) + stored = StoredRegistrationAssistance(str(uuid4()), draft.program_title, draft.registration_url, draft.assistance_text, draft.staff_review_required, draft.boundary, datetime.now(UTC)) + with self.engine.begin() as connection: + connection.execute(registration_records.insert().values(**stored.__dict__)) + return stored + + def get_registration(self, assistance_id: str) -> StoredRegistrationAssistance | None: + with self.engine.begin() as connection: + row = connection.execute(sa.select(registration_records).where(registration_records.c.assistance_id == assistance_id)).mappings().first() + return None if row is None else StoredRegistrationAssistance(**dict(row)) + + def create_maintenance(self, *, issue: str, location: str) -> StoredMaintenanceTriage: + draft = triage_maintenance_request(issue, location) + stored = StoredMaintenanceTriage(str(uuid4()), draft.issue, draft.suggested_category, draft.civic311_handoff_ready, draft.staff_review_required, draft.boundary, datetime.now(UTC)) + with self.engine.begin() as connection: + connection.execute(maintenance_records.insert().values(**stored.__dict__)) + return stored + + def get_maintenance(self, triage_id: str) -> StoredMaintenanceTriage | None: + with self.engine.begin() as connection: + row = connection.execute(sa.select(maintenance_records).where(maintenance_records.c.triage_id == triage_id)).mappings().first() + return None if row is None else StoredMaintenanceTriage(**dict(row)) diff --git a/docs/browser-qa-production-depth-parks-workpaper-persistence-desktop.png b/docs/browser-qa-production-depth-parks-workpaper-persistence-desktop.png new file mode 100644 index 0000000..fd57182 Binary files /dev/null and b/docs/browser-qa-production-depth-parks-workpaper-persistence-desktop.png differ diff --git a/docs/browser-qa-production-depth-parks-workpaper-persistence-mobile.png b/docs/browser-qa-production-depth-parks-workpaper-persistence-mobile.png new file mode 100644 index 0000000..1b9b34b Binary files /dev/null and b/docs/browser-qa-production-depth-parks-workpaper-persistence-mobile.png differ diff --git a/docs/browser-qa-production-depth-parks-workpaper-persistence-summary.md b/docs/browser-qa-production-depth-parks-workpaper-persistence-summary.md new file mode 100644 index 0000000..f750f3f --- /dev/null +++ b/docs/browser-qa-production-depth-parks-workpaper-persistence-summary.md @@ -0,0 +1,27 @@ +# Browser QA: Production Depth Parks Workpaper Persistence + +Target: file:///C:/Users/scott/OneDrive/Desktop/Claude/civicparks/docs/index.html + +## desktop + +Screenshot: C:\Users\scott\OneDrive\Desktop\Claude\civicparks\docs\browser-qa-production-depth-parks-workpaper-persistence-desktop.png + +- versionVisible: PASS +- persistenceVisible: PASS +- envVisible: PASS +- boundaryVisible: PASS +- repoVisible: PASS +- consoleClean: PASS +- console messages: none + +## mobile + +Screenshot: C:\Users\scott\OneDrive\Desktop\Claude\civicparks\docs\browser-qa-production-depth-parks-workpaper-persistence-mobile.png + +- versionVisible: PASS +- persistenceVisible: PASS +- envVisible: PASS +- boundaryVisible: PASS +- repoVisible: PASS +- consoleClean: PASS +- console messages: none diff --git a/docs/index.html b/docs/index.html index 543d44a..ce3220d 100644 --- a/docs/index.html +++ b/docs/index.html @@ -20,10 +20,10 @@
Shipping v0.1.1

Parks help that keeps registration systems authoritative.

-

CivicParks helps staff draft cited park-rule answers, explain programs and facilities, link residents to existing registration systems, and triage maintenance requests for staff review.

+

CivicParks helps staff draft cited park-rule answers, explain programs and facilities, link residents to existing registration systems, persist optional registration/maintenance workpapers, and triage maintenance requests for staff review.

For Staff

Draft policy answers, program replies, registration guidance, and maintenance categories that staff verify before use.

-

For IT

FastAPI runtime pinned to civiccore==0.3.0, deterministic tests, and no registration-system writes.

+

For IT

FastAPI runtime pinned to civiccore==0.3.0, optional persistence with CIVICPARKS_WORKPAPER_DB_URL, deterministic tests, and no registration-system writes.

Boundary

No payments, registrations, participant records, reservation writes, crew dispatch, live LLM calls, or connector runtime.

diff --git a/pyproject.toml b/pyproject.toml index 20e9fa5..0d8e4d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" requires-python = ">=3.11" license = { text = "Apache-2.0" } authors = [{ name = "CivicSuite contributors" }] -dependencies = ["civiccore==0.3.0", "fastapi>=0.115.0,<1.0.0", "uvicorn[standard]>=0.30.0,<1.0.0"] +dependencies = ["civiccore==0.3.0", "fastapi>=0.115.0,<1.0.0", "sqlalchemy>=2.0.0,<3.0.0", "uvicorn[standard]>=0.30.0,<1.0.0"] [project.optional-dependencies] dev = ["build>=1.2.0,<2.0.0", "httpx>=0.27.0,<1.0.0", "pytest>=8.0.0,<9.0.0", "ruff>=0.11.0"] diff --git a/tests/test_production_depth_parks_persistence.py b/tests/test_production_depth_parks_persistence.py new file mode 100644 index 0000000..8350a8e --- /dev/null +++ b/tests/test_production_depth_parks_persistence.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi.testclient import TestClient + +from civicparks.main import app, _dispose_workpaper_repository +from civicparks.persistence import ParksWorkpaperRepository +from civicparks.registration import RecreationProgram + + +client = TestClient(app) +PROGRAM = RecreationProgram("Swim lessons", "youth", "https://parks.example/register", "Ramp access") + + +def test_repository_persists_registration_and_maintenance(tmp_path: Path) -> None: + db_path = tmp_path / "civicparks.db" + repo = ParksWorkpaperRepository(db_url=f"sqlite+pysqlite:///{db_path.as_posix()}") + reg = repo.create_registration(program=PROGRAM) + triage = repo.create_maintenance(issue="trash bin full", location="Oak Park") + repo.engine.dispose() + reloaded = ParksWorkpaperRepository(db_url=f"sqlite+pysqlite:///{db_path.as_posix()}") + assert reloaded.get_registration(reg.assistance_id).program_title == "Swim lessons" + assert reloaded.get_maintenance(triage.triage_id).suggested_category == "park-cleanup" + reloaded.engine.dispose() + db_path.unlink() + + +def test_parks_persistence_api_round_trip(monkeypatch, tmp_path: Path) -> None: + db_path = tmp_path / "civicparks-api.db" + monkeypatch.setenv("CIVICPARKS_WORKPAPER_DB_URL", f"sqlite+pysqlite:///{db_path.as_posix()}") + _dispose_workpaper_repository() + reg = client.post("/api/v1/civicparks/registration-assistance", json={"program": PROGRAM.__dict__}) + fetched_reg = client.get(f"/api/v1/civicparks/registration-assistance/{reg.json()['assistance_id']}") + triage = client.post("/api/v1/civicparks/maintenance-triage", json={"issue":"trash bin full","location":"Oak Park"}) + fetched_triage = client.get(f"/api/v1/civicparks/maintenance-triage/{triage.json()['triage_id']}") + _dispose_workpaper_repository() + monkeypatch.delenv("CIVICPARKS_WORKPAPER_DB_URL") + assert fetched_reg.status_code == 200 + assert fetched_reg.json()["staff_review_required"] is True + assert fetched_triage.status_code == 200 + assert fetched_triage.json()["suggested_category"] == "park-cleanup" + db_path.unlink() + + +def test_get_registration_without_persistence_returns_actionable_503(monkeypatch) -> None: + monkeypatch.delenv("CIVICPARKS_WORKPAPER_DB_URL", raising=False) + _dispose_workpaper_repository() + response = client.get("/api/v1/civicparks/registration-assistance/example") + assert response.status_code == 503 + assert "Set CIVICPARKS_WORKPAPER_DB_URL" in response.json()["detail"]["fix"] + + +def test_get_maintenance_missing_id_returns_actionable_404(monkeypatch, tmp_path: Path) -> None: + db_path = tmp_path / "civicparks-missing.db" + monkeypatch.setenv("CIVICPARKS_WORKPAPER_DB_URL", f"sqlite+pysqlite:///{db_path.as_posix()}") + _dispose_workpaper_repository() + response = client.get("/api/v1/civicparks/maintenance-triage/missing") + _dispose_workpaper_repository() + monkeypatch.delenv("CIVICPARKS_WORKPAPER_DB_URL") + assert response.status_code == 404 + assert "POST /api/v1/civicparks/maintenance-triage" in response.json()["detail"]["fix"] + db_path.unlink() diff --git a/tests/test_runtime_foundation.py b/tests/test_runtime_foundation.py index 820d64a..b4d05cd 100644 --- a/tests/test_runtime_foundation.py +++ b/tests/test_runtime_foundation.py @@ -10,6 +10,7 @@ def test_root_reports_honest_current_state(): payload = client.get("/").json() assert payload["name"] == "CivicParks" assert payload["version"] == __version__ + assert "database-backed registration/maintenance workpapers" in payload["message"] assert "payments" in payload["message"] assert "not implemented yet" in payload["message"] @@ -64,10 +65,10 @@ def test_api_endpoints_return_deterministic_payloads(): } }, ).json() - assert registration["staff_review_required"] is True + assert registration["assistance_id"] is None triage = client.post( "/api/v1/civicparks/maintenance-triage", json={"issue": "trash bin full", "location": "Oak Park"}, ).json() - assert triage["suggested_category"] == "park-cleanup" + assert triage["triage_id"] is None