feat(0.35.0): jalons en écriture depuis la page Projet (issue #26)
CI / test (push) Successful in 2m22s
CI / test (push) Successful in 2m22s
- Clients : create_milestone / update_milestone (cache invalidé) ; GitHub hérite des mêmes endpoints. - API Kanbante : création, modification (titre/échéance, échéance vidée = retirée), fermeture ; CSRF, validation de date avant toute écriture, erreurs forge en 502. - Page Projet : formulaire 'Nouveau jalon' + édition/ fermeture par jalon (project.js versionné). - Échéance à midi UTC : à minuit, le fuseau de l'instance Gitea faisait reculer la date affichée d'un jour (réel). - 291 tests verts (5 nouveaux, palier 24) ; cycle réel complet sur Projets/kanbante (jalons smoke #37/#38 créés, dates vérifiées, fermés).
This commit is contained in:
@@ -1,5 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
## 0.35.0 — 2026-10-26
|
||||
|
||||
### Ajouté — jalons en écriture depuis la page Projet (#26)
|
||||
|
||||
Seconde et dernière carte du backlog de la feuille de
|
||||
route « Gestion de projets » : les jalons n'étaient que lus
|
||||
dans Kanbante, ils s'écrivent maintenant — la forge reste
|
||||
la source de vérité, rien n'est stocké localement.
|
||||
|
||||
- **Créer** un jalon (titre, description et échéance
|
||||
optionnelles) depuis la page Projet, formulaire
|
||||
« ➕ Nouveau jalon ».
|
||||
- **Modifier** titre et échéance d'un jalon ouvert
|
||||
(formulaire déplié par jalon ; échéance vidée = échéance
|
||||
retirée, par un `null` explicite côté forge).
|
||||
- **Fermer** un jalon, avec confirmation (ses issues
|
||||
restent ouvertes).
|
||||
- Écritures dans la forge via son API (`POST/PATCH
|
||||
.../milestones`), CSRF vérifié, échéance invalide
|
||||
refusée proprement **avant** toute écriture ; le board
|
||||
filtré et le bandeau de progression reflètent le nouveau
|
||||
jalon au chargement suivant, sans redémarrage.
|
||||
- Échéance envoyée à **midi UTC** : Gitea renvoie les dates
|
||||
dans le fuseau de son instance — à minuit UTC, l'échéance
|
||||
affichée reculait d'un jour (attrapé au smoke réel).
|
||||
- Hors périmètre (assumé par l'issue) : supprimer un jalon,
|
||||
réassigner les issues entre jalons.
|
||||
|
||||
|
||||
## 0.34.0 — 2026-10-26
|
||||
|
||||
### Ajouté — vue « En vol partout » /inflight (issue #24)
|
||||
|
||||
+1
-1
@@ -177,7 +177,7 @@ from urllib.parse import urlparse
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
APP_VERSION = "0.34.0"
|
||||
APP_VERSION = "0.35.0"
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
|
||||
@@ -477,6 +477,59 @@ class GiteaClient:
|
||||
),
|
||||
)
|
||||
|
||||
async def create_milestone(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
title: str,
|
||||
description: str = "",
|
||||
due_on: str | None = None,
|
||||
) -> dict:
|
||||
"""Create a milestone on the forge (issue #26)."""
|
||||
payload: dict = {"title": title, "description": description}
|
||||
if due_on:
|
||||
payload["due_on"] = due_on
|
||||
r = await self.client.post(
|
||||
f"/repos/{owner}/{repo}/milestones", json=payload
|
||||
)
|
||||
r.raise_for_status()
|
||||
self._cache_clear()
|
||||
return r.json()
|
||||
|
||||
async def update_milestone(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
milestone_id: int,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
due_on: str | None = None,
|
||||
clear_due: bool = False,
|
||||
state: str | None = None,
|
||||
) -> dict:
|
||||
"""Patch a milestone (title, due date, state) — #26.
|
||||
|
||||
due_on=None + clear_due=False leaves the date alone;
|
||||
clear_due=True sends an explicit null (Gitea clears it).
|
||||
"""
|
||||
payload: dict = {}
|
||||
if title is not None:
|
||||
payload["title"] = title
|
||||
if description is not None:
|
||||
payload["description"] = description
|
||||
if clear_due:
|
||||
payload["due_on"] = None
|
||||
elif due_on:
|
||||
payload["due_on"] = due_on
|
||||
if state is not None:
|
||||
payload["state"] = state
|
||||
r = await self.client.patch(
|
||||
f"/repos/{owner}/{repo}/milestones/{milestone_id}", json=payload
|
||||
)
|
||||
r.raise_for_status()
|
||||
self._cache_clear()
|
||||
return r.json()
|
||||
|
||||
async def list_starred(self) -> list[dict]:
|
||||
"""Repos starred by the token user (issue #20: pinning rides
|
||||
on Gitea stars — the forge stays the source of truth)."""
|
||||
|
||||
+133
@@ -3147,7 +3147,10 @@ async def project_page(request: Request, owner: str, repo: str):
|
||||
est = forecast_service.estimate(opened, weekly)
|
||||
milestones.append(
|
||||
{
|
||||
"id": m.get("id"),
|
||||
"title": m.get("title", ""),
|
||||
"description": m.get("description") or "",
|
||||
"due_iso": due_date.isoformat() if due_date else "",
|
||||
"total": total,
|
||||
"closed": closed,
|
||||
"open": opened,
|
||||
@@ -4373,6 +4376,136 @@ async def my_issues(request: Request):
|
||||
)
|
||||
|
||||
|
||||
def _milestone_due_iso(due: str) -> tuple[str | None, bool]:
|
||||
"""Validate a YYYY-MM-DD form date -> (ISO instant, ok)."""
|
||||
due = (due or "").strip()
|
||||
if not due:
|
||||
return None, True
|
||||
try:
|
||||
parsed = datetime.strptime(due, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return None, False
|
||||
# Midi UTC, pas minuit : Gitea renvoie l'échéance dans le
|
||||
# fuseau de son instance (Toronto = UTC-5) — à minuit UTC,
|
||||
# la date affichée reculait d'un jour (attrapé en réel).
|
||||
return parsed.strftime("%Y-%m-%dT12:00:00Z"), True
|
||||
|
||||
|
||||
@app.post("/api/repos/{owner}/{repo}/milestones")
|
||||
async def api_milestone_create(
|
||||
request: Request,
|
||||
owner: str,
|
||||
repo: str,
|
||||
title: str = Form(""),
|
||||
description: str = Form(""),
|
||||
due_on: str = Form(""),
|
||||
csrf_token: str = Form(""),
|
||||
):
|
||||
"""Create a milestone on the forge (issue #26)."""
|
||||
if not _csrf_valid(request, csrf_token):
|
||||
return _csrf_error()
|
||||
title = title.strip()
|
||||
if not title or len(title) > 255:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Titre requis (255 caractères max)"},
|
||||
status_code=400,
|
||||
)
|
||||
due_iso, ok = _milestone_due_iso(due_on)
|
||||
if not ok:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Échéance invalide (format AAAA-MM-JJ)"},
|
||||
status_code=400,
|
||||
)
|
||||
client, _cid, _ws = await _resolve_board_scope(request, owner, repo)
|
||||
try:
|
||||
milestone = await client.create_milestone(
|
||||
owner, repo, title, description.strip(), due_iso
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": f"La forge répond {exc.response.status_code}"},
|
||||
status_code=502,
|
||||
)
|
||||
return JSONResponse(
|
||||
{"ok": True, "milestone": {
|
||||
"id": milestone.get("id"),
|
||||
"title": milestone.get("title"),
|
||||
"due_on": milestone.get("due_on"),
|
||||
}},
|
||||
status_code=201,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/repos/{owner}/{repo}/milestones/{milestone_id}/update")
|
||||
async def api_milestone_update(
|
||||
request: Request,
|
||||
owner: str,
|
||||
repo: str,
|
||||
milestone_id: int,
|
||||
title: str = Form(""),
|
||||
due_on: str = Form(""),
|
||||
csrf_token: str = Form(""),
|
||||
):
|
||||
"""Edit a milestone's title / due date (issue #26).
|
||||
|
||||
An empty due date CLEARS the milestone's deadline.
|
||||
"""
|
||||
if not _csrf_valid(request, csrf_token):
|
||||
return _csrf_error()
|
||||
title = title.strip()
|
||||
if not title or len(title) > 255:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Titre requis (255 caractères max)"},
|
||||
status_code=400,
|
||||
)
|
||||
due_iso, ok = _milestone_due_iso(due_on)
|
||||
if not ok:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Échéance invalide (format AAAA-MM-JJ)"},
|
||||
status_code=400,
|
||||
)
|
||||
client, _cid, _ws = await _resolve_board_scope(request, owner, repo)
|
||||
try:
|
||||
milestone = await client.update_milestone(
|
||||
owner, repo, milestone_id, title=title,
|
||||
due_on=due_iso, clear_due=due_iso is None,
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": f"La forge répond {exc.response.status_code}"},
|
||||
status_code=502,
|
||||
)
|
||||
return {"ok": True, "milestone": {
|
||||
"id": milestone.get("id"),
|
||||
"title": milestone.get("title"),
|
||||
"due_on": milestone.get("due_on"),
|
||||
}}
|
||||
|
||||
|
||||
@app.post("/api/repos/{owner}/{repo}/milestones/{milestone_id}/close")
|
||||
async def api_milestone_close(
|
||||
request: Request,
|
||||
owner: str,
|
||||
repo: str,
|
||||
milestone_id: int,
|
||||
csrf_token: str = Form(""),
|
||||
):
|
||||
"""Close a milestone on the forge (issue #26)."""
|
||||
if not _csrf_valid(request, csrf_token):
|
||||
return _csrf_error()
|
||||
client, _cid, _ws = await _resolve_board_scope(request, owner, repo)
|
||||
try:
|
||||
await client.update_milestone(
|
||||
owner, repo, milestone_id, state="closed"
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": f"La forge répond {exc.response.status_code}"},
|
||||
status_code=502,
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Briefing ───────────────────────────────────────────────
|
||||
|
||||
async def _briefing_data() -> dict:
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ full_name }} — Projet · Kanbante</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📋</text></svg>">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<meta name="repo-full-name" content="{{ full_name }}">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
@@ -51,11 +53,39 @@
|
||||
{% else %}
|
||||
<div class="ms-meta">Aucune issue dans ce jalon pour le moment.</div>
|
||||
{% endif %}
|
||||
{% if ms.id %}
|
||||
<details class="ms-edit">
|
||||
<summary>✏️ Modifier / fermer</summary>
|
||||
<form class="ms-form" data-ms-form data-action="/api/repos/{{ full_name }}/milestones/{{ ms.id }}/update">
|
||||
<label>Titre <input type="text" name="title" value="{{ ms.title }}" maxlength="255" required></label>
|
||||
<label>Échéance <input type="date" name="due_on" value="{{ ms.due_iso }}">
|
||||
<span class="ms-hint">vide = retirer l'échéance</span></label>
|
||||
<div class="ms-form-actions">
|
||||
<button type="submit" class="btn-save btn-inline">💾 Enregistrer</button>
|
||||
</div>
|
||||
</form>
|
||||
<form class="ms-form" data-ms-form data-action="/api/repos/{{ full_name }}/milestones/{{ ms.id }}/close"
|
||||
data-confirm="Fermer le jalon « {{ ms.title }} » ? Ses issues restent ouvertes, sans jalon actif.">
|
||||
<button type="submit" class="btn-mini btn-danger">Fermer le jalon</button>
|
||||
</form>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="proj-empty">Aucun jalon ouvert. Les jalons se créent dans Gitea — le board et le récit de sprint s'appuient dessus.</p>
|
||||
<p class="proj-empty">Aucun jalon ouvert pour le moment.</p>
|
||||
{% endif %}
|
||||
<details class="ms-edit ms-new">
|
||||
<summary>➕ Nouveau jalon</summary>
|
||||
<form class="ms-form" data-ms-form data-action="/api/repos/{{ full_name }}/milestones">
|
||||
<label>Titre <input type="text" name="title" maxlength="255" required placeholder="Ex. : Version 2.0"></label>
|
||||
<label>Échéance <input type="date" name="due_on"></label>
|
||||
<label>Description <textarea name="description" rows="2" maxlength="2000" placeholder="Optionnelle"></textarea></label>
|
||||
<div class="ms-form-actions">
|
||||
<button type="submit" class="btn-save btn-inline">🎯 Créer le jalon</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="proj-panel">
|
||||
@@ -124,6 +154,7 @@
|
||||
|
||||
<footer>Kanbante v{{ version }} · Page projet — jalons, santé, prévisions, activité et notes en un coup d'œil.</footer>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/project.js?v={{ version }}" defer></script>
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "kanbante"
|
||||
version = "0.34.0"
|
||||
version = "0.35.0"
|
||||
description = "Kanban project manager integrated with Gitea"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -1738,3 +1738,15 @@ body.sidebar-collapsed .ws-switch { display: none; }
|
||||
.inflight-total { font-weight: 700; margin-right: 4px; }
|
||||
.inflight-repo { color: var(--accent-2); font-weight: 600; font-size: 0.85rem; }
|
||||
.inflight-wip { color: var(--muted, #9aa3b5); font-size: 0.8rem; font-weight: 400; margin-left: 8px; }
|
||||
|
||||
/* ── Page Projet : gestion des jalons (0.35.0, #26) ── */
|
||||
.ms-edit { margin-top: 8px; }
|
||||
.ms-edit summary { cursor: pointer; font-size: 0.85rem; color: var(--accent-2); list-style: none; }
|
||||
.ms-edit summary::-webkit-details-marker { display: none; }
|
||||
.ms-edit summary:hover { text-decoration: underline; }
|
||||
.ms-form { display: flex; flex-direction: column; gap: 8px; margin: 10px 0; padding: 10px 12px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 10px; }
|
||||
.ms-form label { display: flex; flex-direction: column; gap: 4px; font-size: 0.85rem; }
|
||||
.ms-form input[type="text"], .ms-form input[type="date"], .ms-form textarea { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; color: inherit; font-family: inherit; }
|
||||
.ms-form input:focus, .ms-form textarea:focus { outline: none; border-color: var(--accent); }
|
||||
.ms-hint { color: var(--muted, #9aa3b5); font-size: 0.78rem; }
|
||||
.ms-form-actions { display: flex; gap: 8px; }
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Page Projet — écritures de jalons (0.35.0, issue #26).
|
||||
// Les formulaires data-ms-form sont interceptés : POST vers
|
||||
// l'API Kanbante (qui écrit dans la forge), puis rechargement
|
||||
// pour refléter l'état réel.
|
||||
(() => {
|
||||
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
document.querySelectorAll('form[data-ms-form]').forEach((form) => {
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const confirmMsg = form.dataset.confirm;
|
||||
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
||||
const fd = new FormData(form);
|
||||
fd.append('csrf_token', csrf);
|
||||
const btn = form.querySelector('button[type="submit"]');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch(form.dataset.action, { method: 'POST', body: fd });
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
if (btn) btn.disabled = false;
|
||||
window.alert('❌ ' + err.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
+55
-6
@@ -359,15 +359,64 @@ class FakeGitea:
|
||||
else:
|
||||
self.starred.discard(f"{owner}/{repo}")
|
||||
|
||||
async def get_milestones(self, owner, repo, state="open"):
|
||||
return [
|
||||
@property
|
||||
def _milestones(self):
|
||||
return self.__dict__.setdefault(
|
||||
"milestones_store",
|
||||
{
|
||||
"id": 1, "title": "Sprint 1",
|
||||
"due_on": "2026-10-20T00:00:00Z",
|
||||
"open_issues": 1, "closed_issues": 1,
|
||||
}
|
||||
"Projets/demo": [
|
||||
{
|
||||
"id": 1, "title": "Sprint 1",
|
||||
"description": "",
|
||||
"due_on": "2026-10-20T00:00:00Z",
|
||||
"state": "open",
|
||||
"open_issues": 1, "closed_issues": 1,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
async def get_milestones(self, owner, repo, state="open"):
|
||||
items = self._milestones.get(f"{owner}/{repo}", [])
|
||||
return [
|
||||
dict(m) for m in items
|
||||
if state == "all" or m.get("state", "open") == state
|
||||
]
|
||||
|
||||
async def create_milestone(
|
||||
self, owner, repo, title, description="", due_on=None
|
||||
):
|
||||
store = self._milestones.setdefault(f"{owner}/{repo}", [])
|
||||
milestone = {
|
||||
"id": 100 + len(store),
|
||||
"title": title,
|
||||
"description": description,
|
||||
"due_on": due_on,
|
||||
"state": "open",
|
||||
"open_issues": 0, "closed_issues": 0,
|
||||
}
|
||||
store.append(milestone)
|
||||
return dict(milestone)
|
||||
|
||||
async def update_milestone(
|
||||
self, owner, repo, milestone_id, title=None, description=None,
|
||||
due_on=None, clear_due=False, state=None,
|
||||
):
|
||||
for m in self._milestones.get(f"{owner}/{repo}", []):
|
||||
if m["id"] == milestone_id:
|
||||
if title is not None:
|
||||
m["title"] = title
|
||||
if description is not None:
|
||||
m["description"] = description
|
||||
if clear_due:
|
||||
m["due_on"] = None
|
||||
elif due_on:
|
||||
m["due_on"] = due_on
|
||||
if state is not None:
|
||||
m["state"] = state
|
||||
return dict(m)
|
||||
raise AssertionError("jalon introuvable dans le faux")
|
||||
|
||||
async def create_issue(self, owner, repo, title, body=""):
|
||||
self._next_number += 1
|
||||
issue = _issue(self._next_number, title)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Palier 24 (0.35.0, issue #26): jalons en écriture —
|
||||
créer, modifier (titre/échéance), fermer, depuis Kanbante."""
|
||||
|
||||
from tests.conftest import csrf_from
|
||||
|
||||
|
||||
def test_milestone_create_via_api(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/repos/Projets/demo/milestones",
|
||||
data={
|
||||
"title": "Version 2.0",
|
||||
"description": "Le gros morceau",
|
||||
"due_on": "2027-01-31",
|
||||
"csrf_token": token,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
payload = r.json()
|
||||
assert payload["ok"] is True
|
||||
assert payload["milestone"]["title"] == "Version 2.0"
|
||||
assert payload["milestone"]["due_on"] == "2027-01-31T12:00:00Z"
|
||||
titles = [
|
||||
m["title"] for m in fake_gitea._milestones["Projets/demo"]
|
||||
]
|
||||
assert "Version 2.0" in titles
|
||||
|
||||
|
||||
def test_milestone_create_rejects_bad_date(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
avant = len(fake_gitea._milestones["Projets/demo"])
|
||||
r = client.post(
|
||||
"/api/repos/Projets/demo/milestones",
|
||||
data={
|
||||
"title": "Jalon invalide",
|
||||
"due_on": "31/01/2027",
|
||||
"csrf_token": token,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
# Rien d'écrit côté forge.
|
||||
assert len(fake_gitea._milestones["Projets/demo"]) == avant
|
||||
|
||||
|
||||
def test_milestone_update_title_and_clear_due(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/repos/Projets/demo/milestones/1/update",
|
||||
data={"title": "Sprint 1 — prolongé", "due_on": "", "csrf_token": token},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
sprint = fake_gitea._milestones["Projets/demo"][0]
|
||||
assert sprint["title"] == "Sprint 1 — prolongé"
|
||||
assert sprint["due_on"] is None # échéance retirée
|
||||
|
||||
|
||||
def test_milestone_close(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/repos/Projets/demo/milestones/1/close",
|
||||
data={"csrf_token": token},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert fake_gitea._milestones["Projets/demo"][0]["state"] == "closed"
|
||||
# La page Projet ne liste plus le jalon fermé parmi les ouverts.
|
||||
page = client.get("/project/Projets/demo")
|
||||
assert "Version" in page.text # la page répond toujours
|
||||
assert 'data-action="/api/repos/Projets/demo/milestones"' in page.text
|
||||
|
||||
|
||||
def test_project_page_has_milestone_management(client):
|
||||
page = client.get("/project/Projets/demo")
|
||||
assert page.status_code == 200
|
||||
assert "Nouveau jalon" in page.text
|
||||
assert "Modifier / fermer" in page.text
|
||||
assert 'name="due_on"' in page.text
|
||||
assert "/static/js/project.js?v=" in page.text
|
||||
Reference in New Issue
Block a user