feat: update application version to 0.8.36 and enhance profile creation process
Some checks failed
Deploy Development / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 7s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 37s

- Bumped application version to 0.8.36 in both backend and frontend files.
- Updated the ProfileCreate model to require name and email fields, ensuring schema compliance.
- Implemented a new POST /api/profiles endpoint restricted to platform admins, utilizing a random PIN for user setup.
- Added integration tests for profile creation, including checks for unauthorized access and duplicate email handling.
- Enhanced changelog to reflect the new version and changes made in this release.
This commit is contained in:
Lars 2026-05-05 23:01:14 +02:00
parent caab9f2863
commit 35b14fe1a6
5 changed files with 124 additions and 18 deletions

View File

@ -29,8 +29,10 @@ class PasswordResetConfirm(BaseModel):
new_password: str
class ProfileCreate(BaseModel):
name: str
email: Optional[str] = None
"""Nur für POST /api/profiles (Plattform-Admin): neues Nutzerprofil ohne Self-Registration."""
name: str = Field(min_length=2, max_length=200)
email: EmailStr
class ProfileUpdate(BaseModel):
name: Optional[str] = None

View File

@ -3,14 +3,14 @@ Profile Management Endpoints for Mitai Jinkendo
Handles profile CRUD operations for both admin and current user.
"""
import uuid
import secrets
from typing import Optional
from datetime import datetime
from fastapi import APIRouter, HTTPException, Header, Depends
from db import get_db, get_cursor, r2d
from auth import require_auth
from auth import require_auth, hash_pin
from club_tenancy import assert_club_member, memberships_with_roles, is_platform_admin
from tenant_context import resolve_tenant_context, TenantContext, get_tenant_context
from models import ProfileCreate, ProfileUpdate
@ -87,17 +87,51 @@ def list_profiles(session=Depends(require_auth)):
@router.post("/profiles")
def create_profile(p: ProfileCreate, session=Depends(require_auth)):
"""Create new profile (admin)."""
pid = str(uuid.uuid4())
"""
Neues Profil anlegen nur Plattform-Admin.
Nutzt gleiches Kernschema wie Registrierung (SERIAL id); PIN wird zufällig gesetzt Nutzer
setzt das Passwort über Passwort vergessen oder ein späteres Admin-Tool.
"""
role = session.get("role") or ""
if not is_platform_admin(role):
raise HTTPException(
status_code=403,
detail="Nur Plattform-Administratoren dürfen Profile anlegen",
)
email_norm = str(p.email).strip().lower()
name_s = (p.name or "").strip()
pin_hash = hash_pin(secrets.token_urlsafe(24))
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""INSERT INTO profiles (id,name,avatar_color,sex,dob,height,goal_weight,goal_bf_pct,created,updated)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)""",
(pid,p.name,p.avatar_color,p.sex,p.dob,p.height,p.goal_weight,p.goal_bf_pct))
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
return r2d(cur.fetchone())
cur.execute(
"SELECT id FROM profiles WHERE email IS NOT NULL AND lower(trim(email)) = %s",
(email_norm,),
)
if cur.fetchone():
raise HTTPException(status_code=409, detail="E-Mail bereits registriert")
cur.execute(
"""
INSERT INTO profiles (
name, email, pin_hash, auth_type, role, tier,
email_verified, created_at, updated_at
)
VALUES (%s, %s, %s, 'email', 'user', 'free', TRUE,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id
""",
(name_s, email_norm, pin_hash),
)
row = cur.fetchone()
new_id = row["id"] if row else None
if new_id is None:
raise HTTPException(status_code=500, detail="Profil konnte nicht angelegt werden")
return profile_document(str(new_id))
def profile_document(pid: str) -> dict:

View File

