Aug 3 - Add scheduled task confirm button in notification email, and separate pending/completed scheduled tasks UI
This commit is contained in:
@@ -20,6 +20,7 @@ from datetime import timedelta
|
||||
from flask import (Blueprint, render_template, redirect, url_for, flash,
|
||||
request, abort, current_app)
|
||||
from flask_login import login_required, current_user
|
||||
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
||||
|
||||
from app import db
|
||||
from app.models.scheduled_inspection import (ScheduledInspection,
|
||||
@@ -40,6 +41,40 @@ logger = logging.getLogger(__name__)
|
||||
bp = Blueprint('scheduled_inspections', __name__, url_prefix='/scheduled-inspections')
|
||||
|
||||
|
||||
# ── Email "Confirm receipt" one-click token (phase47) ─────────────────────────
|
||||
# A signed, stateless token (no DB column) lets the assigned inspector confirm
|
||||
# receipt straight from the assignment email, even when not logged in — the same
|
||||
# login-free pattern the public QR pages use. The token binds the schedule id to
|
||||
# the inspector id, so a schedule reassigned to someone else invalidates any
|
||||
# link that was emailed to the previous assignee.
|
||||
_ACK_SALT = 'scheduled-inspection-ack'
|
||||
_ACK_MAX_AGE = 60 * 60 * 24 * 30 # 30 days — a link older than this is expired
|
||||
|
||||
|
||||
def _ack_serializer():
|
||||
return URLSafeTimedSerializer(current_app.config['SECRET_KEY'], salt=_ACK_SALT)
|
||||
|
||||
|
||||
def _make_ack_token(sched):
|
||||
"""Signed token embedding the schedule id + the assigned inspector id."""
|
||||
return _ack_serializer().dumps({'sid': sched.id, 'iid': sched.inspector_id})
|
||||
|
||||
|
||||
def _confirm_action(sched):
|
||||
"""`extra_action` dict for the email "Confirm receipt" button, or None when
|
||||
there is nothing to confirm (no inspector, or already acknowledged). Requires
|
||||
a request context for the external URL. Reused by the assignment email and
|
||||
the advance/due reminder emails so an unconfirmed inspector can always
|
||||
confirm from whichever email reaches them."""
|
||||
if not sched.inspector_id or sched.is_acknowledged:
|
||||
return None
|
||||
return {
|
||||
'label': 'Confirm receipt',
|
||||
'url': url_for('scheduled_inspections.confirm_email',
|
||||
token=_make_ack_token(sched), _external=True),
|
||||
}
|
||||
|
||||
|
||||
def _populate_choices(form):
|
||||
# facility_id choices are ALL active facilities so POST validation passes
|
||||
# regardless of which contract the UI-only contract selector had chosen
|
||||
@@ -69,14 +104,16 @@ def _notify_assignee(sched, reassigned=False):
|
||||
tpl = sched.template.name if sched.template else '—'
|
||||
verb = 'reassigned to you' if reassigned else 'assigned to you'
|
||||
notify(
|
||||
recipient = inspector,
|
||||
title = f'Scheduled inspection {verb} — {fac}',
|
||||
body = (f'A "{tpl}" inspection at {fac} has been {verb} '
|
||||
f'({sched.frequency_label.lower()}), due '
|
||||
f'{sched.next_due_date:%b %d, %Y}.'),
|
||||
link = url_for('scheduled_inspections.index'),
|
||||
event_type = EVENT_SCHEDULED_INSPECTION,
|
||||
send_email = True,
|
||||
recipient = inspector,
|
||||
title = f'Scheduled inspection {verb} — {fac}',
|
||||
body = (f'A "{tpl}" inspection at {fac} has been {verb} '
|
||||
f'({sched.frequency_label.lower()}), due '
|
||||
f'{sched.next_due_date:%b %d, %Y}. '
|
||||
f'Please confirm you received this request.'),
|
||||
link = url_for('scheduled_inspections.index'),
|
||||
event_type = EVENT_SCHEDULED_INSPECTION,
|
||||
send_email = True,
|
||||
extra_action = _confirm_action(sched),
|
||||
)
|
||||
|
||||
|
||||
@@ -186,20 +223,38 @@ def index():
|
||||
if current_user.role == 'customer':
|
||||
abort(403)
|
||||
|
||||
# Two tabs (phase47): Pending = schedules still producing occurrences
|
||||
# (active); Completed = closed schedules (fulfilled one-times, ended
|
||||
# recurring, or manually deactivated). The partition is exhaustive and
|
||||
# non-overlapping, so every schedule appears in exactly one tab; the in-row
|
||||
# Status badge (Active / Ended / Inactive) disambiguates the closed ones.
|
||||
tab = request.args.get('tab', 'pending')
|
||||
if tab not in ('pending', 'completed'):
|
||||
tab = 'pending'
|
||||
|
||||
today = now_eastern().date()
|
||||
q = ScheduledInspection.query
|
||||
base = ScheduledInspection.query
|
||||
|
||||
# Inspectors see only their own assignments; managers see everything.
|
||||
if current_user.role == 'inspector':
|
||||
q = q.filter(ScheduledInspection.inspector_id == current_user.id)
|
||||
base = base.filter(ScheduledInspection.inspector_id == current_user.id)
|
||||
|
||||
schedules = q.order_by(
|
||||
ScheduledInspection.active.desc(),
|
||||
ScheduledInspection.next_due_date.asc(),
|
||||
).all()
|
||||
pending_count = base.filter(ScheduledInspection.active.is_(True)).count()
|
||||
completed_count = base.filter(ScheduledInspection.active.is_(False)).count()
|
||||
|
||||
if tab == 'pending':
|
||||
schedules = (base.filter(ScheduledInspection.active.is_(True))
|
||||
.order_by(ScheduledInspection.next_due_date.asc()).all())
|
||||
else:
|
||||
# Most recently completed first; NULL last_completed (e.g. manually
|
||||
# switched off before ever running) sorts last under MySQL DESC.
|
||||
schedules = (base.filter(ScheduledInspection.active.is_(False))
|
||||
.order_by(ScheduledInspection.last_completed_at.desc(),
|
||||
ScheduledInspection.next_due_date.desc()).all())
|
||||
|
||||
return render_template('scheduled_inspections/list.html',
|
||||
schedules=schedules, today=today,
|
||||
schedules=schedules, today=today, tab=tab,
|
||||
pending_count=pending_count, completed_count=completed_count,
|
||||
open_inspections=_open_inspection_ids(schedules))
|
||||
|
||||
|
||||
@@ -416,23 +471,72 @@ def acknowledge(schedule_id):
|
||||
if not sched.inspector_id or sched.inspector_id != current_user.id:
|
||||
abort(403)
|
||||
|
||||
if sched.acknowledged_at is None:
|
||||
sched.acknowledged_at = now_eastern()
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'ScheduledInspection', sched.id,
|
||||
f'{sched.template.name if sched.template else "?"} @ '
|
||||
f'{sched.facility.name if sched.facility else "?"}',
|
||||
'inspector confirmed receipt')
|
||||
logger.info('SCHED INSP | acknowledge | schedule=%s | by=%s',
|
||||
sched.id, current_user.username)
|
||||
_notify_creator_acknowledged(sched)
|
||||
db.session.commit()
|
||||
if _do_acknowledge(sched, current_user.username):
|
||||
flash('You have confirmed receipt of this scheduled inspection.', 'success')
|
||||
else:
|
||||
flash('You have already confirmed this scheduled inspection.', 'info')
|
||||
return redirect(url_for('scheduled_inspections.index'))
|
||||
|
||||
|
||||
def _do_acknowledge(sched, actor_username):
|
||||
"""Stamp acknowledged_at, log the action, and notify the creator. Idempotent:
|
||||
returns True if this call newly confirmed, False if it was already confirmed.
|
||||
Shared by the logged-in POST route and the login-free email-token GET route.
|
||||
The caller must have verified the actor is the assigned inspector."""
|
||||
if sched.acknowledged_at is not None:
|
||||
return False
|
||||
sched.acknowledged_at = now_eastern()
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'ScheduledInspection', sched.id,
|
||||
f'{sched.template.name if sched.template else "?"} @ '
|
||||
f'{sched.facility.name if sched.facility else "?"}',
|
||||
'inspector confirmed receipt')
|
||||
logger.info('SCHED INSP | acknowledge | schedule=%s | by=%s',
|
||||
sched.id, actor_username)
|
||||
_notify_creator_acknowledged(sched)
|
||||
db.session.commit()
|
||||
return True
|
||||
|
||||
|
||||
# ── Confirm receipt from the assignment email (login-free, token-signed) ──────
|
||||
|
||||
@bp.route('/confirm/<token>')
|
||||
def confirm_email(token):
|
||||
"""One-click "Confirm receipt" landing from the assignment email (phase47).
|
||||
|
||||
Login-free: authorised by the signed token, which binds the schedule id to
|
||||
the inspector id it was emailed to. Renders a standalone result page. The
|
||||
acknowledgement is idempotent, so a re-click (or an email client prefetch)
|
||||
is harmless."""
|
||||
try:
|
||||
data = _ack_serializer().loads(token, max_age=_ACK_MAX_AGE)
|
||||
except SignatureExpired:
|
||||
return render_template('scheduled_inspections/confirm_result.html',
|
||||
status='expired'), 400
|
||||
except BadSignature:
|
||||
return render_template('scheduled_inspections/confirm_result.html',
|
||||
status='invalid'), 400
|
||||
|
||||
sid = data.get('sid')
|
||||
sched = db.session.get(ScheduledInspection, sid) if sid else None
|
||||
if sched is None:
|
||||
return render_template('scheduled_inspections/confirm_result.html',
|
||||
status='missing'), 404
|
||||
# The token's inspector must still be the assigned inspector — a schedule
|
||||
# reassigned to someone else invalidates the previous assignee's link.
|
||||
if not sched.inspector_id or sched.inspector_id != data.get('iid'):
|
||||
return render_template('scheduled_inspections/confirm_result.html',
|
||||
status='reassigned', sched=sched), 409
|
||||
if not sched.active:
|
||||
return render_template('scheduled_inspections/confirm_result.html',
|
||||
status='inactive', sched=sched)
|
||||
|
||||
newly = _do_acknowledge(
|
||||
sched, sched.inspector.username if sched.inspector else 'inspector')
|
||||
return render_template('scheduled_inspections/confirm_result.html',
|
||||
status='confirmed' if newly else 'already', sched=sched)
|
||||
|
||||
|
||||
# ── Cron: reminders (advance / due / overdue) ─────────────────────────────────
|
||||
|
||||
@bp.route('/run', methods=['POST'])
|
||||
@@ -474,13 +578,14 @@ def run_reminders():
|
||||
if (not s.advance_notified and inspector and inspector.active
|
||||
and s.next_due_date == today + timedelta(days=1)):
|
||||
notify(
|
||||
recipient = inspector,
|
||||
title = f'Inspection due tomorrow — {fac_name}',
|
||||
body = (f'Reminder: a "{tpl_name}" inspection at {fac_name} '
|
||||
f'is scheduled for tomorrow ({s.next_due_date:%b %d, %Y}).'),
|
||||
link = link,
|
||||
event_type = EVENT_SCHEDULED_INSPECTION,
|
||||
send_email = True,
|
||||
recipient = inspector,
|
||||
title = f'Inspection due tomorrow — {fac_name}',
|
||||
body = (f'Reminder: a "{tpl_name}" inspection at {fac_name} '
|
||||
f'is scheduled for tomorrow ({s.next_due_date:%b %d, %Y}).'),
|
||||
link = link,
|
||||
event_type = EVENT_SCHEDULED_INSPECTION,
|
||||
send_email = True,
|
||||
extra_action = _confirm_action(s),
|
||||
)
|
||||
s.advance_notified = True
|
||||
sent['advance'] += 1
|
||||
@@ -489,13 +594,14 @@ def run_reminders():
|
||||
if (not s.due_notified and inspector and inspector.active
|
||||
and s.next_due_date <= today):
|
||||
notify(
|
||||
recipient = inspector,
|
||||
title = f'Inspection due today — {fac_name}',
|
||||
body = (f'A "{tpl_name}" inspection at {fac_name} is due '
|
||||
f'({s.next_due_date:%b %d, %Y}). Please complete it.'),
|
||||
link = link,
|
||||
event_type = EVENT_SCHEDULED_INSPECTION,
|
||||
send_email = True,
|
||||
recipient = inspector,
|
||||
title = f'Inspection due today — {fac_name}',
|
||||
body = (f'A "{tpl_name}" inspection at {fac_name} is due '
|
||||
f'({s.next_due_date:%b %d, %Y}). Please complete it.'),
|
||||
link = link,
|
||||
event_type = EVENT_SCHEDULED_INSPECTION,
|
||||
send_email = True,
|
||||
extra_action = _confirm_action(s),
|
||||
)
|
||||
s.due_notified = True
|
||||
sent['due'] += 1
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>Confirm Receipt — Janitorial QC</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background:#eef1f5; }
|
||||
.wrap { max-width: 520px; margin: 8vh auto 0; padding: 16px; }
|
||||
.card-soft { background:#fff; border:1px solid #e6e9ef; border-radius:.85rem; }
|
||||
.badge-icon { width:64px; height:64px; border-radius:50%; display:flex; align-items:center;
|
||||
justify-content:center; font-size:2rem; margin:0 auto 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="card-soft shadow-sm p-4 text-center">
|
||||
|
||||
{% set ok = status in ['confirmed', 'already'] %}
|
||||
<div class="badge-icon"
|
||||
style="background:{{ '#e6f7ec' if ok else '#fdecec' }};color:{{ '#198754' if ok else '#dc3545' }};">
|
||||
<i class="bi bi-{{ 'check-circle-fill' if ok else 'exclamation-triangle-fill' }}"></i>
|
||||
</div>
|
||||
|
||||
{% if status == 'confirmed' %}
|
||||
<h4 class="mb-2">Receipt confirmed</h4>
|
||||
<p class="text-muted mb-0">Thank you — you've confirmed you received this scheduled inspection request.</p>
|
||||
{% elif status == 'already' %}
|
||||
<h4 class="mb-2">Already confirmed</h4>
|
||||
<p class="text-muted mb-0">You had already confirmed receipt of this scheduled inspection. No further action is needed.</p>
|
||||
{% elif status == 'reassigned' %}
|
||||
<h4 class="mb-2">This request was reassigned</h4>
|
||||
<p class="text-muted mb-0">This scheduled inspection is no longer assigned to you, so it can't be confirmed from this link.</p>
|
||||
{% elif status == 'inactive' %}
|
||||
<h4 class="mb-2">No longer active</h4>
|
||||
<p class="text-muted mb-0">This scheduled inspection is no longer active, so there's nothing to confirm.</p>
|
||||
{% elif status == 'expired' %}
|
||||
<h4 class="mb-2">Link expired</h4>
|
||||
<p class="text-muted mb-0">This confirmation link has expired. Please sign in to confirm receipt from the Scheduled Inspections page.</p>
|
||||
{% else %}
|
||||
<h4 class="mb-2">Invalid link</h4>
|
||||
<p class="text-muted mb-0">This confirmation link is not valid. Please sign in to confirm receipt from the Scheduled Inspections page.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if sched %}
|
||||
<hr class="my-3">
|
||||
<div class="text-start small">
|
||||
<div class="mb-1"><span class="text-muted">Facility:</span>
|
||||
<strong>{{ sched.facility.name if sched.facility else '—' }}</strong></div>
|
||||
<div class="mb-1"><span class="text-muted">Template:</span>
|
||||
{{ sched.template.name if sched.template else '—' }}</div>
|
||||
<div><span class="text-muted">Due:</span>
|
||||
{{ sched.next_due_date.strftime('%b %d, %Y') if sched.next_due_date else '—' }}
|
||||
<span class="text-muted">·</span> {{ sched.recurrence_label }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="{{ url_for('scheduled_inspections.index') }}" class="btn btn-primary">
|
||||
<i class="bi bi-box-arrow-in-right"></i> Open Scheduled Inspections
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-muted mt-3 mb-0" style="font-size:.75rem;">
|
||||
You may be asked to sign in.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -19,7 +19,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
{# Pending / Completed tabs (phase47). #}
|
||||
<ul class="nav nav-tabs mb-0">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if tab == 'pending' }}"
|
||||
href="{{ url_for('scheduled_inspections.index', tab='pending') }}">
|
||||
<i class="bi bi-hourglass-split"></i> Pending
|
||||
<span class="badge rounded-pill bg-{{ 'primary' if tab == 'pending' else 'secondary' }} ms-1">{{ pending_count }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if tab == 'completed' }}"
|
||||
href="{{ url_for('scheduled_inspections.index', tab='completed') }}">
|
||||
<i class="bi bi-check2-circle"></i> Completed
|
||||
<span class="badge rounded-pill bg-{{ 'primary' if tab == 'completed' else 'secondary' }} ms-1">{{ completed_count }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="card shadow-sm border-top-0" style="border-top-left-radius:0;border-top-right-radius:0;">
|
||||
<div class="card-body p-0">
|
||||
{% if schedules %}
|
||||
<div class="table-responsive">
|
||||
@@ -74,6 +92,11 @@
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Inactive</span>
|
||||
{% endif %}
|
||||
{% if s.last_completed_at %}
|
||||
<div class="small text-muted" title="Last completed">
|
||||
<i class="bi bi-check2"></i> {{ s.last_completed_at.strftime('%b %d, %Y') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{# Receipt confirmation by the assigned inspector (phase47). #}
|
||||
@@ -88,8 +111,8 @@
|
||||
<span class="badge bg-warning text-dark" title="The inspector has not confirmed receipt yet">
|
||||
<i class="bi bi-hourglass-split"></i> Awaiting
|
||||
</span>
|
||||
{# The assignee can confirm right here. #}
|
||||
{% if s.inspector_id == current_user.id %}
|
||||
{# The assignee can confirm right here (only while still active). #}
|
||||
{% if s.active and s.inspector_id == current_user.id %}
|
||||
<form method="POST" class="d-inline"
|
||||
action="{{ url_for('scheduled_inspections.acknowledge', schedule_id=s.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
@@ -138,10 +161,14 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-4 text-muted text-center">
|
||||
No scheduled inspections yet.
|
||||
{% if tab == 'completed' %}
|
||||
No completed scheduled inspections yet.
|
||||
{% else %}
|
||||
No pending scheduled inspections.
|
||||
{% if current_user.role in ['admin','director','project_manager','auditor'] %}
|
||||
<a href="{{ url_for('scheduled_inspections.create') }}">Create one</a>.
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -44,13 +44,23 @@ _EMAIL_HTML_SINGLE = """\
|
||||
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;">
|
||||
<h2 style="color:#0d6efd;">{{ title }}</h2>
|
||||
<p>{{ body }}</p>
|
||||
{% if link %}
|
||||
{% if link or extra_action %}
|
||||
<p>
|
||||
{% if extra_action %}
|
||||
<a href="{{ extra_action.url }}"
|
||||
style="background:#198754;color:#fff;padding:10px 20px;
|
||||
text-decoration:none;border-radius:4px;display:inline-block;
|
||||
margin-right:8px;">
|
||||
{{ extra_action.label }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if link %}
|
||||
<a href="{{ base_url }}{{ link }}"
|
||||
style="background:#0d6efd;color:#fff;padding:10px 20px;
|
||||
text-decoration:none;border-radius:4px;display:inline-block;">
|
||||
View Details
|
||||
</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
<hr style="border:none;border-top:1px solid #eee;margin-top:32px;">
|
||||
@@ -68,6 +78,9 @@ _EMAIL_TEXT_SINGLE = """\
|
||||
{{ title }}
|
||||
|
||||
{{ body }}
|
||||
{% if extra_action %}
|
||||
{{ extra_action.label }}: {{ extra_action.url }}
|
||||
{% endif %}
|
||||
{% if link %}
|
||||
View: {{ base_url }}{{ link }}
|
||||
{% endif %}
|
||||
@@ -172,6 +185,7 @@ def notify(
|
||||
event_type: str = None,
|
||||
send_email: bool = True,
|
||||
respect_preferences: bool = True,
|
||||
extra_action: dict = None,
|
||||
):
|
||||
"""Create an in-app Notification record and optionally send an email.
|
||||
|
||||
@@ -189,6 +203,11 @@ def notify(
|
||||
respect_preferences : When True (default), per-user email preferences gate delivery.
|
||||
Set False for matrix-routed broadcasts — the matrix is the
|
||||
authority; individual opt-out should not override admin config.
|
||||
extra_action : Optional dict {'label': str, 'url': str} rendered as a second
|
||||
(green) button in the email, before "View Details". `url` must
|
||||
be an absolute URL (it is NOT prefixed with base_url). Used for
|
||||
the scheduled-inspection "Confirm receipt" email link. In-app
|
||||
notifications are unaffected — this only shapes the email.
|
||||
"""
|
||||
# Determine digest flag before creating the record.
|
||||
# Digest mode is only respected when individual preferences are in effect.
|
||||
@@ -246,10 +265,10 @@ def notify(
|
||||
elif should_send:
|
||||
logger.info('EMAIL SEND | user=%s | event=%s | to=%s',
|
||||
recipient.username, event_type, recipient.email)
|
||||
_send_single_email(recipient, title, body, link)
|
||||
_send_single_email(recipient, title, body, link, extra_action)
|
||||
|
||||
|
||||
def _send_single_email(recipient, title, body, link):
|
||||
def _send_single_email(recipient, title, body, link, extra_action=None):
|
||||
"""Dispatch a single immediate notification email in a background thread.
|
||||
|
||||
Sending is offloaded to a daemon thread so SMTP latency never blocks the
|
||||
@@ -265,9 +284,11 @@ def _send_single_email(recipient, title, body, link):
|
||||
)
|
||||
html_body = render_template_string(
|
||||
_EMAIL_HTML_SINGLE, title=title, body=body, link=link, base_url=base_url,
|
||||
extra_action=extra_action,
|
||||
)
|
||||
text_body = render_template_string(
|
||||
_EMAIL_TEXT_SINGLE, title=title, body=body, link=link, base_url=base_url,
|
||||
extra_action=extra_action,
|
||||
)
|
||||
msg = Message(
|
||||
subject = f'[JQC] {title}',
|
||||
|
||||
Reference in New Issue
Block a user