Compare commits
2 Commits
master
...
increase-r
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c9bf2f882 | |||
| 3b396086b8 |
66
docs/plans/2026-03-13-monthly-stats-design.md
Normal file
66
docs/plans/2026-03-13-monthly-stats-design.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Design : statistiques mensuelles dans la page Rapports
|
||||||
|
|
||||||
|
Date : 2026-03-13
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Ajouter une section "Détail mensuel" sous les cartes annuelles existantes dans `/reports`. 12 cartes (une par mois) donnant en un coup d'œil : distance parcourue par mode de transport + durée de travail typique.
|
||||||
|
|
||||||
|
## Décisions clés
|
||||||
|
|
||||||
|
- **Types de jours inclus** : tous les jours enregistrés (y compris absences = 0 min).
|
||||||
|
- **Statistique temporelle** : médiane (plus robuste que la moyenne face aux GARDE à 10h et aux absences).
|
||||||
|
- **"Par semaine"** : médiane de la durée totale hebdomadaire (somme des minutes par semaine ISO, puis médiane).
|
||||||
|
- **Layout** : 12 cartes empilées sous un titre "Détail mensuel".
|
||||||
|
- **Couleurs transport** : évocatrices (vert vélo, indigo électrique, ambre diesel).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Données calculées par mois
|
||||||
|
|
||||||
|
```python
|
||||||
|
monthly_data[month_int] = {
|
||||||
|
"km_by_vehicle": {vehicle_id: km},
|
||||||
|
"km_total": int,
|
||||||
|
"median_daily_min": int, # médiane de entry.total_minutes() sur toutes les entries du mois
|
||||||
|
"median_weekly_min": int, # médiane des sommes hebdomadaires (semaines ISO)
|
||||||
|
"entry_count": int,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Répartition du code
|
||||||
|
|
||||||
|
- **`app/business/time_calc.py`** : nouvelle fonction `monthly_stats(entries)` → calcule les médianes journalière et hebdomadaire. Pas de dépendance Flask.
|
||||||
|
- **`app/routes/reports.py`** : groupement des entries par mois + calcul des km par véhicule (déjà dans ce fichier), appel de `monthly_stats`, transmission du tout au template.
|
||||||
|
- **`app/templates/reports.html`** : section "Détail mensuel" avec les 12 cartes.
|
||||||
|
|
||||||
|
## Visuel des cartes
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ JANVIER 21 jours │
|
||||||
|
│ │
|
||||||
|
│ 312 km │
|
||||||
|
│ [████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░] │
|
||||||
|
│ ■ Vélo (132 km) ■ Électrique (108 km) │
|
||||||
|
│ ■ Diesel (72 km) │
|
||||||
|
│ │
|
||||||
|
│ Médiane/jour 7h52 Médiane/sem. 38h45 │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Barre transport** : `div` flex, sections `width: X%` en `style=` inline (contrainte Tailwind CDN).
|
||||||
|
- **Couleurs** :
|
||||||
|
- Vélo → `#4ade80` (vert vif)
|
||||||
|
- Électrique (fuel = "electric") → `#818cf8` (indigo)
|
||||||
|
- Diesel → `#d4a574` (ambre chaud)
|
||||||
|
- **Légende** : carré coloré + nom du véhicule + km, sous la barre.
|
||||||
|
- **Mois vide** (0 entrées) : carte discrète, texte "Aucune entrée", sans barre.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Nouvelle fonction `monthly_stats()` dans `tests/test_time_calc.py` :
|
||||||
|
- cas nominal (entries avec time_slots)
|
||||||
|
- absences (total_minutes = 0) incluses dans la médiane
|
||||||
|
- mois avec une seule semaine
|
||||||
|
- Route `reports` : vérifier que `monthly_data` est bien transmis au template (test existant à étendre si besoin).
|
||||||
383
docs/plans/2026-03-13-monthly-stats.md
Normal file
383
docs/plans/2026-03-13-monthly-stats.md
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
# Monthly Stats Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Ajouter une section "Détail mensuel" dans `/reports` avec 12 cartes (une par mois) affichant distance totale, barre de répartition par transport, médiane journalière et médiane hebdomadaire.
|
||||||
|
|
||||||
|
**Architecture:** Nouvelle fonction pure `monthly_stats()` dans `time_calc.py` pour les médianes. Groupement par mois + km dans `reports.py`. Rendu HTML avec barre CSS inline (contrainte Tailwind CDN).
|
||||||
|
|
||||||
|
**Tech Stack:** Flask, Jinja2, SQLAlchemy, Python `statistics` stdlib (pas de dépendance à ajouter).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Préparation : créer la branche
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout master
|
||||||
|
git checkout -b monthly-stats-reports
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1 : `monthly_stats()` dans `time_calc.py`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/business/time_calc.py`
|
||||||
|
- Test: `tests/test_time_calc.py`
|
||||||
|
|
||||||
|
**Step 1 : Écrire les tests dans `tests/test_time_calc.py`**
|
||||||
|
|
||||||
|
Ajouter en bas du fichier (après les imports existants, ajouter `TimeSlot` et `time`) :
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.models import WorkEntry, TimeSlot
|
||||||
|
from app.business.time_calc import monthly_stats
|
||||||
|
from datetime import date, time as dtime
|
||||||
|
|
||||||
|
|
||||||
|
def _entry(d: date, *slots: tuple[str, str]) -> WorkEntry:
|
||||||
|
"""Helper : crée un WorkEntry avec des TimeSlots."""
|
||||||
|
entry = WorkEntry(date=d, day_type="WORK")
|
||||||
|
entry.time_slots = [
|
||||||
|
TimeSlot(start_time=dtime(*[int(x) for x in s.split(":")]),
|
||||||
|
end_time=dtime(*[int(x) for x in e.split(":")]))
|
||||||
|
for s, e in slots
|
||||||
|
]
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def _absence(d: date) -> WorkEntry:
|
||||||
|
"""Helper : crée un WorkEntry sans time_slots (0 min)."""
|
||||||
|
entry = WorkEntry(date=d, day_type="CONGE")
|
||||||
|
entry.time_slots = []
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_stats_empty():
|
||||||
|
result = monthly_stats([])
|
||||||
|
assert result == {"median_daily_min": 0, "median_weekly_min": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_stats_median_daily_odd():
|
||||||
|
# 420, 465, 510 → médiane = 465
|
||||||
|
entries = [
|
||||||
|
_entry(date(2025, 1, 6), ("9:00", "16:00")), # 420 min
|
||||||
|
_entry(date(2025, 1, 7), ("9:00", "16:45")), # 465 min
|
||||||
|
_entry(date(2025, 1, 8), ("9:00", "17:30")), # 510 min
|
||||||
|
]
|
||||||
|
result = monthly_stats(entries)
|
||||||
|
assert result["median_daily_min"] == 465
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_stats_includes_absences():
|
||||||
|
# 0, 465 → médiane de 2 valeurs = (0+465)/2 = 232 (int)
|
||||||
|
entries = [
|
||||||
|
_absence(date(2025, 1, 6)),
|
||||||
|
_entry(date(2025, 1, 7), ("9:00", "16:45")),
|
||||||
|
]
|
||||||
|
result = monthly_stats(entries)
|
||||||
|
assert result["median_daily_min"] == 232
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_stats_median_weekly():
|
||||||
|
# Semaine 1 : 465+465 = 930 min
|
||||||
|
# Semaine 2 : 420 min
|
||||||
|
# médiane([930, 420]) = (420+930)/2 = 675
|
||||||
|
entries = [
|
||||||
|
_entry(date(2025, 1, 6), ("9:00", "16:45")), # sem 2
|
||||||
|
_entry(date(2025, 1, 7), ("9:00", "16:45")), # sem 2
|
||||||
|
_entry(date(2025, 1, 13), ("9:00", "16:00")), # sem 3
|
||||||
|
]
|
||||||
|
result = monthly_stats(entries)
|
||||||
|
assert result["median_weekly_min"] == 675
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2 : Vérifier que les tests échouent**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m pytest tests/test_time_calc.py::test_monthly_stats_empty tests/test_time_calc.py::test_monthly_stats_median_daily_odd -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Résultat attendu : `ImportError` ou `FAILED` — `monthly_stats` n'existe pas encore.
|
||||||
|
|
||||||
|
**Step 3 : Implémenter `monthly_stats()` dans `app/business/time_calc.py`**
|
||||||
|
|
||||||
|
Ajouter en bas du fichier :
|
||||||
|
|
||||||
|
```python
|
||||||
|
import statistics as _stats
|
||||||
|
|
||||||
|
|
||||||
|
def monthly_stats(entries: list) -> dict:
|
||||||
|
"""
|
||||||
|
Calcule médiane journalière et médiane hebdomadaire (semaines ISO)
|
||||||
|
pour un groupe d'entrées. Les absences (total_minutes=0) sont incluses.
|
||||||
|
"""
|
||||||
|
if not entries:
|
||||||
|
return {"median_daily_min": 0, "median_weekly_min": 0}
|
||||||
|
|
||||||
|
daily = [e.total_minutes() for e in entries]
|
||||||
|
median_daily = int(_stats.median(daily))
|
||||||
|
|
||||||
|
weekly: dict[tuple, int] = {}
|
||||||
|
for e in entries:
|
||||||
|
key = e.date.isocalendar()[:2] # (year, isoweek)
|
||||||
|
weekly[key] = weekly.get(key, 0) + e.total_minutes()
|
||||||
|
|
||||||
|
median_weekly = int(_stats.median(weekly.values()))
|
||||||
|
|
||||||
|
return {"median_daily_min": median_daily, "median_weekly_min": median_weekly}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4 : Vérifier que tous les tests passent**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m pytest tests/test_time_calc.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Résultat attendu : tous PASSED.
|
||||||
|
|
||||||
|
**Step 5 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -c commit.gpgsign=false add app/business/time_calc.py tests/test_time_calc.py
|
||||||
|
git -c commit.gpgsign=false commit -m "feat: monthly_stats() — médiane journalière et hebdomadaire"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2 : Calcul mensuel dans `reports.py`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/routes/reports.py`
|
||||||
|
|
||||||
|
**Step 1 : Mettre à jour `reports.py`**
|
||||||
|
|
||||||
|
Remplacer le contenu complet par :
|
||||||
|
|
||||||
|
```python
|
||||||
|
from flask import Blueprint, render_template, request
|
||||||
|
from datetime import date
|
||||||
|
from collections import defaultdict
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from app import db
|
||||||
|
from app.models import WorkEntry
|
||||||
|
from app.business.travel_calc import compute_km_for_entry, compute_co2_grams, compute_frais_reels
|
||||||
|
from app.business.time_calc import count_day_types, monthly_stats, minutes_to_str
|
||||||
|
from app.config_loader import get_vehicles, get_journeys, get_bareme
|
||||||
|
|
||||||
|
bp = Blueprint("reports", __name__, url_prefix="/reports")
|
||||||
|
|
||||||
|
MONTHS_FR = {
|
||||||
|
1: "Janvier", 2: "Février", 3: "Mars", 4: "Avril",
|
||||||
|
5: "Mai", 6: "Juin", 7: "Juillet", 8: "Août",
|
||||||
|
9: "Septembre", 10: "Octobre", 11: "Novembre", 12: "Décembre",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/")
|
||||||
|
def index():
|
||||||
|
year = request.args.get("year", date.today().year, type=int)
|
||||||
|
start = date(year, 1, 1)
|
||||||
|
end = date(year, 12, 31)
|
||||||
|
|
||||||
|
entries = db.session.scalars(
|
||||||
|
sa.select(WorkEntry).where(WorkEntry.date.between(start, end))
|
||||||
|
).all()
|
||||||
|
|
||||||
|
vehicles = get_vehicles()
|
||||||
|
journeys = get_journeys()
|
||||||
|
|
||||||
|
# --- Stats annuelles (inchangées) ---
|
||||||
|
total_km = {}
|
||||||
|
total_co2 = 0.0
|
||||||
|
for entry in entries:
|
||||||
|
km = compute_km_for_entry(entry.journey_profile_id, journeys, entry.motor_vehicle_id)
|
||||||
|
for v, d in km.items():
|
||||||
|
total_km[v] = total_km.get(v, 0) + d
|
||||||
|
total_co2 += compute_co2_grams(km, vehicles)
|
||||||
|
|
||||||
|
frais_reels = {}
|
||||||
|
for vehicle_id, km in total_km.items():
|
||||||
|
vehicle = vehicles.get(vehicle_id, {})
|
||||||
|
cv = vehicle.get("cv")
|
||||||
|
if cv:
|
||||||
|
tranches = get_bareme(year, cv)
|
||||||
|
electric = vehicle.get("fuel") == "electric"
|
||||||
|
frais_reels[vehicle_id] = round(compute_frais_reels(km, tranches, electric=electric), 2)
|
||||||
|
|
||||||
|
day_type_counts = count_day_types(entries)
|
||||||
|
|
||||||
|
# --- Stats mensuelles ---
|
||||||
|
entries_by_month: dict[int, list] = defaultdict(list)
|
||||||
|
for entry in entries:
|
||||||
|
entries_by_month[entry.date.month].append(entry)
|
||||||
|
|
||||||
|
monthly_data = {}
|
||||||
|
for month in range(1, 13):
|
||||||
|
month_entries = entries_by_month.get(month, [])
|
||||||
|
|
||||||
|
month_km: dict[str, int] = {}
|
||||||
|
for entry in month_entries:
|
||||||
|
km = compute_km_for_entry(entry.journey_profile_id, journeys, entry.motor_vehicle_id)
|
||||||
|
for v, d in km.items():
|
||||||
|
month_km[v] = month_km.get(v, 0) + d
|
||||||
|
|
||||||
|
stats = monthly_stats(month_entries)
|
||||||
|
|
||||||
|
monthly_data[month] = {
|
||||||
|
"month_name": MONTHS_FR[month],
|
||||||
|
"entry_count": len(month_entries),
|
||||||
|
"km_by_vehicle": month_km,
|
||||||
|
"km_total": sum(month_km.values()),
|
||||||
|
"median_daily_str": minutes_to_str(stats["median_daily_min"]) if month_entries else "–",
|
||||||
|
"median_weekly_str": minutes_to_str(stats["median_weekly_min"]) if month_entries else "–",
|
||||||
|
}
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"reports.html",
|
||||||
|
year=year,
|
||||||
|
total_km=total_km,
|
||||||
|
total_co2_kg=round(total_co2 / 1000, 2),
|
||||||
|
frais_reels=frais_reels,
|
||||||
|
vehicles=vehicles,
|
||||||
|
day_type_counts=day_type_counts,
|
||||||
|
monthly_data=monthly_data,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2 : Vérifier que les tests existants passent toujours**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m pytest tests/test_routes.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Résultat attendu : tous PASSED (aucune régression).
|
||||||
|
|
||||||
|
**Step 3 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -c commit.gpgsign=false add app/routes/reports.py
|
||||||
|
git -c commit.gpgsign=false commit -m "feat: calcul stats mensuelles dans reports (km + médianes)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3 : Section mensuelle dans `reports.html`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/templates/reports.html`
|
||||||
|
|
||||||
|
**Step 1 : Ajouter la section "Détail mensuel" dans `reports.html`**
|
||||||
|
|
||||||
|
Ajouter le bloc suivant avant `{% endblock %}` (après la carte "Répartition") :
|
||||||
|
|
||||||
|
```jinja2
|
||||||
|
<!-- Détail mensuel -->
|
||||||
|
<p class="font-display text-lg font-semibold mt-8 mb-3" style="color:var(--ink);">Détail mensuel</p>
|
||||||
|
|
||||||
|
{% for month_num in range(1, 13) %}
|
||||||
|
{% set m = monthly_data[month_num] %}
|
||||||
|
<div class="card card-ink mb-4">
|
||||||
|
<div class="flex items-baseline justify-between mb-2">
|
||||||
|
<p class="card-label">{{ m.month_name }}</p>
|
||||||
|
<span class="text-xs" style="color:#9A9288;">{{ m.entry_count }} j</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if m.entry_count == 0 %}
|
||||||
|
<p class="text-sm" style="color:#9A9288;">Aucune entrée</p>
|
||||||
|
{% else %}
|
||||||
|
{# --- Barre transport --- #}
|
||||||
|
{% if m.km_total > 0 %}
|
||||||
|
<p class="font-data font-semibold mb-1" style="font-size:1.2rem; color:var(--ink);">
|
||||||
|
{{ m.km_total }}<span class="font-normal text-xs ml-1" style="color:#9A9288;">km</span>
|
||||||
|
</p>
|
||||||
|
<div class="flex rounded overflow-hidden mb-1" style="height:10px; background:#e5e2dc;">
|
||||||
|
{% for vehicle_id, km in m.km_by_vehicle.items() %}
|
||||||
|
{% set v_info = vehicles.get(vehicle_id, {}) %}
|
||||||
|
{% if v_info.get('type') == 'velo' %}
|
||||||
|
{% set color = '#4ade80' %}
|
||||||
|
{% elif v_info.get('fuel') == 'electric' %}
|
||||||
|
{% set color = '#818cf8' %}
|
||||||
|
{% else %}
|
||||||
|
{% set color = '#d4a574' %}
|
||||||
|
{% endif %}
|
||||||
|
{% set pct = (km / m.km_total * 100) | round(1) %}
|
||||||
|
<div style="width:{{ pct }}%; background-color:{{ color }};"></div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-x-3 mb-3">
|
||||||
|
{% for vehicle_id, km in m.km_by_vehicle.items() %}
|
||||||
|
{% set v_info = vehicles.get(vehicle_id, {}) %}
|
||||||
|
{% if v_info.get('type') == 'velo' %}
|
||||||
|
{% set color = '#4ade80' %}
|
||||||
|
{% elif v_info.get('fuel') == 'electric' %}
|
||||||
|
{% set color = '#818cf8' %}
|
||||||
|
{% else %}
|
||||||
|
{% set color = '#d4a574' %}
|
||||||
|
{% endif %}
|
||||||
|
<span class="text-xs" style="color:#8A8278;">
|
||||||
|
<span style="display:inline-block; width:8px; height:8px; border-radius:2px; background:{{ color }}; margin-right:3px;"></span>
|
||||||
|
{{ vehicles.get(vehicle_id, {}).get('name', vehicle_id) }} ({{ km }} km)
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-xs mb-3" style="color:#9A9288;">Aucun déplacement</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# --- Stats temporelles --- #}
|
||||||
|
<div class="flex gap-6">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs mb-0.5" style="color:#9A9288;">Médiane / jour</p>
|
||||||
|
<p class="font-data font-semibold" style="color:var(--ink);">{{ m.median_daily_str }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs mb-0.5" style="color:#9A9288;">Médiane / semaine</p>
|
||||||
|
<p class="font-data font-semibold" style="color:var(--ink);">{{ m.median_weekly_str }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2 : Vérifier manuellement dans le navigateur**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Naviguer vers `http://localhost:5000/reports/` et vérifier :
|
||||||
|
- Section "Détail mensuel" présente
|
||||||
|
- 12 cartes affichées (mois sans données : "Aucune entrée")
|
||||||
|
- Barre colorée pour les mois avec des km
|
||||||
|
- Médianes affichées au format "Xh00"
|
||||||
|
|
||||||
|
**Step 3 : Ajouter un test de route pour la section mensuelle**
|
||||||
|
|
||||||
|
Dans `tests/test_routes.py`, ajouter :
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_reports_monthly_section(client):
|
||||||
|
response = client.get("/reports/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Détail mensuel" in response.text
|
||||||
|
assert "Janvier" in response.text
|
||||||
|
assert "Décembre" in response.text
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4 : Vérifier que tous les tests passent**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m pytest -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Résultat attendu : tous PASSED.
|
||||||
|
|
||||||
|
**Step 5 : Commit final**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -c commit.gpgsign=false add app/templates/reports.html tests/test_routes.py
|
||||||
|
git -c commit.gpgsign=false commit -m "feat: section détail mensuel dans la page rapports"
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user