Compare commits

..

No commits in common. "ed057fe54556f72f735d818dc8a78dabf4679789" and "d13c2c7e2500daa56b2498846273d13c39745736" have entirely different histories.

8 changed files with 75 additions and 164 deletions

View File

@ -37,20 +37,15 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default
"""Create new activity entry.""" """Create new activity entry."""
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
# Phase 4: Check feature access and ENFORCE # Phase 2: Check feature access (non-blocking, log only)
access = check_feature_access(pid, 'activity_entries') access = check_feature_access(pid, 'activity_entries')
log_feature_usage(pid, 'activity_entries', access, 'create') log_feature_usage(pid, 'activity_entries', access, 'create')
if not access['allowed']: if not access['allowed']:
logger.warning( logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: " f"[FEATURE-LIMIT] User {pid} would be blocked: "
f"activity_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})" f"activity_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
) )
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Aktivitätseinträge überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
eid = str(uuid.uuid4()) eid = str(uuid.uuid4())
d = e.model_dump() d = e.model_dump()

View File

@ -33,20 +33,24 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D
"""Export all data as CSV.""" """Export all data as CSV."""
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
# Phase 4: Check feature access and ENFORCE # Phase 2: Check feature access (non-blocking, log only)
access = check_feature_access(pid, 'data_export') access = check_feature_access(pid, 'data_export')
log_feature_usage(pid, 'data_export', access, 'export_csv') log_feature_usage(pid, 'data_export', access, 'export_csv')
if not access['allowed']: if not access['allowed']:
logger.warning( logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: " f"[FEATURE-LIMIT] User {pid} would be blocked: "
f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})" f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})"
) )
raise HTTPException( # NOTE: Phase 2 does NOT block - just logs!
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Daten-Exporte überschritten ({access['used']}/{access['limit']}). " # Old permission check (keep for now)
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." with get_db() as conn:
) cur = get_cursor(conn)
cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,))
prof = cur.fetchone()
if not prof or not prof['export_enabled']:
raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
# Build CSV # Build CSV
output = io.StringIO() output = io.StringIO()
@ -100,20 +104,23 @@ def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict=
"""Export all data as JSON.""" """Export all data as JSON."""
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
# Phase 4: Check feature access and ENFORCE # Phase 2: Check feature access (non-blocking, log only)
access = check_feature_access(pid, 'data_export') access = check_feature_access(pid, 'data_export')
log_feature_usage(pid, 'data_export', access, 'export_json') log_feature_usage(pid, 'data_export', access, 'export_json')
if not access['allowed']: if not access['allowed']:
logger.warning( logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: " f"[FEATURE-LIMIT] User {pid} would be blocked: "
f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})" f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})"
) )
raise HTTPException(
status_code=403, # Old permission check (keep for now)
detail=f"Limit erreicht: Du hast das Kontingent für Daten-Exporte überschritten ({access['used']}/{access['limit']}). " with get_db() as conn:
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." cur = get_cursor(conn)
) cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,))
prof = cur.fetchone()
if not prof or not prof['export_enabled']:
raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
# Collect all data # Collect all data
data = {} data = {}
@ -163,26 +170,23 @@ def export_zip(x_profile_id: Optional[str]=Header(default=None), session: dict=D
"""Export all data as ZIP (CSV + JSON + photos) per specification.""" """Export all data as ZIP (CSV + JSON + photos) per specification."""
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
# Phase 4: Check feature access and ENFORCE # Phase 2: Check feature access (non-blocking, log only)
access = check_feature_access(pid, 'data_export') access = check_feature_access(pid, 'data_export')
log_feature_usage(pid, 'data_export', access, 'export_zip') log_feature_usage(pid, 'data_export', access, 'export_zip')
if not access['allowed']: if not access['allowed']:
logger.warning( logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: " f"[FEATURE-LIMIT] User {pid} would be blocked: "
f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})" f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})"
) )
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Daten-Exporte überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
# Get profile # Old permission check & get profile
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
prof = r2d(cur.fetchone()) prof = r2d(cur.fetchone())
if not prof or not prof.get('export_enabled'):
raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
# Helper: CSV writer with UTF-8 BOM + semicolon # Helper: CSV writer with UTF-8 BOM + semicolon
def write_csv(zf, filename, rows, columns): def write_csv(zf, filename, rows, columns):

View File

