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
43 changes: 43 additions & 0 deletions alembic/versions/f9c2a14e7b80_create_course_filters_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""create course_filters table

Revision ID: f9c2a14e7b80
Revises: ebc91d7b5d43
Create Date: 2026-06-30 12:00:00.000000

Admin-verwaltete Filter-Strings für Kursnamen (Frontend-Chips). Die Filterung
selbst läuft client-seitig — diese Tabelle hält nur die Liste der Begriffe.
``name`` ist unique, damit doppelte Chips im UI vermieden werden.
"""
from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op


revision: str = 'f9c2a14e7b80'
down_revision: Union[str, Sequence[str], None] = 'ebc91d7b5d43'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Create course_filters table."""
op.create_table(
'course_filters',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column(
'name',
sa.String(length=255),
nullable=False,
comment='Anzeige-/Such-String, den das Frontend gegen Kursnamen matcht',
),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name', name='uq_course_filters_name'),
)


def downgrade() -> None:
"""Drop course_filters table."""
op.drop_table('course_filters')
2 changes: 2 additions & 0 deletions src/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from src.api.openstack_projects import router as openstack_projects_router
from src.api.template_version_files import router as template_version_files_router
from src.api.courses import router as courses_router
from src.api.course_filters import router as course_filters_router
from src.api.quotas import router as quotas_router
from src.api.openstack_flavors import router as openstack_flavors_router
from src.api.github_app import router as github_app_router
Expand All @@ -20,6 +21,7 @@
api_router.include_router(openstack_projects_router)
api_router.include_router(template_version_files_router)
api_router.include_router(courses_router)
api_router.include_router(course_filters_router)
api_router.include_router(quotas_router)
api_router.include_router(openstack_flavors_router)
api_router.include_router(github_app_router)
Expand Down
127 changes: 127 additions & 0 deletions src/api/course_filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""Course filter API endpoints.

Admin-verwaltete Filter-Strings (Frontend-Chips), gegen die Kursnamen client-
seitig gematcht werden. Lese-Zugriff ist für alle eingeloggten User offen,
damit das Frontend die Chip-Leiste rendern kann; Schreiben ist Admin-only.
"""
from typing import Optional
from uuid import UUID

from fastapi import APIRouter, Depends, Query, status

from src.core.dependencies import (
CurrentUser,
DBSession,
Pagination,
RequestID,
require_roles,
)
from src.core.response_builder import ResponseBuilder
from src.models.user import UserRole
from src.schemas.course_filter import (
CourseFilterCreate,
CourseFilterResponse,
CourseFilterUpdate,
)
from src.services.course_filter_service import CourseFilterService

router = APIRouter(prefix="/course-filters", tags=["course-filters"])


@router.get("", response_model=None)
async def list_course_filters(
pagination: Pagination,
db: DBSession,
request_id: RequestID,
current_user: CurrentUser,
search: Optional[str] = Query(None, description="Substring-Suche auf name"),
):
"""List course filters. Open to any authenticated user (frontend needs them)."""
service = CourseFilterService(db)
rows, total = service.list_filters(
skip=(pagination.page - 1) * pagination.page_size,
limit=pagination.page_size,
search=search,
)

payload = [
CourseFilterResponse.model_validate(row).model_dump(mode="json") for row in rows
]

return ResponseBuilder.paginated(
data=payload,
page=pagination.page,
page_size=pagination.page_size,
total=total,
message="Course filters retrieved successfully",
request_id=request_id,
)


@router.post(
"",
status_code=status.HTTP_201_CREATED,
response_model=None,
dependencies=[Depends(require_roles(UserRole.ADMIN))],
)
async def create_course_filter(
data: CourseFilterCreate,
db: DBSession,
request_id: RequestID,
current_user: CurrentUser,
):
"""Create a new course filter (admin only)."""
service = CourseFilterService(db)
instance = service.create_filter(data)

return ResponseBuilder.created(
data=CourseFilterResponse.model_validate(instance).model_dump(mode="json"),
message="Course filter created successfully",
request_id=request_id,
)


@router.patch(
"/{filter_id}",
response_model=None,
dependencies=[Depends(require_roles(UserRole.ADMIN))],
)
async def update_course_filter(
filter_id: UUID,
data: CourseFilterUpdate,
db: DBSession,
request_id: RequestID,
current_user: CurrentUser,
):
"""Rename a course filter (admin only)."""
service = CourseFilterService(db)
instance = service.update_filter(filter_id=filter_id, data=data)

return ResponseBuilder.success(
data=CourseFilterResponse.model_validate(instance).model_dump(mode="json"),
message="Course filter updated successfully",
request_id=request_id,
)


@router.delete(
"/{filter_id}",
status_code=status.HTTP_200_OK,
response_model=None,
dependencies=[Depends(require_roles(UserRole.ADMIN))],
)
async def delete_course_filter(
filter_id: UUID,
db: DBSession,
request_id: RequestID,
current_user: CurrentUser,
):
"""Delete a course filter (admin only)."""
service = CourseFilterService(db)
service.delete_filter(filter_id=filter_id)

return ResponseBuilder.success(
data=None,
message="Course filter deleted successfully",
request_id=request_id,
)
2 changes: 2 additions & 0 deletions src/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

# Import all model classes to make them available for SQLAlchemy relationship resolution
from src.models.course import Course
from src.models.course_filter import CourseFilter
from src.models.course_group import CourseGroup
from src.models.course_member import CourseMember
from src.models.deployment import Deployment, DeploymentStatus
Expand All @@ -19,6 +20,7 @@

__all__ = [
"Course",
"CourseFilter",
"CourseGroup",
"CourseMember",
"Deployment",
Expand Down
35 changes: 35 additions & 0 deletions src/models/course_filter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Course filter (frontend chip/blob) database model.

Admin-verwaltete Strings, mit denen das Frontend Kursnamen filtert. Die Filter
sind reine Such-Begriffe (z. B. „SQL", „Web 2026") — sie werden NICHT einem
Kurs zugewiesen, sondern client-seitig auf ``Course.name`` angewandt.
"""
from datetime import datetime, timezone
from uuid import uuid4