@ -1,7 +1,7 @@
"""
Zugriffsrechte Profile: GET /api/profiles/{pid} und DELETE /api/profiles/{pid}.
Zugriffsrechte Profile: POST/GET/DELETE /api/profiles/{pid}.
TestClient + Dependency-Override für Sessions; GET mockt profile_document, DELETE mockt DB,
TestClient + Dependency-Override für Sessions; GET mockt profile_document, DELETE/POST mocken DB,
damit keine echte Datenbank nötig ist.
"""
from __future__ import annotations
@ -129,3 +129,65 @@ def test_delete_profile_admin_ok_when_multiple_profiles(client: TestClient) -> N
assert any(
args and "DELETE FROM profiles" in str(args[0][0]) for args in calls
)
def test_create_profile_forbidden_non_admin(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
with patch("routers.profiles.get_db") as mock_get_db:
r = client.post(
"/api/profiles",
json={"name": "Neu", "email": "neu@example.com"},
headers={"X-Auth-Token": "dummy"},
)
assert r.status_code == 403
mock_get_db.assert_not_called()
def test_create_profile_admin_duplicate_email(client: TestClient) -> None:
mock_cm = MagicMock()
mock_conn = MagicMock()
mock_cm.__enter__.return_value = mock_conn
mock_cm.__exit__.return_value = False
mock_cur = MagicMock()
mock_cur.fetchone.return_value = {"id": 99}
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "admin"}
with patch("routers.profiles.get_db", return_value=mock_cm), patch(
"routers.profiles.get_cursor", return_value=mock_cur
), patch("routers.profiles.hash_pin", return_value="hashed"):
r = client.post(
"/api/profiles",
json={"name": "Dup", "email": "dup@example.com"},
headers={"X-Auth-Token": "dummy"},
)
assert r.status_code == 409
def test_create_profile_admin_success(client: TestClient) -> None:
mock_cm = MagicMock()
mock_conn = MagicMock()
mock_cm.__enter__.return_value = mock_conn
mock_cm.__exit__.return_value = False
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [None, {"id": 501}]
doc = {"id": 501, "name": "Nu User", "email": "nu@example.com"}
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "superadmin"}
with patch("routers.profiles.get_db", return_value=mock_cm), patch(
"routers.profiles.get_cursor", return_value=mock_cur
), patch("routers.profiles.profile_document", return_value=doc), patch(
"routers.profiles.hash_pin", return_value="hashed"
):
r = client.post(
"/api/profiles",
json={"name": "Nu User", "email": "nu@example.com"},
headers={"X-Auth-Token": "dummy"},
)
assert r.status_code == 200
assert r.json() == doc
insert_calls = [
c.args[0] for c in mock_cur.execute.call_args_list if c.args and "INSERT INTO profiles" in str(c.args[0])
]
assert len(insert_calls) >= 1

View File

@ -1,12 +1,12 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.35"
APP_VERSION = "0.8.36"
BUILD_DATE = "2026-05-05"
DB_SCHEMA_VERSION = "20260505041"
MODULE_VERSIONS = {
"auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute)
"profiles": "1.5.1", # GET/DELETE /profiles/{id} nur Admin bei DELETE; test_profiles_read_access.py
"profiles": "1.6.0", # POST /profiles nur Plattform-Admin; Insert SERIAL + E-Mail wie Auth; Tests
"tenant_context": "1.0.4", # pytest: Unit test_access_layer.py + optional Integration test_access_layer_integration (PostgreSQL)
"clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext
"club_memberships": "1.0.1", # Depends(get_tenant_context)
@ -27,6 +27,14 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.36",
"date": "2026-05-05",
"changes": [
"POST /api/profiles: nur Plattform-Admin; Anlage schema-konform (SERIAL, E-Mail, temporärer PIN-Hash); ProfileCreate mit Pflichtfeldern name + email",
"pytest: POST-Szenarien in test_profiles_read_access.py",
],
},
{
"version": "0.8.35",
"date": "2026-05-05",

View File

@ -1,6 +1,6 @@
// Shinkan Jinkendo Frontend Version
export const APP_VERSION = "0.8.35"
export const APP_VERSION = "0.8.36"
export const BUILD_DATE = "2026-05-05"
export const PAGE_VERSIONS = {