Coverage for backend/django/core/auxiliary/services/flowsheet_states/registry.py: 91%
115 statements
« prev ^ index » next coverage.py v7.10.7, created at 2026-07-22 05:22 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2026-07-22 05:22 +0000
1"""Explicit copy boundary for every flowsheet-state-owned model."""
3from __future__ import annotations
5from dataclasses import dataclass, field
6from enum import StrEnum
8from django.apps import apps
11class CloneProfile(StrEnum):
12 """Supported relational clone operations."""
14 REVISION = "revision"
15 RESTORE = "restore"
16 NEW_FLOWSHEET = "new_flowsheet"
17 MODULE_SUBTREE = "module_subtree"
20class ClonePolicy(StrEnum):
21 """Action taken for a model in one clone profile."""
23 COPY = "copy"
24 REBUILD = "rebuild"
25 EXCLUDE = "exclude"
28class CloneRegistryError(ValueError):
29 """Raised when the declared aggregate boundary is incomplete or cyclic."""
32@dataclass(frozen=True)
33class ModelCloneContract:
34 """Copy declaration for one concrete Django model."""
36 label: str
37 policies: dict[CloneProfile, ClonePolicy]
38 clear_relations: frozenset[str] = field(default_factory=frozenset)
40 def policy_for(self, profile: CloneProfile) -> ClonePolicy:
41 """Return the action declared for ``profile``."""
43 return self.policies.get(profile, ClonePolicy.EXCLUDE)
46COPY_LABELS = {
47 "core.Plot",
48 "core.Series",
49 "core_auxiliary.IndexedItem",
50 "core_auxiliary.ControlValue",
51 "core_auxiliary.PropertyValue",
52 "core_auxiliary.PropertyInfo",
53 "core_auxiliary.PropertySet",
54 "core_auxiliary.MLModel",
55 "core_auxiliary.RecycleData",
56 "core_auxiliary.RecycleProperty",
57 "core_auxiliary.Note",
58 "core_auxiliary.Scenario",
59 "core_auxiliary.OptimizationDegreesOfFreedom",
60 "core_auxiliary.ParameterSweepDefinition",
61 "core_auxiliary.ParameterSweepParameter",
62 "core_auxiliary.DataColumn",
63 "core_auxiliary.DataRow",
64 "core_auxiliary.DataCell",
65 "core_auxiliary.MLColumnMapping",
66 "core_auxiliary.MonitoringTable",
67 "core_auxiliary.MonitoringTableProperty",
68 "core_auxiliary.CustomCompound",
69 "core_auxiliary.CompoundProperty",
70 "core_auxiliary.CustomPropertyPackage",
71 "core_auxiliary.CustomPropertyPackageProperty",
72 "core_auxiliary.Kappa",
73 "flowsheetInternals_unitops.Port",
74 "flowsheetInternals_unitops.SimulationObject",
75 "flowsheetInternals_graphicData.PortAnchorPlacement",
76 "flowsheetInternals_graphicData.GraphicObject",
77 "flowsheetInternals_graphicData.Grouping",
78 "PinchAnalysis.PinchInputs",
79 "PinchAnalysis.StreamDataEntry",
80 "PinchAnalysis.Segment",
81 "PinchAnalysis.PinchUtility",
82 "PinchAnalysis.TurbineOptions",
83 "PinchAnalysis.MainOptions",
84 "PinchAnalysis.StreamDataProject",
85 "PinchAnalysis.HenNode",
86 "Economics.EconomicsStudy",
87 "Economics.EconomicsStudyComparisonConfig",
88 "Economics.EconomicsStudyComparisonSelection",
89 "Economics.EconomicsSchedulePlan",
90 "Economics.EconomicsScheduleRule",
91 "Economics.EconomicsAssumptions",
92 "Economics.EconomicsBaseline",
93 "Economics.CostableItem",
94 "Economics.EquipmentMapping",
95 "Economics.CostDriver",
96 "Economics.CapitalCostLine",
97 "Economics.OperatingCostLine",
98 "Economics.EconomicsMetricFormula",
99 "Economics.EconomicsLineFormula",
100}
102EXCLUDE_LABELS = {
103 "core_auxiliary.HistoricalValue",
104 "core_auxiliary.ProcessPathProperty",
105 "core_auxiliary.BuildStateRequestVersion",
106 "core_auxiliary.ProcessPath",
107 "core_auxiliary.UploadSession",
108 "core_auxiliary.ScenarioResultSummaryCache",
109 "core_auxiliary.Solution",
110 "core_auxiliary.SolveCompletionEmail",
111 "core_auxiliary.Task",
112 "PinchAnalysis.PinchTemp",
113 "PinchAnalysis.TargetSummary",
114 "PinchAnalysis.HeatSupplierUtilitySummary",
115 "PinchAnalysis.HeatReceiverUtilitySummary",
116 "PinchAnalysis.PinchGraphSet",
117 "PinchAnalysis.PinchGraph",
118 "PinchAnalysis.PinchCurve",
119 "PinchAnalysis.GraphDataPoint",
120 "Economics.EconomicsResultRun",
121 "Economics.EconomicsResultDependency",
122 "Economics.EconomicsResultLine",
123 "Economics.EconomicsChartDataset",
124 "diagnostics.DiagnosticsResult",
125}
127REBUILD_LABELS = {
128 "core_auxiliary.ObjectTypeCounter",
129 "PinchAnalysis.PinchOutputs",
130}
132# Module templates historically copy the core topology/configuration portion of
133# their source flowsheet, not Pinch or Economics state and not large scenario
134# input tables. The separate coordinator uses this explicit subset. DataColumn
135# and P-Graph path rows remain included here because the pre-cutover module
136# insertion path copied them, even though whole-state revision profiles do not.
137MODULE_COPY_LABELS = {
138 label
139 for label in COPY_LABELS
140 if not label.startswith(("PinchAnalysis.", "Economics."))
141 and label
142 not in {
143 "core_auxiliary.DataRow",
144 "core_auxiliary.DataCell",
145 }
146}
147MODULE_COPY_LABELS.update(
148 {
149 "core_auxiliary.ProcessPath",
150 "core_auxiliary.ProcessPathProperty",
151 }
152)
154STABLE_FK_CLASSIFICATION = {
155 "core_auxiliary.flowsheetstate.flowsheet": "identity",
156 "core_auxiliary.project.active_flowsheet": "project",
157 "core_auxiliary.solvecompletionemail.flowsheet": "operational",
158 "core_auxiliary.task.flowsheet": "operational",
159 "core_auxiliary.uploadsession.flowsheet": "operational",
160 "flowsheetInternals_unitops.flowsheeteditoperation.flowsheet": "operational",
161}
163STATE_REFERENCE_CLASSIFICATION = {
164 "core_auxiliary.flowsheet.current_state": "identity_pointer",
165}
168def _contract(label: str) -> ModelCloneContract:
169 policies = {
170 CloneProfile.REVISION: ClonePolicy.EXCLUDE,
171 CloneProfile.RESTORE: ClonePolicy.EXCLUDE,
172 CloneProfile.NEW_FLOWSHEET: ClonePolicy.EXCLUDE,
173 CloneProfile.MODULE_SUBTREE: ClonePolicy.EXCLUDE,
174 }
175 if label in COPY_LABELS:
176 policies.update(
177 {
178 CloneProfile.REVISION: ClonePolicy.COPY,
179 CloneProfile.RESTORE: ClonePolicy.COPY,
180 CloneProfile.NEW_FLOWSHEET: ClonePolicy.COPY,
181 }
182 )
183 elif label == "core_auxiliary.ObjectTypeCounter":
184 policies.update(
185 {
186 CloneProfile.RESTORE: ClonePolicy.REBUILD,
187 CloneProfile.NEW_FLOWSHEET: ClonePolicy.REBUILD,
188 }
189 )
190 elif label == "PinchAnalysis.PinchOutputs":
191 policies.update(
192 {
193 CloneProfile.RESTORE: ClonePolicy.REBUILD,
194 CloneProfile.NEW_FLOWSHEET: ClonePolicy.REBUILD,
195 }
196 )
197 if label in MODULE_COPY_LABELS:
198 policies[CloneProfile.MODULE_SUBTREE] = ClonePolicy.COPY
200 clear_relations = frozenset(
201 {"csv_upload_session"} if label == "core_auxiliary.MLModel" else ()
202 )
203 return ModelCloneContract(
204 label=label,
205 policies=policies,
206 clear_relations=clear_relations,
207 )
210class FlowsheetStateCloneRegistry:
211 """Validated registry and dependency-order provider."""
213 def __init__(self):
214 labels = COPY_LABELS | EXCLUDE_LABELS | REBUILD_LABELS
215 self._contracts = {label: _contract(label) for label in labels}
217 def contract_for(self, model_or_label) -> ModelCloneContract:
218 """Resolve a contract by model class or canonical Django label."""
220 label = (
221 model_or_label
222 if isinstance(model_or_label, str)
223 else model_or_label._meta.label
224 )
225 try:
226 return self._contracts[label]
227 except KeyError as exc:
228 raise CloneRegistryError(f"No clone contract for {label}.") from exc
230 def policy_for(self, model_or_label, profile: CloneProfile) -> ClonePolicy:
231 """Return the model action for one profile."""
233 return self.contract_for(model_or_label).policy_for(profile)
235 def copy_models(
236 self,
237 profile: CloneProfile,
238 *,
239 include_scenario_results: bool = False,
240 ) -> list[type]:
241 """Return included models in validated foreign-key dependency order.
243 Scenario results are the one optional model in a full duplication;
244 revisions, restores, and ordinary duplicates keep excluding them.
245 """
247 included = {
248 apps.get_model(label)
249 for label, contract in self._contracts.items()
250 if contract.policy_for(profile) == ClonePolicy.COPY
251 or (
252 include_scenario_results
253 and label == "core_auxiliary.Solution"
254 )
255 }
256 dependencies = {model: set() for model in included}
257 for model in included:
258 for relation in model._meta.concrete_fields:
259 if (
260 relation.name == "flowsheet_state"
261 or not relation.is_relation
262 or relation.remote_field is None
263 or relation.remote_field.model not in included
264 ):
265 continue
266 dependencies[model].add(relation.remote_field.model)
268 ordered = []
269 remaining = dict(dependencies)
270 while remaining:
271 ready = sorted(
272 (model for model, deps in remaining.items() if not deps),
273 key=lambda model: model._meta.label_lower,
274 )
275 if not ready: 275 ↛ 276line 275 didn't jump to line 276 because the condition on line 275 was never true
276 cycle = ", ".join(
277 sorted(model._meta.label for model in remaining)
278 )
279 raise CloneRegistryError(
280 f"Clone dependency cycle requires an explicit handler: {cycle}."
281 )
282 ordered.extend(ready)
283 for model in ready:
284 remaining.pop(model)
285 for deps in remaining.values():
286 deps.difference_update(ready)
287 return ordered
289 def validate_completeness(self) -> None:
290 """Validate state ownership, stable ownership, and include/exclude edges."""
292 from core.auxiliary.models.Flowsheet import Flowsheet
293 from core.auxiliary.models.FlowsheetState import FlowsheetState
295 state_owned_models = []
296 stable_fields = set()
297 state_reference_fields = set()
298 for model in apps.get_models():
299 state_fields = [
300 relation
301 for relation in model._meta.concrete_fields
302 if (
303 relation.is_relation
304 and relation.remote_field is not None
305 and relation.remote_field.model is FlowsheetState
306 )
307 ]
308 owner_fields = [
309 relation
310 for relation in state_fields
311 if relation.name == "flowsheet_state"
312 ]
313 if owner_fields:
314 if len(owner_fields) != 1: 314 ↛ 315line 314 didn't jump to line 315 because the condition on line 314 was never true
315 raise CloneRegistryError(
316 f"{model._meta.label} has multiple state owner fields."
317 )
318 state_owned_models.append(model)
319 self.contract_for(model)
320 state_reference_fields.update(
321 f"{model._meta.label_lower}.{relation.name}"
322 for relation in state_fields
323 if relation.name != "flowsheet_state"
324 )
326 for relation in model._meta.concrete_fields:
327 if (
328 relation.is_relation
329 and relation.remote_field is not None
330 and relation.remote_field.model is Flowsheet
331 ):
332 stable_fields.add(
333 f"{model._meta.label_lower}.{relation.name}"
334 )
335 if stable_fields != set(STABLE_FK_CLASSIFICATION): 335 ↛ 336line 335 didn't jump to line 336 because the condition on line 335 was never true
336 missing = stable_fields - set(STABLE_FK_CLASSIFICATION)
337 stale = set(STABLE_FK_CLASSIFICATION) - stable_fields
338 raise CloneRegistryError(
339 f"Stable flowsheet FK classification mismatch; missing={sorted(missing)}, "
340 f"stale={sorted(stale)}."
341 )
342 if state_reference_fields != set(STATE_REFERENCE_CLASSIFICATION): 342 ↛ 343line 342 didn't jump to line 343 because the condition on line 342 was never true
343 missing = state_reference_fields - set(STATE_REFERENCE_CLASSIFICATION)
344 stale = set(STATE_REFERENCE_CLASSIFICATION) - state_reference_fields
345 raise CloneRegistryError(
346 "FlowsheetState reference classification mismatch; "
347 f"missing={sorted(missing)}, stale={sorted(stale)}."
348 )
350 state_owned_set = set(state_owned_models)
351 for profile in (
352 CloneProfile.REVISION,
353 CloneProfile.RESTORE,
354 CloneProfile.NEW_FLOWSHEET,
355 CloneProfile.MODULE_SUBTREE,
356 ):
357 for model in self.copy_models(profile):
358 contract = self.contract_for(model)
359 for relation in model._meta.concrete_fields:
360 if (
361 relation.name == "flowsheet_state"
362 or not relation.is_relation
363 or relation.remote_field is None
364 or relation.remote_field.model not in state_owned_set
365 ):
366 continue
367 related_policy = self.policy_for(
368 relation.remote_field.model,
369 profile,
370 )
371 if related_policy == ClonePolicy.COPY:
372 continue
373 if relation.null and relation.name in contract.clear_relations: 373 ↛ 375line 373 didn't jump to line 375 because the condition on line 373 was always true
374 continue
375 raise CloneRegistryError(
376 f"{model._meta.label}.{relation.name} points to "
377 f"{related_policy} {relation.remote_field.model._meta.label} "
378 f"without a declared clear/rebuild rule for {profile}."
379 )
381 # Force cycle validation even when callers only run the audit.
382 for profile in CloneProfile:
383 self.copy_models(profile)
386registry = FlowsheetStateCloneRegistry()