@ -44,20 +44,16 @@ async def import_zip(
""" """
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
# Phase 4: Check feature access and ENFORCE # Phase 2: Check feature access (non-blocking, log only)
access = check_feature_access(pid, 'data_import') access = check_feature_access(pid, 'data_import')
log_feature_usage(pid, 'data_import', access, 'import_zip') log_feature_usage(pid, 'data_import', access, 'import_zip')
if not access['allowed']: if not access['allowed']:
logger.warning( logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: " f"[FEATURE-LIMIT] User {pid} would be blocked: "
f"data_import {access['reason']} (used: {access['used']}, limit: {access['limit']})" f"data_import {access['reason']} (used: {access['used']}, limit: {access['limit']})"
) )
raise HTTPException( # NOTE: Phase 2 does NOT block - just logs!
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Daten-Importe überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
# Read uploaded file # Read uploaded file
content = await file.read() content = await file.read()

View File

@ -255,20 +255,19 @@ async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(defa
"""Run AI analysis with specified prompt template.""" """Run AI analysis with specified prompt template."""
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
# Phase 4: Check feature access and ENFORCE # Phase 2: Check feature access (non-blocking, log only)
access = check_feature_access(pid, 'ai_calls') access = check_feature_access(pid, 'ai_calls')
log_feature_usage(pid, 'ai_calls', access, 'analyze') log_feature_usage(pid, 'ai_calls', access, 'analyze')
if not access['allowed']: if not access['allowed']:
logger.warning( logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: " f"[FEATURE-LIMIT] User {pid} would be blocked: "
f"ai_calls {access['reason']} (used: {access['used']}, limit: {access['limit']})" f"ai_calls {access['reason']} (used: {access['used']}, limit: {access['limit']})"
) )
raise HTTPException( # NOTE: Phase 2 does NOT block - just logs!
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für KI-Analysen überschritten ({access['used']}/{access['limit']}). " # Old check (keep for now, but will be replaced in Phase 4)
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." check_ai_limit(pid)
)
# Get prompt template # Get prompt template
with get_db() as conn: with get_db() as conn:
@ -331,19 +330,16 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses
"""Run 3-stage pipeline analysis.""" """Run 3-stage pipeline analysis."""
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
# Phase 4: Check pipeline feature access (boolean - enabled/disabled) # Phase 2: Check pipeline feature access (boolean - enabled/disabled)
access_pipeline = check_feature_access(pid, 'ai_pipeline') access_pipeline = check_feature_access(pid, 'ai_pipeline')
log_feature_usage(pid, 'ai_pipeline', access_pipeline, 'pipeline') log_feature_usage(pid, 'ai_pipeline', access_pipeline, 'pipeline')
if not access_pipeline['allowed']: if not access_pipeline['allowed']:
logger.warning( logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: " f"[FEATURE-LIMIT] User {pid} would be blocked: "
f"ai_pipeline {access_pipeline['reason']}" f"ai_pipeline {access_pipeline['reason']}"
) )
raise HTTPException( # NOTE: Phase 2 does NOT block - just logs!
status_code=403,
detail=f"Pipeline-Analyse ist nicht verfügbar. Bitte kontaktiere den Admin."
)
# Also check ai_calls (pipeline uses API calls too) # Also check ai_calls (pipeline uses API calls too)
access_calls = check_feature_access(pid, 'ai_calls') access_calls = check_feature_access(pid, 'ai_calls')
@ -351,14 +347,12 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses
if not access_calls['allowed']: if not access_calls['allowed']:
logger.warning( logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: " f"[FEATURE-LIMIT] User {pid} would be blocked: "
f"ai_calls {access_calls['reason']} (used: {access_calls['used']}, limit: {access_calls['limit']})" f"ai_calls {access_calls['reason']} (used: {access_calls['used']}, limit: {access_calls['limit']})"
) )
raise HTTPException(
status_code=403, # Old check (keep for now)
detail=f"Limit erreicht: Du hast das Kontingent für KI-Analysen überschritten ({access_calls['used']}/{access_calls['limit']}). " check_ai_limit(pid)
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
data = _get_profile_data(pid) data = _get_profile_data(pid)
vars = _prepare_template_vars(data) vars = _prepare_template_vars(data)

View File

@ -34,21 +34,16 @@ async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optiona
"""Import FDDB nutrition CSV.""" """Import FDDB nutrition CSV."""
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
# Phase 4: Check feature access and ENFORCE # Phase 2: Check feature access (non-blocking, log only)
# Note: CSV import can create many entries - we check once before import # Note: CSV import can create many entries - we check once before import
access = check_feature_access(pid, 'nutrition_entries') access = check_feature_access(pid, 'nutrition_entries')
log_feature_usage(pid, 'nutrition_entries', access, 'import_csv') log_feature_usage(pid, 'nutrition_entries', access, 'import_csv')
if not access['allowed']: if not access['allowed']:
logger.warning( logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: " f"[FEATURE-LIMIT] User {pid} would be blocked: "
f"nutrition_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})" f"nutrition_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
) )
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Ernährungseinträge überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
raw = await file.read() raw = await file.read()
try: text = raw.decode('utf-8') try: text = raw.decode('utf-8')

