Task 004: add demo auth and role checks
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
fastapi==0.115.6
|
fastapi==0.115.6
|
||||||
|
httpx==0.28.1
|
||||||
uvicorn[standard]==0.34.0
|
uvicorn[standard]==0.34.0
|
||||||
alembic==1.14.0
|
alembic==1.14.0
|
||||||
SQLAlchemy==2.0.36
|
SQLAlchemy==2.0.36
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from uuid import NAMESPACE_URL, uuid5
|
from uuid import NAMESPACE_URL, UUID, uuid5
|
||||||
|
|
||||||
from src.domain.contracts import (
|
from src.domain.contracts import (
|
||||||
ArticleCreateRequest,
|
ArticleCreateRequest,
|
||||||
@@ -9,6 +9,10 @@ from src.domain.contracts import (
|
|||||||
ArticleSummary,
|
ArticleSummary,
|
||||||
ArticleWorkflowStatus,
|
ArticleWorkflowStatus,
|
||||||
PublishingStatus,
|
PublishingStatus,
|
||||||
|
ReviewActionResponse,
|
||||||
|
ReviewStatus,
|
||||||
|
ReviewSummary,
|
||||||
|
ReviewType,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -37,3 +41,21 @@ def build_placeholder_article_response(
|
|||||||
updated_at=PLACEHOLDER_CREATED_AT,
|
updated_at=PLACEHOLDER_CREATED_AT,
|
||||||
)
|
)
|
||||||
return ArticleCreateResponse(article=article)
|
return ArticleCreateResponse(article=article)
|
||||||
|
|
||||||
|
|
||||||
|
def build_placeholder_plan_approval_response(
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
plan_id: UUID,
|
||||||
|
reviewer_id: UUID,
|
||||||
|
) -> ReviewActionResponse:
|
||||||
|
review = ReviewSummary(
|
||||||
|
id=uuid5(NAMESPACE_URL, f"plan-approval:{article_id}:{plan_id}:{reviewer_id}"),
|
||||||
|
article_id=article_id,
|
||||||
|
review_type=ReviewType.PLAN,
|
||||||
|
status=ReviewStatus.APPROVED,
|
||||||
|
reviewer_id=reviewer_id,
|
||||||
|
notes=None,
|
||||||
|
created_at=PLACEHOLDER_CREATED_AT,
|
||||||
|
)
|
||||||
|
return ReviewActionResponse(review=review)
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.domain.contracts import CurrentUser
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user_by_email(repository: object, email: str) -> CurrentUser:
|
||||||
|
user = repository.users.get_by_email(email)
|
||||||
|
return CurrentUser(
|
||||||
|
id=user.id,
|
||||||
|
email=email,
|
||||||
|
display_name=user.display_name,
|
||||||
|
role=user.role,
|
||||||
|
)
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from hashlib import sha256
|
||||||
|
from uuid import NAMESPACE_URL, UUID, uuid5
|
||||||
|
|
||||||
|
from src.domain.contracts import (
|
||||||
|
CurrentUser,
|
||||||
|
ScriptConfigVersionCreateRequest,
|
||||||
|
ScriptConfigVersionResponse,
|
||||||
|
ScriptConfigVersionStatus,
|
||||||
|
ScriptConfigVersionSummary,
|
||||||
|
TargetSiteConfigCreateRequest,
|
||||||
|
TargetSiteConfigResponse,
|
||||||
|
TargetSiteConfigUpdateRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_target_site(
|
||||||
|
repository: object,
|
||||||
|
request: TargetSiteConfigCreateRequest,
|
||||||
|
) -> TargetSiteConfigResponse:
|
||||||
|
now = _now()
|
||||||
|
site = repository.target_sites.upsert(
|
||||||
|
site_id=_stable_uuid(f"target-site:{request.slug}"),
|
||||||
|
name=request.name,
|
||||||
|
slug=request.slug,
|
||||||
|
publishing_type=request.publishing_type,
|
||||||
|
default_language=request.default_language,
|
||||||
|
brand_voice=request.brand_voice,
|
||||||
|
audience=request.audience,
|
||||||
|
seo_rules=request.seo_rules,
|
||||||
|
visual_rules=request.visual_rules,
|
||||||
|
source_rules=request.source_rules,
|
||||||
|
publishing_rules=request.publishing_rules,
|
||||||
|
active_script_config_version_id=request.active_script_config_version_id,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
return TargetSiteConfigResponse(site=site)
|
||||||
|
|
||||||
|
|
||||||
|
def update_target_site(
|
||||||
|
repository: object,
|
||||||
|
site_id: UUID,
|
||||||
|
request: TargetSiteConfigUpdateRequest,
|
||||||
|
) -> TargetSiteConfigResponse:
|
||||||
|
existing = repository.target_sites.get_by_id(site_id)
|
||||||
|
changes = request.model_dump(exclude_unset=True)
|
||||||
|
publishing_rules = (
|
||||||
|
request.publishing_rules
|
||||||
|
if "publishing_rules" in changes
|
||||||
|
else existing.publishing_rules
|
||||||
|
)
|
||||||
|
now = _now()
|
||||||
|
site = repository.target_sites.update(
|
||||||
|
site_id=existing.id,
|
||||||
|
name=changes.get("name", existing.name),
|
||||||
|
slug=changes.get("slug", existing.slug),
|
||||||
|
publishing_type=changes.get("publishing_type", existing.publishing_type),
|
||||||
|
default_language=changes.get("default_language", existing.default_language),
|
||||||
|
brand_voice=changes.get("brand_voice", existing.brand_voice),
|
||||||
|
audience=changes.get("audience", existing.audience),
|
||||||
|
seo_rules=changes.get("seo_rules", existing.seo_rules),
|
||||||
|
visual_rules=changes.get("visual_rules", existing.visual_rules),
|
||||||
|
source_rules=changes.get("source_rules", existing.source_rules),
|
||||||
|
publishing_rules=publishing_rules,
|
||||||
|
active_script_config_version_id=changes.get(
|
||||||
|
"active_script_config_version_id",
|
||||||
|
existing.active_script_config_version_id,
|
||||||
|
),
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
return TargetSiteConfigResponse(site=site)
|
||||||
|
|
||||||
|
|
||||||
|
def create_script_config_version(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
site_id: UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
request: ScriptConfigVersionCreateRequest,
|
||||||
|
) -> ScriptConfigVersionResponse:
|
||||||
|
repository.target_sites.get_by_id(site_id)
|
||||||
|
existing_versions = repository.script_config_versions.list_for_site(site_id)
|
||||||
|
version = request.version or _next_version(existing_versions)
|
||||||
|
now = _now()
|
||||||
|
version_id = _stable_uuid(f"script-config-version:{site_id}:{version}")
|
||||||
|
status = (
|
||||||
|
ScriptConfigVersionStatus.ACTIVE
|
||||||
|
if request.activate
|
||||||
|
else ScriptConfigVersionStatus.DRAFT
|
||||||
|
)
|
||||||
|
row = repository.script_config_versions.upsert(
|
||||||
|
version_id=version_id,
|
||||||
|
target_site_id=site_id,
|
||||||
|
version=version,
|
||||||
|
status=status,
|
||||||
|
created_by=current_user.id,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
diff=request.diff,
|
||||||
|
rollback_target_version_id=request.rollback_target_version_id,
|
||||||
|
activated_at=now if request.activate else None,
|
||||||
|
publishing_yaml=request.publishing_yaml,
|
||||||
|
publishing_yaml_hash=_sha256(request.publishing_yaml),
|
||||||
|
transform_script=request.transform_script,
|
||||||
|
transform_script_hash=_sha256(request.transform_script),
|
||||||
|
)
|
||||||
|
if request.activate:
|
||||||
|
row = repository.script_config_versions.activate(
|
||||||
|
target_site_id=site_id,
|
||||||
|
version_id=version_id,
|
||||||
|
activated_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ScriptConfigVersionResponse(version=_script_version_summary(row))
|
||||||
|
|
||||||
|
|
||||||
|
def activate_script_config_version(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
site_id: UUID,
|
||||||
|
version_id: UUID,
|
||||||
|
) -> ScriptConfigVersionResponse:
|
||||||
|
repository.target_sites.get_by_id(site_id)
|
||||||
|
row = repository.script_config_versions.activate(
|
||||||
|
target_site_id=site_id,
|
||||||
|
version_id=version_id,
|
||||||
|
activated_at=_now(),
|
||||||
|
)
|
||||||
|
return ScriptConfigVersionResponse(version=_script_version_summary(row))
|
||||||
|
|
||||||
|
|
||||||
|
def list_script_config_versions(
|
||||||
|
repository: object,
|
||||||
|
site_id: UUID,
|
||||||
|
) -> list[ScriptConfigVersionSummary]:
|
||||||
|
repository.target_sites.get_by_id(site_id)
|
||||||
|
rows = repository.script_config_versions.list_for_site(site_id)
|
||||||
|
return [_script_version_summary(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _script_version_summary(row: dict[str, object]) -> ScriptConfigVersionSummary:
|
||||||
|
return ScriptConfigVersionSummary(
|
||||||
|
id=row["id"],
|
||||||
|
target_site_id=row["target_site_id"],
|
||||||
|
version=row["version"],
|
||||||
|
status=row["status"],
|
||||||
|
publishing_yaml_hash=row["publishing_yaml_hash"],
|
||||||
|
transform_script_hash=row["transform_script_hash"],
|
||||||
|
created_by=row["created_by"],
|
||||||
|
created_at=row["created_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _next_version(rows: list[dict[str, object]]) -> int:
|
||||||
|
versions = [int(row["version"]) for row in rows]
|
||||||
|
if not versions:
|
||||||
|
return 1
|
||||||
|
return max(versions) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def _stable_uuid(value: str) -> UUID:
|
||||||
|
return uuid5(NAMESPACE_URL, f"ai-content-pipeline:{value}")
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(value: str) -> str:
|
||||||
|
return sha256(value.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.domain.contracts.enums import Role
|
||||||
|
|
||||||
|
|
||||||
|
ADMIN_ROLES = frozenset({Role.ADMIN})
|
||||||
|
EDITOR_OR_ADMIN_ROLES = frozenset({Role.ADMIN, Role.EDITOR})
|
||||||
@@ -24,6 +24,8 @@ from .models import (
|
|||||||
ArticleSummary,
|
ArticleSummary,
|
||||||
AssetSummary,
|
AssetSummary,
|
||||||
ClaimSummary,
|
ClaimSummary,
|
||||||
|
CurrentUser,
|
||||||
|
CurrentUserResponse,
|
||||||
DraftSummary,
|
DraftSummary,
|
||||||
EvidenceSummary,
|
EvidenceSummary,
|
||||||
PlanSectionSummary,
|
PlanSectionSummary,
|
||||||
@@ -32,10 +34,17 @@ from .models import (
|
|||||||
PublishingRules,
|
PublishingRules,
|
||||||
ResearchArtifactManifestSummary,
|
ResearchArtifactManifestSummary,
|
||||||
ResearchArtifactSummary,
|
ResearchArtifactSummary,
|
||||||
|
ReviewActionResponse,
|
||||||
ReviewSummary,
|
ReviewSummary,
|
||||||
RunnerFileRef,
|
RunnerFileRef,
|
||||||
|
ScriptConfigVersionCreateRequest,
|
||||||
|
ScriptConfigVersionListResponse,
|
||||||
|
ScriptConfigVersionResponse,
|
||||||
ScriptConfigVersionSummary,
|
ScriptConfigVersionSummary,
|
||||||
TargetSiteConfig,
|
TargetSiteConfig,
|
||||||
|
TargetSiteConfigCreateRequest,
|
||||||
|
TargetSiteConfigResponse,
|
||||||
|
TargetSiteConfigUpdateRequest,
|
||||||
UserSummary,
|
UserSummary,
|
||||||
)
|
)
|
||||||
from .workflow import (
|
from .workflow import (
|
||||||
@@ -64,6 +73,8 @@ __all__ = [
|
|||||||
"ClaimRiskLevel",
|
"ClaimRiskLevel",
|
||||||
"ClaimSummary",
|
"ClaimSummary",
|
||||||
"ClaimSupportStatus",
|
"ClaimSupportStatus",
|
||||||
|
"CurrentUser",
|
||||||
|
"CurrentUserResponse",
|
||||||
"DraftSummary",
|
"DraftSummary",
|
||||||
"EvidenceSummary",
|
"EvidenceSummary",
|
||||||
"PlanReviewStatus",
|
"PlanReviewStatus",
|
||||||
@@ -74,14 +85,21 @@ __all__ = [
|
|||||||
"PublishingStatus",
|
"PublishingStatus",
|
||||||
"ResearchArtifactManifestSummary",
|
"ResearchArtifactManifestSummary",
|
||||||
"ResearchArtifactSummary",
|
"ResearchArtifactSummary",
|
||||||
|
"ReviewActionResponse",
|
||||||
"ReviewStatus",
|
"ReviewStatus",
|
||||||
"ReviewSummary",
|
"ReviewSummary",
|
||||||
"ReviewType",
|
"ReviewType",
|
||||||
"Role",
|
"Role",
|
||||||
"RunnerFileRef",
|
"RunnerFileRef",
|
||||||
|
"ScriptConfigVersionCreateRequest",
|
||||||
|
"ScriptConfigVersionListResponse",
|
||||||
|
"ScriptConfigVersionResponse",
|
||||||
"ScriptConfigVersionStatus",
|
"ScriptConfigVersionStatus",
|
||||||
"ScriptConfigVersionSummary",
|
"ScriptConfigVersionSummary",
|
||||||
"TargetSiteConfig",
|
"TargetSiteConfig",
|
||||||
|
"TargetSiteConfigCreateRequest",
|
||||||
|
"TargetSiteConfigResponse",
|
||||||
|
"TargetSiteConfigUpdateRequest",
|
||||||
"UserSummary",
|
"UserSummary",
|
||||||
"next_workflow_statuses",
|
"next_workflow_statuses",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -37,6 +37,17 @@ class UserSummary(ContractModel):
|
|||||||
role: Role
|
role: Role
|
||||||
|
|
||||||
|
|
||||||
|
class CurrentUser(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
email: str = Field(min_length=1)
|
||||||
|
display_name: str = Field(min_length=1)
|
||||||
|
role: Role
|
||||||
|
|
||||||
|
|
||||||
|
class CurrentUserResponse(ContractModel):
|
||||||
|
user: CurrentUser
|
||||||
|
|
||||||
|
|
||||||
class PublishingRules(ContractModel):
|
class PublishingRules(ContractModel):
|
||||||
repository_url: str = Field(min_length=1)
|
repository_url: str = Field(min_length=1)
|
||||||
production_branch: str = Field(min_length=1)
|
production_branch: str = Field(min_length=1)
|
||||||
@@ -65,6 +76,38 @@ class TargetSiteConfig(ContractModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class TargetSiteConfigCreateRequest(ContractModel):
|
||||||
|
name: str = Field(min_length=1)
|
||||||
|
slug: str = Field(min_length=1)
|
||||||
|
publishing_type: str = Field(default="git_next", min_length=1)
|
||||||
|
default_language: str = Field(default="en", min_length=2)
|
||||||
|
brand_voice: str = Field(min_length=1)
|
||||||
|
audience: str = Field(min_length=1)
|
||||||
|
seo_rules: JsonObject = Field(default_factory=dict)
|
||||||
|
visual_rules: JsonObject = Field(default_factory=dict)
|
||||||
|
source_rules: JsonObject = Field(default_factory=dict)
|
||||||
|
publishing_rules: PublishingRules
|
||||||
|
active_script_config_version_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TargetSiteConfigUpdateRequest(ContractModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1)
|
||||||
|
slug: str | None = Field(default=None, min_length=1)
|
||||||
|
publishing_type: str | None = Field(default=None, min_length=1)
|
||||||
|
default_language: str | None = Field(default=None, min_length=2)
|
||||||
|
brand_voice: str | None = Field(default=None, min_length=1)
|
||||||
|
audience: str | None = Field(default=None, min_length=1)
|
||||||
|
seo_rules: JsonObject | None = None
|
||||||
|
visual_rules: JsonObject | None = None
|
||||||
|
source_rules: JsonObject | None = None
|
||||||
|
publishing_rules: PublishingRules | None = None
|
||||||
|
active_script_config_version_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TargetSiteConfigResponse(ContractModel):
|
||||||
|
site: TargetSiteConfig
|
||||||
|
|
||||||
|
|
||||||
class ScriptConfigVersionSummary(ContractModel):
|
class ScriptConfigVersionSummary(ContractModel):
|
||||||
id: UUID
|
id: UUID
|
||||||
target_site_id: UUID
|
target_site_id: UUID
|
||||||
@@ -76,6 +119,23 @@ class ScriptConfigVersionSummary(ContractModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptConfigVersionCreateRequest(ContractModel):
|
||||||
|
publishing_yaml: str = Field(min_length=1)
|
||||||
|
transform_script: str = Field(min_length=1)
|
||||||
|
diff: JsonObject = Field(default_factory=dict)
|
||||||
|
rollback_target_version_id: UUID | None = None
|
||||||
|
activate: bool = False
|
||||||
|
version: int | None = Field(default=None, ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptConfigVersionResponse(ContractModel):
|
||||||
|
version: ScriptConfigVersionSummary
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptConfigVersionListResponse(ContractModel):
|
||||||
|
versions: list[ScriptConfigVersionSummary]
|
||||||
|
|
||||||
|
|
||||||
class ArticleCreateRequest(ContractModel):
|
class ArticleCreateRequest(ContractModel):
|
||||||
target_site_id: UUID
|
target_site_id: UUID
|
||||||
brief_description: str = Field(min_length=1)
|
brief_description: str = Field(min_length=1)
|
||||||
@@ -208,6 +268,10 @@ class ReviewSummary(ContractModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewActionResponse(ContractModel):
|
||||||
|
review: ReviewSummary
|
||||||
|
|
||||||
|
|
||||||
class PublishCommitSummary(ContractModel):
|
class PublishCommitSummary(ContractModel):
|
||||||
id: UUID
|
id: UUID
|
||||||
article_id: UUID
|
article_id: UUID
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ from .models import (
|
|||||||
ArticleSummary,
|
ArticleSummary,
|
||||||
AssetSummary,
|
AssetSummary,
|
||||||
ClaimSummary,
|
ClaimSummary,
|
||||||
|
CurrentUser,
|
||||||
|
CurrentUserResponse,
|
||||||
DraftSummary,
|
DraftSummary,
|
||||||
EvidenceSummary,
|
EvidenceSummary,
|
||||||
PlanSectionSummary,
|
PlanSectionSummary,
|
||||||
@@ -39,10 +41,17 @@ from .models import (
|
|||||||
PublishingRules,
|
PublishingRules,
|
||||||
ResearchArtifactManifestSummary,
|
ResearchArtifactManifestSummary,
|
||||||
ResearchArtifactSummary,
|
ResearchArtifactSummary,
|
||||||
|
ReviewActionResponse,
|
||||||
ReviewSummary,
|
ReviewSummary,
|
||||||
RunnerFileRef,
|
RunnerFileRef,
|
||||||
|
ScriptConfigVersionCreateRequest,
|
||||||
|
ScriptConfigVersionListResponse,
|
||||||
|
ScriptConfigVersionResponse,
|
||||||
ScriptConfigVersionSummary,
|
ScriptConfigVersionSummary,
|
||||||
TargetSiteConfig,
|
TargetSiteConfig,
|
||||||
|
TargetSiteConfigCreateRequest,
|
||||||
|
TargetSiteConfigResponse,
|
||||||
|
TargetSiteConfigUpdateRequest,
|
||||||
UserSummary,
|
UserSummary,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,9 +75,17 @@ CONTRACT_ENUMS: tuple[type[Enum], ...] = (
|
|||||||
|
|
||||||
CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
|
CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
|
||||||
UserSummary,
|
UserSummary,
|
||||||
|
CurrentUser,
|
||||||
|
CurrentUserResponse,
|
||||||
PublishingRules,
|
PublishingRules,
|
||||||
TargetSiteConfig,
|
TargetSiteConfig,
|
||||||
|
TargetSiteConfigCreateRequest,
|
||||||
|
TargetSiteConfigUpdateRequest,
|
||||||
|
TargetSiteConfigResponse,
|
||||||
ScriptConfigVersionSummary,
|
ScriptConfigVersionSummary,
|
||||||
|
ScriptConfigVersionCreateRequest,
|
||||||
|
ScriptConfigVersionResponse,
|
||||||
|
ScriptConfigVersionListResponse,
|
||||||
ArticleCreateRequest,
|
ArticleCreateRequest,
|
||||||
ArticleSummary,
|
ArticleSummary,
|
||||||
ArticleCreateResponse,
|
ArticleCreateResponse,
|
||||||
@@ -82,6 +99,7 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
|
|||||||
DraftSummary,
|
DraftSummary,
|
||||||
AssetSummary,
|
AssetSummary,
|
||||||
ReviewSummary,
|
ReviewSummary,
|
||||||
|
ReviewActionResponse,
|
||||||
PublishCommitSummary,
|
PublishCommitSummary,
|
||||||
RunnerFileRef,
|
RunnerFileRef,
|
||||||
AgentJobSummary,
|
AgentJobSummary,
|
||||||
|
|||||||
@@ -270,6 +270,93 @@ class TargetSitesRepository:
|
|||||||
raise LookupError(f"Target site not found: {slug}")
|
raise LookupError(f"Target site not found: {slug}")
|
||||||
return _target_site_from_row(row)
|
return _target_site_from_row(row)
|
||||||
|
|
||||||
|
def get_by_id(self, site_id: UUID) -> TargetSiteConfig:
|
||||||
|
placeholder = self._repository.placeholder()
|
||||||
|
with self._repository.connection() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
publishing_type,
|
||||||
|
default_language,
|
||||||
|
brand_voice,
|
||||||
|
audience,
|
||||||
|
seo_rules,
|
||||||
|
visual_rules,
|
||||||
|
source_rules,
|
||||||
|
publishing_rules,
|
||||||
|
active_script_config_version_id,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM target_sites
|
||||||
|
WHERE id = {placeholder}
|
||||||
|
""",
|
||||||
|
(str(site_id),),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
raise LookupError(f"Target site not found: {site_id}")
|
||||||
|
return _target_site_from_row(row)
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
site_id: UUID,
|
||||||
|
name: str,
|
||||||
|
slug: str,
|
||||||
|
publishing_type: str,
|
||||||
|
default_language: str,
|
||||||
|
brand_voice: str,
|
||||||
|
audience: str,
|
||||||
|
seo_rules: JsonObject,
|
||||||
|
visual_rules: JsonObject,
|
||||||
|
source_rules: JsonObject,
|
||||||
|
publishing_rules: PublishingRules,
|
||||||
|
active_script_config_version_id: UUID | None,
|
||||||
|
updated_at: datetime,
|
||||||
|
) -> TargetSiteConfig:
|
||||||
|
placeholder = self._repository.placeholder()
|
||||||
|
json_cast = self._repository.json_cast()
|
||||||
|
with self._repository.connection() as connection:
|
||||||
|
connection.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE target_sites
|
||||||
|
SET
|
||||||
|
name = {placeholder},
|
||||||
|
slug = {placeholder},
|
||||||
|
publishing_type = {placeholder},
|
||||||
|
default_language = {placeholder},
|
||||||
|
brand_voice = {placeholder},
|
||||||
|
audience = {placeholder},
|
||||||
|
seo_rules = {placeholder}{json_cast},
|
||||||
|
visual_rules = {placeholder}{json_cast},
|
||||||
|
source_rules = {placeholder}{json_cast},
|
||||||
|
publishing_rules = {placeholder}{json_cast},
|
||||||
|
active_script_config_version_id = {placeholder},
|
||||||
|
updated_at = {placeholder}
|
||||||
|
WHERE id = {placeholder}
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
publishing_type,
|
||||||
|
default_language,
|
||||||
|
brand_voice,
|
||||||
|
audience,
|
||||||
|
_json_value(seo_rules),
|
||||||
|
_json_value(visual_rules),
|
||||||
|
_json_value(source_rules),
|
||||||
|
_json_value(publishing_rules.model_dump(mode="json")),
|
||||||
|
_uuid_value(active_script_config_version_id),
|
||||||
|
_datetime_value(updated_at),
|
||||||
|
str(site_id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.get_by_id(site_id)
|
||||||
|
|
||||||
def list(self) -> list[TargetSiteConfig]:
|
def list(self) -> list[TargetSiteConfig]:
|
||||||
with self._repository.connection() as connection:
|
with self._repository.connection() as connection:
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
@@ -407,6 +494,88 @@ class ScriptConfigVersionsRepository:
|
|||||||
)
|
)
|
||||||
return _plain_row(row)
|
return _plain_row(row)
|
||||||
|
|
||||||
|
def get_by_id(self, version_id: UUID) -> dict[str, Any]:
|
||||||
|
placeholder = self._repository.placeholder()
|
||||||
|
with self._repository.connection() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT *
|
||||||
|
FROM script_config_versions
|
||||||
|
WHERE id = {placeholder}
|
||||||
|
""",
|
||||||
|
(str(version_id),),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
raise LookupError(f"Script config version not found: {version_id}")
|
||||||
|
return _plain_row(row)
|
||||||
|
|
||||||
|
def activate(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
target_site_id: UUID,
|
||||||
|
version_id: UUID,
|
||||||
|
activated_at: datetime,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
placeholder = self._repository.placeholder()
|
||||||
|
with self._repository.connection() as connection:
|
||||||
|
existing = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id
|
||||||
|
FROM script_config_versions
|
||||||
|
WHERE id = {placeholder} AND target_site_id = {placeholder}
|
||||||
|
""",
|
||||||
|
(str(version_id), str(target_site_id)),
|
||||||
|
).fetchone()
|
||||||
|
if existing is None:
|
||||||
|
raise LookupError(
|
||||||
|
f"Script config version not found: {target_site_id} {version_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE script_config_versions
|
||||||
|
SET
|
||||||
|
status = {placeholder},
|
||||||
|
updated_at = {placeholder}
|
||||||
|
WHERE target_site_id = {placeholder} AND id <> {placeholder}
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
ScriptConfigVersionStatus.DEPRECATED.value,
|
||||||
|
_datetime_value(activated_at),
|
||||||
|
str(target_site_id),
|
||||||
|
str(version_id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE script_config_versions
|
||||||
|
SET
|
||||||
|
status = {placeholder},
|
||||||
|
activated_at = {placeholder},
|
||||||
|
updated_at = {placeholder}
|
||||||
|
WHERE id = {placeholder}
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
ScriptConfigVersionStatus.ACTIVE.value,
|
||||||
|
_datetime_value(activated_at),
|
||||||
|
_datetime_value(activated_at),
|
||||||
|
str(version_id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE target_sites
|
||||||
|
SET
|
||||||
|
active_script_config_version_id = {placeholder},
|
||||||
|
updated_at = {placeholder}
|
||||||
|
WHERE id = {placeholder}
|
||||||
|
""",
|
||||||
|
(str(version_id), _datetime_value(activated_at), str(target_site_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.get_by_id(version_id)
|
||||||
|
|
||||||
def list_for_site(self, target_site_id: UUID) -> list[dict[str, Any]]:
|
def list_for_site(self, target_site_id: UUID) -> list[dict[str, Any]]:
|
||||||
placeholder = self._repository.placeholder()
|
placeholder = self._repository.placeholder()
|
||||||
with self._repository.connection() as connection:
|
with self._repository.connection() as connection:
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from collections.abc import Callable, Iterable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import Depends, Header, HTTPException, status
|
||||||
|
|
||||||
|
from src.application.auth import get_current_user_by_email
|
||||||
|
from src.application.seed_data import seed_reference_data
|
||||||
|
from src.domain.contracts import CurrentUser, Role
|
||||||
|
from src.infrastructure.repositories import BackendRepository, open_backend_repository
|
||||||
|
|
||||||
|
|
||||||
|
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||||
|
UNAUTHORIZED_DETAIL = "Unauthorized"
|
||||||
|
FORBIDDEN_DETAIL = "Forbidden"
|
||||||
|
|
||||||
|
_runtime_repository: BackendRepository | None = None
|
||||||
|
_runtime_repository_dsn: str | None = None
|
||||||
|
_runtime_temp_dir: tempfile.TemporaryDirectory[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_repository() -> BackendRepository:
|
||||||
|
global _runtime_repository
|
||||||
|
global _runtime_repository_dsn
|
||||||
|
global _runtime_temp_dir
|
||||||
|
|
||||||
|
dsn = (
|
||||||
|
os.environ.get("DATABASE_URL")
|
||||||
|
or os.environ.get("POSTGRES_DSN")
|
||||||
|
or os.environ.get("PIPELINE_TEST_DATABASE_DSN")
|
||||||
|
)
|
||||||
|
if dsn is None:
|
||||||
|
if _runtime_temp_dir is None:
|
||||||
|
_runtime_temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
sqlite_path = Path(_runtime_temp_dir.name) / "pipeline-demo-auth.db"
|
||||||
|
dsn = f"sqlite:///{sqlite_path}"
|
||||||
|
|
||||||
|
if _runtime_repository is not None and _runtime_repository_dsn == dsn:
|
||||||
|
return _runtime_repository
|
||||||
|
|
||||||
|
repository = open_backend_repository(dsn)
|
||||||
|
repository.setup()
|
||||||
|
seed_reference_data(repository)
|
||||||
|
_runtime_repository = repository
|
||||||
|
_runtime_repository_dsn = dsn
|
||||||
|
return repository
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
demo_user_email: str | None = Header(default=None, alias=DEMO_USER_EMAIL_HEADER),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> CurrentUser:
|
||||||
|
if not demo_user_email:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=UNAUTHORIZED_DETAIL,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return get_current_user_by_email(repository, demo_user_email)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=UNAUTHORIZED_DETAIL,
|
||||||
|
) from error
|
||||||
|
|
||||||
|
|
||||||
|
def require_roles(
|
||||||
|
allowed_roles: Iterable[Role],
|
||||||
|
) -> Callable[[CurrentUser], CurrentUser]:
|
||||||
|
allowed = frozenset(allowed_roles)
|
||||||
|
|
||||||
|
def dependency(
|
||||||
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> CurrentUser:
|
||||||
|
if current_user.role not in allowed:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=FORBIDDEN_DETAIL,
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
return dependency
|
||||||
@@ -6,10 +6,14 @@ from fastapi.responses import JSONResponse
|
|||||||
|
|
||||||
from src.domain.contracts.openapi import inject_contract_schemas
|
from src.domain.contracts.openapi import inject_contract_schemas
|
||||||
from src.presentation.routes.articles import router as articles_router
|
from src.presentation.routes.articles import router as articles_router
|
||||||
|
from src.presentation.routes.auth import router as auth_router
|
||||||
|
from src.presentation.routes.sites import router as sites_router
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="AI Content Pipeline Backend")
|
app = FastAPI(title="AI Content Pipeline Backend")
|
||||||
|
app.include_router(auth_router)
|
||||||
app.include_router(articles_router)
|
app.include_router(articles_router)
|
||||||
|
app.include_router(sites_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, status
|
from uuid import UUID
|
||||||
|
|
||||||
from src.application.articles import build_placeholder_article_response
|
from fastapi import APIRouter, Depends, status
|
||||||
from src.domain.contracts import ArticleCreateRequest, ArticleCreateResponse
|
|
||||||
|
from src.application.articles import (
|
||||||
|
build_placeholder_article_response,
|
||||||
|
build_placeholder_plan_approval_response,
|
||||||
|
)
|
||||||
|
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
|
||||||
|
from src.domain.contracts import (
|
||||||
|
ArticleCreateRequest,
|
||||||
|
ArticleCreateResponse,
|
||||||
|
CurrentUser,
|
||||||
|
ReviewActionResponse,
|
||||||
|
)
|
||||||
|
from src.presentation.dependencies import require_roles
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["articles"])
|
router = APIRouter(prefix="/api", tags=["articles"])
|
||||||
@@ -14,5 +26,24 @@ router = APIRouter(prefix="/api", tags=["articles"])
|
|||||||
response_model=ArticleCreateResponse,
|
response_model=ArticleCreateResponse,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
)
|
)
|
||||||
def create_article(request: ArticleCreateRequest) -> ArticleCreateResponse:
|
def create_article(
|
||||||
|
request: ArticleCreateRequest,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
) -> ArticleCreateResponse:
|
||||||
return build_placeholder_article_response(request)
|
return build_placeholder_article_response(request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/plans/{plan_id}/approve",
|
||||||
|
response_model=ReviewActionResponse,
|
||||||
|
)
|
||||||
|
def approve_plan(
|
||||||
|
article_id: UUID,
|
||||||
|
plan_id: UUID,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
) -> ReviewActionResponse:
|
||||||
|
return build_placeholder_plan_approval_response(
|
||||||
|
article_id=article_id,
|
||||||
|
plan_id=plan_id,
|
||||||
|
reviewer_id=current_user.id,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from src.domain.contracts import CurrentUser, CurrentUserResponse
|
||||||
|
from src.presentation.dependencies import get_current_user
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=CurrentUserResponse)
|
||||||
|
def get_me(
|
||||||
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> CurrentUserResponse:
|
||||||
|
return CurrentUserResponse(user=current_user)
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from src.application.site_config import (
|
||||||
|
activate_script_config_version,
|
||||||
|
create_script_config_version,
|
||||||
|
create_target_site,
|
||||||
|
list_script_config_versions,
|
||||||
|
update_target_site,
|
||||||
|
)
|
||||||
|
from src.domain.auth import ADMIN_ROLES, EDITOR_OR_ADMIN_ROLES
|
||||||
|
from src.domain.contracts import (
|
||||||
|
CurrentUser,
|
||||||
|
ScriptConfigVersionCreateRequest,
|
||||||
|
ScriptConfigVersionListResponse,
|
||||||
|
ScriptConfigVersionResponse,
|
||||||
|
TargetSiteConfigCreateRequest,
|
||||||
|
TargetSiteConfigResponse,
|
||||||
|
TargetSiteConfigUpdateRequest,
|
||||||
|
)
|
||||||
|
from src.infrastructure.repositories import BackendRepository
|
||||||
|
from src.presentation.dependencies import get_repository, require_roles
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["sites"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sites", response_model=list[TargetSiteConfigResponse])
|
||||||
|
def list_sites(
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> list[TargetSiteConfigResponse]:
|
||||||
|
return [TargetSiteConfigResponse(site=site) for site in repository.target_sites.list()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/sites",
|
||||||
|
response_model=TargetSiteConfigResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def post_site(
|
||||||
|
request: TargetSiteConfigCreateRequest,
|
||||||
|
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> TargetSiteConfigResponse:
|
||||||
|
return create_target_site(repository, request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/sites/{site_id}", response_model=TargetSiteConfigResponse)
|
||||||
|
def patch_site(
|
||||||
|
site_id: UUID,
|
||||||
|
request: TargetSiteConfigUpdateRequest,
|
||||||
|
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> TargetSiteConfigResponse:
|
||||||
|
try:
|
||||||
|
return update_target_site(repository, site_id, request)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/sites/{site_id}/publishing-config/versions",
|
||||||
|
response_model=ScriptConfigVersionListResponse,
|
||||||
|
)
|
||||||
|
def get_script_config_versions(
|
||||||
|
site_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ScriptConfigVersionListResponse:
|
||||||
|
try:
|
||||||
|
versions = list_script_config_versions(repository, site_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
return ScriptConfigVersionListResponse(versions=versions)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/sites/{site_id}/publishing-config/versions",
|
||||||
|
response_model=ScriptConfigVersionResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def post_script_config_version(
|
||||||
|
site_id: UUID,
|
||||||
|
request: ScriptConfigVersionCreateRequest,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ScriptConfigVersionResponse:
|
||||||
|
try:
|
||||||
|
return create_script_config_version(
|
||||||
|
repository,
|
||||||
|
site_id=site_id,
|
||||||
|
current_user=current_user,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/sites/{site_id}/publishing-config/versions/{version_id}/activate",
|
||||||
|
response_model=ScriptConfigVersionResponse,
|
||||||
|
)
|
||||||
|
def post_activate_script_config_version(
|
||||||
|
site_id: UUID,
|
||||||
|
version_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ScriptConfigVersionResponse:
|
||||||
|
try:
|
||||||
|
return activate_script_config_version(
|
||||||
|
repository,
|
||||||
|
site_id=site_id,
|
||||||
|
version_id=version_id,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
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 AuthAuthorizationPublicApiTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def test_me_returns_selected_demo_user_and_role(self) -> None:
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/me",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
self.assertEqual(DEMO_ADMIN_EMAIL, response.json()["user"]["email"])
|
||||||
|
self.assertEqual("ADMIN", response.json()["user"]["role"])
|
||||||
|
|
||||||
|
def test_missing_and_unknown_demo_users_are_rejected_consistently(self) -> None:
|
||||||
|
missing_response = self.client.get("/api/me")
|
||||||
|
unknown_response = self.client.get(
|
||||||
|
"/api/me",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: "missing@example.com"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(401, missing_response.status_code, missing_response.text)
|
||||||
|
self.assertEqual(401, unknown_response.status_code, unknown_response.text)
|
||||||
|
self.assertEqual({"detail": "Unauthorized"}, missing_response.json())
|
||||||
|
self.assertEqual({"detail": "Unauthorized"}, unknown_response.json())
|
||||||
|
|
||||||
|
def test_editor_cannot_create_target_site_config(self) -> None:
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json=self._site_payload("editorial_ops_blog"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(403, response.status_code, response.text)
|
||||||
|
self.assertEqual({"detail": "Forbidden"}, response.json())
|
||||||
|
|
||||||
|
def test_admin_can_create_and_update_target_site_config(self) -> None:
|
||||||
|
create_response = self.client.post(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
json=self._site_payload("admin_ops_blog"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(201, create_response.status_code, create_response.text)
|
||||||
|
site = create_response.json()["site"]
|
||||||
|
self.assertEqual("admin_ops_blog", site["slug"])
|
||||||
|
|
||||||
|
update_response = self.client.patch(
|
||||||
|
f"/api/sites/{site['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
json={"brand_voice": "Updated practical editorial operations guidance."},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(200, update_response.status_code, update_response.text)
|
||||||
|
self.assertEqual(
|
||||||
|
"Updated practical editorial operations guidance.",
|
||||||
|
update_response.json()["site"]["brand_voice"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_editor_cannot_update_site_or_mutate_script_versions(self) -> None:
|
||||||
|
create_response = self.client.post(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
json=self._site_payload("script_editor_denied_blog"),
|
||||||
|
)
|
||||||
|
self.assertEqual(201, create_response.status_code, create_response.text)
|
||||||
|
site_id = create_response.json()["site"]["id"]
|
||||||
|
|
||||||
|
version_response = self.client.post(
|
||||||
|
f"/api/sites/{site_id}/publishing-config/versions",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
json=self._script_version_payload(),
|
||||||
|
)
|
||||||
|
self.assertEqual(201, version_response.status_code, version_response.text)
|
||||||
|
version_id = version_response.json()["version"]["id"]
|
||||||
|
|
||||||
|
site_update_response = self.client.patch(
|
||||||
|
f"/api/sites/{site_id}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"brand_voice": "Editor should not mutate Admin config."},
|
||||||
|
)
|
||||||
|
version_create_response = self.client.post(
|
||||||
|
f"/api/sites/{site_id}/publishing-config/versions",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json=self._script_version_payload(),
|
||||||
|
)
|
||||||
|
version_activate_response = self.client.post(
|
||||||
|
f"/api/sites/{site_id}/publishing-config/versions/{version_id}/activate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(403, site_update_response.status_code, site_update_response.text)
|
||||||
|
self.assertEqual(
|
||||||
|
403,
|
||||||
|
version_create_response.status_code,
|
||||||
|
version_create_response.text,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
403,
|
||||||
|
version_activate_response.status_code,
|
||||||
|
version_activate_response.text,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_admin_can_create_and_activate_script_config_version(self) -> None:
|
||||||
|
site_response = self.client.post(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
json=self._site_payload("script_admin_blog"),
|
||||||
|
)
|
||||||
|
self.assertEqual(201, site_response.status_code, site_response.text)
|
||||||
|
site_id = site_response.json()["site"]["id"]
|
||||||
|
|
||||||
|
create_response = self.client.post(
|
||||||
|
f"/api/sites/{site_id}/publishing-config/versions",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
json=self._script_version_payload(),
|
||||||
|
)
|
||||||
|
self.assertEqual(201, create_response.status_code, create_response.text)
|
||||||
|
self.assertEqual("DRAFT", create_response.json()["version"]["status"])
|
||||||
|
|
||||||
|
version_id = create_response.json()["version"]["id"]
|
||||||
|
activate_response = self.client.post(
|
||||||
|
f"/api/sites/{site_id}/publishing-config/versions/{version_id}/activate",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(200, activate_response.status_code, activate_response.text)
|
||||||
|
self.assertEqual("ACTIVE", activate_response.json()["version"]["status"])
|
||||||
|
|
||||||
|
def test_editor_can_create_articles_and_approve_plan_review(self) -> None:
|
||||||
|
sites_response = self.client.get(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, sites_response.status_code, sites_response.text)
|
||||||
|
target_site_id = sites_response.json()[0]["site"]["id"]
|
||||||
|
|
||||||
|
article_response = self.client.post(
|
||||||
|
"/api/articles",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"target_site_id": target_site_id,
|
||||||
|
"brief_description": "Write about editorial AI workflow controls.",
|
||||||
|
"working_title": "Editorial AI Workflow Controls",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(201, article_response.status_code, article_response.text)
|
||||||
|
self.assertEqual(
|
||||||
|
"ARTICLE_BRIEF_CREATED",
|
||||||
|
article_response.json()["article"]["status"],
|
||||||
|
)
|
||||||
|
|
||||||
|
article_id = article_response.json()["article"]["id"]
|
||||||
|
review_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/plans/00000000-0000-0000-0000-000000000123/approve",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(200, review_response.status_code, review_response.text)
|
||||||
|
self.assertEqual("PLAN", review_response.json()["review"]["review_type"])
|
||||||
|
self.assertEqual("APPROVED", review_response.json()["review"]["status"])
|
||||||
|
|
||||||
|
def test_unauthorized_requests_are_rejected_for_protected_mutations(self) -> None:
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/articles",
|
||||||
|
json={
|
||||||
|
"target_site_id": "00000000-0000-0000-0000-000000000001",
|
||||||
|
"brief_description": "No user selected.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(401, response.status_code, response.text)
|
||||||
|
self.assertEqual({"detail": "Unauthorized"}, response.json())
|
||||||
|
|
||||||
|
def _site_payload(self, slug: str) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"name": "Editorial Ops Blog",
|
||||||
|
"slug": slug,
|
||||||
|
"publishing_type": "git_next",
|
||||||
|
"default_language": "en",
|
||||||
|
"brand_voice": "Practical editorial operations guidance.",
|
||||||
|
"audience": "Editorial teams running AI-assisted workflows.",
|
||||||
|
"seo_rules": {"primary_keyword_required": True},
|
||||||
|
"visual_rules": {"hero_style": "product editorial"},
|
||||||
|
"source_rules": {"minimum_sources": 3},
|
||||||
|
"publishing_rules": {
|
||||||
|
"repository_url": "git@github.com:example/editorial-ops.git",
|
||||||
|
"production_branch": "main",
|
||||||
|
"content_format": "mdx",
|
||||||
|
"content_path_template": "content/articles/{slug}.mdx",
|
||||||
|
"asset_path_template": "public/articles/{slug}/{filename}",
|
||||||
|
"frontmatter_mapping": {"title": "title"},
|
||||||
|
"dry_run_renderer": "next-mdx",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _script_version_payload(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"publishing_yaml": "target: editorial_ops\nrepository:\n branch: main\n",
|
||||||
|
"transform_script": "export function transformArticle(article) { return article; }\n",
|
||||||
|
"diff": {"summary": "Task 004 authorization fixture."},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
|
"test:roles": "node tests/role-navigation.test.mjs",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -19,3 +19,70 @@ main {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 48px;
|
padding: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pageHeader {
|
||||||
|
display: flex;
|
||||||
|
max-width: 920px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageHeader h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageHeader p {
|
||||||
|
margin: 0;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 32px;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid #b7c0cc;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #344054;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleNavigation {
|
||||||
|
display: grid;
|
||||||
|
max-width: 920px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleNavigation a {
|
||||||
|
display: flex;
|
||||||
|
min-height: 52px;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid #d0d5dd;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 16px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1d2733;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
main {
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageHeader {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { ArticleListResponse } from "@pipeline/shared";
|
import type { ArticleListResponse } from "@pipeline/shared";
|
||||||
|
|
||||||
|
import { RoleNavigation } from "@/widgets/role-navigation";
|
||||||
|
|
||||||
const initialArticles: ArticleListResponse = {
|
const initialArticles: ArticleListResponse = {
|
||||||
articles: [],
|
articles: [],
|
||||||
};
|
};
|
||||||
@@ -7,11 +9,14 @@ const initialArticles: ArticleListResponse = {
|
|||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
|
<header className="pageHeader">
|
||||||
|
<div>
|
||||||
<h1>AI Content Pipeline</h1>
|
<h1>AI Content Pipeline</h1>
|
||||||
<p>Dockerized monorepo foundation is running.</p>
|
<p>{initialArticles.articles.length} active article workflows</p>
|
||||||
<p>
|
</div>
|
||||||
{initialArticles.articles.length} articles in the contract-backed dashboard.
|
<span className="roleBadge">Editor demo</span>
|
||||||
</p>
|
</header>
|
||||||
|
<RoleNavigation role="EDITOR" />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { getRoleNavigationItems, ROLE_NAVIGATION_ITEMS } from "./model";
|
||||||
|
export type { NavigationItem } from "./model";
|
||||||
|
export { RoleNavigation } from "./ui";
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { Role } from "@pipeline/shared";
|
||||||
|
|
||||||
|
export type NavigationItem = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
roles: readonly Role[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ROLE_NAVIGATION_ITEMS = [
|
||||||
|
{
|
||||||
|
id: "dashboard",
|
||||||
|
label: "Dashboard",
|
||||||
|
href: "/",
|
||||||
|
roles: ["ADMIN", "EDITOR"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "new-article",
|
||||||
|
label: "New article",
|
||||||
|
href: "/articles/new",
|
||||||
|
roles: ["ADMIN", "EDITOR"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "review",
|
||||||
|
label: "Review",
|
||||||
|
href: "/review",
|
||||||
|
roles: ["ADMIN", "EDITOR"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "site-config",
|
||||||
|
label: "Site configuration",
|
||||||
|
href: "/admin/sites",
|
||||||
|
roles: ["ADMIN"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "script-versions",
|
||||||
|
label: "Script versions",
|
||||||
|
href: "/admin/scripts",
|
||||||
|
roles: ["ADMIN"],
|
||||||
|
},
|
||||||
|
] as const satisfies readonly NavigationItem[];
|
||||||
|
|
||||||
|
export function getRoleNavigationItems(role: Role): NavigationItem[] {
|
||||||
|
return ROLE_NAVIGATION_ITEMS.filter((item) =>
|
||||||
|
item.roles.some((allowedRole) => allowedRole === role),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Role } from "@pipeline/shared";
|
||||||
|
|
||||||
|
import { getRoleNavigationItems } from "./model";
|
||||||
|
|
||||||
|
type RoleNavigationProps = {
|
||||||
|
role: Role;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RoleNavigation({ role }: RoleNavigationProps) {
|
||||||
|
const items = getRoleNavigationItems(role);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="Primary navigation">
|
||||||
|
<ul className="roleNavigation">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<a href={item.href}>{item.label}</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import ts from "typescript";
|
||||||
|
|
||||||
|
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const sourcePath = resolve(testDir, "../src/widgets/role-navigation/model.ts");
|
||||||
|
const source = readFileSync(sourcePath, "utf8");
|
||||||
|
const transpiled = ts.transpileModule(source, {
|
||||||
|
compilerOptions: {
|
||||||
|
module: ts.ModuleKind.CommonJS,
|
||||||
|
target: ts.ScriptTarget.ES2022,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const exports = {};
|
||||||
|
new Function("exports", transpiled.outputText)(exports);
|
||||||
|
|
||||||
|
const editorItems = exports.getRoleNavigationItems("EDITOR");
|
||||||
|
const adminItems = exports.getRoleNavigationItems("ADMIN");
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
editorItems.map((item) => item.id),
|
||||||
|
["dashboard", "new-article", "review"],
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
editorItems.some((item) => item.id === "site-config" || item.id === "script-versions"),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
adminItems.some((item) => item.id === "site-config"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
adminItems.some((item) => item.id === "script-versions"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("role navigation hides Admin-only items for Editors");
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
"dev:stack": "docker compose up --build",
|
"dev:stack": "docker compose up --build",
|
||||||
"generate:contracts": "python3 scripts/generate_openapi_contracts.py",
|
"generate:contracts": "python3 scripts/generate_openapi_contracts.py",
|
||||||
"smoke": "bash tests/smoke/public-health.sh",
|
"smoke": "bash tests/smoke/public-health.sh",
|
||||||
|
"test:frontend:roles": "pnpm --filter @pipeline/frontend test:roles",
|
||||||
"typecheck": "pnpm --filter @pipeline/shared typecheck && pnpm --filter @pipeline/frontend typecheck"
|
"typecheck": "pnpm --filter @pipeline/shared typecheck && pnpm --filter @pipeline/frontend typecheck"
|
||||||
},
|
},
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
@@ -703,6 +703,50 @@
|
|||||||
"title": "ClaimSupportStatus",
|
"title": "ClaimSupportStatus",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"CurrentUser": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"display_name": {
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Display Name",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Email",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Id",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"$ref": "#/components/schemas/Role"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"email",
|
||||||
|
"display_name",
|
||||||
|
"role"
|
||||||
|
],
|
||||||
|
"title": "CurrentUser",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"CurrentUserResponse": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"user": {
|
||||||
|
"$ref": "#/components/schemas/CurrentUser"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"user"
|
||||||
|
],
|
||||||
|
"title": "CurrentUserResponse",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"DraftSummary": {
|
"DraftSummary": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1285,6 +1329,19 @@
|
|||||||
"title": "ResearchArtifactSummary",
|
"title": "ResearchArtifactSummary",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"ReviewActionResponse": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"review": {
|
||||||
|
"$ref": "#/components/schemas/ReviewSummary"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"review"
|
||||||
|
],
|
||||||
|
"title": "ReviewActionResponse",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"ReviewStatus": {
|
"ReviewStatus": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"PENDING",
|
"PENDING",
|
||||||
@@ -1400,6 +1457,93 @@
|
|||||||
"title": "RunnerFileRef",
|
"title": "RunnerFileRef",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"ScriptConfigVersionCreateRequest": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"activate": {
|
||||||
|
"default": false,
|
||||||
|
"title": "Activate",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"diff": {
|
||||||
|
"additionalProperties": true,
|
||||||
|
"title": "Diff",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"publishing_yaml": {
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Publishing Yaml",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"rollback_target_version_id": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Rollback Target Version Id"
|
||||||
|
},
|
||||||
|
"transform_script": {
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Transform Script",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Version"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"publishing_yaml",
|
||||||
|
"transform_script"
|
||||||
|
],
|
||||||
|
"title": "ScriptConfigVersionCreateRequest",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"ScriptConfigVersionListResponse": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"versions": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ScriptConfigVersionSummary"
|
||||||
|
},
|
||||||
|
"title": "Versions",
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"versions"
|
||||||
|
],
|
||||||
|
"title": "ScriptConfigVersionListResponse",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"ScriptConfigVersionResponse": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"$ref": "#/components/schemas/ScriptConfigVersionSummary"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"version"
|
||||||
|
],
|
||||||
|
"title": "ScriptConfigVersionResponse",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"ScriptConfigVersionStatus": {
|
"ScriptConfigVersionStatus": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"DRAFT",
|
"DRAFT",
|
||||||
@@ -1559,6 +1703,244 @@
|
|||||||
"title": "TargetSiteConfig",
|
"title": "TargetSiteConfig",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"TargetSiteConfigCreateRequest": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"active_script_config_version_id": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Active Script Config Version Id"
|
||||||
|
},
|
||||||
|
"audience": {
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Audience",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"brand_voice": {
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Brand Voice",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"default_language": {
|
||||||
|
"default": "en",
|
||||||
|
"minLength": 2,
|
||||||
|
"title": "Default Language",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Name",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"publishing_rules": {
|
||||||
|
"$ref": "#/components/schemas/PublishingRules"
|
||||||
|
},
|
||||||
|
"publishing_type": {
|
||||||
|
"default": "git_next",
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Publishing Type",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"seo_rules": {
|
||||||
|
"additionalProperties": true,
|
||||||
|
"title": "Seo Rules",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"slug": {
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Slug",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"source_rules": {
|
||||||
|
"additionalProperties": true,
|
||||||
|
"title": "Source Rules",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"visual_rules": {
|
||||||
|
"additionalProperties": true,
|
||||||
|
"title": "Visual Rules",
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"slug",
|
||||||
|
"brand_voice",
|
||||||
|
"audience",
|
||||||
|
"publishing_rules"
|
||||||
|
],
|
||||||
|
"title": "TargetSiteConfigCreateRequest",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"TargetSiteConfigResponse": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"site": {
|
||||||
|
"$ref": "#/components/schemas/TargetSiteConfig"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"site"
|
||||||
|
],
|
||||||
|
"title": "TargetSiteConfigResponse",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"TargetSiteConfigUpdateRequest": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"active_script_config_version_id": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Active Script Config Version Id"
|
||||||
|
},
|
||||||
|
"audience": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Audience"
|
||||||
|
},
|
||||||
|
"brand_voice": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Brand Voice"
|
||||||
|
},
|
||||||
|
"default_language": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"minLength": 2,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Default Language"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Name"
|
||||||
|
},
|
||||||
|
"publishing_rules": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/PublishingRules"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
"publishing_type": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Publishing Type"
|
||||||
|
},
|
||||||
|
"seo_rules": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"additionalProperties": true,
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Seo Rules"
|
||||||
|
},
|
||||||
|
"slug": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Slug"
|
||||||
|
},
|
||||||
|
"source_rules": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"additionalProperties": true,
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Source Rules"
|
||||||
|
},
|
||||||
|
"visual_rules": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"additionalProperties": true,
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Visual Rules"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "TargetSiteConfigUpdateRequest",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"UserSummary": {
|
"UserSummary": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1628,6 +2010,24 @@
|
|||||||
"/api/articles": {
|
"/api/articles": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "create_article_api_articles_post",
|
"operationId": "create_article_api_articles_post",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "header",
|
||||||
|
"name": "X-Demo-User-Email",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "X-Demo-User-Email"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
@@ -1666,6 +2066,498 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/articles/{article_id}/plans/{plan_id}/approve": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "approve_plan_api_articles__article_id__plans__plan_id__approve_post",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "article_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Article Id",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "plan_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Plan 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/ReviewActionResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful Response"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Validation Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "Approve Plan",
|
||||||
|
"tags": [
|
||||||
|
"articles"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/me": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "get_me_api_me_get",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"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/CurrentUserResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful Response"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Validation Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "Get Me",
|
||||||
|
"tags": [
|
||||||
|
"auth"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/sites": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "list_sites_api_sites_get",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"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": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/TargetSiteConfigResponse"
|
||||||
|
},
|
||||||
|
"title": "Response List Sites Api Sites Get",
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful Response"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Validation Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "List Sites",
|
||||||
|
"tags": [
|
||||||
|
"sites"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"operationId": "post_site_api_sites_post",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"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/TargetSiteConfigCreateRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/TargetSiteConfigResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful Response"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Validation Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "Post Site",
|
||||||
|
"tags": [
|
||||||
|
"sites"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/sites/{site_id}": {
|
||||||
|
"patch": {
|
||||||
|
"operationId": "patch_site_api_sites__site_id__patch",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "site_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Site 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/TargetSiteConfigUpdateRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/TargetSiteConfigResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful Response"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Validation Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "Patch Site",
|
||||||
|
"tags": [
|
||||||
|
"sites"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/sites/{site_id}/publishing-config/versions": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "get_script_config_versions_api_sites__site_id__publishing_config_versions_get",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "site_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Site 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/ScriptConfigVersionListResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful Response"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Validation Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "Get Script Config Versions",
|
||||||
|
"tags": [
|
||||||
|
"sites"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"operationId": "post_script_config_version_api_sites__site_id__publishing_config_versions_post",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "site_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Site 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/ScriptConfigVersionCreateRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ScriptConfigVersionResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful Response"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Validation Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "Post Script Config Version",
|
||||||
|
"tags": [
|
||||||
|
"sites"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/sites/{site_id}/publishing-config/versions/{version_id}/activate": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "post_activate_script_config_version_api_sites__site_id__publishing_config_versions__version_id__activate_post",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "site_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Site Id",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "version_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Version 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/ScriptConfigVersionResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful Response"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Validation Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "Post Activate Script Config Version",
|
||||||
|
"tags": [
|
||||||
|
"sites"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/health": {
|
"/health": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "health_health_get",
|
"operationId": "health_health_get",
|
||||||
|
|||||||
@@ -110,6 +110,17 @@ export type ClaimSummary = {
|
|||||||
|
|
||||||
export type ClaimSupportStatus = "SUPPORTED" | "UNSUPPORTED" | "NEEDS_REVIEW";
|
export type ClaimSupportStatus = "SUPPORTED" | "UNSUPPORTED" | "NEEDS_REVIEW";
|
||||||
|
|
||||||
|
export type CurrentUser = {
|
||||||
|
display_name: string;
|
||||||
|
email: string;
|
||||||
|
id: string;
|
||||||
|
role: Role;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CurrentUserResponse = {
|
||||||
|
user: CurrentUser;
|
||||||
|
};
|
||||||
|
|
||||||
export type DraftSummary = {
|
export type DraftSummary = {
|
||||||
article_id: string;
|
article_id: string;
|
||||||
body_object_key?: string | null;
|
body_object_key?: string | null;
|
||||||
@@ -209,6 +220,10 @@ export type ResearchArtifactSummary = {
|
|||||||
source_url: string;
|
source_url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ReviewActionResponse = {
|
||||||
|
review: ReviewSummary;
|
||||||
|
};
|
||||||
|
|
||||||
export type ReviewStatus = "PENDING" | "APPROVED" | "CHANGES_REQUESTED";
|
export type ReviewStatus = "PENDING" | "APPROVED" | "CHANGES_REQUESTED";
|
||||||
|
|
||||||
export type ReviewSummary = {
|
export type ReviewSummary = {
|
||||||
@@ -230,6 +245,23 @@ export type RunnerFileRef = {
|
|||||||
path: string;
|
path: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ScriptConfigVersionCreateRequest = {
|
||||||
|
activate?: boolean;
|
||||||
|
diff?: Record<string, unknown>;
|
||||||
|
publishing_yaml: string;
|
||||||
|
rollback_target_version_id?: string | null;
|
||||||
|
transform_script: string;
|
||||||
|
version?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScriptConfigVersionListResponse = {
|
||||||
|
versions: ScriptConfigVersionSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScriptConfigVersionResponse = {
|
||||||
|
version: ScriptConfigVersionSummary;
|
||||||
|
};
|
||||||
|
|
||||||
export type ScriptConfigVersionStatus = "DRAFT" | "ACTIVE" | "DEPRECATED";
|
export type ScriptConfigVersionStatus = "DRAFT" | "ACTIVE" | "DEPRECATED";
|
||||||
|
|
||||||
export type ScriptConfigVersionSummary = {
|
export type ScriptConfigVersionSummary = {
|
||||||
@@ -260,6 +292,38 @@ export type TargetSiteConfig = {
|
|||||||
visual_rules?: Record<string, unknown>;
|
visual_rules?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TargetSiteConfigCreateRequest = {
|
||||||
|
active_script_config_version_id?: string | null;
|
||||||
|
audience: string;
|
||||||
|
brand_voice: string;
|
||||||
|
default_language?: string;
|
||||||
|
name: string;
|
||||||
|
publishing_rules: PublishingRules;
|
||||||
|
publishing_type?: string;
|
||||||
|
seo_rules?: Record<string, unknown>;
|
||||||
|
slug: string;
|
||||||
|
source_rules?: Record<string, unknown>;
|
||||||
|
visual_rules?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TargetSiteConfigResponse = {
|
||||||
|
site: TargetSiteConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TargetSiteConfigUpdateRequest = {
|
||||||
|
active_script_config_version_id?: string | null;
|
||||||
|
audience?: string | null;
|
||||||
|
brand_voice?: string | null;
|
||||||
|
default_language?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
publishing_rules?: PublishingRules | null;
|
||||||
|
publishing_type?: string | null;
|
||||||
|
seo_rules?: Record<string, unknown> | null;
|
||||||
|
slug?: string | null;
|
||||||
|
source_rules?: Record<string, unknown> | null;
|
||||||
|
visual_rules?: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type UserSummary = {
|
export type UserSummary = {
|
||||||
display_name: string;
|
display_name: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ Development description: Implement simple internal authentication and role-based
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] TDD pre-requirement: before implementation, write one failing public API authorization test for an Editor attempting an Admin-only action; proceed one permission behavior at a time and record red-green evidence in `Result`.
|
- [x] TDD pre-requirement: before implementation, write one failing public API authorization test for an Editor attempting an Admin-only action; proceed one permission behavior at a time and record red-green evidence in `Result`.
|
||||||
- [ ] `GET /api/me` returns the active user and role.
|
- [x] `GET /api/me` returns the active user and role.
|
||||||
- [ ] Admin can create/update site config and script versions.
|
- [x] Admin can create/update site config and script versions.
|
||||||
- [ ] Editor cannot create/update site config or script versions.
|
- [x] Editor cannot create/update site config or script versions.
|
||||||
- [ ] Editor can create articles and perform review actions.
|
- [x] Editor can create articles and perform review actions.
|
||||||
- [ ] Unauthorized requests are rejected consistently.
|
- [x] Unauthorized requests are rejected consistently.
|
||||||
- [ ] Frontend hides Admin-only navigation for Editors.
|
- [x] Frontend hides Admin-only navigation for Editors.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
@@ -40,9 +40,81 @@ Development description: Implement simple internal authentication and role-based
|
|||||||
|
|
||||||
## Result
|
## Result
|
||||||
|
|
||||||
- Status: Pending execution.
|
- Status: Completed.
|
||||||
- TDD plan: To be filled during execution.
|
- TDD plan:
|
||||||
- Red evidence: To be filled during execution.
|
- First behavior slice: public FastAPI HTTP authorization denial for a demo
|
||||||
- Green evidence: To be filled during execution.
|
Editor attempting an Admin-only site configuration mutation.
|
||||||
- Refactor notes: To be filled during execution.
|
- Public API contract selected for local demo auth:
|
||||||
- Verification output: To be filled during execution.
|
`X-Demo-User-Email: editor@example.com`.
|
||||||
|
- Red test: `POST /api/sites` with an Editor-selected demo user must return
|
||||||
|
stable `403`.
|
||||||
|
- Green slices: `GET /api/me`, Admin allow/Editor deny for site config and
|
||||||
|
script version mutations, Editor article create and plan approval review
|
||||||
|
action, stable `401`/`403` responses, and role-aware frontend navigation.
|
||||||
|
- Red evidence:
|
||||||
|
- Command:
|
||||||
|
`docker compose run --rm --build -v /Users/gavrilovdev/tmp/pupline:/app -w /app backend python apps/backend/tests/integration/test_auth_authorization_public_api.py`
|
||||||
|
- Result: failed as expected because `POST /api/sites` and auth/role checks
|
||||||
|
are not implemented yet.
|
||||||
|
- Output:
|
||||||
|
```text
|
||||||
|
F
|
||||||
|
======================================================================
|
||||||
|
FAIL: test_editor_cannot_create_target_site_config (__main__.AuthAuthorizationPublicApiTest.test_editor_cannot_create_target_site_config)
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/app/apps/backend/tests/integration/test_auth_authorization_public_api.py", line 49, in test_editor_cannot_create_target_site_config
|
||||||
|
self.assertEqual(403, response.status_code, response.text)
|
||||||
|
AssertionError: 403 != 404 : {"detail":"Not Found"}
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Ran 1 test in 0.004s
|
||||||
|
|
||||||
|
FAILED (failures=1)
|
||||||
|
```
|
||||||
|
- Green evidence:
|
||||||
|
- Command:
|
||||||
|
`docker compose run --rm --build -v /Users/gavrilovdev/tmp/pupline:/app -w /app backend python apps/backend/tests/integration/test_auth_authorization_public_api.py`
|
||||||
|
- Output:
|
||||||
|
```text
|
||||||
|
........
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Ran 8 tests in 0.249s
|
||||||
|
|
||||||
|
OK
|
||||||
|
```
|
||||||
|
- Refactor notes:
|
||||||
|
- Added local demo auth via `X-Demo-User-Email` using seeded users.
|
||||||
|
- Kept role constants in domain, current-user/site-config orchestration in
|
||||||
|
application, repository persistence in infrastructure, and FastAPI
|
||||||
|
dependencies/guards in presentation.
|
||||||
|
- Added minimal plan approval review action endpoint for Task 004 role checks.
|
||||||
|
- Added FSD role navigation model/component and a deterministic Node test that
|
||||||
|
executes the TypeScript source.
|
||||||
|
- Regenerated `packages/shared/openapi.json` and `packages/shared/src/api-types.ts`.
|
||||||
|
- Verification output:
|
||||||
|
- `docker compose run --rm --build -v /Users/gavrilovdev/tmp/pupline:/app -w /app backend python -m unittest discover apps/backend/tests`
|
||||||
|
```text
|
||||||
|
..............s....
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Ran 19 tests in 0.364s
|
||||||
|
|
||||||
|
OK (skipped=1)
|
||||||
|
```
|
||||||
|
- `pnpm --filter @pipeline/frontend test:roles`
|
||||||
|
```text
|
||||||
|
role navigation hides Admin-only items for Editors
|
||||||
|
```
|
||||||
|
- `pnpm typecheck`
|
||||||
|
```text
|
||||||
|
@pipeline/shared typecheck: tsc --noEmit
|
||||||
|
@pipeline/frontend typecheck: tsc --noEmit
|
||||||
|
```
|
||||||
|
- `bash tests/smoke/public-health.sh`
|
||||||
|
```text
|
||||||
|
backend health ok
|
||||||
|
runner health ok
|
||||||
|
frontend health ok
|
||||||
|
backend dependencies ok
|
||||||
|
public health smoke ok
|
||||||
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user