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
|
new_password: str
|
||||||
|
|
||||||
class ProfileCreate(BaseModel):
|
class ProfileCreate(BaseModel):
|
||||||
name: str
|
"""Nur für POST /api/profiles (Plattform-Admin): neues Nutzerprofil ohne Self-Registration."""
|
||||||
email: Optional[str] = None
|
|
||||||
|
name: str = Field(min_length=2, max_length=200)
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
class ProfileUpdate(BaseModel):
|
class ProfileUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,14 @@ Profile Management Endpoints for Mitai Jinkendo
|
||||||
|
|
||||||
Handles profile CRUD operations for both admin and current user.
|
Handles profile CRUD operations for both admin and current user.
|
||||||
"""
|
"""
|
||||||
import uuid
|
import secrets
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Header, Depends
|
from fastapi import APIRouter, HTTPException, Header, Depends
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
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 club_tenancy import assert_club_member, memberships_with_roles, is_platform_admin
|
||||||
from tenant_context import resolve_tenant_context, TenantContext, get_tenant_context
|
from tenant_context import resolve_tenant_context, TenantContext, get_tenant_context
|
||||||
from models import ProfileCreate, ProfileUpdate
|
from models import ProfileCreate, ProfileUpdate
|
||||||
|
|
@ -87,17 +87,51 @@ def list_profiles(session=Depends(require_auth)):
|
||||||
|
|
||||||
@router.post("/profiles")
|
@router.post("/profiles")
|
||||||
def create_profile(p: ProfileCreate, session=Depends(require_auth)):
|
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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute("""INSERT INTO profiles (id,name,avatar_color,sex,dob,height,goal_weight,goal_bf_pct,created,updated)
|
cur.execute(
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)""",
|
"SELECT id FROM profiles WHERE email IS NOT NULL AND lower(trim(email)) = %s",
|
||||||
(pid,p.name,p.avatar_color,p.sex,p.dob,p.height,p.goal_weight,p.goal_bf_pct))
|
(email_norm,),
|
||||||
with get_db() as conn:
|
)
|
||||||
cur = get_cursor(conn)
|
if cur.fetchone():
|
||||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
raise HTTPException(status_code=409, detail="E-Mail bereits registriert")
|
||||||
return r2d(cur.fetchone())
|
|
||||||
|
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:
|
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.
|
damit keine echte Datenbank nötig ist.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -129,3 +129,65 @@ def test_delete_profile_admin_ok_when_multiple_profiles(client: TestClient) -> N
|
||||||
assert any(
|
assert any(
|
||||||
args and "DELETE FROM profiles" in str(args[0][0]) for args in calls
|
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
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.35"
|
APP_VERSION = "0.8.36"
|
||||||
BUILD_DATE = "2026-05-05"
|
BUILD_DATE = "2026-05-05"
|
||||||
DB_SCHEMA_VERSION = "20260505041"
|
DB_SCHEMA_VERSION = "20260505041"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute)
|
"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)
|
"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
|
"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)
|
"club_memberships": "1.0.1", # Depends(get_tenant_context)
|
||||||
|
|
@ -27,6 +27,14 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.35",
|
||||||
"date": "2026-05-05",
|
"date": "2026-05-05",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Shinkan Jinkendo Frontend Version
|
// 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 BUILD_DATE = "2026-05-05"
|
||||||
|
|
||||||
export const PAGE_VERSIONS = {
|
export const PAGE_VERSIONS = {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user