app.services.strategy_definition_service module

Service for the persistent strategy model.

Implements the versioning rules for the persistent strategy model:

  • Creating uses version=1 and status=DRAFT.

  • Editing a draft mutates the row in place.

  • Editing a published row forks version + 1 as a new draft instead of mutating the published copy.

  • Publishing a draft transitions it to PUBLISHED and archives any sibling that was previously published, so a (realmId, name) family only ever has at most one live row.

  • Archiving moves a row out of the active set without deleting history.

Tenancy is enforced at this layer: every read/write takes realmId and we never accept it from the caller body - the endpoint resolves it from the auth context and passes it in.

class app.services.strategy_definition_service.RollbackResult(strategy, games_reassigned, tasks_reassigned)[source]

Bases: object

Outcome of a rollback operation, returned by the service so the endpoint can include cascade counts in the audit log without re-hitting the DB.

Parameters:
  • strategy (StrategyDefinitionRead)

  • games_reassigned (int)

  • tasks_reassigned (int)

class app.services.strategy_definition_service.StrategyDefinitionService(strategy_definition_repository, game_repository=None, task_repository=None)[source]

Bases: BaseService

CRUD + lifecycle operations for custom strategies.

Parameters:

strategy_definition_repository (StrategyDefinitionRepository)

__init__(strategy_definition_repository, game_repository=None, task_repository=None)[source]

game_repository and task_repository are optional so legacy call sites that only need CRUD/lifecycle (most tests, the simulation service) keep working without a wider DI graph. The The rollback flow requires both - when missing, rollback() raises a precise error rather than silently leaving cascade UPDATEs undone.

Parameters:

strategy_definition_repository (StrategyDefinitionRepository)

Return type:

None

async name_exists(*, realmId, name)[source]

Whether any version of (realmId, name) already exists.

Used by the import endpoint to decide whether the incoming bundle needs an auto-rename to avoid colliding with the UNIQUE(realmId, name, version) constraint.

Parameters:
  • realmId (str | None)

  • name (str)

Return type:

bool

async list_strategies(*, realmId, status=None, type=None, limit=100)[source]

List strategy definitions for a realm, optionally filtered.

Parameters:
  • realmId (Optional[str]) – Realm/tenant to scope to.

  • status (Optional[str]) – Optional lifecycle-status filter.

  • type (Optional[str]) – Optional strategy-type filter.

  • limit (int) – Maximum rows to return.

Returns:

List[StrategyDefinitionRead] – The matching definitions.

Return type:

List[StrategyDefinitionRead]

async get_strategy(*, id, realmId)[source]

Fetch one strategy definition scoped to a realm.

Parameters:
  • id (str) – Strategy definition identifier.

  • realmId (Optional[str]) – Realm/tenant the strategy must belong to.

Returns:

StrategyDefinitionRead – The matching definition.

Raises:

NotFoundError – If no matching strategy exists in the realm.

Return type:

StrategyDefinitionRead

async create(*, payload, realmId, createdBy, apiKey_used, oauth_user_id)[source]

Create a new strategy-definition draft.

Validates the type/parent combination and any provided AST, rejects a name that already exists in the realm, and persists the draft as version 1.

Parameters:
  • payload (StrategyDefinitionCreate) – The strategy to create.

  • realmId (Optional[str]) – Realm/tenant that will own it.

  • createdBy (Optional[str]) – Identity recorded as the author.

  • apiKey_used (Optional[str]) – API key used for the request, if any.

  • oauth_user_id (Optional[str]) – OAuth subject, if any.

Returns:

StrategyDefinitionRead – The newly created draft.

Raises:
  • BadRequestError – If the type/parent combination is invalid.

  • DuplicatedError – If a strategy with the same name already exists.

Return type:

StrategyDefinitionRead

async update(*, id, payload, realmId, createdBy, apiKey_used, oauth_user_id)[source]

Apply an update.

  • On a DRAFT row: patch in place and return the same id.

  • On a PUBLISHED row: fork a new DRAFT at version + 1 with the patched fields applied, leaving the published row untouched so it keeps running until an explicit publish.

  • On an ARCHIVED row: refuse - archived strategies are immutable.

Parameters:
  • id (str)

  • payload (StrategyDefinitionUpdate)

  • realmId (str | None)

  • createdBy (str | None)

  • apiKey_used (str | None)

  • oauth_user_id (str | None)

Return type:

StrategyDefinitionRead

async publish(*, id, realmId)[source]

Publish a draft strategy, archiving any previously-published sibling.

Idempotent: re-publishing an already-published row is a no-op. If another version of the same name is published, it is archived first so only one published version exists per name.

Parameters:
  • id (str) – Strategy definition identifier.

  • realmId (Optional[str]) – Realm/tenant the strategy must belong to.

Returns:

StrategyDefinitionRead – The published strategy.

Raises:
  • NotFoundError – If no matching strategy exists in the realm.

  • ConflictError – If the strategy is archived.

Return type:

StrategyDefinitionRead

async archive(*, id, realmId)[source]

Archive a strategy so it can no longer be published or assigned.

Idempotent: archiving an already-archived row returns it unchanged.

Parameters:
  • id (str) – Strategy definition identifier.

  • realmId (Optional[str]) – Realm/tenant the strategy must belong to.

Returns:

StrategyDefinitionRead – The archived strategy.

Raises:

NotFoundError – If no matching strategy exists in the realm.

Return type:

StrategyDefinitionRead

async list_versions(*, id, realmId)[source]

Return every version in the family that contains id, newest first. The caller passes a single id (typically the current published version) and we resolve the family name from it so the endpoint contract stays “one id in, full history out”.

Tenant-scoped: get_for_realm 404s when the row belongs to another realm, so cross-tenant probing returns nothing.

Parameters:
  • id (str)

  • realmId (str | None)

Return type:

List[StrategyDefinitionRead]

async get_usage(*, id, realmId)[source]

Reverse lookup: which games/tasks are assigned to this exact strategy version.

Consumers store the assignable id custom:<uuid>, so usage is an exact match on that string - the same value the rollback cascade rewrites. We report per-version (not per-family) because each published version is a distinct uuid that games point at individually; that matches what an admin needs to see before reassigning, archiving or rolling back this version.

Tenant-scoped via get_for_realm: a cross-realm id 404s. No cross-tenant leak through the usage lists either - a game can only be assigned a strategy validated to live in its own realm, so every consumer of custom:<uuid> shares the strategy’s realm.

Parameters:
  • id (str)

  • realmId (str | None)

Return type:

StrategyUsageRead

async rollback(*, id, target_version, realmId)[source]

Promote target_version back to PUBLISHED in the family that contains id, archive whichever version is currently published, and rewrite every Games.strategyId / Tasks.strategyId pointing at the displaced row so no consumer is left referencing an ARCHIVED row.

id doesn’t have to be PUBLISHED: an admin may initiate rollback from any version of the family (e.g. browsing the version history UI). We always treat the family’s current PUBLISHED row as the one to archive + reassign, regardless of which id was clicked.

Errors:
  • 404 if id doesn’t resolve (or is in another realm).

  • 404 if target_version isn’t a version of that family.

  • 409 if the requested target is the row that’s already PUBLISHED - rolling back to the current state would be a no-op that falsely advertises a status change in the audit log.

Parameters:
  • id (str)

  • target_version (int)

  • realmId (str | None)

Return type:

RollbackResult