diff --git a/backend/alembic/versions/d9d57470c1f1_add_site_settings.py b/backend/alembic/versions/d9d57470c1f1_add_site_settings.py new file mode 100644 index 0000000..ca8a821 --- /dev/null +++ b/backend/alembic/versions/d9d57470c1f1_add_site_settings.py @@ -0,0 +1,46 @@ +"""add site_settings + +Revision ID: d9d57470c1f1 +Revises: a33f7a001c32 +Create Date: 2026-10-22 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "d9d57470c1f1" +down_revision: str | Sequence[str] | None = "a33f7a001c32" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "site_settings", + sa.Column("key", sa.String(length=100), nullable=False), + sa.Column("value", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column("updated_by", sa.Uuid(), nullable=True), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["updated_by"], ["accounts.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("key"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("site_settings") + # ### end Alembic commands ### diff --git a/backend/app/main.py b/backend/app/main.py index 9b6e901..40e4f67 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,7 +4,7 @@ from app.config import get_settings from app.errors import register_exception_handlers from app.logging import RequestLoggingMiddleware, configure_logging -from app.routers import content, feedback, health +from app.routers import content, feedback, health, settings configure_logging() @@ -23,6 +23,7 @@ app.add_middleware(RequestLoggingMiddleware) app.include_router(health.router, prefix="/api") +app.include_router(settings.router, prefix="/api") app.include_router(content.router, prefix="/api") app.include_router(feedback.router, prefix="/api") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index ed22494..776359a 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -15,6 +15,7 @@ Texter, ) from app.models.shift import Shift, ShiftEndReason +from app.models.site_setting import SiteSetting __all__ = [ "Account", @@ -30,6 +31,7 @@ "FeedbackSubmission", "Message", "MessageSender", + "SiteSetting", "Shift", "ShiftEndReason", "Texter", diff --git a/backend/app/models/site_setting.py b/backend/app/models/site_setting.py new file mode 100644 index 0000000..76cf624 --- /dev/null +++ b/backend/app/models/site_setting.py @@ -0,0 +1,24 @@ +import uuid +from datetime import datetime +from typing import Any + +from sqlalchemy import DateTime, ForeignKey, String, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class SiteSetting(Base): + """Editable single values (TDD 4.5.3). Valid keys and types live in the SETTINGS registry.""" + + __tablename__ = "site_settings" + + key: Mapped[str] = mapped_column(String(100), primary_key=True) + value: Mapped[Any] = mapped_column(JSONB) + updated_by: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("accounts.id", ondelete="SET NULL") + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py new file mode 100644 index 0000000..a2c1909 --- /dev/null +++ b/backend/app/routers/settings.py @@ -0,0 +1,16 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.database import get_db +from app.schemas.settings import SiteSettingsDTO +from app.services.site_settings import SETTINGS, get_setting + +router = APIRouter(prefix="/settings", tags=["settings"]) + + +@router.get("", response_model=SiteSettingsDTO) +def read_settings(db: Annotated[Session, Depends(get_db)]) -> SiteSettingsDTO: + values = {key: get_setting(db, key) for key, d in SETTINGS.items() if d.public} + return SiteSettingsDTO(**values) diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py new file mode 100644 index 0000000..d276803 --- /dev/null +++ b/backend/app/schemas/settings.py @@ -0,0 +1,12 @@ +from typing import Literal + +from pydantic import AnyHttpUrl, BaseModel + + +class SiteSettingsDTO(BaseModel): + tagline: str + contact_email: str + instagram_handle: str + responder_form_url: AnyHttpUrl | None + sms_number: str | None + channels: list[Literal["web", "sms"]] diff --git a/backend/app/seed.py b/backend/app/seed.py index 8f25235..12278fb 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -20,6 +20,7 @@ def seed_dev() -> None: if get_settings().environment != "development": raise RuntimeError("seed-dev is only allowed in the development environment") + import app.services.site_settings # noqa: F401 -- registers its seed function from app.database import SessionLocal with SessionLocal.begin() as session: diff --git a/backend/app/services/site_settings.py b/backend/app/services/site_settings.py new file mode 100644 index 0000000..9f81ce4 --- /dev/null +++ b/backend/app/services/site_settings.py @@ -0,0 +1,73 @@ +import uuid +from copy import deepcopy +from dataclasses import dataclass +from datetime import datetime +from typing import Annotated, Any, Literal + +from pydantic import AnyHttpUrl, EmailStr, Field, TypeAdapter, ValidationError +from sqlalchemy.orm import Session + +from app.models.site_setting import SiteSetting +from app.seed import register + + +@register +def seed_site_settings(db: Session) -> None: + set_setting(db, "tagline", "Someone to talk to, by students for students.", None) + set_setting(db, "responder_form_url", "https://example.com/responder-form", None) + + +@dataclass(frozen=True) +class SettingDef: + adapter: TypeAdapter[Any] + default: Any + public: bool + description: str + + +def _def(tp: Any, default: Any, description: str, *, public: bool = True) -> SettingDef: + return SettingDef(TypeAdapter(tp), default, public, description) + + +SETTINGS: dict[str, SettingDef] = { + "tagline": _def(Annotated[str, Field(max_length=200)], "", "Short tagline on the home page."), + "contact_email": _def(EmailStr, "peer2peernc@unc.edu", "Public contact email."), + "instagram_handle": _def(str, "uncpeer2peer", "Instagram handle, without the @."), + "responder_form_url": _def(AnyHttpUrl | None, None, "Link to the responder application form."), + "sms_number": _def(str | None, None, "Text line number, if SMS is enabled."), + "channels": _def(list[Literal["web", "sms"]], ["web"], "Which channels the line accepts."), + "line_launch_at": _def( + datetime | None, + None, + "When the line launches. Served by the status endpoint, not here.", + public=False, + ), +} + + +def get_setting(db: Session, key: str) -> Any: + """Stored value if valid, otherwise the default. Unknown keys raise KeyError.""" + definition = SETTINGS[key] + row = db.get(SiteSetting, key) + if row is None: + return deepcopy(definition.default) + try: + return definition.adapter.validate_python(row.value) + except ValidationError: + return deepcopy(definition.default) + + +def set_setting(db: Session, key: str, value: Any, actor_id: uuid.UUID | None) -> Any: + """Validate and upsert. Raises pydantic ValidationError on bad values.""" + definition = SETTINGS[key] + validated = definition.adapter.validate_python(value) + stored = definition.adapter.dump_python(validated, mode="json") + + row = db.get(SiteSetting, key) + if row is None: + db.add(SiteSetting(key=key, value=stored, updated_by=actor_id)) + else: + row.value = stored + row.updated_by = actor_id + db.flush() + return validated diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 36fe268..dc41520 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "psycopg[binary]>=3.3", "pwdlib[argon2]>=0.3.1", "pydantic-settings>=2.15", + "pydantic[email]>=2.13.5", "sqlalchemy>=2.1", "uvicorn[standard]>=0.54", ] diff --git a/backend/tests/test_site_settings.py b/backend/tests/test_site_settings.py new file mode 100644 index 0000000..6f62d32 --- /dev/null +++ b/backend/tests/test_site_settings.py @@ -0,0 +1,39 @@ +import pytest +from fastapi.testclient import TestClient +from pydantic import ValidationError +from sqlalchemy.orm import Session + +from app.services.site_settings import get_setting, set_setting + + +def test_defaults_when_nothing_stored(db: Session) -> None: + assert get_setting(db, "contact_email") == "peer2peernc@unc.edu" + assert get_setting(db, "channels") == ["web"] + assert get_setting(db, "responder_form_url") is None + + +def test_stored_value_wins(db: Session) -> None: + set_setting(db, "tagline", "Talk to a peer", None) + assert get_setting(db, "tagline") == "Talk to a peer" + + +def test_unknown_key_raises(db: Session) -> None: + with pytest.raises(KeyError): + get_setting(db, "nope") + + +def test_set_rejects_bad_email(db: Session) -> None: + with pytest.raises(ValidationError): + set_setting(db, "contact_email", "not-an-email", None) + + +def test_set_rejects_non_url(db: Session) -> None: + with pytest.raises(ValidationError): + set_setting(db, "responder_form_url", "not a url", None) + + +def test_non_public_keys_not_served(db: Session, client: TestClient) -> None: + set_setting(db, "line_launch_at", "2026-11-01T00:00:00Z", None) + body = client.get("/api/settings").json() + assert "line_launch_at" not in body + assert body["contact_email"] == "peer2peernc@unc.edu" diff --git a/backend/uv.lock b/backend/uv.lock index 35077f9..84ccc79 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -396,6 +396,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, ] +[[package]] +name = "dnspython" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/4a/50822184bd67cc6493f0fb6a880749158fcd31ab3fa07409acfd91f9fc85/dnspython-2.9.0.tar.gz", hash = "sha256:b44dc6b18f07a8b1c56676a19fbfdb5209415b046a9cece286baafa87ff3f7f1", size = 423560, upload-time = "2026-10-09T00:07:24.352Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/02/cdcc9b7c051786a103c3b09e1003a82fa0c66bcb91ffbdabcfbf7b4163b9/dnspython-2.9.0-py3-none-any.whl", hash = "sha256:9a4aedb833c3c1b49214d04d44d3032ab7a9135f7c1d29a549b4ff78fd82fda9", size = 354822, upload-time = "2026-10-09T00:07:22.622Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + [[package]] name = "fastapi" version = "0.142.2" @@ -730,6 +752,7 @@ dependencies = [ { name = "fastapi" }, { name = "psycopg", extra = ["binary"] }, { name = "pwdlib", extra = ["argon2"] }, + { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, { name = "sqlalchemy" }, { name = "uvicorn", extra = ["standard"] }, @@ -752,6 +775,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.142" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.3" }, { name = "pwdlib", extras = ["argon2"], specifier = ">=0.3.1" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.13.5" }, { name = "pydantic-settings", specifier = ">=2.15" }, { name = "sqlalchemy", specifier = ">=2.1" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.54" }, @@ -885,6 +909,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, ] +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + [[package]] name = "pydantic-core" version = "2.46.5"