Skip to content

angee.graphql.actions

Shared GraphQL result type, guard, and target preflights for console domain actions.

ActionResult

python
@strawberry.type
class ActionResult()

Outcome of a console domain action: a success flag and a human message.

Returned by non-CRUD action mutations (sync, test, discover, register-payment, …) so the client can surface a toast and refresh the affected record.

On a domain failure the action returns ok=False and may populate validation_errors — a field → messages map keyed by the argument names the form binds to (its arg descriptor names) — which a typed-args action form binds to its inputs, keeping the dialog open until ok=True. Keys that match no argument surface at form level. This is the in-band path; a non-domain failure still raises a GraphQL error carrying the validationErrors extension instead.

id

Public id of the record the verb created, when the action creates one.

A create-and-return verb (register a payment, open a document) populates this so the client can route to or refresh the new record; a verb that only mutates an existing row leaves it None.

validation_error_map

python
@staticmethod
def validation_error_map(error: ValidationError | None,
                         *,
                         camel_case_keys: bool = True) -> JSON | None

Project field-keyed validation messages with caller-selected key casing.

from_error

python
@classmethod
def from_error(cls, error: Exception, summary: str) -> ActionResult

Return a failed result from a caught exception.

A Django ValidationError carrying per-field messages (error_dict) becomes the in-band validation_errors map a typed-args action form binds to its inputs: field names are camel-cased to match the GraphQL argument names the form binds to, and NON_FIELD_ERRORS (or any key that matches no argument) surfaces at form level. Any other exception — or a ValidationError with only non-field messages — yields a message-only failure. summary is the human banner shown either way; the raw exception text is never leaked into it.

BASELINE_ACTION_ERRORS

Domain exceptions an action guard maps to an in-band :class:ActionResult.

An action resolver raises these naturally from the model, manager, or transition it delegates to; :func:action_guard owns the uniform projection to :meth:ActionResult.from_error (the in-band validation_errors path) so each resolver body does not repeat the same try/except. An addon extends the set per action with action_guard(..., errors=(MyDomainError,)).

action_guard

python
def action_guard(
    summary: str, *, errors: tuple[type[Exception], ...] = ()
) -> Callable[[Callable[_P, ActionResult]], Callable[_P, ActionResult]]

Decorate an action resolver so domain errors return an in-band ActionResult.

Runs the resolver body; a raised baseline domain error (:data:BASELINE_ACTION_ERRORS) — plus any addon-local errors — is mapped through :meth:ActionResult.from_error with summary as the human banner, so the body raises naturally and one owner projects the failure (a Django ValidationError carrying error_dict becomes the field-keyed in-band validation_errors map a typed-args form binds). Any other exception propagates as a GraphQL error. @wraps preserves the resolver signature so a Strawberry field decorated with it keeps its introspected arguments. Every caught failure is logged with the action name and traceback before projection.

authorized_action_target

python
def authorized_action_target(info: strawberry.Info,
                             model: type[_RebacActionTarget], id: PublicID,
                             permission: str) -> _RebacActionTarget

Return the actor-authorized row a domain action targets.

The one owner of the action preflight ceremony (session gate → actor-scoped lookup → per-row permission check) that every in-band ActionResult verb repeats:

  1. An unauthenticated session raises rebac.PermissionDenied — a GraphQL error, the same contract as angee.iam's session_user gate, so the client re-authenticates instead of toasting.
  2. The row resolves through the actor's write-scoped queryset (:func:angee.graphql.writes.instance_for_write): a row the actor cannot reach reads as plain not-found, never an existence oracle.
  3. The resolved row must grant the per-row REBAC permission (e.g. "write", "write__status").

Not-found and denied raise the non-field ValidationError shape :func:action_guard maps to an in-band :class:ActionResult: the verb's guard summary banners the toast while the specific reason rides validation_errors[NON_FIELD_ERRORS]. The returned row stays bound to the actor, so the model write that follows runs under REBAC — this is the actor-scoped sibling of :func:resolve_action_target, which is elevated and leaves authorization to the caller.

resolve_action_target

python
def resolve_action_target(
    model: type[_ActionTarget],
    id: PublicID,
    *,
    reason: str,
    queryset: models.QuerySet[_ActionTarget] | None = None,
    select_related: tuple[str, ...] = ()
) -> _ActionTarget

Return an elevated action target addressed by one GraphQL public id.

The caller owns actor authorization, usually with field permission_classes (an operator/admin verb where the actor's role, not the row, authorizes). This helper owns the repeated action-write lookup shape: build the requested queryset, enter system_context for the row read, and raise a stable not-found error instead of leaking None into the action body. A missing row raises ValueError — a GraphQL error, matching the role-gated surface.

For a domain verb authorized by the row itself (the ceremony session gate → actor-scoped lookup → per-row check, returning in-band failures), use :func:authorized_action_target instead.

action_target

python
@contextmanager
def action_target(
    model: type[_ActionTarget],
    id: PublicID,
    *,
    reason: str,
    queryset: models.QuerySet[_ActionTarget] | None = None,
    select_related: tuple[str, ...] = ()
) -> Iterator[_ActionTarget]

Yield a resolved action target inside the matching elevated context.

Released under the AGPL-3.0 License.