View File

@ -31,20 +31,15 @@ async def upload_photo(file: UploadFile=File(...), date: str="",
"""Upload progress photo.""" """Upload progress photo."""
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
# Phase 4: Check feature access and ENFORCE # Phase 2: Check feature access (non-blocking, log only)
access = check_feature_access(pid, 'photos') access = check_feature_access(pid, 'photos')
log_feature_usage(pid, 'photos', access, 'upload') log_feature_usage(pid, 'photos', access, 'upload')
if not access['allowed']: if not access['allowed']:
logger.warning( logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: " f"[FEATURE-LIMIT] User {pid} would be blocked: "
f"photos {access['reason']} (used: {access['used']}, limit: {access['limit']})" f"photos {access['reason']} (used: {access['used']}, limit: {access['limit']})"
) )
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Fotos überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
fid = str(uuid.uuid4()) fid = str(uuid.uuid4())
ext = Path(file.filename).suffix or '.jpg' ext = Path(file.filename).suffix or '.jpg'

View File

@ -2,7 +2,6 @@ import { useState, useEffect, useRef } from 'react'
import { Upload, Pencil, Trash2, Check, X, CheckCircle } from 'lucide-react' import { Upload, Pencil, Trash2, Check, X, CheckCircle } from 'lucide-react'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts' import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
import { api } from '../utils/api' import { api } from '../utils/api'
import UsageBadge from '../components/UsageBadge'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import 'dayjs/locale/de' import 'dayjs/locale/de'
dayjs.locale('de') dayjs.locale('de')
@ -80,7 +79,7 @@ function ImportPanel({ onImported }) {
} }
// Manual Entry // Manual Entry
function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', saving=false, error=null, usage=null }) { function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern' }) {
const set = (k,v) => setForm(f=>({...f,[k]:v})) const set = (k,v) => setForm(f=>({...f,[k]:v}))
return ( return (
<div> <div>
@ -131,25 +130,8 @@ function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', sav
value={form.notes||''} onChange={e=>set('notes',e.target.value)}/> value={form.notes||''} onChange={e=>set('notes',e.target.value)}/>
<span className="form-unit"/> <span className="form-unit"/>
</div> </div>
{error && (
<div style={{padding:'10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:13,color:'var(--danger)',marginBottom:8}}>
{error}
</div>
)}
<div style={{display:'flex',gap:6,marginTop:8}}> <div style={{display:'flex',gap:6,marginTop:8}}>
<div <button className="btn btn-primary" style={{flex:1}} onClick={onSave}>{saveLabel}</button>
title={usage && !usage.allowed ? `Limit erreicht (${usage.used}/${usage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
style={{flex:1,display:'inline-block'}}
>
<button
className="btn btn-primary"
style={{width:'100%', cursor: (usage && !usage.allowed) ? 'not-allowed' : 'pointer'}}
onClick={onSave}
disabled={saving || (usage && !usage.allowed)}
>
{(usage && !usage.allowed) ? '🔒 Limit erreicht' : saveLabel}
</button>
</div>
{onCancel && <button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}><X size={13}/> Abbrechen</button>} {onCancel && <button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}><X size={13}/> Abbrechen</button>}
</div> </div>
</div> </div>
@ -163,51 +145,25 @@ export default function ActivityPage() {
const [tab, setTab] = useState('list') const [tab, setTab] = useState('list')
const [form, setForm] = useState(empty()) const [form, setForm] = useState(empty())
const [editing, setEditing] = useState(null) const [editing, setEditing] = useState(null)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const [error, setError] = useState(null)
const [activityUsage, setActivityUsage] = useState(null) // Phase 4: Usage badge
const load = async () => { const load = async () => {
const [e, s] = await Promise.all([api.listActivity(), api.activityStats()]) const [e, s] = await Promise.all([api.listActivity(), api.activityStats()])
setEntries(e); setStats(s) setEntries(e); setStats(s)
} }
useEffect(()=>{ load() },[])
const loadUsage = () => {
api.getFeatureUsage().then(features => {
const activityFeature = features.find(f => f.feature_id === 'activity_entries')
setActivityUsage(activityFeature)
}).catch(err => console.error('Failed to load usage:', err))
}
useEffect(()=>{
load()
loadUsage()
},[])
const handleSave = async () => { const handleSave = async () => {
setSaving(true) const payload = {...form}
setError(null) if(payload.duration_min) payload.duration_min = parseFloat(payload.duration_min)
try { if(payload.kcal_active) payload.kcal_active = parseFloat(payload.kcal_active)
const payload = {...form} if(payload.hr_avg) payload.hr_avg = parseFloat(payload.hr_avg)
if(payload.duration_min) payload.duration_min = parseFloat(payload.duration_min) if(payload.hr_max) payload.hr_max = parseFloat(payload.hr_max)
if(payload.kcal_active) payload.kcal_active = parseFloat(payload.kcal_active) if(payload.rpe) payload.rpe = parseInt(payload.rpe)
if(payload.hr_avg) payload.hr_avg = parseFloat(payload.hr_avg) payload.source = 'manual'
if(payload.hr_max) payload.hr_max = parseFloat(payload.hr_max) await api.createActivity(payload)
if(payload.rpe) payload.rpe = parseInt(payload.rpe) setSaved(true); await load()
payload.source = 'manual' setTimeout(()=>{ setSaved(false); setForm(empty()) }, 1500)
await api.createActivity(payload)
setSaved(true)
await load()
await loadUsage() // Reload usage after save
setTimeout(()=>{ setSaved(false); setForm(empty()) }, 1500)
} catch (err) {
console.error('Save failed:', err)
setError(err.message || 'Fehler beim Speichern')
setTimeout(()=>setError(null), 5000)
} finally {
setSaving(false)
}
} }
const handleUpdate = async () => { const handleUpdate = async () => {
@ -269,13 +225,9 @@ export default function ActivityPage() {
{tab==='add' && ( {tab==='add' && (
<div className="card section-gap"> <div className="card section-gap">
<div className="card-title badge-container-right"> <div className="card-title">Training eintragen</div>
<span>Training eintragen</span>
{activityUsage && <UsageBadge {...activityUsage} />}
</div>
<EntryForm form={form} setForm={setForm} <EntryForm form={form} setForm={setForm}
onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'} onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}/>
saving={saving} error={error} usage={activityUsage}/>
</div> </div>
)} )}

View File

@ -254,22 +254,12 @@ export default function Analysis() {
</div> </div>
)} )}
</div> </div>
<div <button className="btn btn-primary" style={{flexShrink:0,minWidth:100}}
title={aiUsage && !aiUsage.allowed ? `Limit erreicht (${aiUsage.used}/${aiUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''} onClick={runPipeline} disabled={!!loading||pipelineLoading}>
style={{display:'inline-block'}} {pipelineLoading
> ? <><div className="spinner" style={{width:13,height:13}}/> Läuft</>
<button : <><Brain size={13}/> Starten</>}
className="btn btn-primary" </button>
style={{flexShrink:0,minWidth:100, cursor: (aiUsage && !aiUsage.allowed) ? 'not-allowed' : 'pointer'}}
onClick={runPipeline}
disabled={!!loading||pipelineLoading||(aiUsage && !aiUsage.allowed)}
>
{pipelineLoading
? <><div className="spinner" style={{width:13,height:13}}/> Läuft</>
: (aiUsage && !aiUsage.allowed) ? '🔒 Limit'
: <><Brain size={13}/> Starten</>}
</button>
</div>
{!canUseAI && <div style={{fontSize:11,color:'#D85A30',marginTop:4}}>🔒 KI nicht freigeschaltet</div>} {!canUseAI && <div style={{fontSize:11,color:'#D85A30',marginTop:4}}>🔒 KI nicht freigeschaltet</div>}
</div> </div>
{pipelineLoading && ( {pipelineLoading && (
@ -316,22 +306,12 @@ export default function Analysis() {
</div> </div>
)} )}
</div> </div>
<div <button className="btn btn-primary" style={{flexShrink:0,minWidth:90}}
title={aiUsage && !aiUsage.allowed ? `Limit erreicht (${aiUsage.used}/${aiUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''} onClick={()=>runPrompt(p.slug)} disabled={!!loading||!canUseAI}>
style={{display:'inline-block'}} {loading===p.slug
> ? <><div className="spinner" style={{width:13,height:13}}/> Läuft</>
<button : <><Brain size={13}/> {existing?'Neu erstellen':'Starten'}</>}
className="btn btn-primary" </button>
style={{flexShrink:0,minWidth:90, cursor: (aiUsage && !aiUsage.allowed) ? 'not-allowed' : 'pointer'}}
onClick={()=>runPrompt(p.slug)}
disabled={!!loading||!canUseAI||(aiUsage && !aiUsage.allowed)}
>
{loading===p.slug
? <><div className="spinner" style={{width:13,height:13}}/> Läuft</>
: (aiUsage && !aiUsage.allowed) ? '🔒 Limit'
: <><Brain size={13}/> {existing?'Neu erstellen':'Starten'}</>}
</button>
</div>
</div> </div>
{/* Show existing result collapsed */} {/* Show existing result collapsed */}
{existing && newResult?.id !== existing.id && ( {existing && newResult?.id !== existing.id && (