feat(tasks): Add task overview page with active, completed, and failed task summaries
This commit is contained in:
@@ -214,6 +214,21 @@
|
|||||||
data-bs-target="#truncate-series-modal">
|
data-bs-target="#truncate-series-modal">
|
||||||
<i class="bi bi-scissors"></i> Truncate series
|
<i class="bi bi-scissors"></i> Truncate series
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div class="card mt-2">
|
||||||
|
<div class="card-header d-flex align-items-center justify-content-between">
|
||||||
|
<span class="small fw-semibold">Background Task Progress</span>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a class="btn btn-outline-info btn-sm" href="{% url 'atlas:task_overview' %}">Task Overview</a>
|
||||||
|
<button id="clear-recon-monitors-btn" type="button" class="btn btn-outline-secondary btn-sm">Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<div id="persistent-recon-tasks" class="d-flex flex-column gap-2">
|
||||||
|
<div id="persistent-recon-empty" class="small text-muted">No tracked reconstruction tasks yet.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -399,6 +414,7 @@
|
|||||||
const totalImages = {{ image_url_array_and_count.1 }};
|
const totalImages = {{ image_url_array_and_count.1 }};
|
||||||
const viewerHeightKey = `series_viewer_height_${seriesPk}`;
|
const viewerHeightKey = `series_viewer_height_${seriesPk}`;
|
||||||
const truncateViewerHeightKey = `truncate_viewer_height_${seriesPk}`;
|
const truncateViewerHeightKey = `truncate_viewer_height_${seriesPk}`;
|
||||||
|
const reconTaskStorageKey = `series_recon_tasks_${seriesPk}`;
|
||||||
|
|
||||||
// Restore viewer height from localStorage
|
// Restore viewer height from localStorage
|
||||||
function restoreViewerHeight() {
|
function restoreViewerHeight() {
|
||||||
@@ -465,6 +481,94 @@
|
|||||||
syncOptimizeBounds();
|
syncOptimizeBounds();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStoredReconTasks() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(reconTaskStorageKey);
|
||||||
|
if (!raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStoredReconTasks(items) {
|
||||||
|
localStorage.setItem(reconTaskStorageKey, JSON.stringify(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertStoredReconTask(taskId, statusUrl) {
|
||||||
|
const current = getStoredReconTasks();
|
||||||
|
const existing = current.find((t) => t.taskId === taskId);
|
||||||
|
if (existing) {
|
||||||
|
existing.statusUrl = statusUrl;
|
||||||
|
existing.addedAt = Date.now();
|
||||||
|
} else {
|
||||||
|
current.unshift({ taskId, statusUrl, addedAt: Date.now() });
|
||||||
|
}
|
||||||
|
setStoredReconTasks(current.slice(0, 25));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeStoredReconTask(taskId) {
|
||||||
|
const filtered = getStoredReconTasks().filter((t) => t.taskId !== taskId);
|
||||||
|
setStoredReconTasks(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPersistentTaskMonitor(taskId, statusUrl) {
|
||||||
|
const wrap = document.getElementById('persistent-recon-tasks');
|
||||||
|
const empty = document.getElementById('persistent-recon-empty');
|
||||||
|
if (!wrap) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardId = `persistent-recon-card-${taskId}`;
|
||||||
|
if (document.getElementById(cardId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
empty?.classList.add('d-none');
|
||||||
|
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.id = cardId;
|
||||||
|
card.className = 'border rounded p-2 bg-body-tertiary';
|
||||||
|
|
||||||
|
const statusId = `persistent-recon-status-${taskId}`;
|
||||||
|
const statusUrlWithTarget = `${statusUrl}?target_id=${statusId}`;
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-1">
|
||||||
|
<div class="small fw-semibold">Recon task ${taskId}</div>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-remove-task-id="${taskId}">Hide</button>
|
||||||
|
</div>
|
||||||
|
<div id="${statusId}" hx-get="${statusUrlWithTarget}" hx-trigger="load, every 4s" hx-swap="outerHTML">
|
||||||
|
<div class="small text-muted">Loading task status...</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
wrap.prepend(card);
|
||||||
|
card.querySelector('[data-remove-task-id]')?.addEventListener('click', () => {
|
||||||
|
removeStoredReconTask(taskId);
|
||||||
|
card.remove();
|
||||||
|
if (!wrap.querySelector('[id^="persistent-recon-card-"]')) {
|
||||||
|
empty?.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (window.htmx) {
|
||||||
|
window.htmx.process(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStoredReconTaskMonitors() {
|
||||||
|
const tasks = getStoredReconTasks();
|
||||||
|
tasks.forEach((task) => {
|
||||||
|
if (task?.taskId && task?.statusUrl) {
|
||||||
|
createPersistentTaskMonitor(task.taskId, task.statusUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getTruncateViewerElement() {
|
function getTruncateViewerElement() {
|
||||||
return document.getElementById(truncateApiKey);
|
return document.getElementById(truncateApiKey);
|
||||||
}
|
}
|
||||||
@@ -612,6 +716,16 @@
|
|||||||
|
|
||||||
document.body.addEventListener('htmx:afterSwap', function (event) {
|
document.body.addEventListener('htmx:afterSwap', function (event) {
|
||||||
if (event.target && event.target.id === 'series-optimize-feedback') {
|
if (event.target && event.target.id === 'series-optimize-feedback') {
|
||||||
|
const reconPayload = document.getElementById('recon-task-payload');
|
||||||
|
if (reconPayload) {
|
||||||
|
const taskId = reconPayload.dataset.taskId;
|
||||||
|
const statusUrl = reconPayload.dataset.statusUrl;
|
||||||
|
if (taskId && statusUrl) {
|
||||||
|
upsertStoredReconTask(taskId, statusUrl);
|
||||||
|
createPersistentTaskMonitor(taskId, statusUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const truncatePayload = document.getElementById('truncate-range-payload');
|
const truncatePayload = document.getElementById('truncate-range-payload');
|
||||||
if (truncatePayload) {
|
if (truncatePayload) {
|
||||||
const start = parseInt(truncatePayload.dataset.start || '1', 10);
|
const start = parseInt(truncatePayload.dataset.start || '1', 10);
|
||||||
@@ -667,6 +781,15 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
syncOptimizeBounds();
|
syncOptimizeBounds();
|
||||||
|
renderStoredReconTaskMonitors();
|
||||||
|
|
||||||
|
document.getElementById('clear-recon-monitors-btn')?.addEventListener('click', () => {
|
||||||
|
setStoredReconTasks([]);
|
||||||
|
const wrap = document.getElementById('persistent-recon-tasks');
|
||||||
|
const empty = document.getElementById('persistent-recon-empty');
|
||||||
|
wrap?.querySelectorAll('[id^="persistent-recon-card-"]')?.forEach((node) => node.remove());
|
||||||
|
empty?.classList.remove('d-none');
|
||||||
|
});
|
||||||
|
|
||||||
$("#add-finding-button").click(() => {
|
$("#add-finding-button").click(() => {
|
||||||
$("#hidden-form").show()
|
$("#hidden-form").show()
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
{% load django_htmx %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
Atlas Task Overview
|
||||||
|
{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-xl py-3">
|
||||||
|
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
|
||||||
|
<h2 class="h4 mb-0">Atlas Task Overview</h2>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="?status=all">All</a>
|
||||||
|
<a class="btn btn-outline-warning btn-sm" href="?status=active">Active</a>
|
||||||
|
<a class="btn btn-outline-success btn-sm" href="?status=completed">Completed</a>
|
||||||
|
<a class="btn btn-outline-danger btn-sm" href="?status=failed">Failed</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if backend_missing %}
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
django_tasks_db backend is not available in this environment. Install and migrate it to see task records.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card border-warning">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="small text-muted">Active</div>
|
||||||
|
<div class="h4 mb-0 text-warning">{{ counts.active }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card border-success">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="small text-muted">Completed</div>
|
||||||
|
<div class="h4 mb-0 text-success">{{ counts.completed }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card border-danger">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="small text-muted">Failed</div>
|
||||||
|
<div class="h4 mb-0 text-danger">{{ counts.failed }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Task</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Queue</th>
|
||||||
|
<th>Enqueued</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Finished</th>
|
||||||
|
<th>Worker</th>
|
||||||
|
<th>Error</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for task in tasks %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="small fw-semibold">{{ task.task_path }}</div>
|
||||||
|
<div class="small text-muted">{{ task.id }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if task.status == "FAILED" %}
|
||||||
|
<span class="badge bg-danger">{{ task.status }}</span>
|
||||||
|
{% elif task.status == "SUCCESSFUL" %}
|
||||||
|
<span class="badge bg-success">{{ task.status }}</span>
|
||||||
|
{% elif task.status == "RUNNING" or task.status == "READY" %}
|
||||||
|
<span class="badge bg-warning text-dark">{{ task.status }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">{{ task.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ task.queue_name }}</td>
|
||||||
|
<td class="small">{{ task.enqueued_at|date:"Y-m-d H:i:s" }}</td>
|
||||||
|
<td class="small">{{ task.started_at|date:"Y-m-d H:i:s" }}</td>
|
||||||
|
<td class="small">{{ task.finished_at|date:"Y-m-d H:i:s" }}</td>
|
||||||
|
<td class="small">{{ task.worker_ids }}</td>
|
||||||
|
<td class="small text-danger">{{ task.exception_class_path|default:"" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="text-muted">No tasks found for this filter.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
@@ -552,6 +552,7 @@ urlpatterns = [
|
|||||||
path("structure/create", views.StructureCreate.as_view(), name="structure_create"),
|
path("structure/create", views.StructureCreate.as_view(), name="structure_create"),
|
||||||
path("series/<int:pk>/thumbnail/fail", views.series_thumbnail_fail, name="series_thumbnail_fail"),
|
path("series/<int:pk>/thumbnail/fail", views.series_thumbnail_fail, name="series_thumbnail_fail"),
|
||||||
path("series/<int:pk>/thumbnail", views.series_thumbnail, name="series_thumbnail"),
|
path("series/<int:pk>/thumbnail", views.series_thumbnail, name="series_thumbnail"),
|
||||||
|
path("tasks/overview/", views.task_overview, name="task_overview"),
|
||||||
# TODO: case context series viewing (so that we can view series in the context of a case)
|
# TODO: case context series viewing (so that we can view series in the context of a case)
|
||||||
path("series/<int:pk>", views.series_detail, name="series_detail"),
|
path("series/<int:pk>", views.series_detail, name="series_detail"),
|
||||||
path("series/<int:pk>/viewer", views.series_viewer, name="series_viewer"),
|
path("series/<int:pk>/viewer", views.series_viewer, name="series_viewer"),
|
||||||
|
|||||||
+62
-8
@@ -175,6 +175,11 @@ except ImportError:
|
|||||||
FAILED = "FAILED"
|
FAILED = "FAILED"
|
||||||
SUCCESSFUL = "SUCCESSFUL"
|
SUCCESSFUL = "SUCCESSFUL"
|
||||||
|
|
||||||
|
try:
|
||||||
|
from django_tasks_db.models import DBTaskResult
|
||||||
|
except ImportError:
|
||||||
|
DBTaskResult = None
|
||||||
|
|
||||||
from django_tables2 import SingleTableView, SingleTableMixin
|
from django_tables2 import SingleTableView, SingleTableMixin
|
||||||
from django_filters.views import FilterView
|
from django_filters.views import FilterView
|
||||||
|
|
||||||
@@ -712,6 +717,7 @@ def series_optimize_htmx(request, series_id):
|
|||||||
'hx-trigger="load, every 2s" hx-swap="outerHTML">'
|
'hx-trigger="load, every 2s" hx-swap="outerHTML">'
|
||||||
'<div class="d-flex align-items-center gap-2"><span class="spinner-border spinner-border-sm text-primary" role="status"></span>'
|
'<div class="d-flex align-items-center gap-2"><span class="spinner-border spinner-border-sm text-primary" role="status"></span>'
|
||||||
'<span class="small text-muted">Starting reconstruction task...</span></div></div>'
|
'<span class="small text-muted">Starting reconstruction task...</span></div></div>'
|
||||||
|
f'<div id="recon-task-payload" data-task-id="{escape(str(task_result.id))}" data-status-url="{escape(reverse("atlas:series_reconstruct_status", kwargs={"series_id": series.pk, "task_id": task_result.id}))}"></div>'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -722,18 +728,20 @@ def series_optimize_htmx(request, series_id):
|
|||||||
def series_reconstruct_status_htmx(request, series_id, task_id):
|
def series_reconstruct_status_htmx(request, series_id, task_id):
|
||||||
"""HTMX endpoint returning progress for a queued series reconstruction task."""
|
"""HTMX endpoint returning progress for a queued series reconstruction task."""
|
||||||
series = get_object_or_404(Series, pk=series_id)
|
series = get_object_or_404(Series, pk=series_id)
|
||||||
|
target_id = (request.GET.get("target_id") or "recon-task-status").strip() or "recon-task-status"
|
||||||
|
target_id = escape(target_id)
|
||||||
|
|
||||||
if not series.check_user_can_edit(request.user):
|
if not series.check_user_can_edit(request.user):
|
||||||
return HttpResponse('<div class="alert alert-danger mb-0">Permission denied</div>')
|
return HttpResponse(f'<div id="{target_id}" class="alert alert-danger mb-0">Permission denied</div>')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
task_result = series_reconstruct_task.get_result(task_id)
|
task_result = series_reconstruct_task.get_result(task_id)
|
||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
'<div id="recon-task-status" class="alert alert-warning mb-0">Task backend does not support result polling. Configure a backend with result retrieval support.</div>'
|
f'<div id="{target_id}" class="alert alert-warning mb-0">Task backend does not support result polling. Configure a backend with result retrieval support.</div>'
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return HttpResponse('<div id="recon-task-status" class="alert alert-danger mb-0">Task result not found.</div>')
|
return HttpResponse(f'<div id="{target_id}" class="alert alert-danger mb-0">Task result not found.</div>')
|
||||||
|
|
||||||
task_result.refresh()
|
task_result.refresh()
|
||||||
state = task_result.status
|
state = task_result.status
|
||||||
@@ -748,7 +756,7 @@ def series_reconstruct_status_htmx(request, series_id, task_id):
|
|||||||
if state in (TaskResultStatus.READY, TaskResultStatus.RUNNING):
|
if state in (TaskResultStatus.READY, TaskResultStatus.RUNNING):
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
(
|
(
|
||||||
f'<div id="recon-task-status" hx-get="{poll_url}" hx-trigger="every 2s" hx-swap="outerHTML">'
|
f'<div id="{target_id}" hx-get="{poll_url}?target_id={target_id}" hx-trigger="every 2s" hx-swap="outerHTML">'
|
||||||
f'<div class="small text-muted mb-1">{message}</div>'
|
f'<div class="small text-muted mb-1">{message}</div>'
|
||||||
'<div class="progress" style="height: 10px;">'
|
'<div class="progress" style="height: 10px;">'
|
||||||
f'<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: {pct}%" '
|
f'<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: {pct}%" '
|
||||||
@@ -775,24 +783,70 @@ def series_reconstruct_status_htmx(request, series_id, task_id):
|
|||||||
)
|
)
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
(
|
(
|
||||||
'<div id="recon-task-status" class="alert alert-success mb-0">'
|
f'<div id="{target_id}" class="alert alert-success mb-0">'
|
||||||
f'Reconstruction complete. Created <strong>{len(created_series)}</strong> series: {links}'
|
f'Reconstruction complete. Created <strong>{len(created_series)}</strong> series: {links}'
|
||||||
'</div>'
|
'</div>'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return HttpResponse('<div id="recon-task-status" class="alert alert-warning mb-0">Reconstruction finished but no output series were created.</div>')
|
return HttpResponse(f'<div id="{target_id}" class="alert alert-warning mb-0">Reconstruction finished but no output series were created.</div>')
|
||||||
|
|
||||||
if state == TaskResultStatus.FAILED:
|
if state == TaskResultStatus.FAILED:
|
||||||
err = "Unknown failure"
|
err = "Unknown failure"
|
||||||
if task_result.errors:
|
if task_result.errors:
|
||||||
err = escape(task_result.errors[-1].traceback.splitlines()[-1])
|
err = escape(task_result.errors[-1].traceback.splitlines()[-1])
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
f'<div id="recon-task-status" class="alert alert-danger mb-0">Reconstruction failed: {err}</div>'
|
f'<div id="{target_id}" class="alert alert-danger mb-0">Reconstruction failed: {err}</div>'
|
||||||
)
|
)
|
||||||
|
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
f'<div id="recon-task-status" class="alert alert-secondary mb-0">Task state: {escape(str(state))}</div>'
|
f'<div id="{target_id}" class="alert alert-secondary mb-0">Task state: {escape(str(state))}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@user_is_atlas_editor
|
||||||
|
def task_overview(request):
|
||||||
|
"""Atlas admin-style task overview page with active/completed/failed task summaries."""
|
||||||
|
if DBTaskResult is None:
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"atlas/task_overview.html",
|
||||||
|
{
|
||||||
|
"tasks": [],
|
||||||
|
"counts": {"active": 0, "completed": 0, "failed": 0},
|
||||||
|
"backend_missing": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
selected_status = (request.GET.get("status") or "all").strip().lower()
|
||||||
|
status_map = {
|
||||||
|
"active": ["READY", "RUNNING"],
|
||||||
|
"completed": ["SUCCESSFUL"],
|
||||||
|
"failed": ["FAILED"],
|
||||||
|
}
|
||||||
|
|
||||||
|
base_qs = DBTaskResult.objects.all().order_by("-enqueued_at")
|
||||||
|
if selected_status in status_map:
|
||||||
|
tasks = base_qs.filter(status__in=status_map[selected_status])[:300]
|
||||||
|
else:
|
||||||
|
tasks = base_qs[:300]
|
||||||
|
|
||||||
|
counts = {
|
||||||
|
"active": DBTaskResult.objects.filter(status__in=["READY", "RUNNING"]).count(),
|
||||||
|
"completed": DBTaskResult.objects.filter(status="SUCCESSFUL").count(),
|
||||||
|
"failed": DBTaskResult.objects.filter(status="FAILED").count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"atlas/task_overview.html",
|
||||||
|
{
|
||||||
|
"tasks": tasks,
|
||||||
|
"counts": counts,
|
||||||
|
"selected_status": selected_status,
|
||||||
|
"backend_missing": False,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user