Files
group_fqcd_jr/app/service/config_release_service.py
T

164 lines
6.1 KiB
Python

from datetime import UTC, datetime
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.model.audit import InteractionAudit
from app.model.configuration import ConfigRelease, PlatformConfigItem
from app.model.platform import DomainEventOutbox
class ConfigReleaseError(ValueError):
pass
class ConfigReleaseService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def submit_for_review(self, release_id: int, actor_id: int) -> ConfigRelease:
release = await self._get(release_id)
if release.created_by != actor_id or release.status != "draft":
raise ConfigReleaseError("only draft owner can submit")
release.status = "pending_review"
release.updated_at = self._now()
self._audit("config.submit_review", actor_id, release)
await self.session.flush()
return release
async def approve(self, release_id: int, reviewer_id: int) -> ConfigRelease:
release = await self._get(release_id)
if release.status != "pending_review":
raise ConfigReleaseError("release is not pending review")
if release.created_by == reviewer_id:
raise ConfigReleaseError("creator cannot review own release")
release.status = "approved"
release.reviewer_id = reviewer_id
release.reviewed_at = self._now()
release.updated_at = release.reviewed_at
self._audit("config.approve", reviewer_id, release)
await self.session.flush()
return release
async def activate(self, release_id: int, actor_id: int) -> ConfigRelease:
release = await self._get(release_id)
if release.status != "approved":
raise ConfigReleaseError("only approved release can activate")
active = await self.session.scalars(
select(ConfigRelease).where(ConfigRelease.status == "active")
)
now = self._now()
for previous in active:
previous.status = "superseded"
previous.updated_at = now
release.status = "active"
release.activated_at = now
release.updated_at = now
self._audit("config.activate", actor_id, release)
self.session.add(
DomainEventOutbox(
id=0,
event_id=str(uuid4()),
event_type="config.cache_invalidate_requested",
aggregate_type="config_release",
aggregate_id=str(release.id),
trace_id=release.release_no,
payload={"release_id": release.id, "release_no": release.release_no},
status="pending",
retry_count=0,
occurred_at=now,
created_at=now,
updated_at=now,
)
)
await self.session.flush()
return release
async def rollback(self, release_id: int, actor_id: int) -> ConfigRelease:
target = await self._get(release_id)
if target.status not in {"active", "superseded"}:
raise ConfigReleaseError("release cannot be rolled back")
now = self._now()
current = await self.session.scalars(
select(ConfigRelease).where(ConfigRelease.status == "active")
)
for release in current:
release.status = "superseded"
release.updated_at = now
await self.session.flush()
rollback = ConfigRelease(
release_no=f"rollback-{uuid4().hex[:16]}",
title=f"Rollback {target.release_no}",
change_summary=f"Rollback to {target.release_no}",
status="active",
created_by=actor_id,
reviewer_id=None,
reviewed_at=None,
activated_at=now,
created_at=now,
updated_at=now,
rollback_of_release_id=target.id,
)
self.session.add(rollback)
await self.session.flush()
self.session.add(
DomainEventOutbox(
event_id=str(uuid4()),
event_type="config.cache_invalidate_requested",
aggregate_type="config_release",
aggregate_id=str(target.id),
trace_id=target.release_no,
payload={"release_id": target.id, "release_no": target.release_no},
status="pending",
retry_count=0,
occurred_at=now,
created_at=now,
updated_at=now,
)
)
self._audit("config.rollback", actor_id, rollback)
await self.session.flush()
return rollback
async def get_value(
self, release_id: int, namespace: str, config_key: str
) -> dict[str, object] | None:
item = await self.session.scalar(
select(PlatformConfigItem).where(
PlatformConfigItem.release_id == release_id,
PlatformConfigItem.namespace == namespace,
PlatformConfigItem.config_key == config_key,
)
)
return item.value_json if item else None
@staticmethod
def assert_if_match(current_version: str, if_match: str | None) -> None:
if if_match is None or if_match.strip('"') != current_version:
raise ConfigReleaseError("CONFIG_VERSION_CONFLICT")
async def _get(self, release_id: int) -> ConfigRelease:
release = await self.session.get(ConfigRelease, release_id)
if release is None:
raise ConfigReleaseError("release not found")
return release
def _audit(self, action: str, actor_id: int, release: ConfigRelease) -> None:
self.session.add(
InteractionAudit(
actor_type="user",
actor_id=actor_id,
target_customer_id=None,
session_id=None,
portal="admin",
action_type=action,
detail={"release_id": release.id, "release_no": release.release_no},
created_at=self._now(),
)
)
@staticmethod
def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)