fix(historie): Audit-Lücke bei Mangel/Personal/Objekt-Änderungen geschlossen
Bisher waren nur Kontrolle/Fehlbestand/Nachfüllung/Mindermenge/Geräteinstanz im append-only Audit-Log (historie.log() war seit Prompt 13 nie flächendeckend verdrahtet, docstring sagte das bereits so). Drei neue Ereignisse ergänzt: - mangel_gemeldet / mangel_status_geaendert (Mangel-Modul) - qualifikation_erfasst (Personal-Modul, sicherheitsrelevant: "wer hat wem wann eine Qualifikation bestätigt" hängt fachlich direkt an "wer darf fahren") - objekt_geaendert (Status-/Fahrzeug-Zuordnungsänderungen via PATCH /objekte) Lagerbewegung bewusst NICHT zusätzlich in historie dupliziert - hat bereits eigenes vollständiges Audit-Trail (Wer/Wann/Von/Nach/Grund in eigener Tabelle). Frontend: Änderungslog-Filter um geraet_instanz/mangel/benutzer_qualifikation/ objekt ergänzt (geraet_instanz fehlte dort zuvor ebenfalls). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KC8HYvv6UkCVYheYiTw9DD
This commit is contained in:
@@ -4065,3 +4065,25 @@ Keine Commits in dieser Session.
|
|||||||
- frontend/src/pages/MangelListePage.tsx | 4 ++--
|
- frontend/src/pages/MangelListePage.tsx | 4 ++--
|
||||||
|
|
||||||
---
|
---
|
||||||
|
## 2026-09-05 13:43 – 13:46 (3m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** asb-material
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- 417df2b feat(lager): Lagerbewegung-Protokoll (Roadmap Phase 4, Modul Lager)
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 14 ++++++++++
|
||||||
|
- backend/alembic/versions/0015_lagerbewegung.py | 40 +++++++++++++++++++++++++++
|
||||||
|
- backend/app/api/v1/api.py | 2 ++
|
||||||
|
- backend/app/api/v1/endpoints/lagerbewegung.py | 68 ++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- backend/app/models/lagerbewegung.py | 24 ++++++++++++++++
|
||||||
|
- backend/app/schemas/lagerbewegung.py | 20 ++++++++++++++
|
||||||
|
- backend/app/services/lagerbewegung.py | 26 ++++++++++++++++++
|
||||||
|
- backend/tests/test_lagerbewegung.py | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- frontend/src/api/types.ts | 10 +++++++
|
||||||
|
- frontend/src/pages/AdminPage.tsx | 12 +++++++-
|
||||||
|
- frontend/src/pages/admin/LagerbewegungSection.tsx | 101 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- frontend/src/pages/admin/ObjektSection.tsx | 62 ++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.api.deps import require_roles
|
from app.api.deps import require_roles
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.auth import RolleTyp
|
from app.models.auth import KnotenTyp, RolleTyp, Systemknoten
|
||||||
from app.models.mangel import Mangel, MangelStatus
|
from app.models.mangel import Mangel, MangelStatus
|
||||||
from app.models.objekt import Objekt
|
from app.models.objekt import Objekt
|
||||||
from app.schemas.mangel import MangelCreate, MangelRead, MangelStatusUpdate
|
from app.schemas.mangel import MangelCreate, MangelRead, MangelStatusUpdate
|
||||||
from app.services.mangel import erledige_mangel
|
from app.services import historie as historie_service
|
||||||
|
from app.services.mangel import aendere_status
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -26,6 +27,11 @@ _materialverantwortliche = require_roles(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _hauptserver_id(db: AsyncSession) -> int:
|
||||||
|
result = await db.execute(select(Systemknoten.id).where(Systemknoten.typ == KnotenTyp.haupt))
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/maengel", response_model=list[MangelRead])
|
@router.get("/maengel", response_model=list[MangelRead])
|
||||||
async def liste_maengel(
|
async def liste_maengel(
|
||||||
objekt_id: int | None = None,
|
objekt_id: int | None = None,
|
||||||
@@ -65,6 +71,16 @@ async def melde_mangel(
|
|||||||
)
|
)
|
||||||
db.add(mangel)
|
db.add(mangel)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
await historie_service.log(
|
||||||
|
db,
|
||||||
|
zustaendiger_server_id=await _hauptserver_id(db),
|
||||||
|
benutzer_id=current_user.id,
|
||||||
|
ereignistyp="mangel_gemeldet",
|
||||||
|
entitaet_typ="mangel",
|
||||||
|
entitaet_id=mangel.id,
|
||||||
|
neuer_wert={"beschreibung": mangel.beschreibung, "prioritaet": mangel.prioritaet.value},
|
||||||
|
)
|
||||||
return mangel
|
return mangel
|
||||||
|
|
||||||
|
|
||||||
@@ -79,9 +95,10 @@ async def aendere_mangel_status(
|
|||||||
if mangel is None:
|
if mangel is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mangel nicht gefunden")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mangel nicht gefunden")
|
||||||
|
|
||||||
if payload.status == MangelStatus.erledigt:
|
return await aendere_status(
|
||||||
return await erledige_mangel(db, mangel=mangel, benutzer_id=current_user.id)
|
db,
|
||||||
|
mangel=mangel,
|
||||||
mangel.status = payload.status
|
status=payload.status,
|
||||||
await db.flush()
|
benutzer_id=current_user.id,
|
||||||
return mangel
|
zustaendiger_server_id=await _hauptserver_id(db),
|
||||||
|
)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from app.models.objekt import Objekt
|
|||||||
from app.models.objektposition import Objektposition
|
from app.models.objektposition import Objektposition
|
||||||
from app.models.stammdaten import Material, Objekttyp
|
from app.models.stammdaten import Material, Objekttyp
|
||||||
from app.models.vorlage import Beladungsvorlage
|
from app.models.vorlage import Beladungsvorlage
|
||||||
|
from app.services import historie as historie_service
|
||||||
from app.services.label import generiere_code128_label_pdf
|
from app.services.label import generiere_code128_label_pdf
|
||||||
from app.schemas.objekt import (
|
from app.schemas.objekt import (
|
||||||
FahrzeugdetailsRead,
|
FahrzeugdetailsRead,
|
||||||
@@ -256,7 +257,10 @@ async def erstelle_objekt(
|
|||||||
|
|
||||||
@router.patch("/objekte/{objekt_id}", response_model=ObjektRead)
|
@router.patch("/objekte/{objekt_id}", response_model=ObjektRead)
|
||||||
async def aendere_objekt(
|
async def aendere_objekt(
|
||||||
objekt_id: int, payload: ObjektUpdate, db: AsyncSession = Depends(get_db), _=Depends(_admin_only)
|
objekt_id: int,
|
||||||
|
payload: ObjektUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user=Depends(_admin_only),
|
||||||
) -> ObjektRead:
|
) -> ObjektRead:
|
||||||
"""Fahrzeug-Zuordnung nachträglich setzen/lösen: jedes Objekt gehört zu
|
"""Fahrzeug-Zuordnung nachträglich setzen/lösen: jedes Objekt gehört zu
|
||||||
jedem Zeitpunkt höchstens einem Fahrzeug (1:n). Zielobjekt-Existenz und
|
jedem Zeitpunkt höchstens einem Fahrzeug (1:n). Zielobjekt-Existenz und
|
||||||
@@ -276,12 +280,29 @@ async def aendere_objekt(
|
|||||||
detail="Zyklus: Zielobjekt ist bereits diesem Objekt zugeordnet",
|
detail="Zyklus: Zielobjekt ist bereits diesem Objekt zugeordnet",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
alte_werte = {feld: getattr(objekt, feld) for feld in daten}
|
||||||
for feld, wert in daten.items():
|
for feld, wert in daten.items():
|
||||||
setattr(objekt, feld, wert)
|
setattr(objekt, feld, wert)
|
||||||
try:
|
try:
|
||||||
await db.flush()
|
await db.flush()
|
||||||
except IntegrityError as exc:
|
except IntegrityError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Ungültige Zuordnung") from exc
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Ungültige Zuordnung") from exc
|
||||||
|
|
||||||
|
if daten:
|
||||||
|
# Historie-Lücke (Roadmap-Review): Status-/Fahrzeug-Zuordnungsänderungen
|
||||||
|
# waren bisher nicht protokolliert, anders als z.B. Geräteinstanz-Status.
|
||||||
|
hauptserver = await db.execute(select(Systemknoten.id).where(Systemknoten.typ == KnotenTyp.haupt))
|
||||||
|
await historie_service.log(
|
||||||
|
db,
|
||||||
|
zustaendiger_server_id=hauptserver.scalar_one(),
|
||||||
|
benutzer_id=current_user.id,
|
||||||
|
ereignistyp="objekt_geaendert",
|
||||||
|
entitaet_typ="objekt",
|
||||||
|
entitaet_id=objekt.id,
|
||||||
|
alter_wert={k: (v.value if hasattr(v, "value") else v) for k, v in alte_werte.items()},
|
||||||
|
neuer_wert={k: (v.value if hasattr(v, "value") else v) for k, v in daten.items()},
|
||||||
|
)
|
||||||
|
|
||||||
letzte_map = await letzte_kontrollen_map(db, [objekt.id])
|
letzte_map = await letzte_kontrollen_map(db, [objekt.id])
|
||||||
return _objekt_read(objekt, letzte_map.get(objekt.id))
|
return _objekt_read(objekt, letzte_map.get(objekt.id))
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.api.deps import get_current_user, require_roles
|
from app.api.deps import get_current_user, require_roles
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.auth import RolleTyp
|
from app.models.auth import KnotenTyp, RolleTyp, Systemknoten
|
||||||
from app.models.personal import (
|
from app.models.personal import (
|
||||||
BenutzerQualifikation,
|
BenutzerQualifikation,
|
||||||
Einheit,
|
Einheit,
|
||||||
@@ -25,10 +25,16 @@ from app.schemas.personal import (
|
|||||||
QualifikationstypRead,
|
QualifikationstypRead,
|
||||||
QualifikationstypUpdate,
|
QualifikationstypUpdate,
|
||||||
)
|
)
|
||||||
|
from app.services import historie as historie_service
|
||||||
from app.services.personal import pruefe_berechtigung
|
from app.services.personal import pruefe_berechtigung
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def _hauptserver_id(db: AsyncSession) -> int:
|
||||||
|
result = await db.execute(select(Systemknoten.id).where(Systemknoten.typ == KnotenTyp.haupt))
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
_admin_only = require_roles(RolleTyp.administration)
|
_admin_only = require_roles(RolleTyp.administration)
|
||||||
|
|
||||||
|
|
||||||
@@ -126,7 +132,7 @@ async def erfasse_benutzer_qualifikation(
|
|||||||
benutzer_id: int,
|
benutzer_id: int,
|
||||||
payload: BenutzerQualifikationCreate,
|
payload: BenutzerQualifikationCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_=Depends(_admin_only),
|
current_user=Depends(_admin_only),
|
||||||
) -> BenutzerQualifikation:
|
) -> BenutzerQualifikation:
|
||||||
if payload.benutzer_id != benutzer_id:
|
if payload.benutzer_id != benutzer_id:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="benutzer_id im Pfad und Body weichen ab")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="benutzer_id im Pfad und Body weichen ab")
|
||||||
@@ -138,6 +144,23 @@ async def erfasse_benutzer_qualifikation(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_409_CONFLICT, detail="Qualifikation mit diesem Erwerbsdatum existiert bereits"
|
status_code=status.HTTP_409_CONFLICT, detail="Qualifikation mit diesem Erwerbsdatum existiert bereits"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
# Historie-Lücke (Roadmap-Review): wer hat wem wann eine Qualifikation
|
||||||
|
# bestätigt, ist sicherheitsrelevant ("wer darf fahren" hängt fachlich
|
||||||
|
# direkt daran) und war bisher nirgends protokolliert.
|
||||||
|
await historie_service.log(
|
||||||
|
db,
|
||||||
|
zustaendiger_server_id=await _hauptserver_id(db),
|
||||||
|
benutzer_id=current_user.id,
|
||||||
|
ereignistyp="qualifikation_erfasst",
|
||||||
|
entitaet_typ="benutzer_qualifikation",
|
||||||
|
entitaet_id=qualifikation.id,
|
||||||
|
neuer_wert={
|
||||||
|
"benutzer_id": benutzer_id,
|
||||||
|
"qualifikationstyp_id": payload.qualifikationstyp_id,
|
||||||
|
"gueltig_bis": payload.gueltig_bis.isoformat() if payload.gueltig_bis else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
return qualifikation
|
return qualifikation
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from sqlalchemy import func, select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.mangel import Mangel, MangelPrioritaet, MangelStatus
|
from app.models.mangel import Mangel, MangelPrioritaet, MangelStatus
|
||||||
|
from app.services import historie as historie_service
|
||||||
|
|
||||||
OFFENE_MANGEL_STATI = (
|
OFFENE_MANGEL_STATI = (
|
||||||
MangelStatus.neu,
|
MangelStatus.neu,
|
||||||
@@ -25,9 +26,28 @@ async def objekt_ids_mit_kritischem_offenem_mangel(db: AsyncSession) -> set[int]
|
|||||||
return set(result.scalars().all())
|
return set(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
async def erledige_mangel(db: AsyncSession, *, mangel: Mangel, benutzer_id: int) -> Mangel:
|
async def aendere_status(
|
||||||
mangel.status = MangelStatus.erledigt
|
db: AsyncSession, *, mangel: Mangel, status: MangelStatus, benutzer_id: int, zustaendiger_server_id: int
|
||||||
mangel.erledigt_von = benutzer_id
|
) -> Mangel:
|
||||||
mangel.erledigt_am = datetime.now(timezone.utc)
|
"""Historie-Lücke (Roadmap-Review): Status-Änderungen wurden bisher NICHT
|
||||||
|
protokolliert - Mangel hatte anders als Kontrolle/Fehlbestand/Geräteinstanz
|
||||||
|
kein Audit-Trail, obwohl "wer hat den Mangel auf erledigt gesetzt" fachlich
|
||||||
|
genauso relevant ist."""
|
||||||
|
alter_status = mangel.status
|
||||||
|
mangel.status = status
|
||||||
|
if status == MangelStatus.erledigt:
|
||||||
|
mangel.erledigt_von = benutzer_id
|
||||||
|
mangel.erledigt_am = datetime.now(timezone.utc)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
await historie_service.log(
|
||||||
|
db,
|
||||||
|
zustaendiger_server_id=zustaendiger_server_id,
|
||||||
|
benutzer_id=benutzer_id,
|
||||||
|
ereignistyp="mangel_status_geaendert",
|
||||||
|
entitaet_typ="mangel",
|
||||||
|
entitaet_id=mangel.id,
|
||||||
|
alter_wert={"status": alter_status.value},
|
||||||
|
neuer_wert={"status": status.value},
|
||||||
|
)
|
||||||
return mangel
|
return mangel
|
||||||
|
|||||||
@@ -97,3 +97,18 @@ async def test_ueberfaellige_hu_macht_objekt_nicht_einsatzbereit_und_erscheint_i
|
|||||||
assert len(body) == 1
|
assert len(body) == 1
|
||||||
assert body[0]["typ"] == "fahrzeug_hu"
|
assert body[0]["typ"] == "fahrzeug_hu"
|
||||||
assert body[0]["status"] == "abgelaufen"
|
assert body[0]["status"] == "abgelaufen"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_objekt_status_aenderung_steht_in_historie(client, objekt_mit_position, admin_user):
|
||||||
|
objekt, _material = objekt_mit_position
|
||||||
|
token = await login(client, "admin1")
|
||||||
|
await client.patch(f"/api/v1/objekte/{objekt.id}", json={"status": "in_wartung"}, headers=auth_header(token))
|
||||||
|
|
||||||
|
historie = await client.get(
|
||||||
|
f"/api/v1/historie?entitaet_typ=objekt&entitaet_id={objekt.id}", headers=auth_header(token)
|
||||||
|
)
|
||||||
|
body = historie.json()
|
||||||
|
assert len(body) == 1
|
||||||
|
assert body[0]["ereignistyp"] == "objekt_geaendert"
|
||||||
|
assert body[0]["neuer_wert"]["status"] == "in_wartung"
|
||||||
|
|||||||
@@ -143,3 +143,29 @@ async def test_leitungsverantwortlicher_kann_status_aendern(client, objekt_mit_p
|
|||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["status"] == "erledigt"
|
assert response.json()["status"] == "erledigt"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mangel_meldung_und_status_aenderung_stehen_in_historie(
|
||||||
|
client, objekt_mit_position, mitarbeiter_user, materialverantwortlicher_user
|
||||||
|
):
|
||||||
|
objekt, _material = objekt_mit_position
|
||||||
|
mitarbeiter_token = await login(client, "mitarbeiter1")
|
||||||
|
gemeldet = await client.post(
|
||||||
|
"/api/v1/maengel",
|
||||||
|
json={"objekt_id": objekt.id, "beschreibung": "Funkgerät ausgefallen"},
|
||||||
|
headers=auth_header(mitarbeiter_token),
|
||||||
|
)
|
||||||
|
mangel_id = gemeldet.json()["id"]
|
||||||
|
|
||||||
|
verantwortlicher_token = await login(client, "materialverantwortlicher1")
|
||||||
|
await client.patch(
|
||||||
|
f"/api/v1/maengel/{mangel_id}", json={"status": "in_bearbeitung"}, headers=auth_header(verantwortlicher_token)
|
||||||
|
)
|
||||||
|
|
||||||
|
historie = await client.get(
|
||||||
|
f"/api/v1/historie?entitaet_typ=mangel&entitaet_id={mangel_id}", headers=auth_header(verantwortlicher_token)
|
||||||
|
)
|
||||||
|
ereignistypen = [e["ereignistyp"] for e in historie.json()]
|
||||||
|
assert "mangel_gemeldet" in ereignistypen
|
||||||
|
assert "mangel_status_geaendert" in ereignistypen
|
||||||
|
|||||||
@@ -196,3 +196,35 @@ async def test_unbefristete_qualifikation_erscheint_nicht_im_dashboard(client, a
|
|||||||
|
|
||||||
response = await client.get("/api/v1/dashboard/qualifikationsablaeufe", headers=auth_header(token))
|
response = await client.get("/api/v1/dashboard/qualifikationsablaeufe", headers=auth_header(token))
|
||||||
assert response.json() == []
|
assert response.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_qualifikation_erfassen_steht_in_historie(client, admin_user, mitarbeiter_user):
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
token = await login(client, "admin1")
|
||||||
|
quali = await client.post(
|
||||||
|
"/api/v1/qualifikationstypen",
|
||||||
|
json={"name": "Maschinist", "kategorie": "lehrgang"},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
quali_id = quali.json()["id"]
|
||||||
|
|
||||||
|
erfasst = await client.post(
|
||||||
|
f"/api/v1/benutzer/{mitarbeiter_user.id}/qualifikationen",
|
||||||
|
json={
|
||||||
|
"benutzer_id": mitarbeiter_user.id,
|
||||||
|
"qualifikationstyp_id": quali_id,
|
||||||
|
"erworben_am": date.today().isoformat(),
|
||||||
|
},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
qualifikation_id = erfasst.json()["id"]
|
||||||
|
|
||||||
|
historie = await client.get(
|
||||||
|
f"/api/v1/historie?entitaet_typ=benutzer_qualifikation&entitaet_id={qualifikation_id}",
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
body = historie.json()
|
||||||
|
assert len(body) == 1
|
||||||
|
assert body[0]["ereignistyp"] == "qualifikation_erfasst"
|
||||||
|
|||||||
@@ -7,7 +7,16 @@ interface Props {
|
|||||||
onFehler: (text: string) => void;
|
onFehler: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ENTITAET_TYPEN = ["kontrolle", "fehlbestand", "nachfuellung", "mindermengen_genehmigung"] as const;
|
const ENTITAET_TYPEN = [
|
||||||
|
"kontrolle",
|
||||||
|
"fehlbestand",
|
||||||
|
"nachfuellung",
|
||||||
|
"mindermengen_genehmigung",
|
||||||
|
"geraet_instanz",
|
||||||
|
"mangel",
|
||||||
|
"benutzer_qualifikation",
|
||||||
|
"objekt",
|
||||||
|
] as const;
|
||||||
|
|
||||||
function formatWert(wert: Record<string, unknown> | null): string {
|
function formatWert(wert: Record<string, unknown> | null): string {
|
||||||
if (!wert) return "–";
|
if (!wert) return "–";
|
||||||
|
|||||||
Reference in New Issue
Block a user