@@ -0,0 +1,545 @@
|
||||
"""WESP-shaped daily plan API (/api/daily-plan)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date as date_cls
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import Response
|
||||
|
||||
from app.modules.sync.tenant import TenantContext, require_enterprise_zootech
|
||||
from app.modules.zootech import wesp_daily_plan_service as dp_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _require_ent(enterprise_id: str, tenant: TenantContext) -> None:
|
||||
if tenant.enterprise_id != enterprise_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||
|
||||
|
||||
def _skip_duration_kwargs(data: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"duration": data.get("duration") or data.get("skipDuration"),
|
||||
"until_date": data.get("untilDate") or data.get("until_date") or data.get("validUntil"),
|
||||
}
|
||||
|
||||
|
||||
def _parse_plan_date_route(value) -> date_cls:
|
||||
try:
|
||||
return date_cls.fromisoformat(str(value).strip()[:10])
|
||||
except (TypeError, ValueError):
|
||||
return date_cls.today()
|
||||
|
||||
|
||||
def _lookup_error(exc: LookupError) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": True, "message": str(exc)},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/daily-plan")
|
||||
def get_daily_plan(
|
||||
enterprise_id: str = Query(...),
|
||||
dispenser_id: str = Query(..., alias="dispenser_id"),
|
||||
date: str | None = Query(None),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
_require_ent(enterprise_id, tenant)
|
||||
if not (dispenser_id or "").strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": True, "message": "dispenser_id обязателен"},
|
||||
)
|
||||
try:
|
||||
return dp_service.build_daily_plan(enterprise_id, dispenser_id=dispenser_id.strip(), plan_date=date)
|
||||
except LookupError as exc:
|
||||
raise _lookup_error(exc) from exc
|
||||
|
||||
|
||||
@router.get("/daily-plan/pdf")
|
||||
def get_daily_plan_pdf(
|
||||
enterprise_id: str = Query(...),
|
||||
dispenser_id: str = Query(..., alias="dispenser_id"),
|
||||
date: str | None = Query(None),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
_require_ent(enterprise_id, tenant)
|
||||
if not (dispenser_id or "").strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": True, "message": "dispenser_id обязателен"},
|
||||
)
|
||||
try:
|
||||
plan = dp_service.build_daily_plan(enterprise_id, dispenser_id=dispenser_id.strip(), plan_date=date)
|
||||
except LookupError as exc:
|
||||
raise _lookup_error(exc) from exc
|
||||
pdf_bytes = dp_service.build_daily_plan_pdf(plan)
|
||||
filename = f"plan-{plan.get('date', 'day')}.pdf"
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/daily-plan/skips")
|
||||
def get_daily_plan_skips(
|
||||
enterprise_id: str = Query(...),
|
||||
date: str | None = Query(None),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
_require_ent(enterprise_id, tenant)
|
||||
return dp_service.list_all_skips(enterprise_id, date)
|
||||
|
||||
|
||||
@router.post("/daily-plan/skips")
|
||||
def post_daily_plan_skip(
|
||||
body: dict,
|
||||
enterprise_id: str = Query(...),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
_require_ent(enterprise_id, tenant)
|
||||
recipe_id = (body.get("recipeId") or body.get("recipe_id") or "").strip()
|
||||
if not recipe_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": True, "message": "recipeId обязателен"},
|
||||
)
|
||||
try:
|
||||
row = dp_service.skip_trip(
|
||||
enterprise_id,
|
||||
recipe_id,
|
||||
body.get("date"),
|
||||
user="system",
|
||||
**_skip_duration_kwargs(body),
|
||||
)
|
||||
except LookupError as exc:
|
||||
raise _lookup_error(exc) from exc
|
||||
return dp_service._skip_row_response(row)
|
||||
|
||||
|
||||
@router.delete("/daily-plan/skips")
|
||||
def delete_daily_plan_skip(
|
||||
enterprise_id: str = Query(...),
|
||||
recipe_id: str | None = Query(None, alias="recipe_id"),
|
||||
recipeId: str | None = Query(None),
|
||||
date: str | None = Query(None),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
_require_ent(enterprise_id, tenant)
|
||||
rid = (recipe_id or recipeId or "").strip()
|
||||
if not rid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": True, "message": "recipe_id обязателен"},
|
||||
)
|
||||
if not dp_service.unskip_trip(enterprise_id, rid, date):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": True, "message": "Исключение не найдено"},
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/daily-plan/skips/ingredients")
|
||||
def post_daily_plan_ingredient_skip(
|
||||
body: dict,
|
||||
enterprise_id: str = Query(...),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
_require_ent(enterprise_id, tenant)
|
||||
recipe_id = (body.get("recipeId") or body.get("recipe_id") or "").strip()
|
||||
ingredient_id = (body.get("ingredientId") or body.get("ingredient_id") or "").strip()
|
||||
if not recipe_id or not ingredient_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": True, "message": "recipeId и ingredientId обязательны"},
|
||||
)
|
||||
try:
|
||||
row = dp_service.skip_ingredient(
|
||||
enterprise_id,
|
||||
recipe_id,
|
||||
ingredient_id,
|
||||
body.get("date"),
|
||||
user="system",
|
||||
**_skip_duration_kwargs(body),
|
||||
)
|
||||
except LookupError as exc:
|
||||
raise _lookup_error(exc) from exc
|
||||
return dp_service._skip_row_response(
|
||||
row,
|
||||
id_key="ingredient_id",
|
||||
extra={"ingredientId": row.get("ingredient_id")},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/daily-plan/skips/ingredients")
|
||||
def delete_daily_plan_ingredient_skip(
|
||||
enterprise_id: str = Query(...),
|
||||
recipe_id: str | None = Query(None, alias="recipe_id"),
|
||||
recipeId: str | None = Query(None),
|
||||
ingredient_id: str | None = Query(None, alias="ingredient_id"),
|
||||
ingredientId: str | None = Query(None),
|
||||
date: str | None = Query(None),
|
||||
all: str | None = Query(None),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
_require_ent(enterprise_id, tenant)
|
||||
rid = (recipe_id or recipeId or "").strip()
|
||||
iid = (ingredient_id or ingredientId or "").strip()
|
||||
if not rid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": True, "message": "recipe_id обязателен"},
|
||||
)
|
||||
if all in ("1", "true", "yes"):
|
||||
count = dp_service.unskip_all_ingredient_parts(enterprise_id, rid, date)
|
||||
if not count:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": True, "message": "Исключения не найдены"},
|
||||
)
|
||||
return {"ok": True, "count": count}
|
||||
if not iid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": True, "message": "ingredient_id обязателен"},
|
||||
)
|
||||
if not dp_service.unskip_ingredient(enterprise_id, rid, iid, date):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": True, "message": "Исключение не найдено"},
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/daily-plan/skips/unloading-groups")
|
||||
def post_daily_plan_unloading_group_skip(
|
||||
body: dict,
|
||||
enterprise_id: str = Query(...),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
_require_ent(enterprise_id, tenant)
|
||||
recipe_id = (body.get("recipeId") or body.get("recipe_id") or "").strip()
|
||||
group_id = (
|
||||
body.get("unloadingGroupId")
|
||||
or body.get("unloading_group_id")
|
||||
or body.get("groupId")
|
||||
or ""
|
||||
).strip()
|
||||
if not recipe_id or not group_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": True, "message": "recipeId и unloadingGroupId обязательны"},
|
||||
)
|
||||
try:
|
||||
row = dp_service.skip_unloading_group(
|
||||
enterprise_id,
|
||||
recipe_id,
|
||||
group_id,
|
||||
body.get("date"),
|
||||
user="system",
|
||||
**_skip_duration_kwargs(body),
|
||||
)
|
||||
except LookupError as exc:
|
||||
raise _lookup_error(exc) from exc
|
||||
return dp_service._skip_row_response(row, extra={"unloadingGroupId": row.get("unloading_group_id")})
|
||||
|
||||
|
||||
@router.delete("/daily-plan/skips/unloading-groups")
|
||||
def delete_daily_plan_unloading_group_skip(
|
||||
enterprise_id: str = Query(...),
|
||||
recipe_id: str | None = Query(None, alias="recipe_id"),
|
||||
recipeId: str | None = Query(None),
|
||||
unloading_group_id: str | None = Query(None, alias="unloading_group_id"),
|
||||
unloadingGroupId: str | None = Query(None),
|
||||
groupId: str | None = Query(None),
|
||||
date: str | None = Query(None),
|
||||
all: str | None = Query(None),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
_require_ent(enterprise_id, tenant)
|
||||
rid = (recipe_id or recipeId or "").strip()
|
||||
gid = (unloading_group_id or unloadingGroupId or groupId or "").strip()
|
||||
if not rid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": True, "message": "recipe_id обязателен"},
|
||||
)
|
||||
if all in ("1", "true", "yes"):
|
||||
count = dp_service.unskip_all_unloading_group_parts(enterprise_id, rid, date)
|
||||
if not count:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": True, "message": "Исключения не найдены"},
|
||||
)
|
||||
return {"ok": True, "count": count}
|
||||
if not gid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": True, "message": "unloading_group_id обязателен"},
|
||||
)
|
||||
if not dp_service.unskip_unloading_group(enterprise_id, rid, gid, date):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": True, "message": "Исключение не найдено"},
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/daily-plan/component-alternatives")
|
||||
def get_component_alternatives(
|
||||
enterprise_id: str = Query(...),
|
||||
component_id: str | None = Query(None, alias="component_id"),
|
||||
componentId: str | None = Query(None),
|
||||
q: str | None = Query(None),
|
||||
query: str | None = Query(None),
|
||||
limit: int = Query(20),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
_require_ent(enterprise_id, tenant)
|
||||
cid = (component_id or componentId or "").strip()
|
||||
if not cid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": True, "message": "component_id обязателен"},
|
||||
)
|
||||
try:
|
||||
return dp_service.find_component_alternatives(
|
||||
enterprise_id,
|
||||
cid,
|
||||
query=q or query or "",
|
||||
limit=limit,
|
||||
)
|
||||
except LookupError as exc:
|
||||
raise _lookup_error(exc) from exc
|
||||
|
||||
|
||||
@router.post("/daily-plan/replacements/ingredients")
|
||||
def post_daily_plan_ingredient_replacement(
|
||||
body: dict,
|
||||
enterprise_id: str = Query(...),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
_require_ent(enterprise_id, tenant)
|
||||
recipe_id = (body.get("recipeId") or body.get("recipe_id") or "").strip()
|
||||
ingredient_id = (body.get("ingredientId") or body.get("ingredient_id") or "").strip()
|
||||
replacement_id = (
|
||||
body.get("replacementComponentId")
|
||||
or body.get("replacement_component_id")
|
||||
or body.get("componentId")
|
||||
or ""
|
||||
).strip()
|
||||
if not recipe_id or not ingredient_id or not replacement_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"error": True,
|
||||
"message": "recipeId, ingredientId и replacementComponentId обязательны",
|
||||
},
|
||||
)
|
||||
try:
|
||||
row = dp_service.replace_ingredient(
|
||||
enterprise_id,
|
||||
recipe_id,
|
||||
ingredient_id,
|
||||
replacement_id,
|
||||
body.get("date"),
|
||||
user="system",
|
||||
**_skip_duration_kwargs(body),
|
||||
)
|
||||
except LookupError as exc:
|
||||
raise _lookup_error(exc) from exc
|
||||
return dp_service._skip_row_response(
|
||||
row,
|
||||
extra={
|
||||
"ingredientId": row.get("ingredient_id"),
|
||||
"replacementComponentId": row.get("replacement_component_id"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/daily-plan/replacements/components")
|
||||
def post_daily_plan_component_replacement(
|
||||
body: dict,
|
||||
enterprise_id: str = Query(...),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
_require_ent(enterprise_id, tenant)
|
||||
component_id = (body.get("componentId") or body.get("component_id") or "").strip()
|
||||
replacement_id = (
|
||||
body.get("replacementComponentId") or body.get("replacement_component_id") or ""
|
||||
).strip()
|
||||
dispenser_id = (body.get("dispenserId") or body.get("dispenser_id") or "").strip()
|
||||
if not component_id or not replacement_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": True, "message": "componentId и replacementComponentId обязательны"},
|
||||
)
|
||||
if not dispenser_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": True, "message": "dispenserId обязателен"},
|
||||
)
|
||||
try:
|
||||
rows = dp_service.replace_component_in_plan(
|
||||
enterprise_id,
|
||||
component_id,
|
||||
replacement_id,
|
||||
body.get("date"),
|
||||
dispenser_id=dispenser_id,
|
||||
user="system",
|
||||
**_skip_duration_kwargs(body),
|
||||
)
|
||||
except LookupError as exc:
|
||||
raise _lookup_error(exc) from exc
|
||||
return {
|
||||
"ok": True,
|
||||
"count": len(rows),
|
||||
"componentId": component_id,
|
||||
"replacementComponentId": replacement_id,
|
||||
"recipeIds": sorted({str(row.get("recipe_id")) for row in rows if row.get("recipe_id")}),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/daily-plan/replacements/ingredients")
|
||||
def delete_daily_plan_ingredient_replacement(
|
||||
enterprise_id: str = Query(...),
|
||||
recipe_id: str | None = Query(None, alias="recipe_id"),
|
||||
recipeId: str | None = Query(None),
|
||||
ingredient_id: str | None = Query(None, alias="ingredient_id"),
|
||||
ingredientId: str | None = Query(None),
|
||||
date: str | None = Query(None),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
_require_ent(enterprise_id, tenant)
|
||||
rid = (recipe_id or recipeId or "").strip()
|
||||
iid = (ingredient_id or ingredientId or "").strip()
|
||||
if not rid or not iid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": True, "message": "recipe_id и ingredient_id обязательны"},
|
||||
)
|
||||
if not dp_service.undo_ingredient_replacement(enterprise_id, rid, iid, date):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": True, "message": "Замена не найдена"},
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/daily-plan/component-norms")
|
||||
def get_component_norms(
|
||||
enterprise_id: str = Query(...),
|
||||
dispenser_id: str = Query(..., alias="dispenser_id"),
|
||||
date: str | None = Query(None),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
_require_ent(enterprise_id, tenant)
|
||||
if not (dispenser_id or "").strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": True, "message": "dispenser_id обязателен"},
|
||||
)
|
||||
rows = dp_service.list_component_norms_for_plan(
|
||||
enterprise_id,
|
||||
date,
|
||||
dispenser_id=dispenser_id.strip(),
|
||||
)
|
||||
return {"items": rows, "date": date}
|
||||
|
||||
|
||||
@router.post("/daily-plan/adjustments/components")
|
||||
def post_component_norm_adjustment(
|
||||
body: dict,
|
||||
enterprise_id: str = Query(...),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
_require_ent(enterprise_id, tenant)
|
||||
component_id = (body.get("componentId") or body.get("component_id") or "").strip()
|
||||
if not component_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": True, "message": "componentId обязателен"},
|
||||
)
|
||||
wph = body.get("weightPerHead", body.get("weight_per_head"))
|
||||
dm_ph = body.get("dryMatterPerHead", body.get("dry_matter_per_head"))
|
||||
dm = body.get("dryMatter", body.get("dryMatterPct", body.get("dry_matter")))
|
||||
dm_locked = body.get("dryMatterLocked", body.get("dry_matter_locked", False))
|
||||
try:
|
||||
wph_val = float(wph) if wph is not None and wph != "" else None
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": True, "message": "weightPerHead должен быть числом"},
|
||||
) from exc
|
||||
try:
|
||||
dm_val = float(dm_ph) if dm_ph is not None and dm_ph != "" else None
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": True, "message": "dryMatterPerHead должен быть числом"},
|
||||
) from exc
|
||||
try:
|
||||
dm_pct_val = float(dm) if dm is not None and dm != "" else None
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": True, "message": "dryMatter должен быть числом"},
|
||||
) from exc
|
||||
try:
|
||||
row = dp_service.adjust_component_norm(
|
||||
enterprise_id,
|
||||
component_id,
|
||||
body.get("date"),
|
||||
dry_matter=dm_pct_val,
|
||||
dry_matter_locked=bool(dm_locked),
|
||||
weight_per_head=wph_val,
|
||||
dry_matter_per_head=dm_val,
|
||||
user="system",
|
||||
**_skip_duration_kwargs(body),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": True, "message": str(exc)},
|
||||
) from exc
|
||||
except LookupError as exc:
|
||||
raise _lookup_error(exc) from exc
|
||||
end = row.get("valid_until") or row.get("plan_date")
|
||||
return {
|
||||
"ok": True,
|
||||
"componentId": row.get("component_id"),
|
||||
"dryMatter": row.get("dry_matter"),
|
||||
"dryMatterLocked": bool(row.get("dry_matter_locked")),
|
||||
"weightPerHead": row.get("weight_per_head"),
|
||||
"dryMatterPerHead": row.get("dry_matter_per_head"),
|
||||
"date": str(row.get("plan_date") or "")[:10],
|
||||
"validUntil": str(end)[:10] if end else str(row.get("plan_date") or "")[:10],
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/daily-plan/adjustments/components")
|
||||
def delete_component_norm_adjustment(
|
||||
enterprise_id: str = Query(...),
|
||||
component_id: str | None = Query(None, alias="component_id"),
|
||||
componentId: str | None = Query(None),
|
||||
date: str | None = Query(None),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
_require_ent(enterprise_id, tenant)
|
||||
cid = (component_id or componentId or "").strip()
|
||||
if not cid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": True, "message": "component_id обязателен"},
|
||||
)
|
||||
if not dp_service.undo_component_norm_adjustment(enterprise_id, cid, date):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": True, "message": "Правка нормы не найдена"},
|
||||
)
|
||||
return {"ok": True}
|
||||
Reference in New Issue
Block a user