Subdomain-basiert via Middleware — kein URL-Prefix. apps/core/middleware.py::TenantMiddleware:
verein.cluhu.ch → Subdomain verein. Root-Domain (cluhu.ch, www.*) → kein Tenant → Marketing-Landingpage. Dev unterstützt IP-Subdomains (verein.192.168.178.141:7200).ClubDomain.host (voller Hostname, unique), Fallback Club.slug. ClubDomain ist bewusst kein TenantModel — sie muss global lookupbar sein, bevor ein Tenant feststeht.request.club setzen + Thread-Local → TenantManager filtert ab jetzt jede Query automatisch auf den Club.Person in genau diesem Club haben, sonst 403. Superuser (Plattform-Owner) darf überall rein.⚠️ Dev-Falle: curl gegen eine Subdomain braucht --resolve, sonst greift die Subdomain-Erkennung nicht.
⚠️ Aussperr-Falle: Die Middleware prüft user.person.club_id. Person.user ist SET_NULL — wer die Person eines Users löscht, sperrt den User aus dem Mandanten aus. Deshalb schonen alle Bulk-Delete-Pfade Login-Personen (.filter(user__isnull=True)).
| Regel | Inhalt |
|---|---|
| S1 | TenantModel + TenantManager: automatische club_id-Filterung aller Queries. Model.unscoped nur für Migrations/Plattform-Admin, nie in App-Views. |
| S2 | Same-Club-Check bei polymorphen Referenzen ohne FK (TenantPolymorphicValidator). |
| S3 | Session-Isolation in der Middleware + SESSION_COOKIE_DOMAIN = None (Cookie gilt pro Subdomain, kein Cross-Tenant-Sharing). |
| S4 | Datei-Proxy statt /media/: /media/* ist hart auf 403 gemappt. Einziger Weg: /files/<club_id>/<path> mit Login-, Club- und Realpath-Check (Path-Traversal-Schutz). |
| S5 | Globale Tabellen (object_type, auth_permission_action, asset_filetype_icon, addon_catalog) nur als Lookup, nie als Query-Einstieg. |
Root-Domain (kein Tenant):
/ Landingpage (Addon-Zahlen live aus dem Katalog gezählt, damit Marketing nicht driftet)/addons/ öffentlicher Addon-Schauraum/onboarding/… Vereins-Registrierung/admin/ Plattform-Admin (Django-Admin, nur Plattform-Owner — Mandanten-Owner haben is_staff=False und sehen ihn nie)Tenant-Subdomain, mit Login (CluCore):
/clucore/ Login/Dashboard (der Login ist inline — es gibt bewusst keinen separaten /login/-Pfad)/clucore/mitglieder/, /clucore/personen/, /clucore/gruppen/, /clucore/firmen/, /clucore/rollen/, /clucore/kalender/, /clucore/news/, /clucore/addons/ (Store), /clucore/tabellen/, /clucore/berechtigungen/, /clucore/felder//clucore/org/* sind seit v0.17.7 permanente 301-Redirects/api/org/, /api/columns/, /api/auth/, /api/events/Tenant-Subdomain, ohne Login:
/kalender/antwort/<token>/ Magic-Link Zu-/Absage/kalender/ics/<token>.ics persönlicher ICS-Feedapps/authorization/permissions.py (inkl. Audit-Log AuthMutationAuditLog).has_permission); sonst allow-Suche über Rollen-Permissions. Explizites deny-Override ist noch nicht umgesetzt.owner/admin/member werden pro Club geseedet (beim Onboarding), nicht global — jeder Verein kann Rollen eigenständig anpassen.check_field_policy): keine Policy = erlaubt (Default-open, bewusst pragmatisch).AuthRoleAssignment.person ist CASCADE — Person-Delete löscht die Rollenzuweisung mit./api/auth/-Mutationen prüfen auf API-Ebene nur @login_required — die Tenant-Middleware garantiert Club-Zugehörigkeit, aber kein Admin-Check auf Endpoint-Ebene.4-Schritt-Wizard auf der Root-Domain (/onboarding/): Registrierung (Passwort-Regeln: min. 8, Gross/Klein/Zahl/Sonderzeichen) → E-Mail-Verifikation (4-stelliger Code ODER Magic-Link, 48 h gültig) → Vereins-Setup mit Live-Slug-Prüfung (RESERVED_SLUGS: www, api, admin, cluhu, portal, …) → Auto-Setup.
Das Setup legt in einer Sequenz an: Club, ClubProfile, ClubDomain (<slug>.cluhu.ch, surface='cluweb'), Django-User (username = E-Mail, nie is_staff), Person mit primärer EmailAddress (seit v0.18.5: Personen-E-Mail ist die einzige Wahrheit), Systemrollen, Owner+Admin-Zuweisung, Medienordner. Danach Auto-Login + Willkommens-Mail + Betreiber-Benachrichtigung.
⚠️ Falle: OnboardingVerification-Records überleben ein User-Delete. Beim Komplett-Wipe eines Test-Users die Verification per E-Mail-Adresse separat löschen, sonst meldet das Onboarding „bereits registriert".
⚠️ Benannter Trade-off: Das Klartext-Passwort liegt zwischen Registrierung und Setup temporär im JSON-Feld onboarding_data und wird nach der User-Erstellung entfernt.
Dateien hängen an Custom-Field-Zellen (LinkCellValueAsset → CustomColumnCellValue), nicht direkt an Personen/Mitgliedern. Metadaten (AssetAttribute mit SHA-256-Checksum, MIME, storage_backend) sind von der Ablage getrennt — Storage später austauschbar (S3 etc.). Auslieferung nur über den S4-Datei-Proxy; dieser liegt bewusst ausserhalb von /clucore/, damit CluWeb/CluPortal ihn später mitnutzen können.
<h1> der Topbar ({% block page_title %}). Die früher doppelten Content-Überschriften (<h2>) samt Beschreibungssätzen wurden auf ~20 Seiten entfernt (~80–100 px Höhengewinn). Ausnahmen, wo die Überschrift eigene Information trägt: Übersicht („Willkommen bei …"), News-Formular, „Kommt bald"-Platzhalter, Landingpage.active_section (Vergleich mit dem MenuItem-Key). ⚠️ Jede neue Seite muss einen existierenden Menü-Key senden — der Altwert 'organisation' (Menüpunkt seit v0.14.91 abgeschafft) sorgte monatelang für fehlende Markierung bei Personen/Firmen/Gruppen (gefixt v0.17.7).org/ (v0.17.7): /clucore/personen/, /clucore/firmen/, /clucore/gruppen/, /clucore/rollen/; die alten org/-Pfade sind dauerhafte 301-Redirects (query-erhaltend).apps/core/hints.py. Ein Hinweis = ein Registry-Eintrag (Hint(key, title, text, cta, requires_addon, condition)); die Engine prüft Addon-Gate + Bedingung + Wegklick-Status und rendert eine einheitliche Kachel. Wegklicken gilt pro Benutzer (DismissedHint); ist die Bedingung erfüllt, räumt die Engine die Dismissals ab — tritt sie später erneut ein, erscheint die Kachel wieder. condition darf eine Zahl liefern (→ {n} im Text).