Files
dpb/backend/app/api/v1/module_bre/brapi/controller.py
T
34047007@qq.com b95053c52c init: 初始化 dpb 桃育种系统代码库
前后端 + 后端 FastAPI 全量源码、部署脚本与文档。
2026-08-06 00:17:49 +08:00

551 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""BrAPI 2.x 只读互操作接口 + MIAPPE v1.1 元数据导出(批3 底座#8)。
不建表、不写库:仅将 bre_germplasm / bre_trait / bre_trial_study /
bre_trait_observation / bre_observation 映射为 BrAPI 2.x 资源,
/miappe 导出 MIAPPE v1.1 元数据模板。
响应按 BrAPI 规范以 metadata+pagination+result 信封直接返回,
不套用系统 ResponseSchema(避免破坏外部 BrAPI 客户端解析)。
"""
import json
from decimal import Decimal
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from fastapi.responses import JSONResponse
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.base_schema import AuthSchema
from app.core.dependencies import AuthPermission, db_getter
from ..environment_condition.model import EnvironmentConditionModel
from ..germplasm.model import BreedingGermplasmModel
from ..observation.model import ObservationModel
from ..site.model import BreedingSiteModel
from ..trait.model import TraitModel
from ..trait_observation.model import TraitObservationModel
from ..tree.model import TreeModel
from ..trial_study.model import TrialStudyModel
brapi_router = APIRouter(prefix="/brapi/v2", tags=["BrAPI 互操作"])
COMMON_CROP = "Peach"
_GENUS = "Prunus"
_SPECIES = "persica"
_MAX_PAGE_SIZE = 10000
# ---------------------------------------------------------------- 通用工具
def _num(v):
"""Numeric/Decimal → floatJSON 可序列化);None 透传。"""
if v is None:
return None
if isinstance(v, Decimal):
return float(v)
if isinstance(v, float) and v.is_integer():
return int(v)
return v
def _clean(d: dict) -> dict:
"""剔除 None 值(BrAPI 空字段不输出)。"""
return {k: v for k, v in d.items() if v is not None}
def _pagination(total: int, page: int, page_size: int) -> dict:
total_pages = (total + page_size - 1) // page_size if total else 0
return {
"pageSize": page_size,
"currentPage": page,
"totalCount": total,
"totalPages": total_pages,
}
def _envelope(total: int, page: int, page_size: int, data: list, info: str = "Success") -> dict:
return {
"metadata": {
"pagination": _pagination(total, page, page_size),
"status": [{"messageType": "INFO", "message": info}],
"datafiles": [],
},
"result": {"data": data},
}
def _parse_categories(scale_json: str | None) -> list[str]:
"""解析 bre_trait.scale_json 为 BrAPI scale.validValues.categories。"""
if not scale_json:
return []
try:
data = json.loads(scale_json)
except Exception:
return []
if isinstance(data, list):
out = []
for item in data:
if isinstance(item, dict):
label = item.get("label") or item.get("name") or item.get("value")
if label is not None:
out.append(str(label))
else:
out.append(str(item))
return out
if isinstance(data, dict):
for key in ("categories", "levels", "values"):
if isinstance(data.get(key), list):
return _parse_categories(json.dumps(data[key]))
return [str(k) for k in data.keys()]
return []
# ---------------------------------------------------------------- 映射函数
def _germplasm_to_brapi(g: BreedingGermplasmModel) -> dict:
additional = _clean({
"varietyType": g.variety_type,
"origin": g.origin,
"avgFruitWeightG": _num(g.avg_fruit_weight),
"sscPct": _num(g.ssc),
"firmness": g.firmness,
"maturityPeriod": g.maturity_period,
"floweringPeriod": g.flowering_period,
"chillingRequirementH": g.chilling_requirement,
"diseaseResistance": g.disease_resistance,
"sAlleles": g.s_alleles,
"canBeFemale": g.can_be_female,
"canBeMale": g.can_be_male,
"pedigreeNote": g.pedigree_note,
"photoPath": g.photo_path,
"stage": g.stage,
"generation": g.generation,
"storageType": g.storage_type,
"isRootstock": g.is_rootstock,
"rootstockId": g.rootstock_id,
"instituteCode": g.institute_code,
"collectionSite": g.collection_site,
"biologicalStatus": g.biological_status,
"breedingProgram": g.breeding_program,
})
return _clean({
"germplasmDbId": str(g.id),
"germplasmName": g.cultivar_name,
"germplasmPUI": str(g.uuid) if g.uuid else None,
"accessionNumber": g.accession_no,
"commonCropName": COMMON_CROP,
"genus": _GENUS,
"species": _SPECIES,
"subtaxa": g.variety_type,
"taxonIds": [{"sourceName": "bre_germplasm", "taxonId": g.variety_type}] if g.variety_type else [],
"acquisitionDate": g.acquisition_date.isoformat() if g.acquisition_date else None,
"countryOfOriginCode": g.country_origin,
"collection": g.preservation_site,
"pedigree": g.pedigree_note,
"synonyms": [],
"additionalInfo": additional,
"externalReferences": (
[{"referenceID": str(g.uuid), "referenceSource": "bre_germplasm"}] if g.uuid else []
),
})
def _trait_to_brapi(t: TraitModel) -> dict:
dt_map = {"numeric": "Numerical", "date": "Date", "text": "Text"}
dt = dt_map.get(t.data_type, "Text")
categories = _parse_categories(t.scale_json)
valid_values: dict = {}
if t.valid_min is not None:
valid_values["min"] = _num(t.valid_min)
if t.valid_max is not None:
valid_values["max"] = _num(t.valid_max)
if categories:
valid_values["categories"] = categories
if not valid_values and t.unit:
valid_values["value"] = t.unit
return _clean({
"observationVariableDbId": str(t.id),
"observationVariableName": t.trait_name,
"observationVariablePUI": t.ontology_uri,
"commonCropName": COMMON_CROP,
"trait": _clean({
"traitDbId": str(t.id),
"traitName": t.trait_name,
"class": t.category,
"description": t.remark,
"synonyms": [],
}),
"method": _clean({
"methodDbId": f"m{t.id}",
"methodName": t.method or t.trait_name,
"methodPUI": t.method_uri,
"description": t.method,
}),
"scale": _clean({
"scaleDbId": f"s{t.id}",
"scaleName": t.trait_name,
"scalePUI": t.ontology_uri,
"dataType": dt,
"validValues": valid_values,
}),
"growthStage": _clean({
"growthStageDbId": t.stage,
"growthStageName": t.stage,
}),
"defaultValue": None,
"synonyms": [],
})
def _study_to_brapi(s: TrialStudyModel, site_name: str | None) -> dict:
seasons = [{"seasonDbId": str(s.year), "season": str(s.year)}] if s.year else []
return _clean({
"studyDbId": str(s.id),
"studyName": s.study_name,
"studyDescription": s.remark,
"studyType": "Field Trial",
"studyDesign": s.design_type,
"startDate": f"{s.year}-01-01" if s.year else None,
"active": True,
"trialDbId": str(s.trial_id),
"locationDbId": str(s.site_id) if s.site_id else None,
"locationName": site_name,
"seasons": seasons,
"commonCropName": COMMON_CROP,
"additionalInfo": _clean({
"blockCount": s.block_count,
"season": s.season,
"designType": s.design_type,
}),
})
def _observation_to_brapi(
obs_id: int,
trait_id: int | None,
trait_name: str | None,
tree_id: int | None,
tree_no: str | None,
germplasm_id: int | None,
germplasm_name: str | None,
study_id: int | None,
study_name: str | None,
value: str | None,
time_stamp: str | None,
season_year: int | None,
additional: dict | None = None,
) -> dict:
return _clean({
"observationDbId": str(obs_id),
"observationVariableDbId": str(trait_id) if trait_id else None,
"observationVariableName": trait_name,
"studyDbId": str(study_id) if study_id else None,
"studyName": study_name,
"germplasmDbId": str(germplasm_id) if germplasm_id else None,
"germplasmName": germplasm_name or tree_no,
"observationUnitDbId": f"tree:{tree_id}" if tree_id else None,
"observationUnitName": tree_no,
"observationTimeStamp": time_stamp,
"value": value if value is not None else "",
"season": {"seasonDbId": str(season_year), "season": str(season_year)} if season_year else None,
"additionalInfo": additional or {},
})
# ---------------------------------------------------------------- 端点
@brapi_router.get("/germplasm", summary="BrAPI 种质资源(bre_germplasm 映射)")
async def brapi_germplasm(
auth: Annotated[AuthSchema, Depends(AuthPermission([]))],
db: Annotated[AsyncSession, Depends(db_getter)],
page: int = Query(0, ge=0, description="页码(0基)"),
pageSize: int = Query(1000, ge=1, le=_MAX_PAGE_SIZE, description="页大小"),
germplasmDbId: str | None = Query(None, description="按种质ID过滤"),
germplasmName: str | None = Query(None, description="按名称模糊过滤"),
) -> JSONResponse:
filters = [BreedingGermplasmModel.is_deleted == False]
if germplasmDbId and germplasmDbId.isdigit():
filters.append(BreedingGermplasmModel.id == int(germplasmDbId))
if germplasmName:
filters.append(BreedingGermplasmModel.cultivar_name.ilike(f"%{germplasmName}%"))
total = (await db.execute(select(func.count()).select_from(BreedingGermplasmModel).where(*filters))).scalar() or 0
rows = (
await db.execute(
select(BreedingGermplasmModel).where(*filters)
.order_by(BreedingGermplasmModel.id)
.offset(page * pageSize).limit(pageSize)
)
).scalars().all()
data = [_germplasm_to_brapi(g) for g in rows]
return JSONResponse(content=_envelope(total, page, pageSize, data, f"{total} 条种质"))
@brapi_router.get("/observationvariables", summary="BrAPI 观测变量(bre_trait 映射)")
async def brapi_observationvariables(
auth: Annotated[AuthSchema, Depends(AuthPermission([]))],
db: Annotated[AsyncSession, Depends(db_getter)],
page: int = Query(0, ge=0, description="页码(0基)"),
pageSize: int = Query(1000, ge=1, le=_MAX_PAGE_SIZE, description="页大小"),
observationVariableDbId: str | None = Query(None, description="按观测变量ID过滤"),
observationVariableName: str | None = Query(None, description="按变量名称模糊过滤"),
) -> JSONResponse:
filters = [TraitModel.is_deleted == False]
if observationVariableDbId and observationVariableDbId.isdigit():
filters.append(TraitModel.id == int(observationVariableDbId))
if observationVariableName:
filters.append(TraitModel.trait_name.ilike(f"%{observationVariableName}%"))
total = (await db.execute(select(func.count()).select_from(TraitModel).where(*filters))).scalar() or 0
rows = (
await db.execute(
select(TraitModel).where(*filters)
.order_by(TraitModel.id)
.offset(page * pageSize).limit(pageSize)
)
).scalars().all()
data = [_trait_to_brapi(t) for t in rows]
return JSONResponse(content=_envelope(total, page, pageSize, data, f"{total} 条观测变量"))
@brapi_router.get("/studies", summary="BrAPI 试验研究(bre_trial_study 映射)")
async def brapi_studies(
auth: Annotated[AuthSchema, Depends(AuthPermission([]))],
db: Annotated[AsyncSession, Depends(db_getter)],
page: int = Query(0, ge=0, description="页码(0基)"),
pageSize: int = Query(1000, ge=1, le=_MAX_PAGE_SIZE, description="页大小"),
studyDbId: str | None = Query(None, description="按研究ID过滤"),
studyName: str | None = Query(None, description="按研究名称模糊过滤"),
trialDbId: str | None = Query(None, description="按试验ID过滤"),
) -> JSONResponse:
filters = [TrialStudyModel.is_deleted == False]
if studyDbId and studyDbId.isdigit():
filters.append(TrialStudyModel.id == int(studyDbId))
if studyName:
filters.append(TrialStudyModel.study_name.ilike(f"%{studyName}%"))
if trialDbId and trialDbId.isdigit():
filters.append(TrialStudyModel.trial_id == int(trialDbId))
total = (await db.execute(select(func.count()).select_from(TrialStudyModel).where(*filters))).scalar() or 0
rows = (
await db.execute(
select(TrialStudyModel).where(*filters)
.order_by(TrialStudyModel.id)
.offset(page * pageSize).limit(pageSize)
)
).scalars().all()
site_ids = {s.site_id for s in rows if s.site_id}
site_map: dict[int, str] = {}
if site_ids:
sites = (await db.execute(select(BreedingSiteModel).where(BreedingSiteModel.id.in_(site_ids)))).scalars().all()
site_map = {s.id: s.site_name for s in sites}
data = [_study_to_brapi(s, site_map.get(s.site_id)) for s in rows]
return JSONResponse(content=_envelope(total, page, pageSize, data, f"{total} 条研究"))
@brapi_router.get("/observations", summary="BrAPI 观测值(bre_trait_observation / bre_observation 合并)")
async def brapi_observations(
auth: Annotated[AuthSchema, Depends(AuthPermission([]))],
db: Annotated[AsyncSession, Depends(db_getter)],
page: int = Query(0, ge=0, description="页码(0基)"),
pageSize: int = Query(1000, ge=1, le=_MAX_PAGE_SIZE, description="页大小"),
studyDbId: str | None = Query(None, description="按研究ID过滤"),
germplasmDbId: str | None = Query(None, description="按种质ID过滤(树关联种质)"),
observationVariableDbId: str | None = Query(None, description="按观测变量ID过滤"),
) -> JSONResponse:
study_id = int(studyDbId) if studyDbId and studyDbId.isdigit() else None
var_id = int(observationVariableDbId) if observationVariableDbId and observationVariableDbId.isdigit() else None
tree_filter: set[int] | None = None
if germplasmDbId and germplasmDbId.isdigit():
tree_ids = set(
(await db.execute(
select(TreeModel.id).where(TreeModel.is_deleted == False, TreeModel.germplasm_id == int(germplasmDbId))
)).scalars()
)
if not tree_ids:
return JSONResponse(content=_envelope(0, page, pageSize, []))
tree_filter = tree_ids
filters1 = [TraitObservationModel.is_deleted == False]
filters2 = [ObservationModel.is_deleted == False]
if study_id is not None:
filters1.append(TraitObservationModel.trial_study_id == study_id)
filters2.append(ObservationModel.trial_study_id == study_id)
if var_id is not None:
filters1.append(TraitObservationModel.trait_id == var_id)
filters2.append(ObservationModel.trait_id == var_id)
if tree_filter is not None:
filters1.append(TraitObservationModel.tree_id.in_(tree_filter))
filters2.append(ObservationModel.tree_id.in_(tree_filter))
rows1 = (await db.execute(select(TraitObservationModel).where(*filters1))).scalars().all()
rows2 = (await db.execute(select(ObservationModel).where(*filters2))).scalars().all()
tree_ids_needed = {r.tree_id for r in rows1 if r.tree_id} | {r.tree_id for r in rows2 if r.tree_id}
var_ids_needed = {r.trait_id for r in rows1 if r.trait_id} | {r.trait_id for r in rows2 if r.trait_id}
study_ids_needed = {r.trial_study_id for r in rows1 if r.trial_study_id} | {r.trial_study_id for r in rows2 if r.trial_study_id}
tree_map: dict[int, TreeModel] = {}
if tree_ids_needed:
tree_map = {t.id: t for t in (await db.execute(select(TreeModel).where(TreeModel.id.in_(tree_ids_needed)))).scalars().all()}
var_map: dict[int, TraitModel] = {}
if var_ids_needed:
var_map = {t.id: t for t in (await db.execute(select(TraitModel).where(TraitModel.id.in_(var_ids_needed)))).scalars().all()}
study_map: dict[int, TrialStudyModel] = {}
if study_ids_needed:
study_map = {s.id: s for s in (await db.execute(select(TrialStudyModel).where(TrialStudyModel.id.in_(study_ids_needed)))).scalars().all()}
germ_ids = {tree_map[t].germplasm_id for t in tree_map if tree_map[t].germplasm_id}
germ_map: dict[int, BreedingGermplasmModel] = {}
if germ_ids:
germ_map = {g.id: g for g in (await db.execute(select(BreedingGermplasmModel).where(BreedingGermplasmModel.id.in_(germ_ids)))).scalars().all()}
obs: list[tuple[tuple[str, str], dict]] = []
for r in rows1:
tree = tree_map.get(r.tree_id)
trait = var_map.get(r.trait_id)
study = study_map.get(r.trial_study_id)
germ = germ_map.get(tree.germplasm_id) if tree and tree.germplasm_id else None
value = str(r.value_numeric) if r.value_numeric is not None else (r.value_text or r.value_date)
ts = f"{r.evaluate_year}-01-01" if r.evaluate_year else r.created_time.isoformat() if r.created_time else None
item = _observation_to_brapi(
obs_id=r.id, trait_id=r.trait_id, trait_name=trait.trait_name if trait else None,
tree_id=r.tree_id, tree_no=tree.tree_no if tree else None,
germplasm_id=tree.germplasm_id if tree else None,
germplasm_name=germ.cultivar_name if germ else None,
study_id=r.trial_study_id, study_name=study.study_name if study else None,
value=value, time_stamp=ts, season_year=r.evaluate_year,
additional=_clean({"source": "bre_trait_observation", "evaluationId": r.evaluation_id, "cropLoad": _num(r.crop_load), "remark": r.remark}),
)
obs.append(((ts or "", str(r.id)), item))
for r in rows2:
tree = tree_map.get(r.tree_id)
trait = var_map.get(r.trait_id)
study = study_map.get(r.trial_study_id)
germ = germ_map.get(tree.germplasm_id) if tree and tree.germplasm_id else None
ts = r.obs_date.isoformat() if r.obs_date else (f"{r.obs_year}-01-01" if r.obs_year else r.created_time.isoformat() if r.created_time else None)
unit_db_id = f"tree:{r.tree_id}" if r.tree_id else (f"plot:{r.plot_id}" if r.plot_id else None)
item = _clean({
"observationDbId": f"eav-{r.id}",
"observationVariableDbId": str(r.trait_id) if r.trait_id else None,
"observationVariableName": trait.trait_name if trait else None,
"studyDbId": str(r.trial_study_id) if r.trial_study_id else None,
"studyName": study.study_name if study else None,
"germplasmDbId": str(tree.germplasm_id) if tree and tree.germplasm_id else None,
"germplasmName": (germ.cultivar_name if germ else None) or (tree.tree_no if tree else None),
"observationUnitDbId": unit_db_id,
"observationUnitName": tree.tree_no if tree else None,
"observationTimeStamp": ts,
"value": r.obs_value or "",
"season": {"seasonDbId": str(r.obs_year), "season": str(r.obs_year)} if r.obs_year else None,
"additionalInfo": _clean({"source": "bre_observation", "obsType": r.obs_type, "operatorId": r.operator_id, "remark": r.remark}),
})
obs.append(((ts or "", f"eav-{r.id}"), item))
obs.sort(key=lambda x: x[0])
total = len(obs)
page_items = [item for _, item in obs[page * pageSize:(page + 1) * pageSize]]
return JSONResponse(content=_envelope(total, page, pageSize, page_items, f"{total} 条观测"))
async def _build_miappe(db: AsyncSession, study: TrialStudyModel) -> dict:
"""按单个 study 组装 MIAPPE v1.1 元数据模板。"""
site = None
if study.site_id:
site = (await db.execute(select(BreedingSiteModel).where(BreedingSiteModel.id == study.site_id))).scalars().first()
env_rows: list[EnvironmentConditionModel] = []
if study.site_id and study.year:
env_rows = (await db.execute(
select(EnvironmentConditionModel).where(
EnvironmentConditionModel.site_id == study.site_id,
EnvironmentConditionModel.year == study.year,
)
)).scalars().all()
obs_cnt = (await db.execute(
select(func.count()).select_from(TraitObservationModel).where(TraitObservationModel.trial_study_id == study.id)
)).scalar() or 0
tree_cnt = (await db.execute(
select(func.count()).select_from(TreeModel).where(TreeModel.trial_study_id == study.id)
)).scalar() or 0
trait_cnt = (await db.execute(
select(func.count()).select_from(TraitModel).where(TraitModel.is_deleted == False)
)).scalar() or 0
meteo = [
_clean({
"chillingHours": _num(e.chilling_hours),
"growingDegreeDays": _num(e.growing_degree_days),
"rainfallMm": _num(e.rainfall_mm),
"tempAvgC": _num(e.temp_avg),
"soilMoisture": _num(e.soil_moisture),
"source": e.source,
})
for e in env_rows
]
geo = _clean({
"site": site.site_name if site else None,
"latitude": _num(site.latitude) if site else None,
"longitude": _num(site.longitude) if site else None,
"elevation": site.elevation if site else None,
})
return _clean({
"specification": "MIAPPE v1.1",
"investigation": {
"investigationUniqueID": f"bre-trial:{study.trial_id}",
"investigationTitle": study.study_name,
"project": None,
},
"study": _clean({
"studyUniqueID": f"bre-study:{study.id}",
"studyTitle": study.study_name,
"studyDesign": study.design_type,
"studyDescription": study.remark,
"plantStructure": None,
"coordinateType": "field-trial",
"geoLocation": geo,
"cultureSystem": study.season,
"environmentDescription": None,
"meteorologicalCharacteristics": meteo,
"soilCharacteristics": None,
}),
"biologicalMaterial": _clean({
"organism": f"{_GENUS} {_SPECIES}",
"organ": "fruit",
"genotype": None,
"pedigree": None,
}),
"environment": meteo,
"phenotype": {
"observationUnit": "tree",
"observedVariablesCount": trait_cnt,
"observationUnitsCount": tree_cnt,
"measurementsCount": obs_cnt,
},
})
@brapi_router.get("/miappe", summary="MIAPPE v1.1 元数据模板导出")
async def brapi_miappe(
auth: Annotated[AuthSchema, Depends(AuthPermission([]))],
db: Annotated[AsyncSession, Depends(db_getter)],
studyDbId: str | None = Query(None, description="按研究ID导出 MIAPPE 模板"),
page: int = Query(0, ge=0, description="页码(0基)"),
pageSize: int = Query(100, ge=1, le=_MAX_PAGE_SIZE, description="页大小"),
) -> JSONResponse:
if studyDbId and studyDbId.isdigit():
study = (await db.execute(
select(TrialStudyModel).where(TrialStudyModel.id == int(studyDbId), TrialStudyModel.is_deleted == False)
)).scalars().first()
if not study:
return JSONResponse(content=_envelope(0, page, pageSize, [], "未找到该研究"))
template = await _build_miappe(db, study)
return JSONResponse(content=_envelope(1, 0, pageSize, [template], "MIAPPE v1.1 模板"))
studies = (await db.execute(
select(TrialStudyModel).where(TrialStudyModel.is_deleted == False).order_by(TrialStudyModel.id)
)).scalars().all()
data = [
{
"studyDbId": str(s.id),
"studyName": s.study_name,
"miappeUrl": f"/brapi/v2/miappe?studyDbId={s.id}",
}
for s in studies
]
return JSONResponse(content=_envelope(len(data), page, pageSize, data, "选择 study 导出 MIAPPE 模板"))