"""Preflight gating for the filing submission engine.
``Preflight`` runs four ordered gates against a
:class:`cadrumo.domain.submission.protocols.ModeloDraftLike` before any
browser work begins. Every failure raises
:class:`SubmissionPreflightError`; the happy path is silent.
See Also:
:class:`~cadrumo.domain.submission.engine.SubmissionEngine`
Public read-only engine that owns this preflight runner.
:class:`~cadrumo.domain.submission.protocols.DeadlineWindowChecker`
Gate-3 protocol used only when the caller has not skipped the filing
window check.
:class:`~cadrumo.application.workflow.run_models.WorkflowPurpose`
Application policy input that decides whether workflow callers pass
``skip_deadline_window`` for local verification or filing.
"""
from __future__ import annotations
from datetime import date
from ...core.errors.hierarchy import CadrumoError
from ...core.errors.severity import BaseSeverity
from ...core.i18n.auth_provider import describe_auth_provider_operator_impact
from ...core.logging import get_logger
from ...core.parsing.utils import enum_value as _enum_value
from .errors import SubmissionPreflightError
from .models import ModeloDraftStatus
from .protocols import AuthProviderProbe, DeadlineWindowChecker, ModeloDraftLike
_logger = get_logger(__name__)
_PREFLIGHT_DRAFT_STALE_LOCALE_KEY = "errors.refused.submission_preflight_draft_stale"
_PREFLIGHT_DRAFT_NOT_APPROVED_LOCALE_KEY = "errors.refused.submission_preflight_draft_not_approved"
_PREFLIGHT_ERROR_FINDINGS_LOCALE_KEY = "errors.refused.submission_preflight_error_findings"
_PREFLIGHT_DEADLINE_CLOSED_LOCALE_KEY = "errors.refused.submission_preflight_deadline_closed"
_PREFLIGHT_AUTH_DESCRIBE_FAILED_LOCALE_KEY = "errors.refused.submission_preflight_auth_describe_failed"
_PREFLIGHT_AUTH_NOT_READY_LOCALE_KEY = "errors.refused.submission_preflight_auth_not_ready"
def _check_draft_approval(draft: ModeloDraftLike) -> None:
"""Enforce the first gate: only an approved draft may be submitted."""
status_value = _enum_value(draft.status)
if status_value != ModeloDraftStatus.APROBADO.value:
_logger.debug("preflight gate-1 fail: draft status=%s", draft.status)
if status_value == ModeloDraftStatus.APROBACION_CADUCADA.value:
raise SubmissionPreflightError(
"draft approval is stale",
translated_message=_PREFLIGHT_DRAFT_STALE_LOCALE_KEY,
context={"status": status_value},
)
raise SubmissionPreflightError(
"draft not approved for submission",
translated_message=_PREFLIGHT_DRAFT_NOT_APPROVED_LOCALE_KEY,
context={"status": status_value},
)
_logger.debug("preflight gate-1 ok: draft is approved")
def _check_error_findings(draft: ModeloDraftLike) -> None:
"""Enforce the second gate without hiding malformed finding objects."""
# Read through the typed ``ModeloFindingLike`` port (``.severity`` is
# a REQUIRED field on both real implementations) rather than
# ``getattr(f, "severity", None)``: a future rename of the field
# must fail loud here, not silently exclude every finding -- error
# severity included -- from the gate whose entire job is blocking
# submission on them.
error_findings = tuple(f for f in draft.findings if f.severity == BaseSeverity.ERROR)
if error_findings:
_logger.debug(
"preflight gate-2 fail: %d error-severity findings",
len(error_findings),
)
raise SubmissionPreflightError(
"draft has error-severity findings",
translated_message=_PREFLIGHT_ERROR_FINDINGS_LOCALE_KEY,
context={"finding_count": len(error_findings)},
)
_logger.debug("preflight gate-2 ok: no error findings")
def _check_deadline_window(
deadline_checker: DeadlineWindowChecker,
draft: ModeloDraftLike,
today: date,
*,
skip: bool,
) -> None:
"""Enforce the third gate unless the caller explicitly skipped it."""
if skip:
_logger.debug("preflight gate-3 skipped: verification is independent of the filing window")
elif not deadline_checker.is_window_open(draft.modelo, draft.period, today):
_logger.debug(
"preflight gate-3 fail: deadline window closed for %s %s on %s",
draft.modelo,
draft.period,
today,
)
raise SubmissionPreflightError(
"deadline window is closed",
translated_message=_PREFLIGHT_DEADLINE_CLOSED_LOCALE_KEY,
context={"modelo": draft.modelo, "period": str(draft.period), "today": today.isoformat()},
)
else:
_logger.debug("preflight gate-3 ok: deadline window is open")
def _check_auth_readiness(
auth_provider: AuthProviderProbe,
*,
skip: bool,
) -> None:
"""Enforce the fourth gate unless the workflow has no live AEAT call."""
if skip:
_logger.debug(
"preflight gate-4 skipped: auth-provider readiness binds only "
"live/AEAT-touching purposes, not the local build/verify/file/export flow",
)
return
try:
description = auth_provider.describe()
except CadrumoError as exc:
_logger.warning("preflight gate-4 fail: auth provider describe raised", exc_info=True)
raise SubmissionPreflightError(
"auth provider failed to describe itself",
translated_message=_PREFLIGHT_AUTH_DESCRIBE_FAILED_LOCALE_KEY,
context={"cause_type": type(exc).__name__},
) from exc
if not description.configured or not description.available:
_logger.debug(
"preflight gate-4 fail: auth provider unavailable kind=%s configured=%s available=%s",
description.kind,
description.configured,
description.available,
)
raise SubmissionPreflightError(
"auth provider is not ready",
translated_message=_PREFLIGHT_AUTH_NOT_READY_LOCALE_KEY,
context={
"kind": _enum_value(description.kind),
"configured": description.configured,
"available": description.available,
"operator_impact": describe_auth_provider_operator_impact(description),
},
)
_logger.debug(
"preflight gate-4 ok: auth provider ready (kind=%s expires_on=%s)",
description.kind,
description.expires_on,
)
[docs]
class Preflight:
"""Four-gate validator for a :class:`ModeloDraftLike`.
Gates run in order:
1. Draft status is :attr:`ModeloDraftStatus.APROBADO`.
2. No ``ERROR``-severity entries in ``draft.findings``.
3. Deadline window is open via
:meth:`DeadlineWindowChecker.is_window_open`.
4. Auth provider describes itself cleanly via :meth:`AuthProviderProbe.describe`.
The validator is pure: no I/O beyond the injected Protocol calls,
no state beyond its dependencies.
Attributes:
deadline_checker: Protocol implementation used for gate 3.
auth_provider: Protocol implementation used for gate 4.
"""
def __init__(
self,
*,
deadline_checker: DeadlineWindowChecker,
auth_provider: AuthProviderProbe,
) -> None:
"""Construct a preflight validator.
Args:
deadline_checker: Protocol used for the deadline-window gate.
auth_provider: Protocol used for the auth-provider gate.
"""
self.deadline_checker = deadline_checker
self.auth_provider = auth_provider
[docs]
def check(
self,
draft: ModeloDraftLike,
*,
today: date,
skip_deadline_window: bool = False,
skip_auth_readiness: bool = False,
) -> None:
"""Run the four preflight gates against ``draft``.
Args:
draft: The :class:`ModeloDraftLike` to validate.
today: Reference date for the deadline-window gate.
skip_deadline_window: When ``True``, gate 3 (the AEAT
filing-window check) is skipped. Workflow callers use
this for local VERIFY and local FILE purposes: the
redundant AEAT submission-window check remains disabled
for paths that do not submit to AEAT.
skip_auth_readiness: When ``True``, gate 4 (auth-provider
readiness) is skipped. Auth binds only live/AEAT-touching
purposes (pull, reconcile, a live session): the whole
local artefact flow — build, calculate, VERIFY, local
FILE, export — completes with no auth provider configured,
because the human uploads at the AEAT portal themselves.
Callers that perform an actual AEAT read/submission leave
the gate enabled (the default).
Raises:
SubmissionPreflightError: If any gate fails. The exception
message identifies the failing gate.
"""
_logger.debug(
"preflight start: draft_id=%s modelo=%s period=%s",
draft.draft_id,
draft.modelo,
draft.period,
)
_check_draft_approval(draft)
_check_error_findings(draft)
_check_deadline_window(
self.deadline_checker,
draft,
today,
skip=skip_deadline_window,
)
_check_auth_readiness(self.auth_provider, skip=skip_auth_readiness)