init: 初始化 dpb 桃育种系统代码库
前后端 + 后端 FastAPI 全量源码、部署脚本与文档。
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import urllib.parse
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, File, Path, Query, UploadFile, status
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.common.response import ResponseSchema, StreamResponse, SuccessResponse
|
||||
from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema, PaginationQueryParam
|
||||
from app.core.dependencies import AuthPermission, db_getter
|
||||
from app.core.router_class import OperationLogRoute
|
||||
from app.utils.common_util import bytes2file_response
|
||||
|
||||
from .schema import DemoCreateSchema, DemoOutSchema, DemoQueryParam, DemoUpdateSchema
|
||||
from .service import DemoService
|
||||
|
||||
DemoRouter = APIRouter(route_class=OperationLogRoute, prefix="/demo", tags=["示例管理"])
|
||||
|
||||
|
||||
@DemoRouter.get("/detail/{id}", summary="获取示例详情", response_model=ResponseSchema[DemoOutSchema])
|
||||
async def get_obj_detail_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_example:demo:detail"]))],
|
||||
id: Annotated[int, Path(description="示例ID")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = DemoService(auth, db)
|
||||
result_dict = await service.detail(id=id)
|
||||
return SuccessResponse(data=result_dict, msg="获取示例详情成功")
|
||||
|
||||
|
||||
@DemoRouter.get("/list", summary="分页查询示例", response_model=ResponseSchema[PageResultSchema[DemoOutSchema]])
|
||||
async def get_obj_list_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_example:demo:query"]))],
|
||||
page: Annotated[PaginationQueryParam, Depends()],
|
||||
search: Annotated[DemoQueryParam, Query()],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = DemoService(auth, db)
|
||||
result_dict = await service.page(
|
||||
page_no=page.page_no,
|
||||
page_size=page.page_size,
|
||||
search=search,
|
||||
order_by=page.order_by,
|
||||
)
|
||||
return SuccessResponse(data=result_dict, msg="查询示例列表成功")
|
||||
|
||||
|
||||
@DemoRouter.post("/create", status_code=status.HTTP_201_CREATED, summary="创建示例", response_model=ResponseSchema[DemoOutSchema])
|
||||
async def create_obj_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_example:demo:create"]))],
|
||||
data: Annotated[DemoCreateSchema, Body(description="创建参数")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = DemoService(auth, db)
|
||||
result_dict = await service.create(data=data)
|
||||
return SuccessResponse(data=result_dict, msg="创建示例成功")
|
||||
|
||||
|
||||
@DemoRouter.put("/update/{id}", summary="修改示例", response_model=ResponseSchema[DemoOutSchema])
|
||||
async def update_obj_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_example:demo:update"]))],
|
||||
id: Annotated[int, Path(description="示例ID")],
|
||||
data: Annotated[DemoUpdateSchema, Body(description="修改参数")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = DemoService(auth, db)
|
||||
result_dict = await service.update(id=id, data=data)
|
||||
return SuccessResponse(data=result_dict, msg="修改示例成功")
|
||||
|
||||
|
||||
@DemoRouter.delete("/delete", summary="删除示例", response_model=ResponseSchema[None])
|
||||
async def delete_obj_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_example:demo:delete"]))],
|
||||
ids: Annotated[list[int], Body(description="ID列表")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = DemoService(auth, db)
|
||||
await service.delete(ids=ids)
|
||||
return SuccessResponse(msg="删除示例成功")
|
||||
|
||||
|
||||
@DemoRouter.patch("/status/batch", summary="批量修改示例状态", response_model=ResponseSchema[None])
|
||||
async def batch_set_available_obj_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_example:demo:patch"]))],
|
||||
data: Annotated[BatchSetAvailable, Body(description="状态设置")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = DemoService(auth, db)
|
||||
await service.set_available(data=data)
|
||||
return SuccessResponse(msg="批量修改示例状态成功")
|
||||
|
||||
|
||||
@DemoRouter.post("/export", summary="导出示例")
|
||||
async def export_obj_list_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_example:demo:export"]))],
|
||||
search: Annotated[DemoQueryParam, Query()],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> StreamingResponse:
|
||||
service = DemoService(auth, db)
|
||||
result_dict_list = await service.get_list(search=search)
|
||||
export_result = DemoService.batch_export(obj_list=[item.model_dump() for item in result_dict_list])
|
||||
|
||||
return StreamResponse(
|
||||
data=bytes2file_response(export_result),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": "attachment; filename=demo.xlsx"},
|
||||
)
|
||||
|
||||
|
||||
@DemoRouter.post("/import", summary="导入示例", response_model=ResponseSchema[str])
|
||||
async def import_obj_list_controller(
|
||||
file: Annotated[UploadFile, File(description="导入文件")],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_example:demo:import"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = DemoService(auth, db)
|
||||
batch_import_result = await service.batch_import(file=file, update_support=True)
|
||||
return SuccessResponse(data=batch_import_result, msg="导入示例成功")
|
||||
|
||||
|
||||
@DemoRouter.post("/download/template", summary="获取示例导入模板", dependencies=[Depends(AuthPermission(["module_example:demo:download"]))])
|
||||
async def export_obj_template_controller() -> StreamingResponse:
|
||||
import_template_result = DemoService.import_template_download()
|
||||
|
||||
return StreamResponse(
|
||||
data=bytes2file_response(import_template_result),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={urllib.parse.quote('示例导入模板.xlsx')}",
|
||||
"Access-Control-Expose-Headers": "Content-Disposition",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.base_crud import CRUDBase
|
||||
from app.core.base_schema import AuthSchema
|
||||
|
||||
from .model import DemoModel
|
||||
from .schema import DemoCreateSchema, DemoUpdateSchema
|
||||
|
||||
|
||||
class DemoCRUD(CRUDBase[DemoModel, DemoCreateSchema, DemoUpdateSchema]):
|
||||
"""示例数据层"""
|
||||
|
||||
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
|
||||
"""初始化CRUD数据层
|
||||
|
||||
参数:
|
||||
- auth (AuthSchema): 认证信息模型
|
||||
- db (AsyncSession): 数据库会话
|
||||
"""
|
||||
super().__init__(model=DemoModel, auth=auth, db=db)
|
||||
@@ -0,0 +1,27 @@
|
||||
from datetime import date, datetime, time
|
||||
|
||||
from sqlalchemy import BIGINT, JSON, Boolean, Date, DateTime, Float, Integer, String, Text, Time
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.base_model import ModelMixin, UserMixin
|
||||
|
||||
|
||||
class DemoModel(ModelMixin, UserMixin):
|
||||
"""示例表 - 涵盖大多数常用数据类型
|
||||
"""
|
||||
|
||||
__tablename__: str = "example_demo"
|
||||
__table_args__: dict[str, str] = {"comment": "示例表"}
|
||||
|
||||
name: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="名称")
|
||||
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)", index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
|
||||
int_val: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="整数")
|
||||
bigint_val: Mapped[int | None] = mapped_column(BIGINT, nullable=True, comment="大整数")
|
||||
float_val: Mapped[float | None] = mapped_column(Float, nullable=True, comment="浮点数")
|
||||
bool_val: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, comment="布尔型")
|
||||
date_val: Mapped[date | None] = mapped_column(Date, nullable=True, comment="日期")
|
||||
time_val: Mapped[time | None] = mapped_column(Time, nullable=True, comment="时间")
|
||||
datetime_val: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="日期时间")
|
||||
text_val: Mapped[str | None] = mapped_column(Text, nullable=True, comment="长文本")
|
||||
json_val: Mapped[dict | None] = mapped_column(JSON, nullable=True, comment="元数据(JSON格式)")
|
||||
@@ -0,0 +1,95 @@
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
|
||||
from app.core.validator import DateStr, DateTimeStr, TimeStr
|
||||
|
||||
|
||||
class DemoCreateSchema(BaseModel):
|
||||
"""新增模型"""
|
||||
|
||||
name: str = Field(..., description="名称")
|
||||
status: int = Field(default=0, ge=0, le=1, description="是否启用(0:启用 1:禁用)")
|
||||
description: str | None = Field(default=None, description="描述")
|
||||
int_val: int | None = Field(default=None, description="整数")
|
||||
bigint_val: int | None = Field(default=None, description="大整数")
|
||||
float_val: float | None = Field(default=None, description="浮点数")
|
||||
bool_val: bool = Field(default=True, description="布尔型")
|
||||
date_val: DateStr | None = Field(default=None, description="日期")
|
||||
time_val: TimeStr | None = Field(default=None, description="时间")
|
||||
datetime_val: DateTimeStr | None = Field(default=None, description="日期时间")
|
||||
text_val: str | None = Field(default=None, description="长文本")
|
||||
json_val: dict | None = Field(default=None, description="元数据(JSON格式)")
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls, v: str) -> str:
|
||||
"""验证名称字段的格式和内容。
|
||||
|
||||
参数:
|
||||
- v (str): 原始名称。
|
||||
|
||||
返回:
|
||||
- str: 去空白后的名称。
|
||||
|
||||
异常:
|
||||
- ValueError: 名称为空时抛出。
|
||||
"""
|
||||
# 去除首尾空格
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("名称不能为空")
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _after_validation(self):
|
||||
"""核心业务规则校验
|
||||
"""
|
||||
# 长度校验:名称最小长度
|
||||
if len(self.name) < 2 or len(self.name) > 50:
|
||||
raise ValueError("名称长度必须在2-50个字符之间")
|
||||
# 格式校验:名称只能包含字母、数字、下划线和中划线
|
||||
if not all(c.isalnum() or c in "-_" for c in self.name):
|
||||
raise ValueError("名称只能包含字母、数字、下划线和中划线")
|
||||
if self.status not in [0, 1]:
|
||||
raise ValueError("是否启用必须为0或1")
|
||||
# 描述校验:描述最大长度
|
||||
if self.description and len(self.description) > 255:
|
||||
raise ValueError("描述长度不能超过255个字符")
|
||||
return self
|
||||
|
||||
|
||||
class DemoUpdateSchema(BaseModel):
|
||||
"""更新模型"""
|
||||
|
||||
name: str | None = Field(default=None, description="名称")
|
||||
status: int | None = Field(default=None, ge=0, le=1, description="是否启用(0:启用 1:禁用)")
|
||||
description: str | None = Field(default=None, description="描述")
|
||||
int_val: int | None = Field(default=None, description="整数")
|
||||
bigint_val: int | None = Field(default=None, description="大整数")
|
||||
float_val: float | None = Field(default=None, description="浮点数")
|
||||
bool_val: bool | None = Field(default=None, description="布尔型")
|
||||
date_val: DateStr | None = Field(default=None, description="日期")
|
||||
time_val: TimeStr | None = Field(default=None, description="时间")
|
||||
datetime_val: DateTimeStr | None = Field(default=None, description="日期时间")
|
||||
text_val: str | None = Field(default=None, description="长文本")
|
||||
json_val: dict | None = Field(default=None, description="元数据(JSON格式)")
|
||||
|
||||
|
||||
class DemoOutSchema(DemoCreateSchema, BaseSchema, UserBySchema):
|
||||
"""响应模型"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class DemoQueryParam(BaseQueryParam, UserByQueryParam):
|
||||
"""示例查询参数(演示 Mixin 继承用法)"""
|
||||
|
||||
name: str | None = Field(None, description="名称", json_schema_extra={"q": "like"})
|
||||
description: str | None = Field(None, description="描述", json_schema_extra={"q": "like"})
|
||||
status: int | None = Field(None, description="是否启用", json_schema_extra={"q": "eq"})
|
||||
@@ -0,0 +1,202 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema
|
||||
from app.core.exceptions import CustomException
|
||||
from app.core.logger import logger
|
||||
from app.utils.common_util import search_to_dict
|
||||
from app.utils.excel_util import ExcelUtil
|
||||
|
||||
from .crud import DemoCRUD
|
||||
from .schema import (
|
||||
DemoCreateSchema,
|
||||
DemoOutSchema,
|
||||
DemoQueryParam,
|
||||
DemoUpdateSchema,
|
||||
)
|
||||
|
||||
|
||||
class DemoService:
|
||||
"""示例管理模块服务层"""
|
||||
|
||||
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
|
||||
self.auth = auth
|
||||
self.db = db
|
||||
|
||||
async def detail(self, id: int) -> DemoOutSchema:
|
||||
obj = await DemoCRUD(self.auth, self.db).get(id=id)
|
||||
if not obj:
|
||||
raise CustomException(msg="该数据不存在")
|
||||
return DemoOutSchema.model_validate(obj)
|
||||
|
||||
async def get_list(
|
||||
self,
|
||||
search: DemoQueryParam | None = None,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
) -> list[DemoOutSchema]:
|
||||
obj_list = await DemoCRUD(self.auth, self.db).get_list(search=search_to_dict(search), order_by=order_by)
|
||||
return [DemoOutSchema.model_validate(obj) for obj in obj_list]
|
||||
|
||||
async def page(
|
||||
self,
|
||||
page_no: int,
|
||||
page_size: int,
|
||||
search: DemoQueryParam | None = None,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
) -> PageResultSchema[DemoOutSchema]:
|
||||
offset = (page_no - 1) * page_size
|
||||
return await DemoCRUD(self.auth, self.db).page(
|
||||
offset=offset,
|
||||
limit=page_size,
|
||||
order_by=order_by or [{"id": "asc"}],
|
||||
search=search_to_dict(search, {}),
|
||||
out_schema=DemoOutSchema,
|
||||
)
|
||||
|
||||
async def create(self, data: DemoCreateSchema) -> DemoOutSchema:
|
||||
obj = await DemoCRUD(self.auth, self.db).get(name=data.name)
|
||||
if obj:
|
||||
raise CustomException(msg="创建失败,名称已存在")
|
||||
obj = await DemoCRUD(self.auth, self.db).create(data=data)
|
||||
return DemoOutSchema.model_validate(obj)
|
||||
|
||||
async def update(self, id: int, data: DemoUpdateSchema) -> DemoOutSchema:
|
||||
obj = await DemoCRUD(self.auth, self.db).get(id=id)
|
||||
if not obj:
|
||||
raise CustomException(msg="更新失败,该数据不存在")
|
||||
|
||||
exist_obj = await DemoCRUD(self.auth, self.db).get(name=data.name)
|
||||
if exist_obj and exist_obj.id != id:
|
||||
raise CustomException(msg="更新失败,名称重复")
|
||||
|
||||
obj = await DemoCRUD(self.auth, self.db).update(id=id, data=data)
|
||||
return DemoOutSchema.model_validate(obj)
|
||||
|
||||
async def delete(self, ids: list[int]) -> None:
|
||||
if not ids:
|
||||
raise CustomException(msg="删除失败,删除对象不能为空")
|
||||
objs = await DemoCRUD(self.auth, self.db).get_list(search={"id": ("in", ids)})
|
||||
obj_map = {o.id: o for o in objs}
|
||||
for id_ in ids:
|
||||
if id_ not in obj_map:
|
||||
raise CustomException(msg="删除失败,该数据不存在")
|
||||
await DemoCRUD(self.auth, self.db).delete(ids=ids)
|
||||
|
||||
async def set_available(self, data: BatchSetAvailable) -> None:
|
||||
await DemoCRUD(self.auth, self.db).set(ids=data.ids, status=data.status)
|
||||
|
||||
@staticmethod
|
||||
def batch_export(obj_list: list[dict[str, Any]]) -> bytes:
|
||||
mapping_dict = {
|
||||
"id": "编号",
|
||||
"name": "名称",
|
||||
"status": "状态",
|
||||
"description": "备注",
|
||||
"created_time": "创建时间",
|
||||
"updated_time": "更新时间",
|
||||
"created_id": "创建者",
|
||||
}
|
||||
|
||||
data = obj_list.copy()
|
||||
for item in data:
|
||||
item["status"] = "启用" if item.get("status") == 0 else "停用"
|
||||
creator_info = item.get("created_id")
|
||||
if isinstance(creator_info, dict):
|
||||
item["created_id"] = creator_info.get("name", "未知")
|
||||
else:
|
||||
item["created_id"] = "未知"
|
||||
|
||||
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
|
||||
|
||||
async def batch_import(self, file: UploadFile, update_support: bool = False) -> str:
|
||||
header_dict = {"名称": "name", "状态": "status", "描述": "description"}
|
||||
|
||||
try:
|
||||
contents = await file.read()
|
||||
rows = ExcelUtil.read_excel_to_dicts(contents)
|
||||
await file.close()
|
||||
|
||||
if not rows:
|
||||
raise CustomException(msg="导入文件为空")
|
||||
|
||||
missing_headers = [h for h in header_dict if h not in rows[0]]
|
||||
if missing_headers:
|
||||
raise CustomException(msg=f"导入文件缺少必要的列: {', '.join(missing_headers)}")
|
||||
|
||||
# 将中文字段名映射为英文字段
|
||||
mapped_rows = []
|
||||
for row in rows:
|
||||
mapped_rows.append({en: row.get(ch) for ch, en in header_dict.items()})
|
||||
|
||||
required_fields = ["name", "status"]
|
||||
errors = []
|
||||
for field in required_fields:
|
||||
missing_indices = [i + 1 for i, r in enumerate(mapped_rows) if r.get(field) is None]
|
||||
if missing_indices:
|
||||
field_name = next(k for k, v in header_dict.items() if v == field)
|
||||
rows_str = "、".join(str(i) for i in missing_indices)
|
||||
errors.append(f"{field_name}不能为空,第{rows_str}行")
|
||||
if errors:
|
||||
raise CustomException(msg=f"导入失败,以下行缺少必要字段:\n{'; '.join(errors)}")
|
||||
|
||||
error_msgs = []
|
||||
success_count = 0
|
||||
|
||||
for i, row in enumerate(mapped_rows, start=1):
|
||||
try:
|
||||
status_str = str(row["status"]).strip()
|
||||
if status_str == "正常":
|
||||
status = 0
|
||||
elif status_str == "停用":
|
||||
status = 1
|
||||
else:
|
||||
error_msgs.append(f"第{i}行: 状态必须是'正常'或'停用'")
|
||||
continue
|
||||
|
||||
create_data = DemoCreateSchema(
|
||||
name=str(row["name"]),
|
||||
status=status,
|
||||
description=str(row["description"] or ""),
|
||||
)
|
||||
|
||||
exists_obj = await DemoCRUD(self.auth, self.db).get(name=create_data.name)
|
||||
if exists_obj:
|
||||
if update_support:
|
||||
update_data = DemoUpdateSchema(
|
||||
name=create_data.name,
|
||||
status=create_data.status,
|
||||
description=create_data.description,
|
||||
)
|
||||
await DemoCRUD(self.auth, self.db).update(id=exists_obj.id, data=update_data)
|
||||
success_count += 1
|
||||
else:
|
||||
error_msgs.append(f"第{i}行: 对象 {create_data.name} 已存在")
|
||||
else:
|
||||
await DemoCRUD(self.auth, self.db).create(data=create_data)
|
||||
success_count += 1
|
||||
|
||||
except Exception as e:
|
||||
error_msgs.append(f"第{i}行: {e!s}")
|
||||
continue
|
||||
|
||||
result = f"成功导入 {success_count} 条数据"
|
||||
if error_msgs:
|
||||
result += "\n错误信息:\n" + "\n".join(error_msgs)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"批量导入用户失败: {e!s}")
|
||||
raise CustomException(msg=f"导入失败: {e!s}")
|
||||
|
||||
@staticmethod
|
||||
def import_template_download() -> bytes:
|
||||
header_list = ["名称", "状态", "描述"]
|
||||
selector_header_list = ["状态"]
|
||||
option_list = [{"状态": ["正常", "停用"]}]
|
||||
return ExcelUtil.get_excel_template(
|
||||
header_list=header_list,
|
||||
selector_header_list=selector_header_list,
|
||||
option_list=option_list,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
# 插件元数据(可选):供文档、运维与 module_application.portal 展示使用。
|
||||
# 不用于运行时 pip 安装依赖;依赖见仓库根目录 pyproject.toml / uv.lock。
|
||||
|
||||
name = "example"
|
||||
title = "示例插件"
|
||||
version = "1.0.0"
|
||||
description = "演示 module_* 目录约定与动态路由注册(demo)。"
|
||||
optional = true
|
||||
tags = ["demo", "sample"]
|
||||
Reference in New Issue
Block a user