feat(djapi): 新增日报查询 API(news/reports + news/events)及文档
- api/report/ 包:query(连库+SQL)/ views(2 视图)/ serializers(OpenAPI)/ tests(17 单测) - urls.py 注册 news/reports/、news/events/;settings.py SPECTACULAR 加「日报」tag - .env.example 补 NEWS_DB_* 占位配置;README/continuation.md 更新 - docs/news_report_api.md 使用手册;CLAUDE*.md 修正 CLI 路径为 finance/ 前缀
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
news_report / news_event 只读查询层(日报结构化入库,见 docs/db_schema.md)。
|
||||
|
||||
连接配置来自环境变量(与 docs/report_db_design.md §7 保持一致):
|
||||
NEWS_DB_HOST / NEWS_DB_PORT / NEWS_DB_USER / NEWS_DB_PASSWORD / NEWS_DB_NAME
|
||||
NEWS_DB_PASSWORD 缺失时直接报错,禁止默认密码。
|
||||
|
||||
所有 SQL 均为 MariaDB 方言、参数化查询(防 SQL 注入),不依赖 ORM。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
import mysql.connector
|
||||
|
||||
_REPORT_FIELDS = (
|
||||
"id, report_date, report_type, file_name, generated_at, "
|
||||
"ai_summary, stats, created_at"
|
||||
)
|
||||
_EVENT_FIELDS = (
|
||||
"id, report_id, section, rank, importance, event_type, title, "
|
||||
"summary, sentiment, source, url"
|
||||
)
|
||||
|
||||
|
||||
def load_db_config() -> dict:
|
||||
"""从环境变量读取 NEWS_DB_* 连接配置,密码缺失时抛错。"""
|
||||
password = os.getenv("NEWS_DB_PASSWORD")
|
||||
if not password:
|
||||
raise RuntimeError(
|
||||
"NEWS_DB_PASSWORD 未设置,禁止使用默认密码连接 news 库"
|
||||
)
|
||||
return {
|
||||
"host": os.getenv("NEWS_DB_HOST", "127.0.0.1"),
|
||||
"port": int(os.getenv("NEWS_DB_PORT", "3306")),
|
||||
"user": os.getenv("NEWS_DB_USER", "myquant"),
|
||||
"password": password,
|
||||
"database": os.getenv("NEWS_DB_NAME", "myquant"),
|
||||
"charset": "utf8mb4",
|
||||
}
|
||||
|
||||
|
||||
def _connect():
|
||||
return mysql.connector.connect(**load_db_config())
|
||||
|
||||
|
||||
def _row_to_dict(row: dict) -> dict:
|
||||
"""序列化行:stats JSON 解析、日期/时间转 ISO 字符串。"""
|
||||
d = dict(row)
|
||||
if d.get("stats") is not None:
|
||||
try:
|
||||
d["stats"] = json.loads(d["stats"])
|
||||
except (TypeError, ValueError):
|
||||
d["stats"] = None
|
||||
for k, v in d.items():
|
||||
if isinstance(v, (date, datetime)):
|
||||
d[k] = v.isoformat()
|
||||
return d
|
||||
|
||||
|
||||
def fetch_reports(
|
||||
report_type: str | None = None,
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
report_id: int | None = None,
|
||||
):
|
||||
"""
|
||||
日报查询。
|
||||
|
||||
report_id 给定 → 返回单份详情 dict(含 events,按 section, rank 排序);
|
||||
不存在返回 None。
|
||||
否则 → 返回列表:每天每类型取最新一份(MAX(generated_at) 子查询),
|
||||
仅主表字段(轻量,不带 events)。
|
||||
"""
|
||||
conn = _connect()
|
||||
try:
|
||||
cur = conn.cursor(dictionary=True)
|
||||
if report_id is not None:
|
||||
cur.execute(
|
||||
f"SELECT {_REPORT_FIELDS} FROM news_report WHERE id = %s",
|
||||
(report_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
report = _row_to_dict(row)
|
||||
cur.execute(
|
||||
"SELECT id, section, rank, importance, event_type, title, "
|
||||
"summary, sentiment, source, url "
|
||||
"FROM news_event WHERE report_id = %s ORDER BY section, rank",
|
||||
(report_id,),
|
||||
)
|
||||
report["events"] = [dict(r) for r in cur.fetchall()]
|
||||
return report
|
||||
|
||||
where, params = [], []
|
||||
if report_type:
|
||||
where.append("r.report_type = %s")
|
||||
params.append(report_type)
|
||||
if start_date:
|
||||
where.append("r.report_date >= %s")
|
||||
params.append(start_date.isoformat())
|
||||
if end_date:
|
||||
where.append("r.report_date <= %s")
|
||||
params.append(end_date.isoformat())
|
||||
cond = (" WHERE " + " AND ".join(where)) if where else ""
|
||||
|
||||
sql = (
|
||||
"SELECT r.id, r.report_date, r.report_type, r.file_name, "
|
||||
"r.generated_at, r.ai_summary, r.stats, r.created_at "
|
||||
"FROM news_report r "
|
||||
"JOIN ("
|
||||
" SELECT report_date, report_type, MAX(generated_at) AS g "
|
||||
" FROM news_report GROUP BY report_date, report_type"
|
||||
") t ON r.report_date = t.report_date "
|
||||
" AND r.report_type = t.report_type "
|
||||
" AND r.generated_at = t.g"
|
||||
+ cond
|
||||
+ " ORDER BY r.report_date DESC, r.report_type"
|
||||
)
|
||||
cur.execute(sql, tuple(params))
|
||||
return [_row_to_dict(r) for r in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def fetch_important_events(
|
||||
days: int = 7,
|
||||
importance: int = 4,
|
||||
report_type: str | None = None,
|
||||
section: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> list:
|
||||
"""
|
||||
跨日报重要事件聚合检索(最近 N 天,importance >= 阈值)。
|
||||
|
||||
按 importance DESC, report_date DESC 排序。
|
||||
"""
|
||||
conn = _connect()
|
||||
try:
|
||||
cur = conn.cursor(dictionary=True)
|
||||
since = (date.today() - timedelta(days=days)).isoformat()
|
||||
where = ["r.report_date >= %s", "e.importance >= %s"]
|
||||
params = [since, int(importance)]
|
||||
if report_type:
|
||||
where.append("r.report_type = %s")
|
||||
params.append(report_type)
|
||||
if section:
|
||||
where.append("e.section = %s")
|
||||
params.append(section)
|
||||
|
||||
sql = (
|
||||
"SELECT r.report_date, r.report_type, e.id, e.section, e.rank, "
|
||||
"e.importance, e.event_type, e.title, e.summary, e.sentiment, "
|
||||
"e.source, e.url "
|
||||
"FROM news_event e "
|
||||
"JOIN news_report r ON r.id = e.report_id "
|
||||
"WHERE " + " AND ".join(where)
|
||||
+ " ORDER BY e.importance DESC, r.report_date DESC, "
|
||||
"e.section, e.rank "
|
||||
+ "LIMIT %s"
|
||||
)
|
||||
params.append(int(limit))
|
||||
cur.execute(sql, tuple(params))
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,50 @@
|
||||
"""日报查询 API 的 OpenAPI 文档 serializer(只读,不用于反序列化)。"""
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class EventSerializer(serializers.Serializer):
|
||||
"""日报事件明细(news_event 一行)"""
|
||||
id = serializers.IntegerField()
|
||||
section = serializers.CharField()
|
||||
rank = serializers.IntegerField()
|
||||
importance = serializers.IntegerField(allow_null=True)
|
||||
event_type = serializers.CharField(allow_null=True)
|
||||
title = serializers.CharField()
|
||||
summary = serializers.CharField(allow_null=True)
|
||||
sentiment = serializers.CharField(allow_null=True)
|
||||
source = serializers.CharField(allow_null=True)
|
||||
url = serializers.CharField(allow_null=True)
|
||||
|
||||
|
||||
class ReportListSerializer(serializers.Serializer):
|
||||
"""日报列表项(news_report 主表字段)"""
|
||||
id = serializers.IntegerField()
|
||||
report_date = serializers.CharField()
|
||||
report_type = serializers.CharField()
|
||||
file_name = serializers.CharField()
|
||||
generated_at = serializers.CharField()
|
||||
ai_summary = serializers.CharField(allow_null=True)
|
||||
stats = serializers.JSONField(allow_null=True)
|
||||
created_at = serializers.CharField()
|
||||
|
||||
|
||||
class ReportDetailSerializer(ReportListSerializer):
|
||||
"""日报详情(主表字段 + 事件列表)"""
|
||||
events = EventSerializer(many=True)
|
||||
|
||||
|
||||
class ImportantEventSerializer(serializers.Serializer):
|
||||
"""跨日报重要事件聚合(news_event JOIN news_report)"""
|
||||
report_date = serializers.CharField()
|
||||
report_type = serializers.CharField()
|
||||
id = serializers.IntegerField()
|
||||
section = serializers.CharField()
|
||||
rank = serializers.IntegerField()
|
||||
importance = serializers.IntegerField(allow_null=True)
|
||||
event_type = serializers.CharField(allow_null=True)
|
||||
title = serializers.CharField()
|
||||
summary = serializers.CharField(allow_null=True)
|
||||
sentiment = serializers.CharField(allow_null=True)
|
||||
source = serializers.CharField(allow_null=True)
|
||||
url = serializers.CharField(allow_null=True)
|
||||
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
日报查询 API 测试(GET /api/news/reports/ 与 /api/news/events/)。
|
||||
|
||||
通过 mock 数据库查询层(api.report.query.*),只验证视图层
|
||||
参数解析 / 默认窗口 / 响应结构 / 错误处理,不依赖真实 MySQL。
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
|
||||
class NewsReportsAPITest(TestCase):
|
||||
"""GET /api/news/reports/ 日报查询"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.url = '/api/news/reports/'
|
||||
|
||||
@patch('api.report.query.fetch_reports', return_value=[])
|
||||
def test_default_window_last_24h(self, mock_fetch):
|
||||
resp = self.client.get(self.url)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.json(), [])
|
||||
kwargs = mock_fetch.call_args.kwargs
|
||||
now = timezone.now()
|
||||
self.assertEqual(kwargs['start_date'], (now - timedelta(hours=24)).date())
|
||||
self.assertEqual(kwargs['end_date'], now.date())
|
||||
self.assertIsNone(kwargs['report_type'])
|
||||
self.assertIsNone(kwargs['report_id'])
|
||||
|
||||
@patch('api.report.query.fetch_reports',
|
||||
return_value=[{'id': 1, 'report_date': '2026-08-01', 'report_type': 'finance'}])
|
||||
def test_report_type_and_date_range(self, mock_fetch):
|
||||
resp = self.client.get(self.url, {
|
||||
'report_type': 'finance',
|
||||
'start_date': '2026-07-01',
|
||||
'end_date': '2026-08-03',
|
||||
})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(len(resp.json()), 1)
|
||||
kwargs = mock_fetch.call_args.kwargs
|
||||
self.assertEqual(kwargs['report_type'], 'finance')
|
||||
self.assertEqual(str(kwargs['start_date']), '2026-07-01')
|
||||
self.assertEqual(str(kwargs['end_date']), '2026-08-03')
|
||||
|
||||
@patch('api.report.query.fetch_reports')
|
||||
def test_detail_by_id(self, mock_fetch):
|
||||
mock_fetch.return_value = {
|
||||
'id': 10, 'report_date': '2026-08-03', 'report_type': 'finance',
|
||||
'events': [{'id': 1, 'title': '事件一'}],
|
||||
}
|
||||
resp = self.client.get(self.url, {'id': '10'})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.json()['id'], 10)
|
||||
self.assertEqual(mock_fetch.call_args.kwargs['report_id'], 10)
|
||||
|
||||
@patch('api.report.query.fetch_reports', return_value=None)
|
||||
def test_detail_not_found(self, mock_fetch):
|
||||
resp = self.client.get(self.url, {'id': '99999'})
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
self.assertIn('error', resp.json())
|
||||
|
||||
def test_invalid_report_type(self):
|
||||
resp = self.client.get(self.url, {'report_type': 'xxx'})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
def test_invalid_date_format(self):
|
||||
resp = self.client.get(self.url, {'start_date': '2026/07/01'})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
def test_invalid_id(self):
|
||||
resp = self.client.get(self.url, {'id': 'abc'})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
def test_non_positive_id(self):
|
||||
resp = self.client.get(self.url, {'id': '0'})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
@patch('api.report.query.fetch_reports', side_effect=RuntimeError('db down'))
|
||||
def test_query_error_500(self, mock_fetch):
|
||||
resp = self.client.get(self.url)
|
||||
self.assertEqual(resp.status_code, 500)
|
||||
self.assertIn('error', resp.json())
|
||||
|
||||
|
||||
class NewsEventsAPITest(TestCase):
|
||||
"""GET /api/news/events/ 重要事件聚合"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.url = '/api/news/events/'
|
||||
|
||||
@patch('api.report.query.fetch_important_events', return_value=[])
|
||||
def test_defaults(self, mock_fetch):
|
||||
resp = self.client.get(self.url)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.json(), [])
|
||||
kwargs = mock_fetch.call_args.kwargs
|
||||
self.assertEqual(kwargs['days'], 7)
|
||||
self.assertEqual(kwargs['importance'], 4)
|
||||
self.assertEqual(kwargs['limit'], 100)
|
||||
self.assertIsNone(kwargs['report_type'])
|
||||
self.assertIsNone(kwargs['section'])
|
||||
|
||||
@patch('api.report.query.fetch_important_events',
|
||||
return_value=[{'id': 1, 'title': '重要事件', 'importance': 5}])
|
||||
def test_filters(self, mock_fetch):
|
||||
resp = self.client.get(self.url, {
|
||||
'days': '3', 'importance': '5',
|
||||
'report_type': 'intl', 'section': 'intl', 'limit': '10',
|
||||
})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(len(resp.json()), 1)
|
||||
kwargs = mock_fetch.call_args.kwargs
|
||||
self.assertEqual(kwargs['days'], 3)
|
||||
self.assertEqual(kwargs['importance'], 5)
|
||||
self.assertEqual(kwargs['limit'], 10)
|
||||
self.assertEqual(kwargs['report_type'], 'intl')
|
||||
self.assertEqual(kwargs['section'], 'intl')
|
||||
|
||||
def test_invalid_days(self):
|
||||
resp = self.client.get(self.url, {'days': 'abc'})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
def test_days_out_of_range(self):
|
||||
resp = self.client.get(self.url, {'days': '0'})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
def test_invalid_importance(self):
|
||||
resp = self.client.get(self.url, {'importance': '9'})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
def test_invalid_section(self):
|
||||
resp = self.client.get(self.url, {'section': 'foo'})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
def test_invalid_report_type(self):
|
||||
resp = self.client.get(self.url, {'report_type': 'xxx'})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
@patch('api.report.query.fetch_important_events', side_effect=RuntimeError('db down'))
|
||||
def test_query_error_500(self, mock_fetch):
|
||||
resp = self.client.get(self.url)
|
||||
self.assertEqual(resp.status_code, 500)
|
||||
self.assertIn('error', resp.json())
|
||||
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
日报查询 API 视图。
|
||||
|
||||
- GET /api/news/reports/ 日报查询(默认最近 24 小时;传 id 返回单份详情含事件)
|
||||
- GET /api/news/events/ 跨日报重要事件聚合(最近 N 天 importance >= 阈值)
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
|
||||
from . import query as report_query
|
||||
from .serializers import (
|
||||
ImportantEventSerializer,
|
||||
ReportDetailSerializer,
|
||||
ReportListSerializer,
|
||||
)
|
||||
|
||||
_REPORT_TYPES = ("finance", "intl")
|
||||
_SECTIONS = ("xwlb", "news", "cninfo", "intl")
|
||||
|
||||
|
||||
def _parse_date_param(request, name):
|
||||
"""解析 YYYY-MM-DD 参数,非法格式抛 ValueError。"""
|
||||
raw = request.GET.get(name)
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(raw, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise ValueError(f"{name} 格式错误,应为 YYYY-MM-DD")
|
||||
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(name='report_type', type=str, required=False,
|
||||
description='日报类型:finance | intl(默认两者)'),
|
||||
OpenApiParameter(name='start_date', type=str, required=False,
|
||||
description='起始日期 YYYY-MM-DD(默认:当前时间往前 24 小时)'),
|
||||
OpenApiParameter(name='end_date', type=str, required=False,
|
||||
description='结束日期 YYYY-MM-DD(默认今天)'),
|
||||
OpenApiParameter(name='id', type=int, required=False,
|
||||
description='日报 id,指定时返回单份详情(含事件,按板块/序号排序)'),
|
||||
],
|
||||
responses={200: ReportDetailSerializer},
|
||||
description='AI 财经日报查询:默认返回最近 24 小时的日报列表(每天每类型取最新一份);'
|
||||
'传 id 返回单份详情含事件',
|
||||
tags=['日报'],
|
||||
)
|
||||
@api_view(['GET'])
|
||||
def news_reports(request):
|
||||
try:
|
||||
report_id = request.GET.get('id')
|
||||
if report_id is not None:
|
||||
report_id = int(report_id)
|
||||
if report_id <= 0:
|
||||
raise ValueError("id 必须为正整数")
|
||||
report_type = request.GET.get('report_type')
|
||||
if report_type and report_type not in _REPORT_TYPES:
|
||||
raise ValueError("report_type 仅支持 finance / intl")
|
||||
start_date = _parse_date_param(request, 'start_date')
|
||||
end_date = _parse_date_param(request, 'end_date')
|
||||
except ValueError as e:
|
||||
return Response({'error': str(e)}, status=400)
|
||||
|
||||
now = timezone.now()
|
||||
start_date = start_date or (now - timedelta(hours=24)).date()
|
||||
end_date = end_date or now.date()
|
||||
|
||||
try:
|
||||
data = report_query.fetch_reports(
|
||||
report_type=report_type,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
report_id=report_id,
|
||||
)
|
||||
except Exception as e:
|
||||
return Response({'error': f'查询失败: {e}'}, status=500)
|
||||
|
||||
if report_id is not None:
|
||||
if data is None:
|
||||
return Response({'error': f'日报 id={report_id} 不存在'}, status=404)
|
||||
return Response(data)
|
||||
return Response(data)
|
||||
|
||||
|
||||
def _get_int_param(request, name, default, lo, hi):
|
||||
"""解析整数参数并校验范围,非法抛 ValueError。"""
|
||||
raw = request.GET.get(name)
|
||||
if raw is None:
|
||||
return default
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
raise ValueError(f"{name} 必须为整数")
|
||||
if not (lo <= value <= hi):
|
||||
raise ValueError(f"{name} 需在 {lo}~{hi} 之间")
|
||||
return value
|
||||
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(name='days', type=int, required=False, default=7,
|
||||
description='最近 N 天(1~365)'),
|
||||
OpenApiParameter(name='importance', type=int, required=False, default=4,
|
||||
description='最低重要度(1~5)'),
|
||||
OpenApiParameter(name='report_type', type=str, required=False,
|
||||
description='日报类型:finance | intl(默认两者)'),
|
||||
OpenApiParameter(name='section', type=str, required=False,
|
||||
description='板块:xwlb | news | cninfo | intl(默认全部)'),
|
||||
OpenApiParameter(name='limit', type=int, required=False, default=100,
|
||||
description='返回条数上限(1~500)'),
|
||||
],
|
||||
responses={200: ImportantEventSerializer(many=True)},
|
||||
description='跨日报重要事件聚合:最近 N 天 importance >= 阈值的事件,'
|
||||
'按重要度、日期降序',
|
||||
tags=['日报'],
|
||||
)
|
||||
@api_view(['GET'])
|
||||
def news_events(request):
|
||||
try:
|
||||
days = _get_int_param(request, 'days', 7, 1, 365)
|
||||
importance = _get_int_param(request, 'importance', 4, 1, 5)
|
||||
limit = _get_int_param(request, 'limit', 100, 1, 500)
|
||||
report_type = request.GET.get('report_type')
|
||||
if report_type and report_type not in _REPORT_TYPES:
|
||||
raise ValueError("report_type 仅支持 finance / intl")
|
||||
section = request.GET.get('section')
|
||||
if section and section not in _SECTIONS:
|
||||
raise ValueError("section 仅支持 xwlb / news / cninfo / intl")
|
||||
except ValueError as e:
|
||||
return Response({'error': str(e)}, status=400)
|
||||
|
||||
try:
|
||||
data = report_query.fetch_important_events(
|
||||
days=days,
|
||||
importance=importance,
|
||||
report_type=report_type,
|
||||
section=section,
|
||||
limit=limit,
|
||||
)
|
||||
except Exception as e:
|
||||
return Response({'error': f'查询失败: {e}'}, status=500)
|
||||
return Response(data)
|
||||
@@ -1,5 +1,6 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
from .report import views as report_views
|
||||
|
||||
urlpatterns = [
|
||||
# 其他 URL 路由
|
||||
@@ -21,4 +22,8 @@ urlpatterns = [
|
||||
|
||||
path('xwlbNews/', views.xwlbNews, name='xwlbNews'),
|
||||
path('xwlbFine/', views.xwlbFine, name='xwlbFine'),
|
||||
|
||||
# 日报查询(news_report / news_event)
|
||||
path('news/reports/', report_views.news_reports, name='news_reports'),
|
||||
path('news/events/', report_views.news_events, name='news_events'),
|
||||
]
|
||||
Reference in New Issue
Block a user