feat:[FL-211][高程管理扁平化为文件记录]
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -30,7 +30,9 @@ from ...schemas.elevation import (
|
||||
ElevationFileRecordListResponse,
|
||||
ElevationFileRecordPreviewResponse,
|
||||
ElevationFileRecordSummary,
|
||||
ElevationFileRecordTaskStatusResponse,
|
||||
ElevationFileRecordTerrainBuildResponse,
|
||||
ElevationFileRecordTerrainTaskStatusResponse,
|
||||
ElevationFileRecordUpdateRequest,
|
||||
ElevationFileRecordUploadResponse,
|
||||
)
|
||||
@@ -40,6 +42,10 @@ from ...services.elevation_service import (
|
||||
delete_dataset,
|
||||
get_data_import_job_by_id,
|
||||
get_dataset_analysis_task_status,
|
||||
get_file_record_analysis_task_status,
|
||||
get_file_record_terrain_layer,
|
||||
get_file_record_terrain_task_status,
|
||||
get_file_record_terrain_tile,
|
||||
get_dataset_terrain_layer,
|
||||
get_dataset_terrain_task_status,
|
||||
get_dataset_terrain_tile,
|
||||
@@ -180,6 +186,46 @@ def preview_elevation_file_record(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/records/{record_id}/analysis-status", response_model=ElevationFileRecordTaskStatusResponse)
|
||||
def get_elevation_file_record_analysis_status(
|
||||
record_id: str,
|
||||
_: CurrentUser = Depends(require_any_permission("elevation.read", "elevation.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ElevationFileRecordTaskStatusResponse:
|
||||
return get_file_record_analysis_task_status(db, record_id=record_id)
|
||||
|
||||
|
||||
@router.get("/records/{record_id}/terrain-status", response_model=ElevationFileRecordTerrainTaskStatusResponse)
|
||||
def get_elevation_file_record_terrain_status(
|
||||
record_id: str,
|
||||
_: CurrentUser = Depends(require_any_permission("elevation.read", "elevation.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ElevationFileRecordTerrainTaskStatusResponse:
|
||||
return get_file_record_terrain_task_status(db, record_id=record_id)
|
||||
|
||||
|
||||
@router.get("/records/{record_id}/terrain/layer.json", response_model=ElevationTerrainLayerResponse)
|
||||
def get_elevation_file_record_terrain_layer(
|
||||
record_id: str,
|
||||
_: CurrentUser = Depends(require_any_permission("elevation.read", "elevation.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ElevationTerrainLayerResponse:
|
||||
return get_file_record_terrain_layer(db, record_id=record_id)
|
||||
|
||||
|
||||
@router.get("/records/{record_id}/terrain/{z}/{x}/{y}.terrain")
|
||||
def get_elevation_file_record_terrain_tile_endpoint(
|
||||
record_id: str,
|
||||
z: int,
|
||||
x: int,
|
||||
y: int,
|
||||
_: CurrentUser = Depends(require_any_permission("elevation.read", "elevation.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Response:
|
||||
content = get_file_record_terrain_tile(db, record_id=record_id, z=z, x=x, y=y)
|
||||
return Response(content=content, media_type="application/octet-stream")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Legacy Dataset API (向后兼容,逐步废弃)
|
||||
# ============================================================================
|
||||
@@ -378,6 +424,7 @@ def get_elevation_dataset_terrain_tile_endpoint(
|
||||
def get_elevation_jobs(
|
||||
line_id: str | None = Query(default=None),
|
||||
dataset_id: str | None = Query(default=None),
|
||||
file_record_id: str | None = Query(default=None),
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
_: CurrentUser = Depends(require_any_permission("elevation.read", "elevation.manage")),
|
||||
@@ -387,6 +434,7 @@ def get_elevation_jobs(
|
||||
db,
|
||||
line_id=line_id,
|
||||
dataset_id=dataset_id,
|
||||
file_record_id=file_record_id,
|
||||
status_filter=status_filter,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
@@ -293,6 +293,29 @@ class ElevationDatasetTerrainTaskStatusResponse(BaseModel):
|
||||
update_date: datetime | None = None
|
||||
|
||||
|
||||
class ElevationFileRecordTaskStatusResponse(BaseModel):
|
||||
record_id: str
|
||||
file_name: str
|
||||
task_id: str | None = None
|
||||
status: Literal["queued", "running", "success", "failed", "unknown", "not_found"] = "not_found"
|
||||
detail: str | None = None
|
||||
started_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
update_date: datetime | None = None
|
||||
|
||||
|
||||
class ElevationFileRecordTerrainTaskStatusResponse(BaseModel):
|
||||
record_id: str
|
||||
file_name: str
|
||||
task_id: str | None = None
|
||||
status: Literal["queued", "running", "success", "failed", "unknown", "not_found"] = "not_found"
|
||||
detail: str | None = None
|
||||
terrain_url_template: str | None = None
|
||||
terrain_min_zoom: int | None = None
|
||||
terrain_max_zoom: int | None = None
|
||||
update_date: datetime | None = None
|
||||
|
||||
|
||||
class ElevationTerrainLayerResponse(BaseModel):
|
||||
tilejson: str = "2.1.0"
|
||||
format: str = "heightmap-1.0"
|
||||
@@ -313,7 +336,7 @@ class ElevationApplyJobSummary(BaseModel):
|
||||
line_id: str
|
||||
line_code: str | None = None
|
||||
line_name: str | None = None
|
||||
file_record_id: str
|
||||
file_record_id: str | None = None
|
||||
file_record_name: str | None = None
|
||||
dataset_id: str | None = None
|
||||
dataset_code: str | None = None
|
||||
@@ -354,7 +377,9 @@ class ElevationApplyJobCreateResponse(BaseModel):
|
||||
|
||||
class ElevationDataImportJobSummary(BaseModel):
|
||||
id: str
|
||||
dataset_id: str
|
||||
dataset_id: str | None = None
|
||||
file_record_id: str | None = None
|
||||
file_record_name: str | None = None
|
||||
dataset_code: str | None = None
|
||||
dataset_name: str | None = None
|
||||
status: ElevationDataImportJobStatus
|
||||
@@ -384,4 +409,5 @@ class ElevationDataImportJobListResponse(BaseModel):
|
||||
total: int
|
||||
|
||||
|
||||
ElevationFileRecordPreviewResponse.model_rebuild()
|
||||
ElevationDatasetDataImportResponse.model_rebuild()
|
||||
|
||||
@@ -27,32 +27,21 @@ from ..schemas.elevation import (
|
||||
)
|
||||
from .elevation_service import (
|
||||
ELEVATION_FILE_EXT_FORMAT_MAP,
|
||||
ELEVATION_TOPIC,
|
||||
IMPORTABLE_ELEVATION_EXTENSIONS,
|
||||
RASTER_FILE_FORMATS,
|
||||
TERRAIN_SUPPORTED_DATASET_FORMATS,
|
||||
_analyze_dataset_content,
|
||||
_build_raster_preview,
|
||||
_decode_csv_bytes,
|
||||
_default_terrain_status_for_format,
|
||||
_detect_file_format,
|
||||
_fire_and_forget,
|
||||
_load_dataset_points,
|
||||
_normalize_str,
|
||||
_publish_elevation_change,
|
||||
_queue_dataset_terrain_build_after_analysis,
|
||||
_require_mount,
|
||||
_require_rasterio_available,
|
||||
_resolve_dataset_dir,
|
||||
_resolve_dataset_file_path,
|
||||
_resolve_dataset_mount_code,
|
||||
_sample_preview_points_from_csv,
|
||||
_supports_terrain_build,
|
||||
_sync_dataset_terrain_support,
|
||||
ElevationDatasetPreviewDiagnostics,
|
||||
ElevationDatasetPreviewPoint,
|
||||
join_virtual_path,
|
||||
publish_topic,
|
||||
)
|
||||
from .file_service import _build_driver_or_400
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from ..core.database import SessionLocal
|
||||
from ..models.base import utcnow
|
||||
from ..models.elevation import ElevationApplyJob, ElevationDataImportJob, ElevationDataset, ElevationDatasetFileMeta
|
||||
from ..models.elevation import ElevationApplyJob, ElevationDataImportJob, ElevationDataset, ElevationDatasetFileMeta, ElevationFileRecord
|
||||
from ..models.line import Line
|
||||
from ..models.line_tower import LineTower
|
||||
from ..models.user import User
|
||||
@@ -36,6 +36,8 @@ from ..schemas.elevation import (
|
||||
ElevationDatasetDataImportResponse,
|
||||
ElevationDatasetTerrainBuildResponse,
|
||||
ElevationDatasetTerrainTaskStatusResponse,
|
||||
ElevationFileRecordTaskStatusResponse,
|
||||
ElevationFileRecordTerrainTaskStatusResponse,
|
||||
ElevationTerrainLayerResponse,
|
||||
ElevationDatasetPreviewCell,
|
||||
ElevationDatasetPreviewDiagnostics,
|
||||
@@ -181,11 +183,14 @@ def serialize_dataset(item: ElevationDataset) -> ElevationDatasetSummary:
|
||||
def serialize_job(item: ElevationApplyJob) -> ElevationApplyJobSummary:
|
||||
line = item.line
|
||||
dataset = item.dataset
|
||||
file_record = item.file_record
|
||||
return ElevationApplyJobSummary(
|
||||
id=item.id,
|
||||
line_id=item.line_id,
|
||||
line_code=line.code if line else None,
|
||||
line_name=line.name if line else None,
|
||||
file_record_id=item.file_record_id,
|
||||
file_record_name=file_record.file_name if file_record else None,
|
||||
dataset_id=item.dataset_id,
|
||||
dataset_code=dataset.code if dataset else None,
|
||||
dataset_name=dataset.name if dataset else None,
|
||||
@@ -209,9 +214,12 @@ def serialize_job(item: ElevationApplyJob) -> ElevationApplyJobSummary:
|
||||
|
||||
def serialize_data_import_job(item: ElevationDataImportJob) -> ElevationDataImportJobSummary:
|
||||
dataset = item.dataset
|
||||
file_record = item.file_record
|
||||
return ElevationDataImportJobSummary(
|
||||
id=item.id,
|
||||
dataset_id=item.dataset_id,
|
||||
file_record_id=item.file_record_id,
|
||||
file_record_name=file_record.file_name if file_record else None,
|
||||
dataset_code=dataset.code if dataset else None,
|
||||
dataset_name=dataset.name if dataset else None,
|
||||
status=item.status, # type: ignore[arg-type]
|
||||
@@ -277,6 +285,12 @@ def get_dataset_by_id(db: Session, dataset_id: str) -> ElevationDataset | None:
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_file_record_by_id(db: Session, record_id: str) -> ElevationFileRecord | None:
|
||||
return db.execute(
|
||||
select(ElevationFileRecord).where(ElevationFileRecord.id == record_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_dataset_by_code(db: Session, code: str) -> ElevationDataset | None:
|
||||
normalized = code.strip()
|
||||
if not normalized:
|
||||
@@ -309,6 +323,10 @@ def _supports_terrain_build(dataset: ElevationDataset) -> bool:
|
||||
return _resolve_dataset_file_format(dataset) in TERRAIN_SUPPORTED_DATASET_FORMATS
|
||||
|
||||
|
||||
def _supports_file_record_terrain_build(record: ElevationFileRecord) -> bool:
|
||||
return _resolve_file_record_format(record) in TERRAIN_SUPPORTED_DATASET_FORMATS
|
||||
|
||||
|
||||
def _default_terrain_status_for_format(file_format: str) -> str:
|
||||
return "pending" if file_format in TERRAIN_SUPPORTED_DATASET_FORMATS else "not_supported"
|
||||
|
||||
@@ -339,10 +357,26 @@ def _resolve_dataset_terrain_tile_path(*, dataset_code: str, z: int, x: int, y:
|
||||
return join_virtual_path(join_virtual_path(join_virtual_path(_resolve_dataset_terrain_dir(dataset_code), str(z)), str(x)), f"{y}.terrain")
|
||||
|
||||
|
||||
def _resolve_file_record_terrain_dir(record_id: str) -> str:
|
||||
return f"/elevation/terrain/records/{record_id[:2]}/{record_id[2:4]}/{record_id}"
|
||||
|
||||
|
||||
def _resolve_file_record_terrain_layer_path(record_id: str) -> str:
|
||||
return join_virtual_path(_resolve_file_record_terrain_dir(record_id), "layer.json")
|
||||
|
||||
|
||||
def _resolve_file_record_terrain_tile_path(*, record_id: str, z: int, x: int, y: int) -> str:
|
||||
return join_virtual_path(join_virtual_path(join_virtual_path(_resolve_file_record_terrain_dir(record_id), str(z)), str(x)), f"{y}.terrain")
|
||||
|
||||
|
||||
def _build_dataset_terrain_url_template(dataset_id: str) -> str:
|
||||
return f"/api/v1/elevation/datasets/{dataset_id}/terrain/{{z}}/{{x}}/{{y}}.terrain?v={TERRAIN_TILE_VERSION}"
|
||||
|
||||
|
||||
def _build_file_record_terrain_url_template(record_id: str) -> str:
|
||||
return f"/api/v1/elevation/records/{record_id}/terrain/{{z}}/{{x}}/{{y}}.terrain?v={TERRAIN_TILE_VERSION}"
|
||||
|
||||
|
||||
def _map_dataset_task_status(status_value: str | None) -> str:
|
||||
status_map = {
|
||||
"queued": "queued",
|
||||
@@ -1276,6 +1310,7 @@ def get_dataset_terrain_layer(
|
||||
data = json.loads(payload.content.decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="地形 layer.json 解析失败") from exc
|
||||
data.setdefault("minzoom", 0)
|
||||
return ElevationTerrainLayerResponse.model_validate(data)
|
||||
|
||||
|
||||
@@ -1307,6 +1342,106 @@ def get_dataset_terrain_tile(
|
||||
return payload.content
|
||||
|
||||
|
||||
def get_file_record_analysis_task_status(
|
||||
db: Session,
|
||||
*,
|
||||
record_id: str,
|
||||
) -> ElevationFileRecordTaskStatusResponse:
|
||||
record = get_file_record_by_id(db, record_id)
|
||||
if not record:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="高程文件记录不存在")
|
||||
return ElevationFileRecordTaskStatusResponse(
|
||||
record_id=record.id,
|
||||
file_name=record.file_name,
|
||||
task_id=record.analysis_task_id,
|
||||
status=_map_dataset_task_status(record.analysis_status), # type: ignore[arg-type]
|
||||
detail=record.analysis_error_message,
|
||||
started_at=record.analysis_started_at,
|
||||
finished_at=record.analysis_finished_at,
|
||||
update_date=record.update_date,
|
||||
)
|
||||
|
||||
|
||||
def get_file_record_terrain_task_status(
|
||||
db: Session,
|
||||
*,
|
||||
record_id: str,
|
||||
) -> ElevationFileRecordTerrainTaskStatusResponse:
|
||||
record = get_file_record_by_id(db, record_id)
|
||||
if not record:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="高程文件记录不存在")
|
||||
return ElevationFileRecordTerrainTaskStatusResponse(
|
||||
record_id=record.id,
|
||||
file_name=record.file_name,
|
||||
task_id=record.terrain_task_id,
|
||||
status=_map_dataset_task_status(record.terrain_status), # type: ignore[arg-type]
|
||||
detail=record.terrain_error_message,
|
||||
terrain_url_template=record.terrain_url_template,
|
||||
terrain_min_zoom=record.terrain_min_zoom,
|
||||
terrain_max_zoom=record.terrain_max_zoom,
|
||||
update_date=record.update_date,
|
||||
)
|
||||
|
||||
|
||||
def get_file_record_terrain_layer(
|
||||
db: Session,
|
||||
*,
|
||||
record_id: str,
|
||||
) -> ElevationTerrainLayerResponse:
|
||||
record = get_file_record_by_id(db, record_id)
|
||||
if not record:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="高程文件记录不存在")
|
||||
if record.terrain_status != "ready" or not record.terrain_root_path:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="地形瓦片尚未就绪")
|
||||
|
||||
mount = _require_mount(db, record.mount_code)
|
||||
driver = _build_driver_or_400(mount)
|
||||
layer_path = _resolve_file_record_terrain_layer_path(record.id)
|
||||
try:
|
||||
payload = driver.read_file(layer_path)
|
||||
except StoragePathNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="地形 layer.json 不存在") from exc
|
||||
except StorageInvalidPathError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
except StorageDriverError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
|
||||
try:
|
||||
data = json.loads(payload.content.decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="地形 layer.json 解析失败") from exc
|
||||
data.setdefault("minzoom", 0)
|
||||
return ElevationTerrainLayerResponse.model_validate(data)
|
||||
|
||||
|
||||
def get_file_record_terrain_tile(
|
||||
db: Session,
|
||||
*,
|
||||
record_id: str,
|
||||
z: int,
|
||||
x: int,
|
||||
y: int,
|
||||
) -> bytes:
|
||||
record = get_file_record_by_id(db, record_id)
|
||||
if not record:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="高程文件记录不存在")
|
||||
if record.terrain_status != "ready" or not record.terrain_root_path:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="地形瓦片尚未就绪")
|
||||
|
||||
mount = _require_mount(db, record.mount_code)
|
||||
driver = _build_driver_or_400(mount)
|
||||
tile_path = _resolve_file_record_terrain_tile_path(record_id=record.id, z=z, x=x, y=y)
|
||||
try:
|
||||
payload = driver.read_file(tile_path)
|
||||
except StoragePathNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="地形瓦片不存在") from exc
|
||||
except StorageInvalidPathError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
except StorageDriverError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
return payload.content
|
||||
|
||||
|
||||
def update_dataset(
|
||||
db: Session,
|
||||
dataset_id: str,
|
||||
@@ -1563,6 +1698,7 @@ def list_jobs(
|
||||
dataset_id: str | None,
|
||||
status_filter: str | None,
|
||||
limit: int,
|
||||
file_record_id: str | None = None,
|
||||
) -> ElevationApplyJobListResponse:
|
||||
stmt = select(ElevationApplyJob)
|
||||
total_stmt = select(func.count()).select_from(ElevationApplyJob)
|
||||
@@ -1573,6 +1709,9 @@ def list_jobs(
|
||||
if dataset_id:
|
||||
stmt = stmt.where(ElevationApplyJob.dataset_id == dataset_id)
|
||||
total_stmt = total_stmt.where(ElevationApplyJob.dataset_id == dataset_id)
|
||||
if file_record_id:
|
||||
stmt = stmt.where(ElevationApplyJob.file_record_id == file_record_id)
|
||||
total_stmt = total_stmt.where(ElevationApplyJob.file_record_id == file_record_id)
|
||||
if status_filter in {"pending", "running", "success", "failed"}:
|
||||
stmt = stmt.where(ElevationApplyJob.status == status_filter)
|
||||
total_stmt = total_stmt.where(ElevationApplyJob.status == status_filter)
|
||||
@@ -1603,11 +1742,22 @@ def create_apply_job(
|
||||
if not line:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="线路不存在")
|
||||
|
||||
dataset = get_dataset_by_id(db, payload.dataset_id)
|
||||
if not dataset:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="高程数据集不存在")
|
||||
if dataset.status != "active":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="高程数据集未启用")
|
||||
dataset: ElevationDataset | None = None
|
||||
file_record: ElevationFileRecord | None = None
|
||||
if payload.file_record_id:
|
||||
file_record = get_file_record_by_id(db, payload.file_record_id)
|
||||
if not file_record:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="高程文件记录不存在")
|
||||
if file_record.status != "active":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="高程文件记录未启用")
|
||||
elif payload.dataset_id:
|
||||
dataset = get_dataset_by_id(db, payload.dataset_id)
|
||||
if not dataset:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="高程数据集不存在")
|
||||
if dataset.status != "active":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="高程数据集未启用")
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供 file_record_id 或 dataset_id")
|
||||
|
||||
allowed_modes = {"fill_null_only", "overwrite_all"}
|
||||
if payload.mode not in allowed_modes:
|
||||
@@ -1627,7 +1777,8 @@ def create_apply_job(
|
||||
now = utcnow()
|
||||
job = ElevationApplyJob(
|
||||
line_id=line.id,
|
||||
dataset_id=dataset.id,
|
||||
dataset_id=dataset.id if dataset is not None else None,
|
||||
file_record_id=file_record.id if file_record is not None else None,
|
||||
mode=payload.mode,
|
||||
status="pending",
|
||||
total_tower_count=total_tower_count,
|
||||
@@ -1652,11 +1803,18 @@ def create_apply_job(
|
||||
if not latest:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="任务派发失败")
|
||||
|
||||
_refresh_dataset_usage_status(db, dataset_id=latest.dataset_id)
|
||||
if latest.dataset_id:
|
||||
_refresh_dataset_usage_status(db, dataset_id=latest.dataset_id)
|
||||
|
||||
_publish_elevation_change(
|
||||
"elevation.job.created",
|
||||
{"action": "job_created", "job_id": latest.id, "line_id": latest.line_id},
|
||||
{
|
||||
"action": "job_created",
|
||||
"job_id": latest.id,
|
||||
"line_id": latest.line_id,
|
||||
"dataset_id": latest.dataset_id,
|
||||
"file_record_id": latest.file_record_id,
|
||||
},
|
||||
)
|
||||
return ElevationApplyJobCreateResponse(job=serialize_job(latest), queued=True)
|
||||
|
||||
@@ -1699,34 +1857,50 @@ def execute_apply_job(job_id: str, actor_user_id: str | None = None) -> None:
|
||||
job.started_at = utcnow()
|
||||
job.update_date = utcnow()
|
||||
db.commit()
|
||||
_refresh_dataset_usage_status(db, dataset_id=job.dataset_id)
|
||||
if job.dataset_id:
|
||||
_refresh_dataset_usage_status(db, dataset_id=job.dataset_id)
|
||||
_publish_elevation_change(
|
||||
"elevation.job.running",
|
||||
{"action": "job_running", "job_id": job.id, "line_id": job.line_id},
|
||||
{
|
||||
"action": "job_running",
|
||||
"job_id": job.id,
|
||||
"line_id": job.line_id,
|
||||
"dataset_id": job.dataset_id,
|
||||
"file_record_id": job.file_record_id,
|
||||
},
|
||||
)
|
||||
|
||||
line = db.execute(select(Line).where(Line.id == job.line_id)).scalar_one_or_none()
|
||||
dataset = get_dataset_by_id(db, job.dataset_id)
|
||||
if not line or not dataset:
|
||||
dataset = get_dataset_by_id(db, job.dataset_id) if job.dataset_id else None
|
||||
file_record = get_file_record_by_id(db, job.file_record_id) if job.file_record_id else None
|
||||
elevation_source = file_record or dataset
|
||||
if not line or elevation_source is None:
|
||||
job.status = "failed"
|
||||
job.error_message = "线路或高程数据集不存在"
|
||||
job.error_message = "线路或高程文件记录不存在"
|
||||
job.finished_at = utcnow()
|
||||
job.update_date = utcnow()
|
||||
db.commit()
|
||||
_refresh_dataset_usage_status(db, dataset_id=job.dataset_id)
|
||||
if job.dataset_id:
|
||||
_refresh_dataset_usage_status(db, dataset_id=job.dataset_id)
|
||||
_publish_elevation_change(
|
||||
"elevation.job.failed",
|
||||
{"action": "job_failed", "job_id": job.id, "line_id": job.line_id},
|
||||
{
|
||||
"action": "job_failed",
|
||||
"job_id": job.id,
|
||||
"line_id": job.line_id,
|
||||
"dataset_id": job.dataset_id,
|
||||
"file_record_id": job.file_record_id,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
file_format = _resolve_dataset_file_format(dataset)
|
||||
file_format = _resolve_elevation_file_format(elevation_source)
|
||||
if file_format == "csv":
|
||||
points, warnings = _load_dataset_points(db, dataset)
|
||||
points, warnings = _load_dataset_points(db, elevation_source)
|
||||
stats = _apply_points_to_line_towers(
|
||||
db,
|
||||
line_id=line.id,
|
||||
dataset=dataset,
|
||||
elevation_source=elevation_source,
|
||||
mode=job.mode,
|
||||
points=points,
|
||||
)
|
||||
@@ -1734,7 +1908,7 @@ def execute_apply_job(job_id: str, actor_user_id: str | None = None) -> None:
|
||||
stats, warnings = _apply_raster_to_line_towers(
|
||||
db,
|
||||
line_id=line.id,
|
||||
dataset=dataset,
|
||||
elevation_source=elevation_source,
|
||||
mode=job.mode,
|
||||
)
|
||||
else:
|
||||
@@ -1743,7 +1917,8 @@ def execute_apply_job(job_id: str, actor_user_id: str | None = None) -> None:
|
||||
detail=f"不支持的高程文件格式: {file_format}",
|
||||
)
|
||||
|
||||
_refresh_dataset_usage_status(db, dataset_id=dataset.id)
|
||||
if dataset is not None:
|
||||
_refresh_dataset_usage_status(db, dataset_id=dataset.id)
|
||||
|
||||
warning_note = "; ".join(warnings[:5]) if warnings else None
|
||||
job.updated_tower_count = stats["updated_tower_count"]
|
||||
@@ -1762,8 +1937,10 @@ def execute_apply_job(job_id: str, actor_user_id: str | None = None) -> None:
|
||||
payload={
|
||||
"prepared_at": utcnow().isoformat(),
|
||||
"prepared_by_user_id": resolved_actor_user_id,
|
||||
"dataset_id": dataset.id,
|
||||
"dataset_code": dataset.code,
|
||||
"dataset_id": dataset.id if dataset is not None else None,
|
||||
"dataset_code": dataset.code if dataset is not None else None,
|
||||
"file_record_id": file_record.id if file_record is not None else None,
|
||||
"file_record_name": file_record.file_name if file_record is not None else None,
|
||||
"job_id": job.id,
|
||||
"mode": job.mode,
|
||||
"updated_tower_count": job.updated_tower_count,
|
||||
@@ -1772,7 +1949,8 @@ def execute_apply_job(job_id: str, actor_user_id: str | None = None) -> None:
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
_refresh_dataset_usage_status(db, dataset_id=job.dataset_id)
|
||||
if job.dataset_id:
|
||||
_refresh_dataset_usage_status(db, dataset_id=job.dataset_id)
|
||||
|
||||
_publish_elevation_change(
|
||||
"elevation.job.success",
|
||||
@@ -1780,6 +1958,8 @@ def execute_apply_job(job_id: str, actor_user_id: str | None = None) -> None:
|
||||
"action": "job_success",
|
||||
"job_id": job.id,
|
||||
"line_id": line.id,
|
||||
"dataset_id": job.dataset_id,
|
||||
"file_record_id": job.file_record_id,
|
||||
"updated_tower_count": job.updated_tower_count,
|
||||
"skipped_tower_count": job.skipped_tower_count,
|
||||
},
|
||||
@@ -1797,10 +1977,17 @@ def execute_apply_job(job_id: str, actor_user_id: str | None = None) -> None:
|
||||
failed.finished_at = utcnow()
|
||||
failed.update_date = utcnow()
|
||||
db.commit()
|
||||
_refresh_dataset_usage_status(db, dataset_id=failed.dataset_id)
|
||||
if failed.dataset_id:
|
||||
_refresh_dataset_usage_status(db, dataset_id=failed.dataset_id)
|
||||
_publish_elevation_change(
|
||||
"elevation.job.failed",
|
||||
{"action": "job_failed", "job_id": failed.id, "line_id": failed.line_id},
|
||||
{
|
||||
"action": "job_failed",
|
||||
"job_id": failed.id,
|
||||
"line_id": failed.line_id,
|
||||
"dataset_id": failed.dataset_id,
|
||||
"file_record_id": failed.file_record_id,
|
||||
},
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
@@ -2120,6 +2307,35 @@ def _queue_dataset_terrain_build_after_analysis(db: Session, *, dataset: Elevati
|
||||
db.commit()
|
||||
|
||||
|
||||
def _queue_file_record_terrain_build_after_analysis(db: Session, *, record: ElevationFileRecord, actor_user_id: str | None) -> None:
|
||||
if not _supports_file_record_terrain_build(record):
|
||||
return
|
||||
if record.terrain_status in {"processing", "ready"}:
|
||||
return
|
||||
if record.terrain_status == "pending" and record.terrain_task_id:
|
||||
return
|
||||
record.terrain_status = "pending"
|
||||
record.terrain_error_message = None
|
||||
record.update_date = utcnow()
|
||||
db.commit()
|
||||
try:
|
||||
from ..tasks.elevation_tasks import build_elevation_file_record_terrain_job
|
||||
|
||||
task = build_elevation_file_record_terrain_job.delay(record.id, actor_user_id)
|
||||
record.terrain_task_id = str(task.id)
|
||||
record.update_date = utcnow()
|
||||
db.commit()
|
||||
_publish_elevation_change(
|
||||
"elevation.file_record.terrain.queued",
|
||||
{"action": "file_record_terrain_queued", "file_record_id": record.id, "task_id": record.terrain_task_id},
|
||||
)
|
||||
except Exception as exc:
|
||||
record.terrain_status = "failed"
|
||||
record.terrain_error_message = str(exc)
|
||||
record.update_date = utcnow()
|
||||
db.commit()
|
||||
|
||||
|
||||
def _ensure_dataset_file_exists(db: Session, *, mount_code: str, file_path: str) -> None:
|
||||
mount = _require_mount(db, mount_code.strip())
|
||||
driver = _build_driver_or_400(mount)
|
||||
@@ -2342,7 +2558,7 @@ def _refresh_dataset_usage_status(db: Session, *, dataset_id: str) -> None:
|
||||
|
||||
def _load_dataset_points(
|
||||
db: Session,
|
||||
dataset: ElevationDataset,
|
||||
dataset: ElevationDataset | ElevationFileRecord,
|
||||
) -> tuple[list[ElevationSamplePoint], list[str]]:
|
||||
mount = _require_mount(db, dataset.mount_code)
|
||||
driver = _build_driver_or_400(mount)
|
||||
@@ -2461,7 +2677,7 @@ def _apply_points_to_line_towers(
|
||||
db: Session,
|
||||
*,
|
||||
line_id: str,
|
||||
dataset: ElevationDataset,
|
||||
elevation_source: ElevationDataset | ElevationFileRecord,
|
||||
mode: str,
|
||||
points: list[ElevationSamplePoint],
|
||||
) -> dict[str, int]:
|
||||
@@ -2521,8 +2737,7 @@ def _apply_points_to_line_towers(
|
||||
|
||||
raw_extra = dict(tower.raw_extra_json or {})
|
||||
raw_extra["elevation"] = {
|
||||
"dataset_id": dataset.id,
|
||||
"dataset_code": dataset.code,
|
||||
**_elevation_source_metadata(elevation_source),
|
||||
"sample_method": "nearest",
|
||||
"sample_distance_m": round(distance_m, 3),
|
||||
"sample_distance_source": "computed",
|
||||
@@ -2755,6 +2970,39 @@ def _resolve_dataset_file_format(dataset: ElevationDataset) -> str:
|
||||
return detected
|
||||
|
||||
|
||||
def _resolve_file_record_format(record: ElevationFileRecord) -> str:
|
||||
declared = (record.file_format or "").strip().lower()
|
||||
detected = _detect_file_format(record.file_path)
|
||||
if declared and declared in ELEVATION_FILE_EXT_FORMAT_MAP.values():
|
||||
if declared == detected:
|
||||
return declared
|
||||
if declared in RASTER_FILE_FORMATS and detected in RASTER_FILE_FORMATS:
|
||||
return detected
|
||||
return detected
|
||||
|
||||
|
||||
def _resolve_elevation_file_format(elevation_source: ElevationDataset | ElevationFileRecord) -> str:
|
||||
if isinstance(elevation_source, ElevationFileRecord):
|
||||
return _resolve_file_record_format(elevation_source)
|
||||
return _resolve_dataset_file_format(elevation_source)
|
||||
|
||||
|
||||
def _elevation_source_metadata(elevation_source: Any) -> dict[str, str | None]:
|
||||
if isinstance(elevation_source, ElevationFileRecord) or hasattr(elevation_source, "file_name"):
|
||||
return {
|
||||
"dataset_id": None,
|
||||
"dataset_code": None,
|
||||
"file_record_id": getattr(elevation_source, "id", None),
|
||||
"file_record_name": getattr(elevation_source, "file_name", None),
|
||||
}
|
||||
return {
|
||||
"dataset_id": getattr(elevation_source, "id", None),
|
||||
"dataset_code": getattr(elevation_source, "code", None),
|
||||
"file_record_id": None,
|
||||
"file_record_name": None,
|
||||
}
|
||||
|
||||
|
||||
def _require_rasterio_available() -> Any:
|
||||
try:
|
||||
import rasterio
|
||||
@@ -2772,9 +3020,9 @@ def _require_rasterio_available() -> Any:
|
||||
|
||||
def _open_raster_dataset(
|
||||
db: Session,
|
||||
dataset: ElevationDataset,
|
||||
dataset: ElevationDataset | ElevationFileRecord,
|
||||
) -> _OpenedRasterDataset:
|
||||
file_format = _resolve_dataset_file_format(dataset)
|
||||
file_format = _resolve_elevation_file_format(dataset)
|
||||
if file_format not in RASTER_FILE_FORMATS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -3164,6 +3412,84 @@ def _build_dataset_terrain_tiles(db: Session, dataset: ElevationDataset) -> _Ter
|
||||
)
|
||||
|
||||
|
||||
def _build_file_record_terrain_tiles(db: Session, record: ElevationFileRecord) -> _TerrainBuildArtifacts:
|
||||
mount = _require_mount(db, record.mount_code)
|
||||
driver = _build_driver_or_400(mount)
|
||||
terrain_dir = _resolve_file_record_terrain_dir(record.id)
|
||||
|
||||
_delete_virtual_directory_if_exists(driver, terrain_dir)
|
||||
_ensure_virtual_directory(driver, terrain_dir)
|
||||
|
||||
with _open_raster_dataset(db, record) as opened:
|
||||
rasterio = opened.rasterio
|
||||
src = opened.dataset
|
||||
bounds = _compute_raster_wgs84_bounds(rasterio=rasterio, src=src)
|
||||
min_zoom, max_zoom = _resolve_terrain_zoom_limits(bounds=bounds, resolution_m=record.resolution_m)
|
||||
availability = _build_terrain_available_ranges(bounds=bounds, max_zoom=max_zoom)
|
||||
|
||||
tile_count = 0
|
||||
for level in range(min_zoom, max_zoom + 1):
|
||||
ranges = availability.get(level, [])
|
||||
for tile_range in ranges:
|
||||
for tile_x in range(tile_range["startX"], tile_range["endX"] + 1):
|
||||
for tile_y in range(tile_range["startY"], tile_range["endY"] + 1):
|
||||
tile_count += 1
|
||||
tile_bounds = _tile_bounds_from_tms(level, tile_x, tile_y)
|
||||
child_mask = _build_terrain_child_mask(
|
||||
availability=availability,
|
||||
level=level,
|
||||
x=tile_x,
|
||||
y=tile_y,
|
||||
max_zoom=max_zoom,
|
||||
)
|
||||
tile_bytes = _generate_heightmap_tile_bytes(
|
||||
rasterio=rasterio,
|
||||
src=src,
|
||||
tile_bounds=tile_bounds,
|
||||
child_mask=child_mask,
|
||||
is_blank_root=level == 0 and not _tile_intersects_bounds(tile_bounds, bounds),
|
||||
)
|
||||
tile_path = _resolve_file_record_terrain_tile_path(record_id=record.id, z=level, x=tile_x, y=tile_y)
|
||||
_write_virtual_file(driver, path=tile_path, content=tile_bytes, content_type=TERRAIN_CONTENT_TYPE)
|
||||
|
||||
layer_url = f"/api/v1/elevation/records/{record.id}/terrain/layer.json"
|
||||
terrain_url_template = _build_file_record_terrain_url_template(record.id)
|
||||
layer_payload = ElevationTerrainLayerResponse(
|
||||
tiles=[f"{{z}}/{{x}}/{{y}}.terrain?v={TERRAIN_TILE_VERSION}"],
|
||||
minzoom=0,
|
||||
maxzoom=max_zoom,
|
||||
attribution=record.file_name,
|
||||
bounds=[bounds["west"], bounds["south"], bounds["east"], bounds["north"]],
|
||||
available=[availability.get(level, []) for level in range(0, max_zoom + 1)],
|
||||
).model_dump(mode="json")
|
||||
_write_virtual_file(
|
||||
driver,
|
||||
path=_resolve_file_record_terrain_layer_path(record.id),
|
||||
content=json.dumps(layer_payload, ensure_ascii=True, separators=(",", ":")).encode("utf-8"),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
metadata = {
|
||||
"tool": TERRAIN_BUILD_TOOL,
|
||||
"format": TERRAIN_TILE_FORMAT,
|
||||
"projection": TERRAIN_TILE_PROJECTION,
|
||||
"tile_size": TERRAIN_TILE_SIZE,
|
||||
"generated_at": utcnow().isoformat(),
|
||||
"source_crs": str(src.crs) if src.crs is not None else None,
|
||||
"source_nodata": src.nodatavals[0] if src.nodatavals else None,
|
||||
"resolution_m": record.resolution_m,
|
||||
"tile_count": tile_count,
|
||||
"layer_url": layer_url,
|
||||
"terrain_url_template": terrain_url_template,
|
||||
}
|
||||
return _TerrainBuildArtifacts(
|
||||
min_zoom=min_zoom,
|
||||
max_zoom=max_zoom,
|
||||
bounds=bounds,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
def _generate_heightmap_tile_bytes(
|
||||
*,
|
||||
rasterio: Any,
|
||||
@@ -3414,7 +3740,7 @@ def _apply_raster_to_line_towers(
|
||||
db: Session,
|
||||
*,
|
||||
line_id: str,
|
||||
dataset: ElevationDataset,
|
||||
elevation_source: ElevationDataset | ElevationFileRecord,
|
||||
mode: str,
|
||||
) -> tuple[dict[str, int], list[str]]:
|
||||
towers = db.execute(
|
||||
@@ -3429,7 +3755,7 @@ def _apply_raster_to_line_towers(
|
||||
unmatched_count = 0
|
||||
warnings: list[str] = []
|
||||
|
||||
with _open_raster_dataset(db, dataset) as opened:
|
||||
with _open_raster_dataset(db, elevation_source) as opened:
|
||||
rasterio = opened.rasterio
|
||||
src = opened.dataset
|
||||
warning_text = _append_non_wgs84_bounds_warning(rasterio=rasterio, src=src)
|
||||
@@ -3476,8 +3802,7 @@ def _apply_raster_to_line_towers(
|
||||
|
||||
raw_extra = dict(tower.raw_extra_json or {})
|
||||
raw_extra["elevation"] = {
|
||||
"dataset_id": dataset.id,
|
||||
"dataset_code": dataset.code,
|
||||
**_elevation_source_metadata(elevation_source),
|
||||
"sample_method": "raster_pixel",
|
||||
"sample_distance_m": 0.0,
|
||||
"sample_distance_source": "pixel_lookup",
|
||||
@@ -3558,10 +3883,15 @@ def _publish_elevation_change(event_name: str, payload: dict[str, Any]) -> None:
|
||||
ELEVATION_TOPIC,
|
||||
name=event_name,
|
||||
payload=payload,
|
||||
requires_refetch=["/api/v1/elevation/datasets", "/api/v1/elevation/jobs", "/api/v1/elevation/import-jobs"],
|
||||
requires_refetch=[
|
||||
"/api/v1/elevation/records",
|
||||
"/api/v1/elevation/datasets",
|
||||
"/api/v1/elevation/jobs",
|
||||
"/api/v1/elevation/import-jobs",
|
||||
],
|
||||
dedupe_key=(
|
||||
f"{event_name}:"
|
||||
f"{payload.get('import_job_id') or payload.get('job_id') or payload.get('dataset_id') or 'unknown'}"
|
||||
f"{payload.get('import_job_id') or payload.get('job_id') or payload.get('file_record_id') or payload.get('dataset_id') or 'unknown'}"
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -3841,6 +4171,7 @@ def execute_file_record_analysis_job(*, record_id: str, actor_user_id: str | Non
|
||||
"elevation.file_record.analysis.success",
|
||||
{"action": "file_record_analysis_success", "file_record_id": saved.id},
|
||||
)
|
||||
_queue_file_record_terrain_build_after_analysis(db, record=saved, actor_user_id=actor.id)
|
||||
except Exception as exc:
|
||||
from .elevation_file_record_service import get_file_record_by_id
|
||||
failed = get_file_record_by_id(db, record_id)
|
||||
@@ -3899,75 +4230,19 @@ def execute_file_record_terrain_build_job(*, record_id: str, actor_user_id: str
|
||||
)
|
||||
return
|
||||
|
||||
# Build terrain tiles
|
||||
mount = _require_mount(db, item.mount_code)
|
||||
driver = _build_driver_or_400(mount)
|
||||
|
||||
# Create terrain output directory
|
||||
terrain_dir = f"/elevation/terrain/records/{item.id[:2]}/{item.id[2:4]}/{item.id}"
|
||||
driver.ensure_directory(terrain_dir)
|
||||
|
||||
# Read source file
|
||||
try:
|
||||
read_result = driver.read_file(item.file_path)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"文件不存在: {item.file_path}"
|
||||
) from exc
|
||||
|
||||
# Process with ctb-tile (similar to dataset terrain build)
|
||||
_require_rasterio_available()
|
||||
import tempfile
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=Path(item.file_path).suffix) as src_tmp:
|
||||
src_tmp.write(read_result.content)
|
||||
src_path = src_tmp.name
|
||||
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as output_tmp:
|
||||
# Run ctb-tile
|
||||
cmd = ["ctb-tile", "-f", "Mesh", "-C", "-N", "-o", output_tmp, src_path]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=3600)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise Exception(f"ctb-tile failed: {result.stderr}")
|
||||
|
||||
# Upload generated tiles to storage
|
||||
for root, dirs, files in os.walk(output_tmp):
|
||||
for file in files:
|
||||
local_path = os.path.join(root, file)
|
||||
rel_path = os.path.relpath(local_path, output_tmp)
|
||||
remote_path = join_virtual_path(terrain_dir, rel_path)
|
||||
|
||||
with open(local_path, "rb") as f:
|
||||
content = f.read()
|
||||
|
||||
driver.write_file(remote_path, content=content, content_type="application/octet-stream")
|
||||
|
||||
# Read layer.json if exists
|
||||
layer_json_path = os.path.join(output_tmp, "layer.json")
|
||||
if os.path.exists(layer_json_path):
|
||||
with open(layer_json_path, "r") as f:
|
||||
import json
|
||||
layer_data = json.load(f)
|
||||
item.terrain_min_zoom = layer_data.get("minzoom", 0)
|
||||
item.terrain_max_zoom = layer_data.get("maxzoom", 18)
|
||||
item.terrain_bounds = {"bounds": layer_data.get("bounds")}
|
||||
item.terrain_metadata = layer_data
|
||||
|
||||
finally:
|
||||
os.unlink(src_path)
|
||||
artifacts = _build_file_record_terrain_tiles(db, item)
|
||||
|
||||
saved = get_file_record_by_id(db, record_id)
|
||||
if saved is None:
|
||||
return
|
||||
saved.terrain_status = "ready"
|
||||
saved.terrain_error_message = None
|
||||
saved.terrain_root_path = terrain_dir
|
||||
saved.terrain_url_template = f"/api/v1/elevation/records/{record_id}/terrain/{{z}}/{{x}}/{{y}}.terrain"
|
||||
saved.terrain_root_path = _resolve_file_record_terrain_dir(saved.id)
|
||||
saved.terrain_url_template = _build_file_record_terrain_url_template(saved.id)
|
||||
saved.terrain_min_zoom = artifacts.min_zoom
|
||||
saved.terrain_max_zoom = artifacts.max_zoom
|
||||
saved.terrain_bounds = artifacts.bounds
|
||||
saved.terrain_metadata = artifacts.metadata
|
||||
saved.update_date = utcnow()
|
||||
saved.update_user = actor.id
|
||||
db.commit()
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.atp_model import AtpModel, AtpModelVersion, AtpSimulationRun
|
||||
from app.models.elevation import ElevationDataImportJob, ElevationDataset
|
||||
from app.models.elevation import ElevationDataImportJob, ElevationDataset, ElevationFileRecord
|
||||
from app.models.user import User
|
||||
from app.models.wine import WineRun
|
||||
from app.schemas.atp_model import AtpSimulationRunRequest
|
||||
@@ -233,6 +233,53 @@ def test_queue_dataset_terrain_build_reuses_existing_running_task(monkeypatch) -
|
||||
session.close()
|
||||
|
||||
|
||||
def test_file_record_terrain_layer_and_tile_read_from_record_storage(monkeypatch) -> None:
|
||||
testing_session = _build_sessionmaker(ElevationFileRecord.__table__)
|
||||
session: Session = testing_session()
|
||||
try:
|
||||
record = ElevationFileRecord(
|
||||
id="abcdef1234567890abcdef1234567890",
|
||||
file_name="terrain.tif",
|
||||
file_path="/elevation/records/ab/cd/terrain.tif",
|
||||
file_format="tif",
|
||||
file_size=128,
|
||||
mount_code="default",
|
||||
status="active",
|
||||
terrain_status="ready",
|
||||
terrain_root_path="/elevation/terrain/records/ab/cd/abcdef1234567890abcdef1234567890",
|
||||
terrain_url_template="/api/v1/elevation/records/abcdef1234567890abcdef1234567890/terrain/{z}/{x}/{y}.terrain?v=1.0.0",
|
||||
terrain_min_zoom=0,
|
||||
terrain_max_zoom=0,
|
||||
)
|
||||
session.add(record)
|
||||
session.commit()
|
||||
|
||||
driver = _MemoryStorageDriver()
|
||||
layer_payload = b'{"tilejson":"2.1.0","format":"heightmap-1.0","version":"1.0.0","scheme":"tms","projection":"EPSG:4326","tiles":["{z}/{x}/{y}.terrain?v=1.0.0"],"minzoom":0,"maxzoom":0}'
|
||||
driver.write_file(
|
||||
"/elevation/terrain/records/ab/cd/abcdef1234567890abcdef1234567890/layer.json",
|
||||
content=layer_payload,
|
||||
content_type="application/json",
|
||||
)
|
||||
driver.write_file(
|
||||
"/elevation/terrain/records/ab/cd/abcdef1234567890abcdef1234567890/0/0/0.terrain",
|
||||
content=b"tile-bytes",
|
||||
content_type="application/octet-stream",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(elevation_service, "_require_mount", lambda *_args, **_kwargs: SimpleNamespace(code="default"))
|
||||
monkeypatch.setattr(elevation_service, "_build_driver_or_400", lambda *_args, **_kwargs: driver)
|
||||
|
||||
layer = elevation_service.get_file_record_terrain_layer(session, record_id=record.id)
|
||||
tile = elevation_service.get_file_record_terrain_tile(session, record_id=record.id, z=0, x=0, y=0)
|
||||
|
||||
assert layer.maxzoom == 0
|
||||
assert layer.tiles == ["{z}/{x}/{y}.terrain?v=1.0.0"]
|
||||
assert tile == b"tile-bytes"
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_import_dataset_data_files_queue_job_and_worker_keeps_preferred_raster(monkeypatch) -> None:
|
||||
testing_session = _build_sessionmaker(ElevationDataset.__table__, ElevationDataImportJob.__table__)
|
||||
session: Session = testing_session()
|
||||
@@ -296,7 +343,10 @@ def test_import_dataset_data_files_queue_job_and_worker_keeps_preferred_raster(m
|
||||
assert first.job.uploaded_file_count == 1
|
||||
assert first.job.analysis_task_queued is False
|
||||
assert import_calls == [(first.job.id, actor.id)]
|
||||
assert any(path.endswith(".img") and "/.imports/" in path for path in driver.files)
|
||||
saved_pending_job = session.get(ElevationDataImportJob, first.job.id)
|
||||
assert saved_pending_job is not None
|
||||
assert saved_pending_job.staged_files_json[0]["filename"] == "terrain.img"
|
||||
assert "content_base64" in saved_pending_job.staged_files_json[0]
|
||||
|
||||
second = elevation_service.import_dataset_data_files(
|
||||
session,
|
||||
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.elevation import ElevationApplyJob, ElevationDataset
|
||||
from app.models.elevation import ElevationApplyJob, ElevationDataset, ElevationFileRecord
|
||||
from app.models.line import Line
|
||||
from app.models.line_tower import LineTower
|
||||
from app.models.tower_profile import TowerProfile
|
||||
@@ -28,6 +28,7 @@ def _build_session_factory() -> sessionmaker[Session]:
|
||||
LineTower.__table__,
|
||||
TowerProfile.__table__,
|
||||
ElevationDataset.__table__,
|
||||
ElevationFileRecord.__table__,
|
||||
ElevationApplyJob.__table__,
|
||||
],
|
||||
)
|
||||
@@ -81,6 +82,53 @@ def test_create_apply_job_dispatches_actor_user_id(monkeypatch) -> None:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_create_apply_job_accepts_file_record_id(monkeypatch) -> None:
|
||||
testing_session = _build_session_factory()
|
||||
session = testing_session()
|
||||
try:
|
||||
line = Line(code="L-APPLY-003", name="文件记录回填线路", voltage_kv=220, lightning_param_json={})
|
||||
record = ElevationFileRecord(
|
||||
file_name="record.csv",
|
||||
file_path="/elevation/records/ab/cd/record.csv",
|
||||
file_format="csv",
|
||||
file_size=32,
|
||||
mount_code="default",
|
||||
status="active",
|
||||
)
|
||||
session.add_all([line, record])
|
||||
session.flush()
|
||||
session.add(LineTower(line_id=line.id, seq_no=1, tower_no="T1", longitude=120.0, latitude=30.0))
|
||||
session.commit()
|
||||
|
||||
dispatched: dict[str, str | None] = {}
|
||||
|
||||
def _fake_dispatch(*, job_id: str, actor_user_id: str | None) -> SimpleNamespace:
|
||||
dispatched["job_id"] = job_id
|
||||
dispatched["actor_user_id"] = actor_user_id
|
||||
return SimpleNamespace(id="celery-task-file-record")
|
||||
|
||||
monkeypatch.setattr(elevation_service, "_dispatch_elevation_apply_task", _fake_dispatch)
|
||||
monkeypatch.setattr(elevation_service, "_publish_elevation_change", lambda *args, **kwargs: None)
|
||||
|
||||
response = elevation_service.create_apply_job(
|
||||
session,
|
||||
ElevationApplyJobCreateRequest(line_id=line.id, file_record_id=record.id, mode="overwrite_all"),
|
||||
actor=SimpleNamespace(id="tester"),
|
||||
)
|
||||
|
||||
saved_job = session.get(ElevationApplyJob, response.job.id)
|
||||
assert response.queued is True
|
||||
assert dispatched == {"job_id": response.job.id, "actor_user_id": "tester"}
|
||||
assert saved_job is not None
|
||||
assert saved_job.file_record_id == record.id
|
||||
assert saved_job.dataset_id is None
|
||||
assert saved_job.task_id == "celery-task-file-record"
|
||||
assert response.job.file_record_id == record.id
|
||||
assert response.job.file_record_name == "record.csv"
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_execute_apply_job_uses_saved_actor_for_preparation_source(monkeypatch) -> None:
|
||||
testing_session = _build_session_factory()
|
||||
session = testing_session()
|
||||
@@ -160,3 +208,83 @@ def test_execute_apply_job_uses_saved_actor_for_preparation_source(monkeypatch)
|
||||
verification_session.close()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_execute_apply_job_uses_file_record_source(monkeypatch) -> None:
|
||||
testing_session = _build_session_factory()
|
||||
session = testing_session()
|
||||
try:
|
||||
line = Line(code="L-APPLY-004", name="文件记录高程回填线路", voltage_kv=110, lightning_param_json={})
|
||||
record = ElevationFileRecord(
|
||||
file_name="record.csv",
|
||||
file_path="/elevation/records/ab/cd/record.csv",
|
||||
file_format="csv",
|
||||
file_size=32,
|
||||
mount_code="default",
|
||||
status="active",
|
||||
)
|
||||
session.add_all([line, record])
|
||||
session.flush()
|
||||
|
||||
meter_to_lat = 1 / 111_320.0
|
||||
session.add_all(
|
||||
[
|
||||
LineTower(line_id=line.id, seq_no=1, tower_no="P1", longitude=120.0, latitude=30.0 + 300 * meter_to_lat),
|
||||
LineTower(line_id=line.id, seq_no=2, tower_no="P2", longitude=120.0, latitude=30.0 + 600 * meter_to_lat),
|
||||
LineTower(line_id=line.id, seq_no=3, tower_no="P3", longitude=120.0, latitude=30.0 + 900 * meter_to_lat),
|
||||
]
|
||||
)
|
||||
session.flush()
|
||||
|
||||
job = ElevationApplyJob(
|
||||
line_id=line.id,
|
||||
file_record_id=record.id,
|
||||
mode="overwrite_all",
|
||||
status="pending",
|
||||
total_tower_count=3,
|
||||
create_user="tester",
|
||||
update_user="tester",
|
||||
)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
|
||||
points = [
|
||||
elevation_service.ElevationSamplePoint(
|
||||
lon=120.0,
|
||||
lat=30.0 + distance_m * meter_to_lat,
|
||||
altitude_m=100.0 + distance_m * 0.12,
|
||||
)
|
||||
for distance_m in range(0, 1251, 50)
|
||||
]
|
||||
|
||||
monkeypatch.setattr(elevation_service, "SessionLocal", testing_session)
|
||||
monkeypatch.setattr(elevation_service, "_load_dataset_points", lambda *_args, **_kwargs: (points, []))
|
||||
monkeypatch.setattr(elevation_service, "_publish_elevation_change", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(elevation_service, "_publish_line_change", lambda *args, **kwargs: None)
|
||||
|
||||
elevation_service.execute_apply_job(job.id)
|
||||
|
||||
verification_session = testing_session()
|
||||
try:
|
||||
saved_job = verification_session.get(ElevationApplyJob, job.id)
|
||||
saved_line = verification_session.get(Line, line.id)
|
||||
towers = verification_session.execute(
|
||||
select(LineTower).where(LineTower.line_id == line.id).order_by(LineTower.seq_no.asc())
|
||||
).scalars().all()
|
||||
|
||||
assert saved_job is not None
|
||||
assert saved_job.status == "success"
|
||||
assert saved_line is not None
|
||||
assert saved_line.update_user == "tester"
|
||||
assert all(tower.altitude_m is not None for tower in towers)
|
||||
|
||||
source = saved_line.lightning_param_json["preparation_sources"]["ground_slope"]
|
||||
assert source["prepared_by_user_id"] == "tester"
|
||||
assert source["file_record_id"] == record.id
|
||||
assert source["file_record_name"] == "record.csv"
|
||||
assert source["dataset_id"] is None
|
||||
assert source["job_id"] == job.id
|
||||
finally:
|
||||
verification_session.close()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@@ -158,7 +158,7 @@ def test_apply_points_to_line_towers_computes_ground_slopes() -> None:
|
||||
stats = elevation_service._apply_points_to_line_towers(
|
||||
session,
|
||||
line_id=line.id,
|
||||
dataset=SimpleNamespace(id="ds-1", code="DEM-001"),
|
||||
elevation_source=SimpleNamespace(id="ds-1", code="DEM-001"),
|
||||
mode="overwrite_all",
|
||||
points=points,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user