A8多套打包+M4归档+R5多图:存量功能备份
A8 多套交付包(packaging_task.py): - 修复交付包只打第1条混乱note的bug,按ImageCandidate.strategy分A/B/C组 - 每组生独立note_0N夹(6图+文案.txt),同seq留最新去重,老数据兼容 - task74端到端验:3套各6图,独立agent7项交叉验证全过 M4 归档(tasks.py/exports.py/前端): - list_tasks加date_from/date_to/product_id筛选+product_name批量填(防N+1) - 新增exports.py:产品JSON导出+标杆CSV导出(UTF-8 BOM) - 前端HistoryFilters日期/产品筛选+产品列+打回原因红banner - response.py加raise_param_error;独立agent验A1/A2/A9通过 R5 产品多图(product_images.py/020迁移/前端): - product_images表+5端点(上传/列/改场景/设主图/删图) - 生图按ROLE_SCENE_PREFERENCE选对应场景图,回落primary - 前端ProductImageManager多图画廊 R6 账号config拆页(settings/): - 配置页按角色拆/settings(运营+组长+admin)+/config(仅admin) - Key只显末4位不显余额(守红线) 核销表对齐真实代码状态:D1改稿框/M7裂变/E12评图分纠偏为已完成(曾漏回写) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
100
backend/app/api/v1/exports.py
Normal file
100
backend/app/api/v1/exports.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
app/api/v1/exports.py — 客户原始输入物导出(数据归属红线)
|
||||
产品/标杆属客户 client_data,可导出(JSON/CSV);飞轮偏好属平台不在此列。
|
||||
require_write_permission:仅 workspace 成员可导出本工作区数据。
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.middleware.workspace_guard import CurrentUser, require_write_permission
|
||||
from app.models.product import BenchmarkNote, Product
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["exports"])
|
||||
|
||||
|
||||
def _csv_response(rows: list[dict], fieldnames: list[str], filename: str) -> StreamingResponse:
|
||||
"""通用 CSV 流式响应。加 UTF-8 BOM 防 Windows Excel 中文乱码。"""
|
||||
buf = io.StringIO()
|
||||
buf.write("") # BOM
|
||||
writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
for r in rows:
|
||||
writer.writerow(r)
|
||||
buf.seek(0)
|
||||
return StreamingResponse(
|
||||
iter([buf.getvalue()]),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/products/export")
|
||||
def export_products(
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""产品档案导出(JSON)。selling_points 还原为数组,非 JSON 字符串。"""
|
||||
rows = (
|
||||
db.query(Product)
|
||||
.filter(Product.workspace_id == current_user.workspace_id, Product.is_active == True)
|
||||
.order_by(Product.id)
|
||||
.all()
|
||||
)
|
||||
data = [
|
||||
{
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"category": p.category,
|
||||
"selling_points": json.loads(p.selling_points) if p.selling_points else [],
|
||||
"brand_keyword": p.brand_keyword,
|
||||
"target_audience": p.target_audience,
|
||||
"style_tone": p.style_tone,
|
||||
"created_at": p.created_at.isoformat(),
|
||||
}
|
||||
for p in rows
|
||||
]
|
||||
# 直接返数组(非包 ok 信封):导出供下载/二次处理,前端按 blob 存盘。
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/benchmarks/export")
|
||||
def export_benchmarks(
|
||||
format: str = Query(default="json", pattern="^(json|csv)$"),
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""标杆笔记导出(JSON/CSV)。JOIN Product 填 product_name 便于客户阅读。"""
|
||||
rows = (
|
||||
db.query(BenchmarkNote, Product.name)
|
||||
.outerjoin(Product, BenchmarkNote.product_id == Product.id)
|
||||
.filter(BenchmarkNote.workspace_id == current_user.workspace_id)
|
||||
.order_by(BenchmarkNote.id)
|
||||
.all()
|
||||
)
|
||||
data = [
|
||||
{
|
||||
"id": b.id,
|
||||
"product_id": b.product_id,
|
||||
"product_name": pname,
|
||||
"highlights": b.highlights or "",
|
||||
"link_url": b.link_url or "",
|
||||
"analyze_status": b.analyze_status,
|
||||
"created_at": b.created_at.isoformat(),
|
||||
}
|
||||
for b, pname in rows
|
||||
]
|
||||
if format == "csv":
|
||||
fields = ["id", "product_id", "product_name", "highlights",
|
||||
"link_url", "analyze_status", "created_at"]
|
||||
return _csv_response(data, fields, "benchmarks.csv")
|
||||
return data
|
||||
121
backend/app/api/v1/product_images.py
Normal file
121
backend/app/api/v1/product_images.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
app/api/v1/product_images.py — R5 产品多图管理(列/删/改场景/设主图)
|
||||
|
||||
上传走 products.py 的 /products/{id}/upload-image(带 scene)。
|
||||
本文件管已上传图的增删改。product.image_path 始终同步当前主图(向后兼容)。
|
||||
"""
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.constants.enums import ProductImageScene
|
||||
from app.core.database import get_db
|
||||
from app.core.response import ok, raise_not_found, raise_business
|
||||
from app.middleware.workspace_guard import CurrentUser, require_write_permission
|
||||
from app.models.product import Product, ProductImage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["product-images"])
|
||||
|
||||
|
||||
class SceneUpdate(BaseModel):
|
||||
scene: str
|
||||
|
||||
|
||||
def _get_product(db: Session, product_id: int, workspace_id: int) -> Product:
|
||||
p = db.query(Product).filter(
|
||||
Product.id == product_id, Product.workspace_id == workspace_id
|
||||
).first()
|
||||
if not p:
|
||||
raise_not_found("产品不存在")
|
||||
return p
|
||||
|
||||
|
||||
def _get_image(db: Session, product_id: int, image_id: int) -> ProductImage:
|
||||
img = db.query(ProductImage).filter(
|
||||
ProductImage.id == image_id, ProductImage.product_id == product_id
|
||||
).first()
|
||||
if not img:
|
||||
raise_not_found("产品图不存在")
|
||||
return img
|
||||
|
||||
|
||||
@router.get("/products/{product_id}/images")
|
||||
def list_images(
|
||||
product_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_get_product(db, product_id, current_user.workspace_id)
|
||||
imgs = db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product_id
|
||||
).order_by(ProductImage.sort_order).all()
|
||||
return ok([
|
||||
{"id": im.id, "path": im.path, "scene": im.scene,
|
||||
"is_primary": im.is_primary, "sort_order": im.sort_order}
|
||||
for im in imgs
|
||||
])
|
||||
|
||||
|
||||
@router.put("/products/{product_id}/images/{image_id}/scene")
|
||||
def update_scene(
|
||||
product_id: int, image_id: int, body: SceneUpdate,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_get_product(db, product_id, current_user.workspace_id)
|
||||
if body.scene not in {s.value for s in ProductImageScene}:
|
||||
raise_business(f"非法场景类型 {body.scene}")
|
||||
img = _get_image(db, product_id, image_id)
|
||||
img.scene = body.scene
|
||||
db.commit()
|
||||
logger.info("product image scene updated: id=%s scene=%s", image_id, body.scene)
|
||||
return ok({"id": image_id, "scene": body.scene})
|
||||
|
||||
|
||||
@router.put("/products/{product_id}/images/{image_id}/primary")
|
||||
def set_primary(
|
||||
product_id: int, image_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
p = _get_product(db, product_id, current_user.workspace_id)
|
||||
img = _get_image(db, product_id, image_id)
|
||||
db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product_id, ProductImage.is_primary == True
|
||||
).update({"is_primary": False})
|
||||
img.is_primary = True
|
||||
p.image_path = img.path # 同步主图字段(管道/校验读此字段)
|
||||
db.commit()
|
||||
logger.info("product primary image set: product_id=%s image_id=%s", product_id, image_id)
|
||||
return ok({"product_id": product_id, "primary_image_id": image_id})
|
||||
|
||||
|
||||
@router.delete("/products/{product_id}/images/{image_id}")
|
||||
def delete_image(
|
||||
product_id: int, image_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
p = _get_product(db, product_id, current_user.workspace_id)
|
||||
img = _get_image(db, product_id, image_id)
|
||||
was_primary = img.is_primary
|
||||
db.delete(img)
|
||||
db.flush()
|
||||
# 删的是主图:把剩余 sort_order 最小的一张顶上来当主图,保证 image_path 不悬空
|
||||
if was_primary:
|
||||
nxt = db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product_id
|
||||
).order_by(ProductImage.sort_order).first()
|
||||
if nxt:
|
||||
nxt.is_primary = True
|
||||
p.image_path = nxt.path
|
||||
else:
|
||||
p.image_path = None
|
||||
db.commit()
|
||||
logger.info("product image deleted: product_id=%s image_id=%s was_primary=%s",
|
||||
product_id, image_id, was_primary)
|
||||
return ok({"deleted": image_id})
|
||||
@@ -7,14 +7,14 @@ category 是纯数据字段,不在代码里做枚举(基石A)。
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, UploadFile, File
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, Form
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.response import ok, paginate, raise_not_found
|
||||
from app.middleware.workspace_guard import CurrentUser, require_admin, require_write_permission
|
||||
from app.models.product import BannedWord, BenchmarkNote, Product
|
||||
from app.models.product import BannedWord, BenchmarkNote, Product, ProductImage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["products"])
|
||||
@@ -50,6 +50,7 @@ class BannedWordCreate(BaseModel):
|
||||
|
||||
def _fmt_product(p: Product) -> dict:
|
||||
import json
|
||||
imgs = sorted(getattr(p, "images", None) or [], key=lambda im: im.sort_order)
|
||||
return {
|
||||
"id": p.id, "name": p.name, "category": p.category,
|
||||
"source": p.source, "is_active": p.is_active,
|
||||
@@ -58,6 +59,11 @@ def _fmt_product(p: Product) -> dict:
|
||||
"text_angles": json.loads(p.text_angles) if p.text_angles else [],
|
||||
"custom_prompt": p.custom_prompt,
|
||||
"image_path": p.image_path,
|
||||
"images": [
|
||||
{"id": im.id, "path": im.path, "scene": im.scene,
|
||||
"is_primary": im.is_primary, "sort_order": im.sort_order}
|
||||
for im in imgs
|
||||
],
|
||||
"brand_keyword": p.brand_keyword, # 012: 套2字段暴露
|
||||
"target_audience": p.target_audience, # 012: 套2字段暴露
|
||||
"created_at": p.created_at.isoformat(),
|
||||
@@ -82,7 +88,7 @@ def list_products(
|
||||
@router.post("/products")
|
||||
def create_product(
|
||||
body: ProductCreate,
|
||||
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
import json
|
||||
@@ -118,7 +124,7 @@ def get_product(
|
||||
@router.put("/products/{product_id}")
|
||||
def update_product(
|
||||
product_id: int, body: ProductCreate,
|
||||
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
import json
|
||||
@@ -155,10 +161,28 @@ _ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
||||
_MAX_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
|
||||
def _check_image_magic(data: bytes) -> bool:
|
||||
"""
|
||||
校验文件头 magic number,防 .exe 改名 .jpg 伪造 content_type 绕过。
|
||||
content_type 来自客户端可伪造,magic number 是真实字节,二者都要过。
|
||||
JPEG: FF D8 FF / PNG: 89 50 4E 47 0D 0A 1A 0A / WebP: RIFF....WEBP
|
||||
"""
|
||||
if len(data) < 12:
|
||||
return False
|
||||
if data[:3] == b"\xff\xd8\xff": # JPEG
|
||||
return True
|
||||
if data[:8] == b"\x89PNG\r\n\x1a\n": # PNG
|
||||
return True
|
||||
if data[:4] == b"RIFF" and data[8:12] == b"WEBP": # WebP
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@router.post("/products/{product_id}/upload-image")
|
||||
async def upload_product_image(
|
||||
product_id: int,
|
||||
file: UploadFile = File(...),
|
||||
scene: str = Form("primary"),
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -166,10 +190,12 @@ async def upload_product_image(
|
||||
上传产品参考图(铁律:生图必须带产品图)。
|
||||
文件类型:JPEG/PNG/WebP;大小上限 10 MB。
|
||||
存储路径:uploads/products/{workspace_id}/{product_id}/{filename}
|
||||
写回 product.image_path,生图管道读此字段构建 reference_images。
|
||||
R5多图:每次上传落一条 product_images(带 scene 场景类型)。
|
||||
首张或 scene=primary 时同步写 product.image_path 当主图(向后兼容)。
|
||||
"""
|
||||
from app.core.response import raise_business
|
||||
from app.core.config import get_settings
|
||||
from app.constants.enums import ProductImageScene
|
||||
import os, uuid
|
||||
|
||||
p = db.query(Product).filter(
|
||||
@@ -179,12 +205,18 @@ async def upload_product_image(
|
||||
if not p:
|
||||
raise_not_found("产品不存在")
|
||||
|
||||
valid_scenes = {s.value for s in ProductImageScene}
|
||||
if scene not in valid_scenes:
|
||||
raise_business(f"非法场景类型 {scene},仅支持 {sorted(valid_scenes)}")
|
||||
|
||||
if file.content_type not in _ALLOWED_CONTENT_TYPES:
|
||||
raise_business(f"不支持的文件类型 {file.content_type},仅支持 JPEG/PNG/WebP")
|
||||
|
||||
data = await file.read()
|
||||
if len(data) > _MAX_SIZE_BYTES:
|
||||
raise_business("文件超过 10 MB 限制")
|
||||
if not _check_image_magic(data):
|
||||
raise_business("文件内容与扩展名不符(非真实 JPEG/PNG/WebP 图片)")
|
||||
|
||||
settings = get_settings()
|
||||
ext = os.path.splitext(file.filename or "img.jpg")[1] or ".jpg"
|
||||
@@ -197,10 +229,29 @@ async def upload_product_image(
|
||||
with open(save_path, "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
p.image_path = save_path # 绝对路径,worker 直接 open 可读
|
||||
# 是否首张:决定要不要兜底设主图
|
||||
existing = db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product_id
|
||||
).count()
|
||||
is_primary = (existing == 0) or (scene == ProductImageScene.PRIMARY.value)
|
||||
if is_primary:
|
||||
# 同产品其它主图标记降级,保证唯一主图
|
||||
db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product_id,
|
||||
ProductImage.is_primary == True,
|
||||
).update({"is_primary": False})
|
||||
|
||||
img = ProductImage(
|
||||
product_id=product_id, path=save_path, scene=scene,
|
||||
is_primary=is_primary, sort_order=existing,
|
||||
)
|
||||
db.add(img)
|
||||
if is_primary:
|
||||
p.image_path = save_path # 向后兼容:管道/校验仍读此字段当主图
|
||||
db.commit()
|
||||
db.refresh(p)
|
||||
logger.info("product image uploaded: product_id=%s path=%s", product_id, save_path)
|
||||
logger.info("product image uploaded: product_id=%s scene=%s primary=%s path=%s",
|
||||
product_id, scene, is_primary, save_path)
|
||||
return ok(_fmt_product(p))
|
||||
|
||||
|
||||
@@ -273,6 +324,8 @@ async def analyze_product_image(
|
||||
data = await file.read()
|
||||
if len(data) > _MAX_SIZE_BYTES:
|
||||
raise_business("文件超过 10 MB 限制")
|
||||
if not _check_image_magic(data):
|
||||
raise_business("文件内容与扩展名不符(非真实 JPEG/PNG/WebP 图片)")
|
||||
|
||||
# key 解密(照抄 pipeline_steps.decrypt_user_key,基石B)
|
||||
api_key_row = db.query(UserApiKey).filter(
|
||||
|
||||
@@ -58,7 +58,9 @@ def select_image(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""选图(飞轮信号 image_select +3)。"""
|
||||
import json
|
||||
from app.services.flywheel_service import record_signal
|
||||
from app.constants.enums import IMAGE_STRATEGY_ANGLE
|
||||
task = _check_task_ownership(
|
||||
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
||||
current_user.workspace_id,
|
||||
@@ -70,7 +72,14 @@ def select_image(
|
||||
raise_not_found("图片候选不存在")
|
||||
ic.is_selected = True
|
||||
db.commit()
|
||||
record_signal(db, current_user, task, "image_select", candidate_id=ic.id)
|
||||
# R7 断点2:选图带 strategy → 映射叙事角度标签进飞轮,与文案 angle_label 并轨;
|
||||
# strategy 原值另存 signal_meta,便于后续按套别复盘。angle_label=None 不再空转。
|
||||
angle = IMAGE_STRATEGY_ANGLE.get(ic.strategy or "")
|
||||
meta = json.dumps({"strategy": ic.strategy, "role": ic.role}, ensure_ascii=False) if ic.strategy else None
|
||||
record_signal(
|
||||
db, current_user, task, "image_select",
|
||||
candidate_id=ic.id, angle_label=angle, signal_meta=meta,
|
||||
)
|
||||
return ok({"selected": body.candidate_id})
|
||||
|
||||
|
||||
@@ -159,7 +168,14 @@ def get_preference_context(
|
||||
current_user.workspace_id,
|
||||
)
|
||||
from app.services.flywheel_service import get_preference_context
|
||||
ctx = get_preference_context(db, current_user.workspace_id, task.product_id)
|
||||
# 取产品档案供冷启动基线(与生产链同口径)
|
||||
from app.models.product import Product
|
||||
_p = db.query(Product).filter(Product.id == task.product_id).first()
|
||||
product_dict = {
|
||||
"custom_prompt": getattr(_p, "custom_prompt", "") or "",
|
||||
"style_tone": getattr(_p, "style_tone", "") or "",
|
||||
} if _p else {}
|
||||
ctx = get_preference_context(db, current_user.workspace_id, task.product_id, product_dict)
|
||||
return ok(ctx)
|
||||
|
||||
|
||||
|
||||
@@ -170,16 +170,45 @@ def create_task(
|
||||
def list_tasks(
|
||||
page: int = 1, page_size: int = 20,
|
||||
status: list[str] | None = Query(default=None),
|
||||
date_from: str | None = Query(default=None, description="起始日(YYYY-MM-DD)"),
|
||||
date_to: str | None = Query(default=None, description="结束日(YYYY-MM-DD,含当日)"),
|
||||
product_id: int | None = Query(default=None),
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
from datetime import datetime, timedelta
|
||||
from app.models.product import Product
|
||||
q = db.query(GenerationTask).filter(GenerationTask.workspace_id == current_user.workspace_id)
|
||||
if status:
|
||||
# 支持多状态(?status=approved&status=rejected),单值也兼容
|
||||
q = q.filter(GenerationTask.status.in_(status))
|
||||
if product_id is not None:
|
||||
q = q.filter(GenerationTask.product_id == product_id)
|
||||
# 日期筛选:date 形参非法 → 40001(不静默吞成全量,防运营误判)
|
||||
try:
|
||||
if date_from:
|
||||
q = q.filter(GenerationTask.created_at >= datetime.fromisoformat(date_from))
|
||||
if date_to:
|
||||
# date_to 含当日:取次日 0 点为开区间上界,覆盖当日全部时刻
|
||||
_end = datetime.fromisoformat(date_to) + timedelta(days=1)
|
||||
q = q.filter(GenerationTask.created_at < _end)
|
||||
except ValueError:
|
||||
from app.core.response import raise_param_error
|
||||
raise_param_error("date_from/date_to 需为 YYYY-MM-DD 格式")
|
||||
total = q.count()
|
||||
items = q.order_by(GenerationTask.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
return ok(paginate([_fmt_task(t) for t in items], total, page, page_size))
|
||||
# product_name 一次性批量查(防 N+1):收集本页 product_id → id→name dict
|
||||
pids = {t.product_id for t in items if t.product_id}
|
||||
name_map: dict[int, str] = {}
|
||||
if pids:
|
||||
for p in db.query(Product.id, Product.name).filter(Product.id.in_(pids)).all():
|
||||
name_map[p.id] = p.name
|
||||
rows = []
|
||||
for t in items:
|
||||
d = _fmt_task(t)
|
||||
d["product_name"] = name_map.get(t.product_id)
|
||||
rows.append(d)
|
||||
return ok(paginate(rows, total, page, page_size))
|
||||
|
||||
|
||||
@router.get("/{task_id}")
|
||||
|
||||
Reference in New Issue
Block a user