init: 初始化 dpb 桃育种系统代码库

前后端 + 后端 FastAPI 全量源码、部署脚本与文档。
This commit is contained in:
34047007@qq.com
2026-08-06 00:17:49 +08:00
commit b95053c52c
1469 changed files with 322298 additions and 0 deletions
@@ -0,0 +1,134 @@
import urllib.parse
from typing import Annotated
from fastapi import APIRouter, Body, Depends, File, Path, Query, UploadFile
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, PageResultSchema, PaginationQueryParam, ImportResultSchema
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 (
EnvironmentConditionCreateSchema,
EnvironmentConditionOutSchema,
EnvironmentConditionQueryParam,
EnvironmentConditionUpdateSchema,
)
from .service import EnvironmentConditionService
EnvironmentConditionRouter = APIRouter(route_class=OperationLogRoute, prefix="/environment_condition", tags=["环境因子"])
@EnvironmentConditionRouter.get("/detail/{id}", summary="获取环境因子详情", response_model=ResponseSchema[EnvironmentConditionOutSchema])
async def get_environment_condition__detail_controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:environment_condition:detail"]))],
id: Annotated[int, Path(description="环境因子ID")],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
service = EnvironmentConditionService(auth, db)
result_dict = await service.detail(id=id)
return SuccessResponse(data=result_dict, msg="获取环境因子详情成功")
@EnvironmentConditionRouter.get("/list", summary="分页查询环境因子", response_model=ResponseSchema[PageResultSchema[EnvironmentConditionOutSchema]])
async def get_environment_condition__list_controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:environment_condition:query"]))],
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[EnvironmentConditionQueryParam, Query()],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
service = EnvironmentConditionService(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="查询环境因子列表成功")
@EnvironmentConditionRouter.get("/options", summary="环境因子下拉选项")
async def get_environment_condition__options_controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:environment_condition:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
service = EnvironmentConditionService(auth, db)
options = await service.list_options()
return SuccessResponse(data=options, msg="获取环境因子选项成功")
@EnvironmentConditionRouter.post("/create", summary="创建环境因子", response_model=ResponseSchema[EnvironmentConditionOutSchema])
async def create_environment_condition__controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:environment_condition:create"]))],
data: Annotated[EnvironmentConditionCreateSchema, Body(description="创建参数")],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
service = EnvironmentConditionService(auth, db)
result_dict = await service.create(data=data)
return SuccessResponse(data=result_dict, msg="创建环境因子成功")
@EnvironmentConditionRouter.put("/update/{id}", summary="修改环境因子", response_model=ResponseSchema[EnvironmentConditionOutSchema])
async def update_environment_condition__controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:environment_condition:update"]))],
id: Annotated[int, Path(description="环境因子ID")],
data: Annotated[EnvironmentConditionUpdateSchema, Body(description="修改参数")],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
service = EnvironmentConditionService(auth, db)
result_dict = await service.update(id=id, data=data)
return SuccessResponse(data=result_dict, msg="修改环境因子成功")
@EnvironmentConditionRouter.delete("/delete", summary="删除环境因子", response_model=ResponseSchema[None])
async def delete_environment_condition__controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:environment_condition:delete"]))],
ids: Annotated[list[int], Body(description="ID列表")],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
service = EnvironmentConditionService(auth, db)
await service.delete(ids=ids)
return SuccessResponse(msg="删除环境因子成功")
@EnvironmentConditionRouter.post("/export", summary="导出环境因子")
async def export_environment_condition__controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:environment_condition:export"]))],
search: Annotated[EnvironmentConditionQueryParam, Query()],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> StreamingResponse:
service = EnvironmentConditionService(auth, db)
result_dict_list = await service.get_list(search=search)
export_result = EnvironmentConditionService.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": f"attachment; filename={urllib.parse.quote('环境因子.xlsx')}"},
)
@EnvironmentConditionRouter.post("/import", summary="导入环境因子", response_model=ResponseSchema[ImportResultSchema])
async def import_environment_condition__controller(
file: Annotated[UploadFile, File(description="导入文件")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:environment_condition:import"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
service = EnvironmentConditionService(auth, db)
batch_import_result = await service.batch_import(file=file, update_support=True)
return SuccessResponse(data=batch_import_result, msg="导入环境因子成功")
@EnvironmentConditionRouter.post("/download/template", summary="获取环境因子导入模板", dependencies=[Depends(AuthPermission(["module_bre:environment_condition:download"]))])
async def download_environment_condition__template_controller() -> StreamingResponse:
import_template_result = EnvironmentConditionService.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,18 @@
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.base_crud import CRUDBase
from app.core.base_schema import AuthSchema
from .model import EnvironmentConditionModel
class BreedingEnvironmentConditionCRUD(CRUDBase[EnvironmentConditionModel, Any, Any]):
"""环境因子 CRUD —— 直接复用 CRUDBase(已自动注入数据权限过滤)。"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
super().__init__(EnvironmentConditionModel, auth, db)
environment_condition_crud = BreedingEnvironmentConditionCRUD
@@ -0,0 +1,48 @@
"""环境因子(气象) 数据模型"""
from decimal import Decimal
from sqlalchemy import ForeignKey, Index, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import MappedBase, ModelMixin, UserMixin
class EnvironmentConditionModel(ModelMixin, UserMixin, MappedBase):
"""环境因子(规格 §3.9;G×E 协变量,site×year 唯一)。"""
__tablename__ = "bre_environment_condition"
site_id: Mapped[int] = mapped_column(
Integer, ForeignKey("bre_site.id"), index=True, nullable=False, comment="试验基地"
)
year: Mapped[int] = mapped_column(Integer, nullable=False, comment="年份")
chilling_hours: Mapped[Decimal | None] = mapped_column(
Numeric(8, 1), nullable=True, comment="需冷量(小时)", default=None
)
growing_degree_days: Mapped[Decimal | None] = mapped_column(
Numeric(8, 1), nullable=True, comment="生长度日 GDD", default=None
)
rainfall_mm: Mapped[Decimal | None] = mapped_column(
Numeric(8, 1), nullable=True, comment="降水量(mm)", default=None
)
temp_avg: Mapped[Decimal | None] = mapped_column(
Numeric(6, 1), nullable=True, comment="年均温度(℃)", default=None
)
soil_moisture: Mapped[Decimal | None] = mapped_column(
Numeric(6, 1), nullable=True, comment="土壤湿度", default=None
)
source: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="数据来源", default=None)
remark: Mapped[str | None] = mapped_column(String(512), nullable=True, comment="备注", default=None)
__table_args__ = (
Index("ix_bre_environment_condition_created_deleted", "created_time", "is_deleted"),
Index("uq_env_site_year", "site_id", "year", unique=True),
)
@@ -0,0 +1,61 @@
"""环境因子 —— Pydantic 校验/序列化模型。"""
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
from app.core.base_schema import CommonSchema
class EnvironmentConditionBaseSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
site_id: int = Field(..., description="试验基地")
year: int = Field(..., description="年份")
chilling_hours: Decimal | None = Field(default=None, description="需冷量(小时)")
growing_degree_days: Decimal | None = Field(default=None, description="生长度日 GDD")
rainfall_mm: Decimal | None = Field(default=None, description="降水量(mm)")
temp_avg: Decimal | None = Field(default=None, description="年均温度(℃)")
soil_moisture: Decimal | None = Field(default=None, description="土壤湿度")
source: str | None = Field(default=None, description="数据来源")
remark: str | None = Field(default=None, description="备注")
class EnvironmentConditionCreateSchema(EnvironmentConditionBaseSchema):
pass
class EnvironmentConditionUpdateSchema(EnvironmentConditionBaseSchema):
site_id: int | None = Field(default=None, description="试验基地")
year: int | None = Field(default=None, description="年份")
chilling_hours: Decimal | None = Field(default=None, description="需冷量(小时)")
growing_degree_days: Decimal | None = Field(default=None, description="生长度日 GDD")
rainfall_mm: Decimal | None = Field(default=None, description="降水量(mm)")
temp_avg: Decimal | None = Field(default=None, description="年均温度(℃)")
soil_moisture: Decimal | None = Field(default=None, description="土壤湿度")
source: str | None = Field(default=None, description="数据来源")
remark: str | None = Field(default=None, description="备注")
class EnvironmentConditionOutSchema(EnvironmentConditionBaseSchema):
id: int
uuid: str
site_id: int | None = None
year: int | None = None
site_name: str | None = None # 由 Service 层联表填充
chilling_hours: Decimal | None = None
growing_degree_days: Decimal | None = None
rainfall_mm: Decimal | None = None
temp_avg: Decimal | None = None
soil_moisture: Decimal | None = None
source: str | None = None
remark: str | None = None
created_time: datetime | None = None
updated_time: datetime | None = None
created_by: CommonSchema | None = None
updated_by: CommonSchema | None = None
class EnvironmentConditionQueryParam(BaseModel):
site_id: int | None = Field(default=None, description="试验基地", json_schema_extra={"q": "eq"})
year: int | None = Field(default=None, description="年份", json_schema_extra={"q": "eq"})
@@ -0,0 +1,259 @@
from typing import Any
from fastapi import UploadFile
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.base_schema import AuthSchema, PageResultSchema, ImportResultSchema
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 BreedingEnvironmentConditionCRUD
from .model import EnvironmentConditionModel
from .schema import (
EnvironmentConditionCreateSchema,
EnvironmentConditionOutSchema,
EnvironmentConditionQueryParam,
EnvironmentConditionUpdateSchema,
)
from app.core.base_crud import assert_parents_exist
from app.api.v1.module_bre.site.crud import BreedingSiteCRUD
from app.api.v1.module_bre.site.model import BreedingSiteModel
def _is_blank(v: Any) -> bool:
return v is None or (isinstance(v, str) and v.strip() == "")
def _none_if_blank(v: Any) -> Any:
if _is_blank(v):
return None
return str(v).strip() if isinstance(v, str) else v
def _to_int(v: Any) -> int | None:
if _is_blank(v):
return None
try:
return int(float(v))
except (TypeError, ValueError):
return None
def _to_decimal(v: Any):
if _is_blank(v):
return None
try:
return float(v)
except (TypeError, ValueError):
return None
class EnvironmentConditionService:
"""环境因子 模块服务层"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
self.auth = auth
self.db = db
async def _attach_fk_labels(self, items: list[EnvironmentConditionOutSchema]) -> None:
if not items:
return
site_ids = {getattr(it, "site_id") for it in items if getattr(it, "site_id")}
if site_ids:
refs = await BreedingSiteCRUD(self.auth, self.db).get_list(search={"id": ("in", list(site_ids))})
ref_map = {r.id: getattr(r, "site_name") for r in refs}
for it in items:
it.site_name = ref_map.get(getattr(it, "site_id"))
async def _assert_site_year_unique(
self, site_id: int | None, year: int | None, exclude_id: int | None = None
) -> None:
"""(site_id, year) 唯一校验 —— 与 DB UNIQUE(uq_env_site_year) 对齐,含软删行。"""
if site_id is None or year is None:
return
conditions = [
EnvironmentConditionModel.site_id == site_id,
EnvironmentConditionModel.year == year,
]
if exclude_id is not None:
conditions.append(EnvironmentConditionModel.id != exclude_id)
result = await self.db.execute(select(func.count()).select_from(EnvironmentConditionModel).where(*conditions))
if result.scalar() or 0:
raise CustomException(
msg=f"该基地({site_id})在 {year} 年的环境记录已存在", status_code=409
)
async def detail(self, id: int) -> EnvironmentConditionOutSchema:
obj = await BreedingEnvironmentConditionCRUD(self.auth, self.db).get(id=id)
if not obj:
raise CustomException(msg="该环境记录不存在")
out = EnvironmentConditionOutSchema.model_validate(obj)
await self._attach_fk_labels([out])
return out
async def get_list(
self,
search: EnvironmentConditionQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> list[EnvironmentConditionOutSchema]:
obj_list = await BreedingEnvironmentConditionCRUD(self.auth, self.db).get_list(
search=search_to_dict(search), order_by=order_by
)
outs = [EnvironmentConditionOutSchema.model_validate(obj) for obj in obj_list]
await self._attach_fk_labels(outs)
return outs
async def page(
self,
page_no: int,
page_size: int,
search: EnvironmentConditionQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> PageResultSchema[EnvironmentConditionOutSchema]:
offset = (page_no - 1) * page_size
result = await BreedingEnvironmentConditionCRUD(self.auth, self.db).page(
offset=offset,
limit=page_size,
order_by=order_by or [{"id": "asc"}],
search=search_to_dict(search, {}),
out_schema=EnvironmentConditionOutSchema,
)
await self._attach_fk_labels(result.items)
return result
async def create(self, data: EnvironmentConditionCreateSchema) -> EnvironmentConditionOutSchema:
await assert_parents_exist(self.db, [(BreedingSiteModel, data.site_id, '试验基地')])
await self._assert_site_year_unique(data.site_id, data.year)
obj = await BreedingEnvironmentConditionCRUD(self.auth, self.db).create(data=data)
out = EnvironmentConditionOutSchema.model_validate(obj)
await self._attach_fk_labels([out])
return out
async def update(self, id: int, data: EnvironmentConditionUpdateSchema) -> EnvironmentConditionOutSchema:
obj = await BreedingEnvironmentConditionCRUD(self.auth, self.db).get(id=id)
if not obj:
raise CustomException(msg="更新失败,该环境记录不存在")
await assert_parents_exist(self.db, [(BreedingSiteModel, data.site_id, '试验基地')])
await self._assert_site_year_unique(data.site_id, data.year, exclude_id=id)
obj = await BreedingEnvironmentConditionCRUD(self.auth, self.db).update(id=id, data=data)
out = EnvironmentConditionOutSchema.model_validate(obj)
await self._attach_fk_labels([out])
return out
async def delete(self, ids: list[int]) -> None:
if not ids:
raise CustomException(msg="删除失败,删除对象不能为空")
objs = await BreedingEnvironmentConditionCRUD(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 BreedingEnvironmentConditionCRUD(self.auth, self.db).delete(ids=ids)
async def list_options(self) -> list[dict[str, Any]]:
"""供前端下拉选择使用:返回 [{value, label}]。"""
obj_list = await BreedingEnvironmentConditionCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
return [{"value": o.id, "label": f"基地{o.site_id}·{o.year}"} for o in obj_list]
@staticmethod
def batch_export(obj_list: list[dict[str, Any]]) -> bytes:
mapping_dict = {
"site_name": "试验基地",
"year": "年份",
"chilling_hours": "需冷量(小时)",
"growing_degree_days": "生长度日GDD",
"rainfall_mm": "降水量(mm)",
"temp_avg": "年均温度(℃)",
"soil_moisture": "土壤湿度",
"source": "数据来源",
"remark": "备注",
"created_time": "创建时间",
"created_by": "创建者",
}
data = [dict(item) for item in obj_list]
for item in data:
creator = item.get("created_by")
item["created_by"] = creator.get("name", "未知") if isinstance(creator, dict) else "未知"
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
async def batch_import(self, file: UploadFile, update_support: bool = False) -> ImportResultSchema:
header_dict = {
"试验基地": "site_id",
"年份": "year",
"需冷量(小时)": "chilling_hours",
"生长度日GDD": "growing_degree_days",
"降水量(mm)": "rainfall_mm",
"年均温度(℃)": "temp_avg",
"土壤湿度": "soil_moisture",
"数据来源": "source",
"备注": "remark",
}
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)}")
site_refs = await BreedingSiteCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
site_map = {getattr(r, "site_name"): r.id for r in site_refs}
error_msgs: list[str] = []
success_count = 0
crud = BreedingEnvironmentConditionCRUD(self.auth, self.db)
for i, row in enumerate(rows, start=1):
try:
fields = {
"site_id": site_map.get(str(row.get("site_id")).strip())
if not _is_blank(row.get("site_id")) else None,
"year": _to_int(row.get("year")),
"chilling_hours": _to_decimal(row.get("chilling_hours")),
"growing_degree_days": _to_decimal(row.get("growing_degree_days")),
"rainfall_mm": _to_decimal(row.get("rainfall_mm")),
"temp_avg": _to_decimal(row.get("temp_avg")),
"soil_moisture": _to_decimal(row.get("soil_moisture")),
"source": _none_if_blank(row.get("source")),
"remark": _none_if_blank(row.get("remark")),
}
if fields["site_id"] is None or fields["year"] is None:
raise ValueError("试验基地/年份不能为空")
await self._assert_site_year_unique(fields["site_id"], fields["year"])
create_data = EnvironmentConditionCreateSchema(**fields)
await crud.create(data=create_data)
success_count += 1
except Exception as e:
error_msgs.append(f"{i}行: {e!s}")
continue
return ImportResultSchema(
valid_count=success_count,
invalid_count=len(error_msgs),
message_list=error_msgs,
)
except Exception as e:
logger.error(f"批量导入环境因子失败: {e!s}")
raise CustomException(msg=f"导入失败: {e!s}")
@staticmethod
def import_template_download() -> bytes:
header_list = [
"试验基地",
"年份",
"需冷量(小时)",
"生长度日GDD",
"降水量(mm)",
"年均温度(℃)",
"土壤湿度",
"数据来源",
"备注",
]
selector_header_list = []
option_list = []
return ExcelUtil.get_excel_template(
header_list=header_list,
selector_header_list=selector_header_list,
option_list=option_list,
)