Externe Datei-Links + Power-User-Rolle + PAROL6-Demo
Block A — Externe Links:
- projekt_datei.extern_url: eine "Datei" kann Upload (BLOB) ODER Link sein
- Link-Route /cockpit/projekt/<id>/link, Werkstatt-UI mit Link-Feld
- Katalog + Detail rendern Links als "Oeffnen ↗", Uploads als Download
- Upload-Limit 30 -> 60 MB (grosse Bambu-3MF); darueber: externer Link
Block B — Power-User-Rolle:
- profil.rolle ('schueler'|'power'); Login setzt Session-Rolle
- Decorator power_oder_admin; ist_power-Helper
- Login-Kacheln: Power mit 🔧 statt Tier; Admin-Profilanlage mit Rollen-Dropdown
(Tier nur bei schueler Pflicht, Power kriegt Platzhalter-Slug 'power')
- Cockpit: kein Check-In fuer Power, stattdessen "Power-Werkzeuge"-Karte
- Power-Projekt-Regeln: mehrere parallel erlaubt, Abschluss direkt 'freigegeben'
- Rechte power_oder_admin: Drucker-Verwaltung, Software-Katalog,
Slot-fuer-SuS, inline-edit (beschraenkt auf drucker+software_seite)
- Bleibt admin-only: SuS-Profile, Projekt-Freigaben, Admin-Dashboard
- Power-relevante Admin-Templates: Zurueck-Link rollenabhaengig (Cockpit statt Dashboard)
Block C — PAROL6-Demo:
- scripts/seed_demo_parol6.py: Power-Profil 'Herb' + PAROL6-Roboterarm-Projekt
- GitHub-Repo als Link, Bauanleitung-PDF als BLOB, Cover aus 3MF-Render
- abgeschlossen+freigegeben -> im Katalog, als Vorlage waehlbar
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
12
database.py
12
database.py
@@ -49,6 +49,11 @@ CREATE TABLE IF NOT EXISTS profil (
|
||||
-- den geplanten L1-Gate-Automatismus, weil manche SuS zuhause schon
|
||||
-- drucken oder schneller durch L1 sind als die Doku festhalten kann).
|
||||
reservieren_freigeschaltet INTEGER DEFAULT 0,
|
||||
-- Rolle: 'schueler' (Default) | 'power'. Power-User sind fortgeschrittene
|
||||
-- SuS (oder Herb selbst): duerfen Drucker/Software verwalten + Slots fuer
|
||||
-- andere + mehrere Demo-Projekte, aber KEINE SuS-Verwaltung/Freigaben.
|
||||
-- Kein Tier-Zwang, kein Check-In.
|
||||
rolle TEXT DEFAULT 'schueler',
|
||||
erstellt_am DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(ag_id, vorname, tier),
|
||||
FOREIGN KEY (ag_id) REFERENCES ag_mitgliedschaft(id)
|
||||
@@ -136,11 +141,12 @@ CREATE INDEX IF NOT EXISTS idx_projekt_sichtbar ON projekt(sichtbar_in_ag, freig
|
||||
CREATE TABLE IF NOT EXISTS projekt_datei (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
projekt_id INTEGER NOT NULL,
|
||||
dateiname TEXT NOT NULL,
|
||||
dateiname TEXT NOT NULL, -- Anzeigename (auch fuer Links)
|
||||
mime_type TEXT,
|
||||
groesse_bytes INTEGER,
|
||||
inhalt BLOB,
|
||||
art TEXT NOT NULL, -- 'cover_foto'|'stl'|'3mf'|'foto'|'sonstiges'
|
||||
inhalt BLOB, -- NULL bei externen Links
|
||||
extern_url TEXT, -- gesetzt = externer Link (GitHub/MakerWorld/...), inhalt dann NULL
|
||||
art TEXT NOT NULL, -- 'cover_foto'|'stl'|'3mf'|'foto'|'link'|'sonstiges'
|
||||
hochgeladen_am DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
hochgeladen_von INTEGER,
|
||||
FOREIGN KEY (projekt_id) REFERENCES projekt(id) ON DELETE CASCADE,
|
||||
|
||||
33
migrations/2026-05-30_profil_rolle.py
Normal file
33
migrations/2026-05-30_profil_rolle.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Migration 2026-05-30: profil.rolle einfuehren.
|
||||
|
||||
Rollen-Modell fuer Power-User (fortgeschrittene SuS oder Herb selbst):
|
||||
- 'schueler' (Default): aktueller Stand
|
||||
- 'power': darf Drucker/Software verwalten, Slots fuer andere anlegen,
|
||||
mehrere Demo-Projekte fuehren (direkt freigegeben), KEINE SuS-Verwaltung
|
||||
und KEINE Projekt-Freigaben. Kein Tier-Zwang, kein Check-In.
|
||||
|
||||
Idempotent. Bestehende Profile bleiben 'schueler'.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from database import get_db
|
||||
|
||||
|
||||
def main():
|
||||
conn = get_db()
|
||||
spalten = {row["name"] for row in conn.execute("PRAGMA table_info(profil)")}
|
||||
if "rolle" in spalten:
|
||||
conn.close()
|
||||
print("[migration] profil.rolle existiert bereits.")
|
||||
return
|
||||
conn.execute("ALTER TABLE profil ADD COLUMN rolle TEXT DEFAULT 'schueler'")
|
||||
conn.execute("UPDATE profil SET rolle = 'schueler' WHERE rolle IS NULL")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("[migration] profil.rolle ergaenzt (alle bestehenden Profile = 'schueler').")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
32
migrations/2026-05-30_projekt_datei_extern_url.py
Normal file
32
migrations/2026-05-30_projekt_datei_extern_url.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Migration 2026-05-30: projekt_datei.extern_url einfuehren.
|
||||
|
||||
Erlaubt externe Links als "Datei" (GitHub-Repo, MakerWorld, Printables,
|
||||
gehostete grosse Druckdateien) statt alles als BLOB in die DB zu pressen.
|
||||
|
||||
- extern_url gesetzt -> inhalt BLOB ist NULL, art = 'link'
|
||||
- extern_url NULL -> klassischer Upload (BLOB)
|
||||
|
||||
Idempotent.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from database import get_db
|
||||
|
||||
|
||||
def main():
|
||||
conn = get_db()
|
||||
spalten = {row["name"] for row in conn.execute("PRAGMA table_info(projekt_datei)")}
|
||||
if "extern_url" in spalten:
|
||||
conn.close()
|
||||
print("[migration] projekt_datei.extern_url existiert bereits.")
|
||||
return
|
||||
conn.execute("ALTER TABLE projekt_datei ADD COLUMN extern_url TEXT")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("[migration] projekt_datei.extern_url ergaenzt.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -11,7 +11,7 @@ import secrets
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, session, flash, jsonify, abort
|
||||
|
||||
from database import get_db
|
||||
from services.auth import pin_hashen, admin_passwort_pruefen, admin_required
|
||||
from services.auth import pin_hashen, admin_passwort_pruefen, admin_required, power_oder_admin
|
||||
from tiere import TIERE, tier_emoji, tier_name
|
||||
|
||||
bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||
@@ -69,10 +69,10 @@ def profile_liste():
|
||||
conn = get_db()
|
||||
profile = conn.execute(
|
||||
"SELECT p.id, p.vorname, p.tier, p.aktiv, p.erstellt_am, "
|
||||
" p.ag_id, p.reservieren_freigeschaltet, ag.name AS ag_name "
|
||||
" p.ag_id, p.reservieren_freigeschaltet, p.rolle, ag.name AS ag_name "
|
||||
"FROM profil p "
|
||||
"LEFT JOIN ag_mitgliedschaft ag ON p.ag_id = ag.id "
|
||||
"ORDER BY p.aktiv DESC, p.vorname COLLATE NOCASE"
|
||||
"ORDER BY p.aktiv DESC, p.rolle DESC, p.vorname COLLATE NOCASE"
|
||||
).fetchall()
|
||||
ags = conn.execute("SELECT id, name FROM ag_mitgliedschaft WHERE status = 'aktiv'").fetchall()
|
||||
conn.close()
|
||||
@@ -91,9 +91,17 @@ def profil_neu():
|
||||
vorname = (request.form.get("vorname") or "").strip()
|
||||
tier = (request.form.get("tier") or "").strip()
|
||||
ag_id = (request.form.get("ag_id") or "").strip()
|
||||
rolle = (request.form.get("rolle") or "schueler").strip()
|
||||
if rolle not in ("schueler", "power"):
|
||||
rolle = "schueler"
|
||||
|
||||
# Power-User brauchen kein Tier (Login zeigt 🔧). Platzhalter-Slug,
|
||||
# damit der NOT-NULL- + UNIQUE-Constraint erfuellt ist.
|
||||
if rolle == "power" and not tier:
|
||||
tier = "power"
|
||||
|
||||
if not (vorname and tier and ag_id):
|
||||
flash("Bitte Vorname, Tier und AG angeben.", "error")
|
||||
flash("Bitte Vorname, Tier (bei SuS) und AG angeben.", "error")
|
||||
return redirect(url_for("admin.profile_liste"))
|
||||
|
||||
# PIN generieren: 4-stellig, mit fuehrenden Nullen
|
||||
@@ -103,9 +111,9 @@ def profil_neu():
|
||||
conn = get_db()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO profil (vorname, tier, pin_hash, ag_id, aktiv) "
|
||||
"VALUES (?, ?, ?, ?, 1)",
|
||||
(vorname, tier, pin_hash, ag_id)
|
||||
"INSERT INTO profil (vorname, tier, pin_hash, ag_id, aktiv, rolle) "
|
||||
"VALUES (?, ?, ?, ?, 1, ?)",
|
||||
(vorname, tier, pin_hash, ag_id, rolle)
|
||||
)
|
||||
conn.commit()
|
||||
neue_id = cur.lastrowid
|
||||
@@ -195,8 +203,13 @@ def profil_pin_reset(profil_id):
|
||||
# Inline-Edit (Etappe 1)
|
||||
# ===========================================================================
|
||||
|
||||
# Tabellen, die Power-User (ohne Admin-Session) inline editieren duerfen.
|
||||
# Lektion + Einstellung bleiben Lehrer-Sache.
|
||||
POWER_INLINE_TABELLEN = {"drucker", "software_seite"}
|
||||
|
||||
|
||||
@bp.route("/inline-edit", methods=["POST"])
|
||||
@admin_required
|
||||
@power_oder_admin
|
||||
def inline_edit():
|
||||
"""Universal-Endpunkt fuer Inline-Edit. Prueft Whitelist + loggt Aenderung."""
|
||||
tabelle = (request.form.get("tabelle") or "").strip()
|
||||
@@ -207,6 +220,9 @@ def inline_edit():
|
||||
# Whitelist-Pruefung
|
||||
if tabelle not in ERLAUBTE_INLINE_TABELLEN:
|
||||
return jsonify(ok=False, error=f"Tabelle '{tabelle}' nicht erlaubt"), 400
|
||||
# Power-User (nicht Admin) duerfen nur Drucker + Software inline editieren
|
||||
if not session.get("admin") and tabelle not in POWER_INLINE_TABELLEN:
|
||||
return jsonify(ok=False, error="Dafuer fehlen dir die Rechte"), 403
|
||||
if feld not in ERLAUBTE_INLINE_TABELLEN[tabelle]:
|
||||
return jsonify(ok=False, error=f"Feld '{feld}' nicht erlaubt"), 400
|
||||
try:
|
||||
@@ -263,7 +279,7 @@ def profile_karten():
|
||||
# ===========================================================================
|
||||
|
||||
@bp.route("/slots")
|
||||
@admin_required
|
||||
@power_oder_admin
|
||||
def slot_uebersicht():
|
||||
"""Uebersicht aller bevorstehenden + heute aktiven Slots."""
|
||||
from datetime import datetime, timedelta
|
||||
@@ -287,7 +303,7 @@ def slot_uebersicht():
|
||||
|
||||
|
||||
@bp.route("/slot/neu", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
@power_oder_admin
|
||||
def slot_neu_lehrer():
|
||||
"""Lehrer reserviert im Namen eines SuS — ignoriert das Freischalt-Flag."""
|
||||
from datetime import datetime, timedelta
|
||||
@@ -389,7 +405,7 @@ def slot_neu_lehrer():
|
||||
|
||||
|
||||
@bp.route("/slot/<int:slot_id>/loeschen", methods=["POST"])
|
||||
@admin_required
|
||||
@power_oder_admin
|
||||
def slot_loeschen(slot_id):
|
||||
"""Slot hart loeschen (Lehrer-Override). Ein 'stornieren'-Status haetten
|
||||
wir auch, aber Lehrer-Aufraeumarbeiten sind oft endgueltig.
|
||||
@@ -467,7 +483,7 @@ def _software_clean_form(form):
|
||||
|
||||
|
||||
@bp.route("/software")
|
||||
@admin_required
|
||||
@power_oder_admin
|
||||
def software_liste():
|
||||
"""Verwaltung der Software-Karten — Liste + Anlegen-Formular auf einer Seite."""
|
||||
conn = get_db()
|
||||
@@ -481,7 +497,7 @@ def software_liste():
|
||||
|
||||
|
||||
@bp.route("/software/neu", methods=["POST"])
|
||||
@admin_required
|
||||
@power_oder_admin
|
||||
def software_neu():
|
||||
try:
|
||||
daten = _software_clean_form(request.form)
|
||||
@@ -516,7 +532,7 @@ def software_neu():
|
||||
|
||||
|
||||
@bp.route("/software/<int:karte_id>/bearbeiten", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
@power_oder_admin
|
||||
def software_bearbeiten(karte_id):
|
||||
"""Bearbeiten-Seite fuer eine Software-Karte. Gleiches Form-Layout wie /neu,
|
||||
aber alle Felder vorbefuellt. Slug kann hier NICHT geaendert werden (waere
|
||||
@@ -571,7 +587,7 @@ def software_bearbeiten(karte_id):
|
||||
|
||||
|
||||
@bp.route("/software/<int:karte_id>/aktiv", methods=["POST"])
|
||||
@admin_required
|
||||
@power_oder_admin
|
||||
def software_aktiv_toggle(karte_id):
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
@@ -584,7 +600,7 @@ def software_aktiv_toggle(karte_id):
|
||||
|
||||
|
||||
@bp.route("/software/<int:karte_id>/loeschen", methods=["POST"])
|
||||
@admin_required
|
||||
@power_oder_admin
|
||||
def software_loeschen(karte_id):
|
||||
"""Hart loeschen — wir haben keine FK-Beziehungen zu Software-Karten, das ist sicher."""
|
||||
conn = get_db()
|
||||
@@ -745,7 +761,7 @@ def projekt_admin_loeschen(projekt_id):
|
||||
# ===========================================================================
|
||||
|
||||
@bp.route("/software/<int:karte_id>/bild", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
@power_oder_admin
|
||||
def software_bild(karte_id):
|
||||
"""Bild fuer eine Software-Karte hochladen (Paste + Crop, base64 ueber JSON).
|
||||
|
||||
@@ -795,7 +811,7 @@ def software_bild(karte_id):
|
||||
|
||||
|
||||
@bp.route("/software/<int:karte_id>/bild/loeschen", methods=["POST"])
|
||||
@admin_required
|
||||
@power_oder_admin
|
||||
def software_bild_loeschen(karte_id):
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
@@ -867,9 +883,12 @@ def admin_lektionen():
|
||||
|
||||
|
||||
@bp.route("/drucker")
|
||||
@admin_required
|
||||
@power_oder_admin
|
||||
def admin_drucker():
|
||||
"""Drucker-Verwaltung: Status setzen, Freigabe-Toggle, Links pflegen."""
|
||||
"""Drucker-Verwaltung: Status setzen, Freigabe-Toggle, Links pflegen.
|
||||
|
||||
Power-User (fortgeschrittene SuS / Herb) duerfen das mitverwalten.
|
||||
"""
|
||||
from services.drucker_links import drucker_links
|
||||
conn = get_db()
|
||||
rows = conn.execute(
|
||||
@@ -901,7 +920,7 @@ DRUCKER_STATUS_OPTIONEN = (
|
||||
|
||||
|
||||
@bp.route("/drucker/<int:drucker_id>/status", methods=["POST"])
|
||||
@admin_required
|
||||
@power_oder_admin
|
||||
def drucker_status_setzen(drucker_id):
|
||||
"""Lehrer setzt den Status eines Druckers manuell."""
|
||||
from datetime import datetime
|
||||
@@ -940,7 +959,7 @@ def drucker_status_setzen(drucker_id):
|
||||
|
||||
|
||||
@bp.route("/drucker/<int:drucker_id>/freigabe", methods=["POST"])
|
||||
@admin_required
|
||||
@power_oder_admin
|
||||
def drucker_freigabe_toggle(drucker_id):
|
||||
"""Drucker fuer Reservierung freigeben oder sperren (mit optionalem Grund)."""
|
||||
neuer_wert = 1 if request.form.get("freigeben") == "1" else 0
|
||||
|
||||
@@ -39,8 +39,8 @@ bp = Blueprint("profil", __name__)
|
||||
def login():
|
||||
conn = get_db()
|
||||
profile = conn.execute(
|
||||
"SELECT id, vorname, tier FROM profil WHERE aktiv = 1 "
|
||||
"ORDER BY vorname COLLATE NOCASE, tier"
|
||||
"SELECT id, vorname, tier, rolle FROM profil WHERE aktiv = 1 "
|
||||
"ORDER BY rolle DESC, vorname COLLATE NOCASE, tier"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return render_template("login.html", profile=profile)
|
||||
@@ -50,7 +50,7 @@ def login():
|
||||
def login_pin(profil_id):
|
||||
conn = get_db()
|
||||
profil = conn.execute(
|
||||
"SELECT id, vorname, tier, pin_hash, aktiv FROM profil WHERE id = ?",
|
||||
"SELECT id, vorname, tier, pin_hash, aktiv, rolle FROM profil WHERE id = ?",
|
||||
(profil_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
@@ -77,6 +77,7 @@ def login_pin(profil_id):
|
||||
session["profil_id"] = profil["id"]
|
||||
session["vorname"] = profil["vorname"]
|
||||
session["tier"] = profil["tier"]
|
||||
session["rolle"] = profil["rolle"] or "schueler"
|
||||
return redirect(url_for("profil.cockpit"))
|
||||
else:
|
||||
fehlversuch_loggen(profil_id)
|
||||
@@ -681,8 +682,9 @@ def projekt_werkstatt_speichern(projekt_id):
|
||||
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
|
||||
|
||||
|
||||
# Max-Upload-Groesse (30 MB) — Bambu-3MF-Files sind selten groesser
|
||||
MAX_UPLOAD_BYTES = 30 * 1024 * 1024
|
||||
# Max-Upload-Groesse (60 MB) — Bambu-3MF mit mehreren Platten + Thumbnails
|
||||
# werden schnell gross. Wer noch groesser braucht, nutzt einen externen Link.
|
||||
MAX_UPLOAD_BYTES = 60 * 1024 * 1024
|
||||
|
||||
# Mapping Mime -> art (siehe Schema: art TEXT 'cover_foto'|'stl'|'3mf'|'foto'|'sonstiges')
|
||||
def _datei_art_aus_dateiname(dateiname: str, mime: str) -> str:
|
||||
@@ -741,6 +743,41 @@ def projekt_datei_upload(projekt_id):
|
||||
return jsonify(ok=True, id=neue_id, art=art, groesse=len(blob))
|
||||
|
||||
|
||||
@bp.route("/cockpit/projekt/<int:projekt_id>/link", methods=["POST"])
|
||||
@login_required
|
||||
def projekt_link_hinzufuegen(projekt_id):
|
||||
"""Externen Link als 'Datei' hinzufuegen (GitHub, MakerWorld, gehostete 3MF, ...)."""
|
||||
p = _eigenes_projekt_holen(projekt_id)
|
||||
if not p:
|
||||
flash("Projekt nicht gefunden.", "error")
|
||||
return redirect(url_for("profil.cockpit"))
|
||||
if p["status_lebenszyklus"] != "in_arbeit":
|
||||
flash("Abgeschlossene Projekte koennen keine Links mehr aufnehmen.", "error")
|
||||
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
|
||||
|
||||
name = (request.form.get("dateiname") or "").strip()
|
||||
url = (request.form.get("extern_url") or "").strip()
|
||||
if not url:
|
||||
flash("Bitte eine URL angeben.", "error")
|
||||
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
|
||||
if not (url.startswith("http://") or url.startswith("https://")):
|
||||
flash("Der Link muss mit http:// oder https:// beginnen.", "error")
|
||||
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
|
||||
if not name:
|
||||
name = url # Fallback: URL als Anzeigename
|
||||
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO projekt_datei (projekt_id, dateiname, extern_url, art, hochgeladen_von) "
|
||||
"VALUES (?, ?, ?, 'link', ?)",
|
||||
(projekt_id, name, url, session["profil_id"])
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
flash("Link hinzugefuegt.", "success")
|
||||
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
|
||||
|
||||
|
||||
@bp.route("/cockpit/projekt/<int:projekt_id>/datei/<int:datei_id>/loeschen", methods=["POST"])
|
||||
@login_required
|
||||
def projekt_datei_loeschen(projekt_id, datei_id):
|
||||
@@ -845,6 +882,10 @@ def projekt_abschluss(projekt_id):
|
||||
if ausfuehrung and ausfuehrung not in EVAL_QUALITAET:
|
||||
ausfuehrung = None
|
||||
|
||||
# Power-User-Projekte (Demos) gehen direkt in den Katalog, kein Lehrer-Review.
|
||||
if session.get("rolle") == "power":
|
||||
neuer_freigabe_status = "freigegeben"
|
||||
else:
|
||||
neuer_freigabe_status = freigabe_nach_eval(gesamt)
|
||||
|
||||
conn = get_db()
|
||||
|
||||
162
scripts/seed_demo_parol6.py
Normal file
162
scripts/seed_demo_parol6.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""Seed: Power-Profil 'Herb' + PAROL6-Demo-Projekt.
|
||||
|
||||
Legt (idempotent) an:
|
||||
1. Ein Power-User-Profil 'Herb' (rolle='power', kein Tier). PIN wird beim
|
||||
ersten Anlegen generiert + ausgegeben; bei spaeteren Laeufen unveraendert.
|
||||
2. Das Demo-Projekt 'PAROL6 — 6-Achsen-Roboterarm':
|
||||
- Macher: Herb (Power)
|
||||
- Drucker: P1S #1
|
||||
- Status: abgeschlossen + freigegeben (Power -> direkt im Katalog, als Vorlage waehlbar)
|
||||
- Cover: aus der 3MF extrahiertes Render (thumbnail_middle.png)
|
||||
- Dateien: GitHub-Repo (Link) + Bauanleitung-PDF (BLOB)
|
||||
- Die 3MF selbst wird NICHT gespeichert (nur verlinkt via GitHub-STLs).
|
||||
|
||||
Aufruf:
|
||||
python scripts/seed_demo_parol6.py <3mf-pfad> <pdf-pfad>
|
||||
|
||||
Das 3MF wird nur zum Cover-Extrahieren gelesen, nicht als BLOB abgelegt.
|
||||
"""
|
||||
import sys
|
||||
import zipfile
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from database import get_db
|
||||
from services.auth import pin_hashen
|
||||
|
||||
GITHUB_URL = "https://github.com/PCrnjak/PAROL6-Desktop-robot-arm"
|
||||
PROJEKT_TITEL = "PAROL6 — 6-Achsen-Roboterarm"
|
||||
AG_ID = "lessing-3d-ag"
|
||||
|
||||
BESCHREIBUNG = """Ein **6-Achsen-Roboterarm zum Selberbauen** — Open Source, komplett 3D-druckbar.
|
||||
|
||||
Das **PAROL6** ist ein Roboterarm fuer den Schreibtisch mit 6 Bewegungsachsen — so wie die
|
||||
grossen Industrie-Roboter, nur klein. Entwickelt von Petar Crnjak, Lizenz GPL-3.0 (frei nutzbar).
|
||||
|
||||
## Was dazugehoert
|
||||
- **3D-Druck:** alle Teile sind druckbar (hier vorbereitet fuer den P1S). Die STLs liegen im GitHub-Repo.
|
||||
- **Elektronik:** Schrittmotoren + eine eigene Steuerplatine (muss bestellt werden).
|
||||
- **Steuerung:** ueber eine Python-API, optional mit ROS2/MoveIt simulierbar.
|
||||
|
||||
## Schwierigkeit: anspruchsvoll
|
||||
Das ist ein **Langzeit-Projekt**. Drucken ist nur der erste Schritt — danach kommen Zusammenbau,
|
||||
Elektronik und Programmierung. Nichts fuer einen Nachmittag, aber ein tolles Ziel, wenn du tiefer
|
||||
einsteigen willst.
|
||||
|
||||
## Quellen
|
||||
- **Projektseite (GitHub):** alle Dateien, Stueckliste, Doku — siehe Link unten.
|
||||
- **Bauanleitung:** das angehaengte PDF (81 Seiten)."""
|
||||
|
||||
COVER_KANDIDATEN = [
|
||||
"Auxiliaries/.thumbnails/thumbnail_middle.png",
|
||||
"Metadata/top_1.png",
|
||||
"Metadata/plate_1.png",
|
||||
]
|
||||
|
||||
|
||||
def hole_cover(dreimf_pfad: str):
|
||||
"""Extrahiert das beste Render aus der 3MF. Returns (blob, mime) oder (None, None)."""
|
||||
try:
|
||||
with zipfile.ZipFile(dreimf_pfad) as z:
|
||||
namen = set(z.namelist())
|
||||
for kandidat in COVER_KANDIDATEN:
|
||||
if kandidat in namen:
|
||||
return (z.read(kandidat), "image/png")
|
||||
except Exception as e:
|
||||
print(f" [warn] Cover-Extraktion fehlgeschlagen: {e}")
|
||||
return (None, None)
|
||||
|
||||
|
||||
def hole_pdf(pdf_pfad: str):
|
||||
"""Liest das PDF als BLOB. Returns (blob, dateiname) oder (None, None)."""
|
||||
try:
|
||||
with open(pdf_pfad, "rb") as f:
|
||||
return (f.read(), Path(pdf_pfad).name)
|
||||
except Exception as e:
|
||||
print(f" [warn] PDF-Lesen fehlgeschlagen: {e}")
|
||||
return (None, None)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print("Aufruf: python scripts/seed_demo_parol6.py <3mf-pfad> <pdf-pfad>")
|
||||
sys.exit(1)
|
||||
dreimf_pfad, pdf_pfad = sys.argv[1], sys.argv[2]
|
||||
|
||||
conn = get_db()
|
||||
|
||||
# --- 1. Power-Profil 'Herb' ---
|
||||
herb = conn.execute(
|
||||
"SELECT id, vorname, rolle FROM profil WHERE vorname = ? AND rolle = 'power'",
|
||||
("Herb",)
|
||||
).fetchone()
|
||||
if herb:
|
||||
herb_id = herb["id"]
|
||||
print(f"[seed] Power-Profil 'Herb' existiert bereits (id={herb_id}).")
|
||||
else:
|
||||
pin = f"{secrets.randbelow(10000):04d}"
|
||||
cur = conn.execute(
|
||||
"INSERT INTO profil (vorname, tier, pin_hash, ag_id, aktiv, rolle) "
|
||||
"VALUES (?, 'power', ?, ?, 1, 'power')",
|
||||
("Herb", pin_hashen(pin), AG_ID)
|
||||
)
|
||||
conn.commit()
|
||||
herb_id = cur.lastrowid
|
||||
print(f"[seed] Power-Profil 'Herb' angelegt (id={herb_id}). PIN: {pin} <-- AUFSCHREIBEN!")
|
||||
|
||||
# --- 2. PAROL6-Projekt ---
|
||||
schon = conn.execute(
|
||||
"SELECT id FROM projekt WHERE profil_id = ? AND titel = ?",
|
||||
(herb_id, PROJEKT_TITEL)
|
||||
).fetchone()
|
||||
if schon:
|
||||
print(f"[seed] Projekt '{PROJEKT_TITEL}' existiert bereits (id={schon['id']}) — nichts zu tun.")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
# Drucker P1S #1
|
||||
drucker = conn.execute("SELECT id FROM drucker WHERE name = 'P1S #1'").fetchone()
|
||||
drucker_id = drucker["id"] if drucker else None
|
||||
|
||||
cover_blob, cover_mime = hole_cover(dreimf_pfad)
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO projekt
|
||||
(profil_id, titel, beschreibung_md, status_lebenszyklus, abgeschlossen_am,
|
||||
selbsteinschaetzung, selbsteinschaetzung_planung, selbsteinschaetzung_ausfuehrung,
|
||||
was_gelernt, freigabe_status, freigegeben_am, sichtbar_in_ag,
|
||||
drucker_id_used, cover_blob, cover_mime)
|
||||
VALUES (?, ?, ?, 'abgeschlossen', CURRENT_TIMESTAMP,
|
||||
'zufrieden', 'gut', 'gut',
|
||||
'Ein anspruchsvolles Open-Source-Projekt als Inspiration fuer Fortgeschrittene.',
|
||||
'freigegeben', CURRENT_TIMESTAMP, 1,
|
||||
?, ?, ?)""",
|
||||
(herb_id, PROJEKT_TITEL, BESCHREIBUNG, drucker_id, cover_blob, cover_mime)
|
||||
)
|
||||
projekt_id = cur.lastrowid
|
||||
|
||||
# GitHub-Link als "Datei"
|
||||
conn.execute(
|
||||
"INSERT INTO projekt_datei (projekt_id, dateiname, extern_url, art, hochgeladen_von) "
|
||||
"VALUES (?, ?, ?, 'link', ?)",
|
||||
(projekt_id, "PAROL6-Projekt auf GitHub (STLs + Doku)", GITHUB_URL, herb_id)
|
||||
)
|
||||
|
||||
# PDF als BLOB
|
||||
pdf_blob, pdf_name = hole_pdf(pdf_pfad)
|
||||
if pdf_blob:
|
||||
conn.execute(
|
||||
"INSERT INTO projekt_datei (projekt_id, dateiname, mime_type, groesse_bytes, inhalt, art, hochgeladen_von) "
|
||||
"VALUES (?, ?, 'application/pdf', ?, ?, 'sonstiges', ?)",
|
||||
(projekt_id, "Bauanleitung (81 Seiten, PDF)", len(pdf_blob), pdf_blob, herb_id)
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"[seed] Projekt '{PROJEKT_TITEL}' angelegt (id={projekt_id}).")
|
||||
print(f" Cover: {'ja' if cover_blob else 'NEIN'} | PDF: {'ja ('+str(len(pdf_blob)//1024)+' KB)' if pdf_blob else 'NEIN'} | GitHub-Link: ja | Drucker: P1S #1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -123,6 +123,30 @@ def admin_required(f):
|
||||
return wrapper
|
||||
|
||||
|
||||
def ist_power() -> bool:
|
||||
"""True, wenn der eingeloggte Nutzer ein Power-User-Profil ist."""
|
||||
return session.get("rolle") == "power"
|
||||
|
||||
|
||||
def power_oder_admin(f):
|
||||
"""Erlaubt Admin-Session ODER eingeloggtes Power-User-Profil.
|
||||
|
||||
Fuer Bereiche, die fortgeschrittene SuS mitverwalten duerfen
|
||||
(Drucker, Software-Katalog, Slots fuer andere) — NICHT fuer
|
||||
SuS-Verwaltung oder Projekt-Freigaben.
|
||||
"""
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
if session.get("admin") or ist_power():
|
||||
return f(*args, **kwargs)
|
||||
# Power-Profil eingeloggt aber falsche Rolle, oder gar nicht eingeloggt
|
||||
if session.get("profil_id"):
|
||||
flash("Dafuer fehlen dir die Rechte.", "error")
|
||||
return redirect(url_for("profil.cockpit"))
|
||||
return redirect(url_for("admin.admin_login"))
|
||||
return wrapper
|
||||
|
||||
|
||||
def onboarding_l1_fertig(f):
|
||||
"""V2: blockiert Slot-Routes, bis L1 (Sicherheits-Lektion) abgeschlossen ist."""
|
||||
@wraps(f)
|
||||
|
||||
@@ -49,13 +49,23 @@ def letztes_projekt(conn, profil_id: int):
|
||||
).fetchone()
|
||||
|
||||
|
||||
def ist_power_profil(conn, profil_id: int) -> bool:
|
||||
"""True, wenn das Profil die Rolle 'power' hat."""
|
||||
row = conn.execute("SELECT rolle FROM profil WHERE id = ?", (profil_id,)).fetchone()
|
||||
return bool(row and row["rolle"] == "power")
|
||||
|
||||
|
||||
def darf_neues_anlegen(conn, profil_id: int):
|
||||
"""Prueft, ob der SuS aktuell ein neues Projekt anlegen darf.
|
||||
"""Prueft, ob der Nutzer aktuell ein neues Projekt anlegen darf.
|
||||
|
||||
Power-User duerfen IMMER (mehrere Demo-Projekte parallel). Fuer normale
|
||||
SuS gilt die 1-Projekt-Regel + Freigabe-Gate.
|
||||
|
||||
Returns:
|
||||
(darf: bool, grund: str | None)
|
||||
grund ist der Sperr-Text fuer die UI, wenn darf=False.
|
||||
"""
|
||||
if ist_power_profil(conn, profil_id):
|
||||
return (True, None)
|
||||
if aktives_projekt(conn, profil_id):
|
||||
return (False, "Du hast schon ein laufendes Projekt — erst abschliessen, dann neu anfangen.")
|
||||
letztes = letztes_projekt(conn, profil_id)
|
||||
@@ -104,11 +114,11 @@ def projekt_holen(conn, projekt_id: int):
|
||||
|
||||
|
||||
def dateien_eines_projekts(conn, projekt_id: int):
|
||||
"""Alle hochgeladenen Dateien eines Projekts (ohne BLOB-Inhalt — nur Metadaten)."""
|
||||
"""Alle Dateien + Links eines Projekts (ohne BLOB-Inhalt — nur Metadaten)."""
|
||||
return conn.execute(
|
||||
"SELECT id, dateiname, mime_type, groesse_bytes, art, hochgeladen_am "
|
||||
"SELECT id, dateiname, mime_type, groesse_bytes, art, extern_url, hochgeladen_am "
|
||||
"FROM projekt_datei WHERE projekt_id = ? "
|
||||
"ORDER BY hochgeladen_am ASC",
|
||||
"ORDER BY (extern_url IS NOT NULL) DESC, hochgeladen_am ASC",
|
||||
(projekt_id,)
|
||||
).fetchall()
|
||||
|
||||
@@ -317,11 +327,14 @@ def vorlage_info(conn, vorlage_id: int):
|
||||
|
||||
|
||||
def projekt_dateien_fuer_katalog(conn, projekt_id: int):
|
||||
"""Nur die Metadaten fuer die Datei-Liste im Katalog-Detail (keine BLOBs)."""
|
||||
"""Nur die Metadaten fuer die Datei-Liste im Katalog-Detail (keine BLOBs).
|
||||
|
||||
Links (extern_url) zuerst, dann Uploads.
|
||||
"""
|
||||
return conn.execute(
|
||||
"SELECT id, dateiname, mime_type, groesse_bytes, art "
|
||||
"SELECT id, dateiname, mime_type, groesse_bytes, art, extern_url "
|
||||
"FROM projekt_datei WHERE projekt_id = ? "
|
||||
"ORDER BY art, dateiname",
|
||||
"ORDER BY (extern_url IS NOT NULL) DESC, art, dateiname",
|
||||
(projekt_id,)
|
||||
).fetchall()
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
<h1>Drucker-Verwaltung</h1>
|
||||
<div>
|
||||
<a href="{{ url_for('oeffentlich.drucker') }}" class="btn btn-secondary">SuS-Ansicht</a>
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-secondary">← Dashboard</a>
|
||||
{% if session.admin %}<a href="{{ url_for('admin.dashboard') }}" class="btn btn-secondary">← Dashboard</a>
|
||||
{% else %}<a href="{{ url_for('profil.cockpit') }}" class="btn btn-secondary">← Cockpit</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,14 +13,21 @@
|
||||
<div class="card">
|
||||
<h2>Neues Profil anlegen</h2>
|
||||
<form method="post" action="{{ url_for('admin.profil_neu') }}"
|
||||
style="display: grid; grid-template-columns: 1fr 1fr 1fr auto; gap: 0.6rem; align-items: end;">
|
||||
style="display: grid; grid-template-columns: 1.2fr 1fr 1.4fr 1fr auto; gap: 0.6rem; align-items: end;">
|
||||
<div>
|
||||
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Vorname</label>
|
||||
<input type="text" name="vorname" required style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Rolle</label>
|
||||
<select name="rolle" id="rolle-select" style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
|
||||
<option value="schueler">Schueler</option>
|
||||
<option value="power">🔧 Power-User</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="tier-feld">
|
||||
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Tier-Icon</label>
|
||||
<select name="tier" required style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
|
||||
<select name="tier" id="tier-select" style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
|
||||
<option value="">— waehlen —</option>
|
||||
{% for t in tiere %}
|
||||
<option value="{{ t.slug }}">{{ t.emoji }} {{ t.name }}</option>
|
||||
@@ -39,7 +46,25 @@
|
||||
</form>
|
||||
<p style="margin-top: 0.8rem; font-size: 0.85rem; color: var(--text-light);">
|
||||
Eine 4-stellige PIN wird automatisch erzeugt und nach dem Speichern <strong>einmal</strong> angezeigt.
|
||||
<strong>Power-User</strong> (fortgeschrittene SuS oder du selbst) brauchen kein Tier und duerfen
|
||||
Drucker + Software verwalten + Slots fuer andere anlegen + mehrere Projekte fuehren.
|
||||
</p>
|
||||
<script>
|
||||
// Tier-Auswahl ausblenden, wenn Power-User gewaehlt wird
|
||||
(function() {
|
||||
var rolle = document.getElementById('rolle-select');
|
||||
var tierFeld = document.getElementById('tier-feld');
|
||||
var tierSelect = document.getElementById('tier-select');
|
||||
function sync() {
|
||||
var istPower = rolle.value === 'power';
|
||||
tierFeld.style.opacity = istPower ? '0.4' : '1';
|
||||
tierSelect.required = !istPower;
|
||||
tierSelect.disabled = istPower;
|
||||
}
|
||||
rolle.addEventListener('change', sync);
|
||||
sync();
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<!-- Liste -->
|
||||
@@ -62,9 +87,11 @@
|
||||
<tbody>
|
||||
{% for p in profile %}
|
||||
<tr style="{% if not p.aktiv %}opacity: 0.5;{% endif %}">
|
||||
<td style="font-size: 1.5rem;">{{ tier_emoji(p.tier) }}</td>
|
||||
<td><strong>{{ p.vorname }}</strong></td>
|
||||
<td>{{ tier_name(p.tier) }}</td>
|
||||
<td style="font-size: 1.5rem;">{% if p.rolle == 'power' %}🔧{% else %}{{ tier_emoji(p.tier) }}{% endif %}</td>
|
||||
<td><strong>{{ p.vorname }}</strong>
|
||||
{% if p.rolle == 'power' %}<span class="badge badge-mittel" style="margin-left: 0.3rem;">Power</span>{% endif %}
|
||||
</td>
|
||||
<td>{% if p.rolle == 'power' %}<span style="color: var(--text-light);">—</span>{% else %}{{ tier_name(p.tier) }}{% endif %}</td>
|
||||
<td>{{ p.ag_name or p.ag_id }}</td>
|
||||
<td>{% if p.aktiv %}<span class="badge badge-anfaenger">aktiv</span>{% else %}<span class="badge">inaktiv</span>{% endif %}</td>
|
||||
<td>
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
<h1>Slot-Uebersicht</h1>
|
||||
<div>
|
||||
<a href="{{ url_for('admin.slot_neu_lehrer') }}" class="btn btn-primary">+ Neuer Slot fuer SuS</a>
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-secondary">← Dashboard</a>
|
||||
{% if session.admin %}<a href="{{ url_for('admin.dashboard') }}" class="btn btn-secondary">← Dashboard</a>
|
||||
{% else %}<a href="{{ url_for('profil.cockpit') }}" class="btn btn-secondary">← Cockpit</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
<h1>Software-Karten</h1>
|
||||
<div>
|
||||
<a href="{{ url_for('oeffentlich.software') }}" class="btn btn-secondary">Oeffentliche Seite ansehen</a>
|
||||
<a href="{{ url_for('admin.admin_logout') }}" class="btn btn-secondary">Admin-Logout</a>
|
||||
{% if session.admin %}<a href="{{ url_for('admin.admin_logout') }}" class="btn btn-secondary">Admin-Logout</a>
|
||||
{% else %}<a href="{{ url_for('profil.cockpit') }}" class="btn btn-secondary">← Cockpit</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -53,7 +53,25 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{# ====== Check-In-Modal — wenn heute noch nichts gewaehlt wurde ====== #}
|
||||
{# ====== POWER-WERKZEUGE (nur fuer Power-User) ====== #}
|
||||
{% if session.rolle == 'power' %}
|
||||
<div class="card" style="border-left: 4px solid var(--primary);">
|
||||
<h2>🔧 Power-Werkzeuge</h2>
|
||||
<p style="color: var(--text-light); font-size: 0.9rem; margin-bottom: 0.8rem;">
|
||||
Du bist Power-User — hier deine Verwaltungs-Bereiche. SuS-Profile und Projekt-Freigaben
|
||||
bleiben dem Lehrer-Admin vorbehalten.
|
||||
</p>
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||
<a href="{{ url_for('admin.admin_drucker') }}" class="btn btn-secondary">🖨️ Drucker verwalten</a>
|
||||
<a href="{{ url_for('admin.software_liste') }}" class="btn btn-secondary">🖥️ Software-Katalog</a>
|
||||
<a href="{{ url_for('admin.slot_neu_lehrer') }}" class="btn btn-secondary">🔖 Slot fuer SuS</a>
|
||||
<a href="{{ url_for('profil.projekt_neu') }}" class="btn btn-primary">+ Projekt anlegen</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ====== Check-In-Modal — wenn heute noch nichts gewaehlt wurde (nicht fuer Power) ====== #}
|
||||
{% if session.rolle != 'power' %}
|
||||
{% if not status.check_in_heute %}
|
||||
<div class="card" style="border-top: 4px solid var(--primary);">
|
||||
<h2>Was machst du heute? 🎯</h2>
|
||||
@@ -117,6 +135,7 @@
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}{# Ende: Check-In nur fuer Nicht-Power #}
|
||||
|
||||
{# ====== PROJEKT-KARTE — ganz oben, damit "Hier weitermachen" sofort sichtbar ist ====== #}
|
||||
{% if status.hat_projekte or not status.ist_neu or status.projekt.letztes %}
|
||||
|
||||
@@ -10,10 +10,17 @@
|
||||
{% if profile %}
|
||||
<div class="grid" style="grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; max-width: 900px; margin: 0 auto;">
|
||||
{% for p in profile %}
|
||||
<a href="{{ url_for('profil.login_pin', profil_id=p.id) }}" class="card profil-kachel">
|
||||
<a href="{{ url_for('profil.login_pin', profil_id=p.id) }}"
|
||||
class="card profil-kachel{% if p.rolle == 'power' %} profil-power{% endif %}">
|
||||
{% if p.rolle == 'power' %}
|
||||
<div style="font-size: 3rem;">🔧</div>
|
||||
<div style="font-weight: 600; margin-top: 0.3rem;">{{ p.vorname }}</div>
|
||||
<div style="font-size: 0.8rem; color: var(--text-light);">Power-User</div>
|
||||
{% else %}
|
||||
<div style="font-size: 3rem;">{{ tier_emoji(p.tier) }}</div>
|
||||
<div style="font-weight: 600; margin-top: 0.3rem;">{{ p.vorname }}</div>
|
||||
<div style="font-size: 0.8rem; color: var(--text-light);">{{ tier_name(p.tier) }}</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -40,5 +47,9 @@
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.12);
|
||||
}
|
||||
.profil-power {
|
||||
border-color: var(--primary);
|
||||
background: linear-gradient(180deg, #fff, #EBF5FB);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -217,22 +217,26 @@
|
||||
{% if dateien %}
|
||||
<table style="margin-top: 0.5rem;">
|
||||
<thead>
|
||||
<tr><th>Datei</th><th>Typ</th><th>Groesse</th><th></th></tr>
|
||||
<tr><th>Datei / Link</th><th>Typ</th><th>Groesse</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody id="dateien-tbody">
|
||||
{% for d in dateien %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('api.projekt_datei_download', projekt_id=p.id, datei_id=d.id) }}">{{ d.dateiname }}</a>
|
||||
{% if d.extern_url %}
|
||||
🔗 <a href="{{ d.extern_url }}" target="_blank" rel="noopener noreferrer">{{ d.dateiname }} ↗</a>
|
||||
{% else %}
|
||||
📎 <a href="{{ url_for('api.projekt_datei_download', projekt_id=p.id, datei_id=d.id) }}">{{ d.dateiname }}</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><span class="badge">{{ d.art }}</span></td>
|
||||
<td style="font-size: 0.85rem; color: var(--text-light);">
|
||||
{{ (d.groesse_bytes // 1024) if d.groesse_bytes else 0 }} KB
|
||||
{% if d.extern_url %}—{% else %}{{ (d.groesse_bytes // 1024) if d.groesse_bytes else 0 }} KB{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="{{ url_for('profil.projekt_datei_loeschen', projekt_id=p.id, datei_id=d.id) }}" style="display:inline;"
|
||||
onsubmit="return confirm('Datei {{ d.dateiname }} loeschen?')">
|
||||
<button class="btn btn-secondary" style="padding: 0.2rem 0.5rem; font-size: 0.8rem; color: var(--danger);">loeschen</button>
|
||||
onsubmit="return confirm('{{ d.dateiname }} entfernen?')">
|
||||
<button class="btn btn-secondary" style="padding: 0.2rem 0.5rem; font-size: 0.8rem; color: var(--danger);">entfernen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -241,7 +245,7 @@
|
||||
</table>
|
||||
{% else %}
|
||||
<p style="color: var(--text-light); margin-top: 0.5rem;">
|
||||
<em>Noch keine Dateien.</em>
|
||||
<em>Noch keine Dateien oder Links.</em>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
@@ -250,13 +254,29 @@
|
||||
padding: 1.5rem; text-align: center; cursor: pointer; background: #fafafa;">
|
||||
<div style="font-size: 1.1rem; color: var(--text-light);">📁 Dateien hier ablegen</div>
|
||||
<div style="font-size: 0.85rem; color: var(--text-light); margin-top: 0.3rem;">
|
||||
oder klicken — STL, 3MF, JPG, PNG · max. 30 MB pro Datei
|
||||
oder klicken — STL, 3MF, JPG, PNG · max. 60 MB pro Datei
|
||||
</div>
|
||||
<input type="file" id="datei-input" multiple
|
||||
accept=".stl,.3mf,image/jpeg,image/png,image/webp"
|
||||
style="display: none;">
|
||||
<div id="upload-status" style="margin-top: 0.5rem; font-size: 0.85rem;"></div>
|
||||
</div>
|
||||
|
||||
{# Link statt Upload #}
|
||||
<form method="post" action="{{ url_for('profil.projekt_link_hinzufuegen', projekt_id=p.id) }}"
|
||||
style="margin-top: 0.8rem; display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: flex-end;">
|
||||
<div style="flex: 1; min-width: 140px;">
|
||||
<label style="display:block; font-size: 0.8rem; color: var(--text-light);">Oder Link statt Datei — Name</label>
|
||||
<input type="text" name="dateiname" maxlength="120" placeholder="z.B. Modell auf GitHub"
|
||||
style="padding: 0.4rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
|
||||
</div>
|
||||
<div style="flex: 2; min-width: 200px;">
|
||||
<label style="display:block; font-size: 0.8rem; color: var(--text-light);">URL</label>
|
||||
<input type="url" name="extern_url" placeholder="https://..."
|
||||
style="padding: 0.4rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
|
||||
</div>
|
||||
<button class="btn btn-secondary" style="font-size: 0.85rem;">🔗 Link hinzufuegen</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -144,14 +144,20 @@
|
||||
|
||||
{% if p.dateien %}
|
||||
<div class="kat-dateien">
|
||||
<strong style="font-size: 0.85rem;">Dateien:</strong>
|
||||
<strong style="font-size: 0.85rem;">Dateien & Links:</strong>
|
||||
{% for d in p.dateien %}
|
||||
<a href="{{ url_for('api.projekt_datei_download', projekt_id=p.id, datei_id=d.id) }}">
|
||||
{% if d.extern_url %}
|
||||
<a href="{{ d.extern_url }}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation();">
|
||||
🔗 {{ d.dateiname }} ↗
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('api.projekt_datei_download', projekt_id=p.id, datei_id=d.id) }}" onclick="event.stopPropagation();">
|
||||
📎 {{ d.dateiname }}
|
||||
<span style="color: var(--text-light); font-size: 0.75rem;">
|
||||
({{ d.art }}, {{ (d.groesse_bytes // 1024) if d.groesse_bytes else 0 }} KB)
|
||||
</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user