diff --git a/alembic/versions/c8a3f1e9b7d5_create_template_icons_table.py b/alembic/versions/c8a3f1e9b7d5_create_template_icons_table.py
new file mode 100644
index 0000000..77af027
--- /dev/null
+++ b/alembic/versions/c8a3f1e9b7d5_create_template_icons_table.py
@@ -0,0 +1,93 @@
+"""create template_icons table + drop templates.icon_url
+
+Revision ID: c8a3f1e9b7d5
+Revises: e2a91d05c7b8
+Create Date: 2026-07-01 09:00:00.000000
+
+Zwei Änderungen in einer Migration, weil sie inhaltlich zusammengehören
+und dieser Feature-Branch noch nicht deployt ist (kein Bestand zu retten):
+
+1. Neue Tabelle ``template_icons`` — hält hochgeladene Icon-Bilder als
+ BYTEA. ``template_id`` unique (1:1) und ``ON DELETE CASCADE``, damit
+ die Row automatisch mitgeht, wenn das Template gelöscht wird.
+
+2. Alte Spalte ``templates.icon_url`` fliegt raus. Icons kommen ab jetzt
+ ausschließlich als Upload; ``mdi:*``-Strings/URLs werden nicht mehr
+ unterstützt. Frontend zeigt für Templates ohne hochgeladenes Bild
+ einen Placeholder.
+"""
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+
+revision: str = 'c8a3f1e9b7d5'
+down_revision: Union[str, Sequence[str], None] = 'e2a91d05c7b8'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ """Create template_icons table and drop templates.icon_url."""
+ op.create_table(
+ 'template_icons',
+ sa.Column('id', sa.String(length=36), nullable=False),
+ sa.Column(
+ 'template_id',
+ sa.String(length=36),
+ nullable=False,
+ comment='Owning template — 1:1, jedes Template hat höchstens ein Icon.',
+ ),
+ sa.Column(
+ 'content',
+ sa.LargeBinary(),
+ nullable=False,
+ comment='Rohbytes des Bildes (PNG/JPEG/WebP).',
+ ),
+ sa.Column(
+ 'content_type',
+ sa.String(length=64),
+ nullable=False,
+ comment='MIME-Typ, wird beim Ausliefern als Content-Type-Header verwendet.',
+ ),
+ sa.Column(
+ 'file_name',
+ sa.String(length=255),
+ nullable=True,
+ comment='Original-Dateiname (für Content-Disposition).',
+ ),
+ sa.Column(
+ 'size_bytes',
+ sa.Integer(),
+ nullable=False,
+ comment='Größe von content in Bytes.',
+ ),
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
+ sa.ForeignKeyConstraint(
+ ['template_id'],
+ ['templates.id'],
+ ondelete='CASCADE',
+ ),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('template_id', name='uq_template_icons_template_id'),
+ )
+
+ # icon_url wird durch hochgeladene Icons ersetzt. Kein Bestand zu retten
+ # (dieser Branch ist noch nicht deployed), also einfach droppen.
+ op.drop_column('templates', 'icon_url')
+
+
+def downgrade() -> None:
+ """Restore templates.icon_url and drop template_icons."""
+ op.add_column(
+ 'templates',
+ sa.Column(
+ 'icon_url',
+ sa.String(length=500),
+ nullable=True,
+ comment='Icon URL or identifier (e.g., mdi:server, /icons/template.svg, 🚀)',
+ ),
+ )
+ op.drop_table('template_icons')
diff --git a/bruno/Templates/Create Template.bru b/bruno/Templates/Create Template.bru
index bb2d616..7d2a645 100644
--- a/bruno/Templates/Create Template.bru
+++ b/bruno/Templates/Create Template.bru
@@ -18,8 +18,7 @@ body:json {
{
"name": "Python Flask Template",
"description": "A template for Flask web applications",
- "repo_url": "https://github.com/example/flask-template",
- "icon_url": "mdi:flask"
+ "repo_url": "https://github.com/example/flask-template"
}
}
@@ -46,9 +45,12 @@ docs {
- `name` (required, ≤ 255)
- `description` (optional)
- `repo_url` (required, ≤ 500)
- - `icon_url` (optional, ≤ 500) — e.g. `mdi:flask`, `🚀`, `/icons/template.svg`
- `visibility` (ignored on create; backend pins to `private`)
+ Icons: das Feld `icon_url` gibt es nicht mehr. Nach dem Anlegen kann
+ optional ein Icon-Bild via `POST /templates/{id}/icon` hochgeladen werden
+ (multipart, PNG/JPEG/WebP, max 5 MB).
+
## Returns
Created Template with `visibility='private'`. The post-response script saves
diff --git a/bruno/Templates/Import Template From GitHub.bru b/bruno/Templates/Import Template From GitHub.bru
index b375a6f..828282b 100644
--- a/bruno/Templates/Import Template From GitHub.bru
+++ b/bruno/Templates/Import Template From GitHub.bru
@@ -18,7 +18,6 @@ body:json {
{
"name": "Postgres Group DB",
"description": "Provision a Postgres VM via the appstore",
- "icon_url": "mdi:database",
"github_url": "https://github.com/dozilab/templates",
"app_yaml_path": "postgres/app.yaml"
}
@@ -51,11 +50,13 @@ docs {
- `name` (required, ≤ 255)
- `description` (optional)
- - `icon_url` (optional, ≤ 500)
- `github_url` (required, ≤ 1000)
- `app_yaml_path` (optional) — defaults to `app.yaml` (root) if not given
and the URL is the repo/branch root.
+ Icons: das Feld `icon_url` gibt es nicht mehr. Nach dem Import kann
+ optional ein Icon-Bild via `POST /templates/{id}/icon` hochgeladen werden.
+
## Permissions
ADMIN or LECTURER. Template is always created `visibility=private`. Admins
diff --git a/pyproject.toml b/pyproject.toml
index b96b2e2..cf7e1fc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,6 +15,7 @@ dependencies = [
"psycopg2-binary>=2.9.0",
"alembic>=1.13.0",
"python-jose[cryptography]>=3.3.0",
+ "python-multipart>=0.0.9",
"httpx>=0.25.0",
"openstacksdk>=3.3.0",
"python-heatclient>=3.5.0",
diff --git a/src/api/templates.py b/src/api/templates.py
index bdb1764..94cbc73 100644
--- a/src/api/templates.py
+++ b/src/api/templates.py
@@ -2,7 +2,8 @@
from typing import Optional
from uuid import UUID
-from fastapi import APIRouter, status, Query, Depends
+from fastapi import APIRouter, Depends, File, Query, UploadFile, status
+from fastapi.responses import Response
from src.core.response_builder import ResponseBuilder
from src.core.dependencies import DBSession, RequestID, Pagination, require_roles, CurrentUser
@@ -15,6 +16,7 @@
)
from src.schemas.template_version import TemplateVersionResponse
from src.services.template_service import TemplateService
+from src.services.template_icon_service import TemplateIconService
from src.services.github_import_service import GithubImportService
from src.models.user import UserRole
from src.models.template import TemplateVisibility
@@ -233,7 +235,6 @@ async def import_template_from_github(
app_yaml_path=payload.app_yaml_path,
name=payload.name,
description=payload.description,
- icon_url=payload.icon_url,
owner_user_id=current_user["user_id"],
owner_user_roles=current_user.get("roles", []),
# The pydantic validator normalises this to "private"/"public" (or
@@ -286,3 +287,126 @@ async def import_new_version_from_github(
message="Template version imported from GitHub successfully",
request_id=request_id,
)
+
+
+# ---------------------------------------------------------------------------
+# Icon-Upload
+# ---------------------------------------------------------------------------
+#
+# Der Upload lebt bewusst auf einem eigenen Endpoint statt am POST/PATCH
+# /templates, damit die JSON-API rein-JSON bleibt und Clients ohne
+# Anpassung weiterlaufen. Die drei Endpoints (POST/GET/DELETE) sind
+# symmetrisch und respektieren die Standard-Sichtbarkeitsregeln:
+# Admin darf alles, Owner darf sein eigenes, Fremde nur PUBLIC-Templates
+# mit mindestens einer APPROVED Version (letzteres nur für den Serve-
+# Endpoint — Upload/Delete sind owner-or-admin-only).
+
+
+@router.post(
+ "/{template_id}/icon",
+ status_code=status.HTTP_201_CREATED,
+ response_model=None,
+)
+async def upload_template_icon(
+ template_id: UUID,
+ db: DBSession,
+ request_id: RequestID,
+ current_user: CurrentUser,
+ file: UploadFile = File(..., description="Icon image (PNG, JPEG or WebP, max 5 MB)"),
+):
+ """Upload (or replace) the icon image for a template.
+
+ Owner-or-admin-only. Erlaubte Formate: ``image/png``, ``image/jpeg``,
+ ``image/webp``; maximale Größe: 5 MB (konfigurierbar via
+ ``settings.max_icon_size_bytes``).
+
+ Der Endpoint speichert die Bytes in der Tabelle ``template_icons`` und
+ setzt in der Template-Response ``icon_path`` auf
+ ``/api/v1/templates/{id}/icon``. Templates ohne hochgeladenes Bild
+ haben ``icon_path = null`` — das Frontend zeigt dann einen
+ Placeholder.
+ """
+ is_admin = UserRole.ADMIN.value in current_user.get("roles", [])
+ content = await file.read()
+ service = TemplateIconService(db)
+ icon = service.upload_icon(
+ template_id=str(template_id),
+ content=content,
+ content_type=file.content_type or "application/octet-stream",
+ file_name=file.filename,
+ user_id=current_user["user_id"],
+ is_admin=is_admin,
+ )
+ return ResponseBuilder.created(
+ data={
+ "id": icon.id,
+ "template_id": icon.template_id,
+ "content_type": icon.content_type,
+ "file_name": icon.file_name,
+ "size_bytes": icon.size_bytes,
+ "icon_path": f"/api/v1/templates/{icon.template_id}/icon",
+ },
+ message="Template icon uploaded successfully",
+ request_id=request_id,
+ )
+
+
+@router.get("/{template_id}/icon")
+async def get_template_icon(
+ template_id: UUID,
+ db: DBSession,
+ current_user: CurrentUser,
+):
+ """Return the raw icon bytes for a template.
+
+ Same visibility rules as GET /templates/{id}: admin sees everything,
+ owner sees their own, others only PUBLIC templates with at least one
+ APPROVED version. Sends the stored MIME type as ``Content-Type`` and
+ an ETag derived from the icon row ID for browser caching.
+ """
+ is_admin = UserRole.ADMIN.value in current_user.get("roles", [])
+ service = TemplateIconService(db)
+ icon = service.get_icon(
+ template_id=str(template_id),
+ user_id=current_user["user_id"],
+ is_admin=is_admin,
+ )
+ headers = {
+ # 5 Minuten private cache reichen — das Icon ändert sich selten,
+ # aber ein PATCH sollte binnen kurzer Zeit sichtbar sein.
+ "Cache-Control": "private, max-age=300",
+ "ETag": f'"{icon.id}"',
+ }
+ if icon.file_name:
+ headers["Content-Disposition"] = f'inline; filename="{icon.file_name}"'
+ return Response(
+ content=icon.content,
+ media_type=icon.content_type,
+ headers=headers,
+ )
+
+
+@router.delete(
+ "/{template_id}/icon",
+ status_code=status.HTTP_204_NO_CONTENT,
+)
+async def delete_template_icon(
+ template_id: UUID,
+ db: DBSession,
+ current_user: CurrentUser,
+):
+ """Remove the uploaded icon for a template.
+
+ Owner-or-admin-only. Idempotent: wenn kein Icon existiert, ist die
+ Antwort trotzdem 204 (Client muss nicht wissen, ob vorher eins da war).
+ Danach fällt ``icon_path`` auf ``null`` zurück — Frontend rendert
+ einen Placeholder.
+ """
+ is_admin = UserRole.ADMIN.value in current_user.get("roles", [])
+ service = TemplateIconService(db)
+ service.delete_icon(
+ template_id=str(template_id),
+ user_id=current_user["user_id"],
+ is_admin=is_admin,
+ )
+ return None
diff --git a/src/core/config.py b/src/core/config.py
index 12943f0..705387d 100644
--- a/src/core/config.py
+++ b/src/core/config.py
@@ -59,6 +59,16 @@ def ansible_ssh_private_key(self) -> str | None:
github_app_state_secret: str | None = None
frontend_base_url: str = "http://localhost:5173"
+ # Template icon uploads. Grenzwerte werden im Service gegen die
+ # hochgeladene Datei geprüft — 5 MB und PNG/JPEG/WebP sind das
+ # abgestimmte Default.
+ max_icon_size_bytes: int = 5 * 1024 * 1024
+ allowed_icon_content_types: tuple[str, ...] = (
+ "image/png",
+ "image/jpeg",
+ "image/webp",
+ )
+
@property
def database_url(self) -> str:
diff --git a/src/core/seed_data.py b/src/core/seed_data.py
index 39ecb73..94af99f 100644
--- a/src/core/seed_data.py
+++ b/src/core/seed_data.py
@@ -2032,7 +2032,6 @@ def load_servers_for_user(email: str, servers: dict) -> None:
"Ubuntu VM mit mehreren Benutzerkonten, verwaltet durch Ansible. "
"Pro Gruppe wird ein Linux-Account mit eigenem Arbeitsverzeichnis erstellt."
),
- "icon_url": "mdi:server-network",
"version": "2.1.0",
"files": [
{"name": "app.yaml", "type": FileType.APP_MANIFEST, "path": "app.yaml", "content": MULTIUSER_APP_YAML, "primary": False},
@@ -2051,7 +2050,6 @@ def load_servers_for_user(email: str, servers: dict) -> None:
"eine eigene Datenbank und einen eigenen DB-Rollen-Account. Der Dozent "
"hat lesenden/schreibenden Zugriff auf alle Gruppen-DBs."
),
- "icon_url": "mdi:database",
"version": "2.0.0",
"files": [
{"name": "app.yaml", "type": FileType.APP_MANIFEST, "path": "app.yaml", "content": POSTGRES_APP_YAML, "primary": False},
@@ -2127,7 +2125,6 @@ def _seed_one_app(db: Session, owner_id: str, app: dict) -> None:
name=app["name"],
description=app["description"],
repo_url="https://github.com/dozilab/appstore-templates",
- icon_url=app["icon_url"],
visibility=TemplateVisibility.PUBLIC,
)
db.add(template)
diff --git a/src/models/__init__.py b/src/models/__init__.py
index 1ab935e..46486f5 100644
--- a/src/models/__init__.py
+++ b/src/models/__init__.py
@@ -14,6 +14,7 @@
from src.models.template import Template
from src.models.template_category import TemplateCategory
from src.models.template_category_assignment import TemplateCategoryAssignment
+from src.models.template_icon import TemplateIcon
from src.models.template_version import TemplateVersion
from src.models.template_version_file import TemplateVersionFile
from src.models.user import User
@@ -34,6 +35,7 @@
"Template",
"TemplateCategory",
"TemplateCategoryAssignment",
+ "TemplateIcon",
"TemplateVersion",
"TemplateVersionFile",
"User",
diff --git a/src/models/template.py b/src/models/template.py
index d676a6d..8251bb3 100644
--- a/src/models/template.py
+++ b/src/models/template.py
@@ -25,11 +25,6 @@ class Template(Base):
description: Mapped[str | None] = mapped_column(Text, nullable=True)
owner_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
repo_url: Mapped[str] = mapped_column(String(500), nullable=False)
- icon_url: Mapped[str | None] = mapped_column(
- String(500),
- nullable=True,
- comment="Icon URL or identifier (e.g., mdi:server, /icons/template.svg, 🚀)"
- )
visibility: Mapped[TemplateVisibility] = mapped_column(
SQLEnum(TemplateVisibility),
default=TemplateVisibility.PRIVATE
@@ -60,4 +55,18 @@ class Template(Base):
)
category_assignments: Mapped[list["TemplateCategoryAssignment"]] = relationship("TemplateCategoryAssignment", back_populates="template")
+ # Hochgeladenes Icon-Bild (optional). Getrennte Tabelle statt Spalte am
+ # Template, damit ``SELECT * FROM templates`` keinen 1-5 MB BLOB pro Row
+ # mitlädt. ``uselist=False`` weil per Unique-Constraint auf
+ # ``template_icons.template_id`` maximal ein Icon pro Template existiert.
+ # Die ``content``-Spalte auf ``TemplateIcon`` ist ``deferred``, wird also
+ # nur beim Serve-Endpoint tatsächlich aus der DB gezogen.
+ icon: Mapped["TemplateIcon | None"] = relationship(
+ "TemplateIcon",
+ back_populates="template",
+ cascade="all, delete-orphan",
+ passive_deletes=True,
+ uselist=False,
+ )
+
diff --git a/src/models/template_icon.py b/src/models/template_icon.py
new file mode 100644
index 0000000..fdbbb5e
--- /dev/null
+++ b/src/models/template_icon.py
@@ -0,0 +1,71 @@
+"""Template Icon database model.
+
+Speichert hochgeladene Icon-Bilder als Binärdaten in einer eigenen Tabelle,
+damit große BLOBs nicht in jedem ``SELECT * FROM templates`` mitgeschleppt
+werden. Ein Template hat maximal ein Icon (1:0..1 Beziehung, via unique FK
+auf ``templates.icon_file_id``); Cascade-Delete räumt die Row auf, wenn das
+Template selbst gelöscht wird.
+
+Zulässige Bildformate und die Größenobergrenze werden im Service-Layer
+validiert (siehe ``template_icon_service.py``), nicht in der DB.
+"""
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+from uuid import uuid4
+
+from sqlalchemy import DateTime, ForeignKey, Integer, LargeBinary, String
+from sqlalchemy.orm import Mapped, deferred, mapped_column, relationship
+
+from src.core.database import Base
+
+if TYPE_CHECKING:
+ from src.models.template import Template
+
+
+class TemplateIcon(Base):
+ """Persistiertes Icon-Bild für ein Template."""
+
+ __tablename__ = "template_icons"
+
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
+ template_id: Mapped[str] = mapped_column(
+ String(36),
+ ForeignKey("templates.id", ondelete="CASCADE"),
+ nullable=False,
+ unique=True,
+ comment="Owning template — 1:1, jedes Template hat höchstens ein Icon.",
+ )
+ content: Mapped[bytes] = deferred(
+ mapped_column(
+ LargeBinary,
+ nullable=False,
+ comment="Rohbytes des Bildes (PNG/JPEG/WebP).",
+ )
+ )
+ content_type: Mapped[str] = mapped_column(
+ String(64),
+ nullable=False,
+ comment="MIME-Typ, wird beim Ausliefern als Content-Type-Header verwendet.",
+ )
+ file_name: Mapped[str | None] = mapped_column(
+ String(255),
+ nullable=True,
+ comment="Original-Dateiname (für Content-Disposition).",
+ )
+ size_bytes: Mapped[int] = mapped_column(
+ Integer,
+ nullable=False,
+ comment="Größe von ``content`` in Bytes — redundant, aber praktisch für Listing/Debug.",
+ )
+ 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),
+ )
+
+ # Relationships
+ template: Mapped["Template"] = relationship("Template", back_populates="icon")
diff --git a/src/repositories/template_icon_repository.py b/src/repositories/template_icon_repository.py
new file mode 100644
index 0000000..458a71a
--- /dev/null
+++ b/src/repositories/template_icon_repository.py
@@ -0,0 +1,35 @@
+"""TemplateIcon repository for database operations."""
+from typing import Optional
+
+from sqlalchemy.orm import Session
+
+from src.models.template_icon import TemplateIcon
+from src.repositories.base_repository import BaseRepository
+
+
+class TemplateIconRepository(BaseRepository[TemplateIcon]):
+ """Repository for TemplateIcon database operations."""
+
+ def __init__(self, db: Session):
+ """Initialize TemplateIconRepository with database session."""
+ super().__init__(TemplateIcon, db)
+
+ def get_by_template_id(self, template_id: str) -> Optional[TemplateIcon]:
+ """Fetch the icon row for a given template, if any."""
+ return (
+ self.db.query(self.model)
+ .filter(self.model.template_id == str(template_id))
+ .first()
+ )
+
+ def delete_by_template_id(self, template_id: str) -> bool:
+ """Remove the icon row for a given template.
+
+ Returns True if a row was deleted, False if nothing existed.
+ """
+ icon = self.get_by_template_id(template_id)
+ if not icon:
+ return False
+ self.db.delete(icon)
+ self.db.commit()
+ return True
diff --git a/src/schemas/template.py b/src/schemas/template.py
index 3696602..896ec0c 100644
--- a/src/schemas/template.py
+++ b/src/schemas/template.py
@@ -13,11 +13,15 @@
class TemplateCreate(BaseModel):
- """Schema for creating a template."""
+ """Schema for creating a template.
+
+ Icons werden nicht mehr im Metadata-Body übergeben — der Client legt
+ das Template zunächst ohne Icon an und lädt anschließend optional ein
+ Bild via ``POST /templates/{id}/icon`` hoch.
+ """
name: str = Field(..., description="Name of the template", max_length=255)
description: Optional[str] = Field(None, description="Template description")
repo_url: str = Field(..., description="Git repository URL", max_length=500)
- icon_url: Optional[str] = Field(None, description="Icon URL or identifier (mdi:server, fa:server, 🚀, /icons/template.svg)", max_length=500)
visibility: str = Field(default="private", description="Template visibility (private/public)")
model_config = ConfigDict(
@@ -26,7 +30,6 @@ class TemplateCreate(BaseModel):
"name": "Python Flask Template",
"description": "A template for Flask web applications",
"repo_url": "https://github.com/example/flask-template",
- "icon_url": "mdi:flask",
"visibility": "public"
}
}
@@ -34,11 +37,14 @@ class TemplateCreate(BaseModel):
class TemplateUpdate(BaseModel):
- """Schema for updating a template."""
+ """Schema for updating a template.
+
+ Wie ``TemplateCreate`` — kein Icon-Feld mehr. Bild-Änderungen laufen
+ über den dedizierten Upload-Endpoint.
+ """
name: Optional[str] = Field(None, description="Name of the template", max_length=255)
description: Optional[str] = Field(None, description="Template description")
repo_url: Optional[str] = Field(None, description="Git repository URL", max_length=500)
- icon_url: Optional[str] = Field(None, description="Icon URL or identifier (mdi:server, fa:server, 🚀, /icons/template.svg)", max_length=500)
visibility: Optional[str] = Field(None, description="Template visibility (private/public) - Only admins can change this")
model_config = ConfigDict(
@@ -46,7 +52,6 @@ class TemplateUpdate(BaseModel):
"example": {
"name": "Updated Template Name",
"description": "Updated description",
- "icon_url": "mdi:server"
}
}
)
@@ -59,7 +64,6 @@ class TemplateResponse(BaseModel):
description: Optional[str] = Field(None, description="Template description")
owner_id: str = Field(..., description="Owner user ID")
repo_url: str = Field(..., description="Git repository URL")
- icon_url: Optional[str] = Field(None, description="Icon URL or identifier")
visibility: str = Field(..., description="Template visibility")
publish_requested: bool = Field(
default=False,
@@ -81,6 +85,11 @@ class TemplateResponse(BaseModel):
# `owner_username` are exposed to clients.
owner: Any = Field(default=None, exclude=True, repr=False)
+ # Internes Feld für die ``icon_path``-Berechnung. Wird von SQLAlchemy
+ # via ``from_attributes=True`` gefüllt, aus der Response aber
+ # ausgeblendet — Clients bekommen nur ``icon_path``.
+ icon: Any = Field(default=None, exclude=True, repr=False)
+
@computed_field # type: ignore[prop-decorator]
@property
def owner_name(self) -> Optional[str]:
@@ -105,6 +114,23 @@ def owner_username(self) -> Optional[str]:
"""Cached preferred_username of the owner; ``None`` for legacy users."""
return getattr(self.owner, "username", None) if self.owner else None
+ @computed_field # type: ignore[prop-decorator]
+ @property
+ def icon_path(self) -> Optional[str]:
+ """Relativer API-Pfad zum Icon-Bild, oder ``None``.
+
+ Wenn ein Icon-Bild via ``POST /templates/{id}/icon`` hochgeladen
+ wurde → ``/api/v1/templates/{id}/icon``. Sonst ``None`` — der
+ Client rendert dann einen Default-Placeholder.
+
+ Bewusst *path*, nicht *url*: der Wert enthält keinen Origin und
+ muss vom Client gegen die API-Base-URL aufgelöst werden (dieselbe
+ Base-URL, gegen die auch alle anderen ``/api/v1/*``-Calls laufen).
+ """
+ if self.icon is not None:
+ return f"/api/v1/templates/{self.id}/icon"
+ return None
+
model_config = ConfigDict(
from_attributes=True,
json_schema_extra={
@@ -117,8 +143,8 @@ def owner_username(self) -> Optional[str]:
"owner_email": "berg@dhbw.de",
"owner_username": "bberg",
"repo_url": "https://github.com/example/flask-template",
- "icon_url": "mdi:flask",
"visibility": "public",
+ "icon_path": None,
"versions": [],
"created_at": "2024-11-27T10:00:00Z",
"updated_at": "2024-11-27T10:00:00Z"
@@ -134,10 +160,12 @@ class GithubImportNewTemplate(BaseModel):
no approval flow). Pass ``visibility="public"`` to make it marketplace-
visible — the first version then enters the standard approval flow
(``pending`` unless the caller is an admin).
+
+ Icons werden nach dem Import optional via ``POST /templates/{id}/icon``
+ hochgeladen — kein Icon-Feld mehr auf dem Import-Body.
"""
name: str = Field(..., max_length=255)
description: Optional[str] = None
- icon_url: Optional[str] = Field(None, max_length=500)
github_url: str = Field(..., description=GITHUB_URL_DESCRIPTION, max_length=1000)
app_yaml_path: Optional[str] = Field(
default=None,
@@ -201,4 +229,3 @@ class GithubImportNewVersion(BaseModel):
}
}
)
-
diff --git a/src/services/github_import_service.py b/src/services/github_import_service.py
index fba7094..cfe4fb5 100644
--- a/src/services/github_import_service.py
+++ b/src/services/github_import_service.py
@@ -334,7 +334,6 @@ def import_to_new_template(
app_yaml_path: Optional[str],
name: str,
description: Optional[str],
- icon_url: Optional[str],
owner_user_id: str,
owner_user_roles: list[str],
visibility: TemplateVisibility = TemplateVisibility.PRIVATE,
@@ -368,7 +367,6 @@ def import_to_new_template(
description=description,
owner_id=owner_user_id,
repo_url=github_url,
- icon_url=icon_url,
visibility=effective_visibility,
publish_requested=wants_public,
)
diff --git a/src/services/template_icon_service.py b/src/services/template_icon_service.py
new file mode 100644
index 0000000..2ad99db
--- /dev/null
+++ b/src/services/template_icon_service.py
@@ -0,0 +1,194 @@
+"""Template icon service.
+
+Kapselt Upload, Auslieferung und Löschen des hochgeladenen Icon-Bilds eines
+Templates. Der Endpoint-Layer prüft Rollen und Ownership; der Service prüft
+Bild-Format und -Größe und delegiert die eigentliche Datenbank-Interaktion
+ans Repository.
+"""
+import logging
+from typing import Optional
+
+from sqlalchemy.orm import Session
+
+from src.core.config import get_settings
+from src.core.exceptions import BadRequestException, ForbiddenException
+from src.models.template import Template
+from src.models.template_icon import TemplateIcon
+from src.repositories.template_icon_repository import TemplateIconRepository
+from src.services.template_service import TemplateService
+
+logger = logging.getLogger(__name__)
+
+
+class TemplateIconService:
+ """Service für den Upload/Serve/Delete-Lebenszyklus eines Template-Icons."""
+
+ def __init__(self, db: Session):
+ self.db = db
+ self.repo = TemplateIconRepository(db)
+ self.template_service = TemplateService(db)
+
+ # ------------------------------------------------------------------
+ # Read
+ # ------------------------------------------------------------------
+ def get_icon(
+ self,
+ template_id: str,
+ *,
+ user_id: str,
+ is_admin: bool = False,
+ ) -> TemplateIcon:
+ """Return the icon row for a template, gated by visibility.
+
+ Sichtbarkeitsregeln matchen ``TemplateService.get_template``:
+ Admin darf alles, Owner darf sein eigenes, Fremde nur PUBLIC-Templates
+ mit mindestens einer APPROVED Version. Wenn das Template zwar
+ sichtbar ist, aber kein Icon hochgeladen wurde, wird 404 geworfen.
+ """
+ # ``get_template`` wirft NotFound/Forbidden nach denselben Regeln,
+ # die auch beim normalen Template-GET greifen.
+ self.template_service.get_template(template_id, user_id=user_id, is_admin=is_admin)
+
+ icon = self.repo.get_by_template_id(template_id)
+ if not icon:
+ # Bewusst 404, nicht 204: der Client bekommt sonst einen
+ # Content-Type: application/json ohne Body und rätselt.
+ from src.core.exceptions import NotFoundException
+
+ raise NotFoundException(f"Template {template_id} has no uploaded icon")
+ return icon
+
+ # ------------------------------------------------------------------
+ # Write
+ # ------------------------------------------------------------------
+ def upload_icon(
+ self,
+ template_id: str,
+ *,
+ content: bytes,
+ content_type: str,
+ file_name: Optional[str],
+ user_id: str,
+ is_admin: bool = False,
+ ) -> TemplateIcon:
+ """Persist a new icon for a template (create or replace).
+
+ Nur Owner oder Admin dürfen ein Icon setzen. Validierung:
+ - Content-Type muss in ``settings.allowed_icon_content_types`` sein
+ (Default: PNG/JPEG/WebP) → sonst 415.
+ - ``content`` darf ``settings.max_icon_size_bytes`` nicht überschreiten
+ → sonst 413.
+ - Leere Uploads werden abgelehnt (400).
+ """
+ template = self._require_owner_or_admin(template_id, user_id=user_id, is_admin=is_admin)
+ settings = get_settings()
+
+ # 1) Content-Type-Prüfung — via 415 statt 400, damit Clients gezielt
+ # reagieren können ("bitte anderes Format wählen").
+ normalized = (content_type or "").split(";", 1)[0].strip().lower()
+ if normalized not in settings.allowed_icon_content_types:
+ from starlette.exceptions import HTTPException
+
+ raise HTTPException(
+ status_code=415,
+ detail=(
+ f"Unsupported icon content type: {content_type!r}. "
+ f"Allowed: {', '.join(settings.allowed_icon_content_types)}"
+ ),
+ )
+
+ # 2) Größe.
+ size = len(content)
+ if size == 0:
+ raise BadRequestException("Uploaded icon file is empty")
+ if size > settings.max_icon_size_bytes:
+ from starlette.exceptions import HTTPException
+
+ raise HTTPException(
+ status_code=413,
+ detail=(
+ f"Icon file too large: {size} bytes "
+ f"(max {settings.max_icon_size_bytes} bytes)"
+ ),
+ )
+
+ # 3) Persistieren — create-or-replace. Wir modifizieren die bestehende
+ # Row statt sie zu löschen+neu-anzulegen, damit ``id`` und
+ # ``created_at`` stabil bleiben (Cache-Buster im Frontend nutzt
+ # ``updated_at``).
+ existing = self.repo.get_by_template_id(template_id)
+ if existing:
+ existing.content = content
+ existing.content_type = normalized
+ existing.file_name = file_name
+ existing.size_bytes = size
+ self.db.commit()
+ self.db.refresh(existing)
+ icon = existing
+ action = "replaced"
+ else:
+ icon = self.repo.create(
+ template_id=template.id,
+ content=content,
+ content_type=normalized,
+ file_name=file_name,
+ size_bytes=size,
+ )
+ action = "created"
+
+ logger.info(
+ "Template icon %s",
+ action,
+ extra={
+ "template_id": template_id,
+ "user_id": user_id,
+ "icon_id": icon.id,
+ "size_bytes": size,
+ "content_type": normalized,
+ },
+ )
+ return icon
+
+ def delete_icon(
+ self,
+ template_id: str,
+ *,
+ user_id: str,
+ is_admin: bool = False,
+ ) -> bool:
+ """Remove the uploaded icon for a template.
+
+ Returns True if something was deleted, False if the template
+ already had no icon. In both cases the endpoint returns 204;
+ the boolean is exposed for tests.
+ """
+ self._require_owner_or_admin(template_id, user_id=user_id, is_admin=is_admin)
+ deleted = self.repo.delete_by_template_id(template_id)
+ if deleted:
+ logger.info(
+ "Template icon deleted",
+ extra={"template_id": template_id, "user_id": user_id},
+ )
+ return deleted
+
+ # ------------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------------
+ def _require_owner_or_admin(
+ self,
+ template_id: str,
+ *,
+ user_id: str,
+ is_admin: bool,
+ ) -> Template:
+ """Load template + enforce owner-or-admin gate for mutating ops."""
+ template = self.template_service.get_template(
+ template_id,
+ user_id=user_id,
+ is_admin=is_admin,
+ )
+ if template.owner_id != user_id and not is_admin:
+ raise ForbiddenException(
+ "You do not have permission to manage the icon of this template"
+ )
+ return template
diff --git a/src/services/template_service.py b/src/services/template_service.py
index c0086a7..e2da30d 100644
--- a/src/services/template_service.py
+++ b/src/services/template_service.py
@@ -88,7 +88,6 @@ def create_template(
name=template_data.name,
description=template_data.description,
repo_url=template_data.repo_url,
- icon_url=template_data.icon_url,
visibility=TemplateVisibility.PRIVATE,
owner_id=owner_id,
)
diff --git a/tests/api/test_template_icon_routes.py b/tests/api/test_template_icon_routes.py
new file mode 100644
index 0000000..4689fdf
--- /dev/null
+++ b/tests/api/test_template_icon_routes.py
@@ -0,0 +1,331 @@
+"""API-Tests für die Template-Icon-Endpoints.
+
+Deckt POST/GET/DELETE ab, inkl. Content-Type-Whitelist, Größenlimit,
+Owner/Admin-Gate, sowie das Zusammenspiel mit der TemplateResponse
+(``icon_path`` zeigt nach dem Upload auf den Serve-Endpoint, sonst null).
+"""
+import pytest
+from fastapi import status
+from fastapi.testclient import TestClient
+from sqlalchemy import create_engine, event
+from sqlalchemy.orm import sessionmaker
+from sqlalchemy.pool import StaticPool
+
+from src.core.database import Base
+from src.core.dependencies import get_current_user, get_db
+from src.main import app
+from src.models.template import Template, TemplateVisibility
+from src.models.user import User
+
+
+SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
+engine = create_engine(
+ SQLALCHEMY_DATABASE_URL,
+ connect_args={"check_same_thread": False},
+ poolclass=StaticPool,
+)
+
+
+@event.listens_for(engine, "connect")
+def _sqlite_enable_fks(dbapi_connection, _conn_record):
+ cursor = dbapi_connection.cursor()
+ cursor.execute("PRAGMA foreign_keys=ON")
+ cursor.close()
+
+
+TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+
+
+@pytest.fixture(scope="function")
+def db_session():
+ import src.models.deployment # noqa
+ import src.models.deployment_instance # noqa
+ import src.models.deployment_instance_access # noqa
+ import src.models.deployment_log # noqa
+ import src.models.template_category # noqa
+ import src.models.template_category_assignment # noqa
+ import src.models.template_icon # noqa
+ import src.models.template_version # noqa
+ import src.models.course # noqa
+ import src.models.course_member # noqa
+ import src.models.course_group # noqa
+ import src.models.group_member # noqa
+ import src.models.openstack_project # noqa
+
+ Base.metadata.create_all(bind=engine)
+ session = TestingSessionLocal()
+ try:
+ yield session
+ finally:
+ session.close()
+ Base.metadata.drop_all(bind=engine)
+
+
+@pytest.fixture
+def owner(db_session):
+ """Der User, dem das Sample-Template gehört."""
+ user = User(id="00000000-0000-0000-0000-000000000000", external_id="ext-owner")
+ db_session.add(user)
+ db_session.commit()
+ db_session.refresh(user)
+ return user
+
+
+@pytest.fixture
+def other_user(db_session):
+ """Ein weiterer User, der weder Owner noch Admin ist."""
+ user = User(id="11111111-1111-1111-1111-111111111111", external_id="ext-other")
+ db_session.add(user)
+ db_session.commit()
+ db_session.refresh(user)
+ return user
+
+
+@pytest.fixture
+def sample_template(db_session, owner):
+ template = Template(
+ name="Icon Template",
+ description="Template to test icon upload",
+ owner_id=owner.id,
+ repo_url="https://github.com/example/icon-template",
+ visibility=TemplateVisibility.PUBLIC,
+ )
+ db_session.add(template)
+ db_session.commit()
+ db_session.refresh(template)
+ return template
+
+
+def _make_client(db_session, user_id: str, roles: list[str]):
+ """Wire the TestClient with a static current-user override."""
+ def override_get_db():
+ try:
+ yield db_session
+ finally:
+ pass
+
+ def override_get_current_user():
+ return {
+ "sub": user_id,
+ "email": f"{user_id}@example.com",
+ "name": user_id,
+ "preferred_username": user_id,
+ "roles": roles,
+ "user_id": user_id,
+ }
+
+ app.dependency_overrides[get_db] = override_get_db
+ app.dependency_overrides[get_current_user] = override_get_current_user
+ return TestClient(app)
+
+
+@pytest.fixture
+def owner_client(db_session, owner):
+ """Client authenticated as the template owner (lecturer role)."""
+ client = _make_client(db_session, owner.id, ["lecturer"])
+ yield client
+ app.dependency_overrides.clear()
+
+
+@pytest.fixture
+def admin_client(db_session, other_user):
+ """Client authenticated as an admin user (not the owner)."""
+ client = _make_client(db_session, other_user.id, ["admin", "lecturer"])
+ yield client
+ app.dependency_overrides.clear()
+
+
+# Minimal, valid PNG (1x1 transparent pixel) — small enough that we can
+# use the real Pillow-free byte sequence in tests without a dependency.
+PNG_1x1 = (
+ b"\x89PNG\r\n\x1a\n"
+ b"\x00\x00\x00\rIHDR"
+ b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89"
+ b"\x00\x00\x00\rIDATx\x9cc\xf8\xcf\xc0\x00\x00\x00\x03\x00\x01"
+ b"\x00\x18\xdd\x8d\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
+)
+
+
+class TestUploadIcon:
+ def test_owner_can_upload_png(self, owner_client, sample_template):
+ response = owner_client.post(
+ f"/api/v1/templates/{sample_template.id}/icon",
+ files={"file": ("logo.png", PNG_1x1, "image/png")},
+ )
+ assert response.status_code == status.HTTP_201_CREATED
+ data = response.json()["data"]
+ assert data["template_id"] == sample_template.id
+ assert data["content_type"] == "image/png"
+ assert data["size_bytes"] == len(PNG_1x1)
+ assert data["icon_path"] == f"/api/v1/templates/{sample_template.id}/icon"
+
+ def test_upload_populates_icon_path_in_template_response(
+ self, owner_client, sample_template
+ ):
+ owner_client.post(
+ f"/api/v1/templates/{sample_template.id}/icon",
+ files={"file": ("logo.png", PNG_1x1, "image/png")},
+ )
+ get_resp = owner_client.get(f"/api/v1/templates/{sample_template.id}")
+ assert get_resp.status_code == status.HTTP_200_OK
+ body = get_resp.json()["data"]
+ assert body["icon_path"] == f"/api/v1/templates/{sample_template.id}/icon"
+ # icon_url gibt es nicht mehr — nur noch icon_path
+ assert "icon_url" not in body
+
+ def test_upload_svg_rejected_415(self, owner_client, sample_template):
+ response = owner_client.post(
+ f"/api/v1/templates/{sample_template.id}/icon",
+ files={"file": ("logo.svg", b"", "image/svg+xml")},
+ )
+ assert response.status_code == 415
+
+ def test_upload_text_rejected_415(self, owner_client, sample_template):
+ response = owner_client.post(
+ f"/api/v1/templates/{sample_template.id}/icon",
+ files={"file": ("hello.txt", b"hello", "text/plain")},
+ )
+ assert response.status_code == 415
+
+ def test_upload_empty_file_rejected_400(self, owner_client, sample_template):
+ response = owner_client.post(
+ f"/api/v1/templates/{sample_template.id}/icon",
+ files={"file": ("empty.png", b"", "image/png")},
+ )
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ def test_stranger_cannot_upload(self, db_session, sample_template, other_user):
+ """Ein User, der weder Owner noch Admin ist, darf kein Icon setzen.
+
+ Der Template-Sichtbarkeits-Gate schießt hier zuerst (PUBLIC-Template
+ ohne APPROVED Version → 403 auf GET), also bekommen wir schon
+ beim Ownership-Check ein 403 zurück statt eines 200.
+ """
+ client = _make_client(db_session, other_user.id, ["lecturer"])
+ try:
+ response = client.post(
+ f"/api/v1/templates/{sample_template.id}/icon",
+ files={"file": ("logo.png", PNG_1x1, "image/png")},
+ )
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+ finally:
+ app.dependency_overrides.clear()
+
+ def test_admin_can_upload_on_other_users_template(
+ self, admin_client, sample_template
+ ):
+ response = admin_client.post(
+ f"/api/v1/templates/{sample_template.id}/icon",
+ files={"file": ("logo.png", PNG_1x1, "image/png")},
+ )
+ assert response.status_code == status.HTTP_201_CREATED
+
+ def test_reupload_replaces_bytes(self, owner_client, sample_template):
+ owner_client.post(
+ f"/api/v1/templates/{sample_template.id}/icon",
+ files={"file": ("logo.png", PNG_1x1, "image/png")},
+ )
+ # Anderes Byte-Muster hochladen — Größe muss sich am GET zeigen.
+ larger = PNG_1x1 + b"\x00" * 32
+ # Zweiter Upload — muss die vorhandene Row updaten, nicht duplizieren.
+ r2 = owner_client.post(
+ f"/api/v1/templates/{sample_template.id}/icon",
+ files={"file": ("logo2.png", larger, "image/png")},
+ )
+ assert r2.status_code == status.HTTP_201_CREATED
+ assert r2.json()["data"]["size_bytes"] == len(larger)
+ assert r2.json()["data"]["file_name"] == "logo2.png"
+
+
+class TestGetIcon:
+ def test_get_returns_stored_bytes_with_correct_mime(
+ self, owner_client, sample_template
+ ):
+ owner_client.post(
+ f"/api/v1/templates/{sample_template.id}/icon",
+ files={"file": ("logo.png", PNG_1x1, "image/png")},
+ )
+ response = owner_client.get(f"/api/v1/templates/{sample_template.id}/icon")
+ assert response.status_code == status.HTTP_200_OK
+ assert response.headers["content-type"] == "image/png"
+ assert response.content == PNG_1x1
+
+ def test_get_returns_404_when_no_icon_uploaded(
+ self, owner_client, sample_template
+ ):
+ response = owner_client.get(f"/api/v1/templates/{sample_template.id}/icon")
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+
+class TestDeleteIcon:
+ def test_owner_can_delete_icon(self, owner_client, sample_template):
+ owner_client.post(
+ f"/api/v1/templates/{sample_template.id}/icon",
+ files={"file": ("logo.png", PNG_1x1, "image/png")},
+ )
+ response = owner_client.delete(
+ f"/api/v1/templates/{sample_template.id}/icon"
+ )
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+ # Icon ist danach weg → GET liefert 404.
+ get_resp = owner_client.get(f"/api/v1/templates/{sample_template.id}/icon")
+ assert get_resp.status_code == status.HTTP_404_NOT_FOUND
+ # ``icon_path`` ist ohne Upload ``None`` — Frontend rendert Placeholder.
+ tpl_resp = owner_client.get(f"/api/v1/templates/{sample_template.id}")
+ body = tpl_resp.json()["data"]
+ assert body["icon_path"] is None
+
+ def test_delete_is_idempotent(self, owner_client, sample_template):
+ """Auch ohne vorher hochgeladenes Icon liefert DELETE 204."""
+ response = owner_client.delete(
+ f"/api/v1/templates/{sample_template.id}/icon"
+ )
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+
+ def test_stranger_cannot_delete(
+ self, db_session, sample_template, other_user, owner_client
+ ):
+ # Setup: owner lädt zuerst ein Icon hoch, dann darf ``other_user``
+ # es nicht wegnehmen.
+ owner_client.post(
+ f"/api/v1/templates/{sample_template.id}/icon",
+ files={"file": ("logo.png", PNG_1x1, "image/png")},
+ )
+ app.dependency_overrides.clear()
+
+ client = _make_client(db_session, other_user.id, ["lecturer"])
+ try:
+ response = client.delete(
+ f"/api/v1/templates/{sample_template.id}/icon"
+ )
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+ finally:
+ app.dependency_overrides.clear()
+
+
+class TestTemplateDeletionCascadesIcon:
+ def test_deleting_template_removes_its_icon(
+ self, owner_client, sample_template, db_session
+ ):
+ from src.models.template_icon import TemplateIcon
+
+ owner_client.post(
+ f"/api/v1/templates/{sample_template.id}/icon",
+ files={"file": ("logo.png", PNG_1x1, "image/png")},
+ )
+ assert (
+ db_session.query(TemplateIcon)
+ .filter_by(template_id=sample_template.id)
+ .first()
+ is not None
+ )
+ del_resp = owner_client.delete(f"/api/v1/templates/{sample_template.id}")
+ assert del_resp.status_code == status.HTTP_204_NO_CONTENT
+ # Cascade sollte die Icon-Row mitreißen.
+ db_session.expire_all()
+ assert (
+ db_session.query(TemplateIcon)
+ .filter_by(template_id=sample_template.id)
+ .first()
+ is None
+ )
diff --git a/tests/unit/test_template_icon_path_schema.py b/tests/unit/test_template_icon_path_schema.py
new file mode 100644
index 0000000..a54283f
--- /dev/null
+++ b/tests/unit/test_template_icon_path_schema.py
@@ -0,0 +1,65 @@
+"""Tests für ``icon_path`` auf TemplateResponse.
+
+Nach dem Umbau kennt das Backend nur noch hochgeladene Icon-Bilder;
+``mdi:*``/URL-Strings gibt es nicht mehr. Frontend rendert entweder
+``icon_path`` als ``
`` (gegen die API-Base-URL aufgelöst)
+oder einen Placeholder.
+"""
+from datetime import datetime, timezone
+from types import SimpleNamespace
+
+from src.schemas.template import TemplateResponse
+
+
+def _orm_template(**overrides):
+ """Build a Template-like ORM stub for schema validation."""
+ defaults = dict(
+ id="tmpl-1",
+ name="Test Template",
+ description=None,
+ owner_id="user-1",
+ repo_url="https://github.com/example/test",
+ visibility="private",
+ versions=None,
+ owner=None,
+ icon=None,
+ created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
+ updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
+ )
+ defaults.update(overrides)
+ return SimpleNamespace(**defaults)
+
+
+class TestIconPath:
+ def test_uploaded_icon_returns_serve_path(self):
+ icon = SimpleNamespace(id="icon-42")
+ response = TemplateResponse.model_validate(_orm_template(icon=icon))
+ assert response.icon_path == "/api/v1/templates/tmpl-1/icon"
+
+ def test_no_icon_returns_none(self):
+ """Ohne Upload ist ``icon_path`` ``None`` — kein Fallback."""
+ response = TemplateResponse.model_validate(_orm_template(icon=None))
+ assert response.icon_path is None
+
+
+class TestSerializedPayloadShape:
+ def test_raw_icon_object_not_leaked_into_json(self):
+ """Die ORM-Icon-Relation darf nicht in die Response wandern —
+ Clients bekommen nur ``icon_path``."""
+ icon = SimpleNamespace(id="icon-42", content_type="image/png")
+ payload = TemplateResponse.model_validate(
+ _orm_template(icon=icon)
+ ).model_dump(mode="json")
+
+ assert "icon" not in payload
+ assert "icon_url" not in payload # Altes Feld existiert nicht mehr
+ assert "effective_icon" not in payload # Zwischenname war effective_icon
+ assert "has_uploaded_icon" not in payload # ebenfalls entfernt
+ assert payload["icon_path"] == "/api/v1/templates/tmpl-1/icon"
+
+ def test_json_payload_when_no_upload(self):
+ payload = TemplateResponse.model_validate(
+ _orm_template(icon=None)
+ ).model_dump(mode="json")
+
+ assert payload["icon_path"] is None
diff --git a/tests/unit/test_template_icon_service.py b/tests/unit/test_template_icon_service.py
new file mode 100644
index 0000000..399a489
--- /dev/null
+++ b/tests/unit/test_template_icon_service.py
@@ -0,0 +1,352 @@
+"""Unit-Tests für den Template-Icon-Service.
+
+Testen Content-Type-Whitelist, Größenlimit, Owner/Admin-Gate, sowie den
+Create-vs-Replace-Zweig. Wir vermeiden echte DB-Setups und nutzen
+MagicMock-Sessions — die Zusammenarbeit mit dem Repository ist trivial
+genug, dass die Interaktion pro Testfall stubbbar ist.
+"""
+from unittest.mock import MagicMock
+from uuid import uuid4
+
+import pytest
+from starlette.exceptions import HTTPException
+
+from src.core.exceptions import BadRequestException, ForbiddenException
+from src.models.template import Template, TemplateVisibility
+from src.models.template_icon import TemplateIcon
+from src.services.template_icon_service import TemplateIconService
+
+
+def _tpl(owner_id: str = "owner-1") -> Template:
+ """Build a plain Template ORM object (no DB) with the fields the
+ service touches. ``visibility=PUBLIC`` because ``get_template`` also
+ checks the general visibility gate — for owner access that check is
+ a no-op, but we want to be defensive.
+ """
+ t = Template()
+ t.id = str(uuid4())
+ t.name = "demo"
+ t.description = None
+ t.owner_id = owner_id
+ t.repo_url = "https://example.com"
+ t.visibility = TemplateVisibility.PRIVATE
+ t.publish_requested = False
+ t.versions = []
+ return t
+
+
+def _service_with_stubs(template: Template) -> TemplateIconService:
+ """Wire a service with mocked ``template_service`` + ``repo`` so we
+ can drive the two collaborators without a real DB."""
+ svc = TemplateIconService(MagicMock())
+ svc.template_service = MagicMock()
+ svc.template_service.get_template.return_value = template
+ svc.repo = MagicMock()
+ return svc
+
+
+# ---------------------------------------------------------------------------
+# Upload — content-type validation
+# ---------------------------------------------------------------------------
+class TestUploadContentTypes:
+ def test_png_accepted(self):
+ tpl = _tpl()
+ svc = _service_with_stubs(tpl)
+ svc.repo.get_by_template_id.return_value = None
+ svc.repo.create.return_value = TemplateIcon(
+ id=str(uuid4()),
+ template_id=tpl.id,
+ content=b"x",
+ content_type="image/png",
+ file_name="a.png",
+ size_bytes=1,
+ )
+ icon = svc.upload_icon(
+ tpl.id,
+ content=b"x",
+ content_type="image/png",
+ file_name="a.png",
+ user_id="owner-1",
+ )
+ assert icon.content_type == "image/png"
+
+ def test_jpeg_accepted(self):
+ tpl = _tpl()
+ svc = _service_with_stubs(tpl)
+ svc.repo.get_by_template_id.return_value = None
+ svc.repo.create.return_value = TemplateIcon(
+ id=str(uuid4()),
+ template_id=tpl.id,
+ content=b"y",
+ content_type="image/jpeg",
+ file_name="a.jpg",
+ size_bytes=1,
+ )
+ icon = svc.upload_icon(
+ tpl.id,
+ content=b"y",
+ content_type="image/jpeg",
+ file_name="a.jpg",
+ user_id="owner-1",
+ )
+ assert icon.content_type == "image/jpeg"
+
+ def test_webp_accepted(self):
+ tpl = _tpl()
+ svc = _service_with_stubs(tpl)
+ svc.repo.get_by_template_id.return_value = None
+ svc.repo.create.return_value = TemplateIcon(
+ id=str(uuid4()),
+ template_id=tpl.id,
+ content=b"z",
+ content_type="image/webp",
+ file_name=None,
+ size_bytes=1,
+ )
+ icon = svc.upload_icon(
+ tpl.id,
+ content=b"z",
+ content_type="image/webp",
+ file_name=None,
+ user_id="owner-1",
+ )
+ assert icon.content_type == "image/webp"
+
+ def test_svg_rejected_with_415(self):
+ """SVG ist bewusst nicht erlaubt (XML-Payload / Skript-Vektor)."""
+ tpl = _tpl()
+ svc = _service_with_stubs(tpl)
+ with pytest.raises(HTTPException) as exc:
+ svc.upload_icon(
+ tpl.id,
+ content=b"",
+ content_type="image/svg+xml",
+ file_name="a.svg",
+ user_id="owner-1",
+ )
+ assert exc.value.status_code == 415
+
+ def test_plain_text_rejected_with_415(self):
+ tpl = _tpl()
+ svc = _service_with_stubs(tpl)
+ with pytest.raises(HTTPException) as exc:
+ svc.upload_icon(
+ tpl.id,
+ content=b"hello",
+ content_type="text/plain",
+ file_name="a.txt",
+ user_id="owner-1",
+ )
+ assert exc.value.status_code == 415
+
+ def test_content_type_with_charset_suffix_still_accepted(self):
+ """Browser hängen manchmal ``; charset=…`` an — wir strippen."""
+ tpl = _tpl()
+ svc = _service_with_stubs(tpl)
+ svc.repo.get_by_template_id.return_value = None
+ svc.repo.create.return_value = TemplateIcon(
+ id=str(uuid4()),
+ template_id=tpl.id,
+ content=b"x",
+ content_type="image/png",
+ file_name="a.png",
+ size_bytes=1,
+ )
+ icon = svc.upload_icon(
+ tpl.id,
+ content=b"x",
+ content_type="image/png; charset=binary",
+ file_name="a.png",
+ user_id="owner-1",
+ )
+ assert icon.content_type == "image/png"
+
+
+# ---------------------------------------------------------------------------
+# Upload — size validation
+# ---------------------------------------------------------------------------
+class TestUploadSize:
+ def test_empty_upload_rejected_400(self):
+ tpl = _tpl()
+ svc = _service_with_stubs(tpl)
+ with pytest.raises(BadRequestException):
+ svc.upload_icon(
+ tpl.id,
+ content=b"",
+ content_type="image/png",
+ file_name="a.png",
+ user_id="owner-1",
+ )
+
+ def test_oversize_upload_rejected_413(self, monkeypatch):
+ tpl = _tpl()
+ svc = _service_with_stubs(tpl)
+
+ # Kleiner Grenzwert, damit wir keine 5 MB im Test allokieren müssen.
+ from src.core import config as config_module
+
+ fake_settings = config_module.get_settings()
+ # ``Settings`` ist eine Pydantic-Instanz; wir mutieren die Cache-Kopie.
+ # Der ``get_settings``-Cache liefert dieselbe Instanz, damit reicht das.
+ original = fake_settings.max_icon_size_bytes
+ fake_settings.max_icon_size_bytes = 10
+ try:
+ with pytest.raises(HTTPException) as exc:
+ svc.upload_icon(
+ tpl.id,
+ content=b"x" * 20,
+ content_type="image/png",
+ file_name="a.png",
+ user_id="owner-1",
+ )
+ assert exc.value.status_code == 413
+ finally:
+ fake_settings.max_icon_size_bytes = original
+
+
+# ---------------------------------------------------------------------------
+# Upload — owner/admin gate
+# ---------------------------------------------------------------------------
+class TestUploadAuthGate:
+ def test_non_owner_non_admin_forbidden(self):
+ tpl = _tpl(owner_id="owner-1")
+ svc = _service_with_stubs(tpl)
+ with pytest.raises(ForbiddenException):
+ svc.upload_icon(
+ tpl.id,
+ content=b"x",
+ content_type="image/png",
+ file_name="a.png",
+ user_id="stranger",
+ is_admin=False,
+ )
+
+ def test_admin_allowed_even_if_not_owner(self):
+ tpl = _tpl(owner_id="owner-1")
+ svc = _service_with_stubs(tpl)
+ svc.repo.get_by_template_id.return_value = None
+ svc.repo.create.return_value = TemplateIcon(
+ id=str(uuid4()),
+ template_id=tpl.id,
+ content=b"x",
+ content_type="image/png",
+ file_name="a.png",
+ size_bytes=1,
+ )
+ icon = svc.upload_icon(
+ tpl.id,
+ content=b"x",
+ content_type="image/png",
+ file_name="a.png",
+ user_id="admin-99",
+ is_admin=True,
+ )
+ assert icon is not None
+
+
+# ---------------------------------------------------------------------------
+# Upload — create-vs-replace behaviour
+# ---------------------------------------------------------------------------
+class TestUploadCreateOrReplace:
+ def test_first_upload_creates_new_row(self):
+ tpl = _tpl()
+ svc = _service_with_stubs(tpl)
+ svc.repo.get_by_template_id.return_value = None
+ created = TemplateIcon(
+ id=str(uuid4()),
+ template_id=tpl.id,
+ content=b"x",
+ content_type="image/png",
+ file_name="a.png",
+ size_bytes=1,
+ )
+ svc.repo.create.return_value = created
+
+ icon = svc.upload_icon(
+ tpl.id,
+ content=b"x",
+ content_type="image/png",
+ file_name="a.png",
+ user_id="owner-1",
+ )
+ assert icon is created
+ svc.repo.create.assert_called_once()
+
+ def test_second_upload_replaces_content_keeps_id(self):
+ """Bei bereits vorhandenem Icon wird die Row in-place mutiert,
+ damit ``id`` und ``created_at`` stabil bleiben."""
+ tpl = _tpl()
+ svc = _service_with_stubs(tpl)
+ existing = TemplateIcon(
+ id="stable-icon-id",
+ template_id=tpl.id,
+ content=b"old",
+ content_type="image/jpeg",
+ file_name="old.jpg",
+ size_bytes=3,
+ )
+ svc.repo.get_by_template_id.return_value = existing
+
+ icon = svc.upload_icon(
+ tpl.id,
+ content=b"NEWDATA",
+ content_type="image/png",
+ file_name="new.png",
+ user_id="owner-1",
+ )
+ assert icon.id == "stable-icon-id"
+ assert icon.content == b"NEWDATA"
+ assert icon.content_type == "image/png"
+ assert icon.file_name == "new.png"
+ assert icon.size_bytes == 7
+ # ``create`` darf im Replace-Pfad nicht aufgerufen werden.
+ svc.repo.create.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# Get / Delete
+# ---------------------------------------------------------------------------
+class TestGetIcon:
+ def test_get_returns_icon_when_present(self):
+ tpl = _tpl()
+ svc = _service_with_stubs(tpl)
+ stored = TemplateIcon(
+ id="x",
+ template_id=tpl.id,
+ content=b"blob",
+ content_type="image/png",
+ file_name="a.png",
+ size_bytes=4,
+ )
+ svc.repo.get_by_template_id.return_value = stored
+ icon = svc.get_icon(tpl.id, user_id="owner-1")
+ assert icon is stored
+
+ def test_get_raises_404_when_no_icon(self):
+ from src.core.exceptions import NotFoundException
+
+ tpl = _tpl()
+ svc = _service_with_stubs(tpl)
+ svc.repo.get_by_template_id.return_value = None
+ with pytest.raises(NotFoundException):
+ svc.get_icon(tpl.id, user_id="owner-1")
+
+
+class TestDeleteIcon:
+ def test_delete_returns_true_when_deleted(self):
+ tpl = _tpl()
+ svc = _service_with_stubs(tpl)
+ svc.repo.delete_by_template_id.return_value = True
+ assert svc.delete_icon(tpl.id, user_id="owner-1") is True
+
+ def test_delete_idempotent_returns_false_when_nothing_existed(self):
+ tpl = _tpl()
+ svc = _service_with_stubs(tpl)
+ svc.repo.delete_by_template_id.return_value = False
+ assert svc.delete_icon(tpl.id, user_id="owner-1") is False
+
+ def test_delete_non_owner_forbidden(self):
+ tpl = _tpl(owner_id="owner-1")
+ svc = _service_with_stubs(tpl)
+ with pytest.raises(ForbiddenException):
+ svc.delete_icon(tpl.id, user_id="stranger", is_admin=False)
diff --git a/tests/unit/test_template_response_schema.py b/tests/unit/test_template_response_schema.py
index 164b180..9b21249 100644
--- a/tests/unit/test_template_response_schema.py
+++ b/tests/unit/test_template_response_schema.py
@@ -15,10 +15,10 @@ def _orm_template(owner=None, **overrides):
description="A test template",
owner_id="user-1",
repo_url="https://github.com/example/test",
- icon_url=None,
visibility="private",
versions=None,
owner=owner,
+ icon=None,
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
)
diff --git a/uv.lock b/uv.lock
index 83ed3c3..c24bb86 100644
--- a/uv.lock
+++ b/uv.lock
@@ -71,6 +71,7 @@ dependencies = [
{ name = "python-dotenv" },
{ name = "python-heatclient" },
{ name = "python-jose", extra = ["cryptography"] },
+ { name = "python-multipart" },
{ name = "pyyaml" },
{ name = "sqlalchemy" },
{ name = "uvicorn", extra = ["standard"] },
@@ -107,6 +108,7 @@ requires-dist = [
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "python-heatclient", specifier = ">=3.5.0" },
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" },
+ { name = "python-multipart", specifier = ">=0.0.9" },
{ name = "pyyaml", specifier = ">=6.0.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
{ name = "sqlalchemy", specifier = ">=2.0.0" },
@@ -1777,6 +1779,15 @@ cryptography = [
{ name = "cryptography" },
]
+[[package]]
+name = "python-multipart"
+version = "0.0.32"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
+]
+
[[package]]
name = "python-swiftclient"
version = "4.10.0"