docs: add auth permissions implementation plan
This commit is contained in:
parent
e85a1eb5fa
commit
35312c41f5
661
docs/superpowers/plans/2026-04-27-auth-permissions.md
Normal file
661
docs/superpowers/plans/2026-04-27-auth-permissions.md
Normal file
@ -0,0 +1,661 @@
|
||||
# Auth Permissions Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Öffentliche Routen ermöglichen, Permission-basierte Kachel-Filterung einführen, und Navbar kontextabhängig gestalten.
|
||||
|
||||
**Architecture:** Drei neue Funktionen in `dependencies.py` (`check_permission`, `get_current_user_optional`, `require_permission`). `MODULES` in `main.py` bekommt `slug` und `permission`, aktive Module werden automatisch registriert. `GET /` wird öffentlich. Navbar in `base.html` zeigt User-Info conditional. Bestehende `get_current_user` und `require_admin` bleiben unverändert.
|
||||
|
||||
**Tech Stack:** FastAPI, Jinja2, SQLAlchemy, pytest, importlib
|
||||
|
||||
---
|
||||
|
||||
### Task 1: check_permission, get_current_user_optional, require_permission
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/modules/auth/dependencies.py`
|
||||
- Create: `tests/test_permissions.py`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Create `tests/test_permissions.py`:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.core.auth import create_access_token
|
||||
from app.core.database import Base, get_db
|
||||
from app.modules.auth.dependencies import (
|
||||
RequiresLoginException,
|
||||
check_permission,
|
||||
get_current_user_optional,
|
||||
require_permission,
|
||||
)
|
||||
from app.modules.auth.models import User
|
||||
from app.modules.auth.service import hash_password
|
||||
|
||||
|
||||
# --- check_permission: pure unit tests, no DB needed ---
|
||||
|
||||
def test_public_allows_anonymous():
|
||||
assert check_permission(None, ["public"]) is True
|
||||
|
||||
|
||||
def test_public_allows_logged_in():
|
||||
user = User(username="u", full_name="U", is_admin=False)
|
||||
assert check_permission(user, ["public"]) is True
|
||||
|
||||
|
||||
def test_authenticated_blocks_anonymous():
|
||||
assert check_permission(None, ["authenticated"]) is False
|
||||
|
||||
|
||||
def test_authenticated_allows_user():
|
||||
user = User(username="u", full_name="U")
|
||||
assert check_permission(user, ["authenticated"]) is True
|
||||
|
||||
|
||||
def test_admin_blocks_regular_user():
|
||||
user = User(username="u", full_name="U", is_admin=False)
|
||||
assert check_permission(user, ["admin"]) is False
|
||||
|
||||
|
||||
def test_admin_allows_admin():
|
||||
user = User(username="u", full_name="U", is_admin=True)
|
||||
assert check_permission(user, ["admin"]) is True
|
||||
|
||||
|
||||
def test_or_logic_grants_access_on_any_match():
|
||||
regular = User(username="u", full_name="U", is_admin=False)
|
||||
assert check_permission(regular, ["admin", "authenticated"]) is True
|
||||
|
||||
|
||||
def test_empty_permissions_blocks_all():
|
||||
user = User(username="u", full_name="U")
|
||||
assert check_permission(user, []) is False
|
||||
|
||||
|
||||
# --- get_current_user_optional and require_permission: integration tests ---
|
||||
|
||||
@pytest.fixture
|
||||
def db():
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
yield session
|
||||
session.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_app(db):
|
||||
app = FastAPI()
|
||||
|
||||
@app.exception_handler(RequiresLoginException)
|
||||
async def handle(request, exc):
|
||||
return RedirectResponse(url="/auth/login", status_code=307)
|
||||
|
||||
@app.get("/public")
|
||||
async def public_route(user=Depends(get_current_user_optional)):
|
||||
return {"user": user.username if user else None}
|
||||
|
||||
@app.get("/members")
|
||||
async def members_route(user=Depends(require_permission(["authenticated"]))):
|
||||
return {"user": user.username if user else None}
|
||||
|
||||
@app.get("/admin")
|
||||
async def admin_route(user=Depends(require_permission(["admin"]))):
|
||||
return {"user": user.username if user else None}
|
||||
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(test_app):
|
||||
return TestClient(test_app, follow_redirects=False)
|
||||
|
||||
|
||||
def _cookie(username: str, is_admin: bool) -> dict:
|
||||
return {"access_token": create_access_token(username=username, is_admin=is_admin)}
|
||||
|
||||
|
||||
def test_optional_returns_none_without_cookie(client):
|
||||
response = client.get("/public")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["user"] is None
|
||||
|
||||
|
||||
def test_optional_returns_user_with_valid_cookie(client, db):
|
||||
db.add(User(username="alice", full_name="Alice", pw_hash=hash_password("pw")))
|
||||
db.commit()
|
||||
response = client.get("/public", cookies=_cookie("alice", False))
|
||||
assert response.status_code == 200
|
||||
assert response.json()["user"] == "alice"
|
||||
|
||||
|
||||
def test_require_authenticated_redirects_anonymous(client):
|
||||
response = client.get("/members")
|
||||
assert response.status_code == 307
|
||||
assert "/auth/login" in response.headers["location"]
|
||||
|
||||
|
||||
def test_require_authenticated_allows_logged_in(client, db):
|
||||
db.add(User(username="bob", full_name="Bob", pw_hash=hash_password("pw")))
|
||||
db.commit()
|
||||
response = client.get("/members", cookies=_cookie("bob", False))
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_require_admin_redirects_regular_user(client, db):
|
||||
db.add(User(username="reg", full_name="Reg", is_admin=False, pw_hash=hash_password("pw")))
|
||||
db.commit()
|
||||
response = client.get("/admin", cookies=_cookie("reg", False))
|
||||
assert response.status_code == 307
|
||||
|
||||
|
||||
def test_require_admin_allows_admin(client, db):
|
||||
db.add(User(username="adm", full_name="Adm", is_admin=True, pw_hash=hash_password("pw")))
|
||||
db.commit()
|
||||
response = client.get("/admin", cookies=_cookie("adm", True))
|
||||
assert response.status_code == 200
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to confirm failure**
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_permissions.py -v
|
||||
```
|
||||
|
||||
Expected: FAIL — `ImportError: cannot import name 'check_permission'`
|
||||
|
||||
- [ ] **Step 3: Update `app/modules/auth/dependencies.py`**
|
||||
|
||||
```python
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Callable
|
||||
|
||||
from app.core.auth import decode_token, get_token_from_request
|
||||
from app.core.database import get_db
|
||||
from app.modules.auth.models import User
|
||||
from app.modules.auth.service import get_user
|
||||
|
||||
|
||||
class RequiresLoginException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def check_permission(user: User | None, permissions: list[str]) -> bool:
|
||||
if "public" in permissions:
|
||||
return True
|
||||
if user is None:
|
||||
return False
|
||||
if "authenticated" in permissions:
|
||||
return True
|
||||
if "admin" in permissions and user.is_admin:
|
||||
return True
|
||||
return False # group membership check: Part 2
|
||||
|
||||
|
||||
async def get_current_user_optional(
|
||||
request: Request, db: Session = Depends(get_db)
|
||||
) -> User | None:
|
||||
token = get_token_from_request(request)
|
||||
if not token:
|
||||
return None
|
||||
payload = decode_token(token)
|
||||
if not payload:
|
||||
return None
|
||||
user = get_user(db, payload.get("sub", ""))
|
||||
if user is None or not user.is_active:
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
def require_permission(permissions: list[str]) -> Callable:
|
||||
async def _dep(
|
||||
request: Request, db: Session = Depends(get_db)
|
||||
) -> User | None:
|
||||
user = await get_current_user_optional(request, db)
|
||||
if not check_permission(user, permissions):
|
||||
raise RequiresLoginException()
|
||||
return user
|
||||
return _dep
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
request: Request, db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
token = get_token_from_request(request)
|
||||
if not token:
|
||||
raise RequiresLoginException()
|
||||
payload = decode_token(token)
|
||||
if not payload:
|
||||
raise RequiresLoginException()
|
||||
user = get_user(db, payload.get("sub", ""))
|
||||
if user is None or not user.is_active:
|
||||
raise RequiresLoginException()
|
||||
return user
|
||||
|
||||
|
||||
async def require_admin(user: User = Depends(get_current_user)) -> User:
|
||||
if not user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return user
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to confirm pass**
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_permissions.py -v
|
||||
```
|
||||
|
||||
Expected: All 16 tests PASS.
|
||||
|
||||
- [ ] **Step 5: Run full suite to confirm no regressions**
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest -v
|
||||
```
|
||||
|
||||
Expected: All tests PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add app/modules/auth/dependencies.py tests/test_permissions.py
|
||||
git commit -m "feat: add check_permission, get_current_user_optional, require_permission"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: MODULES registry + public landing page
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/main.py`
|
||||
- Modify: `tests/test_landing.py`
|
||||
|
||||
- [ ] **Step 1: Replace `app/main.py`**
|
||||
|
||||
```python
|
||||
import importlib
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import Depends, FastAPI, Request, WebSocket
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import get_db
|
||||
from app.modules.auth.dependencies import (
|
||||
RequiresLoginException,
|
||||
check_permission,
|
||||
get_current_user,
|
||||
get_current_user_optional,
|
||||
)
|
||||
from app.modules.auth.router import router as auth_router
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
MODULES = [
|
||||
{
|
||||
"slug": "rss",
|
||||
"icon": "📡",
|
||||
"name": "RSS-Feed Server",
|
||||
"description": "Aggregiert und verteilt Neuigkeiten der Fakultät als standardkonformen RSS 2.0 Feed.",
|
||||
"status": "active",
|
||||
"permission": ["authenticated"],
|
||||
},
|
||||
{
|
||||
"slug": "kalender",
|
||||
"icon": "📅",
|
||||
"name": "Kalender",
|
||||
"description": "Veranstaltungen, Prüfungstermine und Fristen im iCal-Format.",
|
||||
"status": "planned",
|
||||
"permission": ["authenticated"],
|
||||
},
|
||||
{
|
||||
"slug": "benachrichtigungen",
|
||||
"icon": "🔔",
|
||||
"name": "Benachrichtigungen",
|
||||
"description": "Push-Nachrichten und WebSocket-basierte Echtzeit-Alerts für Studierende.",
|
||||
"status": "planned",
|
||||
"permission": ["authenticated"],
|
||||
},
|
||||
{
|
||||
"slug": "auslastung",
|
||||
"icon": "📊",
|
||||
"name": "Auslastung",
|
||||
"description": "Raum- und Ressourcenauslastung der Fakultät in Echtzeit.",
|
||||
"status": "planned",
|
||||
"permission": ["authenticated"],
|
||||
},
|
||||
{
|
||||
"slug": "lehrveranstaltungen",
|
||||
"icon": "📚",
|
||||
"name": "Lehrveranstaltungen",
|
||||
"description": "Stundenplan-API und Kursinformationen aus dem Campus-System.",
|
||||
"status": "planned",
|
||||
"permission": ["authenticated"],
|
||||
},
|
||||
]
|
||||
|
||||
NAV_ITEMS = [
|
||||
{"label": "Übersicht", "url": "/", "active": True},
|
||||
{"label": "RSS-Feeds", "url": "/rss", "active": False},
|
||||
{"label": "Kalender", "url": "/kalender", "active": False},
|
||||
{"label": "Docs", "url": "/docs", "active": False},
|
||||
]
|
||||
|
||||
|
||||
def _reset_admin() -> None:
|
||||
from app.core.database import Base, SessionLocal, engine
|
||||
from app.modules.auth.models import User # noqa: F401
|
||||
from app.modules.auth.service import get_user, hash_password
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
with SessionLocal() as db:
|
||||
user = get_user(db, settings.ADMIN_USERNAME)
|
||||
if user is None:
|
||||
user = User(username=settings.ADMIN_USERNAME, full_name="Administrator")
|
||||
db.add(user)
|
||||
user.pw_hash = hash_password(settings.ADMIN_PASSWORD)
|
||||
user.is_admin = True
|
||||
user.is_active = True
|
||||
db.commit()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
_reset_admin()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="University Process Hub",
|
||||
root_path=settings.APP_PREFIX,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
app.include_router(auth_router)
|
||||
|
||||
for _module in MODULES:
|
||||
if _module["status"] == "active":
|
||||
try:
|
||||
_mod = importlib.import_module(f"app.modules.{_module['slug']}.router")
|
||||
app.include_router(_mod.router, prefix=f"/{_module['slug']}")
|
||||
except ModuleNotFoundError:
|
||||
logger.warning("Module router not found: app.modules.%s.router", _module["slug"])
|
||||
|
||||
|
||||
@app.exception_handler(RequiresLoginException)
|
||||
async def requires_login_handler(request: Request, exc: RequiresLoginException):
|
||||
return RedirectResponse(url="/auth/login", status_code=307)
|
||||
|
||||
|
||||
def _db_mode() -> str:
|
||||
url = settings.DATABASE_URL
|
||||
return "SQLite (dev)" if url.startswith("sqlite") else "MariaDB (prod)"
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def root(request: Request, current_user=Depends(get_current_user_optional)):
|
||||
visible = [m for m in MODULES if check_permission(current_user, m["permission"])]
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"index.html",
|
||||
{
|
||||
"nav_items": NAV_ITEMS,
|
||||
"modules": visible,
|
||||
"db_mode": _db_mode(),
|
||||
"app_version": "0.1.0",
|
||||
"current_user": current_user,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.websocket("/ws/hello/{name}")
|
||||
async def websocket_hello(websocket: WebSocket, name: str):
|
||||
await websocket.accept()
|
||||
await websocket.send_text(f"Hello, {name}!")
|
||||
await websocket.close()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace `tests/test_landing.py`**
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.core.auth import create_access_token
|
||||
from app.core.database import Base, get_db
|
||||
from app.main import app
|
||||
from app.modules.auth.models import User
|
||||
from app.modules.auth.service import hash_password
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def override_db():
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
app.dependency_overrides[get_db] = lambda: session
|
||||
yield session
|
||||
app.dependency_overrides.clear()
|
||||
session.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(app, follow_redirects=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_cookies(override_db):
|
||||
user = User(username="testuser", full_name="Test User", pw_hash=hash_password("pw"))
|
||||
override_db.add(user)
|
||||
override_db.commit()
|
||||
token = create_access_token(username="testuser", is_admin=False)
|
||||
return {"access_token": token}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_cookies(override_db):
|
||||
user = User(username="adminuser", full_name="Admin User", is_admin=True, pw_hash=hash_password("pw"))
|
||||
override_db.add(user)
|
||||
override_db.commit()
|
||||
token = create_access_token(username="adminuser", is_admin=True)
|
||||
return {"access_token": token}
|
||||
|
||||
|
||||
def test_landing_accessible_without_auth(client):
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
|
||||
|
||||
def test_landing_shows_no_modules_for_anonymous(client):
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "RSS-Feed Server" not in response.text
|
||||
|
||||
|
||||
def test_landing_shows_modules_for_authenticated(client, auth_cookies):
|
||||
response = client.get("/", cookies=auth_cookies)
|
||||
assert response.status_code == 200
|
||||
assert "RSS-Feed Server" in response.text
|
||||
|
||||
|
||||
def test_landing_contains_title(client):
|
||||
response = client.get("/")
|
||||
assert "University Process Hub" in response.text
|
||||
|
||||
|
||||
def test_landing_info_strip_shows_db_mode(client, auth_cookies):
|
||||
response = client.get("/", cookies=auth_cookies)
|
||||
assert "SQLite" in response.text or "MariaDB" in response.text
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run to confirm all tests pass**
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest -v
|
||||
```
|
||||
|
||||
Expected: All tests PASS. Note: `test_landing_without_auth_redirects` is replaced by `test_landing_accessible_without_auth`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add app/main.py tests/test_landing.py
|
||||
git commit -m "feat: public landing page with permission-based tile filtering"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Navbar conditional user display
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/templates/base.html`
|
||||
- Modify: `tests/test_landing.py` (add navbar tests)
|
||||
|
||||
- [ ] **Step 1: Add navbar tests to `tests/test_landing.py`**
|
||||
|
||||
Append these tests to the existing file:
|
||||
|
||||
```python
|
||||
def test_navbar_shows_login_button_for_anonymous(client):
|
||||
response = client.get("/")
|
||||
assert "Anmelden" in response.text
|
||||
assert "Abmelden" not in response.text
|
||||
|
||||
|
||||
def test_navbar_shows_username_and_logout_when_logged_in(client, auth_cookies):
|
||||
response = client.get("/", cookies=auth_cookies)
|
||||
assert "testuser" in response.text
|
||||
assert "Abmelden" in response.text
|
||||
assert "Anmelden" not in response.text
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to confirm failure**
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_landing.py::test_navbar_shows_username_and_logout_when_logged_in -v
|
||||
```
|
||||
|
||||
Expected: FAIL — navbar currently always shows "Anmelden" button.
|
||||
|
||||
- [ ] **Step 3: Replace `app/templates/base.html`**
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}EFI Hub{% endblock %}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: { 'efi': '#e2001a' }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-gray-100 min-h-screen flex flex-col">
|
||||
|
||||
<nav class="bg-efi h-14 flex items-center px-8 shrink-0">
|
||||
<a href="/" class="text-white font-bold text-lg tracking-tight mr-8">
|
||||
EFI<span class="opacity-70 font-normal">Hub</span>
|
||||
</a>
|
||||
{% for item in nav_items %}
|
||||
<a href="{{ item.url }}"
|
||||
class="text-white/85 hover:text-white text-sm px-4 h-14 flex items-center border-b-2
|
||||
{% if item.active %}border-white text-white{% else %}border-transparent{% endif %}">
|
||||
{{ item.label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
{% if current_user | default(None) %}
|
||||
<span class="text-white/85 text-sm">{{ current_user.username }}</span>
|
||||
<a href="/auth/logout"
|
||||
class="text-white text-sm px-4 py-1.5 rounded border border-white/40 bg-white/15 hover:bg-white/25">
|
||||
Abmelden
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="/auth/login"
|
||||
class="text-white text-sm px-4 py-1.5 rounded border border-white/40 bg-white/15 hover:bg-white/25">
|
||||
Anmelden
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="flex-1">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="bg-gray-100 border-t border-gray-300 px-8 py-3.5 flex items-center justify-between text-xs text-gray-400 shrink-0">
|
||||
<span class="font-semibold text-gray-500">EFI Hub</span>
|
||||
<span>Technische Hochschule Nürnberg Georg Simon Ohm · Fakultät EFI</span>
|
||||
<span>v{{ app_version }}</span>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
Wichtig: `current_user | default(None)` verhindert `UndefinedError` auf Seiten (z. B. Login-Page) die `current_user` nicht im Template-Context übergeben.
|
||||
|
||||
- [ ] **Step 4: Run to confirm pass**
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_landing.py -v
|
||||
```
|
||||
|
||||
Expected: All landing page tests PASS.
|
||||
|
||||
- [ ] **Step 5: Run full suite**
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest -v
|
||||
```
|
||||
|
||||
Expected: All tests PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add app/templates/base.html tests/test_landing.py
|
||||
git commit -m "feat: conditional navbar — shows username/logout when logged in"
|
||||
```
|
||||
Loading…
x
Reference in New Issue
Block a user