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
52 changes: 52 additions & 0 deletions backend/alembic/versions/880d8ffe73a3_add_faqs_and_statistics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""add faqs and statistics

Revision ID: 880d8ffe73a3
Revises: a33f7a001c32
Create Date: 2026-10-22 10:30:17.973874

"""

from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "880d8ffe73a3"
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(
"faqs",
sa.Column("category", sa.String(length=100), nullable=False),
sa.Column("question", sa.String(length=300), nullable=False),
sa.Column("answer_md", sa.Text(), nullable=False),
sa.Column("display_order", sa.Integer(), nullable=False),
sa.Column("is_published", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"statistics",
sa.Column("label", sa.String(length=200), nullable=False),
sa.Column("value", sa.String(length=50), nullable=False),
sa.Column("display_order", sa.Integer(), nullable=False),
sa.Column("is_published", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("statistics")
op.drop_table("faqs")
# ### end Alembic commands ###
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""merge hours and FAQ statistics heads

Revision ID: 9cfb30f17a43
Revises: 4480022af87b, 880d8ffe73a3
Create Date: 2026-10-25 14:20:26.939228

"""

from collections.abc import Sequence

# revision identifiers, used by Alembic.
revision: str = "9cfb30f17a43"
down_revision: str | Sequence[str] | None = ("4480022af87b", "880d8ffe73a3")
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Upgrade schema."""
pass


def downgrade() -> None:
"""Downgrade schema."""
pass
18 changes: 17 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,19 @@
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, hours, resources, settings, status
from app.routers import (
content,
faqs,
feedback,
health,
hours,
resources,
settings,
statistics,
status,
)
from app.routers.admin import faqs as admin_faqs
from app.routers.admin import statistics as admin_statistics

configure_logging()

Expand All @@ -30,3 +42,7 @@
app.include_router(feedback.router, prefix="/api")
app.include_router(resources.router, prefix="/api")
app.include_router(status.router, prefix="/api")
app.include_router(faqs.router, prefix="/api")
app.include_router(statistics.router, prefix="/api")
app.include_router(admin_faqs.router, prefix="/api")
app.include_router(admin_statistics.router, prefix="/api")
4 changes: 4 additions & 0 deletions backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from app.models.account import Account, AccountRole, AuthSession
from app.models.audit import AuditLog
from app.models.content_block import ContentBlock
from app.models.faq import Faq
from app.models.feedback import FeedbackSubmission
from app.models.hours import HoursException, WeeklyHours
from app.models.messaging import (
Expand All @@ -18,8 +19,11 @@
from app.models.resource import Resource, ResourceTag, ResourceTagLink
from app.models.shift import Shift, ShiftEndReason
from app.models.site_setting import SiteSetting
from app.models.statistic import Statistic

__all__ = [
"Faq",
"Statistic",
"Account",
"AccountRole",
"AuthSession",
Expand Down
15 changes: 15 additions & 0 deletions backend/app/models/faq.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from sqlalchemy import Boolean, Integer, String, Text, false
from sqlalchemy.orm import Mapped, mapped_column

from app.database import Base
from app.models.base import UUIDPrimaryKeyMixin


class Faq(UUIDPrimaryKeyMixin, Base):
__tablename__ = "faqs"

category: Mapped[str] = mapped_column(String(100))
question: Mapped[str] = mapped_column(String(300))
answer_md: Mapped[str] = mapped_column(Text)
display_order: Mapped[int] = mapped_column(Integer)
is_published: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
14 changes: 14 additions & 0 deletions backend/app/models/statistic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from sqlalchemy import Boolean, Integer, String, false
from sqlalchemy.orm import Mapped, mapped_column

from app.database import Base
from app.models.base import UUIDPrimaryKeyMixin


class Statistic(UUIDPrimaryKeyMixin, Base):
__tablename__ = "statistics"

label: Mapped[str] = mapped_column(String(200))
value: Mapped[str] = mapped_column(String(50))
display_order: Mapped[int] = mapped_column(Integer)
is_published: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
Empty file.
74 changes: 74 additions & 0 deletions backend/app/routers/admin/faqs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import uuid
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy import func, select
from sqlalchemy.orm import Session

from app.database import get_db
from app.dependencies.auth import require_coordinator
from app.models import Account
from app.models.faq import Faq
from app.schemas.faqs import AdminFaqDTO, FaqInputDTO, FaqPatchDTO
from app.services.audit import AuditAction, record_audit

router = APIRouter(prefix="/admin/faqs", tags=["admin faqs"])
Database = Annotated[Session, Depends(get_db)]
Coordinator = Annotated[Account, Depends(require_coordinator)]


def get_item(db: Session, id: uuid.UUID) -> Faq:
item = db.get(Faq, id)
if item is None:
raise HTTPException(status_code=404, detail="Faq not found")
return item


@router.get("", response_model=list[AdminFaqDTO])
def list_faqs(db: Database, actor: Coordinator):
return db.scalars(select(Faq).order_by(Faq.display_order, Faq.id)).all()


@router.post("", response_model=AdminFaqDTO, status_code=201)
def create_faq(data: FaqInputDTO, db: Database, actor: Coordinator):
values = data.model_dump()
if values["display_order"] is None:
last = db.scalar(select(func.max(Faq.display_order)))
values["display_order"] = 0 if last is None else last + 1
item = Faq(**values)
db.add(item)
db.flush()
record_audit(
db, actor_id=actor.id, action=AuditAction.FAQ_CREATE, target_type="faq", target_id=item.id
)
db.commit()
return item


@router.patch("/{id}", response_model=AdminFaqDTO)
def update_faq(id: uuid.UUID, data: FaqPatchDTO, db: Database, actor: Coordinator):
item = get_item(db, id)
values = data.model_dump(exclude_unset=True)
for field, value in values.items():
setattr(item, field, value)
record_audit(
db,
actor_id=actor.id,
action=AuditAction.FAQ_UPDATE,
target_type="faq",
target_id=item.id,
details={"fields": sorted(values)},
)
db.commit()
return item


@router.delete("/{id}", status_code=204)
def delete_faq(id: uuid.UUID, db: Database, actor: Coordinator) -> Response:
item = get_item(db, id)
record_audit(
db, actor_id=actor.id, action=AuditAction.FAQ_DELETE, target_type="faq", target_id=item.id
)
db.delete(item)
db.commit()
return Response(status_code=204)
82 changes: 82 additions & 0 deletions backend/app/routers/admin/statistics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import uuid
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy import func, select
from sqlalchemy.orm import Session

from app.database import get_db
from app.dependencies.auth import require_coordinator
from app.models import Account
from app.models.statistic import Statistic
from app.schemas.statistics import AdminStatisticDTO, StatisticInputDTO, StatisticPatchDTO
from app.services.audit import AuditAction, record_audit

router = APIRouter(prefix="/admin/statistics", tags=["admin statistics"])
Database = Annotated[Session, Depends(get_db)]
Coordinator = Annotated[Account, Depends(require_coordinator)]


def get_item(db: Session, id: uuid.UUID) -> Statistic:
item = db.get(Statistic, id)
if item is None:
raise HTTPException(status_code=404, detail="Statistic not found")
return item


@router.get("", response_model=list[AdminStatisticDTO])
def list_statistics(db: Database, actor: Coordinator):
return db.scalars(select(Statistic).order_by(Statistic.display_order, Statistic.id)).all()


@router.post("", response_model=AdminStatisticDTO, status_code=201)
def create_statistic(data: StatisticInputDTO, db: Database, actor: Coordinator):
values = data.model_dump()
if values["display_order"] is None:
last = db.scalar(select(func.max(Statistic.display_order)))
values["display_order"] = 0 if last is None else last + 1
item = Statistic(**values)
db.add(item)
db.flush()
record_audit(
db,
actor_id=actor.id,
action=AuditAction.STATISTIC_CREATE,
target_type="statistic",
target_id=item.id,
)
db.commit()
return item


@router.patch("/{id}", response_model=AdminStatisticDTO)
def update_statistic(id: uuid.UUID, data: StatisticPatchDTO, db: Database, actor: Coordinator):
item = get_item(db, id)
values = data.model_dump(exclude_unset=True)
for field, value in values.items():
setattr(item, field, value)
record_audit(
db,
actor_id=actor.id,
action=AuditAction.STATISTIC_UPDATE,
target_type="statistic",
target_id=item.id,
details={"fields": sorted(values)},
)
db.commit()
return item


@router.delete("/{id}", status_code=204)
def delete_statistic(id: uuid.UUID, db: Database, actor: Coordinator) -> Response:
item = get_item(db, id)
record_audit(
db,
actor_id=actor.id,
action=AuditAction.STATISTIC_DELETE,
target_type="statistic",
target_id=item.id,
)
db.delete(item)
db.commit()
return Response(status_code=204)
18 changes: 18 additions & 0 deletions backend/app/routers/faqs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from typing import Annotated

from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.orm import Session

from app.database import get_db
from app.models.faq import Faq
from app.schemas.faqs import FaqDTO

router = APIRouter(prefix="/faqs", tags=["faqs"])


@router.get("", response_model=list[FaqDTO])
def list_faqs(db: Annotated[Session, Depends(get_db)]):
return db.scalars(
select(Faq).where(Faq.is_published.is_(True)).order_by(Faq.display_order, Faq.id)
).all()
20 changes: 20 additions & 0 deletions backend/app/routers/statistics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from typing import Annotated

from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.orm import Session

from app.database import get_db
from app.models.statistic import Statistic
from app.schemas.statistics import StatisticDTO

router = APIRouter(prefix="/statistics", tags=["statistics"])


@router.get("", response_model=list[StatisticDTO])
def list_statistics(db: Annotated[Session, Depends(get_db)]):
return db.scalars(
select(Statistic)
.where(Statistic.is_published.is_(True))
.order_by(Statistic.display_order, Statistic.id)
).all()
Loading
Loading