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
93 changes: 93 additions & 0 deletions alembic/versions/c8a3f1e9b7d5_create_template_icons_table.py
Original file line number Diff line number Diff line change
@@ -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')
8 changes: 5 additions & 3 deletions bruno/Templates/Create Template.bru
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}

Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions bruno/Templates/Import Template From GitHub.bru
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
128 changes: 126 additions & 2 deletions src/api/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
10 changes: 10 additions & 0 deletions src/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 0 additions & 3 deletions src/core/seed_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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},
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -34,6 +35,7 @@
"Template",
"TemplateCategory",
"TemplateCategoryAssignment",
"TemplateIcon",
"TemplateVersion",
"TemplateVersionFile",
"User",
Expand Down
19 changes: 14 additions & 5 deletions src/models/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)


Loading
Loading