164 lines
6.1 KiB
Python
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)
|