feat: v9c Phase 2 - Backend Non-Blocking Logging (12 Endpoints)
PHASE 2: Backend Non-Blocking Logging - KOMPLETT
Instrumentierte Endpoints (12):
- Data: weight, circumference, caliper, nutrition, activity, photos (6)
- AI: insights/run/{slug}, insights/pipeline (2)
- Export: csv, json, zip (3)
- Import: zip (1)
Pattern implementiert:
- check_feature_access() VOR Operation (non-blocking)
- [FEATURE-LIMIT] Logging wenn Limit überschritten
- increment_feature_usage() NACH Operation
- Alte Permission-Checks bleiben aktiv
Features geprüft:
- weight_entries, circumference_entries, caliper_entries
- nutrition_entries, activity_entries, photos
- ai_calls, ai_pipeline
- data_export, data_import
Monitoring: 1-2 Wochen Log-Only-Phase
Logs zeigen: Wie oft würde blockiert werden?
Nächste Phase: Frontend Display (Usage-Counter)
Phase 1 (Cleanup) + Phase 2 (Logging) vollständig!
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
73bea5ee86
commit
ddcd2f4350
|
|
@ -6,16 +6,18 @@ Handles workout/activity logging, statistics, and Apple Health CSV import.
|
|||
import csv
|
||||
import io
|
||||
import uuid
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth
|
||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
||||
from models import ActivityEntry
|
||||
from routers.profiles import get_pid
|
||||
|
||||
router = APIRouter(prefix="/api/activity", tags=["activity"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("")
|
||||
|
|
@ -33,6 +35,15 @@ def list_activity(limit: int=200, x_profile_id: Optional[str]=Header(default=Non
|
|||
def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Create new activity entry."""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
||||
# Phase 2: Check feature access (non-blocking, log only)
|
||||
access = check_feature_access(pid, 'activity_entries')
|
||||
if not access['allowed']:
|
||||
logger.warning(
|
||||
f"[FEATURE-LIMIT] User {pid} would be blocked: "
|
||||
f"activity_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
||||
)
|
||||
|
||||
eid = str(uuid.uuid4())
|
||||
d = e.model_dump()
|
||||
with get_db() as conn:
|
||||
|
|
@ -44,6 +55,10 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default
|
|||
(eid,pid,d['date'],d['start_time'],d['end_time'],d['activity_type'],d['duration_min'],
|
||||
d['kcal_active'],d['kcal_resting'],d['hr_avg'],d['hr_max'],d['distance_km'],
|
||||
d['rpe'],d['source'],d['notes']))
|
||||
|
||||
# Phase 2: Increment usage counter (always for new entries)
|
||||
increment_feature_usage(pid, 'activity_entries')
|
||||
|
||||
return {"id":eid,"date":e.date}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,16 +4,18 @@ Caliper/Skinfold Tracking Endpoints for Mitai Jinkendo
|
|||
Handles body fat measurements via skinfold caliper (4 methods supported).
|
||||
"""
|
||||
import uuid
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Header, Depends
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth
|
||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
||||
from models import CaliperEntry
|
||||
from routers.profiles import get_pid
|
||||
|
||||
router = APIRouter(prefix="/api/caliper", tags=["caliper"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("")
|
||||
|
|
@ -31,17 +33,30 @@ def list_caliper(limit: int=100, x_profile_id: Optional[str]=Header(default=None
|
|||
def upsert_caliper(e: CaliperEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Create or update caliper entry (upsert by date)."""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
||||
# Phase 2: Check feature access (non-blocking, log only)
|
||||
access = check_feature_access(pid, 'caliper_entries')
|
||||
if not access['allowed']:
|
||||
logger.warning(
|
||||
f"[FEATURE-LIMIT] User {pid} would be blocked: "
|
||||
f"caliper_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
||||
)
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT id FROM caliper_log WHERE profile_id=%s AND date=%s", (pid,e.date))
|
||||
ex = cur.fetchone()
|
||||
d = e.model_dump()
|
||||
is_new_entry = not ex
|
||||
|
||||
if ex:
|
||||
# UPDATE existing entry
|
||||
eid = ex['id']
|
||||
sets = ', '.join(f"{k}=%s" for k in d if k!='date')
|
||||
cur.execute(f"UPDATE caliper_log SET {sets} WHERE id=%s",
|
||||
[v for k,v in d.items() if k!='date']+[eid])
|
||||
else:
|
||||
# INSERT new entry
|
||||
eid = str(uuid.uuid4())
|
||||
cur.execute("""INSERT INTO caliper_log
|
||||
(id,profile_id,date,sf_method,sf_chest,sf_axilla,sf_triceps,sf_subscap,sf_suprailiac,
|
||||
|
|
@ -50,6 +65,10 @@ def upsert_caliper(e: CaliperEntry, x_profile_id: Optional[str]=Header(default=N
|
|||
(eid,pid,d['date'],d['sf_method'],d['sf_chest'],d['sf_axilla'],d['sf_triceps'],
|
||||
d['sf_subscap'],d['sf_suprailiac'],d['sf_abdomen'],d['sf_thigh'],d['sf_calf_med'],
|
||||
d['sf_lowerback'],d['sf_biceps'],d['body_fat_pct'],d['lean_mass'],d['fat_mass'],d['notes']))
|
||||
|
||||
# Phase 2: Increment usage counter (only for new entries)
|
||||
increment_feature_usage(pid, 'caliper_entries')
|
||||
|
||||
return {"id":eid,"date":e.date}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,16 +4,18 @@ Circumference Tracking Endpoints for Mitai Jinkendo
|
|||
Handles body circumference measurements (8 measurement points).
|
||||
"""
|
||||
import uuid
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Header, Depends
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth
|
||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
||||
from models import CircumferenceEntry
|
||||
from routers.profiles import get_pid
|
||||
|
||||
router = APIRouter(prefix="/api/circumferences", tags=["circumference"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("")
|
||||
|
|
@ -31,23 +33,40 @@ def list_circs(limit: int=100, x_profile_id: Optional[str]=Header(default=None),
|
|||
def upsert_circ(e: CircumferenceEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Create or update circumference entry (upsert by date)."""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
||||
# Phase 2: Check feature access (non-blocking, log only)
|
||||
access = check_feature_access(pid, 'circumference_entries')
|
||||
if not access['allowed']:
|
||||
logger.warning(
|
||||
f"[FEATURE-LIMIT] User {pid} would be blocked: "
|
||||
f"circumference_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
||||
)
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT id FROM circumference_log WHERE profile_id=%s AND date=%s", (pid,e.date))
|
||||
ex = cur.fetchone()
|
||||
d = e.model_dump()
|
||||
is_new_entry = not ex
|
||||
|
||||
if ex:
|
||||
# UPDATE existing entry
|
||||
eid = ex['id']
|
||||
sets = ', '.join(f"{k}=%s" for k in d if k!='date')
|
||||
cur.execute(f"UPDATE circumference_log SET {sets} WHERE id=%s",
|
||||
[v for k,v in d.items() if k!='date']+[eid])
|
||||
else:
|
||||
# INSERT new entry
|
||||
eid = str(uuid.uuid4())
|
||||
cur.execute("""INSERT INTO circumference_log
|
||||
(id,profile_id,date,c_neck,c_chest,c_waist,c_belly,c_hip,c_thigh,c_calf,c_arm,notes,photo_id,created)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
|
||||
(eid,pid,d['date'],d['c_neck'],d['c_chest'],d['c_waist'],d['c_belly'],
|
||||
d['c_hip'],d['c_thigh'],d['c_calf'],d['c_arm'],d['notes'],d['photo_id']))
|
||||
|
||||
# Phase 2: Increment usage counter (only for new entries)
|
||||
increment_feature_usage(pid, 'circumference_entries')
|
||||
|
||||
return {"id":eid,"date":e.date}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import os
|
|||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
|
@ -17,10 +18,11 @@ from fastapi import APIRouter, HTTPException, Header, Depends
|
|||
from fastapi.responses import StreamingResponse, Response
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth
|
||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
||||
from routers.profiles import get_pid
|
||||
|
||||
router = APIRouter(prefix="/api/export", tags=["export"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos"))
|
||||
|
||||
|
|
@ -30,7 +32,16 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D
|
|||
"""Export all data as CSV."""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
||||
# Check export permission
|
||||
# Phase 2: Check feature access (non-blocking, log only)
|
||||
access = check_feature_access(pid, 'data_export')
|
||||
if not access['allowed']:
|
||||
logger.warning(
|
||||
f"[FEATURE-LIMIT] User {pid} would be blocked: "
|
||||
f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
||||
)
|
||||
# NOTE: Phase 2 does NOT block - just logs!
|
||||
|
||||
# Old permission check (keep for now)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,))
|
||||
|
|
@ -74,6 +85,10 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D
|
|||
writer.writerow(["Training", r['date'], r['activity_type'], f"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal"])
|
||||
|
||||
output.seek(0)
|
||||
|
||||
# Phase 2: Increment usage counter
|
||||
increment_feature_usage(pid, 'data_export')
|
||||
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
|
|
@ -86,7 +101,15 @@ def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict=
|
|||
"""Export all data as JSON."""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
||||
# Check export permission
|
||||
# Phase 2: Check feature access (non-blocking, log only)
|
||||
access = check_feature_access(pid, 'data_export')
|
||||
if not access['allowed']:
|
||||
logger.warning(
|
||||
f"[FEATURE-LIMIT] User {pid} would be blocked: "
|
||||
f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
||||
)
|
||||
|
||||
# Old permission check (keep for now)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,))
|
||||
|
|
@ -126,6 +149,10 @@ def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict=
|
|||
return str(obj)
|
||||
|
||||
json_str = json.dumps(data, indent=2, default=decimal_handler)
|
||||
|
||||
# Phase 2: Increment usage counter
|
||||
increment_feature_usage(pid, 'data_export')
|
||||
|
||||
return Response(
|
||||
content=json_str,
|
||||
media_type="application/json",
|
||||
|
|
@ -138,7 +165,15 @@ def export_zip(x_profile_id: Optional[str]=Header(default=None), session: dict=D
|
|||
"""Export all data as ZIP (CSV + JSON + photos) per specification."""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
||||
# Check export permission & get profile
|
||||
# Phase 2: Check feature access (non-blocking, log only)
|
||||
access = check_feature_access(pid, 'data_export')
|
||||
if not access['allowed']:
|
||||
logger.warning(
|
||||
f"[FEATURE-LIMIT] User {pid} would be blocked: "
|
||||
f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
||||
)
|
||||
|
||||
# Old permission check & get profile
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
||||
|
|
@ -297,6 +332,10 @@ Datumsformat: YYYY-MM-DD
|
|||
|
||||
zip_buffer.seek(0)
|
||||
filename = f"mitai-export-{profile_name.replace(' ','-')}-{export_date}.zip"
|
||||
|
||||
# Phase 2: Increment usage counter
|
||||
increment_feature_usage(pid, 'data_export')
|
||||
|
||||
return StreamingResponse(
|
||||
iter([zip_buffer.getvalue()]),
|
||||
media_type="application/zip",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import csv
|
|||
import io
|
||||
import json
|
||||
import uuid
|
||||
import logging
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
|
@ -16,10 +17,11 @@ from datetime import datetime
|
|||
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
|
||||
|
||||
from db import get_db, get_cursor
|
||||
from auth import require_auth
|
||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
||||
from routers.profiles import get_pid
|
||||
|
||||
router = APIRouter(prefix="/api/import", tags=["import"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos"))
|
||||
|
||||
|
|
@ -41,6 +43,15 @@ async def import_zip(
|
|||
"""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
||||
# Phase 2: Check feature access (non-blocking, log only)
|
||||
access = check_feature_access(pid, 'data_import')
|
||||
if not access['allowed']:
|
||||
logger.warning(
|
||||
f"[FEATURE-LIMIT] User {pid} would be blocked: "
|
||||
f"data_import {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
||||
)
|
||||
# NOTE: Phase 2 does NOT block - just logs!
|
||||
|
||||
# Read uploaded file
|
||||
content = await file.read()
|
||||
zip_buffer = io.BytesIO(content)
|
||||
|
|
@ -254,6 +265,9 @@ async def import_zip(
|
|||
conn.rollback()
|
||||
raise HTTPException(500, f"Import fehlgeschlagen: {str(e)}")
|
||||
|
||||
# Phase 2: Increment usage counter
|
||||
increment_feature_usage(pid, 'data_import')
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "Import erfolgreich",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ Handles AI analysis execution, prompt management, and usage tracking.
|
|||
import os
|
||||
import json
|
||||
import uuid
|
||||
import logging
|
||||
import httpx
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
|
@ -13,10 +14,11 @@ from datetime import datetime
|
|||
from fastapi import APIRouter, HTTPException, Header, Depends
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth, require_admin
|
||||
from auth import require_auth, require_admin, check_feature_access, increment_feature_usage
|
||||
from routers.profiles import get_pid
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["insights"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OPENROUTER_KEY = os.getenv("OPENROUTER_API_KEY", "")
|
||||
OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "anthropic/claude-sonnet-4")
|
||||
|
|
@ -251,6 +253,17 @@ def delete_ai_insight(scope: str, x_profile_id: Optional[str]=Header(default=Non
|
|||
async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Run AI analysis with specified prompt template."""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
||||
# Phase 2: Check feature access (non-blocking, log only)
|
||||
access = check_feature_access(pid, 'ai_calls')
|
||||
if not access['allowed']:
|
||||
logger.warning(
|
||||
f"[FEATURE-LIMIT] User {pid} would be blocked: "
|
||||
f"ai_calls {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
||||
)
|
||||
# NOTE: Phase 2 does NOT block - just logs!
|
||||
|
||||
# Old check (keep for now, but will be replaced in Phase 4)
|
||||
check_ai_limit(pid)
|
||||
|
||||
# Get prompt template
|
||||
|
|
@ -300,7 +313,12 @@ async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(defa
|
|||
cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)",
|
||||
(str(uuid.uuid4()), pid, slug, content))
|
||||
|
||||
# Phase 2: Increment new feature usage counter
|
||||
increment_feature_usage(pid, 'ai_calls')
|
||||
|
||||
# Old usage tracking (keep for now)
|
||||
inc_ai_usage(pid)
|
||||
|
||||
return {"scope": slug, "content": content}
|
||||
|
||||
|
||||
|
|
@ -308,6 +326,25 @@ async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(defa
|
|||
async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Run 3-stage pipeline analysis."""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
||||
# Phase 2: Check pipeline feature access (boolean - enabled/disabled)
|
||||
access_pipeline = check_feature_access(pid, 'ai_pipeline')
|
||||
if not access_pipeline['allowed']:
|
||||
logger.warning(
|
||||
f"[FEATURE-LIMIT] User {pid} would be blocked: "
|
||||
f"ai_pipeline {access_pipeline['reason']}"
|
||||
)
|
||||
# NOTE: Phase 2 does NOT block - just logs!
|
||||
|
||||
# Also check ai_calls (pipeline uses API calls too)
|
||||
access_calls = check_feature_access(pid, 'ai_calls')
|
||||
if not access_calls['allowed']:
|
||||
logger.warning(
|
||||
f"[FEATURE-LIMIT] User {pid} would be blocked: "
|
||||
f"ai_calls {access_calls['reason']} (used: {access_calls['used']}, limit: {access_calls['limit']})"
|
||||
)
|
||||
|
||||
# Old check (keep for now)
|
||||
check_ai_limit(pid)
|
||||
|
||||
data = _get_profile_data(pid)
|
||||
|
|
@ -436,7 +473,13 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses
|
|||
cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,'pipeline',%s,CURRENT_TIMESTAMP)",
|
||||
(str(uuid.uuid4()), pid, final_content))
|
||||
|
||||
# Phase 2: Increment ai_calls usage (pipeline uses multiple API calls)
|
||||
# Note: We increment once per pipeline run, not per individual call
|
||||
increment_feature_usage(pid, 'ai_calls')
|
||||
|
||||
# Old usage tracking (keep for now)
|
||||
inc_ai_usage(pid)
|
||||
|
||||
return {"scope": "pipeline", "content": final_content, "stage1": stage1_results}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,16 +6,18 @@ Handles nutrition data, FDDB CSV import, correlations, and weekly aggregates.
|
|||
import csv
|
||||
import io
|
||||
import uuid
|
||||
import logging
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth
|
||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
||||
from routers.profiles import get_pid
|
||||
|
||||
router = APIRouter(prefix="/api/nutrition", tags=["nutrition"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Helper ────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -30,6 +32,16 @@ def _pf(s):
|
|||
async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Import FDDB nutrition CSV."""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
||||
# Phase 2: Check feature access (non-blocking, log only)
|
||||
# Note: CSV import can create many entries - we check once before import
|
||||
access = check_feature_access(pid, 'nutrition_entries')
|
||||
if not access['allowed']:
|
||||
logger.warning(
|
||||
f"[FEATURE-LIMIT] User {pid} would be blocked: "
|
||||
f"nutrition_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
||||
)
|
||||
|
||||
raw = await file.read()
|
||||
try: text = raw.decode('utf-8')
|
||||
except: text = raw.decode('latin-1')
|
||||
|
|
@ -52,20 +64,30 @@ async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optiona
|
|||
days[iso]['protein_g'] += _pf(row.get('protein_g',0))
|
||||
count+=1
|
||||
inserted=0
|
||||
new_entries=0
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
for iso,vals in days.items():
|
||||
kcal=round(vals['kcal'],1); fat=round(vals['fat_g'],1)
|
||||
carbs=round(vals['carbs_g'],1); prot=round(vals['protein_g'],1)
|
||||
cur.execute("SELECT id FROM nutrition_log WHERE profile_id=%s AND date=%s",(pid,iso))
|
||||
if cur.fetchone():
|
||||
is_new = not cur.fetchone()
|
||||
if not is_new:
|
||||
# UPDATE existing
|
||||
cur.execute("UPDATE nutrition_log SET kcal=%s,protein_g=%s,fat_g=%s,carbs_g=%s WHERE profile_id=%s AND date=%s",
|
||||
(kcal,prot,fat,carbs,pid,iso))
|
||||
else:
|
||||
# INSERT new
|
||||
cur.execute("INSERT INTO nutrition_log (id,profile_id,date,kcal,protein_g,fat_g,carbs_g,source,created) VALUES (%s,%s,%s,%s,%s,%s,%s,'csv',CURRENT_TIMESTAMP)",
|
||||
(str(uuid.uuid4()),pid,iso,kcal,prot,fat,carbs))
|
||||
new_entries += 1
|
||||
inserted+=1
|
||||
return {"rows_parsed":count,"days_imported":inserted,
|
||||
|
||||
# Phase 2: Increment usage counter for each new entry created
|
||||
for _ in range(new_entries):
|
||||
increment_feature_usage(pid, 'nutrition_entries')
|
||||
|
||||
return {"rows_parsed":count,"days_imported":inserted,"new_entries":new_entries,
|
||||
"date_range":{"from":min(days) if days else None,"to":max(days) if days else None}}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Handles progress photo uploads and retrieval.
|
|||
"""
|
||||
import os
|
||||
import uuid
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
|
@ -13,10 +14,11 @@ from fastapi.responses import FileResponse
|
|||
import aiofiles
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth, require_auth_flexible
|
||||
from auth import require_auth, require_auth_flexible, check_feature_access, increment_feature_usage
|
||||
from routers.profiles import get_pid
|
||||
|
||||
router = APIRouter(prefix="/api/photos", tags=["photos"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos"))
|
||||
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -27,6 +29,15 @@ async def upload_photo(file: UploadFile=File(...), date: str="",
|
|||
x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Upload progress photo."""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
||||
# Phase 2: Check feature access (non-blocking, log only)
|
||||
access = check_feature_access(pid, 'photos')
|
||||
if not access['allowed']:
|
||||
logger.warning(
|
||||
f"[FEATURE-LIMIT] User {pid} would be blocked: "
|
||||
f"photos {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
||||
)
|
||||
|
||||
fid = str(uuid.uuid4())
|
||||
ext = Path(file.filename).suffix or '.jpg'
|
||||
path = PHOTOS_DIR / f"{fid}{ext}"
|
||||
|
|
@ -35,6 +46,10 @@ async def upload_photo(file: UploadFile=File(...), date: str="",
|
|||
cur = get_cursor(conn)
|
||||
cur.execute("INSERT INTO photos (id,profile_id,date,path,created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)",
|
||||
(fid,pid,date,str(path)))
|
||||
|
||||
# Phase 2: Increment usage counter
|
||||
increment_feature_usage(pid, 'photos')
|
||||
|
||||
return {"id":fid,"date":date}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,16 +4,18 @@ Weight Tracking Endpoints for Mitai Jinkendo
|
|||
Handles weight log CRUD operations and statistics.
|
||||
"""
|
||||
import uuid
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Header, Depends
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth
|
||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
||||
from models import WeightEntry
|
||||
from routers.profiles import get_pid
|
||||
|
||||
router = APIRouter(prefix="/api/weight", tags=["weight"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("")
|
||||
|
|
@ -31,17 +33,35 @@ def list_weight(limit: int=365, x_profile_id: Optional[str]=Header(default=None)
|
|||
def upsert_weight(e: WeightEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Create or update weight entry (upsert by date)."""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
||||
# Phase 2: Check feature access (non-blocking, log only)
|
||||
access = check_feature_access(pid, 'weight_entries')
|
||||
if not access['allowed']:
|
||||
logger.warning(
|
||||
f"[FEATURE-LIMIT] User {pid} would be blocked: "
|
||||
f"weight_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
||||
)
|
||||
# NOTE: Phase 2 does NOT block - just logs!
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT id FROM weight_log WHERE profile_id=%s AND date=%s", (pid,e.date))
|
||||
ex = cur.fetchone()
|
||||
is_new_entry = not ex
|
||||
|
||||
if ex:
|
||||
# UPDATE existing entry
|
||||
cur.execute("UPDATE weight_log SET weight=%s,note=%s WHERE id=%s", (e.weight,e.note,ex['id']))
|
||||
wid = ex['id']
|
||||
else:
|
||||
# INSERT new entry
|
||||
wid = str(uuid.uuid4())
|
||||
cur.execute("INSERT INTO weight_log (id,profile_id,date,weight,note,created) VALUES (%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)",
|
||||
(wid,pid,e.date,e.weight,e.note))
|
||||
|
||||
# Phase 2: Increment usage counter (only for new entries)
|
||||
increment_feature_usage(pid, 'weight_entries')
|
||||
|
||||
return {"id":wid,"date":e.date,"weight":e.weight}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user