✨ feat(admin): iniciar governanca versionada de tools na fase 5
parent
d6e765ce3c
commit
b3662906bc
@ -0,0 +1,48 @@
|
||||
"""
|
||||
Rotina dedicada de bootstrap do banco administrativo.
|
||||
Cria tabelas do dominio administrativo de forma explicita, fora do startup do app.
|
||||
"""
|
||||
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
from admin_app.db.database import AdminBase, admin_engine
|
||||
from admin_app.db.models import AuditLog, StaffAccount, StaffSession, ToolDraft, ToolVersion
|
||||
|
||||
_REGISTERED_MODELS = (AuditLog, StaffAccount, StaffSession, ToolDraft, ToolVersion)
|
||||
|
||||
|
||||
def _ensure_admin_schema_evolution() -> None:
|
||||
inspector = inspect(admin_engine)
|
||||
table_names = set(inspector.get_table_names())
|
||||
|
||||
if "tool_drafts" in table_names:
|
||||
tool_draft_columns = {column["name"] for column in inspector.get_columns("tool_drafts")}
|
||||
statements: list[str] = []
|
||||
if "current_version_number" not in tool_draft_columns:
|
||||
statements.append("ALTER TABLE tool_drafts ADD COLUMN current_version_number INT NOT NULL DEFAULT 1")
|
||||
if "version_count" not in tool_draft_columns:
|
||||
statements.append("ALTER TABLE tool_drafts ADD COLUMN version_count INT NOT NULL DEFAULT 1")
|
||||
if statements:
|
||||
with admin_engine.begin() as connection:
|
||||
for statement in statements:
|
||||
connection.execute(text(statement))
|
||||
|
||||
|
||||
def bootstrap_admin_database() -> None:
|
||||
"""Cria o schema administrativo sem executar seed implicita."""
|
||||
print("Inicializando schema administrativo...")
|
||||
try:
|
||||
AdminBase.metadata.create_all(bind=admin_engine)
|
||||
_ensure_admin_schema_evolution()
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Falha ao inicializar banco administrativo: {exc}") from exc
|
||||
|
||||
print("Schema administrativo inicializado com sucesso!")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
bootstrap_admin_database()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -0,0 +1,12 @@
|
||||
"""Alias legado para o bootstrap explicito do banco administrativo."""
|
||||
|
||||
from admin_app.db.bootstrap import bootstrap_admin_database
|
||||
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
bootstrap_admin_database()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_db()
|
||||
@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, Integer, JSON, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import TypeDecorator
|
||||
|
||||
from admin_app.db.models.base import AdminTimestampedModel
|
||||
from shared.contracts import ToolLifecycleStatus
|
||||
|
||||
|
||||
class ToolLifecycleStatusType(TypeDecorator):
|
||||
impl = String(32)
|
||||
cache_ok = True
|
||||
|
||||
@property
|
||||
def python_type(self):
|
||||
return ToolLifecycleStatus
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, ToolLifecycleStatus):
|
||||
return value.value
|
||||
return ToolLifecycleStatus(str(value).strip().lower()).value
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
if value is None:
|
||||
return None
|
||||
return ToolLifecycleStatus(str(value).strip().lower())
|
||||
|
||||
|
||||
class ToolDraft(AdminTimestampedModel):
|
||||
__tablename__ = "tool_drafts"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
draft_id: Mapped[str] = mapped_column(String(40), unique=True, index=True, nullable=False)
|
||||
tool_name: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||
display_name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
domain: Mapped[str] = mapped_column(String(40), index=True, nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
business_goal: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
status: Mapped[ToolLifecycleStatus] = mapped_column(
|
||||
ToolLifecycleStatusType(),
|
||||
nullable=False,
|
||||
default=ToolLifecycleStatus.DRAFT,
|
||||
index=True,
|
||||
)
|
||||
summary: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
parameters_json: Mapped[list[dict]] = mapped_column(JSON, nullable=False, default=list)
|
||||
required_parameter_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
current_version_number: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
version_count: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
requires_director_approval: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=True,
|
||||
)
|
||||
owner_staff_account_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("staff_accounts.id"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
owner_display_name: Mapped[str] = mapped_column(String(150), nullable=False)
|
||||
@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, Integer, JSON, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from admin_app.db.models.base import AdminTimestampedModel
|
||||
from admin_app.db.models.tool_draft import ToolLifecycleStatusType
|
||||
from shared.contracts import ToolLifecycleStatus
|
||||
|
||||
|
||||
class ToolVersion(AdminTimestampedModel):
|
||||
__tablename__ = "tool_versions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tool_name",
|
||||
"version_number",
|
||||
name="uq_tool_versions_tool_name_version_number",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
version_id: Mapped[str] = mapped_column(String(120), unique=True, index=True, nullable=False)
|
||||
draft_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("tool_drafts.id"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
tool_name: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
status: Mapped[ToolLifecycleStatus] = mapped_column(
|
||||
ToolLifecycleStatusType(),
|
||||
nullable=False,
|
||||
default=ToolLifecycleStatus.DRAFT,
|
||||
index=True,
|
||||
)
|
||||
summary: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
business_goal: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
parameters_json: Mapped[list[dict]] = mapped_column(JSON, nullable=False, default=list)
|
||||
required_parameter_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
requires_director_approval: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=True,
|
||||
)
|
||||
owner_staff_account_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("staff_accounts.id"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
owner_display_name: Mapped[str] = mapped_column(String(150), nullable=False)
|
||||
@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from admin_app.db.models import ToolDraft
|
||||
from admin_app.repositories.base_repository import BaseRepository
|
||||
from shared.contracts import ToolLifecycleStatus
|
||||
|
||||
|
||||
class ToolDraftRepository(BaseRepository):
|
||||
def list_drafts(
|
||||
self,
|
||||
*,
|
||||
statuses: tuple[ToolLifecycleStatus, ...] | None = None,
|
||||
) -> list[ToolDraft]:
|
||||
statement = select(ToolDraft).order_by(
|
||||
ToolDraft.updated_at.desc(),
|
||||
ToolDraft.created_at.desc(),
|
||||
)
|
||||
if statuses:
|
||||
statement = statement.where(ToolDraft.status.in_(statuses))
|
||||
return list(self.db.execute(statement).scalars().all())
|
||||
|
||||
def get_by_tool_name(self, tool_name: str) -> ToolDraft | None:
|
||||
statement = select(ToolDraft).where(ToolDraft.tool_name == str(tool_name or "").strip().lower())
|
||||
return self.db.execute(statement).scalar_one_or_none()
|
||||
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
tool_name: str,
|
||||
display_name: str,
|
||||
domain: str,
|
||||
description: str,
|
||||
business_goal: str,
|
||||
summary: str,
|
||||
parameters_json: list[dict],
|
||||
required_parameter_count: int,
|
||||
current_version_number: int,
|
||||
version_count: int,
|
||||
owner_staff_account_id: int,
|
||||
owner_display_name: str,
|
||||
requires_director_approval: bool = True,
|
||||
commit: bool = True,
|
||||
) -> ToolDraft:
|
||||
draft = ToolDraft(
|
||||
draft_id=self._build_draft_id(),
|
||||
tool_name=tool_name,
|
||||
display_name=display_name,
|
||||
domain=domain,
|
||||
description=description,
|
||||
business_goal=business_goal,
|
||||
status=ToolLifecycleStatus.DRAFT,
|
||||
summary=summary,
|
||||
parameters_json=parameters_json,
|
||||
required_parameter_count=required_parameter_count,
|
||||
current_version_number=current_version_number,
|
||||
version_count=version_count,
|
||||
requires_director_approval=requires_director_approval,
|
||||
owner_staff_account_id=owner_staff_account_id,
|
||||
owner_display_name=owner_display_name,
|
||||
)
|
||||
self.db.add(draft)
|
||||
if commit:
|
||||
self.db.commit()
|
||||
self.db.refresh(draft)
|
||||
else:
|
||||
self.db.flush()
|
||||
return draft
|
||||
|
||||
def update_submission(
|
||||
self,
|
||||
draft: ToolDraft,
|
||||
*,
|
||||
display_name: str,
|
||||
domain: str,
|
||||
description: str,
|
||||
business_goal: str,
|
||||
summary: str,
|
||||
parameters_json: list[dict],
|
||||
required_parameter_count: int,
|
||||
current_version_number: int,
|
||||
version_count: int,
|
||||
owner_staff_account_id: int,
|
||||
owner_display_name: str,
|
||||
requires_director_approval: bool = True,
|
||||
commit: bool = True,
|
||||
) -> ToolDraft:
|
||||
draft.display_name = display_name
|
||||
draft.domain = domain
|
||||
draft.description = description
|
||||
draft.business_goal = business_goal
|
||||
draft.status = ToolLifecycleStatus.DRAFT
|
||||
draft.summary = summary
|
||||
draft.parameters_json = parameters_json
|
||||
draft.required_parameter_count = required_parameter_count
|
||||
draft.current_version_number = current_version_number
|
||||
draft.version_count = version_count
|
||||
draft.requires_director_approval = requires_director_approval
|
||||
draft.owner_staff_account_id = owner_staff_account_id
|
||||
draft.owner_display_name = owner_display_name
|
||||
if commit:
|
||||
self.db.commit()
|
||||
self.db.refresh(draft)
|
||||
else:
|
||||
self.db.flush()
|
||||
return draft
|
||||
|
||||
@staticmethod
|
||||
def _build_draft_id() -> str:
|
||||
return f"draft_{uuid4().hex[:24]}"
|
||||
@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from admin_app.db.models import ToolVersion
|
||||
from admin_app.repositories.base_repository import BaseRepository
|
||||
from shared.contracts import ToolLifecycleStatus
|
||||
|
||||
|
||||
class ToolVersionRepository(BaseRepository):
|
||||
def list_versions(
|
||||
self,
|
||||
*,
|
||||
tool_name: str | None = None,
|
||||
draft_id: int | None = None,
|
||||
statuses: tuple[ToolLifecycleStatus, ...] | None = None,
|
||||
) -> list[ToolVersion]:
|
||||
statement = select(ToolVersion).order_by(
|
||||
ToolVersion.version_number.desc(),
|
||||
ToolVersion.updated_at.desc(),
|
||||
ToolVersion.created_at.desc(),
|
||||
)
|
||||
if tool_name:
|
||||
statement = statement.where(ToolVersion.tool_name == str(tool_name).strip().lower())
|
||||
if draft_id is not None:
|
||||
statement = statement.where(ToolVersion.draft_id == draft_id)
|
||||
if statuses:
|
||||
statement = statement.where(ToolVersion.status.in_(statuses))
|
||||
return list(self.db.execute(statement).scalars().all())
|
||||
|
||||
def get_next_version_number(self, tool_name: str) -> int:
|
||||
statement = select(func.max(ToolVersion.version_number)).where(
|
||||
ToolVersion.tool_name == str(tool_name or "").strip().lower()
|
||||
)
|
||||
max_version = self.db.execute(statement).scalar_one_or_none()
|
||||
return int(max_version or 0) + 1
|
||||
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
draft_id: int,
|
||||
tool_name: str,
|
||||
version_number: int,
|
||||
summary: str,
|
||||
description: str,
|
||||
business_goal: str,
|
||||
parameters_json: list[dict],
|
||||
required_parameter_count: int,
|
||||
owner_staff_account_id: int,
|
||||
owner_display_name: str,
|
||||
status: ToolLifecycleStatus = ToolLifecycleStatus.DRAFT,
|
||||
requires_director_approval: bool = True,
|
||||
commit: bool = True,
|
||||
) -> ToolVersion:
|
||||
version = ToolVersion(
|
||||
version_id=self.build_version_id(tool_name, version_number),
|
||||
draft_id=draft_id,
|
||||
tool_name=tool_name,
|
||||
version_number=version_number,
|
||||
status=status,
|
||||
summary=summary,
|
||||
description=description,
|
||||
business_goal=business_goal,
|
||||
parameters_json=parameters_json,
|
||||
required_parameter_count=required_parameter_count,
|
||||
requires_director_approval=requires_director_approval,
|
||||
owner_staff_account_id=owner_staff_account_id,
|
||||
owner_display_name=owner_display_name,
|
||||
)
|
||||
self.db.add(version)
|
||||
if commit:
|
||||
self.db.commit()
|
||||
self.db.refresh(version)
|
||||
else:
|
||||
self.db.flush()
|
||||
return version
|
||||
|
||||
@staticmethod
|
||||
def build_version_id(tool_name: str, version_number: int) -> str:
|
||||
normalized_tool_name = str(tool_name or "").strip().lower()
|
||||
return f"tool_version::{normalized_tool_name}::v{int(version_number)}"
|
||||
@ -0,0 +1,64 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from admin_app.db import bootstrap as bootstrap_module
|
||||
from admin_app.db import init_db as init_db_module
|
||||
|
||||
|
||||
class AdminBootstrapRuntimeTests(unittest.TestCase):
|
||||
@patch.object(bootstrap_module, "_ensure_admin_schema_evolution")
|
||||
@patch.object(bootstrap_module.AdminBase.metadata, "create_all")
|
||||
def test_bootstrap_admin_database_creates_schema(self, create_all, ensure_admin_schema_evolution):
|
||||
bootstrap_module.bootstrap_admin_database()
|
||||
|
||||
create_all.assert_called_once_with(bind=bootstrap_module.admin_engine)
|
||||
ensure_admin_schema_evolution.assert_called_once_with()
|
||||
|
||||
@patch.object(
|
||||
bootstrap_module.AdminBase.metadata,
|
||||
"create_all",
|
||||
side_effect=RuntimeError("admin db down"),
|
||||
)
|
||||
def test_bootstrap_admin_database_wraps_failures(self, create_all):
|
||||
with self.assertRaisesRegex(RuntimeError, "admin db down"):
|
||||
bootstrap_module.bootstrap_admin_database()
|
||||
|
||||
create_all.assert_called_once_with(bind=bootstrap_module.admin_engine)
|
||||
|
||||
@patch.object(bootstrap_module, "text", side_effect=lambda statement: statement)
|
||||
@patch.object(bootstrap_module, "inspect")
|
||||
def test_schema_evolution_adds_version_columns_when_missing(self, inspect_mock, text_mock):
|
||||
inspector = inspect_mock.return_value
|
||||
inspector.get_table_names.return_value = ["tool_drafts"]
|
||||
inspector.get_columns.return_value = [
|
||||
{"name": "id"},
|
||||
{"name": "draft_id"},
|
||||
{"name": "tool_name"},
|
||||
]
|
||||
connection = MagicMock()
|
||||
transaction = MagicMock()
|
||||
transaction.__enter__.return_value = connection
|
||||
transaction.__exit__.return_value = None
|
||||
|
||||
with patch.object(bootstrap_module.admin_engine, "begin", return_value=transaction) as begin:
|
||||
bootstrap_module._ensure_admin_schema_evolution()
|
||||
|
||||
begin.assert_called_once_with()
|
||||
executed_statements = [call.args[0] for call in connection.execute.call_args_list]
|
||||
self.assertEqual(
|
||||
executed_statements,
|
||||
[
|
||||
"ALTER TABLE tool_drafts ADD COLUMN current_version_number INT NOT NULL DEFAULT 1",
|
||||
"ALTER TABLE tool_drafts ADD COLUMN version_count INT NOT NULL DEFAULT 1",
|
||||
],
|
||||
)
|
||||
|
||||
@patch.object(init_db_module, "bootstrap_admin_database")
|
||||
def test_init_db_wrapper_delegates_to_bootstrap_admin_database(self, bootstrap_admin_database):
|
||||
init_db_module.init_db()
|
||||
|
||||
bootstrap_admin_database.assert_called_once_with()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -0,0 +1,42 @@
|
||||
import unittest
|
||||
|
||||
from admin_app.db.models import ToolDraft
|
||||
from shared.contracts import ToolLifecycleStatus
|
||||
|
||||
|
||||
class ToolDraftModelTests(unittest.TestCase):
|
||||
def test_tool_draft_declares_expected_table_and_columns(self):
|
||||
self.assertEqual(ToolDraft.__tablename__, "tool_drafts")
|
||||
self.assertIn("draft_id", ToolDraft.__table__.columns)
|
||||
self.assertIn("tool_name", ToolDraft.__table__.columns)
|
||||
self.assertIn("display_name", ToolDraft.__table__.columns)
|
||||
self.assertIn("domain", ToolDraft.__table__.columns)
|
||||
self.assertIn("description", ToolDraft.__table__.columns)
|
||||
self.assertIn("business_goal", ToolDraft.__table__.columns)
|
||||
self.assertIn("status", ToolDraft.__table__.columns)
|
||||
self.assertIn("summary", ToolDraft.__table__.columns)
|
||||
self.assertIn("parameters_json", ToolDraft.__table__.columns)
|
||||
self.assertIn("required_parameter_count", ToolDraft.__table__.columns)
|
||||
self.assertIn("current_version_number", ToolDraft.__table__.columns)
|
||||
self.assertIn("version_count", ToolDraft.__table__.columns)
|
||||
self.assertIn("requires_director_approval", ToolDraft.__table__.columns)
|
||||
self.assertIn("owner_staff_account_id", ToolDraft.__table__.columns)
|
||||
self.assertIn("owner_display_name", ToolDraft.__table__.columns)
|
||||
self.assertIn("created_at", ToolDraft.__table__.columns)
|
||||
self.assertIn("updated_at", ToolDraft.__table__.columns)
|
||||
|
||||
def test_tool_draft_uses_unique_tool_name_foreign_key_and_draft_status_default(self):
|
||||
self.assertTrue(ToolDraft.__table__.columns["tool_name"].unique)
|
||||
|
||||
foreign_keys = list(ToolDraft.__table__.columns["owner_staff_account_id"].foreign_keys)
|
||||
self.assertEqual(len(foreign_keys), 1)
|
||||
self.assertEqual(str(foreign_keys[0].target_fullname), "staff_accounts.id")
|
||||
|
||||
status_column = ToolDraft.__table__.columns["status"]
|
||||
self.assertEqual(status_column.default.arg, ToolLifecycleStatus.DRAFT)
|
||||
self.assertEqual(status_column.type.process_bind_param("approved", None), "approved")
|
||||
self.assertEqual(status_column.type.process_result_value("draft", None), ToolLifecycleStatus.DRAFT)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -0,0 +1,310 @@
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from admin_app.core import AdminSettings
|
||||
from admin_app.db.models import ToolDraft, ToolVersion
|
||||
from admin_app.services.tool_management_service import ToolManagementService
|
||||
from shared.contracts import ToolLifecycleStatus
|
||||
|
||||
|
||||
class _FakeToolDraftRepository:
|
||||
def __init__(self):
|
||||
self.drafts: list[ToolDraft] = []
|
||||
self.next_id = 1
|
||||
|
||||
def list_drafts(self, *, statuses=None) -> list[ToolDraft]:
|
||||
drafts = sorted(
|
||||
self.drafts,
|
||||
key=lambda draft: draft.updated_at or draft.created_at or datetime.min.replace(tzinfo=timezone.utc),
|
||||
reverse=True,
|
||||
)
|
||||
if statuses:
|
||||
allowed = set(statuses)
|
||||
drafts = [draft for draft in drafts if draft.status in allowed]
|
||||
return drafts
|
||||
|
||||
def get_by_tool_name(self, tool_name: str) -> ToolDraft | None:
|
||||
normalized = str(tool_name or "").strip().lower()
|
||||
for draft in self.drafts:
|
||||
if draft.tool_name == normalized:
|
||||
return draft
|
||||
return None
|
||||
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
tool_name: str,
|
||||
display_name: str,
|
||||
domain: str,
|
||||
description: str,
|
||||
business_goal: str,
|
||||
summary: str,
|
||||
parameters_json: list[dict],
|
||||
required_parameter_count: int,
|
||||
current_version_number: int,
|
||||
version_count: int,
|
||||
owner_staff_account_id: int,
|
||||
owner_display_name: str,
|
||||
requires_director_approval: bool = True,
|
||||
commit: bool = True,
|
||||
) -> ToolDraft:
|
||||
now = datetime(2026, 3, 31, 15, 0, tzinfo=timezone.utc)
|
||||
draft = ToolDraft(
|
||||
id=self.next_id,
|
||||
draft_id=f"draft_fake_{self.next_id}",
|
||||
tool_name=tool_name,
|
||||
display_name=display_name,
|
||||
domain=domain,
|
||||
description=description,
|
||||
business_goal=business_goal,
|
||||
status=ToolLifecycleStatus.DRAFT,
|
||||
summary=summary,
|
||||
parameters_json=parameters_json,
|
||||
required_parameter_count=required_parameter_count,
|
||||
current_version_number=current_version_number,
|
||||
version_count=version_count,
|
||||
requires_director_approval=requires_director_approval,
|
||||
owner_staff_account_id=owner_staff_account_id,
|
||||
owner_display_name=owner_display_name,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
self.next_id += 1
|
||||
self.drafts.append(draft)
|
||||
return draft
|
||||
|
||||
def update_submission(
|
||||
self,
|
||||
draft: ToolDraft,
|
||||
*,
|
||||
display_name: str,
|
||||
domain: str,
|
||||
description: str,
|
||||
business_goal: str,
|
||||
summary: str,
|
||||
parameters_json: list[dict],
|
||||
required_parameter_count: int,
|
||||
current_version_number: int,
|
||||
version_count: int,
|
||||
owner_staff_account_id: int,
|
||||
owner_display_name: str,
|
||||
requires_director_approval: bool = True,
|
||||
commit: bool = True,
|
||||
) -> ToolDraft:
|
||||
draft.display_name = display_name
|
||||
draft.domain = domain
|
||||
draft.description = description
|
||||
draft.business_goal = business_goal
|
||||
draft.status = ToolLifecycleStatus.DRAFT
|
||||
draft.summary = summary
|
||||
draft.parameters_json = parameters_json
|
||||
draft.required_parameter_count = required_parameter_count
|
||||
draft.current_version_number = current_version_number
|
||||
draft.version_count = version_count
|
||||
draft.requires_director_approval = requires_director_approval
|
||||
draft.owner_staff_account_id = owner_staff_account_id
|
||||
draft.owner_display_name = owner_display_name
|
||||
draft.updated_at = datetime(2026, 3, 31, 15, current_version_number, tzinfo=timezone.utc)
|
||||
return draft
|
||||
|
||||
|
||||
class _FakeToolVersionRepository:
|
||||
def __init__(self):
|
||||
self.versions: list[ToolVersion] = []
|
||||
self.next_id = 1
|
||||
|
||||
def list_versions(self, *, tool_name=None, draft_id=None, statuses=None) -> list[ToolVersion]:
|
||||
versions = sorted(
|
||||
self.versions,
|
||||
key=lambda version: (version.version_number, version.updated_at or version.created_at or datetime.min.replace(tzinfo=timezone.utc)),
|
||||
reverse=True,
|
||||
)
|
||||
if tool_name:
|
||||
normalized = str(tool_name).strip().lower()
|
||||
versions = [version for version in versions if version.tool_name == normalized]
|
||||
if draft_id is not None:
|
||||
versions = [version for version in versions if version.draft_id == draft_id]
|
||||
if statuses:
|
||||
allowed = set(statuses)
|
||||
versions = [version for version in versions if version.status in allowed]
|
||||
return versions
|
||||
|
||||
def get_next_version_number(self, tool_name: str) -> int:
|
||||
versions = self.list_versions(tool_name=tool_name)
|
||||
return (versions[0].version_number if versions else 0) + 1
|
||||
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
draft_id: int,
|
||||
tool_name: str,
|
||||
version_number: int,
|
||||
summary: str,
|
||||
description: str,
|
||||
business_goal: str,
|
||||
parameters_json: list[dict],
|
||||
required_parameter_count: int,
|
||||
owner_staff_account_id: int,
|
||||
owner_display_name: str,
|
||||
status: ToolLifecycleStatus = ToolLifecycleStatus.DRAFT,
|
||||
requires_director_approval: bool = True,
|
||||
commit: bool = True,
|
||||
) -> ToolVersion:
|
||||
now = datetime(2026, 3, 31, 16, version_number, tzinfo=timezone.utc)
|
||||
version = ToolVersion(
|
||||
id=self.next_id,
|
||||
version_id=self.build_version_id(tool_name, version_number),
|
||||
draft_id=draft_id,
|
||||
tool_name=tool_name,
|
||||
version_number=version_number,
|
||||
status=status,
|
||||
summary=summary,
|
||||
description=description,
|
||||
business_goal=business_goal,
|
||||
parameters_json=parameters_json,
|
||||
required_parameter_count=required_parameter_count,
|
||||
requires_director_approval=requires_director_approval,
|
||||
owner_staff_account_id=owner_staff_account_id,
|
||||
owner_display_name=owner_display_name,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
self.next_id += 1
|
||||
self.versions.append(version)
|
||||
return version
|
||||
|
||||
@staticmethod
|
||||
def build_version_id(tool_name: str, version_number: int) -> str:
|
||||
normalized = str(tool_name or "").strip().lower()
|
||||
return f"tool_version::{normalized}::v{int(version_number)}"
|
||||
|
||||
|
||||
class AdminToolManagementServiceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.draft_repository = _FakeToolDraftRepository()
|
||||
self.version_repository = _FakeToolVersionRepository()
|
||||
self.service = ToolManagementService(
|
||||
settings=AdminSettings(admin_api_prefix="/admin"),
|
||||
draft_repository=self.draft_repository,
|
||||
version_repository=self.version_repository,
|
||||
)
|
||||
|
||||
def test_create_draft_submission_persists_initial_tool_version(self):
|
||||
payload = self.service.create_draft_submission(
|
||||
{
|
||||
"domain": "vendas",
|
||||
"tool_name": "consultar_vendas_periodo",
|
||||
"display_name": "Consultar vendas por periodo",
|
||||
"description": "Consulta vendas consolidadas por periodo informado no painel.",
|
||||
"business_goal": "Ajudar o time interno a acompanhar o desempenho comercial com mais agilidade.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "periodo_inicio",
|
||||
"parameter_type": "string",
|
||||
"description": "Data inicial usada no filtro.",
|
||||
"required": True,
|
||||
},
|
||||
{
|
||||
"name": "periodo_fim",
|
||||
"parameter_type": "string",
|
||||
"description": "Data final usada no filtro.",
|
||||
"required": True,
|
||||
},
|
||||
],
|
||||
},
|
||||
owner_staff_account_id=7,
|
||||
owner_name="Equipe Interna",
|
||||
)
|
||||
|
||||
self.assertEqual(payload["storage_status"], "admin_database")
|
||||
self.assertEqual(payload["draft_preview"]["draft_id"], "draft_fake_1")
|
||||
self.assertEqual(payload["draft_preview"]["version_id"], "tool_version::consultar_vendas_periodo::v1")
|
||||
self.assertEqual(payload["draft_preview"]["version_number"], 1)
|
||||
self.assertEqual(payload["draft_preview"]["version_count"], 1)
|
||||
self.assertEqual(payload["draft_preview"]["status"], ToolLifecycleStatus.DRAFT)
|
||||
self.assertEqual(payload["draft_preview"]["owner_name"], "Equipe Interna")
|
||||
self.assertEqual(len(self.draft_repository.drafts), 1)
|
||||
self.assertEqual(len(self.version_repository.versions), 1)
|
||||
|
||||
def test_create_draft_submission_reuses_root_draft_and_increments_version(self):
|
||||
self.service.create_draft_submission(
|
||||
{
|
||||
"domain": "locacao",
|
||||
"tool_name": "emitir_resumo_locacao",
|
||||
"display_name": "Emitir resumo de locacao",
|
||||
"description": "Resume o contrato atual de locacao para consulta administrativa.",
|
||||
"business_goal": "Dar visibilidade rapida ao status do contrato e dos dados principais.",
|
||||
"parameters": [],
|
||||
},
|
||||
owner_staff_account_id=3,
|
||||
owner_name="Analista de Locacao",
|
||||
)
|
||||
|
||||
payload = self.service.create_draft_submission(
|
||||
{
|
||||
"domain": "locacao",
|
||||
"tool_name": "emitir_resumo_locacao",
|
||||
"display_name": "Emitir resumo de locacao",
|
||||
"description": "Resume o contrato atual de locacao e os principais eventos administrativos.",
|
||||
"business_goal": "Dar visibilidade rapida ao status do contrato, do pagamento e dos dados principais.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "contrato_id",
|
||||
"parameter_type": "string",
|
||||
"description": "Identificador do contrato consultado.",
|
||||
"required": True,
|
||||
}
|
||||
],
|
||||
},
|
||||
owner_staff_account_id=4,
|
||||
owner_name="Coordenacao de Locacao",
|
||||
)
|
||||
|
||||
self.assertEqual(payload["draft_preview"]["version_id"], "tool_version::emitir_resumo_locacao::v2")
|
||||
self.assertEqual(payload["draft_preview"]["version_number"], 2)
|
||||
self.assertEqual(payload["draft_preview"]["version_count"], 2)
|
||||
self.assertEqual(len(self.draft_repository.drafts), 1)
|
||||
self.assertEqual(len(self.version_repository.versions), 2)
|
||||
self.assertEqual(self.draft_repository.drafts[0].current_version_number, 2)
|
||||
self.assertEqual(self.draft_repository.drafts[0].version_count, 2)
|
||||
self.assertEqual(self.draft_repository.drafts[0].owner_display_name, "Coordenacao de Locacao")
|
||||
|
||||
def test_build_drafts_payload_returns_versioned_draft_summaries(self):
|
||||
self.service.create_draft_submission(
|
||||
{
|
||||
"domain": "orquestracao",
|
||||
"tool_name": "priorizar_contato_quente",
|
||||
"display_name": "Priorizar contato quente",
|
||||
"description": "Classifica contatos mais quentes para orientar o proximo passo do atendimento.",
|
||||
"business_goal": "Dar mais foco comercial ao time interno ao identificar oportunidades mais urgentes.",
|
||||
"parameters": [],
|
||||
},
|
||||
owner_staff_account_id=5,
|
||||
owner_name="Equipe de Tools",
|
||||
)
|
||||
self.service.create_draft_submission(
|
||||
{
|
||||
"domain": "orquestracao",
|
||||
"tool_name": "priorizar_contato_quente",
|
||||
"display_name": "Priorizar contato quente",
|
||||
"description": "Classifica contatos mais quentes com sinais adicionais para orientar o atendimento.",
|
||||
"business_goal": "Dar mais foco comercial ao time interno ao identificar oportunidades quentes com mais contexto.",
|
||||
"parameters": [],
|
||||
},
|
||||
owner_staff_account_id=6,
|
||||
owner_name="Diretoria Comercial",
|
||||
)
|
||||
|
||||
payload = self.service.build_drafts_payload()
|
||||
|
||||
self.assertEqual(payload["storage_status"], "admin_database")
|
||||
self.assertEqual(len(payload["drafts"]), 1)
|
||||
self.assertEqual(payload["drafts"][0]["tool_name"], "priorizar_contato_quente")
|
||||
self.assertEqual(payload["drafts"][0]["current_version_number"], 2)
|
||||
self.assertEqual(payload["drafts"][0]["version_count"], 2)
|
||||
self.assertEqual(payload["drafts"][0]["owner_name"], "Diretoria Comercial")
|
||||
self.assertEqual(payload["supported_statuses"], [ToolLifecycleStatus.DRAFT])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -0,0 +1,43 @@
|
||||
import unittest
|
||||
|
||||
from admin_app.db.models import ToolVersion
|
||||
from shared.contracts import ToolLifecycleStatus
|
||||
|
||||
|
||||
class ToolVersionModelTests(unittest.TestCase):
|
||||
def test_tool_version_declares_expected_table_and_columns(self):
|
||||
self.assertEqual(ToolVersion.__tablename__, "tool_versions")
|
||||
self.assertIn("version_id", ToolVersion.__table__.columns)
|
||||
self.assertIn("draft_id", ToolVersion.__table__.columns)
|
||||
self.assertIn("tool_name", ToolVersion.__table__.columns)
|
||||
self.assertIn("version_number", ToolVersion.__table__.columns)
|
||||
self.assertIn("status", ToolVersion.__table__.columns)
|
||||
self.assertIn("summary", ToolVersion.__table__.columns)
|
||||
self.assertIn("description", ToolVersion.__table__.columns)
|
||||
self.assertIn("business_goal", ToolVersion.__table__.columns)
|
||||
self.assertIn("parameters_json", ToolVersion.__table__.columns)
|
||||
self.assertIn("required_parameter_count", ToolVersion.__table__.columns)
|
||||
self.assertIn("requires_director_approval", ToolVersion.__table__.columns)
|
||||
self.assertIn("owner_staff_account_id", ToolVersion.__table__.columns)
|
||||
self.assertIn("owner_display_name", ToolVersion.__table__.columns)
|
||||
|
||||
def test_tool_version_uses_expected_constraints_and_defaults(self):
|
||||
foreign_keys = list(ToolVersion.__table__.columns["draft_id"].foreign_keys)
|
||||
self.assertEqual(len(foreign_keys), 1)
|
||||
self.assertEqual(str(foreign_keys[0].target_fullname), "tool_drafts.id")
|
||||
|
||||
owner_foreign_keys = list(ToolVersion.__table__.columns["owner_staff_account_id"].foreign_keys)
|
||||
self.assertEqual(len(owner_foreign_keys), 1)
|
||||
self.assertEqual(str(owner_foreign_keys[0].target_fullname), "staff_accounts.id")
|
||||
|
||||
status_column = ToolVersion.__table__.columns["status"]
|
||||
self.assertEqual(status_column.default.arg, ToolLifecycleStatus.DRAFT)
|
||||
self.assertEqual(status_column.type.process_bind_param("validated", None), "validated")
|
||||
self.assertEqual(status_column.type.process_result_value("draft", None), ToolLifecycleStatus.DRAFT)
|
||||
|
||||
unique_constraints = {constraint.name for constraint in ToolVersion.__table__.constraints}
|
||||
self.assertIn("uq_tool_versions_tool_name_version_number", unique_constraints)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in New Issue