feat(reports): per-project report config + automatic morning run
Add SoundReportConfig (one row per project) + the scheduler tick that runs the
nightly report on its own:
- model SoundReportConfig (enabled, report_time, metric_keys, baseline range,
recipients, last_run_date) — new table, auto-created by create_all (no migration).
- GET/PUT /api/projects/{id}/reports/config with validation.
- SchedulerService.run_due_reports(): each loop, for every enabled config past
its report_time, run last night's report once (dedup via last_run_date),
writing the file + emailing (dry-run until SMTP is set).
- UI: gear button beside "Night Report" opens a settings modal (enable, time,
baseline range, metrics, recipients) that GET/PUTs the config.
Verified: table registers + auto-creates, config CRUD + validation, tick
runs/dedups, templates render and gate to sound projects.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -218,6 +218,32 @@ class ProjectModule(Base):
|
|||||||
__table_args__ = (UniqueConstraint("project_id", "module_type", name="uq_project_module"),)
|
__table_args__ = (UniqueConstraint("project_id", "module_type", name="uq_project_module"),)
|
||||||
|
|
||||||
|
|
||||||
|
class SoundReportConfig(Base):
|
||||||
|
"""
|
||||||
|
Per-project configuration for the automated nightly sound report
|
||||||
|
(FTP report pipeline). One row per project. Read by the morning tick in
|
||||||
|
SchedulerService and by the manual /reports endpoints (as defaults).
|
||||||
|
|
||||||
|
New table → created by Base.metadata.create_all() on startup; no migration
|
||||||
|
needed (only a rebuild/restart).
|
||||||
|
"""
|
||||||
|
__tablename__ = "sound_report_configs"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, default=lambda: __import__('uuid').uuid4().__str__())
|
||||||
|
project_id = Column(String, nullable=False, index=True, unique=True) # FK to projects.id
|
||||||
|
|
||||||
|
enabled = Column(Boolean, default=False, nullable=False) # run the daily report?
|
||||||
|
report_time = Column(String, default="08:00", nullable=False) # local HH:MM to run/send
|
||||||
|
metric_keys = Column(String, default="lmax,l01,l10,l90", nullable=False) # csv of metric keys
|
||||||
|
baseline_start = Column(Date, nullable=True) # baseline-week range
|
||||||
|
baseline_end = Column(Date, nullable=True)
|
||||||
|
recipients = Column(Text, nullable=True) # csv; falls back to REPORT_SMTP_RECIPIENTS env
|
||||||
|
last_run_date = Column(Date, nullable=True) # evening-date of the last reported night (dedup)
|
||||||
|
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
class MonitoringLocation(Base):
|
class MonitoringLocation(Base):
|
||||||
"""
|
"""
|
||||||
Monitoring locations: generic location for monitoring activities.
|
Monitoring locations: generic location for monitoring activities.
|
||||||
|
|||||||
@@ -20,12 +20,14 @@ import logging
|
|||||||
from datetime import datetime, timedelta, date
|
from datetime import datetime, timedelta, date
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import Project
|
from backend.models import Project, SoundReportConfig
|
||||||
from backend.services.report_pipeline import METRIC_REGISTRY, DEFAULT_METRICS
|
from backend.services.report_pipeline import METRIC_REGISTRY, DEFAULT_METRICS
|
||||||
from backend.services.report_orchestrator import run_nightly_report
|
from backend.services.report_orchestrator import run_nightly_report
|
||||||
from backend.utils.timezone import utc_to_local
|
from backend.utils.timezone import utc_to_local
|
||||||
@@ -62,6 +64,82 @@ def _parse_metrics(s: Optional[str]) -> list[str]:
|
|||||||
return keys or list(DEFAULT_METRICS)
|
return keys or list(DEFAULT_METRICS)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_hhmm(s) -> str:
|
||||||
|
"""Validate a local HH:MM (24h) time string."""
|
||||||
|
try:
|
||||||
|
hh, mm = str(s).split(":")
|
||||||
|
h, m = int(hh), int(mm)
|
||||||
|
if 0 <= h < 24 and 0 <= m < 60:
|
||||||
|
return f"{h:02d}:{m:02d}"
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
pass
|
||||||
|
raise HTTPException(status_code=400, detail=f"report_time must be HH:MM 24-hour (got {s!r})")
|
||||||
|
|
||||||
|
|
||||||
|
def _config_dict(cfg: Optional[SoundReportConfig], project_id: str) -> dict:
|
||||||
|
"""Serialise a config row (or defaults if none yet) to JSON."""
|
||||||
|
return {
|
||||||
|
"project_id": project_id,
|
||||||
|
"exists": cfg is not None,
|
||||||
|
"enabled": cfg.enabled if cfg else False,
|
||||||
|
"report_time": cfg.report_time if cfg else "08:00",
|
||||||
|
"metric_keys": cfg.metric_keys if cfg else ",".join(DEFAULT_METRICS),
|
||||||
|
"baseline_start": cfg.baseline_start.isoformat() if cfg and cfg.baseline_start else None,
|
||||||
|
"baseline_end": cfg.baseline_end.isoformat() if cfg and cfg.baseline_end else None,
|
||||||
|
"recipients": (cfg.recipients if cfg and cfg.recipients else ""),
|
||||||
|
"last_run_date": cfg.last_run_date.isoformat() if cfg and cfg.last_run_date else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config")
|
||||||
|
async def get_report_config(project_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Return the project's nightly-report config (or defaults if not set yet)."""
|
||||||
|
if not db.query(Project).filter_by(id=project_id).first():
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
cfg = db.query(SoundReportConfig).filter_by(project_id=project_id).first()
|
||||||
|
return _config_dict(cfg, project_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/config")
|
||||||
|
async def put_report_config(project_id: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Create or update the project's nightly-report config (JSON body)."""
|
||||||
|
if not db.query(Project).filter_by(id=project_id).first():
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
data = await request.json()
|
||||||
|
|
||||||
|
cfg = db.query(SoundReportConfig).filter_by(project_id=project_id).first()
|
||||||
|
created = cfg is None
|
||||||
|
if cfg is None:
|
||||||
|
cfg = SoundReportConfig(id=str(uuid.uuid4()), project_id=project_id)
|
||||||
|
db.add(cfg)
|
||||||
|
|
||||||
|
if "enabled" in data:
|
||||||
|
cfg.enabled = bool(data["enabled"])
|
||||||
|
if "report_time" in data:
|
||||||
|
cfg.report_time = _validate_hhmm(data["report_time"])
|
||||||
|
if "metric_keys" in data:
|
||||||
|
mk = data["metric_keys"]
|
||||||
|
mk = mk if isinstance(mk, str) else ",".join(mk or [])
|
||||||
|
cfg.metric_keys = ",".join(_parse_metrics(mk))
|
||||||
|
if "baseline_start" in data or "baseline_end" in data:
|
||||||
|
bs = _parse_date(data.get("baseline_start") or None, "baseline_start")
|
||||||
|
be = _parse_date(data.get("baseline_end") or None, "baseline_end")
|
||||||
|
if (bs and not be) or (be and not bs):
|
||||||
|
raise HTTPException(status_code=400, detail="Provide both baseline dates, or neither.")
|
||||||
|
if bs and be and bs > be:
|
||||||
|
raise HTTPException(status_code=400, detail="baseline_start must be on or before baseline_end.")
|
||||||
|
cfg.baseline_start, cfg.baseline_end = bs, be
|
||||||
|
if "recipients" in data:
|
||||||
|
recips = data["recipients"]
|
||||||
|
if isinstance(recips, list):
|
||||||
|
recips = ",".join(recips)
|
||||||
|
cfg.recipients = (recips or "").strip() or None
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cfg)
|
||||||
|
return {**_config_dict(cfg, project_id), "created": created}
|
||||||
|
|
||||||
|
|
||||||
def _resolve_params(project_id, db, night_date, baseline_start, baseline_end, metrics):
|
def _resolve_params(project_id, db, night_date, baseline_start, baseline_end, metrics):
|
||||||
"""Shared validation/parsing for both endpoints."""
|
"""Shared validation/parsing for both endpoints."""
|
||||||
if not db.query(Project).filter_by(id=project_id).first():
|
if not db.query(Project).filter_by(id=project_id).first():
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ class SchedulerService:
|
|||||||
# Execute pending actions
|
# Execute pending actions
|
||||||
await self.execute_pending_actions()
|
await self.execute_pending_actions()
|
||||||
|
|
||||||
|
# Run any due nightly sound reports (FTP report pipeline)
|
||||||
|
await self.run_due_reports()
|
||||||
|
|
||||||
# Generate actions from recurring schedules (every hour)
|
# Generate actions from recurring schedules (every hour)
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
if (now - last_generation_check).total_seconds() >= 3600:
|
if (now - last_generation_check).total_seconds() >= 3600:
|
||||||
@@ -782,6 +785,66 @@ class SchedulerService:
|
|||||||
|
|
||||||
return cleaned
|
return cleaned
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Nightly Sound Report (FTP report pipeline)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def run_due_reports(self):
|
||||||
|
"""Run any project nightly sound reports that are due.
|
||||||
|
|
||||||
|
For each enabled SoundReportConfig: if local time is past report_time
|
||||||
|
and we haven't already reported last night, build the report (writes a
|
||||||
|
file always; emails if SMTP is configured, else dry-run) and stamp
|
||||||
|
last_run_date. Idempotent across restarts via last_run_date.
|
||||||
|
"""
|
||||||
|
from backend.models import SoundReportConfig
|
||||||
|
from backend.services.report_orchestrator import run_nightly_report
|
||||||
|
from backend.utils.timezone import utc_to_local
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
configs = db.query(SoundReportConfig).filter_by(enabled=True).all()
|
||||||
|
if not configs:
|
||||||
|
return
|
||||||
|
|
||||||
|
local_now = utc_to_local(datetime.utcnow())
|
||||||
|
night_date = local_now.date() - timedelta(days=1) # last night's evening date
|
||||||
|
|
||||||
|
for cfg in configs:
|
||||||
|
try:
|
||||||
|
# Past the configured local report time (HH:MM)?
|
||||||
|
try:
|
||||||
|
hh, mm = (int(x) for x in cfg.report_time.split(":"))
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
hh, mm = 8, 0
|
||||||
|
due = (local_now.hour, local_now.minute) >= (hh, mm)
|
||||||
|
if not due or cfg.last_run_date == night_date:
|
||||||
|
continue
|
||||||
|
|
||||||
|
metric_keys = [m.strip() for m in (cfg.metric_keys or "").split(",") if m.strip()] or None
|
||||||
|
recipients = [r.strip() for r in (cfg.recipients or "").split(",") if r.strip()] or None
|
||||||
|
|
||||||
|
logger.info(f"[REPORT] Running nightly report for project {cfg.project_id} (night {night_date})")
|
||||||
|
result = run_nightly_report(
|
||||||
|
db, cfg.project_id, night_date,
|
||||||
|
metric_keys=metric_keys,
|
||||||
|
baseline_start=cfg.baseline_start,
|
||||||
|
baseline_end=cfg.baseline_end,
|
||||||
|
recipients=recipients,
|
||||||
|
)
|
||||||
|
cfg.last_run_date = night_date
|
||||||
|
db.commit()
|
||||||
|
email = result.get("email", {})
|
||||||
|
logger.info(
|
||||||
|
f"[REPORT] project {cfg.project_id}: {result.get('location_count')} location(s); "
|
||||||
|
f"email={'sent' if email.get('sent') else ('dry-run' if email.get('dry_run') else (email.get('error') or 'skipped'))}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[REPORT] Failed nightly report for project {cfg.project_id}: {e}", exc_info=True)
|
||||||
|
db.rollback()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# Manual Execution (for testing/debugging)
|
# Manual Execution (for testing/debugging)
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
|
|||||||
@@ -82,6 +82,14 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Night Report
|
Night Report
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="openReportSettings('{{ project.id }}')"
|
||||||
|
title="Nightly report settings — schedule, baseline range, recipients"
|
||||||
|
class="px-3 py-2 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center text-sm">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button onclick="openMergeModal()"
|
<button onclick="openMergeModal()"
|
||||||
title="Merge this project into another (consolidates duplicates)"
|
title="Merge this project into another (consolidates duplicates)"
|
||||||
@@ -154,6 +162,100 @@ function viewNightReport(projectId) {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Nightly Report Settings Modal -->
|
||||||
|
<div id="report-settings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Nightly Report Settings</h3>
|
||||||
|
<button onclick="closeReportSettings()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-5 space-y-4">
|
||||||
|
<label class="flex items-center gap-2 text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||||
|
<input type="checkbox" id="rs-enabled" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||||
|
Email the report automatically each morning
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Report time (local)</label>
|
||||||
|
<input type="time" id="rs-report-time" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
|
||||||
|
<p class="text-xs text-gray-400 mt-1">Runs after this time for the night that just ended.</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Baseline start</label>
|
||||||
|
<input type="date" id="rs-baseline-start" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Baseline end</label>
|
||||||
|
<input type="date" id="rs-baseline-end" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Metrics</label>
|
||||||
|
<input type="text" id="rs-metrics" placeholder="lmax,l01,l10,l90" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
|
||||||
|
<p class="text-xs text-gray-400 mt-1">Comma list. Options: lmax, l01, l10, l50, l90, l95, leq.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Recipients</label>
|
||||||
|
<input type="text" id="rs-recipients" placeholder="brian@…, dad@…" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
|
||||||
|
<p class="text-xs text-gray-400 mt-1">Comma list. Blank → the default SMTP recipients.</p>
|
||||||
|
</div>
|
||||||
|
<p id="rs-status" class="text-xs"></p>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
|
||||||
|
<button onclick="closeReportSettings()" class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-sm">Cancel</button>
|
||||||
|
<button onclick="saveReportSettings('{{ project.id }}')" class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors text-sm">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function openReportSettings(projectId) {
|
||||||
|
var show = function () { document.getElementById('report-settings-modal').classList.remove('hidden'); };
|
||||||
|
document.getElementById('rs-status').textContent = '';
|
||||||
|
fetch('/api/projects/' + projectId + '/reports/config')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (c) {
|
||||||
|
document.getElementById('rs-enabled').checked = !!c.enabled;
|
||||||
|
document.getElementById('rs-report-time').value = c.report_time || '08:00';
|
||||||
|
document.getElementById('rs-baseline-start').value = c.baseline_start || '';
|
||||||
|
document.getElementById('rs-baseline-end').value = c.baseline_end || '';
|
||||||
|
document.getElementById('rs-metrics').value = c.metric_keys || 'lmax,l01,l10,l90';
|
||||||
|
document.getElementById('rs-recipients').value = c.recipients || '';
|
||||||
|
show();
|
||||||
|
})
|
||||||
|
.catch(show);
|
||||||
|
}
|
||||||
|
function closeReportSettings() {
|
||||||
|
document.getElementById('report-settings-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
function saveReportSettings(projectId) {
|
||||||
|
var st = document.getElementById('rs-status');
|
||||||
|
var bs = document.getElementById('rs-baseline-start').value;
|
||||||
|
var be = document.getElementById('rs-baseline-end').value;
|
||||||
|
if ((bs && !be) || (be && !bs)) {
|
||||||
|
st.style.color = '#b00020'; st.textContent = 'Provide both baseline dates, or neither.'; return;
|
||||||
|
}
|
||||||
|
var body = {
|
||||||
|
enabled: document.getElementById('rs-enabled').checked,
|
||||||
|
report_time: document.getElementById('rs-report-time').value || '08:00',
|
||||||
|
metric_keys: document.getElementById('rs-metrics').value || 'lmax,l01,l10,l90',
|
||||||
|
baseline_start: bs || null,
|
||||||
|
baseline_end: be || null,
|
||||||
|
recipients: document.getElementById('rs-recipients').value || ''
|
||||||
|
};
|
||||||
|
st.style.color = ''; st.textContent = 'Saving…';
|
||||||
|
fetch('/api/projects/' + projectId + '/reports/config', {
|
||||||
|
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
||||||
|
}).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
|
||||||
|
.then(function (res) {
|
||||||
|
if (res.ok) { st.style.color = '#1a7f37'; st.textContent = 'Saved.'; setTimeout(closeReportSettings, 700); }
|
||||||
|
else { st.style.color = '#b00020'; st.textContent = 'Error: ' + (res.j.detail || 'save failed'); }
|
||||||
|
})
|
||||||
|
.catch(function (e) { st.style.color = '#b00020'; st.textContent = 'Error: ' + e; });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<!-- Merge Modal —
|
<!-- Merge Modal —
|
||||||
min-h on the body ensures the typeahead dropdown has room to render
|
min-h on the body ensures the typeahead dropdown has room to render
|
||||||
below the input without forcing the operator to scroll inside the
|
below the input without forcing the operator to scroll inside the
|
||||||
|
|||||||
Reference in New Issue
Block a user