Coverage for netbox_data_import/preview_coordinator.py: 95.84%
505 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-30 09:47 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-30 09:47 +0000
1# SPDX-License-Identifier: Apache-2.0
2# SPDX-FileCopyrightText: 2026 Marcin Zieba <marcinpsk@gmail.com>
3"""The Preview Coordinator: the one owner of each browser session's active preview (ADR 0004).
5`setup_claim` gives the setup page the claim of the session's row, creating the empty row on first
6use. `read_preview` returns an immutable snapshot for a page or a read. `apply_preview_command` runs
7one command under the coordinator lock, then the profile lock, then whatever the command locks, and
8it is the only code that stores a plan or moves the revision. No other module touches preview state,
9and this one reads nothing from the session beyond its key.
10"""
12from __future__ import annotations
14import hashlib
15import logging
16import re
17import secrets
18import uuid
19from collections.abc import Callable, Iterable, Mapping
20from dataclasses import dataclass, field
21from functools import cached_property
22from types import MappingProxyType
23from typing import Any, ClassVar
25from core.signals import clear_events
26from django.conf import settings
27from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError
28from django.db import IntegrityError, transaction
29from django.utils import timezone
31from . import branching
32from .adapter_config import PREVIEW_PORT_CLASS_MAP
33from .import_engine import ImportEngine, StaleSourceDocument
34from .models import (
35 ImportProfile,
36 PreviewCoordinator,
37 PreviewState,
38 SourceDocument,
39 locked_profile_policy,
40 validate_adapter_target_module,
41 validate_registered_adapter,
42)
43from .plan import ImportPlan, PlanError, canonical_json
44from .preview_limits import MAX_PLAN_BYTES, preview_plan_byte_limit
45from .object_permissions import clear_user_permission_caches
46from .review_workspace import ReviewWorkspace, refuse_moved_policy
47from .public_refusal import PublicRefusal
49logger = logging.getLogger(__name__)
52def _refresh_actor(user) -> None:
53 """Read the actor's current status and grants after any wait for the profile lock."""
54 try:
55 user.refresh_from_db(fields=["is_active", "is_superuser"])
56 except ObjectDoesNotExist as exc:
57 raise PermissionDenied from exc
58 if not user.is_active:
59 raise PermissionDenied
60 clear_user_permission_caches(user)
63CLAIM_FIELDS = ("preview_token", "preview_revision", "preview_document", "preview_profile")
64_TOKEN = re.compile(r"[A-Za-z0-9_-]{43}")
65PLANNING_KEYS = ("site_id", "location_id", "tenant_id")
67NO_PREVIEW = "No import preview is in progress. Start a new import."
68CLAIM_INVALID = "This request does not name a preview. Reload the page and try again."
69STALE_PREVIEW = "This preview changed in another tab or request. Reload it and try again."
70NEWER_PREVIEW = "A newer import was started in another tab. Reload the page to continue there."
71EXPIRED_PREVIEW = "This preview expired. Start a new import."
72SUBMITTED_PREVIEW = "The import already started, so this preview can no longer take a decision."
73SYNC_QUEUED = (
74 "A trace synchronization waits in the queue and has not started. "
75 "Wait for it to finish, or cancel it, before you change this workspace."
76)
77SYNC_RUNNING = "A trace synchronization is running. Wait for it to finish before you change this workspace."
78SYNC_FINISHED = "The trace synchronization finished. Re-read the preview before the next decision."
79SYNC_STARTED = "The trace synchronization already started, so you cannot cancel it."
80SYNC_CANCELLED = "The operator cancelled this trace synchronization before it started."
81SYNC_CANCEL_DONE = "The trace synchronization was cancelled. The preview was re-read from NetBox."
82SYNC_NOT_THIS_PREVIEW = "This preview did not queue that trace synchronization."
83SYNC_OTHER_ACTIVE = "Another trace synchronization of this source waits or runs. Wait for it to finish."
84UNREADABLE_PREVIEW = "This preview cannot be read. Re-read the preview."
85PREVIEW_TOO_LARGE = "This preview is too large to store. Split the source file and import each part."
86SOURCE_GONE = "The stored source is no longer available. Upload it again."
89class StalePreview(PublicRefusal):
90 """The command named a preview that is not the active one, or one that cannot take it now."""
93class PreviewCommandRefused(PublicRefusal):
94 """A command refused for a reason this plugin wrote, so the response may state it."""
96 def __init__(self, message: str, status: int = 400):
97 super().__init__(message)
98 self.status = status
101@dataclass(frozen=True)
102class PreviewClaim:
103 """The preview a page displays: its generation token, revision, Source Document and profile."""
105 token: str
106 revision: int
107 document_id: int | None
108 profile_id: int | None
110 @classmethod
111 def posted(cls, data) -> PreviewClaim:
112 """Return the claim a request carries, refusing a missing or malformed one."""
113 token = data.get("preview_token", "")
114 if not isinstance(token, str) or not _TOKEN.fullmatch(token):
115 raise StalePreview(CLAIM_INVALID)
116 revision = _claim_number(data.get("preview_revision"))
117 if revision is None:
118 raise StalePreview(CLAIM_INVALID)
119 return cls(
120 token=token,
121 revision=revision,
122 document_id=_optional_claim_number(data.get("preview_document")),
123 profile_id=_optional_claim_number(data.get("preview_profile")),
124 )
126 def fields(self) -> dict[str, str]:
127 """Return the claim as the form fields a page renders and posts back."""
128 return {
129 "preview_token": self.token,
130 "preview_revision": str(self.revision),
131 "preview_document": "" if self.document_id is None else str(self.document_id),
132 "preview_profile": "" if self.profile_id is None else str(self.profile_id),
133 }
136def _claim_number(value) -> int | None:
137 """Return one posted positive integer, or None for anything else."""
138 if not isinstance(value, str) or not value.isascii() or not value.isdigit() or len(value) > 19:
139 return None
140 number = int(value)
141 return number if number >= 1 else None
144def _optional_claim_number(value) -> int | None:
145 """Return one posted id, None for an empty value, and refuse a malformed one."""
146 if value in (None, ""):
147 return None
148 number = _claim_number(value)
149 if number is None:
150 raise StalePreview(CLAIM_INVALID)
151 return number
154def _claim_of(row: PreviewCoordinator) -> PreviewClaim:
155 return PreviewClaim(
156 token=row.preview_token,
157 revision=row.revision,
158 document_id=row.source_document_id,
159 profile_id=row.profile_id,
160 )
163def _same_generation(row: PreviewCoordinator, expected: PreviewClaim) -> bool:
164 """Return whether a claim names this row's generation and identity, at any revision up to now."""
165 return (
166 secrets.compare_digest(row.preview_token, expected.token)
167 and expected.revision <= row.revision
168 and expected.document_id == row.source_document_id
169 and expected.profile_id == row.profile_id
170 )
173def _exact(row: PreviewCoordinator, expected: PreviewClaim) -> bool:
174 return _same_generation(row, expected) and expected.revision == row.revision
177def _new_token() -> str:
178 return secrets.token_urlsafe(32)
181def _session_binding(request, *, create: bool = False) -> str | None:
182 """Return the digest of the server-side session key, which selects the session's row."""
183 if create and not request.session.session_key:
184 request.session.save()
185 key = request.session.session_key
186 if not key:
187 return None
188 return hashlib.sha256(key.encode()).hexdigest()
191def _session_expiry(request):
192 return request.session.get_expiry_date()
195def _payload_expiry(request):
196 """Return when a new preview expires: with its session, and no later than its Source Document."""
197 return min(_session_expiry(request), timezone.now() + PreviewCoordinator.PAYLOAD_LIFETIME)
200def _owned(row: PreviewCoordinator | None, user) -> PreviewCoordinator | None:
201 if row is not None:
202 _check_owner(row, user)
203 return row
206def _check_owner(row: PreviewCoordinator, user) -> PreviewCoordinator:
207 if row.owner_id != user.pk:
208 # Django rotates the key at login, so a binding owned by another user is never this session's.
209 raise PermissionDenied("This preview belongs to another user.")
210 return row
213def _payload_expired(row: PreviewCoordinator) -> bool:
214 return row.state in PreviewState.ACTIVE and row.expires_at <= timezone.now()
217def _clear_payload(row: PreviewCoordinator, state: str) -> None:
218 row.state = state
219 row.profile_id = None
220 row.source_document_id = None
221 row.context = {}
222 row.plan = None
223 row.job_id = None
226def _expire(row: PreviewCoordinator) -> None:
227 """Clear an expired payload and start a new empty generation, so its claims stay refused."""
228 _clear_payload(row, PreviewState.EXPIRED)
229 row.preview_token = _new_token()
230 row.revision += 1
231 row.save()
234def planning_context_of(context: Mapping) -> dict:
235 """Return the planning context a preview with this context plans against."""
236 projected = {key: context.get(key) for key in PLANNING_KEYS}
237 # A preview without its own PortClass map keeps the planning context every earlier plan states.
238 if port_class_map := context.get(PREVIEW_PORT_CLASS_MAP):
239 projected[PREVIEW_PORT_CLASS_MAP] = dict(port_class_map)
240 return projected
243def with_preview_port_class(context: Mapping, label: str, port_class: str | None) -> dict:
244 """Return the preview context with one PortClass label mapped for this preview alone, or cleared."""
245 port_class_map = dict(context.get(PREVIEW_PORT_CLASS_MAP) or {})
246 if port_class is None:
247 port_class_map.pop(label, None)
248 else:
249 port_class_map[label] = port_class
250 updated = {key: value for key, value in context.items() if key != PREVIEW_PORT_CLASS_MAP}
251 if port_class_map:
252 updated[PREVIEW_PORT_CLASS_MAP] = dict(sorted(port_class_map.items()))
253 return updated
256def sync_hold_reason(statuses: Iterable[str]) -> str:
257 """Return why trace sync Jobs in these statuses hold their source, or "" when none of them is active."""
258 from core.choices import JobStatusChoices
260 active = set(statuses) & set(JobStatusChoices.ENQUEUED_STATE_CHOICES)
261 if not active:
262 return ""
263 return SYNC_RUNNING if JobStatusChoices.STATUS_RUNNING in active else SYNC_QUEUED
266def _sync_hold(jobs) -> str:
267 return sync_hold_reason(jobs.values_list("status", flat=True))
270def setup_claim(request) -> PreviewClaim:
271 """Return the claim the setup page posts, creating the session's empty row on its first visit."""
272 branching.refuse_branch()
273 binding = _session_binding(request, create=True)
274 row = _owned(PreviewCoordinator.objects.filter(session_binding=binding).defer("plan").first(), request.user)
275 if row is None:
276 try:
277 with transaction.atomic():
278 row = PreviewCoordinator.objects.create(
279 session_binding=binding,
280 owner=request.user,
281 preview_token=_new_token(),
282 expires_at=_session_expiry(request),
283 )
284 except IntegrityError:
285 # A concurrent first visit created it; the unique binding leaves exactly one row.
286 row = _check_owner(PreviewCoordinator.objects.defer("plan").get(session_binding=binding), request.user)
287 return _claim_of(row)
290@dataclass(frozen=True)
291class PreviewSnapshot:
292 """One immutable read of the session's preview, with its profile and document checked live."""
294 claim: PreviewClaim
295 state: str
296 expired: bool
297 context: Mapping[str, Any]
298 job_id: int | None
299 profile: ImportProfile | None
300 document: SourceDocument | None
301 _plan_data: dict | None = field(default=None, repr=False)
303 @property
304 def active(self) -> bool:
305 """Return whether a live, unexpired preview with its profile and document stands behind this read."""
306 return (
307 self.state in PreviewState.ACTIVE
308 and not self.expired
309 and self.profile is not None
310 and self.document is not None
311 )
313 @property
314 def planning_context(self) -> dict:
315 """Return the site, location and tenant the preview plans against."""
316 return planning_context_of(self.context)
318 @cached_property
319 def plan(self) -> ImportPlan:
320 """Return the stored plan, which is immutable, raising PlanError when this release cannot read it."""
321 if self._plan_data is None:
322 raise PlanError("The preview stores no Import Plan.")
323 return ImportPlan.from_dict(self._plan_data)
325 def workspace(self, viewer) -> ReviewWorkspace:
326 """Return the stored plan as one viewer's Review Workspace."""
327 return ReviewWorkspace(self.plan, viewer)
330def _frozen(value):
331 if isinstance(value, Mapping):
332 return MappingProxyType({key: _frozen(item) for key, item in value.items()})
333 if isinstance(value, (list, tuple)):
334 return tuple(_frozen(item) for item in value)
335 return value
338def read_preview(
339 request, *, expected: PreviewClaim | None = None, profile_action: str = "change", include_plan: bool = True
340) -> PreviewSnapshot:
341 """Return the session's preview as it stands now; with `expected`, refuse any other one."""
342 branching.refuse_branch()
343 binding = _session_binding(request)
344 row = None
345 if binding is not None:
346 rows = PreviewCoordinator.objects.filter(session_binding=binding)
347 row = _owned((rows if include_plan else rows.defer("plan")).first(), request.user)
348 if row is None:
349 if expected is not None:
350 raise StalePreview(NO_PREVIEW)
351 return PreviewSnapshot(
352 claim=PreviewClaim("", 1, None, None),
353 state=PreviewState.EMPTY,
354 expired=False,
355 context=MappingProxyType({}),
356 job_id=None,
357 profile=None,
358 document=None,
359 )
360 if expected is not None:
361 if not _exact(row, expected):
362 raise StalePreview(STALE_PREVIEW)
363 if _payload_expired(row):
364 raise StalePreview(EXPIRED_PREVIEW)
365 profile = document = None
366 if row.state in PreviewState.ACTIVE:
367 profile = ImportProfile.objects.restrict(request.user, profile_action).filter(pk=row.profile_id).first()
368 if profile is not None:
369 document = (
370 SourceDocument.objects.filter(pk=row.source_document_id, profile=profile).defer("content").first()
371 )
372 return PreviewSnapshot(
373 claim=_claim_of(row),
374 state=row.state,
375 expired=_payload_expired(row),
376 context=_frozen(row.context or {}),
377 job_id=row.job_id,
378 profile=profile,
379 document=document,
380 _plan_data=row.plan if include_plan else None,
381 )
384class LockedPreview:
385 """The preview one command runs against, read under the coordinator and the profile locks."""
387 def __init__(self, *, actor, profile, document, context, plan, job_id):
388 self.actor = actor
389 self.profile = profile
390 self.document = document
391 self.context = MappingProxyType(dict(context))
392 self.plan = plan
393 self.job_id = job_id
395 @property
396 def planning_context(self) -> dict:
397 """Return the site, location and tenant the preview plans against."""
398 return planning_context_of(self.context)
400 @property
401 def reviewed_fingerprint(self) -> str:
402 """Return the profile fingerprint the stored plan was made under."""
403 return self.plan.profile_fingerprint
405 @cached_property
406 def workspace(self) -> ReviewWorkspace:
407 """Return the stored plan as the acting operator's Review Workspace."""
408 return ReviewWorkspace(self.plan, self.actor)
411@dataclass(frozen=True)
412class CommandOutcome:
413 """What one command did. A command never stores a plan: the coordinator stores `plan` for it."""
415 message: str = ""
416 payload: Mapping[str, Any] = field(default_factory=dict)
417 # A replacement the command already produced, inside its own savepoint.
418 plan: ImportPlan | None = None
419 # The preview context the command leaves behind; a replacement plan must be planned against it.
420 context: Mapping[str, Any] | None = None
421 # Commit what the command wrote durably, keep the preview unchanged, then raise this.
422 refusal: Exception | None = None
423 # The state and Job a queueing command leaves behind, and its after-commit compensation.
424 state: str | None = None
425 job_id: int | None = None
426 compensate: Callable[[], None] | None = None
429@dataclass(frozen=True)
430class PreviewResult:
431 """A committed command: the claim the page now holds, the outcome, and the preview's profile."""
433 claim: PreviewClaim
434 outcome: CommandOutcome
435 profile_id: int | None
436 state: str
439class PreviewCommand:
440 """One coordinated change of the active preview (ADR 0004).
442 A subclass implements `apply`, which runs under the coordinator and profile locks against the
443 stored plan. The coordinator then replans when `replans` is set, and advances the revision when
444 `advances` is set. `apply` raises to refuse; everything it wrote rolls back.
445 """
447 allowed_states: ClassVar[frozenset[str]] = frozenset({PreviewState.READY})
448 profile_action: ClassVar[str] = "change"
449 reviews_policy: ClassVar[bool] = True
450 replans: ClassVar[bool] = True
451 advances: ClassVar[bool] = True
452 reads_plan: ClassVar[bool] = True
453 # Only setup replaces an expired preview; every other command finds it gone.
454 replaces_expired: ClassVar[bool] = False
456 def accepts(self, row: PreviewCoordinator, expected: PreviewClaim) -> bool:
457 """Return whether the posted claim names the row exactly."""
458 return _exact(row, expected)
460 def apply(self, preview: LockedPreview) -> CommandOutcome:
461 """Make this command's own writes against the locked preview."""
462 raise NotImplementedError
464 def run(self, request, row: PreviewCoordinator) -> CommandOutcome:
465 """Run the command against the locked row; the generic path every writer takes."""
466 _refuse_state(row, self.allowed_states)
467 with locked_profile_policy(row.profile_id):
468 _refresh_actor(request.user)
469 profile = (
470 ImportProfile.objects.restrict(request.user, self.profile_action).filter(pk=row.profile_id).first()
471 )
472 if profile is None:
473 raise ImportProfile.DoesNotExist("The import profile is no longer available.")
474 _refuse_unplannable(profile)
475 document = SourceDocument.objects.filter(pk=row.source_document_id, profile=profile).first()
476 if document is None:
477 raise StaleSourceDocument(SOURCE_GONE)
478 plan = None
479 if self.reads_plan:
480 plan = _stored_plan(row, request.user, document)
481 if self.reviews_policy:
482 refuse_moved_policy(profile, plan.profile_fingerprint)
483 preview = LockedPreview(
484 actor=request.user,
485 profile=profile,
486 document=document,
487 context=row.context or {},
488 plan=plan,
489 job_id=row.job_id,
490 )
491 outcome = self.apply(preview)
492 if outcome.refusal is not None:
493 # atomic-exit-safe: durable-refusal-committed
494 return outcome
495 if outcome.context is not None:
496 row.context = dict(outcome.context)
497 planning_context = planning_context_of(row.context or {})
498 replacement = outcome.plan
499 if replacement is not None and dict(replacement.planning_context) != planning_context:
500 raise RuntimeError("A preview command returned a plan made for another planning context.")
501 if replacement is None and self.replans:
502 replacement = ImportEngine.plan(profile, document, request.user, planning_context)
503 if replacement is not None:
504 _store_plan(row, replacement)
505 row.state = PreviewState.READY
506 row.job_id = None
507 if outcome.state is not None:
508 row.state = outcome.state
509 row.job_id = outcome.job_id
510 if self.advances:
511 row.revision += 1
512 row.save()
513 # atomic-exit-safe: command-published
514 return outcome
517def _refuse_unplannable(profile) -> None:
518 """Refuse a profile whose adapter this release cannot plan, because no command can replan it."""
519 try:
520 validate_registered_adapter(profile)
521 validate_adapter_target_module(profile.source_adapter)
522 except ValidationError as exc:
523 raise PreviewCommandRefused("; ".join(exc.messages), 409) from exc
526def _refuse_state(row: PreviewCoordinator, allowed) -> None:
527 """Refuse a command the row's state does not take, naming why."""
528 if row.state in allowed:
529 return
530 if row.state == PreviewState.SYNC_PENDING:
531 from core.models import Job
533 raise StalePreview(_sync_hold(Job.objects.filter(pk=row.job_id)) or SYNC_FINISHED)
534 if row.state == PreviewState.SUBMITTED:
535 raise StalePreview(SUBMITTED_PREVIEW)
536 if row.state == PreviewState.EXPIRED:
537 raise StalePreview(EXPIRED_PREVIEW)
538 raise StalePreview(NO_PREVIEW)
541def _stored_plan(row: PreviewCoordinator, actor, document) -> ImportPlan:
542 """Return the row's plan, refusing one this release cannot read or one made for another preview."""
543 try:
544 plan = ImportPlan.from_dict(row.plan)
545 except PlanError as exc:
546 raise StalePreview(UNREADABLE_PREVIEW) from exc
547 if (
548 plan.actor != str(actor.pk)
549 or plan.source_fingerprint != document.content_fingerprint
550 or dict(plan.planning_context) != planning_context_of(row.context or {})
551 ):
552 logger.warning("Preview coordinator %s holds a plan made for another preview.", row.pk)
553 raise StalePreview(UNREADABLE_PREVIEW)
554 return plan
557def validate_preview_plan(plan: ImportPlan) -> dict:
558 """Return a storable preview, or refuse it before the command's writes commit."""
559 data = plan.to_dict()
560 limit = preview_plan_byte_limit(
561 settings.PLUGINS_CONFIG["netbox_data_import"].get("preview_max_plan_bytes", MAX_PLAN_BYTES)
562 )
563 if len(canonical_json(data).encode()) > limit:
564 raise PreviewCommandRefused(PREVIEW_TOO_LARGE, status=413)
565 return data
568def _store_plan(row: PreviewCoordinator, plan: ImportPlan) -> None:
569 row.plan = validate_preview_plan(plan)
572def apply_preview_command(request, expected: PreviewClaim, command: PreviewCommand) -> PreviewResult:
573 """Run one command under the coordinator lock and publish what it changed; refuse a stale claim."""
574 branching.refuse_branch()
575 binding = _session_binding(request)
576 if binding is None:
577 raise StalePreview(NO_PREVIEW)
578 committed: list[bool] = []
579 outcome: CommandOutcome | None = None
580 refusal: Exception | None = None
581 try:
582 with transaction.atomic():
583 # Registered first, so it runs before a queue push that may raise after the commit.
584 transaction.on_commit(lambda: committed.append(True))
585 row = _owned(
586 PreviewCoordinator.objects.select_for_update().filter(session_binding=binding).first(),
587 request.user,
588 )
589 row = _refuse_claim(row, expected, command)
590 if _payload_expired(row) and not command.replaces_expired:
591 _expire(row)
592 refusal = StalePreview(EXPIRED_PREVIEW)
593 else:
594 outcome = command.run(request, row)
595 refusal = outcome.refusal
596 claim, profile_id, state = _claim_of(row), row.profile_id, row.state
597 except BaseException:
598 if not committed:
599 # Everything the command wrote rolled back, so NetBox must not send the events it queued.
600 clear_events.send(sender=PreviewCommand)
601 elif outcome is not None and outcome.compensate is not None:
602 outcome.compensate()
603 raise
604 if refusal is not None:
605 raise refusal
606 if outcome is None:
607 raise RuntimeError("A preview command finished without an outcome.")
608 return PreviewResult(claim=claim, outcome=outcome, profile_id=profile_id, state=state)
611def _refuse_claim(
612 row: PreviewCoordinator | None, expected: PreviewClaim, command: PreviewCommand
613) -> PreviewCoordinator:
614 """Refuse a command whose claim does not name this session's row as the command requires."""
615 if row is None:
616 raise StalePreview(NO_PREVIEW)
617 if not command.accepts(row, expected):
618 raise StalePreview(
619 STALE_PREVIEW if secrets.compare_digest(row.preview_token, expected.token) else NEWER_PREVIEW
620 )
621 return row
624class StartPreview(PreviewCommand):
625 """Store a new upload, plan it, and make it the session's preview (the setup command).
627 It may replace any revision of the generation the setup page showed, never a newer one.
628 """
630 allowed_states = frozenset({value for value, _label in PreviewState.CHOICES})
631 replaces_expired = True
633 def __init__(self, *, profile, content: bytes, filename: str, site, location=None, tenant=None):
634 self.profile = profile
635 self.content = content
636 self.filename = filename
637 self.context = {
638 "site_id": site.pk,
639 "location_id": location.pk if location else None,
640 "tenant_id": tenant.pk if tenant else None,
641 "filename": filename,
642 }
644 def accepts(self, row, expected):
645 """Return whether the claim names the row's generation, at any revision it reached."""
646 return _same_generation(row, expected)
648 def run(self, request, row):
649 """Store the upload, plan it, and start a new generation in the locked row."""
650 with locked_profile_policy(self.profile.pk):
651 _refresh_actor(request.user)
652 profile = ImportProfile.objects.restrict(request.user, "change").filter(pk=self.profile.pk).first()
653 if profile is None:
654 raise ImportProfile.DoesNotExist("The import profile is no longer available.")
655 document = SourceDocument.store(
656 profile=profile, content=self.content, filename=self.filename, uploaded_by=request.user
657 )
658 plan = ImportEngine.plan(profile, document, request.user, planning_context_of(self.context))
659 # A new generation: every claim on the old one is refused from here on.
660 row.preview_token = _new_token()
661 row.revision += 1
662 row.state = PreviewState.READY
663 row.profile_id = profile.pk
664 row.source_document_id = document.pk
665 row.context = self.context
666 row.job_id = None
667 _store_plan(row, plan)
668 row.expires_at = _payload_expiry(request)
669 row.save()
670 # atomic-exit-safe: new-generation-published
671 return CommandOutcome(payload={"plan": plan})
674class DiscardPreview(PreviewCommand):
675 """End the preview; the next setup starts from an empty generation."""
677 allowed_states = frozenset(PreviewState.ACTIVE)
678 # An expired preview is still discarded, rather than refused for having expired.
679 replaces_expired = True
681 def run(self, request, row):
682 """Clear the locked row and start an empty generation."""
683 _refuse_state(row, self.allowed_states)
684 _clear_payload(row, PreviewState.EMPTY)
685 row.preview_token = _new_token()
686 row.revision += 1
687 row.expires_at = _session_expiry(request)
688 row.save()
689 return CommandOutcome(message="The import preview was discarded.")
692class RereadPreview(PreviewCommand):
693 """Replan the preview from its stored source against live NetBox.
695 It is also the schema recovery, so it never reads the stored plan, and it ends a finished trace
696 sync's hold. It refuses while that sync's Job still runs, because NetBox is mid-write then.
697 """
699 allowed_states = frozenset({PreviewState.READY, PreviewState.SYNC_PENDING, PreviewState.SUBMITTED})
700 reviews_policy = False
701 reads_plan = False
703 def run(self, request, row):
704 """Re-read a submitted preview only if its native Job has disappeared."""
705 from core.models import Job
707 if row.state == PreviewState.SUBMITTED and Job.objects.filter(pk=row.job_id).exists():
708 raise StalePreview(SUBMITTED_PREVIEW)
709 return super().run(request, row)
711 def apply(self, preview):
712 """Refuse while any sync of this source still writes; the coordinator replans."""
713 from .jobs import recover_abandoned_import_job, retained_sync_jobs
715 for job in retained_sync_jobs(preview.actor, preview.profile.pk, preview.document.pk):
716 recover_abandoned_import_job(job)
718 if reason := _sync_hold(retained_sync_jobs(preview.actor, preview.profile.pk, preview.document.pk)):
719 raise StalePreview(reason)
720 return CommandOutcome(message="The preview was re-read from NetBox.")
723class CancelTraceSync(PreviewCommand):
724 """Cancel the preview's own trace sync before a worker starts it, then replan the preview.
726 A worker that holds the Job lock owns the delivery, so the command refuses instead of waiting.
727 """
729 allowed_states = frozenset({PreviewState.SYNC_PENDING})
730 reviews_policy = False
731 reads_plan = False
733 def __init__(self, job_id: int):
734 self.job_id = job_id
736 def apply(self, preview):
737 """Refuse another preview's Job, a Job a worker took, or a source another sync holds."""
738 from core.choices import JobStatusChoices
740 from .jobs import ImportJobRunner, cancel_queued_import_job, retained_sync_jobs
742 job = (
743 ImportJobRunner.get_jobs()
744 .filter(
745 pk=self.job_id,
746 user=preview.actor,
747 data__job_type=ImportJobRunner.job_type,
748 data__keeps_preview=True,
749 data__profile_id=preview.profile.pk,
750 data__source_document_id=preview.document.pk,
751 )
752 .first()
753 )
754 if job is None or job.pk != preview.job_id:
755 raise StalePreview(SYNC_NOT_THIS_PREVIEW)
756 # The replan below reads NetBox, which another sync of this source may be writing.
757 if retained_sync_jobs(preview.actor, preview.profile.pk, preview.document.pk).exclude(pk=job.pk).exists():
758 raise StalePreview(SYNC_OTHER_ACTIVE)
759 if not cancel_queued_import_job(job, SYNC_CANCELLED):
760 raise StalePreview(SYNC_FINISHED if job.status in JobStatusChoices.TERMINAL_STATE_CHOICES else SYNC_STARTED)
761 return CommandOutcome(message=SYNC_CANCEL_DONE)
764class RestorePreview(PreviewCommand):
765 """Return to the preview whose final import failed, replanned against live NetBox."""
767 allowed_states = frozenset({PreviewState.SUBMITTED})
768 reviews_policy = False
769 reads_plan = False
771 def __init__(self, job_id: int):
772 self.job_id = job_id
774 def apply(self, preview):
775 """Refuse a Job this preview did not submit, or one that did not fail."""
776 from core.choices import JobStatusChoices
777 from core.models import Job
778 from .jobs import recover_abandoned_import_job
780 if preview.job_id != self.job_id:
781 raise StalePreview("This preview does not belong to that failed import.")
782 job = Job.objects.filter(pk=self.job_id).first()
783 if job is not None:
784 recover_abandoned_import_job(job)
786 failed = Job.objects.filter(
787 pk=self.job_id, status__in=(JobStatusChoices.STATUS_FAILED, JobStatusChoices.STATUS_ERRORED)
788 )
789 if not failed.exists():
790 raise StalePreview("This preview does not belong to that failed import.")
791 return CommandOutcome(message="The preview of the failed import was re-read from NetBox.")
794class QueueImport(PreviewCommand):
795 """Queue a selection of the reviewed plan, recording the Job on the preview it came from.
797 The final import submits the preview. A per-trace sync keeps it, pending until its Job ends.
798 """
800 replans = False
801 keeps_preview: ClassVar[bool] = False
803 def selection_for(self, preview: LockedPreview) -> list[str]:
804 """Return the Synchronization Units to queue, or raise PreviewCommandRefused."""
805 raise NotImplementedError
807 def apply(self, preview):
808 """Queue one Job for the selection and leave the preview submitted or pending on it."""
809 from core.choices import JobNotificationChoices, JobStatusChoices
810 from core.models import Job
812 from .cable_disclosure import redact_deleted_cables
813 from .jobs import ImportJobRunner, retained_sync_jobs
815 selection = self.selection_for(preview)
816 # The Job rows order this against a sync another request queued for the same source.
817 if reason := _sync_hold(retained_sync_jobs(preview.actor, preview.profile.pk, preview.document.pk)):
818 raise StalePreview(reason)
819 job = ImportJobRunner.enqueue(
820 name=ImportJobRunner.name,
821 user=preview.actor,
822 notifications=JobNotificationChoices.NOTIFICATION_NEVER,
823 job_timeout=3600,
824 profile_id=preview.profile.pk,
825 source_document_id=preview.document.pk,
826 accepted_plan=redact_deleted_cables(preview.plan.to_dict()),
827 selection=selection,
828 idempotency_key=uuid.uuid4().hex,
829 )
830 job.data = {
831 "job_type": ImportJobRunner.job_type,
832 "phase": "queued",
833 "processed": 0,
834 "total": 0,
835 "filename": preview.context.get("filename", ""),
836 "profile_id": preview.profile.pk,
837 "profile_name": preview.profile.name,
838 "source_document_id": preview.document.pk,
839 "context_data": dict(preview.context),
840 # What makes this Job hold its preview, for the conservative check above.
841 "keeps_preview": self.keeps_preview,
842 }
843 job.save(update_fields=["data"])
845 def compensate():
846 # The push runs after commit, so a Job no worker will run must not hold the preview.
847 Job.objects.filter(pk=job.pk, status=JobStatusChoices.STATUS_PENDING).update(
848 status=JobStatusChoices.STATUS_ERRORED
849 )
850 _release_job(job.pk)
852 return CommandOutcome(
853 state=PreviewState.SYNC_PENDING if self.keeps_preview else PreviewState.SUBMITTED,
854 job_id=job.pk,
855 compensate=compensate,
856 )
859def _release_job(job_id: int) -> None:
860 """Return a preview to review when its queued Job never reached a worker, and only that one."""
861 with transaction.atomic():
862 row = PreviewCoordinator.objects.select_for_update().filter(job_id=job_id).first()
863 if row is None or row.state not in PreviewState.HOLDS_JOB:
864 # atomic-exit-safe: release-not-needed
865 return
866 row.state = PreviewState.READY
867 row.job_id = None
868 row.revision += 1
869 row.save()
872def expire_previews(*, now=None) -> tuple[int, int]:
873 """Clear payloads past their expiry and delete rows long after it; return both counts.
875 A deleted row cannot revive a claim: only the setup page creates a row, with a fresh token.
876 """
877 reference_time = now or timezone.now()
878 if timezone.is_naive(reference_time):
879 raise ValueError("now must be timezone-aware")
880 expired = 0
881 while True:
882 with transaction.atomic():
883 batch = list(
884 PreviewCoordinator.objects.select_for_update(skip_locked=True)
885 .filter(state__in=sorted(PreviewState.ACTIVE), expires_at__lte=reference_time)
886 .order_by("pk")[:100]
887 )
888 for row in batch:
889 _expire(row)
890 expired += len(batch)
891 if len(batch) < 100:
892 break
893 cutoff = reference_time - PreviewCoordinator.PAYLOAD_LIFETIME
894 deleted, _ = PreviewCoordinator.objects.filter(
895 state__in=(PreviewState.EMPTY, PreviewState.EXPIRED), expires_at__lte=cutoff
896 ).delete()
897 return expired, deleted
900__all__ = (
901 "CLAIM_FIELDS",
902 "CancelTraceSync",
903 "CommandOutcome",
904 "DiscardPreview",
905 "LockedPreview",
906 "PreviewClaim",
907 "PreviewCommand",
908 "PreviewCommandRefused",
909 "PreviewResult",
910 "PreviewSnapshot",
911 "QueueImport",
912 "RereadPreview",
913 "RestorePreview",
914 "StalePreview",
915 "StartPreview",
916 "apply_preview_command",
917 "expire_previews",
918 "planning_context_of",
919 "read_preview",
920 "setup_claim",
921 "sync_hold_reason",
922 "with_preview_port_class",
923)