Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions backend/alembic/versions/d9d57470c1f1_add_site_settings.py
Original file line number Diff line number Diff line change
@@ -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 ###
3 changes: 2 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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")
2 changes: 2 additions & 0 deletions backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
Texter,
)
from app.models.shift import Shift, ShiftEndReason
from app.models.site_setting import SiteSetting

__all__ = [
"Account",
Expand All @@ -30,6 +31,7 @@
"FeedbackSubmission",
"Message",
"MessageSender",
"SiteSetting",
"Shift",
"ShiftEndReason",
"Texter",
Expand Down
24 changes: 24 additions & 0 deletions backend/app/models/site_setting.py
Original file line number Diff line number Diff line change
@@ -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()
)
16 changes: 16 additions & 0 deletions backend/app/routers/settings.py
Original file line number Diff line number Diff line change
@@ -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)
12 changes: 12 additions & 0 deletions backend/app/schemas/settings.py
Original file line number Diff line number Diff line change
@@ -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"]]
1 change: 1 addition & 0 deletions backend/app/seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
73 changes: 73 additions & 0 deletions backend/app/services/site_settings.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand Down
39 changes: 39 additions & 0 deletions backend/tests/test_site_settings.py
Original file line number Diff line number Diff line change
@@ -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"
29 changes: 29 additions & 0 deletions backend/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading