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
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:
parent
caab9f2863
commit
35b14fe1a6
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user