GUI-Umbau Phase 3: Kunden-Workspace-Creator

Organisations-Admins bekommen erstmals Zugriff auf die Workspace-
Komposition (bisher exklusiv /admin/technik/workspaces vorbehalten,
ADR-0028 hatte das als "zunaechst admin-only" offen gelassen).

- routers/workspace.py (Neubau, ersetzt den Phase-1-Platzhalter):
  /workspace (eigene Workspaces + bereitgestellte globale Standards,
  ueber den bestehenden ADR-0028-Endpunkt), /workspace (POST, anlegen -
  organization_id kommt nie aus dem Formular), /workspace/{id} (Detail:
  eigener Workspace -> volle Kategorie-Checkbox-Komposition, Wiederver-
  wendung von admin_technik.gruppiere_merkmale_nach_kategorie(); globaler
  Standard -> reine Leseansicht, kein Formular).
- Webapps-Verwaltung zieht von der bisherigen eigenen Seite (/webapps)
  hierher um (workspace_detail.html, nur fuer eigene Workspaces sichtbar)
  - alte Route bleibt nur noch als 301-Redirect auf /workspace bestehen.
- Neue Besitz-/Sichtbarkeitspruefung fetch_sichtbaren_workspace() (eigene
  Organisation ODER global sichtbar, sonst 404) analog zum bestehenden
  find_oe_in_organization()-Muster.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLk93dhDMrkPXefVdknMV9
This commit is contained in:
adminandClaude Sonnet 5 committed 2026-09-11 09:03:04 +02:00
1 parent ae6a1c38c6
commit 6d9b2f5a0d
5 files changed
+368 -186

No files matched your search