from sqlalchemy import String, DateTime
from sqlalchemy.orm import Mapped, mapped_column

from src.core.database import Base


class CourseFilter(Base):
"""Filter-Tag für Kursnamen (Frontend-Chips)."""

__tablename__ = "course_filters"

id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
name: Mapped[str] = mapped_column(
String(255),
nullable=False,
unique=True,
comment="Anzeige-/Such-String, den das Frontend gegen Kursnamen matcht",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
43 changes: 43 additions & 0 deletions src/repositories/course_filter_repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Course filter repository for database operations."""
from typing import Optional

from sqlalchemy.orm import Session

from src.models.course_filter import CourseFilter
from src.repositories.base_repository import BaseRepository


class CourseFilterRepository(BaseRepository[CourseFilter]):
"""Repository for CourseFilter database operations."""

def __init__(self, db: Session):
super().__init__(CourseFilter, db)

def get_by_name(self, name: str) -> Optional[CourseFilter]:
"""Fetch a filter by its exact (case-sensitive) name."""
return self.db.query(self.model).filter(self.model.name == name).first()

def get_all_filtered(
self,
skip: int = 0,
limit: int = 100,
search: Optional[str] = None,
) -> tuple[list[CourseFilter], int]:
"""List filters with optional substring search and pagination.

Returns:
Tuple of (rows, total count matching the search).
"""
query = self.db.query(self.model)
if search:
query = query.filter(self.model.name.ilike(f"%{search}%"))

total = query.count()
rows = (
query
.order_by(self.model.name.asc())
.offset(skip)
.limit(limit)
.all()
)
return rows, total
60 changes: 60 additions & 0 deletions src/schemas/course_filter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Course filter schemas for request/response validation."""
from datetime import datetime
from typing import Optional

from pydantic import BaseModel, ConfigDict, Field, field_validator


class CourseFilterCreate(BaseModel):
"""Schema for creating a course filter."""

name: str = Field(..., description="Filter-String (z. B. „SQL“)", min_length=1, max_length=255)

@field_validator("name")
@classmethod
def _strip(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("name must not be blank")
return v

model_config = ConfigDict(json_schema_extra={"example": {"name": "SQL"}})


class CourseFilterUpdate(BaseModel):
"""Schema for renaming a course filter."""

name: Optional[str] = Field(None, description="Neuer Filter-String", min_length=1, max_length=255)

@field_validator("name")
@classmethod
def _strip(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return v
v = v.strip()
if not v:
raise ValueError("name must not be blank")
return v

model_config = ConfigDict(json_schema_extra={"example": {"name": "SQL Grundlagen"}})


class CourseFilterResponse(BaseModel):
"""Schema for course filter response."""

id: str = Field(..., description="Filter-ID")
name: str = Field(..., description="Filter-String")
created_at: datetime = Field(..., description="Creation timestamp")
updated_at: datetime = Field(..., description="Last update timestamp")

model_config = ConfigDict(
from_attributes=True,
json_schema_extra={
"example": {
"id": "filter-123",
"name": "SQL",
"created_at": "2026-06-30T10:00:00Z",
"updated_at": "2026-06-30T10:00:00Z",
}
},
)
Loading
Loading