feat: implement final approval gate workflow and checklist
This commit is contained in:
@@ -0,0 +1,378 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from src.domain.contracts import (
|
||||||
|
AgentJobType,
|
||||||
|
AssetStatus,
|
||||||
|
AssetType,
|
||||||
|
ArticleWorkflowStatus,
|
||||||
|
ClaimRiskLevel,
|
||||||
|
ClaimSupportStatus,
|
||||||
|
ContentReviewKind,
|
||||||
|
FinalApprovalChecklistItemSummary,
|
||||||
|
FinalApprovalChecklistResponse,
|
||||||
|
FinalApprovalRequest,
|
||||||
|
FinalApprovalResponse,
|
||||||
|
FinalRevisionRequest,
|
||||||
|
FinalRevisionRequestResponse,
|
||||||
|
PlanReviewStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_final_approval_checklist(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
request: FinalApprovalRequest | None = None,
|
||||||
|
) -> FinalApprovalChecklistResponse:
|
||||||
|
repository.articles.get(article_id)
|
||||||
|
latest_draft = repository.article_drafts.latest_for_article(article_id)
|
||||||
|
approved_plan = _approved_plan(repository, article_id=article_id)
|
||||||
|
evidence = repository.evidence_items.list_for_article(article_id)
|
||||||
|
assets = repository.assets.list_for_article(article_id)
|
||||||
|
unresolved = repository.content_reviews.list_unresolved_for_article(article_id=article_id)
|
||||||
|
high_risk_unsupported = _high_risk_unsupported_claims(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
draft=latest_draft,
|
||||||
|
)
|
||||||
|
settings = request.publishing_settings if request is not None else None
|
||||||
|
requested_draft_version = request.draft_version if request is not None else None
|
||||||
|
|
||||||
|
checklist_items: list[FinalApprovalChecklistItemSummary] = []
|
||||||
|
blockers: list[str] = []
|
||||||
|
completed_items: list[str] = []
|
||||||
|
|
||||||
|
def add_item(key: str, title: str, completed: bool, blocker: str | None = None) -> None:
|
||||||
|
checklist_items.append(
|
||||||
|
FinalApprovalChecklistItemSummary(
|
||||||
|
key=key,
|
||||||
|
title=title,
|
||||||
|
completed=completed,
|
||||||
|
blocker=blocker,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if completed:
|
||||||
|
completed_items.append(title)
|
||||||
|
elif blocker:
|
||||||
|
blockers.append(blocker)
|
||||||
|
|
||||||
|
has_draft = latest_draft is not None
|
||||||
|
add_item(
|
||||||
|
key="exact_draft_version",
|
||||||
|
title="Exact draft version selected",
|
||||||
|
completed=has_draft and requested_draft_version == latest_draft.version,
|
||||||
|
blocker=(
|
||||||
|
None
|
||||||
|
if has_draft and requested_draft_version == latest_draft.version
|
||||||
|
else (
|
||||||
|
"Draft version is required for final approval."
|
||||||
|
if not has_draft
|
||||||
|
else f"Final approval requires latest draft version {latest_draft.version}."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
add_item(
|
||||||
|
key="plan_followed",
|
||||||
|
title="Plan followed",
|
||||||
|
completed=has_draft and _draft_contains_approved_plan(approved_plan, latest_draft.body_markdown),
|
||||||
|
blocker="Approved plan sections are not fully reflected in the assembled draft.",
|
||||||
|
)
|
||||||
|
add_item(
|
||||||
|
key="evidence_reviewed",
|
||||||
|
title="Evidence reviewed",
|
||||||
|
completed=bool(evidence) and all(item.review_status != "PENDING" for item in evidence),
|
||||||
|
blocker="Evidence review is incomplete (there are pending evidence items).",
|
||||||
|
)
|
||||||
|
add_item(
|
||||||
|
key="no_high_risk_unsupported_claims",
|
||||||
|
title="No high-risk unsupported claims remain",
|
||||||
|
completed=not high_risk_unsupported,
|
||||||
|
blocker=(
|
||||||
|
None
|
||||||
|
if not high_risk_unsupported
|
||||||
|
else (
|
||||||
|
"High-risk unsupported claims must be resolved: "
|
||||||
|
+ "; ".join(high_risk_unsupported[:3])
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
add_item(
|
||||||
|
key="seo_metadata_approved",
|
||||||
|
title="SEO metadata approved",
|
||||||
|
completed=has_draft and _has_seo_metadata(latest_draft) and not _has_unresolved_meta_issues(unresolved),
|
||||||
|
blocker="SEO metadata is incomplete or has unresolved review issues.",
|
||||||
|
)
|
||||||
|
|
||||||
|
required_assets = [asset for asset in assets if asset.status != AssetStatus.REJECTED]
|
||||||
|
unapproved_required_assets = [
|
||||||
|
asset
|
||||||
|
for asset in required_assets
|
||||||
|
if asset.status != AssetStatus.APPROVED or not asset.file_url
|
||||||
|
]
|
||||||
|
add_item(
|
||||||
|
key="images_assets_approved",
|
||||||
|
title="Images/assets approved",
|
||||||
|
completed=bool(required_assets) and not unapproved_required_assets,
|
||||||
|
blocker=(
|
||||||
|
"Required assets are missing."
|
||||||
|
if not required_assets
|
||||||
|
else f"{len(unapproved_required_assets)} required assets are not approved."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
required_table_diagrams = [
|
||||||
|
asset
|
||||||
|
for asset in required_assets
|
||||||
|
if asset.asset_type in {
|
||||||
|
AssetType.DIAGRAM,
|
||||||
|
AssetType.TABLE,
|
||||||
|
AssetType.FLOWCHART,
|
||||||
|
AssetType.COMPARISON_MATRIX,
|
||||||
|
AssetType.ARCHITECTURE_DIAGRAM,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
unapproved_table_diagrams = [
|
||||||
|
asset
|
||||||
|
for asset in required_table_diagrams
|
||||||
|
if asset.status != AssetStatus.APPROVED or not asset.file_url
|
||||||
|
]
|
||||||
|
add_item(
|
||||||
|
key="tables_and_diagrams_approved",
|
||||||
|
title="Tables and diagrams approved",
|
||||||
|
completed=bool(required_table_diagrams) and not unapproved_table_diagrams,
|
||||||
|
blocker=(
|
||||||
|
"Required table/diagram assets are missing."
|
||||||
|
if not required_table_diagrams
|
||||||
|
else f"{len(unapproved_table_diagrams)} table/diagram assets are not approved."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
has_internal_links = has_draft and _has_internal_links(latest_draft.body_markdown)
|
||||||
|
add_item(
|
||||||
|
key="internal_links_approved",
|
||||||
|
title="Internal links approved",
|
||||||
|
completed=bool(has_internal_links) and not _has_unresolved_internal_link_issue(unresolved),
|
||||||
|
blocker="Draft must include approved internal links.",
|
||||||
|
)
|
||||||
|
|
||||||
|
frontmatter = settings.frontmatter if settings is not None else None
|
||||||
|
add_item(
|
||||||
|
key="frontmatter_fields_selected",
|
||||||
|
title="Frontmatter fields selected",
|
||||||
|
completed=isinstance(frontmatter, dict) and bool(frontmatter),
|
||||||
|
blocker="Required publishing settings are missing: frontmatter fields.",
|
||||||
|
)
|
||||||
|
content_path = settings.content_path if settings is not None else None
|
||||||
|
add_item(
|
||||||
|
key="content_path_selected",
|
||||||
|
title="Content path selected",
|
||||||
|
completed=bool(content_path and content_path.strip()),
|
||||||
|
blocker="Required publishing settings are missing: content path.",
|
||||||
|
)
|
||||||
|
author = settings.author if settings is not None else None
|
||||||
|
add_item(
|
||||||
|
key="author_selected",
|
||||||
|
title="Author selected",
|
||||||
|
completed=bool(author and author.strip()),
|
||||||
|
blocker="Required publishing settings are missing: author.",
|
||||||
|
)
|
||||||
|
publishing_mode = settings.publishing_mode if settings is not None else None
|
||||||
|
add_item(
|
||||||
|
key="publishing_mode_selected",
|
||||||
|
title="Publishing mode selected",
|
||||||
|
completed=bool(publishing_mode and publishing_mode.strip()),
|
||||||
|
blocker="Required publishing settings are missing: publishing mode.",
|
||||||
|
)
|
||||||
|
|
||||||
|
can_approve = len(blockers) == 0
|
||||||
|
return FinalApprovalChecklistResponse(
|
||||||
|
article_id=article_id,
|
||||||
|
draft_version=latest_draft.version if latest_draft is not None else None,
|
||||||
|
requested_draft_version=requested_draft_version,
|
||||||
|
can_approve=can_approve,
|
||||||
|
completed_items=completed_items,
|
||||||
|
blockers=blockers,
|
||||||
|
items=checklist_items,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def approve_final_article(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
request: FinalApprovalRequest,
|
||||||
|
actor_user_id: UUID,
|
||||||
|
) -> FinalApprovalResponse:
|
||||||
|
article = repository.articles.get(article_id)
|
||||||
|
checklist = get_final_approval_checklist(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
if not checklist.can_approve:
|
||||||
|
raise PermissionError("; ".join(checklist.blockers))
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
updated = repository.articles.update_status(
|
||||||
|
article_id=article_id,
|
||||||
|
status=ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
repository.articles.create_workflow_event(
|
||||||
|
article_id=article_id,
|
||||||
|
event_type="FINAL_APPROVAL_GRANTED",
|
||||||
|
from_status=article.status,
|
||||||
|
to_status=ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
payload={
|
||||||
|
"draft_version": checklist.draft_version,
|
||||||
|
"publishing_settings": (
|
||||||
|
request.publishing_settings.model_dump(mode="json")
|
||||||
|
if request.publishing_settings is not None
|
||||||
|
else {}
|
||||||
|
),
|
||||||
|
"completed_items": checklist.completed_items,
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return FinalApprovalResponse(article=updated, checklist=checklist)
|
||||||
|
|
||||||
|
|
||||||
|
def request_final_revision(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
request: FinalRevisionRequest,
|
||||||
|
actor_user_id: UUID,
|
||||||
|
) -> FinalRevisionRequestResponse:
|
||||||
|
article = repository.articles.get(article_id)
|
||||||
|
checklist_request = FinalApprovalRequest(
|
||||||
|
draft_version=request.draft_version,
|
||||||
|
publishing_settings=None,
|
||||||
|
)
|
||||||
|
checklist = get_final_approval_checklist(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
request=checklist_request,
|
||||||
|
)
|
||||||
|
now = _now()
|
||||||
|
updated = repository.articles.update_status(
|
||||||
|
article_id=article_id,
|
||||||
|
status=ArticleWorkflowStatus.FINAL_REVISION_REQUIRED,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
repository.articles.create_workflow_event(
|
||||||
|
article_id=article_id,
|
||||||
|
event_type="FINAL_REVISION_REQUESTED",
|
||||||
|
from_status=article.status,
|
||||||
|
to_status=ArticleWorkflowStatus.FINAL_REVISION_REQUIRED,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
payload={
|
||||||
|
"reason": (request.reason or "").strip(),
|
||||||
|
"draft_version": checklist.draft_version,
|
||||||
|
"blockers": checklist.blockers,
|
||||||
|
"completed_items": checklist.completed_items,
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return FinalRevisionRequestResponse(article=updated, checklist=checklist)
|
||||||
|
|
||||||
|
|
||||||
|
def _approved_plan(repository: object, *, article_id: UUID) -> object | None:
|
||||||
|
plans = repository.article_plans.list_for_article(article_id)
|
||||||
|
approved_plans = [plan for plan in plans if plan.status == PlanReviewStatus.APPROVED]
|
||||||
|
if not approved_plans:
|
||||||
|
return None
|
||||||
|
approved_plans.sort(key=lambda plan: plan.version)
|
||||||
|
return approved_plans[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _draft_contains_approved_plan(approved_plan: object | None, body_markdown: str) -> bool:
|
||||||
|
if approved_plan is None or not approved_plan.sections:
|
||||||
|
return False
|
||||||
|
for section in approved_plan.sections:
|
||||||
|
if f"## {section.heading}" not in body_markdown:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _has_seo_metadata(draft: object) -> bool:
|
||||||
|
return bool(draft.meta_title and draft.meta_title.strip()) and bool(
|
||||||
|
draft.meta_description and draft.meta_description.strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_unresolved_meta_issues(unresolved: list[object]) -> bool:
|
||||||
|
return any(
|
||||||
|
item.review_kind == ContentReviewKind.SEO
|
||||||
|
and item.location in {"meta_title", "meta_description", "title"}
|
||||||
|
for item in unresolved
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_internal_links(markdown: str) -> bool:
|
||||||
|
return re.search(r"\]\(/[^)]+\)", markdown) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_unresolved_internal_link_issue(unresolved: list[object]) -> bool:
|
||||||
|
for item in unresolved:
|
||||||
|
if item.suggestion_key == "internal-link":
|
||||||
|
return True
|
||||||
|
if "internal link" in item.message.lower():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _high_risk_unsupported_claims(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
draft: object | None,
|
||||||
|
) -> list[str]:
|
||||||
|
claims: list[str] = []
|
||||||
|
for claim in repository.claims.list_for_article(article_id):
|
||||||
|
if (
|
||||||
|
claim.support_status == ClaimSupportStatus.UNSUPPORTED
|
||||||
|
and claim.risk_level == ClaimRiskLevel.HIGH
|
||||||
|
):
|
||||||
|
claims.append(claim.claim_text)
|
||||||
|
|
||||||
|
for job in repository.agent_jobs.list_for_article(article_id):
|
||||||
|
if job.job_type != AgentJobType.SECTION_SCAFFOLD:
|
||||||
|
continue
|
||||||
|
raw_claims = job.payload.get("unsupported_claims")
|
||||||
|
if not isinstance(raw_claims, list):
|
||||||
|
continue
|
||||||
|
for raw in raw_claims:
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
risk_level = str(raw.get("risk_level", "")).lower()
|
||||||
|
claim_text = str(raw.get("claim_text", "")).strip()
|
||||||
|
if risk_level == "high" and claim_text:
|
||||||
|
claims.append(claim_text)
|
||||||
|
|
||||||
|
if draft is not None:
|
||||||
|
for warning in draft.unsupported_claim_warnings:
|
||||||
|
if "high-risk" in warning.lower():
|
||||||
|
claims.append(warning)
|
||||||
|
|
||||||
|
deduped: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for claim in claims:
|
||||||
|
normalized = claim.strip()
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
if normalized in seen:
|
||||||
|
continue
|
||||||
|
seen.add(normalized)
|
||||||
|
deduped.append(normalized)
|
||||||
|
return deduped
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
@@ -57,6 +57,11 @@ from .models import (
|
|||||||
EvidenceMatrixResponse,
|
EvidenceMatrixResponse,
|
||||||
EvidenceResponse,
|
EvidenceResponse,
|
||||||
EvidenceUpdateRequest,
|
EvidenceUpdateRequest,
|
||||||
|
FinalApprovalChecklistItemSummary,
|
||||||
|
FinalApprovalChecklistResponse,
|
||||||
|
FinalApprovalPublishingSettings,
|
||||||
|
FinalApprovalRequest,
|
||||||
|
FinalApprovalResponse,
|
||||||
PlanSectionSummary,
|
PlanSectionSummary,
|
||||||
PlanSectionEditRequest,
|
PlanSectionEditRequest,
|
||||||
PlanListResponse,
|
PlanListResponse,
|
||||||
@@ -78,6 +83,8 @@ from .models import (
|
|||||||
SeoReviewReportResponse,
|
SeoReviewReportResponse,
|
||||||
LanguageReviewRunResponse,
|
LanguageReviewRunResponse,
|
||||||
LanguageReviewReportResponse,
|
LanguageReviewReportResponse,
|
||||||
|
FinalRevisionRequest,
|
||||||
|
FinalRevisionRequestResponse,
|
||||||
FinalReviewIssueSummary,
|
FinalReviewIssueSummary,
|
||||||
FinalReviewIssuesResponse,
|
FinalReviewIssuesResponse,
|
||||||
RunnerFileRef,
|
RunnerFileRef,
|
||||||
@@ -149,6 +156,13 @@ __all__ = [
|
|||||||
"EvidenceMatrixResponse",
|
"EvidenceMatrixResponse",
|
||||||
"EvidenceResponse",
|
"EvidenceResponse",
|
||||||
"EvidenceUpdateRequest",
|
"EvidenceUpdateRequest",
|
||||||
|
"FinalApprovalChecklistItemSummary",
|
||||||
|
"FinalApprovalChecklistResponse",
|
||||||
|
"FinalApprovalPublishingSettings",
|
||||||
|
"FinalApprovalRequest",
|
||||||
|
"FinalApprovalResponse",
|
||||||
|
"FinalRevisionRequest",
|
||||||
|
"FinalRevisionRequestResponse",
|
||||||
"FinalReviewIssueSummary",
|
"FinalReviewIssueSummary",
|
||||||
"FinalReviewIssuesResponse",
|
"FinalReviewIssuesResponse",
|
||||||
"LanguageReviewRunResponse",
|
"LanguageReviewRunResponse",
|
||||||
|
|||||||
@@ -598,6 +598,50 @@ class FinalReviewIssuesResponse(ContractModel):
|
|||||||
unresolved_issues: list[FinalReviewIssueSummary] = Field(default_factory=list)
|
unresolved_issues: list[FinalReviewIssueSummary] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class FinalApprovalPublishingSettings(ContractModel):
|
||||||
|
content_path: str | None = None
|
||||||
|
author: str | None = None
|
||||||
|
publishing_mode: str | None = None
|
||||||
|
frontmatter: JsonObject | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FinalApprovalRequest(ContractModel):
|
||||||
|
draft_version: int | None = Field(default=None, ge=1)
|
||||||
|
publishing_settings: FinalApprovalPublishingSettings | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FinalRevisionRequest(ContractModel):
|
||||||
|
reason: str | None = None
|
||||||
|
draft_version: int | None = Field(default=None, ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class FinalApprovalChecklistItemSummary(ContractModel):
|
||||||
|
key: str = Field(min_length=1)
|
||||||
|
title: str = Field(min_length=1)
|
||||||
|
completed: bool
|
||||||
|
blocker: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FinalApprovalChecklistResponse(ContractModel):
|
||||||
|
article_id: UUID
|
||||||
|
draft_version: int | None = Field(default=None, ge=1)
|
||||||
|
requested_draft_version: int | None = Field(default=None, ge=1)
|
||||||
|
can_approve: bool
|
||||||
|
completed_items: list[str] = Field(default_factory=list)
|
||||||
|
blockers: list[str] = Field(default_factory=list)
|
||||||
|
items: list[FinalApprovalChecklistItemSummary] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class FinalApprovalResponse(ContractModel):
|
||||||
|
article: ArticleSummary
|
||||||
|
checklist: FinalApprovalChecklistResponse
|
||||||
|
|
||||||
|
|
||||||
|
class FinalRevisionRequestResponse(ContractModel):
|
||||||
|
article: ArticleSummary
|
||||||
|
checklist: FinalApprovalChecklistResponse
|
||||||
|
|
||||||
|
|
||||||
class AssetGenerateSpecsResponse(ContractModel):
|
class AssetGenerateSpecsResponse(ContractModel):
|
||||||
assets: list[AssetSummary] = Field(default_factory=list)
|
assets: list[AssetSummary] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ from .models import (
|
|||||||
ContentReviewIssueSummary,
|
ContentReviewIssueSummary,
|
||||||
ContentReviewReportSummary,
|
ContentReviewReportSummary,
|
||||||
ContentReviewSuggestionSummary,
|
ContentReviewSuggestionSummary,
|
||||||
|
FinalApprovalChecklistItemSummary,
|
||||||
|
FinalApprovalChecklistResponse,
|
||||||
|
FinalApprovalPublishingSettings,
|
||||||
|
FinalApprovalRequest,
|
||||||
|
FinalApprovalResponse,
|
||||||
EvidenceSummary,
|
EvidenceSummary,
|
||||||
EvidenceCreateRequest,
|
EvidenceCreateRequest,
|
||||||
EvidenceMatrixResponse,
|
EvidenceMatrixResponse,
|
||||||
@@ -84,6 +89,8 @@ from .models import (
|
|||||||
ReviewSuggestionEditRequest,
|
ReviewSuggestionEditRequest,
|
||||||
ReviewActionResponse,
|
ReviewActionResponse,
|
||||||
ReviewSummary,
|
ReviewSummary,
|
||||||
|
FinalRevisionRequest,
|
||||||
|
FinalRevisionRequestResponse,
|
||||||
SeoReviewReportResponse,
|
SeoReviewReportResponse,
|
||||||
SeoReviewRunResponse,
|
SeoReviewRunResponse,
|
||||||
RunnerFileRef,
|
RunnerFileRef,
|
||||||
@@ -167,6 +174,13 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
|
|||||||
ContentReviewIssueSummary,
|
ContentReviewIssueSummary,
|
||||||
ContentReviewReportSummary,
|
ContentReviewReportSummary,
|
||||||
ContentReviewSuggestionSummary,
|
ContentReviewSuggestionSummary,
|
||||||
|
FinalApprovalChecklistItemSummary,
|
||||||
|
FinalApprovalChecklistResponse,
|
||||||
|
FinalApprovalPublishingSettings,
|
||||||
|
FinalApprovalRequest,
|
||||||
|
FinalApprovalResponse,
|
||||||
|
FinalRevisionRequest,
|
||||||
|
FinalRevisionRequestResponse,
|
||||||
FinalReviewIssueSummary,
|
FinalReviewIssueSummary,
|
||||||
FinalReviewIssuesResponse,
|
FinalReviewIssuesResponse,
|
||||||
LanguageReviewReportResponse,
|
LanguageReviewReportResponse,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from src.presentation.routes.auth import router as auth_router
|
|||||||
from src.presentation.routes.boundary_questions import router as boundary_questions_router
|
from src.presentation.routes.boundary_questions import router as boundary_questions_router
|
||||||
from src.presentation.routes.drafts import router as drafts_router
|
from src.presentation.routes.drafts import router as drafts_router
|
||||||
from src.presentation.routes.evidence import router as evidence_router
|
from src.presentation.routes.evidence import router as evidence_router
|
||||||
|
from src.presentation.routes.final_approval import router as final_approval_router
|
||||||
from src.presentation.routes.plans import router as plans_router
|
from src.presentation.routes.plans import router as plans_router
|
||||||
from src.presentation.routes.reviews import router as reviews_router
|
from src.presentation.routes.reviews import router as reviews_router
|
||||||
from src.presentation.routes.sites import router as sites_router
|
from src.presentation.routes.sites import router as sites_router
|
||||||
@@ -29,6 +30,7 @@ app.include_router(plans_router)
|
|||||||
app.include_router(evidence_router)
|
app.include_router(evidence_router)
|
||||||
app.include_router(drafts_router)
|
app.include_router(drafts_router)
|
||||||
app.include_router(reviews_router)
|
app.include_router(reviews_router)
|
||||||
|
app.include_router(final_approval_router)
|
||||||
app.include_router(agent_jobs_router)
|
app.include_router(agent_jobs_router)
|
||||||
app.include_router(internal_agent_jobs_router)
|
app.include_router(internal_agent_jobs_router)
|
||||||
app.include_router(sites_router)
|
app.include_router(sites_router)
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from src.application.final_approval import (
|
||||||
|
approve_final_article,
|
||||||
|
get_final_approval_checklist,
|
||||||
|
request_final_revision,
|
||||||
|
)
|
||||||
|
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
|
||||||
|
from src.domain.contracts import (
|
||||||
|
CurrentUser,
|
||||||
|
FinalApprovalChecklistResponse,
|
||||||
|
FinalApprovalRequest,
|
||||||
|
FinalApprovalResponse,
|
||||||
|
FinalRevisionRequest,
|
||||||
|
FinalRevisionRequestResponse,
|
||||||
|
)
|
||||||
|
from src.infrastructure.repositories import BackendRepository
|
||||||
|
from src.presentation.dependencies import get_repository, require_roles
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["final-approval"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/articles/{article_id}/final-approval/checklist",
|
||||||
|
response_model=FinalApprovalChecklistResponse,
|
||||||
|
)
|
||||||
|
def get_article_final_approval_checklist(
|
||||||
|
article_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> FinalApprovalChecklistResponse:
|
||||||
|
try:
|
||||||
|
return get_final_approval_checklist(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
request=None,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/final-approval",
|
||||||
|
response_model=FinalApprovalResponse,
|
||||||
|
)
|
||||||
|
def post_article_final_approval(
|
||||||
|
article_id: UUID,
|
||||||
|
request: FinalApprovalRequest,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> FinalApprovalResponse:
|
||||||
|
try:
|
||||||
|
return approve_final_article(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
request=request,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
except PermissionError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/final-revision-request",
|
||||||
|
response_model=FinalRevisionRequestResponse,
|
||||||
|
)
|
||||||
|
def post_article_final_revision_request(
|
||||||
|
article_id: UUID,
|
||||||
|
request: FinalRevisionRequest,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> FinalRevisionRequestResponse:
|
||||||
|
try:
|
||||||
|
return request_final_revision(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
request=request,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
from src.application.seed_data import seed_reference_data # noqa: E402
|
||||||
|
from src.infrastructure.repositories import open_backend_repository # noqa: E402
|
||||||
|
from src.presentation.dependencies import get_repository # noqa: E402
|
||||||
|
from src.presentation.main import app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
DEMO_EDITOR_EMAIL = "editor@example.com"
|
||||||
|
DEMO_ADMIN_EMAIL = "admin@example.com"
|
||||||
|
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||||
|
|
||||||
|
|
||||||
|
class FinalApprovalGatePublicApiTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||||
|
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(Path(self.tmp_dir.name) / "objects")
|
||||||
|
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'final-approval-gate.db'}"
|
||||||
|
self.repository = open_backend_repository(dsn)
|
||||||
|
self.repository.setup()
|
||||||
|
seed_reference_data(self.repository)
|
||||||
|
app.dependency_overrides[get_repository] = lambda: self.repository
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
os.environ.pop("OBJECT_STORAGE_LOCAL_ROOT", None)
|
||||||
|
self.tmp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_high_risk_unsupported_claims_block_final_approval(self) -> None:
|
||||||
|
article_id, draft = self._prepare_article_ready_for_final_approval(
|
||||||
|
include_high_risk_unsupported=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/final-approval",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json=self._final_approval_payload(draft_version=draft["version"]),
|
||||||
|
)
|
||||||
|
self.assertEqual(409, response.status_code, response.text)
|
||||||
|
self.assertIn("high-risk unsupported claims", response.text.lower())
|
||||||
|
|
||||||
|
def test_final_approval_requires_exact_draft_version(self) -> None:
|
||||||
|
article_id, draft = self._prepare_article_ready_for_final_approval()
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/final-approval",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json=self._final_approval_payload(draft_version=draft["version"] + 1),
|
||||||
|
)
|
||||||
|
self.assertEqual(409, response.status_code, response.text)
|
||||||
|
self.assertIn("latest draft version", response.text.lower())
|
||||||
|
|
||||||
|
def test_missing_required_publishing_settings_block_final_approval(self) -> None:
|
||||||
|
article_id, draft = self._prepare_article_ready_for_final_approval()
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/final-approval",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"draft_version": draft["version"],
|
||||||
|
"publishing_settings": {
|
||||||
|
"content_path": "",
|
||||||
|
"author": "",
|
||||||
|
"publishing_mode": "",
|
||||||
|
"frontmatter": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(409, response.status_code, response.text)
|
||||||
|
self.assertIn("required publishing settings are missing", response.text.lower())
|
||||||
|
|
||||||
|
def test_unapproved_required_assets_block_final_approval(self) -> None:
|
||||||
|
article_id, draft = self._prepare_article_ready_for_final_approval(approve_assets=False)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/final-approval",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json=self._final_approval_payload(draft_version=draft["version"]),
|
||||||
|
)
|
||||||
|
self.assertEqual(409, response.status_code, response.text)
|
||||||
|
self.assertIn("required assets", response.text.lower())
|
||||||
|
|
||||||
|
def test_successful_final_approval_writes_event_and_moves_to_publish_dry_run_required(self) -> None:
|
||||||
|
article_id, draft = self._prepare_article_ready_for_final_approval()
|
||||||
|
actor = self.client.get(
|
||||||
|
"/api/me",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()["user"]
|
||||||
|
payload = self._final_approval_payload(draft_version=draft["version"])
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/final-approval",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
body = response.json()
|
||||||
|
self.assertEqual("PUBLISH_DRY_RUN_REQUIRED", body["article"]["status"])
|
||||||
|
self.assertTrue(body["checklist"]["can_approve"])
|
||||||
|
self.assertFalse(body["checklist"]["blockers"])
|
||||||
|
|
||||||
|
detail = self.client.get(
|
||||||
|
f"/api/articles/{article_id}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()
|
||||||
|
events = [
|
||||||
|
event
|
||||||
|
for event in detail["workflow_events"]
|
||||||
|
if event["event_type"] == "FINAL_APPROVAL_GRANTED"
|
||||||
|
]
|
||||||
|
self.assertTrue(events, "Expected FINAL_APPROVAL_GRANTED event")
|
||||||
|
latest = events[-1]
|
||||||
|
self.assertEqual(actor["id"], latest["actor_user_id"])
|
||||||
|
self.assertTrue(latest["created_at"])
|
||||||
|
self.assertEqual(draft["version"], latest["payload"]["draft_version"])
|
||||||
|
self.assertEqual(payload["publishing_settings"], latest["payload"]["publishing_settings"])
|
||||||
|
|
||||||
|
def test_revision_request_writes_event_and_moves_article_to_final_revision_required(self) -> None:
|
||||||
|
article_id, draft = self._prepare_article_ready_for_final_approval()
|
||||||
|
actor = self.client.get(
|
||||||
|
"/api/me",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()["user"]
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/final-revision-request",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"reason": "Need factual clarification in section 2.",
|
||||||
|
"draft_version": draft["version"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
body = response.json()
|
||||||
|
self.assertEqual("FINAL_REVISION_REQUIRED", body["article"]["status"])
|
||||||
|
|
||||||
|
detail = self.client.get(
|
||||||
|
f"/api/articles/{article_id}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()
|
||||||
|
events = [
|
||||||
|
event
|
||||||
|
for event in detail["workflow_events"]
|
||||||
|
if event["event_type"] == "FINAL_REVISION_REQUESTED"
|
||||||
|
]
|
||||||
|
self.assertTrue(events, "Expected FINAL_REVISION_REQUESTED event")
|
||||||
|
latest = events[-1]
|
||||||
|
self.assertEqual(actor["id"], latest["actor_user_id"])
|
||||||
|
self.assertEqual("Need factual clarification in section 2.", latest["payload"]["reason"])
|
||||||
|
self.assertEqual(draft["version"], latest["payload"]["draft_version"])
|
||||||
|
|
||||||
|
def test_checklist_endpoint_returns_blockers_and_completed_items(self) -> None:
|
||||||
|
article_id, _ = self._prepare_article_ready_for_final_approval(approve_assets=False)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/final-approval/checklist",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
checklist = response.json()
|
||||||
|
self.assertFalse(checklist["can_approve"])
|
||||||
|
self.assertTrue(checklist["blockers"])
|
||||||
|
self.assertTrue(checklist["completed_items"])
|
||||||
|
self.assertTrue(any(item["completed"] for item in checklist["items"]))
|
||||||
|
self.assertTrue(any(not item["completed"] for item in checklist["items"]))
|
||||||
|
|
||||||
|
def _final_approval_payload(self, *, draft_version: int) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"draft_version": draft_version,
|
||||||
|
"publishing_settings": {
|
||||||
|
"content_path": "/guides/final-approval-gate",
|
||||||
|
"author": "Editorial Team",
|
||||||
|
"publishing_mode": "MANUAL",
|
||||||
|
"frontmatter": {
|
||||||
|
"category": "Guides",
|
||||||
|
"tags": ["workflow", "approval"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _prepare_article_ready_for_final_approval(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
include_high_risk_unsupported: bool = False,
|
||||||
|
approve_assets: bool = True,
|
||||||
|
) -> tuple[str, dict[str, Any]]:
|
||||||
|
article_id = self._create_article_with_approved_plan()
|
||||||
|
self._ensure_evidence_ready(article_id)
|
||||||
|
self._approve_all_evidence(article_id)
|
||||||
|
|
||||||
|
section_jobs = self._start_parallel_production(article_id)
|
||||||
|
self.assertGreaterEqual(len(section_jobs), 1)
|
||||||
|
first_job = section_jobs[0]
|
||||||
|
|
||||||
|
if include_high_risk_unsupported:
|
||||||
|
failed = self._complete_job(
|
||||||
|
first_job["id"],
|
||||||
|
output={
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"output_files": [{"path": "outputs/high-risk.md"}],
|
||||||
|
"payload": {
|
||||||
|
"used_evidence_ids": first_job["payload"]["used_evidence_ids"],
|
||||||
|
"unsupported_claims": [
|
||||||
|
{
|
||||||
|
"claim_text": "Unverified migration benchmark for market leadership.",
|
||||||
|
"risk_level": "high",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"draft_markdown": "Section draft with high-risk unsupported claim.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual("FAILED", failed["status"])
|
||||||
|
retry_response = self.client.post(
|
||||||
|
f"/api/agent-jobs/{first_job['id']}/retry",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, retry_response.status_code, retry_response.text)
|
||||||
|
retry_job = retry_response.json()["job"]
|
||||||
|
retried = self._complete_job(
|
||||||
|
retry_job["id"],
|
||||||
|
output={
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"output_files": [{"path": "outputs/retry-clean.md"}],
|
||||||
|
"payload": {
|
||||||
|
"used_evidence_ids": retry_job["payload"]["used_evidence_ids"],
|
||||||
|
"unsupported_claims": [],
|
||||||
|
"draft_markdown": (
|
||||||
|
f"## {retry_job['payload']['heading']}\n\n"
|
||||||
|
"Clean retry section with [internal guide](/guides/internal)."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual("SUCCEEDED", retried["status"])
|
||||||
|
else:
|
||||||
|
completed = self._complete_job(
|
||||||
|
first_job["id"],
|
||||||
|
output={
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"output_files": [{"path": "outputs/section-1.md"}],
|
||||||
|
"payload": {
|
||||||
|
"used_evidence_ids": first_job["payload"]["used_evidence_ids"],
|
||||||
|
"unsupported_claims": [],
|
||||||
|
"draft_markdown": (
|
||||||
|
f"## {first_job['payload']['heading']}\n\n"
|
||||||
|
"Use [internal guide](/guides/internal) for implementation details."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual("SUCCEEDED", completed["status"])
|
||||||
|
|
||||||
|
for index, job in enumerate(section_jobs[1:], start=2):
|
||||||
|
completed = self._complete_job(
|
||||||
|
job["id"],
|
||||||
|
output={
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"output_files": [{"path": f"outputs/section-{index}.md"}],
|
||||||
|
"payload": {
|
||||||
|
"used_evidence_ids": job["payload"]["used_evidence_ids"],
|
||||||
|
"unsupported_claims": [],
|
||||||
|
"draft_markdown": f"## {job['payload']['heading']}\n\nSection {index} ready.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual("SUCCEEDED", completed["status"])
|
||||||
|
|
||||||
|
assemble_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/draft/assemble",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, assemble_response.status_code, assemble_response.text)
|
||||||
|
draft = assemble_response.json()["draft"]
|
||||||
|
|
||||||
|
assets_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/assets/generate-specs",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, assets_response.status_code, assets_response.text)
|
||||||
|
assets = assets_response.json()["assets"]
|
||||||
|
self.assertTrue(assets)
|
||||||
|
|
||||||
|
if approve_assets:
|
||||||
|
for asset in assets:
|
||||||
|
upload_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/assets/{asset['id']}/upload",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"filename": f"{asset['id']}.png",
|
||||||
|
"content_base64": base64.b64encode(b"asset-binary").decode("utf-8"),
|
||||||
|
"content_type": "image/png",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, upload_response.status_code, upload_response.text)
|
||||||
|
approve_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/assets/{asset['id']}/approve",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, approve_response.status_code, approve_response.text)
|
||||||
|
|
||||||
|
return article_id, draft
|
||||||
|
|
||||||
|
def _approve_all_evidence(self, article_id: str) -> None:
|
||||||
|
evidence_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/evidence",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
|
||||||
|
evidence = evidence_response.json()["evidence"]
|
||||||
|
self.assertTrue(evidence)
|
||||||
|
for item in evidence:
|
||||||
|
patch_response = self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/evidence/{item['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"review_status": "APPROVED"},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||||
|
|
||||||
|
def _complete_job(self, job_id: str, *, output: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/internal/agent-jobs/{job_id}/complete",
|
||||||
|
json={
|
||||||
|
"workspace_path": f"/tmp/{job_id}",
|
||||||
|
"stdout": "fake section scaffolding runner\n",
|
||||||
|
"stderr": "",
|
||||||
|
"exit_code": 0,
|
||||||
|
"duration_ms": 1,
|
||||||
|
"output": output,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
return response.json()["job"]
|
||||||
|
|
||||||
|
def _start_parallel_production(self, article_id: str) -> list[dict[str, Any]]:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/draft/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(202, response.status_code, response.text)
|
||||||
|
return [job for job in response.json()["jobs"] if job["job_type"] == "SECTION_SCAFFOLD"]
|
||||||
|
|
||||||
|
def _ensure_evidence_ready(self, article_id: str) -> None:
|
||||||
|
research_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/research/start",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, research_response.status_code, research_response.text)
|
||||||
|
|
||||||
|
evidence_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/evidence",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
|
||||||
|
self.assertEqual("EVIDENCE_MATRIX_READY", evidence_response.json()["article"]["status"])
|
||||||
|
|
||||||
|
def _create_article_with_approved_plan(self) -> str:
|
||||||
|
site = self.client.get(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()[0]["site"]
|
||||||
|
article = self.client.post(
|
||||||
|
"/api/articles",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"target_site_id": site["id"],
|
||||||
|
"brief_description": "Validate final approval gate checklist and transitions.",
|
||||||
|
"working_title": "Final Approval Gate",
|
||||||
|
"content_type": "longform_guide",
|
||||||
|
"primary_keyword": "final approval gate",
|
||||||
|
},
|
||||||
|
).json()["article"]
|
||||||
|
article_id = article["id"]
|
||||||
|
|
||||||
|
questions = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()["questions"]
|
||||||
|
for question in questions:
|
||||||
|
if question["is_required"]:
|
||||||
|
patch_response = self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"answer": f"Answer for {question['category']}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||||
|
|
||||||
|
submit_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/boundary-questions/submit",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, submit_response.status_code, submit_response.text)
|
||||||
|
|
||||||
|
plan_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plan/generate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, plan_response.status_code, plan_response.text)
|
||||||
|
plan = plan_response.json()["plan"]
|
||||||
|
self.assertGreaterEqual(len(plan["sections"]), 1)
|
||||||
|
|
||||||
|
approve_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plans/{plan['id']}/approve",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, approve_response.status_code, approve_response.text)
|
||||||
|
return article_id
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type {
|
||||||
|
FinalApprovalChecklistItemSummary,
|
||||||
|
FinalApprovalChecklistResponse,
|
||||||
|
} from "@pipeline/shared";
|
||||||
|
|
||||||
|
export type FinalApprovalChecklistItemViewModel = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
completed: boolean;
|
||||||
|
blocker: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FinalApprovalViewModel = {
|
||||||
|
canApprove: boolean;
|
||||||
|
completedItems: string[];
|
||||||
|
blockers: string[];
|
||||||
|
draftVersion: number | null;
|
||||||
|
requestedDraftVersion: number | null;
|
||||||
|
items: FinalApprovalChecklistItemViewModel[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildFinalApprovalViewModel(
|
||||||
|
checklist: FinalApprovalChecklistResponse | null,
|
||||||
|
): FinalApprovalViewModel {
|
||||||
|
if (!checklist) {
|
||||||
|
return {
|
||||||
|
canApprove: false,
|
||||||
|
completedItems: [],
|
||||||
|
blockers: [],
|
||||||
|
draftVersion: null,
|
||||||
|
requestedDraftVersion: null,
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
canApprove: checklist.can_approve,
|
||||||
|
completedItems: checklist.completed_items ?? [],
|
||||||
|
blockers: checklist.blockers ?? [],
|
||||||
|
draftVersion: checklist.draft_version ?? null,
|
||||||
|
requestedDraftVersion: checklist.requested_draft_version ?? null,
|
||||||
|
items: (checklist.items ?? []).map(mapChecklistItem),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapChecklistItem(
|
||||||
|
item: FinalApprovalChecklistItemSummary,
|
||||||
|
): FinalApprovalChecklistItemViewModel {
|
||||||
|
return {
|
||||||
|
key: item.key,
|
||||||
|
title: item.title,
|
||||||
|
completed: item.completed,
|
||||||
|
blocker: item.blocker ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import type { FinalApprovalChecklistResponse } from "@pipeline/shared";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
fetchFinalApprovalChecklist,
|
||||||
|
submitFinalApproval,
|
||||||
|
submitFinalRevisionRequest,
|
||||||
|
} from "@/shared/pipeline-api";
|
||||||
|
import { buildFinalApprovalViewModel } from "./model";
|
||||||
|
|
||||||
|
type FinalApprovalPanelProps = {
|
||||||
|
articleId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_FRONTMATTER = '{\n "category": "Guides"\n}';
|
||||||
|
|
||||||
|
export function FinalApprovalPanel({ articleId }: FinalApprovalPanelProps) {
|
||||||
|
const [checklist, setChecklist] = useState<FinalApprovalChecklistResponse | null>(null);
|
||||||
|
const [draftVersionInput, setDraftVersionInput] = useState("");
|
||||||
|
const [contentPath, setContentPath] = useState("/guides/final-approval-gate");
|
||||||
|
const [author, setAuthor] = useState("Editorial Team");
|
||||||
|
const [publishingMode, setPublishingMode] = useState("MANUAL");
|
||||||
|
const [frontmatterText, setFrontmatterText] = useState(DEFAULT_FRONTMATTER);
|
||||||
|
const [revisionReason, setRevisionReason] = useState("Need final refinements before approval.");
|
||||||
|
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
|
||||||
|
const viewModel = useMemo(
|
||||||
|
() => buildFinalApprovalViewModel(checklist),
|
||||||
|
[checklist],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function refreshChecklist() {
|
||||||
|
setBusyKey("refresh");
|
||||||
|
setMessage("");
|
||||||
|
try {
|
||||||
|
const response = await fetchFinalApprovalChecklist(articleId);
|
||||||
|
setChecklist(response);
|
||||||
|
if (!draftVersionInput && response.draft_version) {
|
||||||
|
setDraftVersionInput(String(response.draft_version));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error instanceof ApiError ? error.message : "Failed to fetch final checklist.");
|
||||||
|
} finally {
|
||||||
|
setBusyKey(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approve() {
|
||||||
|
let frontmatter: Record<string, unknown> | null = null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(frontmatterText);
|
||||||
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||||
|
frontmatter = parsed as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage("Frontmatter must be a valid JSON object.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedDraftVersion = Number.parseInt(draftVersionInput, 10);
|
||||||
|
setBusyKey("approve");
|
||||||
|
setMessage("");
|
||||||
|
try {
|
||||||
|
const response = await submitFinalApproval(articleId, {
|
||||||
|
draft_version: Number.isNaN(parsedDraftVersion) ? null : parsedDraftVersion,
|
||||||
|
publishing_settings: {
|
||||||
|
content_path: contentPath,
|
||||||
|
author,
|
||||||
|
publishing_mode: publishingMode,
|
||||||
|
frontmatter,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setChecklist(response.checklist);
|
||||||
|
setMessage(`Final approval completed. Article status: ${response.article.status}.`);
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error instanceof ApiError ? error.message : "Final approval failed.");
|
||||||
|
await refreshChecklist();
|
||||||
|
} finally {
|
||||||
|
setBusyKey(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestRevision() {
|
||||||
|
const parsedDraftVersion = Number.parseInt(draftVersionInput, 10);
|
||||||
|
setBusyKey("revision");
|
||||||
|
setMessage("");
|
||||||
|
try {
|
||||||
|
const response = await submitFinalRevisionRequest(articleId, {
|
||||||
|
reason: revisionReason,
|
||||||
|
draft_version: Number.isNaN(parsedDraftVersion) ? null : parsedDraftVersion,
|
||||||
|
});
|
||||||
|
setChecklist(response.checklist);
|
||||||
|
setMessage(`Revision requested. Article status: ${response.article.status}.`);
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error instanceof ApiError ? error.message : "Failed to request revision.");
|
||||||
|
} finally {
|
||||||
|
setBusyKey(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="reviewPanel">
|
||||||
|
<div className="sectionHeader">
|
||||||
|
<div>
|
||||||
|
<h2>Final approval gate</h2>
|
||||||
|
<p>Checklist blockers: {viewModel.blockers.length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="formActions">
|
||||||
|
<button type="button" onClick={() => void refreshChecklist()} disabled={busyKey !== null}>
|
||||||
|
{busyKey === "refresh" ? "Refreshing..." : "Refresh checklist"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message ? <p className={message.includes("failed") ? "formError" : "formSuccess"}>{message}</p> : null}
|
||||||
|
|
||||||
|
{viewModel.items.length > 0 ? (
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Checklist item</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{viewModel.items.map((item) => (
|
||||||
|
<tr key={item.key}>
|
||||||
|
<td>{item.title}</td>
|
||||||
|
<td>{item.completed ? "Completed" : "Blocked"}</td>
|
||||||
|
<td>{item.completed ? "—" : item.blocker || "—"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<p>No checklist yet. Refresh to load current gate status.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewModel.completedItems.length > 0 ? (
|
||||||
|
<ul className="timeline">
|
||||||
|
{viewModel.completedItems.map((item) => (
|
||||||
|
<li key={item}>Completed: {item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{viewModel.blockers.length > 0 ? (
|
||||||
|
<ul className="timeline">
|
||||||
|
{viewModel.blockers.map((item) => (
|
||||||
|
<li key={item}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="reviewActions">
|
||||||
|
<input
|
||||||
|
value={draftVersionInput}
|
||||||
|
onChange={(event) => setDraftVersionInput(event.target.value)}
|
||||||
|
placeholder={viewModel.draftVersion ? String(viewModel.draftVersion) : "Draft version"}
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={contentPath}
|
||||||
|
onChange={(event) => setContentPath(event.target.value)}
|
||||||
|
placeholder="/guides/final-approval-gate"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={author}
|
||||||
|
onChange={(event) => setAuthor(event.target.value)}
|
||||||
|
placeholder="Author"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={publishingMode}
|
||||||
|
onChange={(event) => setPublishingMode(event.target.value)}
|
||||||
|
placeholder="MANUAL"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={frontmatterText}
|
||||||
|
onChange={(event) => setFrontmatterText(event.target.value)}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => void approve()} disabled={busyKey !== null}>
|
||||||
|
{busyKey === "approve" ? "Approving..." : "Approve final"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="reviewActions">
|
||||||
|
<input
|
||||||
|
value={revisionReason}
|
||||||
|
onChange={(event) => setRevisionReason(event.target.value)}
|
||||||
|
placeholder="Revision reason"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => void requestRevision()} disabled={busyKey !== null}>
|
||||||
|
{busyKey === "revision" ? "Submitting..." : "Request revision"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { ArticleDetailShell } from "@/features/article-detail/ui";
|
|||||||
import { DraftEditorPanel } from "@/features/article-draft/ui";
|
import { DraftEditorPanel } from "@/features/article-draft/ui";
|
||||||
import { ArticleReviewPanel } from "@/features/article-review/ui";
|
import { ArticleReviewPanel } from "@/features/article-review/ui";
|
||||||
import { MediaLibraryPanel } from "@/features/article-assets/ui";
|
import { MediaLibraryPanel } from "@/features/article-assets/ui";
|
||||||
|
import { FinalApprovalPanel } from "@/features/final-approval/ui";
|
||||||
import { fetchArticleDetail, fetchDrafts } from "@/shared/pipeline-api";
|
import { fetchArticleDetail, fetchDrafts } from "@/shared/pipeline-api";
|
||||||
import { ApiError } from "@/shared/pipeline-api";
|
import { ApiError } from "@/shared/pipeline-api";
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ export default async function ArticleDetailPage({
|
|||||||
assets={detail.assets ?? []}
|
assets={detail.assets ?? []}
|
||||||
/>
|
/>
|
||||||
<ArticleReviewPanel articleId={articleId} />
|
<ArticleReviewPanel articleId={articleId} />
|
||||||
|
<FinalApprovalPanel articleId={articleId} />
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ import type {
|
|||||||
EvidenceCreateRequest,
|
EvidenceCreateRequest,
|
||||||
EvidenceUpdateRequest,
|
EvidenceUpdateRequest,
|
||||||
FinalReviewIssuesResponse,
|
FinalReviewIssuesResponse,
|
||||||
|
FinalApprovalChecklistResponse,
|
||||||
|
FinalApprovalRequest,
|
||||||
|
FinalApprovalResponse,
|
||||||
|
FinalRevisionRequest,
|
||||||
|
FinalRevisionRequestResponse,
|
||||||
LanguageReviewReportResponse,
|
LanguageReviewReportResponse,
|
||||||
LanguageReviewRunResponse,
|
LanguageReviewRunResponse,
|
||||||
PlanListResponse,
|
PlanListResponse,
|
||||||
@@ -622,3 +627,31 @@ export function fetchFinalReviewIssues(
|
|||||||
): Promise<FinalReviewIssuesResponse> {
|
): Promise<FinalReviewIssuesResponse> {
|
||||||
return apiGet<FinalReviewIssuesResponse>(`/api/articles/${articleId}/final-review/issues`);
|
return apiGet<FinalReviewIssuesResponse>(`/api/articles/${articleId}/final-review/issues`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchFinalApprovalChecklist(
|
||||||
|
articleId: string,
|
||||||
|
): Promise<FinalApprovalChecklistResponse> {
|
||||||
|
return apiGet<FinalApprovalChecklistResponse>(
|
||||||
|
`/api/articles/${articleId}/final-approval/checklist`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function submitFinalApproval(
|
||||||
|
articleId: string,
|
||||||
|
request: FinalApprovalRequest,
|
||||||
|
): Promise<FinalApprovalResponse> {
|
||||||
|
return apiPost<FinalApprovalRequest, FinalApprovalResponse>(
|
||||||
|
`/api/articles/${articleId}/final-approval`,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function submitFinalRevisionRequest(
|
||||||
|
articleId: string,
|
||||||
|
request: FinalRevisionRequest,
|
||||||
|
): Promise<FinalRevisionRequestResponse> {
|
||||||
|
return apiPost<FinalRevisionRequest, FinalRevisionRequestResponse>(
|
||||||
|
`/api/articles/${articleId}/final-revision-request`,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import ts from "typescript";
|
||||||
|
|
||||||
|
const testDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const sourcePath = path.resolve(testDir, "../src/features/final-approval/model.ts");
|
||||||
|
const source = readFileSync(sourcePath, "utf8");
|
||||||
|
const compiled = ts.transpileModule(source, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const moduleExports = {};
|
||||||
|
new Function("exports", compiled.outputText)(moduleExports);
|
||||||
|
|
||||||
|
const { buildFinalApprovalViewModel } = moduleExports;
|
||||||
|
|
||||||
|
const viewModel = buildFinalApprovalViewModel({
|
||||||
|
article_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||||
|
draft_version: 3,
|
||||||
|
requested_draft_version: 2,
|
||||||
|
can_approve: false,
|
||||||
|
completed_items: ["Plan followed", "Evidence reviewed"],
|
||||||
|
blockers: [
|
||||||
|
"Final approval requires latest draft version 3.",
|
||||||
|
"Required publishing settings are missing: content path.",
|
||||||
|
],
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: "exact_draft_version",
|
||||||
|
title: "Exact draft version selected",
|
||||||
|
completed: false,
|
||||||
|
blocker: "Final approval requires latest draft version 3.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "plan_followed",
|
||||||
|
title: "Plan followed",
|
||||||
|
completed: true,
|
||||||
|
blocker: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(viewModel.canApprove, false);
|
||||||
|
assert.equal(viewModel.draftVersion, 3);
|
||||||
|
assert.equal(viewModel.requestedDraftVersion, 2);
|
||||||
|
assert.equal(viewModel.completedItems.length, 2);
|
||||||
|
assert.equal(viewModel.blockers.length, 2);
|
||||||
|
assert.equal(viewModel.items.length, 2);
|
||||||
|
assert.equal(viewModel.items[0].completed, false);
|
||||||
|
assert.equal(viewModel.items[1].completed, true);
|
||||||
@@ -2075,6 +2075,215 @@
|
|||||||
"title": "EvidenceUpdateRequest",
|
"title": "EvidenceUpdateRequest",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"FinalApprovalChecklistItemSummary": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"blocker": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Blocker"
|
||||||
|
},
|
||||||
|
"completed": {
|
||||||
|
"title": "Completed",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Key",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Title",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"key",
|
||||||
|
"title",
|
||||||
|
"completed"
|
||||||
|
],
|
||||||
|
"title": "FinalApprovalChecklistItemSummary",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"FinalApprovalChecklistResponse": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"article_id": {
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Article Id",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"blockers": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"title": "Blockers",
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"can_approve": {
|
||||||
|
"title": "Can Approve",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"completed_items": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"title": "Completed Items",
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"draft_version": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Draft Version"
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/FinalApprovalChecklistItemSummary"
|
||||||
|
},
|
||||||
|
"title": "Items",
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"requested_draft_version": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Requested Draft Version"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"article_id",
|
||||||
|
"can_approve"
|
||||||
|
],
|
||||||
|
"title": "FinalApprovalChecklistResponse",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"FinalApprovalPublishingSettings": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"author": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Author"
|
||||||
|
},
|
||||||
|
"content_path": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Content Path"
|
||||||
|
},
|
||||||
|
"frontmatter": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"additionalProperties": true,
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Frontmatter"
|
||||||
|
},
|
||||||
|
"publishing_mode": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Publishing Mode"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "FinalApprovalPublishingSettings",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"FinalApprovalRequest": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"draft_version": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Draft Version"
|
||||||
|
},
|
||||||
|
"publishing_settings": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/FinalApprovalPublishingSettings"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "FinalApprovalRequest",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"FinalApprovalResponse": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"article": {
|
||||||
|
"$ref": "#/components/schemas/ArticleSummary"
|
||||||
|
},
|
||||||
|
"checklist": {
|
||||||
|
"$ref": "#/components/schemas/FinalApprovalChecklistResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"article",
|
||||||
|
"checklist"
|
||||||
|
],
|
||||||
|
"title": "FinalApprovalResponse",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"FinalReviewIssueSummary": {
|
"FinalReviewIssueSummary": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -2144,6 +2353,55 @@
|
|||||||
"title": "FinalReviewIssuesResponse",
|
"title": "FinalReviewIssuesResponse",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"FinalRevisionRequest": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"draft_version": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Draft Version"
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Reason"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "FinalRevisionRequest",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"FinalRevisionRequestResponse": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"article": {
|
||||||
|
"$ref": "#/components/schemas/ArticleSummary"
|
||||||
|
},
|
||||||
|
"checklist": {
|
||||||
|
"$ref": "#/components/schemas/FinalApprovalChecklistResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"article",
|
||||||
|
"checklist"
|
||||||
|
],
|
||||||
|
"title": "FinalRevisionRequestResponse",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"HTTPValidationError": {
|
"HTTPValidationError": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"detail": {
|
"detail": {
|
||||||
@@ -5552,6 +5810,134 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/articles/{article_id}/final-approval": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "post_article_final_approval_api_articles__article_id__final_approval_post",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "article_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Article Id",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "header",
|
||||||
|
"name": "X-Demo-User-Email",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "X-Demo-User-Email"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/FinalApprovalRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/FinalApprovalResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful Response"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Validation Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "Post Article Final Approval",
|
||||||
|
"tags": [
|
||||||
|
"final-approval"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/articles/{article_id}/final-approval/checklist": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "get_article_final_approval_checklist_api_articles__article_id__final_approval_checklist_get",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "article_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Article Id",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "header",
|
||||||
|
"name": "X-Demo-User-Email",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "X-Demo-User-Email"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/FinalApprovalChecklistResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful Response"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Validation Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "Get Article Final Approval Checklist",
|
||||||
|
"tags": [
|
||||||
|
"final-approval"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/articles/{article_id}/final-review/issues": {
|
"/api/articles/{article_id}/final-review/issues": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "get_article_final_review_issues_api_articles__article_id__final_review_issues_get",
|
"operationId": "get_article_final_review_issues_api_articles__article_id__final_review_issues_get",
|
||||||
@@ -5611,6 +5997,75 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/articles/{article_id}/final-revision-request": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "post_article_final_revision_request_api_articles__article_id__final_revision_request_post",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "article_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Article Id",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "header",
|
||||||
|
"name": "X-Demo-User-Email",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "X-Demo-User-Email"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/FinalRevisionRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/FinalRevisionRequestResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful Response"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Validation Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "Post Article Final Revision Request",
|
||||||
|
"tags": [
|
||||||
|
"final-approval"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/articles/{article_id}/final/approve": {
|
"/api/articles/{article_id}/final/approve": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "post_final_approve_api_articles__article_id__final_approve_post",
|
"operationId": "post_final_approve_api_articles__article_id__final_approve_post",
|
||||||
|
|||||||
@@ -349,6 +349,40 @@ export type EvidenceUpdateRequest = {
|
|||||||
review_status?: string | null;
|
review_status?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FinalApprovalChecklistItemSummary = {
|
||||||
|
blocker?: string | null;
|
||||||
|
completed: boolean;
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FinalApprovalChecklistResponse = {
|
||||||
|
article_id: string;
|
||||||
|
blockers?: string[];
|
||||||
|
can_approve: boolean;
|
||||||
|
completed_items?: string[];
|
||||||
|
draft_version?: number | null;
|
||||||
|
items?: FinalApprovalChecklistItemSummary[];
|
||||||
|
requested_draft_version?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FinalApprovalPublishingSettings = {
|
||||||
|
author?: string | null;
|
||||||
|
content_path?: string | null;
|
||||||
|
frontmatter?: Record<string, unknown> | null;
|
||||||
|
publishing_mode?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FinalApprovalRequest = {
|
||||||
|
draft_version?: number | null;
|
||||||
|
publishing_settings?: FinalApprovalPublishingSettings | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FinalApprovalResponse = {
|
||||||
|
article: ArticleSummary;
|
||||||
|
checklist: FinalApprovalChecklistResponse;
|
||||||
|
};
|
||||||
|
|
||||||
export type FinalReviewIssueSummary = {
|
export type FinalReviewIssueSummary = {
|
||||||
location: string;
|
location: string;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -364,6 +398,16 @@ export type FinalReviewIssuesResponse = {
|
|||||||
unresolved_issues?: FinalReviewIssueSummary[];
|
unresolved_issues?: FinalReviewIssueSummary[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FinalRevisionRequest = {
|
||||||
|
draft_version?: number | null;
|
||||||
|
reason?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FinalRevisionRequestResponse = {
|
||||||
|
article: ArticleSummary;
|
||||||
|
checklist: FinalApprovalChecklistResponse;
|
||||||
|
};
|
||||||
|
|
||||||
export type HTTPValidationError = {
|
export type HTTPValidationError = {
|
||||||
detail?: ValidationError[];
|
detail?: ValidationError[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,14 +34,14 @@ Development description: Implement the final review gate that prevents publishin
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] TDD pre-requirement: before implementation, write one failing behavior test proving high-risk unsupported claims block final approval; proceed one checklist behavior at a time and record evidence in `Result`.
|
- [x] TDD pre-requirement: before implementation, write one failing behavior test proving high-risk unsupported claims block final approval; proceed one checklist behavior at a time and record evidence in `Result`.
|
||||||
- [ ] Final approval requires an exact draft version.
|
- [x] Final approval requires an exact draft version.
|
||||||
- [ ] High-risk unsupported claims block approval.
|
- [x] High-risk unsupported claims block approval.
|
||||||
- [ ] Missing required publishing settings block approval.
|
- [x] Missing required publishing settings block approval.
|
||||||
- [ ] Unapproved required assets block approval.
|
- [x] Unapproved required assets block approval.
|
||||||
- [ ] Approval writes a workflow event with actor, timestamp, draft version, and publishing settings.
|
- [x] Approval writes a workflow event with actor, timestamp, draft version, and publishing settings.
|
||||||
- [ ] Revision request writes event and moves article to `FINAL_REVISION_REQUIRED`.
|
- [x] Revision request writes event and moves article to `FINAL_REVISION_REQUIRED`.
|
||||||
- [ ] UI shows blockers and completed checklist items.
|
- [x] UI shows blockers and completed checklist items.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
@@ -51,9 +51,32 @@ Development description: Implement the final review gate that prevents publishin
|
|||||||
|
|
||||||
## Result
|
## Result
|
||||||
|
|
||||||
- Status: Pending execution.
|
- Status: Done (TDD RED -> GREEN completed).
|
||||||
- TDD plan: To be filled during execution.
|
- TDD progression:
|
||||||
- Red evidence: To be filled during execution.
|
1. Pre-requirement RED test created for high-risk unsupported claims blocker.
|
||||||
- Green evidence: To be filled during execution.
|
2. Implemented final approval gate service with checklist evaluation on assembled draft + evidence + assets + review state.
|
||||||
- Refactor notes: To be filled during execution.
|
3. Added public endpoints:
|
||||||
- Verification output: To be filled during execution.
|
- `POST /api/articles/{article_id}/final-approval`
|
||||||
|
- `POST /api/articles/{article_id}/final-revision-request`
|
||||||
|
- `GET /api/articles/{article_id}/final-approval/checklist` (for UI checklist visibility).
|
||||||
|
4. Added workflow events:
|
||||||
|
- `FINAL_APPROVAL_GRANTED` -> `PUBLISH_DRY_RUN_REQUIRED`
|
||||||
|
- `FINAL_REVISION_REQUESTED` -> `FINAL_REVISION_REQUIRED`
|
||||||
|
5. Added frontend final-approval feature (FSD) to show blockers/completed checklist items and invoke approval/revision endpoints.
|
||||||
|
|
||||||
|
- Implemented behavior coverage:
|
||||||
|
- Exact draft version is required and validated against latest immutable draft.
|
||||||
|
- High-risk unsupported claims (claims + section scaffold history) block approval.
|
||||||
|
- Missing required publishing settings (`frontmatter`, `content_path`, `author`, `publishing_mode`) block approval.
|
||||||
|
- Unapproved required assets block approval.
|
||||||
|
- Successful approval writes event payload with actor/timestamp/draft version/publishing settings and moves state to `PUBLISH_DRY_RUN_REQUIRED`.
|
||||||
|
- Revision request writes event payload and moves state to `FINAL_REVISION_REQUIRED`.
|
||||||
|
- UI renders checklist rows, blockers, and completed items.
|
||||||
|
|
||||||
|
- Verification output:
|
||||||
|
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_final_approval_gate_public_api.py` -> OK (7 tests)
|
||||||
|
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_seo_language_review_public_api.py` -> OK (6 tests)
|
||||||
|
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_assets_media_library_public_api.py` -> OK (7 tests)
|
||||||
|
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_draft_assembly_public_api.py` -> OK (5 tests)
|
||||||
|
- `node apps/frontend/tests/final_approval.model.test.mjs` -> OK
|
||||||
|
- `pnpm --dir apps/frontend typecheck` -> OK
|
||||||
|
|||||||
Reference in New Issue
Block a user