+8 -93
View File
@@ -1,99 +1,14 @@
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import RedirectResponse, StreamingResponse
from anode_client import anode_request, anode_stream
from auth import get_current_user
from templating import templates
from fastapi import APIRouter
from fastapi.responses import RedirectResponse
router = APIRouter()
def fetch_organization_workspaces(organization_id: str) -> list[dict]:
"""Eigene Workspaces des Kunden - abgeleitet aus den eigenen
Bereitstellungsvorlagen (es gibt noch keine direkte Workspace-Liste im
Kunden-Self-Service, siehe project_phase2_app_store_merkmale-Memory).
Ein Workspace kann von mehreren Vorlagen geteilt werden - hier dedupliziert."""
result = anode_request("GET", f"/api/v1/organizations/{organization_id}/bereitstellungsvorlagen")
vorlagen = result.get("bereitstellungsvorlagen", []) if result.get("success") else []
gesehen = {}
for v in vorlagen:
workspace = v.get("workspace")
if workspace and workspace["id"] not in gesehen:
gesehen[workspace["id"]] = workspace
return list(gesehen.values())
def find_workspace_in_organization(workspace_id: str, organization_id: str) -> dict | None:
"""Besitz-Validierung analog zu find_oe_in_organization() (struktur.py,
siehe ADR-0011): eine workspace_id aus der URL wird nur akzeptiert, wenn
sie tatsächlich zu einer eigenen Bereitstellungsvorlage gehört."""
for workspace in fetch_organization_workspaces(organization_id):
if workspace["id"] == workspace_id:
return workspace
return None
# GUI-Umbau Phase 3 (11.09.2026): Webapps sind Teil des neuen
# "Workspace"-Navigationspunkts geworden (routers/workspace.py) statt
# einer eigenen Seite - dieser Router bleibt nur noch als Weiterleitung
# für alte Lesezeichen/Links bestehen.
@router.get("/webapps")
def webapps_ansicht(request: Request, user=Depends(get_current_user)):
organization_id = str(user.organization_id)
workspaces = fetch_organization_workspaces(organization_id)
for workspace in workspaces:
webapps_result = anode_request("GET", f"/api/v1/workspaces/{workspace['id']}/webapps")
workspace["webapps"] = webapps_result.get("webapps", []) if webapps_result.get("success") else []
templates_result = anode_request("GET", f"/api/v1/workspaces/{workspace['id']}/webapp-templates/available")
workspace["verfuegbare_vorlagen"] = templates_result.get("webapp_templates", []) if templates_result.get("success") else []
return templates.TemplateResponse(request, "webapps.html", {"user": user, "workspaces": workspaces})
@router.post("/webapps/{workspace_id}/hinzufuegen")
def webapp_hinzufuegen(
request: Request, workspace_id: str, name: str = Form(...), url: str = Form(...),
user=Depends(get_current_user),
):
if find_workspace_in_organization(workspace_id, str(user.organization_id)) is None:
raise HTTPException(status_code=404, detail="Workspace wurde nicht gefunden.")
anode_request("POST", f"/api/v1/workspaces/{workspace_id}/webapps", json={"name": name, "url": url})
return RedirectResponse("/webapps", status_code=303)
@router.post("/webapps/{workspace_id}/{webapp_id}/entfernen")
def webapp_entfernen(workspace_id: str, webapp_id: str, user=Depends(get_current_user)):
if find_workspace_in_organization(workspace_id, str(user.organization_id)) is None:
raise HTTPException(status_code=404, detail="Workspace wurde nicht gefunden.")
anode_request("DELETE", f"/api/v1/workspaces/{workspace_id}/webapps/{webapp_id}")
return RedirectResponse("/webapps", status_code=303)
@router.post("/webapps/{workspace_id}/vorlagen/{template_id}/aktivieren")
def webapp_vorlage_aktivieren(workspace_id: str, template_id: str, user=Depends(get_current_user)):
if find_workspace_in_organization(workspace_id, str(user.organization_id)) is None:
raise HTTPException(status_code=404, detail="Workspace wurde nicht gefunden.")
anode_request("POST", f"/api/v1/workspaces/{workspace_id}/webapp-templates/{template_id}/activate")
return RedirectResponse("/webapps", status_code=303)
@router.get("/webapp-icons/{filename}")
def webapp_icon_proxy(filename: str, user=Depends(get_current_user)):
# Gleiches Proxy-Muster wie im Admin-Bereich (admin_technik.py) und beim
# ISO-Download - kundenplattform kennt anodes oeffentliche Adresse nicht.
def stream_datei():
with anode_stream("GET", f"/webapp-icons/{filename}") as response:
response.raise_for_status()
for chunk in response.iter_bytes(chunk_size=65536):
yield chunk
return StreamingResponse(stream_datei(), media_type="image/x-icon")
def webapps_redirect():
return RedirectResponse("/workspace", status_code=301)
+175 -8
View File
@@ -1,19 +1,186 @@
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import RedirectResponse, StreamingResponse
from anode_client import anode_request, anode_stream
from auth import get_current_user
from routers.admin_technik import gruppiere_merkmale_nach_kategorie
from templating import templates
router = APIRouter()
@router.get("/workspace")
def workspace_liste(request: Request, user=Depends(get_current_user)):
def fetch_verfuegbare_workspaces(organization_id: str) -> list[dict]:
result = anode_request("GET", f"/api/v1/organizations/{organization_id}/workspaces")
return result.get("workspaces", []) if result.get("success") else []
def fetch_sichtbaren_workspace(workspace_id: str, organization_id: str) -> dict | None:
"""
Platzhalter (GUI-Umbau Phase 1, 11.09.2026) - der Navigationspunkt
"Workspace" existiert schon, der eigentliche Kunden-Workspace-Creator
(eigene Workspaces komponieren, globale Standards read-only ansehen,
Webapps-Verwaltung zieht hierher um) kommt in Phase 3 des Plans.
Besitz-/Sichtbarkeits-Validierung (analog find_oe_in_organization(),
ADR-0011) - ein Workspace ist sichtbar, wenn er entweder der eigenen
Organisation gehört (bearbeitbar) oder global ist (organization_id
NULL, nur lesend). Ein privater Workspace einer fremden Organisation
bleibt unsichtbar.
"""
return templates.TemplateResponse(request, "workspace_liste.html", {"user": user})
result = anode_request("GET", f"/api/v1/workspaces/{workspace_id}")
workspace = result.get("workspace") if result.get("success") else None
if workspace is None:
return None
ws_org = workspace.get("organization_id")
if ws_org is not None and ws_org != organization_id:
return None
return workspace
def _eigenen_workspace_oder_404(workspace_id: str, organization_id: str) -> dict:
"""Wie fetch_sichtbaren_workspace(), aber verlangt zusätzlich
Bearbeitbarkeit (eigene Organisation, nicht global) - für alle
schreibenden Endpunkte (Merkmale/Webapps ändern)."""
workspace = fetch_sichtbaren_workspace(workspace_id, organization_id)
if workspace is None or workspace.get("organization_id") != organization_id:
raise HTTPException(status_code=404, detail="Workspace wurde nicht gefunden.")
return workspace
@router.get("/workspace")
def workspace_liste(request: Request, user=Depends(get_current_user)):
alle = fetch_verfuegbare_workspaces(str(user.organization_id))
return templates.TemplateResponse(
request,
"workspace_liste.html",
{
"user": user,
"eigene_workspaces": [w for w in alle if not w["ist_global"]],
"standard_workspaces": [w for w in alle if w["ist_global"]],
},
)
@router.post("/workspace")
def workspace_anlegen(
key: str = Form(...), name: str = Form(...), description: str = Form(default=""),
user=Depends(get_current_user),
):
# organization_id kommt nie aus dem Formular, sondern immer aus der
# Session - gleiches Muster wie /organisation/konten (verhindert einen
# global sichtbaren Workspace per manipuliertem Request).
result = anode_request(
"POST", "/api/v1/workspaces",
json={"key": key, "name": name, "description": description or None, "organization_id": str(user.organization_id)},
)
if not result.get("success"):
raise HTTPException(status_code=400, detail=result.get("message", "Workspace konnte nicht angelegt werden."))
return RedirectResponse(f"/workspace/{result['workspace']['id']}", status_code=303)
@router.get("/workspace/{workspace_id}")
def workspace_detail(request: Request, workspace_id: str, user=Depends(get_current_user)):
organization_id = str(user.organization_id)
workspace = fetch_sichtbaren_workspace(workspace_id, organization_id)
if workspace is None:
raise HTTPException(status_code=404, detail="Workspace wurde nicht gefunden.")
bearbeitbar = workspace.get("organization_id") == organization_id
merkmale_result = anode_request("GET", "/api/v1/merkmale")
alle_merkmale = merkmale_result.get("merkmale", []) if merkmale_result.get("success") else []
komponierte_ids = {m["id"] for m in workspace["merkmale"]}
kategorien = gruppiere_merkmale_nach_kategorie(alle_merkmale, komponierte_ids)
webapps = []
verfuegbare_vorlagen = []
if bearbeitbar:
webapps_result = anode_request("GET", f"/api/v1/workspaces/{workspace_id}/webapps")
webapps = webapps_result.get("webapps", []) if webapps_result.get("success") else []
vorlagen_result = anode_request("GET", f"/api/v1/workspaces/{workspace_id}/webapp-templates/available")
verfuegbare_vorlagen = vorlagen_result.get("webapp_templates", []) if vorlagen_result.get("success") else []
return templates.TemplateResponse(
request,
"workspace_detail.html",
{
"user": user,
"workspace": workspace,
"workspace_id": workspace_id,
"kategorien": kategorien,
"bearbeitbar": bearbeitbar,
"webapps": webapps,
"verfuegbare_vorlagen": verfuegbare_vorlagen,
},
)
@router.post("/workspace/{workspace_id}/merkmale")
async def workspace_merkmale_speichern(request: Request, workspace_id: str, user=Depends(get_current_user)):
"""Diff-basiertes Sammel-Speichern, gleiches Muster wie
admin_technik.py:workspace_merkmale_speichern()."""
organization_id = str(user.organization_id)
workspace = _eigenen_workspace_oder_404(workspace_id, organization_id)
form = await request.form()
ausgewaehlt = set(form.getlist("merkmal_ids"))
aktuell = {m["id"] for m in workspace["merkmale"]}
for merkmal_id in ausgewaehlt - aktuell:
anode_request("POST", f"/api/v1/workspaces/{workspace_id}/merkmale/{merkmal_id}")
for merkmal_id in aktuell - ausgewaehlt:
anode_request("DELETE", f"/api/v1/workspaces/{workspace_id}/merkmale/{merkmal_id}")
return RedirectResponse(f"/workspace/{workspace_id}", status_code=303)
@router.post("/workspace/{workspace_id}/webapps")
def webapp_hinzufuegen(
workspace_id: str, name: str = Form(...), url: str = Form(...), user=Depends(get_current_user)
):
_eigenen_workspace_oder_404(workspace_id, str(user.organization_id))
anode_request("POST", f"/api/v1/workspaces/{workspace_id}/webapps", json={"name": name, "url": url})
return RedirectResponse(f"/workspace/{workspace_id}", status_code=303)
@router.post("/workspace/{workspace_id}/webapps/{webapp_id}/entfernen")
def webapp_entfernen(workspace_id: str, webapp_id: str, user=Depends(get_current_user)):
_eigenen_workspace_oder_404(workspace_id, str(user.organization_id))
anode_request("DELETE", f"/api/v1/workspaces/{workspace_id}/webapps/{webapp_id}")
return RedirectResponse(f"/workspace/{workspace_id}", status_code=303)
@router.post("/workspace/{workspace_id}/vorlagen/{template_id}/aktivieren")
def webapp_vorlage_aktivieren(workspace_id: str, template_id: str, user=Depends(get_current_user)):
_eigenen_workspace_oder_404(workspace_id, str(user.organization_id))
anode_request("POST", f"/api/v1/workspaces/{workspace_id}/webapp-templates/{template_id}/activate")
return RedirectResponse(f"/workspace/{workspace_id}", status_code=303)
@router.get("/webapp-icons/{filename}")
def webapp_icon_proxy(filename: str, user=Depends(get_current_user)):
# Gleiches Proxy-Muster wie im Admin-Bereich (admin_technik.py) und
# beim ISO-Download - kundenplattform kennt anodes öffentliche Adresse
# nicht.
def stream_datei():
with anode_stream("GET", f"/webapp-icons/{filename}") as response:
response.raise_for_status()
for chunk in response.iter_bytes(chunk_size=65536):
yield chunk
return StreamingResponse(stream_datei(), media_type="image/x-icon")
-78
View File
@@ -1,78 +0,0 @@
{% extends "base.html" %}
{% block title %}Webapps — Tuxflotte{% endblock %}
{% block content %}
<h2>Webapps</h2>
<p><small>URLs, die auf den Geräten deiner Workspaces als Menü-/Desktop-Verknüpfung mit dem Favicon der Seite erscheinen.</small></p>
{% if not workspaces %}
<p>Noch kein Workspace vorhanden - lege zuerst ein Installationsmedium mit einer Bereitstellungsvorlage an.</p>
{% endif %}
{% for w in workspaces %}
<article>
<h3>{{ w.name }}</h3>
{% if w.webapps %}
<figure>
<table>
<tbody>
{% for webapp in w.webapps %}
<tr>
<td style="width:2rem">
{% if webapp.icon_path %}
<img src="/webapp-icons/{{ webapp.icon_path.split('/')[-1] }}" alt="" width="24" height="24"
title="Bild verlinkt zum Betreiber ({{ webapp.url }})">
{% endif %}
</td>
<td>{{ webapp.name }}<br><small><a href="{{ webapp.url }}" target="_blank" rel="noopener">{{ webapp.url }}</a></small></td>
<td>
<form method="post" action="/webapps/{{ w.id }}/{{ webapp.id }}/entfernen">
<button type="submit" class="secondary outline">Entfernen</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</figure>
{% else %}
<p><small>Noch keine Webapps für diesen Workspace.</small></p>
{% endif %}
{% if w.verfuegbare_vorlagen %}
<details>
<summary role="button" class="outline">Vorlage aktivieren</summary>
<table>
<tbody>
{% for vorlage in w.verfuegbare_vorlagen %}
<tr>
<td style="width:2rem">
{% if vorlage.icon_path %}
<img src="/webapp-icons/{{ vorlage.icon_path.split('/')[-1] }}" alt="" width="24" height="24"
title="Bild verlinkt zum Betreiber ({{ vorlage.url }})">
{% endif %}
</td>
<td>{{ vorlage.name }}{% if vorlage.description %}<br><small>{{ vorlage.description }}</small>{% endif %}</td>
<td>
<form method="post" action="/webapps/{{ w.id }}/vorlagen/{{ vorlage.id }}/aktivieren">
<button type="submit" class="secondary outline">Aktivieren</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</details>
{% endif %}
<details>
<summary role="button" class="secondary outline">Eigene Webapp hinzufügen</summary>
<form method="post" action="/webapps/{{ w.id }}/hinzufuegen">
<label>Name<input type="text" name="name" required placeholder="z.B. Schulportal"></label>
<label>URL<input type="url" name="url" required placeholder="https://..."></label>
<button type="submit">Hinzufügen</button>
</form>
</details>
</article>
{% endfor %}
{% endblock %}
+122
View File
@@ -0,0 +1,122 @@
{% extends "base.html" %}
{% block title %}{{ workspace.name if workspace else workspace_id }} — Tuxflotte{% endblock %}
{% block content %}
<p><a href="/workspace">← Workspace</a></p>
<h2>{{ workspace.name }}</h2>
<p>
{% if bearbeitbar %}Eigener Workspace{% else %}Bereitgestellter Standard <small>(schreibgeschützt)</small>{% endif %}
{% if workspace.description %} — {{ workspace.description }}{% endif %}
</p>
<section>
<h3>Zusammensetzung</h3>
{% if bearbeitbar %}
<p><small>
Angehakte Merkmale gehören zum Grundbestand dieses Workspace - gilt
additiv für alle Geräte, die ihm zugewiesen sind (per
Installationsmedium, oder nachträglich per Gerätegruppe mit
eigenem Workspace).
</small></p>
{% if not kategorien %}
<p>Keine katalogfähigen Merkmale vorhanden.</p>
{% else %}
<form method="post" action="/workspace/{{ workspace_id }}/merkmale">
{% for kategorie_name, eintraege in kategorien.items() %}
<fieldset>
<legend><strong>{{ kategorie_name }}</strong></legend>
{% for m in eintraege %}
<label>
<input type="checkbox" name="merkmal_ids" value="{{ m.id }}" {% if m.zugeordnet %}checked{% endif %}>
{{ m.name }}
{% if m.description %}<br><small>{{ m.description }}</small>{% endif %}
</label>
{% endfor %}
</fieldset>
{% endfor %}
<button type="submit">Zusammensetzung speichern</button>
</form>
{% endif %}
{% else %}
{% for kategorie_name, eintraege in kategorien.items() %}
{% set zugeordnete = eintraege | selectattr("zugeordnet") | list %}
{% if zugeordnete %}
<p><strong>{{ kategorie_name }}</strong></p>
<ul>
{% for m in zugeordnete %}
<li>{{ m.name }}{% if m.description %} — <small>{{ m.description }}</small>{% endif %}</li>
{% endfor %}
</ul>
{% endif %}
{% endfor %}
{% endif %}
</section>
{% if bearbeitbar %}
<section>
<h3>Webapps</h3>
<p><small>URLs, die auf den Geräten dieses Workspace als Menü-/Desktop-Verknüpfung mit dem Favicon der Seite erscheinen.</small></p>
{% if webapps %}
<figure>
<table>
<tbody>
{% for webapp in webapps %}
<tr>
<td style="width:2rem">
{% if webapp.icon_path %}
<img src="/webapp-icons/{{ webapp.icon_path.split('/')[-1] }}" alt="" width="24" height="24"
title="Bild verlinkt zum Betreiber ({{ webapp.url }})">
{% endif %}
</td>
<td>{{ webapp.name }}<br><small><a href="{{ webapp.url }}" target="_blank" rel="noopener">{{ webapp.url }}</a></small></td>
<td>
<form method="post" action="/workspace/{{ workspace_id }}/webapps/{{ webapp.id }}/entfernen">
<button type="submit" class="secondary outline">Entfernen</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</figure>
{% else %}
<p><small>Noch keine Webapps für diesen Workspace.</small></p>
{% endif %}
{% if verfuegbare_vorlagen %}
<details>
<summary role="button" class="outline">Vorlage aktivieren</summary>
<table>
<tbody>
{% for vorlage in verfuegbare_vorlagen %}
<tr>
<td style="width:2rem">
{% if vorlage.icon_path %}
<img src="/webapp-icons/{{ vorlage.icon_path.split('/')[-1] }}" alt="" width="24" height="24"
title="Bild verlinkt zum Betreiber ({{ vorlage.url }})">
{% endif %}
</td>
<td>{{ vorlage.name }}{% if vorlage.description %}<br><small>{{ vorlage.description }}</small>{% endif %}</td>
<td>
<form method="post" action="/workspace/{{ workspace_id }}/vorlagen/{{ vorlage.id }}/aktivieren">
<button type="submit" class="secondary outline">Aktivieren</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</details>
{% endif %}
<details>
<summary role="button" class="secondary outline">Eigene Webapp hinzufügen</summary>
<form method="post" action="/workspace/{{ workspace_id }}/webapps">
<label>Name<input type="text" name="name" required placeholder="z.B. Schulportal"></label>
<label>URL<input type="url" name="url" required placeholder="https://..."></label>
<button type="submit">Hinzufügen</button>
</form>
</details>
</section>
{% endif %}
{% endblock %}
+63 -7
View File
@@ -2,11 +2,67 @@
{% block title %}Workspace — Tuxflotte{% endblock %}
{% block content %}
<h2>Workspace</h2>
<p>
Hier entsteht bald der Workspace-Creator: eigene Workspaces
zusammenstellen oder einen bereitgestellten Standard übernehmen, dazu
die Webapp-Verwaltung (zieht von der bisherigen Webapps-Seite hierher
um).
</p>
<p><a href="/webapps">Webapps verwalten →</a> (vorübergehend noch hier, zieht in Kürze um)</p>
<p><small>
Ein Workspace ist der Merkmal-Grundbestand, den Sie einem
Installationsmedium mitgeben - wählen Sie einen bereitgestellten
Standard unverändert, oder stellen Sie einen eigenen zusammen.
</small></p>
<section>
<h3>Ihre Workspaces</h3>
{% if not eigene_workspaces %}
<p>Noch kein eigener Workspace angelegt.</p>
{% else %}
<figure>
<table>
<thead><tr><th>Name</th><th>Beschreibung</th><th></th></tr></thead>
<tbody>
{% for w in eigene_workspaces %}
<tr>
<td>{{ w.name }}</td>
<td>{{ w.description or "—" }}</td>
<td><a href="/workspace/{{ w.id }}" role="button" class="outline">Bearbeiten →</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</figure>
{% endif %}
<details>
<summary role="button" class="secondary outline">Neuen Workspace anlegen</summary>
<form method="post" action="/workspace">
<label for="key">Key <small>(technischer Kurzname, z.B. "schulcomputer-klasse5")</small></label>
<input type="text" id="key" name="key" required>
<label for="name">Name</label>
<input type="text" id="name" name="name" required>
<label for="description">Beschreibung</label>
<input type="text" id="description" name="description">
<button type="submit">Anlegen</button>
</form>
</details>
</section>
<section>
<h3>Bereitgestellte Standards</h3>
<p><small>Schreibgeschützte Vorlagen - für ein Installationsmedium direkt unverändert auswählbar.</small></p>
{% if not standard_workspaces %}
<p>Aktuell keine Standards verfügbar.</p>
{% else %}
<figure>
<table>
<thead><tr><th>Name</th><th>Beschreibung</th><th></th></tr></thead>
<tbody>
{% for w in standard_workspaces %}
<tr>
<td>{{ w.name }}</td>
<td>{{ w.description or "—" }}</td>
<td><a href="/workspace/{{ w.id }}" role="button" class="outline secondary">Ansehen →</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</figure>
{% endif %}
</section>
{% endblock %}