Compare commits
No commits in common. "ed057fe54556f72f735d818dc8a78dabf4679789" and "d13c2c7e2500daa56b2498846273d13c39745736" have entirely different histories.
ed057fe545
...
d13c2c7e25
|
|
@ -37,20 +37,15 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default
|
|||
"""Create new activity entry."""
|
||||
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')
|
||||
log_feature_usage(pid, 'activity_entries', access, 'create')
|
||||
|
||||
if not access['allowed']:
|
||||
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']})"
|
||||
)
|
||||
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())
|
||||
d = e.model_dump()
|
||||
|
|
|
|||
|
|
@ -33,20 +33,24 @@ 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)
|
||||
|
||||
# Phase 4: Check feature access and ENFORCE
|
||||
# Phase 2: Check feature access (non-blocking, log only)
|
||||
access = check_feature_access(pid, 'data_export')
|
||||
log_feature_usage(pid, 'data_export', access, 'export_csv')
|
||||
|
||||
if not access['allowed']:
|
||||
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']})"
|
||||
)
|
||||
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."
|
||||
)
|
||||
# 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,))
|
||||
prof = cur.fetchone()
|
||||
if not prof or not prof['export_enabled']:
|
||||
raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
|
||||
|
||||
# Build CSV
|
||||
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."""
|
||||
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')
|
||||
log_feature_usage(pid, 'data_export', access, 'export_json')
|
||||
|
||||
if not access['allowed']:
|
||||
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']})"
|
||||
)
|
||||
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."
|
||||
)
|
||||
|
||||
# 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,))
|
||||
prof = cur.fetchone()
|
||||
if not prof or not prof['export_enabled']:
|
||||
raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
|
||||
|
||||
# Collect all 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."""
|
||||
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')
|
||||
log_feature_usage(pid, 'data_export', access, 'export_zip')
|
||||
|
||||
if not access['allowed']:
|
||||
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']})"
|
||||
)
|
||||
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:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
||||
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
|
||||
def write_csv(zf, filename, rows, columns):
|
||||
|
|
|
|||
|
|
@ -44,20 +44,16 @@ async def import_zip(
|
|||
"""
|
||||
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')
|
||||
log_feature_usage(pid, 'data_import', access, 'import_zip')
|
||||
|
||||
if not access['allowed']:
|
||||
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']})"
|
||||
)
|
||||
raise HTTPException(
|
||||
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."
|
||||
)
|
||||
# NOTE: Phase 2 does NOT block - just logs!
|
||||
|
||||
# Read uploaded file
|
||||
content = await file.read()
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
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')
|
||||
log_feature_usage(pid, 'ai_calls', access, 'analyze')
|
||||
|
||||
if not access['allowed']:
|
||||
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']})"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Limit erreicht: Du hast das Kontingent für KI-Analysen überschritten ({access['used']}/{access['limit']}). "
|
||||
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
||||
)
|
||||
# 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
|
||||
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."""
|
||||
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')
|
||||
log_feature_usage(pid, 'ai_pipeline', access_pipeline, 'pipeline')
|
||||
|
||||
if not access_pipeline['allowed']:
|
||||
logger.warning(
|
||||
f"[FEATURE-LIMIT] User {pid} blocked: "
|
||||
f"[FEATURE-LIMIT] User {pid} would be blocked: "
|
||||
f"ai_pipeline {access_pipeline['reason']}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Pipeline-Analyse ist nicht verfügbar. Bitte kontaktiere den Admin."
|
||||
)
|
||||
# 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')
|
||||
|
|
@ -351,14 +347,12 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses
|
|||
|
||||
if not access_calls['allowed']:
|
||||
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']})"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Limit erreicht: Du hast das Kontingent für KI-Analysen überschritten ({access_calls['used']}/{access_calls['limit']}). "
|
||||
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
||||
)
|
||||
|
||||
# Old check (keep for now)
|
||||
check_ai_limit(pid)
|
||||
|
||||
data = _get_profile_data(pid)
|
||||
vars = _prepare_template_vars(data)
|
||||
|
|
|
|||
|
|
@ -34,21 +34,16 @@ async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optiona
|
|||
"""Import FDDB nutrition CSV."""
|
||||
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
|
||||
access = check_feature_access(pid, 'nutrition_entries')
|
||||
log_feature_usage(pid, 'nutrition_entries', access, 'import_csv')
|
||||
|
||||
if not access['allowed']:
|
||||
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']})"
|
||||
)
|
||||
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()
|
||||
try: text = raw.decode('utf-8')
|
||||
|
|
|
|||
|
|
@ -31,20 +31,15 @@ async def upload_photo(file: UploadFile=File(...), date: str="",
|
|||
"""Upload progress photo."""
|
||||
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')
|
||||
log_feature_usage(pid, 'photos', access, 'upload')
|
||||
|
||||
if not access['allowed']:
|
||||
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']})"
|
||||
)
|
||||
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())
|
||||
ext = Path(file.filename).suffix or '.jpg'
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { useState, useEffect, useRef } from 'react'
|
|||
import { Upload, Pencil, Trash2, Check, X, CheckCircle } from 'lucide-react'
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
|
||||
import { api } from '../utils/api'
|
||||
import UsageBadge from '../components/UsageBadge'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/de'
|
||||
dayjs.locale('de')
|
||||
|
|
@ -80,7 +79,7 @@ function ImportPanel({ onImported }) {
|
|||
}
|
||||
|
||||
// ── 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}))
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -131,25 +130,8 @@ function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', sav
|
|||
value={form.notes||''} onChange={e=>set('notes',e.target.value)}/>
|
||||
<span className="form-unit"/>
|
||||
</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
|
||||
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>
|
||||
<button className="btn btn-primary" style={{flex:1}} onClick={onSave}>{saveLabel}</button>
|
||||
{onCancel && <button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}><X size={13}/> Abbrechen</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -163,51 +145,25 @@ export default function ActivityPage() {
|
|||
const [tab, setTab] = useState('list')
|
||||
const [form, setForm] = useState(empty())
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [saving, setSaving] = 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 [e, s] = await Promise.all([api.listActivity(), api.activityStats()])
|
||||
setEntries(e); setStats(s)
|
||||
}
|
||||
|
||||
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()
|
||||
},[])
|
||||
useEffect(()=>{ load() },[])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const payload = {...form}
|
||||
if(payload.duration_min) payload.duration_min = parseFloat(payload.duration_min)
|
||||
if(payload.kcal_active) payload.kcal_active = parseFloat(payload.kcal_active)
|
||||
if(payload.hr_avg) payload.hr_avg = parseFloat(payload.hr_avg)
|
||||
if(payload.hr_max) payload.hr_max = parseFloat(payload.hr_max)
|
||||
if(payload.rpe) payload.rpe = parseInt(payload.rpe)
|
||||
payload.source = 'manual'
|
||||
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 payload = {...form}
|
||||
if(payload.duration_min) payload.duration_min = parseFloat(payload.duration_min)
|
||||
if(payload.kcal_active) payload.kcal_active = parseFloat(payload.kcal_active)
|
||||
if(payload.hr_avg) payload.hr_avg = parseFloat(payload.hr_avg)
|
||||
if(payload.hr_max) payload.hr_max = parseFloat(payload.hr_max)
|
||||
if(payload.rpe) payload.rpe = parseInt(payload.rpe)
|
||||
payload.source = 'manual'
|
||||
await api.createActivity(payload)
|
||||
setSaved(true); await load()
|
||||
setTimeout(()=>{ setSaved(false); setForm(empty()) }, 1500)
|
||||
}
|
||||
|
||||
const handleUpdate = async () => {
|
||||
|
|
@ -269,13 +225,9 @@ export default function ActivityPage() {
|
|||
|
||||
{tab==='add' && (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title badge-container-right">
|
||||
<span>Training eintragen</span>
|
||||
{activityUsage && <UsageBadge {...activityUsage} />}
|
||||
</div>
|
||||
<div className="card-title">Training eintragen</div>
|
||||
<EntryForm form={form} setForm={setForm}
|
||||
onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}
|
||||
saving={saving} error={error} usage={activityUsage}/>
|
||||
onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -254,22 +254,12 @@ export default function Analysis() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
title={aiUsage && !aiUsage.allowed ? `Limit erreicht (${aiUsage.used}/${aiUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
|
||||
style={{display:'inline-block'}}
|
||||
>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
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>
|
||||
<button className="btn btn-primary" style={{flexShrink:0,minWidth:100}}
|
||||
onClick={runPipeline} disabled={!!loading||pipelineLoading}>
|
||||
{pipelineLoading
|
||||
? <><div className="spinner" style={{width:13,height:13}}/> Läuft…</>
|
||||
: <><Brain size={13}/> Starten</>}
|
||||
</button>
|
||||
{!canUseAI && <div style={{fontSize:11,color:'#D85A30',marginTop:4}}>🔒 KI nicht freigeschaltet</div>}
|
||||
</div>
|
||||
{pipelineLoading && (
|
||||
|
|
@ -316,22 +306,12 @@ export default function Analysis() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
title={aiUsage && !aiUsage.allowed ? `Limit erreicht (${aiUsage.used}/${aiUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
|
||||
style={{display:'inline-block'}}
|
||||
>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
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>
|
||||
<button className="btn btn-primary" style={{flexShrink:0,minWidth:90}}
|
||||
onClick={()=>runPrompt(p.slug)} disabled={!!loading||!canUseAI}>
|
||||
{loading===p.slug
|
||||
? <><div className="spinner" style={{width:13,height:13}}/> Läuft…</>
|
||||
: <><Brain size={13}/> {existing?'Neu erstellen':'Starten'}</>}
|
||||
</button>
|
||||
</div>
|
||||
{/* Show existing result collapsed */}
|
||||
{existing && newResult?.id !== existing.id && (
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user