Files
KSP_project/app/routes/web.py
T
ArmorandClaude Opus 4.7 ac5141fd02 feat: docking log management, sim_time DB-only, modal helpers
- Add docking_log_list and docking_log_delete routes with dedicated page
- _append_docking_log: detect existing state interval before creating new entry
- _append_undocking_log: find entry by node title+at_time, accept docked_at param
- Fix _entry_state_label and _entry_location to return time-correct values
- Add api_set_sim_time POST endpoint; remove sim_time from all url_for() calls
- _resolve_simulation_time simplified to DB-only (no URL param)
- app.js: add openModal/closeModal/toggleTheme helpers
- base.html: add 对接日志管理 nav item, remove Today button

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 16:19:32 +08:00

4943 lines
190 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.
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation
from difflib import SequenceMatcher
from math import ceil
from pathlib import Path
import re
from typing import Any
import uuid
from flask import Blueprint, abort, flash, has_request_context, redirect, render_template, request, session, url_for
from sqlalchemy import select, text, distinct
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import selectinload
from app.extensions import db
from app.models import Asset, AssetLogEntry, AssetStateNode, CommunicationPart, DockingEvent, EngineFamily, EngineVariant, TankSpec, VehicleCost
from app.services.fuel_conversion import list_fuel_factors
from app.services.log_book_importer import import_log_book_data
web_bp = Blueprint("web", __name__)
DEFAULT_PAGE_SIZE = 30
CUSTOM_OPTION_VALUE = "__custom__"
LOG_BOOK_PATH = Path(__file__).resolve().parents[2] / "log_book.xlsx"
SIMULATION_TIME_SESSION_KEY = "simulation_time"
ENGINE_SORT_OPTIONS = [
{"value": "engine_name_asc", "label": "名称 A-Z"},
{"value": "engine_name_desc", "label": "名称 Z-A"},
{"value": "mass_t_asc", "label": "重量从低到高"},
{"value": "mass_t_desc", "label": "重量从高到低"},
{"value": "vac_thrust_asc", "label": "真空推力从低到高"},
{"value": "vac_thrust_desc", "label": "真空推力从高到低"},
{"value": "sl_thrust_asc", "label": "海平面推力从低到高"},
{"value": "sl_thrust_desc", "label": "海平面推力从高到低"},
{"value": "vac_isp_asc", "label": "真空比冲从低到高"},
{"value": "vac_isp_desc", "label": "真空比冲从高到低"},
{"value": "sl_isp_asc", "label": "海平面比冲从低到高"},
{"value": "sl_isp_desc", "label": "海平面比冲从高到低"},
]
ENGINE_SORT_COLUMNS = [
{"key": "engine_name", "label": "Engine"},
{"key": "mass_t", "label": "Mass"},
{"key": "sl_thrust", "label": "SL Thrust"},
{"key": "vac_thrust", "label": "Vac Thrust"},
{"key": "sl_isp", "label": "SL ISP"},
{"key": "vac_isp", "label": "Vac ISP"},
]
COMM_SORT_OPTIONS = [
{"value": "part_name", "label": "名称 A-Z"},
{"value": "range_desc", "label": "通信距离从高到低"},
{"value": "mass_desc", "label": "质量从高到低"},
]
TANK_SORT_OPTIONS = [
{"value": "tank_name", "label": "名称 A-Z"},
{"value": "volume_desc", "label": "容积从高到低"},
{"value": "ratio_desc", "label": "质量比从高到低"},
]
VEHICLE_SORT_OPTIONS = [
{"value": "vehicle_name", "label": "名称 A-Z"},
{"value": "launch_price_desc", "label": "发射成本从高到低"},
{"value": "launch_price_asc", "label": "发射成本从低到高"},
]
ASSET_SORT_OPTIONS = [
{"value": "name_asc", "label": "名称 A-Z"},
{"value": "asset_type_asc", "label": "类型 A-Z"},
{"value": "updated_desc", "label": "最近更新优先"},
]
MISSION_PREVIEW_TIME = "2060-03-12 09:00"
MISSION_PREVIEW_METRICS = [
{"label": "Tracked Assets", "value": "14"},
{"label": "Active Missions", "value": "6"},
{"label": "Upcoming Events (30d)", "value": "9"},
]
MISSION_PREVIEW_FILTERS = {
"simulation_time": MISSION_PREVIEW_TIME,
"asset_type": "All Asset Types",
"location": "Earth System",
"record_scope": "Confirmed + Planned",
}
MISSION_BOARD_RECORD_SCOPE_OPTIONS = [
{"value": "all", "label": "全部资产"},
{"value": "active", "label": "仅活动中"},
{"value": "active_planned", "label": "活动中 + 计划中"},
{"value": "recorded", "label": "仅已记录"},
]
TIMELINE_SCALE_OPTIONS = [
{"value": "day", "label": "按日"},
{"value": "month", "label": "按月"},
{"value": "year", "label": "按年"},
]
MISSION_STATUS_GROUPS = [
{
"title": "In Transit",
"accent": "warning",
"items": [
{
"name": "XH-02 Taibai",
"asset_type": "Exploration Mothership",
"location": "Earth to Mars Transfer",
"mission": "Support Mars One Campus",
"state_window": "2060-02-26 to 2060-03-29",
"next_event": "Mars Arrival · 2060-03-29",
"summary": "31-day transfer, planned delta-v margin 105 km/s.",
"record_status": "Confirmed",
},
{
"name": "Amalthea Crew Vehicle 4",
"asset_type": "Shuttle",
"location": "LKO to Lingxiao Station",
"mission": "Expedition 16 Crew Rotation",
"state_window": "2060-03-28 to 2060-03-30",
"next_event": "Docking Window · 2060-03-30",
"summary": "10 crew manifest uploaded, rendezvous burn pending.",
"record_status": "Planned",
},
],
},
{
"title": "Docked / On Station",
"accent": "primary",
"items": [
{
"name": "Lingxiao Station",
"asset_type": "Station",
"location": "Low Kerbin Orbit",
"mission": "Continuous habitation and logistics hub",
"state_window": "2059-07-23 to present",
"next_event": "Expedition 16 Crew Arrival · 2060-03-30",
"summary": "Expedition 15 is onboard, crew handover prep in progress.",
"record_status": "Active",
},
{
"name": "Tianhe Cargo Pod 7",
"asset_type": "Cargo Shuttle",
"location": "Lingxiao Forward Berth",
"mission": "Dry cargo replenishment",
"state_window": "2060-03-01 to 2060-03-18",
"next_event": "Undock · 2060-03-18",
"summary": "Food, station spares, and EVA consumables unloaded.",
"record_status": "Confirmed",
},
],
},
{
"title": "At Port / Surface Ops",
"accent": "neutral",
"items": [
{
"name": "XH-03 Changxi",
"asset_type": "Exploration Mothership",
"location": "Star Port",
"mission": "Refit for Jupiter relay deployment",
"state_window": "2060-01-08 to 2060-04-12",
"next_event": "Rollout Review · 2060-04-12",
"summary": "Main engine refurbishment at bay 4, payload integration queued.",
"record_status": "Confirmed",
},
{
"name": "Mars One Campus Alpha",
"asset_type": "Surface Outpost",
"location": "Mars Surface",
"mission": "Habitat expansion and ISRU staging",
"state_window": "2059-10-20 to present",
"next_event": "Taibai Supply Window · 2060-03-29",
"summary": "Surface crew awaiting structural truss shipment and greenhouse kit.",
"record_status": "Active",
},
],
},
]
MISSION_LOCATION_GROUPS = [
{
"title": "Earth System",
"subtitle": "Ports, orbital stations, and nearby transfers",
"items": [
{
"name": "Lingxiao Station",
"asset_type": "Station",
"state": "Docked / On Station",
"mission": "Crew rotation and logistics hub",
"location": "Low Kerbin Orbit",
"next_event": "Crew Arrival · 2060-03-30",
},
{
"name": "XH-03 Changxi",
"asset_type": "Exploration Mothership",
"state": "At Port",
"mission": "Refit for Jupiter relay deployment",
"location": "Star Port",
"next_event": "Rollout Review · 2060-04-12",
},
{
"name": "Amalthea Crew Vehicle 4",
"asset_type": "Shuttle",
"state": "In Transit",
"mission": "Expedition 16 Crew Rotation",
"location": "LKO Transfer Corridor",
"next_event": "Docking Window · 2060-03-30",
},
],
},
{
"title": "Mars System",
"subtitle": "Orbital assets and surface support network",
"items": [
{
"name": "Mars One Campus Alpha",
"asset_type": "Surface Outpost",
"state": "Surface Ops",
"mission": "Habitat expansion and ISRU staging",
"location": "Mars Surface",
"next_event": "Taibai Supply Window · 2060-03-29",
},
{
"name": "XH-02 Taibai",
"asset_type": "Exploration Mothership",
"state": "In Transit",
"mission": "Support Mars One Campus",
"location": "Mars Transfer Lane",
"next_event": "Mars Arrival · 2060-03-29",
},
],
},
]
MISSION_UPCOMING_EVENTS = [
{"when": "2060-03-18", "title": "Tianhe Cargo Pod 7 undock from Lingxiao Station", "scope": "Lingxiao Station"},
{"when": "2060-03-29", "title": "XH-02 Taibai arrives at Mars", "scope": "Mars System"},
{"when": "2060-03-30", "title": "Expedition 16 crew arrives via Amalthea Crew Vehicle", "scope": "Lingxiao Station"},
{"when": "2060-04-02", "title": "Expedition 15 crew departs Lingxiao Station", "scope": "Lingxiao Station"},
]
MISSION_LOG_HEADER = {
"name": "XH-02 Taibai",
"asset_type": "Exploration Mothership",
"program": "Mars Expedition Program",
"current_state": "In Transit",
"current_location": "Earth to Mars Transfer",
"current_mission": "Support Mars One Campus",
"state_window": "2060-02-26 06:00 to 2060-03-29 18:00",
"next_event": "Mars Arrival · 2060-03-29 18:00",
}
MISSION_LOG_ENTRIES = [
{
"mode": "event",
"timestamp": "2058-03-15 10:20",
"title": "First module delivered to Star Port",
"location": "Star Port",
"record_status": "Completed",
"summary": "Core transfer spine and habitat ring unloaded for final assembly.",
},
{
"mode": "event",
"timestamp": "2059-08-06 09:00",
"title": "Commissioned",
"location": "Star Port",
"record_status": "Completed",
"summary": "Flight certification signed for Mars transport duties.",
},
{
"mode": "state",
"timestamp": "2059-08-08 04:10",
"end_timestamp": "2059-10-17 13:40",
"title": "Outbound transfer carrying Mars Expedition 3",
"location": "Earth to Mars Transfer",
"record_status": "Completed",
"summary": "Departure from Earth with full crew and descent stack attached.",
},
{
"mode": "event",
"timestamp": "2059-10-17 13:40",
"title": "Mars Arrival",
"location": "Mars Orbit",
"record_status": "Completed",
"summary": "Insertion burn nominal. Crew descent staging began next day.",
},
{
"mode": "state",
"timestamp": "2059-10-18 08:00",
"end_timestamp": "2059-10-19 06:10",
"title": "Mars orbit standby and crew pickup window",
"location": "Mars Orbit",
"record_status": "Completed",
"summary": "Received Mars Expedition 1 return crew after descent vehicle separation.",
},
{
"mode": "state",
"timestamp": "2059-10-19 06:10",
"end_timestamp": "2059-12-25 22:30",
"title": "Return transfer from Mars to Earth",
"location": "Mars to Earth Transfer",
"record_status": "Completed",
"summary": "Return trajectory with Expedition 1 crew aboard.",
},
{
"mode": "state",
"timestamp": "2060-02-26 06:00",
"end_timestamp": "2060-03-29 18:00",
"title": "Support mission transfer to Mars One Campus",
"location": "Earth to Mars Transfer",
"record_status": "Active",
"summary": "Carrying structural truss and surface greenhouse kit; 105 km/s, 31-day transition.",
},
{
"mode": "event",
"timestamp": "2060-03-29 18:00",
"title": "Mars Arrival",
"location": "Mars Orbit",
"record_status": "Planned",
"summary": "Planned capture burn and cargo handoff to surface fleet.",
},
]
MISSION_TIMELINE_ROWS = [
{
"name": "XH-02 Taibai",
"asset_type": "Exploration Mothership",
"segments": [
{"label": "Transit to Mars", "start": 6, "span": 22, "tone": "warning"},
{"label": "Mars Orbit", "start": 29, "span": 6, "tone": "primary"},
{"label": "Return Transit", "start": 38, "span": 21, "tone": "neutral"},
{"label": "Port Stay", "start": 61, "span": 8, "tone": "soft"},
{"label": "Support Transit", "start": 73, "span": 22, "tone": "warning current"},
],
"events": [
{"label": "Commissioned", "offset": 1},
{"label": "Mars Arrival", "offset": 28},
{"label": "Earth Arrival", "offset": 59},
{"label": "Next Mars Arrival", "offset": 95},
],
},
{
"name": "Lingxiao Station",
"asset_type": "Station",
"segments": [
{"label": "Continuous Ops", "start": 0, "span": 96, "tone": "primary current"},
],
"events": [
{"label": "Crew 9 Arrival", "offset": 10},
{"label": "Expedition 15 Arrival", "offset": 62},
{"label": "Expedition 16 Arrival", "offset": 88},
],
},
{
"name": "Mars One Campus Alpha",
"asset_type": "Surface Outpost",
"segments": [
{"label": "Surface Expansion", "start": 34, "span": 62, "tone": "neutral current"},
],
"events": [
{"label": "First Crew Descent", "offset": 37},
{"label": "Taibai Supply Window", "offset": 95},
],
},
]
MISSION_TIMELINE_SCALE = [
"2059-08",
"2059-09",
"2059-10",
"2059-11",
"2059-12",
"2060-01",
"2060-02",
"2060-03",
]
def _mission_preview_context() -> dict[str, object]:
return {
"simulation_time": MISSION_PREVIEW_TIME,
"metrics": MISSION_PREVIEW_METRICS,
"filters": MISSION_PREVIEW_FILTERS,
"upcoming_events": MISSION_UPCOMING_EVENTS,
}
FAMILY_FORM_FIELDS = [
{"name": "engine_name", "label": "Engine", "kind": "text", "required": True},
{"name": "part_name", "label": "Part Name", "kind": "text", "required": False},
{"name": "cycle", "label": "Cycle", "kind": "combo", "required": False},
{"name": "size_m", "label": "Size (m)", "kind": "decimal", "required": False},
{"name": "entry_cost", "label": "Entry Cost", "kind": "integer", "required": False},
]
VARIANT_FORM_FIELDS = [
{"name": "fuel_type", "label": "Fuel Type", "kind": "combo", "required": True},
{"name": "config_name", "label": "Config Name", "kind": "text", "required": False},
{"name": "work_env", "label": "Work Env", "kind": "text", "required": False},
{"name": "sl_isp", "label": "SL ISP", "kind": "decimal", "required": False},
{"name": "vac_isp", "label": "Vac ISP", "kind": "decimal", "required": False},
{"name": "min_thrust_kn", "label": "Min Thrust (kN)", "kind": "decimal", "required": False},
{"name": "max_thrust_kn", "label": "Max Thrust (kN)", "kind": "decimal", "required": False},
{"name": "mass_t", "label": "Mass (t)", "kind": "decimal", "required": False},
{"name": "twr", "label": "TWR", "kind": "decimal", "required": False},
{"name": "throttle_ratio", "label": "Throttle Ratio", "kind": "decimal", "required": False},
{"name": "tvc_deg", "label": "TVC (deg)", "kind": "decimal", "required": False},
{"name": "price_kd", "label": "Price (KD)", "kind": "integer", "required": False},
{"name": "ignitions", "label": "Ignitions", "kind": "integer", "required": False},
{
"name": "has_unlimited_ignitions",
"label": "Unlimited Ignitions",
"kind": "checkbox",
"required": False,
},
{"name": "mod_source", "label": "Mod Source", "kind": "combo", "required": False},
{"name": "tech_required", "label": "Tech Required", "kind": "text", "required": False},
{"name": "engine_note", "label": "Engine Note", "kind": "textarea", "required": False},
{"name": "config_note", "label": "Config Note", "kind": "textarea", "required": False},
]
VARIANT_CREATE_FIELDS = [
{"name": "fuel_type", "label": "Fuel Type", "kind": "combo", "required": True},
{"name": "config_name", "label": "Config Name", "kind": "text", "required": False},
{"name": "work_env", "label": "Work Env", "kind": "text", "required": False},
{"name": "sl_isp", "label": "SL ISP", "kind": "decimal", "required": False},
{"name": "vac_isp", "label": "Vac ISP", "kind": "decimal", "required": False},
{"name": "max_thrust_kn", "label": "Max Thrust (kN)", "kind": "decimal", "required": False},
{"name": "mod_source", "label": "Mod Source", "kind": "combo", "required": False},
]
COMMUNICATION_FIELDS = [
{"name": "part_name", "label": "Part", "kind": "text", "required": True},
{"name": "display_name", "label": "Part Showname", "kind": "text", "required": False},
{"name": "antenna_type", "label": "Type", "kind": "text", "required": False},
{"name": "source", "label": "Source", "kind": "text", "required": False},
{"name": "mass_t", "label": "Mass (t)", "kind": "decimal", "required": False},
{"name": "range_km", "label": "Range km", "kind": "decimal", "required": False},
{"name": "angle_deg", "label": "Angle", "kind": "decimal", "required": False},
{"name": "idle_power_watt", "label": "Idle Power (W)", "kind": "decimal", "required": False},
{
"name": "transmitting_power_watt",
"label": "Transmitting Power (W)",
"kind": "decimal",
"required": False,
},
{"name": "entry_cost", "label": "Entry Cost", "kind": "integer", "required": False},
{"name": "cost", "label": "Cost", "kind": "integer", "required": False},
{"name": "is_active", "label": "Is Active", "kind": "checkbox", "required": False},
{
"name": "is_deployable",
"label": "Is Deployable",
"kind": "checkbox",
"required": False,
},
{"name": "is_feeder", "label": "Is Feeder", "kind": "checkbox", "required": False},
{"name": "description", "label": "Description", "kind": "textarea", "required": False},
{"name": "note", "label": "Note", "kind": "textarea", "required": False},
]
TANK_FIELDS = [
{"name": "tank_name", "label": "Tank", "kind": "text", "required": True},
{"name": "fuel_type", "label": "Fuel Type", "kind": "text", "required": False},
{"name": "vehicle_name", "label": "Vehicle", "kind": "text", "required": False},
{"name": "source", "label": "Source", "kind": "text", "required": False},
{"name": "dry_mass_t", "label": "Dry Mass", "kind": "decimal", "required": False},
{"name": "fuel_mass_t", "label": "Fuel Mass", "kind": "decimal", "required": False},
{"name": "wet_mass_t", "label": "Wet Mass", "kind": "decimal", "required": False},
{"name": "tank_volume_l", "label": "Tank Volume (L)", "kind": "decimal", "required": False},
{"name": "mass_ratio", "label": "Mass Ratio", "kind": "decimal", "required": False},
{
"name": "kiloliters_per_ton",
"label": "KL per ton",
"kind": "decimal",
"required": False,
},
{"name": "note", "label": "Note", "kind": "textarea", "required": False},
]
VEHICLE_FIELDS = [
{"name": "vehicle_name", "label": "Vehicle", "kind": "text", "required": True},
{"name": "launch_price", "label": "Launch Price", "kind": "integer", "required": False},
{"name": "source", "label": "Source", "kind": "text", "required": False},
]
ASSET_FIELDS = [
{"name": "name", "label": "Asset", "kind": "text", "required": True},
{"name": "asset_type", "label": "Asset Type", "kind": "combo", "required": True},
{"name": "is_retired", "label": "Retired", "kind": "checkbox", "required": False},
{"name": "program", "label": "Program", "kind": "text", "required": False},
{"name": "home_region", "label": "Home Region", "kind": "text", "required": False},
{"name": "note", "label": "Note", "kind": "textarea", "required": False},
]
ASSET_TYPE_BASE_OPTIONS = (
"Shuttle",
"Station",
"Surface Outpost",
"Exploration Mothership",
)
ASSET_ENTRY_KIND_OPTIONS = [
{"value": "event", "label": "Event Point"},
{"value": "state", "label": "State Interval"},
]
PROJECT_METRICS = [
{"label": "Engine rows", "value": "478"},
{"label": "Communication rows", "value": "50"},
{"label": "Tank rows", "value": "27"},
{"label": "Vehicle cost rows", "value": "10"},
]
MODULES = [
{
"title": "Engine catalog",
"status": "live",
"description": "Core engine families and variants with filters, pagination, create/delete, and editable variants.",
"endpoint": "web.engine_list",
},
{
"title": "Communication parts",
"status": "live",
"description": "Paginated communication part list with source/type filters and edit forms.",
"endpoint": "web.communication_list",
},
{
"title": "Tank specs",
"status": "live",
"description": "Paginated tank list with fuel/source filters and edit forms.",
"endpoint": "web.tank_list",
},
{
"title": "Vehicle costs",
"status": "live",
"description": "Paginated vehicle cost list with sorting and edit forms.",
"endpoint": "web.vehicle_list",
},
{
"title": "Mission assets",
"status": "beta",
"description": "Asset CRUD with real asset log entries and simulation-time-derived current state.",
"endpoint": "web.asset_list",
},
{
"title": "Fuel converter",
"status": "live",
"description": "A working liters-to-tons and tons-to-liters converter based on the workbook coefficients.",
"endpoint": "web.fuel_converter",
},
]
@dataclass(slots=True)
class EngineCatalogRow:
family: EngineFamily
config_count: int
fuel_types: tuple[str, ...]
mod_sources: tuple[str, ...]
mass_t: Decimal | None
twr: Decimal | None
vac_thrust_kn: Decimal | None
sl_thrust_kn: Decimal | None
vac_isp: Decimal | None
sl_isp: Decimal | None
def _engine_family_statement() -> object:
return select(EngineFamily).options(selectinload(EngineFamily.variants))
def _load_model_or_404(model: type[Any], object_id: object) -> Any:
instance = db.session.get(model, object_id)
if instance is None:
abort(404)
return instance
def _load_engine_family_or_404(family_id: object) -> EngineFamily:
statement = _engine_family_statement().where(EngineFamily.id == family_id)
family = db.session.execute(statement).scalar_one_or_none()
if family is None:
abort(404)
return family
def _normalized_text(raw_value: str | None) -> str | None:
if raw_value is None:
return None
cleaned = raw_value.strip()
return cleaned or None
def _parse_decimal(raw_value: str | None, label: str) -> Decimal | None:
cleaned = _normalized_text(raw_value)
if cleaned is None:
return None
try:
return Decimal(cleaned)
except InvalidOperation as exc:
raise ValueError(f"{label} 必须是数值") from exc
def _parse_integer(raw_value: str | None, label: str) -> int | None:
cleaned = _normalized_text(raw_value)
if cleaned is None:
return None
try:
decimal_value = Decimal(cleaned)
except InvalidOperation as exc:
raise ValueError(f"{label} 必须是整数") from exc
if decimal_value != decimal_value.to_integral_value():
raise ValueError(f"{label} 必须是整数")
return int(decimal_value)
def _as_utc(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _parse_datetime(raw_value: str | None, label: str) -> datetime | None:
cleaned = _normalized_text(raw_value)
if cleaned is None:
return None
try:
parsed = datetime.fromisoformat(cleaned.replace(" ", "T"))
except ValueError as exc:
normalized = (
cleaned.replace("年", "/")
.replace("月", "/")
.replace("日", " ")
.replace("T", " ")
)
match = re.fullmatch(r"(\d{4})[/-](\d{1,2})[/-](\d{1,2})\s+(\d{1,2}):(\d{1,2})", " ".join(normalized.split()))
if match is None:
raise ValueError(f"{label} 必须是合法时间") from exc
try:
parsed = datetime(
int(match.group(1)),
int(match.group(2)),
int(match.group(3)),
int(match.group(4)),
int(match.group(5)),
)
except ValueError as inner_exc:
raise ValueError(f"{label} 必须是合法时间") from inner_exc
return _as_utc(parsed)
HARDCODED_SIMULATION_TIME = datetime(2060, 3, 12, 9, 0, tzinfo=timezone.utc)
def _db_read_simulation_time() -> datetime | None:
try:
row = db.session.execute(
text("SELECT value FROM simulation_settings WHERE key = 'current_time'")
).fetchone()
if row is None:
return None
return _parse_datetime(row[0], "DB Simulation Time")
except Exception:
return None
def _db_write_simulation_time(value: datetime) -> None:
formatted = _format_datetime_input(value)
try:
db.session.execute(
text(
"INSERT INTO simulation_settings (key, value, updated_at) "
"VALUES ('current_time', :val, NOW()) "
"ON CONFLICT (key) DO UPDATE SET value = :val2, updated_at = NOW()"
),
{"val": formatted, "val2": formatted},
)
db.session.commit()
except Exception:
db.session.rollback()
def _default_simulation_time() -> datetime:
db_time = _db_read_simulation_time()
if db_time is not None:
return db_time
return HARDCODED_SIMULATION_TIME
def _all_state_labels() -> list[str]:
"""Return merged state labels: defaults + all unique labels from DB."""
defaults = ['start-transit','transit-complete','Exploration','Maintenance','Docked',
'Construction','Testing','Autonomous Standby','Crew Handoff','Cargo Delivery',
'Surface Expedition','Planetary Operations','Base Construction','Outpost Construction',
'Base Expansion','Outpost Expansion']
db_labels = (
db.session.execute(
select(distinct(AssetStateNode.state_label)).where(AssetStateNode.state_label.isnot(None))
).scalars().all()
)
merged = {*defaults, *db_labels}
return sorted(merged, key=str.casefold)
def _remember_simulation_time(value: datetime) -> None:
if not has_request_context():
return
session[SIMULATION_TIME_SESSION_KEY] = _format_datetime_input(value)
def _saved_simulation_time_value() -> str | None:
if not has_request_context():
return None
saved_value = session.get(SIMULATION_TIME_SESSION_KEY)
return saved_value if isinstance(saved_value, str) else None
def _resolve_simulation_time(raw_value: str | None = None) -> datetime:
db_time = _db_read_simulation_time()
if db_time is not None:
_remember_simulation_time(db_time)
return db_time
saved_value = _saved_simulation_time_value()
if saved_value is not None:
try:
saved_datetime = _parse_datetime(saved_value, "Simulation Time")
except ValueError:
saved_datetime = None
if saved_datetime is not None:
return saved_datetime
default_value = HARDCODED_SIMULATION_TIME
_remember_simulation_time(default_value)
return default_value
def _format_datetime_display(value: datetime | None) -> str:
normalized = _as_utc(value)
if normalized is None:
return "-"
return normalized.strftime("%Y-%m-%d %H:%M")
def _format_datetime_input(value: datetime | None) -> str:
normalized = _as_utc(value)
if normalized is None:
return ""
return normalized.strftime("%Y-%m-%dT%H:%M")
def _datetime_input_parts(value: datetime | None) -> dict[str, str]:
normalized = _as_utc(value)
if normalized is None:
return {"year": "", "month": "", "day": "", "hour": "", "minute": ""}
return {
"year": normalized.strftime("%Y"),
"month": normalized.strftime("%m"),
"day": normalized.strftime("%d"),
"hour": normalized.strftime("%H"),
"minute": normalized.strftime("%M"),
}
def _parse_datetime_form_value(
form: object,
field_name: str,
label: str,
*,
required: bool = False,
) -> datetime | None:
part_names = ("year", "month", "day", "hour", "minute")
part_values = {part: _normalized_text(form.get(f"{field_name}_{part}")) for part in part_names}
if any(value is not None for value in part_values.values()):
if any(value is None for value in part_values.values()):
raise ValueError(f"{label} 需要完整填写年月日时分")
try:
return datetime(
int(part_values["year"]),
int(part_values["month"]),
int(part_values["day"]),
int(part_values["hour"]),
int(part_values["minute"]),
tzinfo=timezone.utc,
)
except ValueError as exc:
raise ValueError(f"{label} 必须是合法时间") from exc
parsed_value = _parse_datetime(form.get(field_name), label)
if required and parsed_value is None:
raise ValueError(f"{label} 不能为空")
return parsed_value
class FormValidationError(ValueError):
def __init__(
self,
message: str,
*,
field_errors: dict[str, str] | None = None,
messages: list[str] | None = None,
) -> None:
super().__init__(message)
self.field_errors = field_errors or {}
self.messages = messages or [message]
def _build_validation_modal(
error: Exception | str,
*,
title: str = "本次提交未保存",
field_errors: dict[str, str] | None = None,
) -> dict[str, object]:
if isinstance(error, FormValidationError):
merged_field_errors = {**error.field_errors, **(field_errors or {})}
messages = list(error.messages)
else:
merged_field_errors = field_errors or {}
messages = [str(error)]
for message in merged_field_errors.values():
if message not in messages:
messages.append(message)
return {
"title": title,
"messages": messages,
"field_errors": merged_field_errors,
}
def _collect_submitted_form_values(
form: object,
field_specs: list[dict[str, object]],
prefix: str = "",
) -> dict[str, object]:
values: dict[str, object] = {}
for field_spec in field_specs:
field_name = field_spec["name"]
full_name = f"{prefix}{field_name}"
field_kind = field_spec["kind"]
if field_kind == "checkbox":
values[field_name] = full_name in form
continue
if field_kind == "combo":
selected_value = form.get(full_name)
custom_value = form.get(f"{full_name}__custom")
if _normalized_text(custom_value) is not None:
values[field_name] = custom_value or ""
elif selected_value == CUSTOM_OPTION_VALUE:
values[field_name] = ""
else:
values[field_name] = selected_value or ""
continue
values[field_name] = form.get(full_name) or ""
return values
def _apply_fields(
target: object,
form: object,
field_specs: list[dict[str, object]],
prefix: str = "",
) -> None:
parsed_values: dict[str, object] = {}
for field_spec in field_specs:
field_name = field_spec["name"]
full_name = f"{prefix}{field_name}"
field_kind = field_spec["kind"]
label = field_spec["label"]
required = bool(field_spec.get("required"))
try:
if field_kind == "checkbox":
value = full_name in form
elif field_kind == "combo":
selected_value = _normalized_text(form.get(full_name))
custom_value = _normalized_text(form.get(f"{full_name}__custom"))
if custom_value is not None:
value = custom_value
elif selected_value == CUSTOM_OPTION_VALUE:
value = None
else:
value = selected_value
elif field_kind == "decimal":
value = _parse_decimal(form.get(full_name), label)
elif field_kind == "integer":
value = _parse_integer(form.get(full_name), label)
else:
value = _normalized_text(form.get(full_name))
except ValueError as exc:
raise FormValidationError(str(exc), field_errors={field_name: str(exc)}) from exc
if required and value in (None, ""):
message = f"{label} 不能为空"
raise FormValidationError(message, field_errors={field_name: message})
parsed_values[field_name] = value
for field_name, value in parsed_values.items():
setattr(target, field_name, value)
def _collect_form_values(entity: object, field_specs: list[dict[str, object]]) -> dict[str, object]:
return {field["name"]: getattr(entity, field["name"], None) for field in field_specs}
def _load_asset_or_404(asset_id: object) -> Asset:
statement = select(Asset).options(selectinload(Asset.log_entries).selectinload(AssetLogEntry.state_nodes)).where(Asset.id == asset_id)
asset = db.session.execute(statement).scalar_one_or_none()
if asset is None:
abort(404)
return asset
def _load_asset_entry_or_404(asset_id: object, entry_id: object) -> AssetLogEntry:
entry = db.session.get(AssetLogEntry, entry_id)
if entry is None or entry.asset_id != asset_id:
abort(404)
return entry
def _apply_asset_entry_fields(target: AssetLogEntry, form: object) -> None:
entry_kind = _normalized_text(form.get("entry_kind")) or "event"
allowed_kinds = {option["value"] for option in ASSET_ENTRY_KIND_OPTIONS}
if entry_kind not in allowed_kinds:
message = "Entry Type 无效"
raise FormValidationError(message, field_errors={"entry_kind": message})
title = _normalized_text(form.get("title"))
if title is None:
message = "Title 不能为空"
raise FormValidationError(message, field_errors={"title": message})
try:
start_at = _parse_datetime_form_value(form, "start_at", "Start At", required=True)
except ValueError as exc:
raise FormValidationError(str(exc), field_errors={"start_at": str(exc)}) from exc
if start_at is None:
message = "Start At 不能为空"
raise FormValidationError(message, field_errors={"start_at": message})
try:
end_at = _parse_datetime_form_value(form, "end_at", "End At")
except ValueError as exc:
raise FormValidationError(str(exc), field_errors={"end_at": str(exc)}) from exc
if entry_kind == "event":
end_at = None
elif end_at is not None and end_at <= start_at:
message = "End At 必须晚于 Start At"
raise FormValidationError(message, field_errors={"start_at": message, "end_at": message})
state_node_rows = []
if entry_kind == "state":
state_node_rows = _parse_state_node_rows(form, start_at, end_at)
else:
state_node_rows = _event_location_state_node_rows(target, form.get("location"), title, start_at)
target.entry_kind = entry_kind
target.title = title
target.mission_label = _normalize_multi_value_text(form.get("mission_label"))
target.start_at = start_at
target.end_at = end_at
target.summary = _normalized_text(form.get("summary"))
target.note = _normalized_text(form.get("note"))
_sync_state_nodes(target, state_node_rows)
def _apply_asset_entry_json_fields(target: AssetLogEntry, data: object) -> None:
if not isinstance(data, dict):
message = "Payload invalid"
raise FormValidationError(message, field_errors={"payload": message})
entry_kind = _normalized_text(data.get("entry_kind")) or target.entry_kind or "event"
allowed_kinds = {option["value"] for option in ASSET_ENTRY_KIND_OPTIONS}
if entry_kind not in allowed_kinds:
message = "Entry Type invalid"
raise FormValidationError(message, field_errors={"entry_kind": message})
title = _normalized_text(data.get("title")) or target.title
if title is None:
message = "Title required"
raise FormValidationError(message, field_errors={"title": message})
try:
start_at = _parse_datetime(data.get("start_at") or data.get("sim_time"), "Start At")
except ValueError as exc:
raise FormValidationError(str(exc), field_errors={"start_at": str(exc)}) from exc
if start_at is None:
start_at = target.start_at
if start_at is None:
message = "Start At required"
raise FormValidationError(message, field_errors={"start_at": message})
if "end_at" in data:
try:
end_at = _parse_datetime(data.get("end_at"), "End At")
except ValueError as exc:
raise FormValidationError(str(exc), field_errors={"end_at": str(exc)}) from exc
else:
end_at = target.end_at
if entry_kind == "event":
end_at = None
elif end_at is not None and end_at <= start_at:
message = "End At must be later than Start At"
raise FormValidationError(message, field_errors={"start_at": message, "end_at": message})
state_node_rows = []
if entry_kind == "state":
state_node_rows = _parse_json_state_node_rows(data, start_at, end_at)
else:
state_node_rows = _event_location_state_node_rows(target, data.get("location"), title, start_at)
target.entry_kind = entry_kind
target.title = title
target.mission_label = _normalize_multi_value_text(data.get("mission_label"))
target.start_at = start_at
target.end_at = end_at
target.summary = _normalized_text(data.get("summary"))
target.note = _normalized_text(data.get("note"))
_sync_state_nodes(target, state_node_rows)
def _asset_entry_form_values(entry: AssetLogEntry) -> dict[str, object]:
return {
"entry_kind": entry.entry_kind or "event",
"title": entry.title or "",
"mission_label": entry.mission_label or "",
"location": _entry_location(entry) or "",
"start_at": _format_datetime_input(entry.start_at),
"start_at_parts": _datetime_input_parts(entry.start_at),
"end_at": _format_datetime_input(entry.end_at),
"end_at_parts": _datetime_input_parts(entry.end_at),
"summary": entry.summary or "",
"note": entry.note or "",
"state_nodes": [
{
"id": str(node.id) if node.id is not None else "",
"title": node.title or "",
"detail": node.detail or "",
"at": _format_datetime_input(node.at_time),
"target_location": node.target_location or "",
"previous_location": node.previous_location or "",
"transit_location": node.transit_location or "",
"state_label": node.state_label or "",
}
for node in sorted(entry.state_nodes, key=lambda item: _as_utc(item.at_time) or _default_simulation_time())
],
}
def _asset_entry_form_values_from_form(form: object) -> dict[str, object]:
node_ids = form.getlist("state_node_id")
node_titles = form.getlist("state_node_title")
node_details = form.getlist("state_node_detail")
node_times = form.getlist("state_node_at")
node_locations = form.getlist("state_node_target_location")
node_prev_locations = form.getlist("state_node_previous_location")
node_transit_locations = form.getlist("state_node_transit_location")
node_state_labels = form.getlist("state_node_state_label")
row_count = max(len(node_ids), len(node_titles), len(node_details), len(node_times))
state_nodes = []
for index in range(row_count):
state_nodes.append(
{
"id": node_ids[index] if index < len(node_ids) else "",
"title": node_titles[index] if index < len(node_titles) else "",
"detail": node_details[index] if index < len(node_details) else "",
"at": node_times[index] if index < len(node_times) else "",
"target_location": node_locations[index] if index < len(node_locations) else "",
"previous_location": node_prev_locations[index] if index < len(node_prev_locations) else "",
"transit_location": node_transit_locations[index] if index < len(node_transit_locations) else "",
"state_label": node_state_labels[index] if index < len(node_state_labels) else "",
}
)
node_locations_2 = form.getlist("location")
return {
"entry_kind": form.get("entry_kind") or "event",
"title": form.get("title") or "",
"mission_label": form.get("mission_label") or "",
"location": node_locations_2[0] if node_locations_2 else "",
"start_at": form.get("start_at") or "",
"start_at_parts": {"year": "", "month": "", "day": "", "hour": "", "minute": ""},
"end_at": form.get("end_at") or "",
"end_at_parts": {"year": "", "month": "", "day": "", "hour": "", "minute": ""},
"summary": form.get("summary") or "",
"note": form.get("note") or "",
"state_nodes": state_nodes,
}
def _parse_state_node_rows(
form: object,
start_at: datetime,
end_at: datetime | None,
) -> list[dict[str, object]]:
node_ids = form.getlist("state_node_id")
node_titles = form.getlist("state_node_title")
node_details = form.getlist("state_node_detail")
node_times = form.getlist("state_node_at")
node_locations = form.getlist("state_node_target_location")
node_prev_locations = form.getlist("state_node_previous_location")
node_transit_locations = form.getlist("state_node_transit_location")
node_state_labels = form.getlist("state_node_state_label")
if not node_ids and not node_titles and not node_details and not node_times:
return []
if (
len(node_ids) != len(node_titles)
or len(node_titles) != len(node_details)
or len(node_details) != len(node_times)
):
message = "State Node 数据不完整"
raise FormValidationError(message, field_errors={"state_nodes": message})
state_nodes = []
for index, (raw_id, raw_title, raw_detail, raw_at_time) in enumerate(
zip(node_ids, node_titles, node_details, node_times)
):
node_id = _normalized_text(raw_id)
title = _normalized_text(raw_title)
detail = _normalized_text(raw_detail)
display_index = index + 1
try:
at_time = _parse_datetime(raw_at_time, f"State Node {display_index} At")
except ValueError as exc:
raise FormValidationError(
str(exc),
field_errors={f"state_node_at:{index}": str(exc)},
) from exc
if title is None and detail is None and at_time is None:
continue
if title is None or at_time is None:
message = f"State Node {display_index} 需要同时填写 Title 和 At"
field_errors: dict[str, str] = {}
if title is None:
field_errors[f"state_node_title:{index}"] = message
if at_time is None:
field_errors[f"state_node_at:{index}"] = message
raise FormValidationError(message, field_errors=field_errors)
if at_time < start_at:
message = f"State Node {display_index} 时间不能早于 Start At"
raise FormValidationError(
message,
field_errors={"start_at": message, f"state_node_at:{index}": message},
)
if end_at is not None and at_time > end_at:
message = f"State Node {display_index} 时间不能晚于 End At"
raise FormValidationError(
message,
field_errors={"end_at": message, f"state_node_at:{index}": message},
)
location = _normalized_text(node_locations[index]) if index < len(node_locations) else None
prev_loc = _normalized_text(node_prev_locations[index]) if index < len(node_prev_locations) else None
transit_loc = _normalized_text(node_transit_locations[index]) if index < len(node_transit_locations) else None
state_label = _normalize_multi_value_text(node_state_labels[index]) if index < len(node_state_labels) else None
state_nodes.append(
{
"id": node_id,
"title": title,
"detail": detail,
"at_time": at_time,
"target_location": location,
"previous_location": prev_loc,
"transit_location": transit_loc,
"state_label": state_label,
}
)
return state_nodes
def _parse_json_state_node_rows(
data: dict[str, object],
start_at: datetime,
end_at: datetime | None,
) -> list[dict[str, object]]:
raw_nodes = data.get("state_nodes") or []
if not isinstance(raw_nodes, list):
message = "State Nodes invalid"
raise FormValidationError(message, field_errors={"state_nodes": message})
state_nodes = []
for index, raw_node in enumerate(raw_nodes):
if not isinstance(raw_node, dict):
message = f"State Node {index + 1} invalid"
raise FormValidationError(message, field_errors={"state_nodes": message})
title = _normalized_text(raw_node.get("title"))
detail = _normalized_text(raw_node.get("detail"))
try:
at_time = _parse_datetime(raw_node.get("at_time") or raw_node.get("at"), f"State Node {index + 1} At")
except ValueError as exc:
raise FormValidationError(
str(exc),
field_errors={f"state_node_at:{index}": str(exc)},
) from exc
if at_time is None:
at_time = start_at
if title is None and detail is None:
continue
if title is None:
message = f"State Node {index + 1} Title required"
raise FormValidationError(message, field_errors={f"state_node_title:{index}": message})
if at_time < start_at:
message = f"State Node {index + 1} cannot be earlier than Start At"
raise FormValidationError(
message,
field_errors={"start_at": message, f"state_node_at:{index}": message},
)
if end_at is not None and at_time > end_at:
message = f"State Node {index + 1} cannot be later than End At"
raise FormValidationError(
message,
field_errors={"end_at": message, f"state_node_at:{index}": message},
)
target_location = _normalized_text(
raw_node.get("target_location") or raw_node.get("location") or data.get("location")
)
state_label = _normalize_multi_value_text(raw_node.get("state_label") or data.get("state_label"))
state_nodes.append(
{
"id": _normalized_text(raw_node.get("id")),
"title": title,
"detail": detail,
"at_time": at_time,
"target_location": target_location,
"previous_location": _normalized_text(raw_node.get("previous_location")),
"transit_location": _normalized_text(raw_node.get("transit_location")),
"state_label": state_label,
}
)
return state_nodes
def _event_location_state_node_rows(
target: AssetLogEntry,
raw_location: object,
title: str,
start_at: datetime,
) -> list[dict[str, object]]:
location = _normalized_text(raw_location)
if location is None:
return []
existing_node_id = ""
if target.state_nodes:
existing_node_id = str(sorted(target.state_nodes, key=lambda item: _as_utc(item.at_time) or start_at)[0].id)
return [
{
"id": existing_node_id,
"title": title,
"detail": None,
"at_time": start_at,
"target_location": location,
"previous_location": None,
"transit_location": None,
"state_label": None,
}
]
def _append_docking_log(
asset: Asset,
child_name: str,
parent_name: str,
target_location: str,
docked_at: datetime,
note: str | None,
) -> None:
title = f"{child_name} docked to {parent_name}"
# Check if an existing (non-dock) state interval covers the dock time
existing = AssetLogEntry.query.filter(
AssetLogEntry.asset_id == asset.id,
AssetLogEntry.entry_kind == "state",
AssetLogEntry.start_at <= docked_at,
).filter(
(AssetLogEntry.end_at.is_(None)) | (AssetLogEntry.end_at > docked_at)
).filter(
~AssetLogEntry.title.ilike("%docked to%")
).order_by(AssetLogEntry.start_at.asc()).first()
if existing is not None:
existing.state_nodes.append(
AssetStateNode(
title=title,
detail=note,
at_time=docked_at,
target_location=target_location,
state_label="Docked",
)
)
else:
entry = AssetLogEntry(
entry_kind="state",
title=title,
start_at=docked_at,
summary=note,
asset=asset,
)
entry.state_nodes.append(
AssetStateNode(
title=title,
detail=note,
at_time=docked_at,
target_location=target_location,
state_label="Docked",
)
)
db.session.add(entry)
def _append_undocking_log(
asset: Asset,
child_name: str,
parent_name: str,
docked_at: datetime,
undocked_at: datetime,
note: str | None,
) -> None:
"""Record an undocking event in the asset's log timeline."""
dock_title = f"{child_name} docked to {parent_name}"
# Find the entry that contains the dock node (may not be titled 'docked to')
entry = AssetLogEntry.query.filter(
AssetLogEntry.asset_id == asset.id,
AssetLogEntry.entry_kind == "state",
AssetLogEntry.state_nodes.any(
(AssetStateNode.title == dock_title) &
(AssetStateNode.at_time == docked_at)
)
).first()
if entry is not None:
entry.state_nodes.append(AssetStateNode(
title=f"{child_name} undocked from {parent_name}",
detail=note,
at_time=undocked_at,
state_label="Docked",
))
if entry.end_at is None:
entry.end_at = undocked_at
else:
undock_title = f"{child_name} undocked from {parent_name}"
entry = AssetLogEntry(
entry_kind="event",
title=undock_title,
start_at=undocked_at,
summary=note,
asset=asset,
)
db.session.add(entry)
def _remove_undocking_log(
asset: Asset,
child_name: str,
parent_name: str,
) -> None:
"""Remove undock state nodes and reopen the dock entry."""
dock_title = f"{child_name} docked to {parent_name}"
undock_title = f"{child_name} undocked from {parent_name}"
entry = AssetLogEntry.query.filter_by(
asset_id=asset.id, entry_kind="state", title=dock_title
).order_by(AssetLogEntry.start_at.desc()).first()
if entry is not None:
nodes_to_remove = [n for n in entry.state_nodes if n.title == undock_title]
for node in nodes_to_remove:
db.session.delete(node)
entry.end_at = None
def _parse_uuid_value(raw_value: object) -> uuid.UUID | None:
if isinstance(raw_value, uuid.UUID):
return raw_value
cleaned = _normalized_text(str(raw_value)) if raw_value is not None else None
if cleaned is None:
return None
try:
return uuid.UUID(cleaned)
except ValueError:
return None
def _sync_state_nodes(target: AssetLogEntry, state_node_rows: list[dict[str, object]]) -> None:
existing_nodes = {str(node.id): node for node in target.state_nodes if node.id is not None}
next_nodes = []
for row in state_node_rows:
raw_node_id = row["id"]
if isinstance(raw_node_id, str) and raw_node_id:
node = existing_nodes.pop(raw_node_id, None)
if node is None:
message = "State Node 无效"
raise FormValidationError(message, field_errors={"state_nodes": message})
else:
node = AssetStateNode()
node.title = str(row["title"])
node.detail = row["detail"]
node.at_time = row["at_time"]
node.target_location = row.get("target_location")
node.previous_location = row.get("previous_location")
node.transit_location = row.get("transit_location")
node.state_label = row.get("state_label")
next_nodes.append(node)
next_nodes.sort(key=lambda item: _as_utc(item.at_time) or _default_simulation_time())
target.state_nodes = next_nodes
def _normalize_variant_ignitions(variant: EngineVariant) -> None:
if variant.has_unlimited_ignitions:
variant.ignitions = None
return
if variant.ignitions is None:
variant.has_unlimited_ignitions = True
def _calculate_variant_twr(max_thrust: Decimal | None, mass: Decimal | None) -> Decimal | None:
if max_thrust is None or mass in (None, Decimal("0")):
return None
return max_thrust / Decimal("9.80665") / mass
def _calculate_variant_throttle_ratio(
min_thrust: Decimal | None, max_thrust: Decimal | None
) -> Decimal | None:
if min_thrust is None or max_thrust in (None, Decimal("0")):
return None
return min_thrust / max_thrust
def _refresh_variant_derived_fields(variant: EngineVariant) -> None:
variant.twr = _calculate_variant_twr(variant.max_thrust_kn, variant.mass_t)
variant.throttle_ratio = _calculate_variant_throttle_ratio(
variant.min_thrust_kn,
variant.max_thrust_kn,
)
def _distinct_variant_values(family: EngineFamily, attribute_name: str) -> tuple[str, ...]:
values = {
getattr(variant, attribute_name)
for variant in family.variants
if getattr(variant, attribute_name) not in (None, "")
}
return tuple(sorted(values, key=str.casefold))
def _distinct_text_values(values: list[str | None]) -> tuple[str, ...]:
cleaned_values = {value.strip() for value in values if value and value.strip()}
return tuple(sorted(cleaned_values, key=str.casefold))
def _engine_form_options() -> dict[str, tuple[str, ...]]:
cycle_values = db.session.execute(select(EngineFamily.cycle)).scalars().all()
fuel_values = db.session.execute(select(EngineVariant.fuel_type)).scalars().all()
mod_values = db.session.execute(select(EngineVariant.mod_source)).scalars().all()
return {
"cycle": _distinct_text_values(cycle_values),
"fuel_type": _distinct_text_values(fuel_values),
"mod_source": _distinct_text_values(mod_values),
}
def _asset_form_options() -> dict[str, tuple[str, ...]]:
asset_type_values = db.session.execute(select(Asset.asset_type)).scalars().all()
return {
"asset_type": _distinct_text_values([*ASSET_TYPE_BASE_OPTIONS, *asset_type_values]),
}
def _max_variant_decimal(family: EngineFamily, attribute_name: str) -> Decimal | None:
values = [
getattr(variant, attribute_name)
for variant in family.variants
if getattr(variant, attribute_name) is not None
]
return max(values) if values else None
def _estimate_sl_thrust_kn(variant: EngineVariant) -> Decimal | None:
if variant.max_thrust_kn is None:
return None
if variant.sl_isp is None or variant.vac_isp in (None, Decimal("0")):
return None
return variant.max_thrust_kn * variant.sl_isp / variant.vac_isp
def _build_catalog_row(family: EngineFamily) -> EngineCatalogRow:
sl_thrust_values = [
value for value in (_estimate_sl_thrust_kn(variant) for variant in family.variants) if value is not None
]
return EngineCatalogRow(
family=family,
config_count=len(family.variants),
fuel_types=_distinct_variant_values(family, "fuel_type"),
mod_sources=_distinct_variant_values(family, "mod_source"),
mass_t=_max_variant_decimal(family, "mass_t"),
twr=_max_variant_decimal(family, "twr"),
vac_thrust_kn=_max_variant_decimal(family, "max_thrust_kn"),
sl_thrust_kn=max(sl_thrust_values) if sl_thrust_values else None,
vac_isp=_max_variant_decimal(family, "vac_isp"),
sl_isp=_max_variant_decimal(family, "sl_isp"),
)
def _build_catalog_rows() -> list[EngineCatalogRow]:
families = db.session.execute(
_engine_family_statement().order_by(EngineFamily.engine_name.asc())
).scalars().unique().all()
return [_build_catalog_row(family) for family in families]
def _matches_catalog_search(row: EngineCatalogRow, search_term: str) -> bool:
normalized = search_term.casefold()
search_targets = [
row.family.engine_name,
row.family.part_name or "",
row.family.cycle or "",
*row.fuel_types,
*row.mod_sources,
*(variant.config_name or "" for variant in row.family.variants),
]
return any(normalized in target.casefold() for target in search_targets if target)
def _decimal_sort_key(value: Decimal | None, descending: bool = False) -> tuple[int, Decimal]:
if value is None:
return (1, Decimal("0"))
return (0, -value if descending else value)
def _sort_catalog_rows(rows: list[EngineCatalogRow], sort_option: str) -> str:
allowed = {option["value"] for option in ENGINE_SORT_OPTIONS}
normalized = sort_option if sort_option in allowed else "engine_name_asc"
if normalized == "engine_name_desc":
rows.sort(key=lambda row: row.family.engine_name.casefold(), reverse=True)
return normalized
if normalized == "vac_thrust_asc":
rows.sort(
key=lambda row: (
_decimal_sort_key(row.vac_thrust_kn),
row.family.engine_name.casefold(),
)
)
return normalized
if normalized == "vac_thrust_desc":
rows.sort(
key=lambda row: (
_decimal_sort_key(row.vac_thrust_kn, descending=True),
row.family.engine_name.casefold(),
)
)
return normalized
if normalized == "sl_thrust_asc":
rows.sort(
key=lambda row: (
_decimal_sort_key(row.sl_thrust_kn),
row.family.engine_name.casefold(),
)
)
return normalized
if normalized == "sl_thrust_desc":
rows.sort(
key=lambda row: (
_decimal_sort_key(row.sl_thrust_kn, descending=True),
row.family.engine_name.casefold(),
)
)
return normalized
if normalized == "mass_t_asc":
rows.sort(
key=lambda row: (
_decimal_sort_key(row.mass_t),
row.family.engine_name.casefold(),
)
)
return normalized
if normalized == "mass_t_desc":
rows.sort(
key=lambda row: (
_decimal_sort_key(row.mass_t, descending=True),
row.family.engine_name.casefold(),
)
)
return normalized
if normalized == "vac_isp_asc":
rows.sort(
key=lambda row: (
_decimal_sort_key(row.vac_isp),
row.family.engine_name.casefold(),
)
)
return normalized
if normalized == "vac_isp_desc":
rows.sort(
key=lambda row: (
_decimal_sort_key(row.vac_isp, descending=True),
row.family.engine_name.casefold(),
)
)
return normalized
if normalized == "sl_isp_asc":
rows.sort(
key=lambda row: (
_decimal_sort_key(row.sl_isp),
row.family.engine_name.casefold(),
)
)
return normalized
if normalized == "sl_isp_desc":
rows.sort(
key=lambda row: (
_decimal_sort_key(row.sl_isp, descending=True),
row.family.engine_name.casefold(),
)
)
return normalized
rows.sort(key=lambda row: row.family.engine_name.casefold())
return normalized
def _build_engine_sort_links(query_args: dict[str, object], current_sort: str) -> list[dict[str, object]]:
columns = []
for column in ENGINE_SORT_COLUMNS:
asc_value = f"{column['key']}_asc"
desc_value = f"{column['key']}_desc"
columns.append(
{
"key": column["key"],
"label": column["label"],
"asc_url": _page_url("web.engine_list", {**query_args, "sort": asc_value}, 1),
"desc_url": _page_url("web.engine_list", {**query_args, "sort": desc_value}, 1),
"asc_active": current_sort == asc_value,
"desc_active": current_sort == desc_value,
}
)
return columns
def _sort_communications(rows: list[CommunicationPart], sort_option: str) -> str:
allowed = {option["value"] for option in COMM_SORT_OPTIONS}
normalized = sort_option if sort_option in allowed else "part_name"
if normalized == "range_desc":
rows.sort(
key=lambda item: (
_decimal_sort_key(item.range_km, descending=True),
item.part_name.casefold(),
)
)
return normalized
if normalized == "mass_desc":
rows.sort(
key=lambda item: (
_decimal_sort_key(item.mass_t, descending=True),
item.part_name.casefold(),
)
)
return normalized
rows.sort(key=lambda item: item.part_name.casefold())
return normalized
def _sort_tanks(rows: list[TankSpec], sort_option: str) -> str:
allowed = {option["value"] for option in TANK_SORT_OPTIONS}
normalized = sort_option if sort_option in allowed else "tank_name"
if normalized == "volume_desc":
rows.sort(
key=lambda item: (
_decimal_sort_key(item.tank_volume_l, descending=True),
item.tank_name.casefold(),
)
)
return normalized
if normalized == "ratio_desc":
rows.sort(
key=lambda item: (
_decimal_sort_key(item.mass_ratio, descending=True),
item.tank_name.casefold(),
)
)
return normalized
rows.sort(key=lambda item: item.tank_name.casefold())
return normalized
def _sort_vehicles(rows: list[VehicleCost], sort_option: str) -> str:
allowed = {option["value"] for option in VEHICLE_SORT_OPTIONS}
normalized = sort_option if sort_option in allowed else "vehicle_name"
if normalized == "launch_price_desc":
rows.sort(
key=lambda item: (
item.launch_price is None,
-(item.launch_price or 0),
item.vehicle_name.casefold(),
)
)
return normalized
if normalized == "launch_price_asc":
rows.sort(
key=lambda item: (
item.launch_price is None,
item.launch_price or 0,
item.vehicle_name.casefold(),
)
)
return normalized
rows.sort(key=lambda item: item.vehicle_name.casefold())
return normalized
def _sort_assets(rows: list[Asset], sort_option: str) -> str:
allowed = {option["value"] for option in ASSET_SORT_OPTIONS}
normalized = sort_option if sort_option in allowed else "name_asc"
if normalized == "updated_desc":
rows.sort(
key=lambda item: (
_as_utc(item.updated_at) or datetime.min.replace(tzinfo=timezone.utc),
item.name.casefold(),
),
reverse=True,
)
elif normalized == "asset_type_asc":
rows.sort(key=lambda item: ((item.asset_type or "").casefold(), item.name.casefold()))
else:
rows.sort(key=lambda item: item.name.casefold())
rows.sort(key=lambda item: bool(item.is_retired))
return normalized
def _classify_asset_entry(entry: AssetLogEntry, simulation_time: datetime) -> dict[str, str]:
start_at = _as_utc(entry.start_at)
end_at = _as_utc(entry.end_at)
if entry.entry_kind == "state":
if start_at and start_at <= simulation_time and (end_at is None or simulation_time < end_at):
return {"label": "Current Interval", "tone": "active"}
if start_at and simulation_time < start_at:
return {"label": "Upcoming Interval", "tone": "planned"}
return {"label": "Past Interval", "tone": "completed"}
if start_at and simulation_time < start_at:
return {"label": "Upcoming Event", "tone": "planned"}
return {"label": "Past Event", "tone": "completed"}
def _entry_matches_date_range(
entry: AssetLogEntry,
range_start: datetime | None,
range_end: datetime | None,
) -> bool:
start_at = _as_utc(entry.start_at)
if start_at is None:
return False
window_start = range_start or datetime.min.replace(tzinfo=timezone.utc)
window_end = range_end or datetime.max.replace(tzinfo=timezone.utc)
if entry.entry_kind == "event":
return window_start <= start_at <= window_end
effective_end = _as_utc(entry.end_at) or datetime.max.replace(tzinfo=timezone.utc)
return start_at <= window_end and effective_end >= window_start
def _build_asset_future_events(
asset: Asset,
simulation_time: datetime,
active_state: AssetLogEntry | None,
) -> list[dict[str, object]]:
future_events: list[dict[str, object]] = []
if active_state is not None:
for state_node in sorted(
active_state.state_nodes,
key=lambda item: _as_utc(item.at_time) or simulation_time,
):
node_time = _as_utc(state_node.at_time)
if node_time is None or node_time <= simulation_time:
continue
future_events.append(
{
"sort_at": node_time,
"title": state_node.title,
"when": _format_datetime_display(state_node.at_time),
"scope": active_state.title or asset.name,
"summary": state_node.detail or "在所属 State Interval 中编辑该节点。",
"kind_label": "State Node",
"status_label": "Upcoming Node",
"state_labels": _split_multi_value_text(_entry_state_label(active_state, simulation_time)),
"mission_labels": _split_multi_value_text(active_state.mission_label),
"source_entry_id": active_state.id,
"action_label": "编辑所属区间",
"edit_hint": "State Node 需要在所属 State Interval 中修改。",
}
)
for entry in sorted(
asset.log_entries,
key=lambda item: _as_utc(item.start_at) or _default_simulation_time(),
):
start_at = _as_utc(entry.start_at)
if start_at is None or start_at <= simulation_time:
continue
status = _classify_asset_entry(entry, simulation_time)
future_events.append(
{
"sort_at": start_at,
"title": entry.title,
"when": _format_datetime_display(entry.start_at),
"scope": _entry_location(entry, simulation_time) or _display_multi_value_text(entry.mission_label) or asset.name,
"summary": entry.summary or "未填写摘要。",
"kind_label": "Event Point" if entry.entry_kind == "event" else "State Interval",
"status_label": status["label"],
"state_labels": _split_multi_value_text(_entry_state_label(entry, simulation_time)),
"mission_labels": _split_multi_value_text(entry.mission_label),
"source_entry_id": entry.id,
"action_label": "编辑条目",
"edit_hint": None,
}
)
future_events.sort(key=lambda item: (item["sort_at"], str(item["title"])))
return future_events
def _entry_location(entry: AssetLogEntry, simulation_time: datetime | None = None) -> str | None:
"""Derive location from the most recent state node at or before simulation_time."""
sim = simulation_time or _default_simulation_time()
if entry.state_nodes:
sorted_nodes = sorted(entry.state_nodes, key=lambda n: _as_utc(n.at_time) or _default_simulation_time())
for node in reversed(sorted_nodes):
node_at = _as_utc(node.at_time)
if node_at and node_at <= sim and node.target_location:
return node.target_location
return None
def _entry_state_label(entry: AssetLogEntry, simulation_time: datetime | None = None) -> str | None:
"""Derive state_label from the most recent state node at or before simulation_time."""
sim = simulation_time or _default_simulation_time()
if entry.state_nodes:
sorted_nodes = sorted(entry.state_nodes, key=lambda n: _as_utc(n.at_time) or _default_simulation_time())
for node in reversed(sorted_nodes):
node_at = _as_utc(node.at_time)
if node_at and node_at <= sim and node.state_label:
return node.state_label
return None
def _is_docking_event_active(event: DockingEvent, simulation_time: datetime) -> bool:
docked_at = _as_utc(event.docked_at)
undocked_at = _as_utc(event.undocked_at)
return bool(docked_at and docked_at <= simulation_time and (undocked_at is None or simulation_time < undocked_at))
def _asset_summary_link(asset: Asset, simulation_time: datetime) -> dict[str, object]:
snapshot = _build_asset_snapshot(asset, simulation_time, include_docking=False)
return {
"id": asset.id,
"name": asset.name,
"current_location": snapshot["current_location"],
}
def _build_docking_summary(asset: Asset, simulation_time: datetime) -> dict[str, object]:
target_events = db.session.execute(
select(DockingEvent)
.options(selectinload(DockingEvent.parent_asset))
.where(DockingEvent.child_asset_id == asset.id)
.order_by(DockingEvent.docked_at.desc())
).scalars().all()
target_event = next(
(event for event in target_events if _is_docking_event_active(event, simulation_time)),
None,
)
child_events = [
event
for event in db.session.execute(
select(DockingEvent)
.options(selectinload(DockingEvent.child_asset))
.where(DockingEvent.parent_asset_id == asset.id)
.order_by(DockingEvent.docked_at.desc())
).scalars().all()
if _is_docking_event_active(event, simulation_time)
]
docking_target = None
if target_event is not None and target_event.parent_asset is not None:
docking_target = {
**_asset_summary_link(target_event.parent_asset, simulation_time),
"docked_at": _format_datetime_display(target_event.docked_at),
"event_id": str(target_event.id),
"undocked_at": _format_datetime_input(target_event.undocked_at) if target_event.undocked_at else None,
}
docked_vehicles = []
for event in child_events:
if event.child_asset is not None:
item = _asset_summary_link(event.child_asset, simulation_time)
else:
item = {"id": None, "name": event.child_label or "External vehicle", "current_location": "-"}
item["docked_at"] = _format_datetime_display(event.docked_at)
item["event_id"] = str(event.id)
item["undocked_at"] = _format_datetime_input(event.undocked_at) if event.undocked_at else None
docked_vehicles.append(item)
return {
"docking_target": docking_target,
"docked_vehicles": docked_vehicles,
}
def _build_asset_snapshot(asset: Asset, simulation_time: datetime, *, include_docking: bool = True) -> dict[str, object]:
simulation_time = _as_utc(simulation_time) or _default_simulation_time()
entries = sorted(asset.log_entries, key=lambda item: _as_utc(item.start_at) or _default_simulation_time())
active_state = None
for entry in entries:
if entry.entry_kind != "state":
continue
start_at = _as_utc(entry.start_at)
end_at = _as_utc(entry.end_at)
if start_at and start_at <= simulation_time and (end_at is None or simulation_time < end_at):
active_state = entry
past_entries = [entry for entry in entries if (_as_utc(entry.start_at) or simulation_time) <= simulation_time]
last_entry = past_entries[-1] if past_entries else None
future_entries = [entry for entry in entries if (_as_utc(entry.start_at) or simulation_time) > simulation_time]
next_entry = future_entries[0] if future_entries else None
future_events = _build_asset_future_events(asset, simulation_time, active_state)
if active_state is not None:
current_state = _display_multi_value_text(_entry_state_label(active_state, simulation_time)) or active_state.title
current_location = _entry_location(active_state, simulation_time) or asset.home_region or "-"
current_mission = _display_multi_value_text(active_state.mission_label) or asset.program or "-"
state_window = f"{_format_datetime_display(active_state.start_at)}{_format_datetime_display(active_state.end_at) if active_state.end_at else 'Open'}"
else:
current_state = "No Active Interval" if last_entry is not None else "No Logged Timeline"
current_location = (
(_entry_location(last_entry, simulation_time) if last_entry is not None else None)
or asset.home_region
or "-"
)
current_mission = (
(_display_multi_value_text(last_entry.mission_label) if last_entry is not None else None)
or asset.program
or "-"
)
if last_entry is None:
state_window = "No entry covers selected Simulation Time"
elif last_entry.entry_kind == "state":
state_window = (
f"Last interval · {_format_datetime_display(last_entry.start_at)}"
f" → {_format_datetime_display(last_entry.end_at) if last_entry.end_at else 'Open'}"
)
else:
state_window = f"Last event · {_format_datetime_display(last_entry.start_at)}"
current_event = active_state.title if active_state is not None else (last_entry.title if last_entry is not None else "No Logged Timeline")
next_event_label = "No future event recorded"
if future_events:
next_event_label = f"{future_events[0]['title']} · {future_events[0]['when']}"
snapshot = {
"current_state": current_state,
"current_event": current_event,
"current_location": current_location,
"current_mission": current_mission,
"state_window": state_window,
"next_event": next_event_label,
"next_entry": next_entry,
"active_state": active_state,
"last_entry": last_entry,
"future_events": future_events,
"upcoming_events": future_events[:4],
}
if include_docking:
snapshot.update(_build_docking_summary(asset, simulation_time))
else:
snapshot.update({"docking_target": None, "docked_vehicles": []})
return snapshot
def _build_asset_catalog_row(asset: Asset, simulation_time: datetime) -> dict[str, object]:
snapshot = _build_asset_snapshot(asset, simulation_time)
return {
"asset": asset,
"is_retired": asset.is_retired,
"current_event": snapshot["current_event"],
"current_location": snapshot["current_location"],
"next_event": snapshot["next_event"],
"entry_count": len(asset.log_entries),
}
def _load_assets_with_logs() -> list[Asset]:
return db.session.execute(
select(Asset).options(selectinload(Asset.log_entries).selectinload(AssetLogEntry.state_nodes)).order_by(Asset.name.asc())
).scalars().all()
def _classify_asset_board_record(snapshot: dict[str, object]) -> dict[str, object]:
if snapshot["active_state"] is not None:
return {"label": "Active", "rank": 0}
if snapshot["next_entry"] is not None:
return {"label": "Planned", "rank": 1}
if snapshot["last_entry"] is not None:
return {"label": "Recorded", "rank": 2}
return {"label": "Unlogged", "rank": 3}
def _derive_status_group_title(snapshot: dict[str, object], record_status: dict[str, object]) -> str:
if snapshot["active_state"] is not None:
return str(snapshot["current_state"])
if record_status["label"] == "Planned":
return "Upcoming / Not Yet Active"
if record_status["label"] == "Recorded":
return "No Active Interval"
return "No Logged Timeline"
def _status_group_accent(group_title: str, record_label: str) -> str:
normalized = group_title.casefold()
if record_label == "Planned":
return "warning"
if "transit" in normalized or "transfer" in normalized or "cruise" in normalized:
return "warning"
if "station" in normalized or "dock" in normalized or "orbit" in normalized:
return "primary"
if "surface" in normalized or "port" in normalized or "outpost" in normalized or "base" in normalized:
return "neutral"
if "no active" in normalized or "no logged" in normalized:
return "soft"
if record_label == "Active":
return "primary"
if record_label == "Recorded":
return "soft"
return "neutral"
def _asset_board_summary(asset: Asset, snapshot: dict[str, object]) -> str:
summary_source = snapshot["active_state"] or snapshot["last_entry"] or snapshot["next_entry"]
if summary_source is not None and summary_source.summary:
return summary_source.summary
if asset.note:
return asset.note
if snapshot["last_entry"] is not None:
return f"Last logged entry: {snapshot['last_entry'].title}."
if snapshot["next_entry"] is not None:
return f"Next recorded entry: {snapshot['next_entry'].title}."
return "No summary recorded."
def _normalize_location_group(location: str) -> str:
if not location or location == "-":
return "Unknown Location"
return location
def _build_mission_board_rows(assets: list[Asset], simulation_time: datetime) -> list[dict[str, object]]:
rows = []
for asset in assets:
snapshot = _build_asset_snapshot(asset, simulation_time)
record_status = _classify_asset_board_record(snapshot)
state_group = _derive_status_group_title(snapshot, record_status)
current_location = _normalize_location_group(str(snapshot["current_location"]))
state_display = state_group if snapshot["active_state"] is None else str(snapshot["current_state"])
rows.append(
{
"asset": asset,
"snapshot": snapshot,
"record_status": record_status,
"name": asset.name,
"asset_id": asset.id,
"asset_type": asset.asset_type,
"location": current_location,
"mission": snapshot["current_mission"],
"state_window": snapshot["state_window"],
"next_event": snapshot["next_event"],
"summary": _asset_board_summary(asset, snapshot),
"status_group": state_group,
"status_accent": _status_group_accent(state_group, str(record_status["label"])),
"state_display": state_display,
}
)
return rows
def _normalize_record_scope(raw_value: str | None) -> str:
allowed = {option["value"] for option in MISSION_BOARD_RECORD_SCOPE_OPTIONS}
normalized = _normalized_text(raw_value) or "all"
return normalized if normalized in allowed else "all"
def _matches_record_scope(row: dict[str, object], record_scope: str) -> bool:
record_label = row["record_status"]["label"]
if record_scope == "active":
return record_label == "Active"
if record_scope == "active_planned":
return record_label in {"Active", "Planned"}
if record_scope == "recorded":
return record_label == "Recorded"
return True
def _filter_mission_board_rows(
rows: list[dict[str, object]],
asset_type_filter: str,
location_filter: str,
record_scope: str,
) -> list[dict[str, object]]:
filtered_rows = []
for row in rows:
if asset_type_filter and asset_type_filter != (row["asset_type"] or ""):
continue
if location_filter and location_filter != row["location"]:
continue
if not _matches_record_scope(row, record_scope):
continue
filtered_rows.append(row)
return filtered_rows
def _build_status_board_groups(rows: list[dict[str, object]]) -> list[dict[str, object]]:
groups_by_title: dict[str, dict[str, object]] = {}
for row in rows:
group = groups_by_title.setdefault(
row["status_group"],
{
"title": row["status_group"],
"accent": row["status_accent"],
"rank": row["record_status"]["rank"],
"items": [],
},
)
group["rank"] = min(group["rank"], row["record_status"]["rank"])
group["items"].append(
{
"asset_id": row["asset_id"],
"name": row["name"],
"asset_type": row["asset_type"],
"location": row["location"],
"mission": row["mission"],
"state_window": row["state_window"],
"next_event": row["next_event"],
"summary": row["summary"],
"record_status": row["record_status"]["label"],
"record_sort_rank": row["record_status"]["rank"],
}
)
groups = list(groups_by_title.values())
for group in groups:
group["items"].sort(key=lambda item: (item["record_sort_rank"], item["name"].casefold()))
groups.sort(key=lambda item: (item["rank"], item["title"].casefold()))
return groups
def _build_location_board_groups(rows: list[dict[str, object]]) -> list[dict[str, object]]:
groups_by_location: dict[str, dict[str, object]] = {}
for row in rows:
group = groups_by_location.setdefault(
row["location"],
{
"title": row["location"],
"subtitle": "Derived from active interval or latest logged event.",
"items": [],
},
)
group["items"].append(
{
"asset_id": row["asset_id"],
"name": row["name"],
"asset_type": row["asset_type"],
"state": row["state_display"],
"mission": row["mission"],
"location": row["location"],
"next_event": row["next_event"],
"record_sort_rank": row["record_status"]["rank"],
}
)
groups = list(groups_by_location.values())
for group in groups:
item_count = len(group["items"])
if group["title"] == "Unknown Location":
group["subtitle"] = "No active interval or logged location is available yet."
elif item_count == 1:
group["subtitle"] = "1 asset currently attributed to this location."
else:
group["subtitle"] = f"{item_count} assets currently attributed to this location."
group["items"].sort(key=lambda item: (item["record_sort_rank"], item["name"].casefold()))
groups.sort(key=lambda item: (item["title"] == "Unknown Location", item["title"].casefold()))
return groups
def _build_board_upcoming_events(rows: list[dict[str, object]], simulation_time: datetime) -> list[dict[str, str]]:
future_entries = []
for row in rows:
asset = row["asset"]
for entry in asset.log_entries:
start_at = _as_utc(entry.start_at)
if start_at is None or start_at <= simulation_time:
continue
future_entries.append(
{
"when": _format_datetime_display(entry.start_at),
"title": f"{asset.name} · {entry.title}",
"scope": _entry_location(entry) or row["location"],
"sort_at": start_at,
}
)
future_entries.sort(key=lambda item: item["sort_at"])
return [
{"when": item["when"], "title": item["title"], "scope": item["scope"]}
for item in future_entries[:6]
]
def _build_board_metrics(all_rows: list[dict[str, object]], visible_rows: list[dict[str, object]], simulation_time: datetime) -> list[dict[str, str]]:
upcoming_window_limit = simulation_time + timedelta(days=30)
upcoming_30d_count = 0
for row in visible_rows:
for entry in row["asset"].log_entries:
start_at = _as_utc(entry.start_at)
if start_at is not None and simulation_time < start_at <= upcoming_window_limit:
upcoming_30d_count += 1
return [
{"label": "Total Assets", "value": str(len(all_rows))},
{"label": "Visible Cards", "value": str(len(visible_rows))},
{"label": "30-Day Events", "value": str(upcoming_30d_count)},
]
def _build_asset_catalog_metrics(all_assets: list[Asset], visible_assets: list[Asset], simulation_time: datetime) -> list[dict[str, str]]:
upcoming_window_limit = simulation_time + timedelta(days=30)
upcoming_30d_count = 0
for asset in visible_assets:
for entry in asset.log_entries:
start_at = _as_utc(entry.start_at)
if start_at is not None and simulation_time < start_at <= upcoming_window_limit:
upcoming_30d_count += 1
return [
{"label": "Total Assets", "value": str(len(all_assets))},
{"label": "Current Results", "value": str(len(visible_assets))},
{"label": "30-Day Events", "value": str(upcoming_30d_count)},
]
def _build_header_hidden_fields(values: dict[str, object]) -> list[dict[str, str]]:
hidden_fields: list[dict[str, str]] = []
for name, value in values.items():
if value in (None, "", [], (), set()):
continue
if isinstance(value, (list, tuple, set)):
for item in value:
if item in (None, ""):
continue
hidden_fields.append({"name": name, "value": str(item)})
continue
hidden_fields.append({"name": name, "value": str(value)})
return hidden_fields
def _mission_board_query_args(
simulation_time: datetime,
asset_type_filter: str,
location_filter: str,
record_scope: str,
) -> dict[str, str]:
params = {
"sim_time": _format_datetime_input(simulation_time),
"record_scope": record_scope,
}
if asset_type_filter:
params["asset_type"] = asset_type_filter
if location_filter:
params["location"] = location_filter
return params
def _mission_board_context(
endpoint: str,
simulation_time: datetime,
asset_type_filter: str,
location_filter: str,
record_scope: str,
all_rows: list[dict[str, object]],
visible_rows: list[dict[str, object]],
) -> dict[str, object]:
query_args = _mission_board_query_args(simulation_time, asset_type_filter, location_filter, record_scope)
return {
"simulation_time": _format_datetime_display(simulation_time),
"simulation_time_display": _format_datetime_display(simulation_time),
"simulation_time_input": _format_datetime_input(simulation_time),
"metrics": _build_board_metrics(all_rows, visible_rows, simulation_time),
"preview_eyebrow": "Mission Ops",
"header_time_action": url_for(endpoint),
"header_time_field_id": f"{endpoint.rsplit('.', 1)[-1]}-header-sim-time",
"header_time_submit_label": "Apply Time",
"header_hidden_fields": _build_header_hidden_fields(
{
"asset_type": asset_type_filter,
"location": location_filter,
"record_scope": record_scope,
}
),
"upcoming_events": _build_board_upcoming_events(visible_rows, simulation_time),
"asset_type_filter": asset_type_filter,
"location_filter": location_filter,
"record_scope": record_scope,
"asset_type_options": sorted({row["asset_type"] for row in all_rows if row["asset_type"]}, key=str.casefold),
"location_options": sorted({row["location"] for row in all_rows if row["location"]}, key=str.casefold),
"record_scope_options": MISSION_BOARD_RECORD_SCOPE_OPTIONS,
"time_shift_urls": {
"minus_7": url_for(endpoint, **{**query_args, "sim_time": _format_datetime_input(simulation_time - timedelta(days=7))}),
"plus_7": url_for(endpoint, **{**query_args, "sim_time": _format_datetime_input(simulation_time + timedelta(days=7))}),
},
"reset_url": url_for(endpoint
),
"board_nav_urls": {
"status": url_for("web.mission_status_board_preview", **query_args),
"location": url_for("web.mission_location_board_preview", **query_args),
"mission": url_for("web.mission_list"
),
"asset": url_for("web.asset_list"
),
"timeline": url_for("web.mission_timeline_preview"
),
},
}
def _mission_ops_nav_urls(simulation_time: datetime) -> dict[str, str]:
simulation_time_input = _format_datetime_input(simulation_time)
return {
"status": url_for("web.mission_status_board_preview"
),
"location": url_for("web.mission_location_board_preview"
),
"mission": url_for("web.mission_list"
),
"asset": url_for("web.asset_list"
),
"timeline": url_for("web.mission_timeline_preview"
),
}
def _month_start(value: datetime) -> datetime:
normalized = _as_utc(value) or _default_simulation_time()
return normalized.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
def _month_end(value: datetime) -> datetime:
start = _month_start(value)
if start.month == 12:
return start.replace(year=start.year + 1, month=1)
return start.replace(month=start.month + 1)
def _month_delta(start: datetime, end: datetime) -> int:
return (end.year - start.year) * 12 + (end.month - start.month)
def _day_start(value: datetime) -> datetime:
normalized = _as_utc(value) or _default_simulation_time()
return normalized.replace(hour=0, minute=0, second=0, microsecond=0)
def _days_in_month(year: int, month: int) -> int:
if month == 12:
next_month = datetime(year + 1, 1, 1)
else:
next_month = datetime(year, month + 1, 1)
return (next_month - datetime(year, month, 1)).days
def _timeline_add_units(value: datetime, scale: str, count: int = 1) -> datetime:
normalized = _as_utc(value) or _default_simulation_time()
if scale == "day":
return normalized + timedelta(days=count)
if scale == "year":
target_year = normalized.year + count
target_day = min(normalized.day, _days_in_month(target_year, normalized.month))
return normalized.replace(year=target_year, day=target_day)
total_month = (normalized.year * 12 + (normalized.month - 1)) + count
target_year = total_month // 12
target_month = (total_month % 12) + 1
target_day = min(normalized.day, _days_in_month(target_year, target_month))
return normalized.replace(year=target_year, month=target_month, day=target_day)
def _timeline_bucket_start(value: datetime, scale: str) -> datetime:
normalized = _as_utc(value) or _default_simulation_time()
if scale == "year":
return normalized.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
if scale == "day":
return _day_start(normalized)
return _month_start(normalized)
def _timeline_next_bucket(value: datetime, scale: str) -> datetime:
bucket_start = _timeline_bucket_start(value, scale)
if scale == "year":
return bucket_start.replace(year=bucket_start.year + 1)
if scale == "day":
return bucket_start + timedelta(days=1)
return _month_end(bucket_start)
def _timeline_bucket_delta(start: datetime, end: datetime, scale: str) -> int:
start_bucket = _timeline_bucket_start(start, scale)
end_bucket = _timeline_bucket_start(end, scale)
if scale == "year":
return end_bucket.year - start_bucket.year
if scale == "day":
return (end_bucket - start_bucket).days
return _month_delta(start_bucket, end_bucket)
def _resolve_timeline_scale(raw_value: str | None, range_start: datetime, range_end: datetime) -> str:
allowed = {option["value"] for option in TIMELINE_SCALE_OPTIONS}
normalized = _normalized_text(raw_value)
if normalized in allowed:
return str(normalized)
total_days = max(1, int((_timeline_bucket_start(range_end, "day") - _timeline_bucket_start(range_start, "day")).days) + 1)
if total_days <= 120:
return "day"
if total_days <= 365 * 4:
return "month"
return "year"
def _resolve_timeline_boundary(raw_value: str | None, fallback: datetime) -> datetime:
try:
return _parse_datetime(raw_value, "Timeline Boundary") or fallback
except ValueError:
return fallback
def _timeline_default_range(rows: list[dict[str, object]], simulation_time: datetime) -> tuple[datetime, datetime]:
return _timeline_default_range_for_scale(rows, simulation_time, "month")
def _timeline_default_range_for_scale(rows: list[dict[str, object]], simulation_time: datetime, scale: str) -> tuple[datetime, datetime]:
start_timestamps = []
effective_end_timestamps = []
for row in rows:
for entry in row["asset"].log_entries:
start_at = _as_utc(entry.start_at)
end_at = _as_utc(entry.end_at)
if start_at is not None:
start_timestamps.append(start_at)
effective_end_timestamps.append(end_at or start_at)
all_timestamps = [*start_timestamps, *effective_end_timestamps]
if not all_timestamps:
fallback_start = _day_start(simulation_time)
return fallback_start, _timeline_add_units(fallback_start, scale)
earliest_start = min(start_timestamps or all_timestamps)
latest_finish = max(effective_end_timestamps or all_timestamps)
return earliest_start, _timeline_add_units(latest_finish, scale)
def _timeline_scale_labels(start_at: datetime, end_at: datetime, scale: str) -> list[str]:
labels = []
cursor = _timeline_bucket_start(start_at, scale)
boundary = _timeline_bucket_start(end_at, scale)
while cursor <= boundary:
if scale == "year":
labels.append(cursor.strftime("%Y"))
elif scale == "day":
labels.append(cursor.strftime("%Y-%m-%d"))
else:
labels.append(cursor.strftime("%Y-%m"))
cursor = _timeline_next_bucket(cursor, scale)
return labels
def _timeline_range_display(start_at: datetime, end_at: datetime, scale: str) -> str:
start_bucket = _timeline_bucket_start(start_at, scale)
end_bucket = _timeline_bucket_start(end_at, scale)
if scale == "year":
return f"{start_bucket.strftime('%Y')} to {end_bucket.strftime('%Y')}"
if scale == "day":
return f"{start_bucket.strftime('%Y-%m-%d')} to {end_bucket.strftime('%Y-%m-%d')}"
return f"{start_bucket.strftime('%Y-%m')} to {end_bucket.strftime('%Y-%m')}"
def _timeline_column_width(scale: str) -> int:
if scale == "day":
return 48
if scale == "year":
return 84
return 72
def _timeline_segment_tone(entry: AssetLogEntry, simulation_time: datetime) -> str:
title = (entry.title or "").casefold()
location = (_entry_location(entry, simulation_time) or "").casefold()
tone = "neutral"
if any(token in title for token in ("transfer", "transit", "outbound", "return", "departure")):
tone = "warning"
elif any(token in title for token in ("operation", "ops", "orbit", "station", "trial", "expedition")):
tone = "primary"
elif any(token in title for token in ("maintenance", "upgrade", "construction", "fitting", "reconfig")):
tone = "soft"
elif any(token in location for token in ("mars", "europa", "jupiter", "saturn", "neptune", "surface", "orbit")):
tone = "primary"
start_at = _as_utc(entry.start_at)
end_at = _as_utc(entry.end_at)
if start_at is not None and start_at <= simulation_time and (end_at is None or simulation_time < end_at):
return f"{tone} current"
return tone
def _entry_overlaps_timeline(entry: AssetLogEntry, range_start: datetime, range_end: datetime) -> bool:
start_at = _as_utc(entry.start_at)
end_at = _as_utc(entry.end_at)
if start_at is None:
return False
if entry.entry_kind == "event":
return range_start <= start_at <= range_end
effective_end = end_at or range_end
return start_at <= range_end and effective_end > range_start
def _timeline_entry_edit_url(asset_id: object, entry_id: object, simulation_time: datetime) -> str:
return url_for(
"web.asset_entry_edit",
asset_id=asset_id,
entry_id=entry_id,
)
def _timeline_state_windows(asset: Asset) -> dict[object, tuple[datetime, datetime | None]]:
state_entries = [
entry
for entry in sorted(asset.log_entries, key=lambda item: _as_utc(item.start_at) or _default_simulation_time())
if entry.entry_kind == "state" and _as_utc(entry.start_at) is not None
]
windows: dict[object, tuple[datetime, datetime | None]] = {}
for entry in state_entries:
start_at = _as_utc(entry.start_at)
if start_at is None:
continue
windows[entry.id] = (start_at, _as_utc(entry.end_at))
return windows
def _timeline_state_is_active(
entry: AssetLogEntry,
at_time: datetime,
state_windows: dict[object, tuple[datetime, datetime | None]],
) -> bool:
start_at, end_at = state_windows.get(entry.id, (_as_utc(entry.start_at) or at_time, _as_utc(entry.end_at)))
return start_at <= at_time and (end_at is None or at_time < end_at)
def _timeline_state_overlaps(
entry: AssetLogEntry,
range_start: datetime,
range_end: datetime,
state_windows: dict[object, tuple[datetime, datetime | None]],
) -> bool:
start_at, end_at = state_windows.get(entry.id, (_as_utc(entry.start_at) or range_start, _as_utc(entry.end_at)))
effective_end = end_at or range_end
return start_at <= range_end and effective_end > range_start
def _timeline_position(value: datetime, bucket_origin: datetime, scale: str, bucket_count: int) -> float:
normalized = _as_utc(value) or bucket_origin
if normalized <= bucket_origin:
return 0.0
bucket_start = _timeline_bucket_start(normalized, scale)
bucket_end = _timeline_next_bucket(bucket_start, scale)
bucket_index = _timeline_bucket_delta(bucket_origin, bucket_start, scale)
bucket_duration = max((bucket_end - bucket_start).total_seconds(), 1.0)
fraction = max(0.0, min(1.0, (normalized - bucket_start).total_seconds() / bucket_duration))
return max(0.0, min(float(bucket_count), bucket_index + fraction))
def _timeline_percent(position: float, bucket_count: int) -> str:
if bucket_count <= 0:
return "0%"
return f"{(position / bucket_count) * 100:.6f}%"
def _assign_timeline_tracks(
items: list[dict[str, object]],
*,
start_key: str,
end_key: str,
minimum_span: float,
gap: float = 0.0,
span_key: str | None = None,
) -> int:
if not items:
return 1
track_ends: list[float] = []
for item in items:
start_position = float(item[start_key])
item_span = minimum_span
if span_key is not None:
span_value = item.get(span_key)
if span_value is not None:
item_span = max(item_span, float(span_value))
end_position = max(float(item[end_key]), start_position + item_span)
track_index = 0
while track_index < len(track_ends) and start_position < track_ends[track_index] + gap:
track_index += 1
if track_index == len(track_ends):
track_ends.append(end_position)
else:
track_ends[track_index] = end_position
item["track"] = track_index
return max(1, len(track_ends))
def _timeline_state_track_settings(scale: str) -> tuple[float, float]:
if scale == "year":
return 0.9, 0.04
if scale == "day":
return 0.18, 0.02
return 0.24, 0.03
def _timeline_event_track_settings(scale: str) -> tuple[float, float]:
label_span = 110 / max(_timeline_column_width(scale), 1)
if scale == "year":
return max(0.95, label_span), 0.08
if scale == "day":
return max(1.75, label_span), 0.04
return max(1.35, label_span), 0.06
def _timeline_event_label_width_px(label: str, scale: str) -> int:
weighted_length = 0.0
for character in label.strip():
if character.isspace():
weighted_length += 0.3
elif ord(character) < 128:
weighted_length += 0.85
else:
weighted_length += 1.55
if scale == "year":
minimum_width, maximum_width = 110, 190
elif scale == "day":
minimum_width, maximum_width = 150, 260
else:
minimum_width, maximum_width = 132, 220
estimated_width = int(28 + weighted_length * 7)
return max(minimum_width, min(maximum_width, estimated_width))
def _timeline_event_label_span(width_px: int, scale: str) -> float:
return width_px / max(_timeline_column_width(scale), 1)
def _timeline_state_lane_height(track_count: int) -> int:
return max(1, track_count) * 38 + 22
def _timeline_state_segment_center(track_index: int) -> int:
return 25 + max(track_index, 0) * 38
def _timeline_event_node_anchor(track_index: int) -> int:
return 23 + max(track_index, 0) * 54
def _timeline_state_node_connector_height(
state_track_count: int,
state_track_index: int,
event_track_index: int,
) -> int:
return (
_timeline_state_lane_height(state_track_count)
+ 8
+ _timeline_event_node_anchor(event_track_index)
- _timeline_state_segment_center(state_track_index)
)
def _build_timeline_rows(
rows: list[dict[str, object]],
simulation_time: datetime,
range_start: datetime,
range_end: datetime,
scale: str,
) -> tuple[list[dict[str, object]], dict[str, int]]:
bucket_origin = _timeline_bucket_start(range_start, scale)
bucket_count = len(_timeline_scale_labels(range_start, range_end, scale))
timeline_rows = []
state_count = 0
event_count = 0
for row in rows:
asset = row["asset"]
state_windows = _timeline_state_windows(asset)
segments = []
events = []
for entry in sorted(asset.log_entries, key=lambda item: _as_utc(item.start_at) or range_start):
if entry.entry_kind == "state":
if not _timeline_state_overlaps(entry, range_start, range_end, state_windows):
continue
state_start_at, state_end_at = state_windows.get(entry.id, (_as_utc(entry.start_at) or range_start, _as_utc(entry.end_at)))
start_at = max(state_start_at, range_start)
exclusive_end = state_end_at
if exclusive_end is not None:
exclusive_end = exclusive_end - timedelta(microseconds=1)
end_at = min(exclusive_end or range_end, range_end)
if end_at < start_at:
end_at = start_at
actual_start_position = _timeline_position(start_at, bucket_origin, scale, bucket_count)
actual_end_position = _timeline_position(end_at, bucket_origin, scale, bucket_count)
if actual_end_position <= actual_start_position:
actual_end_position = min(float(bucket_count), actual_start_position + 0.02)
state_count += 1
# Map location to celestial body color
loc = (_entry_location(entry, simulation_time) or "").lower()
if "jupiter" in loc or "europa" in loc or "ganymede" in loc or "callisto" in loc:
body_color = "#c9b07c" # Jupiter gold
elif "saturn" in loc or "titan" in loc:
body_color = "#e0c97f" # Saturn beige
elif "mars" in loc:
body_color = "#c1440e" # Mars red
elif "earth" in loc or "leo" in loc or "geo" in loc:
body_color = "#4da6ff" # Earth blue
elif "neptune" in loc or "triton" in loc:
body_color = "#3f54ba" # Neptune blue
elif "sun" in loc:
body_color = "#FDB813" # Sun yellow
elif "transfer" in loc:
body_color = "#94A3B8" # Gray
else:
body_color = "#94A3B8" # Default gray
segments.append(
{
"entry_id": entry.id,
"label": entry.title,
"tone": _timeline_segment_tone(entry, simulation_time),
"url": _timeline_entry_edit_url(entry.asset_id, entry.id, simulation_time),
"start_position": actual_start_position,
"body_end_position": actual_end_position,
"display_end_position": actual_end_position,
"start_at": _format_datetime_display(entry.start_at),
"end_at": _format_datetime_display(entry.end_at) if entry.end_at else "ongoing",
"state_node_count": len([n for n in entry.state_nodes if _as_utc(n.at_time) and range_start <= _as_utc(n.at_time) <= range_end]),
"body_color": body_color,
"start_iso": _format_datetime_input(entry.start_at),
"end_iso": _format_datetime_input(entry.end_at) if entry.end_at else "",
}
)
for state_node in sorted(entry.state_nodes, key=lambda item: _as_utc(item.at_time) or range_start):
node_at = _as_utc(state_node.at_time)
if node_at is None or node_at < range_start or node_at > range_end:
continue
full_label_parts = [state_node.title, entry.title]
if state_node.detail:
full_label_parts.append(state_node.detail)
event_position = _timeline_position(node_at, bucket_origin, scale, bucket_count)
label_width_px = _timeline_event_label_width_px(state_node.title, scale)
label_span = _timeline_event_label_span(label_width_px, scale)
label_side = "left" if event_position >= bucket_count * 0.6 else "right"
track_start_position = max(0.0, event_position - label_span) if label_side == "left" else event_position
track_end_position = event_position if label_side == "left" else min(float(bucket_count), event_position + label_span)
events.append(
{
"label": state_node.title,
"full_label": " · ".join(full_label_parts),
"url": _timeline_entry_edit_url(asset.id, entry.id, simulation_time),
"position": event_position,
"at_iso": _format_datetime_input(state_node.at_time),
"is_state_node": True,
"label_width_px": label_width_px,
"label_span": label_span,
"label_side": label_side,
"track_start_position": track_start_position,
"track_end_position": track_end_position,
"state_node": True,
"parent_entry_id": entry.id,
}
)
event_count += 1
continue
if not _entry_overlaps_timeline(entry, range_start, range_end):
continue
event_position = _timeline_position(_as_utc(entry.start_at) or range_start, bucket_origin, scale, bucket_count)
label_width_px = _timeline_event_label_width_px(entry.title, scale)
label_span = _timeline_event_label_span(label_width_px, scale)
label_side = "left" if event_position >= bucket_count * 0.6 else "right"
track_start_position = max(0.0, event_position - label_span) if label_side == "left" else event_position
track_end_position = event_position if label_side == "left" else min(float(bucket_count), event_position + label_span)
events.append(
{
"label": entry.title,
"full_label": entry.title,
"url": _timeline_entry_edit_url(asset.id, entry.id, simulation_time),
"position": event_position,
"at_iso": _format_datetime_input(entry.start_at),
"is_state_node": False,
"label_width_px": label_width_px,
"label_span": label_span,
"label_side": label_side,
"track_start_position": track_start_position,
"track_end_position": track_end_position,
"state_node": False,
}
)
event_count += 1
segments.sort(key=lambda item: float(item["start_position"]))
events.sort(key=lambda item: float(item["position"]))
state_minimum_span, state_gap = _timeline_state_track_settings(scale)
event_minimum_span, event_gap = _timeline_event_track_settings(scale)
state_track_count = _assign_timeline_tracks(
segments,
start_key="start_position",
end_key="display_end_position",
minimum_span=state_minimum_span,
gap=state_gap,
)
segment_tracks = {segment["entry_id"]: int(segment["track"]) for segment in segments}
event_track_count = _assign_timeline_tracks(
events,
start_key="track_start_position",
end_key="track_end_position",
minimum_span=event_minimum_span,
gap=event_gap,
)
for segment in segments:
display_span = max(float(segment["display_end_position"]) - float(segment["start_position"]), 0.02)
body_span = max(float(segment["body_end_position"]) - float(segment["start_position"]), 0.02)
segment["left_pct"] = _timeline_percent(float(segment["start_position"]), bucket_count)
segment["track_width_pct"] = _timeline_percent(display_span, bucket_count)
segment["body_width_pct"] = f"{(body_span / display_span) * 100:.6f}%"
segment["compact"] = (scale == "year" and body_span < 0.45) or (state_track_count > 1 and body_span < 0.75)
for event in events:
event["left_pct"] = _timeline_percent(float(event["position"]), bucket_count)
if not event.get("state_node"):
continue
parent_track = segment_tracks.get(event["parent_entry_id"])
if parent_track is None:
continue
event["connector_height_px"] = f"{_timeline_state_node_connector_height(state_track_count, parent_track, int(event['track']))}px"
timeline_rows.append(
{
"asset_id": asset.id,
"name": asset.name,
"asset_type": asset.asset_type,
"current_state": row["state_display"],
"segments": segments,
"events": events,
"state_track_count": state_track_count,
"event_track_count": event_track_count,
}
)
timeline_rows.sort(key=lambda item: item["name"].casefold())
return timeline_rows, {"states": state_count, "events": event_count}
def _page_url(endpoint: str, query_args: dict[str, object], page_number: int) -> str:
params = {key: value for key, value in query_args.items() if value not in (None, "")}
params["page"] = page_number
return url_for(endpoint, **params)
def _page_markers(total_pages: int, current_page: int) -> list[int | None]:
if total_pages <= 9:
return list(range(1, total_pages + 1))
markers: list[int | None] = [1]
start = max(2, current_page - 2)
end = min(total_pages - 1, current_page + 2)
if start > 2:
markers.append(None)
markers.extend(range(start, end + 1))
if end < total_pages - 1:
markers.append(None)
markers.append(total_pages)
return markers
def _paginate(
items: list[Any],
endpoint: str,
query_args: dict[str, object],
requested_page: int,
) -> tuple[list[Any], dict[str, Any]]:
total_count = len(items)
total_pages = max(1, ceil(total_count / DEFAULT_PAGE_SIZE))
page = min(max(requested_page, 1), total_pages)
start_index = (page - 1) * DEFAULT_PAGE_SIZE
page_items = items[start_index : start_index + DEFAULT_PAGE_SIZE]
markers = _page_markers(total_pages, page)
page_links = []
for marker in markers:
if marker is None:
page_links.append({"ellipsis": True})
continue
page_links.append(
{
"ellipsis": False,
"number": marker,
"url": _page_url(endpoint, query_args, marker),
"current": marker == page,
}
)
context = {
"page": page,
"total_pages": total_pages,
"total_count": total_count,
"has_prev": page > 1,
"has_next": page < total_pages,
"prev_url": _page_url(endpoint, query_args, page - 1) if page > 1 else None,
"next_url": _page_url(endpoint, query_args, page + 1) if page < total_pages else None,
"page_links": page_links,
}
return page_items, context
def _entity_matches_query(values: list[str | None], search_term: str) -> bool:
normalized = search_term.casefold()
return any(normalized in (value or "").casefold() for value in values)
def _split_multi_value_text(value: str | None) -> list[str]:
if value is None:
return []
labels: list[str] = []
for part in str(value).split(";"):
cleaned = _normalized_text(part)
if cleaned is None or cleaned in labels:
continue
labels.append(cleaned)
return labels
def _normalize_multi_value_text(value: str | None) -> str | None:
labels = _split_multi_value_text(value)
if not labels:
return None
return "; ".join(labels)
def _display_multi_value_text(value: str | None, separator: str = " · ") -> str | None:
labels = _split_multi_value_text(value)
if not labels:
return None
return separator.join(labels)
def _mission_label_search_key(value: str | None) -> str:
return re.sub(r"[\s\-_]+", "", (value or "").casefold())
def _mission_label_matches_query(mission_label: str | None, search_term: str) -> bool:
if mission_label is None:
return False
raw_query = search_term.casefold().strip()
raw_label = mission_label.casefold().strip()
if not raw_query or not raw_label:
return False
normalized_query = _mission_label_search_key(search_term)
normalized_label = _mission_label_search_key(mission_label)
if normalized_query and normalized_query in normalized_label:
return True
if raw_query in raw_label:
return True
query_tokens = [token for token in re.split(r"[\s\-_]+", raw_query) if token]
label_tokens = [token for token in re.split(r"[\s\-_]+", raw_label) if token]
if query_tokens and all(
any(
token in label_token
or label_token in token
or SequenceMatcher(None, token, label_token).ratio() >= 0.86
for label_token in label_tokens
)
for token in query_tokens
):
return True
return False
@web_bp.get("/")
def dashboard() -> str:
simulation_time = _resolve_simulation_time(request.args.get("sim_time"))
simulation_time_input = _format_datetime_input(simulation_time)
# Load all assets with logs
assets = db.session.execute(
select(Asset).options(selectinload(Asset.log_entries)).order_by(Asset.name)
).scalars().all()
# Build snapshots
asset_rows = []
for asset in assets:
snapshot = _build_asset_snapshot(asset, simulation_time)
asset_rows.append({
"id": asset.id,
"name": asset.name,
"asset_type": asset.asset_type,
"is_retired": asset.is_retired,
"location": snapshot.get("current_location", "—"),
"mission": snapshot.get("current_mission", "—"),
"state": snapshot.get("current_state", "—"),
})
# Metrics
active = [a for a in asset_rows if not a["is_retired"]]
motherships = len([a for a in active if a["asset_type"] == "Exploration Mothership"])
stations = len([a for a in active if a["asset_type"] == "Station"])
total_assets = len(active)
# Status groups
state_groups = {
"Transfer": {"label": "In Transit", "css_class": "tf", "assets": []},
"Exploration": {"label": "Exploration", "css_class": "ex", "assets": []},
"Maintenance": {"label": "Maintenance", "css_class": "mt", "assets": []},
"Docked": {"label": "Docked / Surface", "css_class": "dk", "assets": []},
}
for a in active:
state = a["state"]
if state in state_groups:
state_groups[state]["assets"].append(a)
else:
state_groups["Docked"]["assets"].append(a)
status_groups = [g for g in state_groups.values() if g["assets"]]
# Upcoming events (30 days)
now = simulation_time
upcoming_events = []
for a in active:
snapshot = _build_asset_snapshot(a if hasattr(a, "log_entries") else assets[0], simulation_time)
for ev in snapshot.get("future_events", []):
ev_date = ev.get("date", "")
if ev_date and ev_date >= now.strftime("%Y-%m-%d"):
upcoming_events.append({
"date": ev_date,
"title": f"{a['name']}: {ev.get('title', '')}",
"within_30d": True,
})
upcoming_events.sort(key=lambda e: e["date"])
upcoming_events = upcoming_events[:5]
active_missions = len([a for a in active if a["state"] in ("Transfer", "Exploration")])
upcoming_count = len(upcoming_events)
upcoming_summary = f"{upcoming_count} events" if upcoming_count else "None imminent"
return render_template(
"dashboard.html",
title="任务总览",
active_page="overview",
simulation_time_display=_format_datetime_display(simulation_time),
simulation_time_input=simulation_time_input,
total_assets=total_assets,
mothership_count=motherships,
station_count=stations,
active_missions=active_missions,
upcoming_count=upcoming_count,
upcoming_summary=upcoming_summary,
status_groups=status_groups,
upcoming_events=upcoming_events,
)
@web_bp.get("/fuel-converter")
def fuel_converter() -> str:
return render_template(
"fuel_converter.html",
title="燃料换算",
active_page="converter",
fuel_factors=list_fuel_factors(),
)
@web_bp.get("/engines")
def engine_list() -> str:
search_term = request.args.get("q", "").strip()
fuel_filter = request.args.get("fuel_type", "").strip()
cycle_filter = request.args.get("cycle", "").strip()
mod_source_filter = request.args.get("mod_source", "").strip()
sort_option = request.args.get("sort", "engine_name_asc").strip()
requested_page = request.args.get("page", default=1, type=int) or 1
catalog_rows = _build_catalog_rows()
fuel_options = sorted({fuel for row in catalog_rows for fuel in row.fuel_types}, key=str.casefold)
cycle_options = sorted({row.family.cycle for row in catalog_rows if row.family.cycle}, key=str.casefold)
mod_source_options = sorted({source for row in catalog_rows for source in row.mod_sources}, key=str.casefold)
filtered_rows = []
for row in catalog_rows:
if search_term and not _matches_catalog_search(row, search_term):
continue
if fuel_filter and fuel_filter not in row.fuel_types:
continue
if cycle_filter and cycle_filter != (row.family.cycle or ""):
continue
if mod_source_filter and mod_source_filter not in row.mod_sources:
continue
filtered_rows.append(row)
normalized_sort = _sort_catalog_rows(filtered_rows, sort_option)
query_args = {
"q": search_term,
"fuel_type": fuel_filter,
"cycle": cycle_filter,
"mod_source": mod_source_filter,
}
page_rows, paging = _paginate(filtered_rows, "web.engine_list", query_args, requested_page)
sort_columns = _build_engine_sort_links(query_args, normalized_sort)
return render_template(
"engine_list.html",
title="引擎目录",
active_page="engines",
catalog_rows=page_rows,
search_term=search_term,
fuel_filter=fuel_filter,
cycle_filter=cycle_filter,
mod_source_filter=mod_source_filter,
sort_option=normalized_sort,
sort_columns=sort_columns,
fuel_options=fuel_options,
cycle_options=cycle_options,
mod_source_options=mod_source_options,
total_catalog_count=len(catalog_rows),
**paging,
)
@web_bp.route("/engines/new", methods=["GET", "POST"])
def engine_new() -> str | object:
family = EngineFamily()
variant = EngineVariant()
form_options = _engine_form_options()
if request.method == "POST":
try:
_apply_fields(family, request.form, FAMILY_FORM_FIELDS)
_apply_fields(variant, request.form, VARIANT_CREATE_FIELDS, prefix="initial-")
_normalize_variant_ignitions(variant)
variant.family = family
db.session.add(family)
db.session.add(variant)
db.session.commit()
except ValueError as exc:
db.session.rollback()
flash(str(exc), "error")
except IntegrityError:
db.session.rollback()
flash("创建失败:Engine 名称可能已存在。", "error")
else:
flash("引擎已创建。", "success")
return redirect(url_for("web.engine_detail", family_id=family.id))
return render_template(
"engine_create.html",
title="Create Engine",
family_fields=FAMILY_FORM_FIELDS,
variant_fields=VARIANT_CREATE_FIELDS,
form_options=form_options,
custom_option_value=CUSTOM_OPTION_VALUE,
)
@web_bp.get("/engines/<uuid:family_id>")
def engine_detail(family_id: object) -> str:
family = _load_engine_family_or_404(family_id)
variant_rows = [
{
"variant": variant,
"sl_thrust_kn": _estimate_sl_thrust_kn(variant),
"vac_thrust_kn": variant.max_thrust_kn,
}
for variant in family.variants
]
return render_template(
"engine_detail.html",
title=family.engine_name,
family=family,
variant_rows=variant_rows,
)
@web_bp.route("/engines/<uuid:family_id>/edit", methods=["GET", "POST"])
def engine_edit(family_id: object) -> str:
family = _load_engine_family_or_404(family_id)
form_options = _engine_form_options()
if request.method == "POST":
try:
_apply_fields(family, request.form, FAMILY_FORM_FIELDS)
for variant in family.variants:
_apply_fields(
variant,
request.form,
VARIANT_FORM_FIELDS,
prefix=f"variant-{variant.id}-",
)
_normalize_variant_ignitions(variant)
_refresh_variant_derived_fields(variant)
db.session.commit()
except ValueError as exc:
db.session.rollback()
flash(str(exc), "error")
except IntegrityError:
db.session.rollback()
flash("保存失败:Engine 名称必须唯一。", "error")
else:
flash("引擎数据已保存。", "success")
return redirect(url_for("web.engine_detail", family_id=family.id))
return render_template(
"engine_edit.html",
title=f"Edit · {family.engine_name}",
family=family,
family_fields=FAMILY_FORM_FIELDS,
variant_fields=VARIANT_FORM_FIELDS,
variant_create_fields=VARIANT_CREATE_FIELDS,
form_options=form_options,
custom_option_value=CUSTOM_OPTION_VALUE,
)
@web_bp.post("/engines/<uuid:family_id>/variants/add")
def engine_variant_add(family_id: object) -> object:
family = _load_engine_family_or_404(family_id)
variant = EngineVariant(family=family)
try:
_apply_fields(variant, request.form, VARIANT_CREATE_FIELDS, prefix="add-")
_normalize_variant_ignitions(variant)
db.session.add(variant)
db.session.commit()
except ValueError as exc:
db.session.rollback()
flash(str(exc), "error")
else:
flash("已新增配置项。", "success")
return redirect(url_for("web.engine_edit", family_id=family.id))
@web_bp.post("/engines/<uuid:family_id>/variants/<uuid:variant_id>/delete")
def engine_variant_delete(family_id: object, variant_id: object) -> object:
family = _load_engine_family_or_404(family_id)
variant = db.session.get(EngineVariant, variant_id)
if variant is None or variant.family_id != family.id:
abort(404)
db.session.delete(variant)
db.session.commit()
flash("配置项已删除。", "success")
return redirect(url_for("web.engine_edit", family_id=family.id))
@web_bp.post("/engines/<uuid:family_id>/delete")
def engine_delete(family_id: object) -> object:
family = _load_engine_family_or_404(family_id)
db.session.delete(family)
db.session.commit()
flash(f"已删除引擎:{family.engine_name}", "success")
return redirect(url_for("web.engine_list"))
@web_bp.get("/communications")
def communication_list() -> str:
search_term = request.args.get("q", "").strip()
source_filter = request.args.get("source", "").strip()
type_filter = request.args.get("antenna_type", "").strip()
sort_option = request.args.get("sort", "part_name").strip()
requested_page = request.args.get("page", default=1, type=int) or 1
rows = db.session.execute(select(CommunicationPart).order_by(CommunicationPart.part_name.asc())).scalars().all()
source_options = sorted({row.source for row in rows if row.source}, key=str.casefold)
type_options = sorted({row.antenna_type for row in rows if row.antenna_type}, key=str.casefold)
filtered_rows = []
for row in rows:
if search_term and not _entity_matches_query(
[row.part_name, row.display_name, row.source, row.antenna_type],
search_term,
):
continue
if source_filter and source_filter != (row.source or ""):
continue
if type_filter and type_filter != (row.antenna_type or ""):
continue
filtered_rows.append(row)
normalized_sort = _sort_communications(filtered_rows, sort_option)
query_args = {
"q": search_term,
"source": source_filter,
"antenna_type": type_filter,
"sort": normalized_sort,
}
page_rows, paging = _paginate(filtered_rows, "web.communication_list", query_args, requested_page)
return render_template(
"communication_list.html",
title="通信部件",
active_page="comms",
rows=page_rows,
search_term=search_term,
source_filter=source_filter,
antenna_type_filter=type_filter,
sort_option=normalized_sort,
sort_options=COMM_SORT_OPTIONS,
source_options=source_options,
type_options=type_options,
total_catalog_count=len(rows),
**paging,
)
@web_bp.route("/communications/new", methods=["GET", "POST"])
def communication_new() -> str | object:
part = CommunicationPart()
if request.method == "POST":
try:
_apply_fields(part, request.form, COMMUNICATION_FIELDS)
db.session.add(part)
db.session.commit()
except ValueError as exc:
db.session.rollback()
flash(str(exc), "error")
else:
flash("已新增通信部件。", "success")
return redirect(url_for("web.communication_edit", part_id=part.id))
return render_template(
"resource_edit.html",
title="Create Communication",
eyebrow="Communication",
heading="新增通信部件",
description="新建一条通信部件记录。",
fields=COMMUNICATION_FIELDS,
values=_collect_form_values(part, COMMUNICATION_FIELDS),
cancel_url=url_for("web.communication_list"),
submit_label="创建",
delete_url=None,
)
@web_bp.route("/communications/<uuid:part_id>/edit", methods=["GET", "POST"])
def communication_edit(part_id: object) -> str | object:
part = _load_model_or_404(CommunicationPart, part_id)
if request.method == "POST":
try:
_apply_fields(part, request.form, COMMUNICATION_FIELDS)
db.session.commit()
except ValueError as exc:
db.session.rollback()
flash(str(exc), "error")
else:
flash("通信部件已保存。", "success")
return redirect(url_for("web.communication_list"))
return render_template(
"resource_edit.html",
title=f"Edit · {part.part_name}",
eyebrow="Communication",
heading=f"编辑 {part.part_name}",
description="修改通信部件字段。",
fields=COMMUNICATION_FIELDS,
values=_collect_form_values(part, COMMUNICATION_FIELDS),
cancel_url=url_for("web.communication_list"),
submit_label="保存",
delete_url=url_for("web.communication_delete", part_id=part.id),
delete_label="删除通信部件",
delete_confirm="确认删除这个通信部件?",
)
@web_bp.post("/communications/<uuid:part_id>/delete")
def communication_delete(part_id: object) -> object:
part = _load_model_or_404(CommunicationPart, part_id)
db.session.delete(part)
db.session.commit()
flash("通信部件已删除。", "success")
return redirect(url_for("web.communication_list"))
@web_bp.get("/tanks")
def tank_list() -> str:
search_term = request.args.get("q", "").strip()
fuel_filter = request.args.get("fuel_type", "").strip()
source_filter = request.args.get("source", "").strip()
sort_option = request.args.get("sort", "tank_name").strip()
requested_page = request.args.get("page", default=1, type=int) or 1
rows = db.session.execute(select(TankSpec).order_by(TankSpec.tank_name.asc())).scalars().all()
fuel_options = sorted({row.fuel_type for row in rows if row.fuel_type}, key=str.casefold)
source_options = sorted({row.source for row in rows if row.source}, key=str.casefold)
filtered_rows = []
for row in rows:
if search_term and not _entity_matches_query(
[row.tank_name, row.vehicle_name, row.source, row.fuel_type],
search_term,
):
continue
if fuel_filter and fuel_filter != (row.fuel_type or ""):
continue
if source_filter and source_filter != (row.source or ""):
continue
filtered_rows.append(row)
normalized_sort = _sort_tanks(filtered_rows, sort_option)
query_args = {
"q": search_term,
"fuel_type": fuel_filter,
"source": source_filter,
"sort": normalized_sort,
}
page_rows, paging = _paginate(filtered_rows, "web.tank_list", query_args, requested_page)
return render_template(
"tank_list.html",
title="燃料箱规格",
active_page="tanks",
rows=page_rows,
search_term=search_term,
fuel_filter=fuel_filter,
source_filter=source_filter,
sort_option=normalized_sort,
sort_options=TANK_SORT_OPTIONS,
fuel_options=fuel_options,
source_options=source_options,
total_catalog_count=len(rows),
**paging,
)
@web_bp.route("/tanks/new", methods=["GET", "POST"])
def tank_new() -> str | object:
tank = TankSpec()
if request.method == "POST":
try:
_apply_fields(tank, request.form, TANK_FIELDS)
db.session.add(tank)
db.session.commit()
except ValueError as exc:
db.session.rollback()
flash(str(exc), "error")
else:
flash("已新增燃料箱记录。", "success")
return redirect(url_for("web.tank_edit", tank_id=tank.id))
return render_template(
"resource_edit.html",
title="Create Tank",
eyebrow="Tank",
heading="新增燃料箱记录",
description="录入一条 Tank Chart 数据。",
fields=TANK_FIELDS,
values=_collect_form_values(tank, TANK_FIELDS),
cancel_url=url_for("web.tank_list"),
submit_label="创建",
delete_url=None,
)
@web_bp.route("/tanks/<uuid:tank_id>/edit", methods=["GET", "POST"])
def tank_edit(tank_id: object) -> str | object:
tank = _load_model_or_404(TankSpec, tank_id)
if request.method == "POST":
try:
_apply_fields(tank, request.form, TANK_FIELDS)
db.session.commit()
except ValueError as exc:
db.session.rollback()
flash(str(exc), "error")
else:
flash("燃料箱记录已保存。", "success")
return redirect(url_for("web.tank_list"))
return render_template(
"resource_edit.html",
title=f"Edit · {tank.tank_name}",
eyebrow="Tank",
heading=f"编辑 {tank.tank_name}",
description="修改燃料箱字段。",
fields=TANK_FIELDS,
values=_collect_form_values(tank, TANK_FIELDS),
cancel_url=url_for("web.tank_list"),
submit_label="保存",
delete_url=url_for("web.tank_delete", tank_id=tank.id),
delete_label="删除燃料箱记录",
delete_confirm="确认删除这个燃料箱记录?",
)
@web_bp.post("/tanks/<uuid:tank_id>/delete")
def tank_delete(tank_id: object) -> object:
tank = _load_model_or_404(TankSpec, tank_id)
db.session.delete(tank)
db.session.commit()
flash("燃料箱记录已删除。", "success")
return redirect(url_for("web.tank_list"))
@web_bp.get("/vehicles")
def vehicle_list() -> str:
search_term = request.args.get("q", "").strip()
source_filter = request.args.get("source", "").strip()
sort_option = request.args.get("sort", "vehicle_name").strip()
requested_page = request.args.get("page", default=1, type=int) or 1
rows = db.session.execute(select(VehicleCost).order_by(VehicleCost.vehicle_name.asc())).scalars().all()
source_options = sorted({row.source for row in rows if row.source}, key=str.casefold)
filtered_rows = []
for row in rows:
if search_term and not _entity_matches_query([row.vehicle_name, row.source], search_term):
continue
if source_filter and source_filter != (row.source or ""):
continue
filtered_rows.append(row)
normalized_sort = _sort_vehicles(filtered_rows, sort_option)
query_args = {"q": search_term, "source": source_filter, "sort": normalized_sort}
page_rows, paging = _paginate(filtered_rows, "web.vehicle_list", query_args, requested_page)
return render_template(
"vehicle_list.html",
active_page="vehicles",
title="Vehicle Costs",
rows=page_rows,
search_term=search_term,
source_filter=source_filter,
sort_option=normalized_sort,
sort_options=VEHICLE_SORT_OPTIONS,
source_options=source_options,
total_catalog_count=len(rows),
**paging,
)
@web_bp.route("/vehicles/new", methods=["GET", "POST"])
def vehicle_new() -> str | object:
vehicle = VehicleCost()
if request.method == "POST":
try:
_apply_fields(vehicle, request.form, VEHICLE_FIELDS)
db.session.add(vehicle)
db.session.commit()
except ValueError as exc:
db.session.rollback()
flash(str(exc), "error")
else:
flash("已新增载具成本记录。", "success")
return redirect(url_for("web.vehicle_edit", vehicle_id=vehicle.id))
return render_template(
"resource_edit.html",
title="Create Vehicle",
eyebrow="Vehicle",
heading="新增载具成本",
description="录入一条 Vehicle Cost 数据。",
fields=VEHICLE_FIELDS,
values=_collect_form_values(vehicle, VEHICLE_FIELDS),
cancel_url=url_for("web.vehicle_list"),
submit_label="创建",
delete_url=None,
)
@web_bp.route("/vehicles/<uuid:vehicle_id>/edit", methods=["GET", "POST"])
def vehicle_edit(vehicle_id: object) -> str | object:
vehicle = _load_model_or_404(VehicleCost, vehicle_id)
if request.method == "POST":
try:
_apply_fields(vehicle, request.form, VEHICLE_FIELDS)
db.session.commit()
except ValueError as exc:
db.session.rollback()
flash(str(exc), "error")
else:
flash("载具成本记录已保存。", "success")
return redirect(url_for("web.vehicle_list"))
return render_template(
"resource_edit.html",
title=f"Edit · {vehicle.vehicle_name}",
eyebrow="Vehicle",
heading=f"编辑 {vehicle.vehicle_name}",
description="修改发射成本字段。",
fields=VEHICLE_FIELDS,
values=_collect_form_values(vehicle, VEHICLE_FIELDS),
cancel_url=url_for("web.vehicle_list"),
submit_label="保存",
delete_url=url_for("web.vehicle_delete", vehicle_id=vehicle.id),
delete_label="删除载具成本记录",
delete_confirm="确认删除这个载具成本记录?",
)
@web_bp.post("/vehicles/<uuid:vehicle_id>/delete")
def vehicle_delete(vehicle_id: object) -> object:
vehicle = _load_model_or_404(VehicleCost, vehicle_id)
db.session.delete(vehicle)
db.session.commit()
flash("载具成本记录已删除。", "success")
return redirect(url_for("web.vehicle_list"))
@web_bp.get("/assets")
def asset_list() -> str:
search_term = request.args.get("q", "").strip()
asset_type_filter = request.args.get("asset_type", "").strip()
sort_option = request.args.get("sort", "name_asc").strip()
requested_page = request.args.get("page", default=1, type=int) or 1
simulation_time = _resolve_simulation_time(request.args.get("sim_time"))
rows = db.session.execute(
select(Asset).options(selectinload(Asset.log_entries)).order_by(Asset.name.asc())
).scalars().all()
asset_type_options = sorted({row.asset_type for row in rows if row.asset_type}, key=str.casefold)
filtered_rows = []
for row in rows:
if search_term and not _entity_matches_query([row.name, row.asset_type, row.program, row.home_region], search_term):
continue
if asset_type_filter and asset_type_filter != (row.asset_type or ""):
continue
filtered_rows.append(row)
normalized_sort = _sort_assets(filtered_rows, sort_option)
query_args = {
"q": search_term,
"asset_type": asset_type_filter,
"sort": normalized_sort,
"sim_time": _format_datetime_input(simulation_time),
}
page_rows, paging = _paginate(filtered_rows, "web.asset_list", query_args, requested_page)
catalog_rows = [_build_asset_catalog_row(asset, simulation_time) for asset in page_rows]
return render_template(
"asset_list.html",
title="任务资产",
active_page="assets",
preview_title="任务资产目录",
preview_eyebrow="Mission Asset",
rows=catalog_rows,
search_term=search_term,
asset_type_filter=asset_type_filter,
sort_option=normalized_sort,
sort_options=ASSET_SORT_OPTIONS,
asset_type_options=asset_type_options,
total_catalog_count=len(rows),
metrics=_build_asset_catalog_metrics(rows, filtered_rows, simulation_time),
preview_tab="asset",
board_nav_urls=_mission_ops_nav_urls(simulation_time),
simulation_time_display=_format_datetime_display(simulation_time),
simulation_time_input=_format_datetime_input(simulation_time),
header_time_action=url_for("web.asset_list"),
header_time_field_id="asset-list-header-sim-time",
header_time_submit_label="Apply Time",
header_hidden_fields=_build_header_hidden_fields(
{
"q": search_term,
"asset_type": asset_type_filter,
"sort": normalized_sort,
}
),
**paging,
)
@web_bp.get("/missions")
def mission_list() -> str:
simulation_time = _resolve_simulation_time(request.args.get("sim_time"))
search_term = request.args.get("q", "").strip()
asset_id_filter = request.args.get("asset_id", "").strip()
mission_label_filter = request.args.get("mission_label", "").strip()
location_filter = request.args.get("location", "").strip()
try:
range_start = _parse_datetime(request.args.get("start"), "任务开始时间")
except ValueError as exc:
range_start = None
flash(str(exc), "error")
try:
range_end = _parse_datetime(request.args.get("end"), "任务结束时间")
except ValueError as exc:
range_end = None
flash(str(exc), "error")
if range_start is not None and range_end is not None and range_end < range_start:
flash("任务结束时间不能早于开始时间。", "error")
range_end = range_start
all_rows = []
for asset in _load_assets_with_logs():
if asset.is_retired:
continue
for entry in sorted(
asset.log_entries,
key=lambda item: _as_utc(item.start_at) or _default_simulation_time(),
reverse=True,
):
mission_labels = _split_multi_value_text(entry.mission_label)
if not mission_labels:
continue
state_labels = _split_multi_value_text(_entry_state_label(entry, simulation_time))
status = _classify_asset_entry(entry, simulation_time)
for mission_label in mission_labels:
all_rows.append(
{
"asset": asset,
"asset_id": str(asset.id),
"asset_name": asset.name,
"asset_type": asset.asset_type or "-",
"entry": entry,
"entry_id": entry.id,
"mission_label": mission_label,
"mission_labels": mission_labels,
"state_labels": state_labels,
"title": entry.title,
"mode_label": "Event Point" if entry.entry_kind == "event" else "State Interval",
"status_label": status["label"],
"status_tone": status["tone"],
"timestamp": _format_datetime_display(entry.start_at),
"end_timestamp": _format_datetime_display(entry.end_at) if entry.end_at else None,
"location": _entry_location(entry, simulation_time) or "-",
"summary": entry.summary or "未填写摘要。",
"sort_at": _as_utc(entry.start_at) or _default_simulation_time(),
}
)
asset_options = [
{"value": asset_id, "label": asset_name}
for asset_id, asset_name in sorted(
{item["asset_id"]: item["asset_name"] for item in all_rows}.items(),
key=lambda item: item[1].casefold(),
)
]
mission_label_options = sorted({row["mission_label"] for row in all_rows}, key=str.casefold)
location_options = sorted(
{row["location"] for row in all_rows if row["location"] and row["location"] != "-"},
key=str.casefold,
)
filtered_rows = []
for row in all_rows:
if asset_id_filter and asset_id_filter != row["asset_id"]:
continue
if mission_label_filter and mission_label_filter != row["mission_label"]:
continue
if location_filter and location_filter != row["location"]:
continue
if search_term and not _mission_label_matches_query(row["mission_label"], search_term):
continue
if not _entry_matches_date_range(row["entry"], range_start, range_end):
continue
filtered_rows.append(row)
grouped_rows: dict[str, dict[str, object]] = {}
for row in filtered_rows:
mission_label = str(row["mission_label"])
group = grouped_rows.setdefault(
mission_label,
{
"mission_label": mission_label,
"entries": [],
"asset_names": set(),
"locations": set(),
"active_count": 0,
"planned_count": 0,
"latest_at": row["sort_at"],
},
)
group["entries"].append(row)
group["asset_names"].add(str(row["asset_name"]))
if row["location"] != "-":
group["locations"].add(str(row["location"]))
if row["status_tone"] == "active":
group["active_count"] += 1
elif row["status_tone"] == "planned":
group["planned_count"] += 1
if row["sort_at"] > group["latest_at"]:
group["latest_at"] = row["sort_at"]
mission_groups = []
for group in grouped_rows.values():
entries = sorted(group["entries"], key=lambda item: item["sort_at"], reverse=True)
asset_names = sorted(group["asset_names"], key=str.casefold)
locations = sorted(group["locations"], key=str.casefold)
if group["active_count"]:
group_status = "Active"
group_rank = 0
elif group["planned_count"]:
group_status = "Upcoming"
group_rank = 1
else:
group_status = "Recorded"
group_rank = 2
mission_groups.append(
{
"mission_label": group["mission_label"],
"entries": entries,
"entry_count": len(entries),
"asset_names": asset_names,
"asset_count": len(asset_names),
"locations": locations or ["-"],
"status_label": group_status,
"group_rank": group_rank,
"latest_timestamp": _format_datetime_display(group["latest_at"]),
}
)
mission_groups.sort(key=lambda item: (item["group_rank"], item["mission_label"].casefold()))
selected_asset_name = next(
(option["label"] for option in asset_options if option["value"] == asset_id_filter),
"全部资产",
)
return render_template(
"mission_list.html",
title="任务运营 · 任务",
preview_title="任务",
preview_eyebrow="Mission Label",
preview_tab="mission",
mission_groups=mission_groups,
search_term=search_term,
asset_id_filter=asset_id_filter,
mission_label_filter=mission_label_filter,
location_filter=location_filter,
asset_options=asset_options,
mission_label_options=mission_label_options,
location_options=location_options,
selected_asset_name=selected_asset_name,
total_entry_count=len(all_rows),
filtered_entry_count=len(filtered_rows),
metrics=[
{"label": "Visible Missions", "value": str(len(mission_groups))},
{"label": "Visible Entries", "value": str(len(filtered_rows))},
{"label": "Active Missions", "value": str(sum(1 for group in mission_groups if group["status_label"] == "Active"))},
],
simulation_time_display=_format_datetime_display(simulation_time),
simulation_time_input=_format_datetime_input(simulation_time),
range_start_input=_format_datetime_input(range_start),
range_end_input=_format_datetime_input(range_end),
header_time_action=url_for("web.mission_list"),
header_time_field_id="mission-list-header-sim-time",
header_time_submit_label="Apply Time",
header_hidden_fields=_build_header_hidden_fields(
{
"q": search_term,
"asset_id": asset_id_filter,
"mission_label": mission_label_filter,
"location": location_filter,
"start": _format_datetime_input(range_start),
"end": _format_datetime_input(range_end),
}
),
board_nav_urls=_mission_ops_nav_urls(simulation_time),
)
@web_bp.route("/assets/new", methods=["GET", "POST"])
def asset_new() -> str | object:
asset = Asset()
form_options = _asset_form_options()
simulation_time = _resolve_simulation_time(request.form.get("sim_time") if request.method == "POST" else request.args.get("sim_time"))
simulation_time_input = _format_datetime_input(simulation_time)
values = _collect_form_values(asset, ASSET_FIELDS)
validation_modal: dict[str, object] | None = None
field_errors: dict[str, str] = {}
if request.method == "POST":
try:
_apply_fields(asset, request.form, ASSET_FIELDS)
db.session.add(asset)
db.session.commit()
except FormValidationError as exc:
db.session.rollback()
validation_modal = _build_validation_modal(exc)
field_errors = dict(validation_modal["field_errors"])
values = _collect_submitted_form_values(request.form, ASSET_FIELDS)
except IntegrityError:
db.session.rollback()
validation_modal = _build_validation_modal(
"创建失败:Asset 名称必须唯一。",
field_errors={"name": "Asset 名称必须唯一。"},
)
field_errors = dict(validation_modal["field_errors"])
values = _collect_submitted_form_values(request.form, ASSET_FIELDS)
else:
flash("资产已创建。", "success")
return redirect(url_for("web.asset_detail", asset_id=asset.id
))
return render_template(
"resource_edit.html",
title="新增任务资产",
eyebrow="Mission Asset",
heading="新增任务资产",
description=None,
fields=ASSET_FIELDS,
values=values,
form_options=form_options,
custom_option_value=CUSTOM_OPTION_VALUE,
validation_modal=validation_modal,
field_errors=field_errors,
cancel_url=url_for("web.asset_list"
),
submit_label="创建",
delete_url=None,
hidden_fields={"sim_time": simulation_time_input},
mission_ops_tabs=True,
preview_tab="asset",
board_nav_urls=_mission_ops_nav_urls(simulation_time),
)
@web_bp.route("/assets/<uuid:asset_id>/edit", methods=["GET", "POST"])
def asset_edit(asset_id: object) -> str | object:
asset = _load_asset_or_404(asset_id)
form_options = _asset_form_options()
simulation_time = _resolve_simulation_time(request.form.get("sim_time") if request.method == "POST" else request.args.get("sim_time"))
simulation_time_input = _format_datetime_input(simulation_time)
values = _collect_form_values(asset, ASSET_FIELDS)
validation_modal: dict[str, object] | None = None
field_errors: dict[str, str] = {}
if request.method == "POST":
try:
_apply_fields(asset, request.form, ASSET_FIELDS)
db.session.commit()
except FormValidationError as exc:
db.session.rollback()
validation_modal = _build_validation_modal(exc)
field_errors = dict(validation_modal["field_errors"])
values = _collect_submitted_form_values(request.form, ASSET_FIELDS)
except IntegrityError:
db.session.rollback()
validation_modal = _build_validation_modal(
"保存失败:Asset 名称必须唯一。",
field_errors={"name": "Asset 名称必须唯一。"},
)
field_errors = dict(validation_modal["field_errors"])
values = _collect_submitted_form_values(request.form, ASSET_FIELDS)
else:
flash("资产信息已保存。", "success")
return redirect(url_for("web.asset_detail", asset_id=asset.id
))
return render_template(
"resource_edit.html",
title=f"编辑任务资产 · {asset.name}",
eyebrow="Mission Asset",
heading=f"编辑 {asset.name}",
description=None,
fields=ASSET_FIELDS,
values=values,
form_options=form_options,
custom_option_value=CUSTOM_OPTION_VALUE,
validation_modal=validation_modal,
field_errors=field_errors,
cancel_url=url_for("web.asset_detail", asset_id=asset.id
),
submit_label="保存",
delete_url=url_for("web.asset_delete", asset_id=asset.id),
delete_label="删除资产",
delete_confirm="确认删除这个资产及其日志?",
hidden_fields={"sim_time": simulation_time_input},
delete_hidden_fields={"sim_time": simulation_time_input},
mission_ops_tabs=True,
preview_tab="asset",
board_nav_urls=_mission_ops_nav_urls(simulation_time),
)
@web_bp.post("/assets/<uuid:asset_id>/delete")
def asset_delete(asset_id: object) -> object:
asset = _load_asset_or_404(asset_id)
simulation_time_input = _normalized_text(request.form.get("sim_time"))
db.session.delete(asset)
db.session.commit()
flash("资产已删除。", "success")
redirect_kwargs: dict[str, str] = {}
if simulation_time_input is not None:
redirect_kwargs["sim_time"] = simulation_time_input
return redirect(url_for("web.asset_list", **redirect_kwargs))
@web_bp.post("/assets/import-log-book")
def asset_import_log_book() -> object:
redirect_kwargs: dict[str, str] = {}
simulation_time_input = _normalized_text(request.form.get("sim_time"))
if simulation_time_input is not None:
redirect_kwargs["sim_time"] = simulation_time_input
try:
summary = import_log_book_data(LOG_BOOK_PATH)
except FileNotFoundError as exc:
flash(str(exc), "error")
except Exception as exc:
flash(f"导入 log_book 失败:{exc}", "error")
else:
imported_sheet_names = ", ".join(item.sheet_name for item in summary.imported_assets) or "无"
total_states = sum(item.state_entries for item in summary.imported_assets)
total_events = sum(item.event_entries for item in summary.imported_assets)
flash(
f"已从 {summary.workbook} 导入 {len(summary.imported_assets)} 个载具 sheet{imported_sheet_names}),共 {total_states} 条 state 和 {total_events} 条 event。",
"success",
)
return redirect(url_for("web.asset_list", **redirect_kwargs))
@web_bp.get("/assets/<uuid:asset_id>")
def asset_detail(asset_id: object) -> str:
asset = _load_asset_or_404(asset_id)
simulation_time = _resolve_simulation_time(request.args.get("sim_time"))
requested_page = request.args.get("page", default=1, type=int) or 1
snapshot = _build_asset_snapshot(asset, simulation_time)
simulation_time_input = _format_datetime_input(simulation_time)
future_events = [
{
**item,
"edit_url": url_for(
"web.asset_entry_edit",
asset_id=asset.id,
entry_id=item["source_entry_id"],
),
}
for item in snapshot["future_events"]
]
try:
entry_range_start = _parse_datetime(request.args.get("start"), "日志开始时间")
except ValueError as exc:
entry_range_start = None
flash(str(exc), "error")
try:
entry_range_end = _parse_datetime(request.args.get("end"), "日志结束时间")
except ValueError as exc:
entry_range_end = None
flash(str(exc), "error")
if entry_range_start is not None and entry_range_end is not None and entry_range_end < entry_range_start:
flash("日志结束时间不能早于开始时间。", "error")
entry_range_end = entry_range_start
entry_rows = []
filtered_entries = [
entry
for entry in sorted(
asset.log_entries,
key=lambda item: _as_utc(item.start_at) or _default_simulation_time(),
reverse=True,
)
if _entry_matches_date_range(entry, entry_range_start, entry_range_end)
]
for entry in filtered_entries:
status = _classify_asset_entry(entry, simulation_time)
entry_rows.append(
{
"id": entry.id,
"source_entry_id": entry.id,
"mode": entry.entry_kind,
"mode_label": "Event Point" if entry.entry_kind == "event" else "State Interval",
"status_label": status["label"],
"title": entry.title,
"timestamp": _format_datetime_display(entry.start_at),
"end_timestamp": _format_datetime_display(entry.end_at) if entry.end_at else None,
"location": _entry_location(entry, simulation_time) or "-",
"summary": entry.summary or "未填写摘要。",
"mission_label": _display_multi_value_text(entry.mission_label),
"state_label": _display_multi_value_text(_entry_state_label(entry, simulation_time)),
"mission_labels": _split_multi_value_text(entry.mission_label),
"state_labels": _split_multi_value_text(_entry_state_label(entry, simulation_time)),
"edit_url": url_for("web.asset_entry_edit", asset_id=asset.id, entry_id=entry.id
),
"state_nodes": [
{"title": n.title, "at": _format_datetime_display(n.at_time), "detail": n.detail or ""}
for n in sorted(entry.state_nodes, key=lambda x: _as_utc(x.at_time) or _default_simulation_time())
] if entry.entry_kind == "state" and entry.state_nodes else [],
}
)
paged_entries, pagination_context = _paginate(
entry_rows,
"web.asset_detail",
{
"asset_id": asset.id,
"sim_time": _format_datetime_input(simulation_time),
"start": _format_datetime_input(entry_range_start),
"end": _format_datetime_input(entry_range_end),
},
requested_page,
)
return render_template(
"asset_detail.html",
title=asset.name,
active_page="assets",
preview_title=asset.name,
preview_eyebrow="Mission Asset",
asset=asset,
asset_summary=snapshot,
all_assets=db.session.execute(select(Asset).where(Asset.id != asset.id).order_by(Asset.name)).scalars().all(),
future_events=future_events,
entries=paged_entries,
upcoming_events=future_events[:4],
total_entry_count=len(asset.log_entries),
filtered_entry_count=len(entry_rows),
metrics=[
{"label": "Asset Type", "value": asset.asset_type},
{"label": "Entries", "value": str(len(asset.log_entries))},
{"label": "Current State", "value": str(snapshot["current_state"])},
],
entry_filter_start_input=_format_datetime_input(entry_range_start),
entry_filter_end_input=_format_datetime_input(entry_range_end),
entry_page=pagination_context["page"],
entry_total_pages=pagination_context["total_pages"],
entry_has_prev=pagination_context["has_prev"],
entry_has_next=pagination_context["has_next"],
entry_prev_url=pagination_context["prev_url"],
entry_next_url=pagination_context["next_url"],
entry_page_links=pagination_context["page_links"],
preview_tab="asset",
board_nav_urls=_mission_ops_nav_urls(simulation_time),
simulation_time_display=_format_datetime_display(simulation_time),
simulation_time_input=simulation_time_input,
header_time_action=url_for("web.asset_detail", asset_id=asset.id),
header_time_field_id="asset-detail-header-sim-time",
header_time_submit_label="Apply Time",
header_hidden_fields=_build_header_hidden_fields(
{
"start": _format_datetime_input(entry_range_start),
"end": _format_datetime_input(entry_range_end),
"page": requested_page if requested_page > 1 else None,
}
),
)
@web_bp.route("/assets/<uuid:asset_id>/entries/new", methods=["GET", "POST"])
def asset_entry_new(asset_id: object) -> str | object:
asset = _load_asset_or_404(asset_id)
simulation_time = _resolve_simulation_time(request.form.get("sim_time") if request.method == "POST" else request.args.get("sim_time"))
simulation_time_input = _format_datetime_input(simulation_time)
requested_kind = _normalized_text(request.args.get("kind")) or "event"
if requested_kind not in {option["value"] for option in ASSET_ENTRY_KIND_OPTIONS}:
requested_kind = "event"
entry = AssetLogEntry(entry_kind=requested_kind)
form_values = _asset_entry_form_values(entry)
validation_modal: dict[str, object] | None = None
field_errors: dict[str, str] = {}
if request.method == "POST":
# Support both form and JSON
if request.content_type and "application/json" in request.content_type:
data = request.get_json(force=True, silent=True) or {}
try:
_apply_asset_entry_json_fields(entry, data)
entry.asset = asset
db.session.add(entry)
db.session.commit()
return {"success": True, "entry_id": str(entry.id)}
entry.entry_kind = data.get("entry_kind", "event")
entry.title = data.get("title", "")
entry.summary = data.get("summary")
if data.get("start_at"):
entry.start_at = _parse_datetime(data["start_at"], "开始时间")
if data.get("end_at"):
entry.end_at = _parse_datetime(data["end_at"], "结束时间")
entry.asset = asset
db.session.add(entry)
# Handle state nodes
for node_data in data.get("state_nodes", []):
if node_data.get("title"):
node = AssetStateNode(
title=node_data["title"],
detail=node_data.get("detail"),
at_time=_parse_datetime(node_data.get("at_time"), "节点时间") or entry.start_at,
entry=entry,
)
db.session.add(node)
db.session.commit()
return {"success": True, "entry_id": str(entry.id)}
except Exception as e:
db.session.rollback()
return {"error": str(e)}, 400
else:
try:
_apply_asset_entry_fields(entry, request.form)
entry.asset = asset
db.session.add(entry)
db.session.commit()
except FormValidationError as exc:
db.session.rollback()
validation_modal = _build_validation_modal(exc)
field_errors = dict(validation_modal["field_errors"])
form_values = _asset_entry_form_values_from_form(request.form)
else:
flash("日志条目已新增。", "success")
return redirect(url_for("web.asset_detail", asset_id=asset.id
))
return render_template(
"asset_entry_form.html",
title=f"新增日志 · {asset.name}",
asset=asset,
heading=f"为 {asset.name} 新增日志条目",
description=None,
submit_label="创建条目",
form_values=form_values,
kind_options=ASSET_ENTRY_KIND_OPTIONS,
validation_modal=validation_modal,
field_errors=field_errors,
all_states=_all_state_labels(),
cancel_url=url_for("web.asset_detail", asset_id=asset.id
),
delete_url=None,
simulation_time_input=simulation_time_input,
preview_tab="asset",
board_nav_urls=_mission_ops_nav_urls(simulation_time),
)
@web_bp.route("/assets/<uuid:asset_id>/entries/<uuid:entry_id>/edit", methods=["GET", "POST"])
def asset_entry_edit(asset_id: object, entry_id: object) -> str | object:
asset = _load_asset_or_404(asset_id)
entry = _load_asset_entry_or_404(asset.id, entry_id)
simulation_time = _resolve_simulation_time(request.form.get("sim_time") if request.method == "POST" else request.args.get("sim_time"))
simulation_time_input = _format_datetime_input(simulation_time)
form_values = _asset_entry_form_values(entry)
validation_modal: dict[str, object] | None = None
field_errors: dict[str, str] = {}
if request.method == "POST":
if request.content_type and "application/json" in request.content_type:
data = request.get_json(force=True, silent=True) or {}
try:
_apply_asset_entry_json_fields(entry, data)
db.session.commit()
return {"success": True}
entry.entry_kind = data.get("entry_kind", entry.entry_kind)
entry.title = data.get("title", entry.title)
entry.summary = data.get("summary")
if data.get("start_at"):
entry.start_at = _parse_datetime(data["start_at"], "开始时间")
if "end_at" in data:
entry.end_at = _parse_datetime(data["end_at"], "结束时间") if data["end_at"] else None
# Handle state nodes
for node in entry.state_nodes:
db.session.delete(node)
for node_data in data.get("state_nodes", []):
if node_data.get("title"):
node = AssetStateNode(
title=node_data["title"],
detail=node_data.get("detail"),
at_time=_parse_datetime(node_data.get("at_time"), "节点时间") or entry.start_at,
entry=entry,
)
db.session.add(node)
db.session.commit()
return {"success": True}
except Exception as e:
db.session.rollback()
return {"error": str(e)}, 400
else:
try:
_apply_asset_entry_fields(entry, request.form)
db.session.commit()
except FormValidationError as exc:
db.session.rollback()
validation_modal = _build_validation_modal(exc)
field_errors = dict(validation_modal["field_errors"])
form_values = _asset_entry_form_values_from_form(request.form)
else:
flash("日志条目已保存。", "success")
return redirect(url_for("web.asset_detail", asset_id=asset.id
))
return render_template(
"asset_entry_form.html",
title=f"编辑日志 · {asset.name}",
asset=asset,
heading=f"编辑 {asset.name} 的日志条目",
description=None,
submit_label="保存条目",
form_values=form_values,
kind_options=ASSET_ENTRY_KIND_OPTIONS,
validation_modal=validation_modal,
field_errors=field_errors,
all_states=_all_state_labels(),
cancel_url=url_for("web.asset_detail", asset_id=asset.id
),
delete_url=url_for("web.asset_entry_delete", asset_id=asset.id, entry_id=entry.id),
simulation_time_input=simulation_time_input,
preview_tab="asset",
board_nav_urls=_mission_ops_nav_urls(simulation_time),
)
@web_bp.post("/api/v1/sim-time")
def api_set_sim_time() -> object:
data = request.get_json(force=True, silent=True) or {}
time_str = data.get("sim_time", "").strip()
if time_str:
parsed = _parse_datetime(time_str, "sim_time")
if parsed is not None:
_db_write_simulation_time(parsed)
return {"success": True}
return {"error": "Invalid time"}, 400
@web_bp.post("/api/v1/assets/<uuid:asset_id>/dock")
def api_asset_dock(asset_id: object) -> object:
asset = _load_asset_or_404(asset_id)
data = request.get_json(force=True, silent=True) or {}
mode = _normalized_text(data.get("mode")) or "dock_vehicle"
vehicle_id = data.get("vehicle_id")
target_asset_id = data.get("target_asset_id") or vehicle_id
vehicle_name = _normalized_text(data.get("vehicle_name"))
note = _normalized_text(data.get("note"))
sim_time = _resolve_simulation_time(data.get("sim_time"))
dock_time = _parse_datetime(data.get("dock_time"), "对接时间") or sim_time
try:
if mode == "dock_to_target":
if not target_asset_id:
raise FormValidationError("Docking target required")
parent_asset = db.session.get(Asset, _parse_uuid_value(target_asset_id))
if parent_asset is None or parent_asset.id == asset.id:
raise FormValidationError("Docking target invalid")
child_asset = asset
child_label = None
elif mode == "dock_vehicle":
parent_asset = asset
if vehicle_id and vehicle_id != "_custom":
child_asset = db.session.get(Asset, _parse_uuid_value(vehicle_id))
if child_asset is None or child_asset.id == asset.id:
raise FormValidationError("Docked vehicle invalid")
child_label = None
else:
child_asset = None
child_label = vehicle_name
if child_label is None:
raise FormValidationError("Vehicle name required")
else:
raise FormValidationError("Docking mode invalid")
child_name = child_asset.name if child_asset is not None else str(child_label)
parent_location = str(_build_asset_snapshot(parent_asset, dock_time, include_docking=False)["current_location"])
event = DockingEvent(
parent_asset=parent_asset,
child_asset=child_asset,
child_label=child_label,
docked_at=dock_time,
dock_note=note,
)
db.session.add(event)
_append_docking_log(parent_asset, child_name, parent_asset.name, parent_location, dock_time, note)
if child_asset is not None:
_append_docking_log(child_asset, child_name, parent_asset.name, parent_location, dock_time, note)
db.session.commit()
except FormValidationError as exc:
db.session.rollback()
return {"error": str(exc)}, 400
if request.content_type and "application/json" in request.content_type:
return {"success": True}
flash("对接已记录。", "success")
return redirect(url_for("web.asset_detail", asset_id=asset.id
))
@web_bp.post("/api/v1/assets/<uuid:asset_id>/undock")
def api_asset_undock(asset_id: object) -> object:
asset = _load_asset_or_404(asset_id)
data = request.get_json(force=True, silent=True) or {}
event_id = data.get("event_id")
action = _normalized_text(data.get("action")) or "undock"
note = _normalized_text(data.get("note"))
sim_time = _resolve_simulation_time(data.get("sim_time"))
undock_time = _parse_datetime(data.get("undock_time"), "解对接时间") or sim_time
try:
if not event_id:
raise FormValidationError("Docking event required")
docking_event = db.session.get(DockingEvent, _parse_uuid_value(event_id))
if docking_event is None:
raise FormValidationError("Docking event not found")
if docking_event.parent_asset_id != asset.id and docking_event.child_asset_id != asset.id:
raise FormValidationError("Docking event does not belong to this asset")
parent_asset = db.session.get(Asset, docking_event.parent_asset_id)
child_asset = db.session.get(Asset, docking_event.child_asset_id) if docking_event.child_asset_id else None
child_name = child_asset.name if child_asset is not None else (docking_event.child_label or "External vehicle")
parent_name = parent_asset.name if parent_asset is not None else "Unknown"
if action == "delete_future":
if docking_event.undocked_at is None:
raise FormValidationError("No future undock to delete")
docking_event.undocked_at = None
docking_event.undock_note = None
_remove_undocking_log(parent_asset, child_name, parent_name)
if child_asset is not None:
_remove_undocking_log(child_asset, child_name, parent_name)
elif action == "edit_future":
if docking_event.undocked_at is None:
raise FormValidationError("No future undock to edit")
_remove_undocking_log(parent_asset, child_name, parent_name)
if child_asset is not None:
_remove_undocking_log(child_asset, child_name, parent_name)
docking_event.undocked_at = undock_time
docking_event.undock_note = note
_append_undocking_log(parent_asset, child_name, parent_name, docking_event.docked_at, undock_time, note)
if child_asset is not None:
_append_undocking_log(child_asset, child_name, parent_name, docking_event.docked_at, undock_time, note)
else:
if docking_event.undocked_at is not None:
raise FormValidationError("Already undocked")
docking_event.undocked_at = undock_time
docking_event.undock_note = note
if parent_asset is not None:
_append_undocking_log(parent_asset, child_name, parent_name, docking_event.docked_at, undock_time, note)
if child_asset is not None:
_append_undocking_log(child_asset, child_name, parent_name, docking_event.docked_at, undock_time, note)
db.session.commit()
except FormValidationError as exc:
db.session.rollback()
return {"error": str(exc)}, 400
if request.content_type and "application/json" in request.content_type:
return {"success": True}
flash("对接已解除。", "success")
return redirect(url_for("web.asset_detail", asset_id=asset.id
))
@web_bp.get("/docking-logs")
def docking_log_list() -> str:
simulation_time = _resolve_simulation_time()
simulation_time_input = _format_datetime_input(simulation_time)
events = (
db.session.execute(
select(DockingEvent)
.options(selectinload(DockingEvent.parent_asset), selectinload(DockingEvent.child_asset))
.order_by(DockingEvent.docked_at.desc())
).scalars().all()
)
return render_template(
"docking_logs.html",
title="对接日志管理",
active_page="docking_logs",
events=events,
simulation_time_display=_format_datetime_display(simulation_time),
simulation_time_input=simulation_time_input,
)
@web_bp.post("/docking-logs/<uuid:event_id>/delete")
def docking_log_delete(event_id: object) -> object:
event = db.session.get(DockingEvent, _parse_uuid_value(event_id))
if event is None:
flash("对接记录未找到。", "error")
return redirect(url_for("web.docking_log_list"))
parent = db.session.get(Asset, event.parent_asset_id) if event.parent_asset_id else None
child = db.session.get(Asset, event.child_asset_id) if event.child_asset_id else None
child_name = child.name if child else (event.child_label or "External vehicle")
parent_name = parent.name if parent else "Unknown"
# Remove docking log entries from both assets
dock_title = f"{child_name} docked to {parent_name}"
for asset in [parent, child]:
if asset is None:
continue
entry = AssetLogEntry.query.filter(
AssetLogEntry.asset_id == asset.id,
AssetLogEntry.entry_kind == "state",
AssetLogEntry.state_nodes.any(
(AssetStateNode.title == dock_title) &
(AssetStateNode.at_time == event.docked_at)
)
).first()
if entry:
nodes_to_remove = [n for n in entry.state_nodes if n.title == dock_title or 'undocked' in (n.title or '')]
for n in nodes_to_remove:
db.session.delete(n)
if not entry.state_nodes:
db.session.delete(entry)
db.session.delete(event)
db.session.commit()
flash("对接记录已删除。", "success")
return redirect(url_for("web.docking_log_list"))
@web_bp.post("/assets/<uuid:asset_id>/entries/<uuid:entry_id>/delete")
def asset_entry_delete(asset_id: object, entry_id: object) -> object:
asset = _load_asset_or_404(asset_id)
entry = _load_asset_entry_or_404(asset.id, entry_id)
simulation_time_input = _normalized_text(request.form.get("sim_time"))
db.session.delete(entry)
db.session.commit()
flash("日志条目已删除。", "success")
redirect_kwargs: dict[str, str | object] = {"asset_id": asset.id}
if simulation_time_input is not None:
redirect_kwargs["sim_time"] = simulation_time_input
return redirect(url_for("web.asset_detail", **redirect_kwargs))
@web_bp.get("/mission-preview")
def mission_preview_home() -> object:
return redirect(url_for("web.mission_status_board_preview"))
@web_bp.get("/mission-preview/status-board")
def mission_status_board_preview() -> str:
simulation_time = _resolve_simulation_time(request.args.get("sim_time"))
asset_type_filter = request.args.get("asset_type", "").strip()
location_filter = request.args.get("location", "").strip()
record_scope = _normalize_record_scope(request.args.get("record_scope"))
all_rows = [
row
for row in _build_mission_board_rows(_load_assets_with_logs(), simulation_time)
if not row["asset"].is_retired
]
visible_rows = _filter_mission_board_rows(all_rows, asset_type_filter, location_filter, record_scope)
return render_template(
"mission_status_board_preview.html",
title="任务运营 · 状态板",
preview_title="状态板",
preview_tab="status",
status_groups=_build_status_board_groups(visible_rows),
**_mission_board_context(
"web.mission_status_board_preview",
simulation_time,
asset_type_filter,
location_filter,
record_scope,
all_rows,
visible_rows,
),
)
@web_bp.get("/mission-preview/location-board")
def mission_location_board_preview() -> str:
simulation_time = _resolve_simulation_time(request.args.get("sim_time"))
simulation_time_input = _format_datetime_input(simulation_time)
# Load all assets with logs
assets = _load_assets_with_logs()
# Build asset position data for Canvas
# Map location strings to approximate orbital bodies and distances
LOCATION_BODY_MAP = {
"LEO": "earth", "GEO": "earth", "MEO": "earth", "Earth System": "earth", "Earth Surface": "earth",
"Lunar Orbit": "moon", "Lunar Surface": "moon",
"Mars Orbit": "mars", "Mars Surface": "mars", "Phobos Orbit": "mars", "Deimos Orbit": "mars",
"Jupiter Orbit": "jupiter", "Europa Orbit": "jupiter", "Europa Surface": "jupiter",
"Ganymede Orbit": "jupiter", "Callisto Orbit": "jupiter",
"Saturn Orbit": "saturn", "Titan Orbit": "saturn", "Titan Surface": "saturn",
"Uranus Orbit": "uranus", "Neptune Orbit": "neptune", "Triton Orbit": "neptune",
"Mercury Orbit": "mercury", "Mercury Surface": "mercury",
"Venus Orbit": "venus", "Venus Surface": "venus",
"Solar Orbit": "sun", "Transfer": "transfer",
"Ceres Orbit": "ceres", "Ceres Surface": "ceres",
"Vesta Orbit": "vesta", "Vesta Surface": "vesta",
"Pluto Orbit": "pluto", "Pluto Surface": "pluto",
}
asset_positions = []
for asset in assets:
if asset.is_retired:
continue
snapshot = _build_asset_snapshot(asset, simulation_time)
location = snapshot.get("current_location", "Transfer")
body = LOCATION_BODY_MAP.get(location, "transfer")
asset_positions.append({
"id": str(asset.id),
"name": asset.name,
"asset_type": asset.asset_type,
"location": location,
"body": body,
"mission": snapshot.get("current_mission", ""),
"state": snapshot.get("current_state", ""),
})
# Count ongoing missions and upcoming events
active_assets = [a for a in asset_positions if a["state"] in ("Transfer", "Exploration")]
upcoming = []
return render_template(
"mission_location_board_preview.html",
title="地点板",
active_page="location",
simulation_time_display=_format_datetime_display(simulation_time),
simulation_time_input=simulation_time_input,
asset_positions_json=__import__("json").dumps(asset_positions),
asset_count=len(asset_positions),
ongoing_count=len(active_assets),
upcoming_events=[],
)
@web_bp.get("/mission-preview/asset-log")
def mission_asset_log_preview() -> object:
simulation_time = _resolve_simulation_time(request.args.get("sim_time"))
return redirect(url_for("web.asset_list"
))
@web_bp.get("/mission-preview/timeline")
def mission_timeline_preview() -> str:
simulation_time = _resolve_simulation_time(request.args.get("sim_time"))
search_term = request.args.get("q", "").strip()
asset_type_filter = request.args.get("asset_type", "").strip()
location_filter = request.args.get("location", "").strip()
state_filter = request.args.get("state", "").strip()
record_scope = _normalize_record_scope(request.args.get("record_scope"))
requested_asset_ids = {asset_id for asset_id in request.args.getlist("asset_ids") if asset_id}
all_rows = _build_mission_board_rows(_load_assets_with_logs(), simulation_time)
# Apply search filter
if search_term:
all_rows = [r for r in all_rows if search_term.lower() in (r.get("name", "") or "").lower()]
filtered_rows = _filter_mission_board_rows(all_rows, asset_type_filter, location_filter, record_scope)
visible_rows = [
row for row in filtered_rows if not requested_asset_ids or str(row["asset_id"]) in requested_asset_ids
]
default_rows = visible_rows or filtered_rows or all_rows
initial_range_start, initial_range_end = _timeline_default_range(default_rows, simulation_time)
timeline_range_mode = "manual" if _normalized_text(request.args.get("range_mode")) == "manual" else "auto"
raw_scale = request.args.get("scale")
if timeline_range_mode == "manual":
range_start = _resolve_timeline_boundary(request.args.get("start"), initial_range_start)
range_end = _resolve_timeline_boundary(request.args.get("end"), initial_range_end)
else:
range_start = initial_range_start
range_end = initial_range_end
if range_end < range_start:
range_end = range_start
# Determine scale from the ACTUAL range, not the initial default
timeline_scale_mode = _resolve_timeline_scale(raw_scale, range_start, range_end)
if range_end < range_start:
range_end = range_start
timeline_scale = _timeline_scale_labels(range_start, range_end, timeline_scale_mode)
timeline_rows, timeline_counts = _build_timeline_rows(
visible_rows,
simulation_time,
range_start,
range_end,
timeline_scale_mode,
)
for r in timeline_rows:
r["asset_url"] = url_for("web.asset_detail", asset_id=r["asset_id"]
)
timeline_column_width = _timeline_column_width(timeline_scale_mode)
board_query_args = _mission_board_query_args(simulation_time, asset_type_filter, location_filter, record_scope)
timeline_query_args = {**board_query_args, "scale": timeline_scale_mode}
if requested_asset_ids:
timeline_query_args["asset_ids"] = sorted(requested_asset_ids)
if timeline_range_mode == "manual":
timeline_query_args["start"] = _format_datetime_input(range_start)
timeline_query_args["end"] = _format_datetime_input(range_end)
timeline_query_args["range_mode"] = timeline_range_mode
selected_asset_options = [
{
"value": str(row["asset_id"]),
"label": row["name"],
"asset_type": row["asset_type"],
"selected": str(row["asset_id"]) in requested_asset_ids,
}
for row in sorted(filtered_rows, key=lambda item: item["name"].casefold())
]
# Build JSON-LD like data for client-side rendering
import json as _json
timeline_json_data = {
"range_start": _format_datetime_input(range_start),
"range_end": _format_datetime_input(range_end),
"bucket_origin": _format_datetime_input(_timeline_bucket_start(range_start, timeline_scale_mode)),
"scale": timeline_scale_mode,
"bucket_count": len(timeline_scale),
"rows": [
{
"asset_id": str(r["asset_id"]),
"asset_name": str(r["name"]),
"asset_type": str(r["asset_type"]),
"asset_url": url_for("web.asset_detail", asset_id=r["asset_id"]
),
"segments": [
{
"entry_id": str(s["entry_id"]),
"label": str(s["label"]),
"tone": str(s["tone"]),
"url": str(s["url"]),
"start_position": float(s["start_position"]),
"body_end_position": float(s["body_end_position"]),
"display_end_position": float(s["display_end_position"]),
"track": int(s.get("track", 0)),
"start_at": str(s["start_at"]),
"end_at": str(s["end_at"]),
"body_color": str(s["body_color"]),
"start_iso": str(s.get("start_iso", "")),
"end_iso": str(s.get("end_iso", "")),
}
for s in r.get("segments", [])
],
"events": [
{
"label": str(e["label"]),
"url": str(e["url"]),
"position": float(e["position"]),
"at_iso": str(e.get("at_iso", "")),
"full_label": str(e.get("full_label", e["label"])),
"is_state_node": bool(e.get("is_state_node", False)),
"track": int(e.get("track", 0)),
"parent_entry_id": str(e.get("parent_entry_id", "")),
"parent_track": next(
(
int(segment.get("track", 0))
for segment in r.get("segments", [])
if segment.get("entry_id") == e.get("parent_entry_id")
),
None,
),
}
for e in r.get("events", [])
],
}
for r in timeline_rows
],
}
timeline_json = _json.dumps(timeline_json_data, ensure_ascii=False)
is_partial = request.args.get("_partial") == "1"
template = "mission_timeline_preview.html"
if is_partial:
return timeline_json, 200, {"Content-Type": "application/json"}
return render_template(
template,
title="时间线",
active_page="timeline",
search_term=search_term,
asset_type_filter=asset_type_filter,
location_filter=location_filter,
state_filter=state_filter,
preview_title="时间线",
preview_eyebrow="Mission Ops",
preview_tab="timeline",
timeline_scale=timeline_scale,
timeline_rows=timeline_rows,
timeline_json=timeline_json,
timeline_scale_mode=timeline_scale_mode,
timeline_scale_options=TIMELINE_SCALE_OPTIONS,
timeline_column_width=timeline_column_width,
timeline_range_start=_format_datetime_input(range_start),
timeline_range_end=_format_datetime_input(range_end),
timeline_range_mode=timeline_range_mode,
timeline_range_display=_timeline_range_display(range_start, range_end, timeline_scale_mode),
simulation_time=_format_datetime_display(simulation_time),
simulation_time_input=_format_datetime_input(simulation_time),
metrics=[
{"label": "Visible Assets", "value": str(len(timeline_rows))},
{"label": "State Segments", "value": str(timeline_counts["states"] )},
{"label": "Event Nodes", "value": str(timeline_counts["events"] )},
],
header_time_action=url_for("web.mission_timeline_preview"),
header_time_field_id="timeline-header-sim-time",
header_time_submit_label="Apply Time",
header_hidden_fields=_build_header_hidden_fields(
{
"asset_type": asset_type_filter,
"location": location_filter,
"record_scope": record_scope,
"scale": timeline_scale_mode,
"asset_ids": sorted(requested_asset_ids),
"range_mode": timeline_range_mode if timeline_range_mode == "manual" else None,
"start": _format_datetime_input(range_start) if timeline_range_mode == "manual" else None,
"end": _format_datetime_input(range_end) if timeline_range_mode == "manual" else None,
}
),
record_scope=record_scope,
selected_asset_ids=sorted(requested_asset_ids),
selected_asset_count=len(requested_asset_ids),
timeline_asset_options=selected_asset_options,
reset_url=url_for(
"web.mission_timeline_preview",
asset_type=asset_type_filter or None,
location=location_filter or None,
record_scope=record_scope,
scale=timeline_scale_mode,
asset_ids=sorted(requested_asset_ids),
),
asset_type_options=sorted({row["asset_type"] for row in all_rows if row["asset_type"]}, key=str.casefold),
location_options=sorted({row["location"] for row in all_rows if row["location"]}, key=str.casefold),
record_scope_options=MISSION_BOARD_RECORD_SCOPE_OPTIONS,
board_nav_urls={
"status": url_for("web.mission_status_board_preview", **board_query_args),
"location": url_for("web.mission_location_board_preview", **board_query_args),
"mission": url_for("web.mission_list"
),
"asset": url_for("web.asset_list"
),
"timeline": url_for("web.mission_timeline_preview", **timeline_query_args),
},
)