feat(problems): publish immutable candidates with audited maintenance
This commit is contained in:
@@ -11,7 +11,7 @@ from kf_oj.infra.db.session import build_engine
|
||||
from kf_oj.infra.messaging import models as messaging_models # noqa: F401
|
||||
from kf_oj.modules.identity import models as identity_models # noqa: F401
|
||||
from kf_oj.modules.operations import models as operations_models # noqa: F401
|
||||
from kf_oj.modules.problems import import_models # noqa: F401
|
||||
from kf_oj.modules.problems import import_models, maintenance_models # noqa: F401
|
||||
from kf_oj.modules.problems import models as problem_models # noqa: F401
|
||||
from kf_oj.modules.submissions import models as submission_models # noqa: F401
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Durable imports/candidates, append-only events and accepted-authority worker port."""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from kf_oj.modules.operations.models import MaintenanceEvent
|
||||
@@ -50,9 +51,31 @@ def upgrade():
|
||||
ProblemImportOperation,
|
||||
ProblemCandidate,
|
||||
CandidateCase,
|
||||
MaintenanceEvent,
|
||||
):
|
||||
model.__table__.create(connection)
|
||||
# Freeze the historical shape. A later operations model must not cause a
|
||||
# fresh database to skip ahead and conflict with a subsequent ALTER migration.
|
||||
op.create_table(
|
||||
"problem_maintenance_events",
|
||||
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.Column("actor_id", sa.Uuid(), sa.ForeignKey("accounts.id"), nullable=False),
|
||||
sa.Column("authorization_version", sa.BigInteger(), nullable=False),
|
||||
sa.Column("operation_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("action", sa.String(24), nullable=False),
|
||||
sa.Column("target_id", sa.Uuid()),
|
||||
sa.CheckConstraint("authorization_version > 0", name="authority"),
|
||||
sa.CheckConstraint(
|
||||
"action IN ('upload_created','upload_received','import_ready','import_failed',"
|
||||
"'import_discarded','import_expired','published','disabled','restored')",
|
||||
name="action",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_problem_maintenance_events_page", "problem_maintenance_events", ["created_at", "id"]
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_problem_import_candidate",
|
||||
"problem_import_operations",
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Immutable maintenance receipts, explicit operator audit and read-only history."""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from kf_oj.modules.problems.maintenance_models import ProblemMaintenanceOperation
|
||||
|
||||
revision = "0008_problem_maintenance"
|
||||
down_revision = "0007_problem_imports"
|
||||
branch_labels = depends_on = None
|
||||
OWNER = "kf_oj_identity_authority_owner"
|
||||
|
||||
|
||||
def upgrade():
|
||||
ProblemMaintenanceOperation.__table__.create(op.get_bind())
|
||||
op.execute("GRANT SELECT,INSERT ON problem_maintenance_operations TO kf_oj_platform")
|
||||
op.add_column(
|
||||
"problem_maintenance_events",
|
||||
sa.Column("actor_kind", sa.String(8), nullable=False, server_default=sa.text("'account'")),
|
||||
)
|
||||
op.add_column("problem_maintenance_events", sa.Column("operator_name", sa.String(63)))
|
||||
op.alter_column("problem_maintenance_events", "actor_id", nullable=True)
|
||||
op.drop_constraint(op.f("ck_problem_maintenance_events_action"), "problem_maintenance_events")
|
||||
op.create_check_constraint(
|
||||
"action",
|
||||
"problem_maintenance_events",
|
||||
"action IN ('upload_created','upload_received','import_ready','import_failed',"
|
||||
"'import_discarded','import_expired','published','disabled','restored',"
|
||||
"'authority_bootstrap','authority_grant','authority_revoke','authority_enable','authority_disable')",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"actor",
|
||||
"problem_maintenance_events",
|
||||
"(actor_kind='account' AND actor_id IS NOT NULL AND operator_name IS NULL) OR "
|
||||
"(actor_kind='operator' AND actor_id IS NULL AND operator_name IS NOT NULL)",
|
||||
)
|
||||
op.execute("""CREATE FUNCTION public.kf_append_only_maintenance() RETURNS trigger
|
||||
LANGUAGE plpgsql SET search_path=pg_catalog,pg_temp AS $$ BEGIN
|
||||
RAISE EXCEPTION 'append-only maintenance history' USING ERRCODE='23514'; END $$""")
|
||||
for table in ("problem_maintenance_operations", "problem_maintenance_events"):
|
||||
op.execute(f"""CREATE TRIGGER append_only_maintenance BEFORE UPDATE OR DELETE ON {table}
|
||||
FOR EACH ROW EXECUTE FUNCTION public.kf_append_only_maintenance()""")
|
||||
# Preserve the already-verified authority implementation; its only entry
|
||||
# point becomes a fixed audited wrapper owned by the same narrow NOLOGIN role.
|
||||
op.execute(
|
||||
"ALTER FUNCTION public.kf_manage_account_authority(text,text) "
|
||||
"RENAME TO kf_manage_account_authority_unlogged"
|
||||
)
|
||||
op.execute(
|
||||
"REVOKE ALL ON FUNCTION public.kf_manage_account_authority_unlogged(text,text) "
|
||||
"FROM PUBLIC,kf_oj_identity_operator"
|
||||
)
|
||||
op.execute(f"GRANT INSERT ON problem_maintenance_events TO {OWNER}")
|
||||
op.execute("""
|
||||
CREATE FUNCTION public.kf_manage_account_authority(p_username text,p_operation text)
|
||||
RETURNS TABLE(account_id uuid,username text,enabled boolean,is_administrator boolean,
|
||||
authorization_version bigint,changed boolean)
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$
|
||||
DECLARE result record; BEGIN
|
||||
SELECT * INTO STRICT result FROM
|
||||
public.kf_manage_account_authority_unlogged(p_username,p_operation);
|
||||
IF result.changed THEN
|
||||
INSERT INTO public.problem_maintenance_events
|
||||
(id,created_at,actor_id,actor_kind,operator_name,authorization_version,
|
||||
operation_id,action,target_id)
|
||||
VALUES (gen_random_uuid(),clock_timestamp(),NULL,'operator',session_user,
|
||||
result.authorization_version,gen_random_uuid(),'authority_' || p_operation,
|
||||
result.account_id);
|
||||
END IF;
|
||||
RETURN QUERY SELECT result.account_id,result.username,result.enabled,
|
||||
result.is_administrator,result.authorization_version,result.changed;
|
||||
END $$""")
|
||||
op.execute(f"GRANT CREATE ON SCHEMA public TO {OWNER}")
|
||||
op.execute(f"ALTER FUNCTION public.kf_manage_account_authority(text,text) OWNER TO {OWNER}")
|
||||
op.execute(f"REVOKE CREATE ON SCHEMA public FROM {OWNER}")
|
||||
op.execute("REVOKE ALL ON FUNCTION public.kf_manage_account_authority(text,text) FROM PUBLIC")
|
||||
op.execute(
|
||||
"GRANT EXECUTE ON FUNCTION public.kf_manage_account_authority(text,text) "
|
||||
"TO kf_oj_identity_operator"
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
if (
|
||||
op.get_bind()
|
||||
.exec_driver_sql(
|
||||
"SELECT EXISTS(SELECT 1 FROM problem_maintenance_operations) OR "
|
||||
"EXISTS(SELECT 1 FROM problem_maintenance_events WHERE actor_kind='operator')"
|
||||
)
|
||||
.scalar()
|
||||
):
|
||||
raise RuntimeError("cannot discard committed maintenance history")
|
||||
op.execute("DROP FUNCTION public.kf_manage_account_authority(text,text)")
|
||||
op.execute(
|
||||
"ALTER FUNCTION public.kf_manage_account_authority_unlogged(text,text) "
|
||||
"RENAME TO kf_manage_account_authority"
|
||||
)
|
||||
op.execute(
|
||||
"GRANT EXECUTE ON FUNCTION public.kf_manage_account_authority(text,text) "
|
||||
"TO kf_oj_identity_operator"
|
||||
)
|
||||
op.execute(f"REVOKE INSERT ON problem_maintenance_events FROM {OWNER}")
|
||||
for table in ("problem_maintenance_operations", "problem_maintenance_events"):
|
||||
op.execute(f"DROP TRIGGER append_only_maintenance ON {table}")
|
||||
op.execute("DROP FUNCTION public.kf_append_only_maintenance()")
|
||||
ProblemMaintenanceOperation.__table__.drop(op.get_bind())
|
||||
op.drop_constraint(op.f("ck_problem_maintenance_events_actor"), "problem_maintenance_events")
|
||||
op.drop_constraint(op.f("ck_problem_maintenance_events_action"), "problem_maintenance_events")
|
||||
op.create_check_constraint(
|
||||
"action",
|
||||
"problem_maintenance_events",
|
||||
"action IN ('upload_created','upload_received','import_ready','import_failed',"
|
||||
"'import_discarded','import_expired','published','disabled','restored')",
|
||||
)
|
||||
op.alter_column("problem_maintenance_events", "actor_id", nullable=False)
|
||||
op.drop_column("problem_maintenance_events", "operator_name")
|
||||
op.drop_column("problem_maintenance_events", "actor_kind")
|
||||
@@ -8,6 +8,7 @@ from starlette.responses import JSONResponse
|
||||
from kf_oj import __version__
|
||||
from kf_oj.api.body_limits import BodyLimitMiddleware
|
||||
from kf_oj.api.v1.admin_imports import router as import_router
|
||||
from kf_oj.api.v1.admin_problems import router as maintenance_router
|
||||
from kf_oj.api.v1.identity import router as identity_router
|
||||
from kf_oj.api.v1.problems import router as problems_router
|
||||
from kf_oj.api.v1.router import router
|
||||
@@ -21,8 +22,10 @@ from kf_oj.infra.artifacts.store import ArtifactStore
|
||||
from kf_oj.infra.coordinator import CoordinatorControl
|
||||
from kf_oj.infra.db.session import build_engine, build_session_factory
|
||||
from kf_oj.modules.identity.service import IdentityService
|
||||
from kf_oj.modules.operations.queries import MaintenanceAuditQueries
|
||||
from kf_oj.modules.operations.service import MaintenanceAudit
|
||||
from kf_oj.modules.problems.imports import ProblemImports
|
||||
from kf_oj.modules.problems.maintenance import ProblemMaintenance
|
||||
from kf_oj.modules.problems.service import ProblemService
|
||||
from kf_oj.modules.submissions.performance import SubmissionPerformanceQueries
|
||||
from kf_oj.modules.submissions.queries import SubmissionQueries
|
||||
@@ -69,6 +72,16 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
MaintenanceAudit(),
|
||||
settings.judge_admission,
|
||||
)
|
||||
app.state.problem_maintenance = ProblemMaintenance(
|
||||
app.state.sessions,
|
||||
app.state.identity,
|
||||
MaintenanceAudit(),
|
||||
artifacts,
|
||||
settings.judge_admission,
|
||||
)
|
||||
app.state.maintenance_audit = MaintenanceAuditQueries(
|
||||
app.state.sessions, app.state.identity
|
||||
)
|
||||
app.state.problems = ProblemService(
|
||||
app.state.sessions, artifacts, settings.judge_admission
|
||||
)
|
||||
@@ -98,6 +111,8 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
app.state.submission_queries = None
|
||||
app.state.submission_performance = None
|
||||
app.state.problem_imports = None
|
||||
app.state.problem_maintenance = None
|
||||
app.state.maintenance_audit = None
|
||||
if engine is not None:
|
||||
await engine.dispose()
|
||||
if store is not None:
|
||||
@@ -137,4 +152,5 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
app.include_router(worker_router, prefix="/api/v1")
|
||||
app.include_router(submission_router, prefix="/api/v1")
|
||||
app.include_router(import_router, prefix="/api/v1")
|
||||
app.include_router(maintenance_router, prefix="/api/v1")
|
||||
return app
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Live administrator projections and actor-bound immutable mutations."""
|
||||
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.exc import DBAPIError
|
||||
|
||||
from kf_oj.api.admin_dependencies import maintenance_reader, maintenance_writer
|
||||
from kf_oj.contracts.problem_maintenance import (
|
||||
AuditAction,
|
||||
ChangeProblemAvailability,
|
||||
MaintainedProblem,
|
||||
MaintainedProblemPage,
|
||||
MaintainedVersionPage,
|
||||
MaintenanceAuditPage,
|
||||
MaintenanceReceipt,
|
||||
PublishCandidate,
|
||||
)
|
||||
from kf_oj.infra.artifacts.store import ArtifactError
|
||||
from kf_oj.modules.identity.public import MaintenanceDenied, Principal
|
||||
from kf_oj.modules.problems.imports import ImportConflict, ImportMissing, ImportUnavailable
|
||||
from kf_oj.modules.problems.public import ProblemUnavailable
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["problem-maintenance"])
|
||||
|
||||
|
||||
def service(request, name="problem_maintenance"):
|
||||
result = getattr(request.app.state, name, None)
|
||||
if result is None:
|
||||
raise HTTPException(503, "problem maintenance unavailable")
|
||||
return result
|
||||
|
||||
|
||||
async def operation(awaitable):
|
||||
try:
|
||||
return await awaitable
|
||||
except MaintenanceDenied:
|
||||
raise HTTPException(403, "problem maintenance required") from None
|
||||
except ImportConflict:
|
||||
raise HTTPException(409, "maintenance precondition conflict") from None
|
||||
except ImportMissing:
|
||||
raise HTTPException(404, "maintenance object unavailable") from None
|
||||
except (ImportUnavailable, ArtifactError, ProblemUnavailable, OSError, DBAPIError):
|
||||
raise HTTPException(503, "problem maintenance unavailable") from None
|
||||
except ValueError:
|
||||
raise HTTPException(422, "invalid maintenance request") from None
|
||||
|
||||
|
||||
@router.get("/problems", response_model=MaintainedProblemPage)
|
||||
async def page(
|
||||
request: Request,
|
||||
principal: Annotated[Principal, Depends(maintenance_reader)],
|
||||
after: Annotated[str | None, Query(pattern=r"^[a-z0-9][a-z0-9_.-]{0,63}$")] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
enabled: bool | None = None,
|
||||
):
|
||||
return await operation(
|
||||
service(request).page(principal, after=after, limit=limit, enabled=enabled)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/problem-slugs/{slug}", response_model=MaintainedProblem)
|
||||
async def by_slug(
|
||||
slug: str, request: Request, principal: Annotated[Principal, Depends(maintenance_reader)]
|
||||
):
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from kf_oj.contracts.judging import Label
|
||||
|
||||
try:
|
||||
TypeAdapter(Label).validate_python(slug)
|
||||
except ValueError:
|
||||
raise HTTPException(422, "invalid problem slug") from None
|
||||
return await operation(service(request).by_slug(principal, slug))
|
||||
|
||||
|
||||
@router.get("/problems/{problem_id}", response_model=MaintainedProblem)
|
||||
async def detail(
|
||||
problem_id: UUID, request: Request, principal: Annotated[Principal, Depends(maintenance_reader)]
|
||||
):
|
||||
return await operation(service(request).detail(principal, problem_id))
|
||||
|
||||
|
||||
@router.get("/problems/{problem_id}/versions", response_model=MaintainedVersionPage)
|
||||
async def versions(
|
||||
problem_id: UUID,
|
||||
request: Request,
|
||||
principal: Annotated[Principal, Depends(maintenance_reader)],
|
||||
before: Annotated[int | None, Query(ge=1)] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
):
|
||||
return await operation(
|
||||
service(request).versions(principal, problem_id, before=before, limit=limit)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/problem-maintenance/{operation_id}", response_model=MaintenanceReceipt)
|
||||
async def receipt(
|
||||
operation_id: UUID,
|
||||
request: Request,
|
||||
principal: Annotated[Principal, Depends(maintenance_reader)],
|
||||
):
|
||||
return await operation(service(request).receipt(principal, operation_id))
|
||||
|
||||
|
||||
@router.post("/problem-publications", response_model=MaintenanceReceipt, status_code=201)
|
||||
async def publish(
|
||||
body: PublishCandidate,
|
||||
request: Request,
|
||||
principal: Annotated[Principal, Depends(maintenance_writer)],
|
||||
):
|
||||
return await operation(service(request).publish(principal, body))
|
||||
|
||||
|
||||
@router.post("/problems/{problem_id}/disable", response_model=MaintenanceReceipt)
|
||||
async def disable(
|
||||
problem_id: UUID,
|
||||
body: ChangeProblemAvailability,
|
||||
request: Request,
|
||||
principal: Annotated[Principal, Depends(maintenance_writer)],
|
||||
):
|
||||
return await operation(
|
||||
service(request).availability(principal, problem_id, body, enabled=False)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/problems/{problem_id}/restore", response_model=MaintenanceReceipt)
|
||||
async def restore(
|
||||
problem_id: UUID,
|
||||
body: ChangeProblemAvailability,
|
||||
request: Request,
|
||||
principal: Annotated[Principal, Depends(maintenance_writer)],
|
||||
):
|
||||
return await operation(service(request).availability(principal, problem_id, body, enabled=True))
|
||||
|
||||
|
||||
@router.get("/maintenance-audit", response_model=MaintenanceAuditPage)
|
||||
async def audit(
|
||||
request: Request,
|
||||
principal: Annotated[Principal, Depends(maintenance_reader)],
|
||||
before: Annotated[str | None, Query(max_length=100)] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
action: AuditAction | None = None,
|
||||
):
|
||||
return await operation(
|
||||
service(request, "maintenance_audit").page(
|
||||
principal, before=before, limit=limit, action=action
|
||||
)
|
||||
)
|
||||
@@ -13,6 +13,12 @@ from kf_oj.contracts.admin_problems import (
|
||||
from kf_oj.contracts.judge import JudgeTaskNotification
|
||||
from kf_oj.contracts.judging import WIRE_MODELS
|
||||
from kf_oj.contracts.judging_v2 import WIRE_MODELS as WIRE_MODELS_V2
|
||||
from kf_oj.contracts.problem_maintenance import (
|
||||
ChangeProblemAvailability,
|
||||
MaintenanceAuditPage,
|
||||
MaintenanceReceipt,
|
||||
PublishCandidate,
|
||||
)
|
||||
from kf_oj.contracts.submission_performance import SubmissionPerformance
|
||||
from kf_oj.core.settings import Settings
|
||||
|
||||
@@ -49,6 +55,10 @@ def main() -> None:
|
||||
)
|
||||
outputs[root / "contracts/schemas/submission-performance.v1.schema.json"] = performance
|
||||
for name, model in {
|
||||
"problem-publish": PublishCandidate,
|
||||
"problem-availability": ChangeProblemAvailability,
|
||||
"problem-maintenance": MaintenanceReceipt,
|
||||
"problem-maintenance-audit": MaintenanceAuditPage,
|
||||
"problem-import-create": CreateProblemImport,
|
||||
"problem-import": ProblemImportDetail,
|
||||
"problem-import-capabilities": ProblemImportCapabilities,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Bounded administrator projections and immutable operation receipts, separate from judging."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import Field, StrictBool
|
||||
|
||||
from kf_oj.contracts.admin_problems import CandidatePreview
|
||||
from kf_oj.contracts.judging import Identifier, Label, Version, WireModel
|
||||
from kf_oj.contracts.problems import ProblemSummary
|
||||
|
||||
MaintenanceAction = Literal["publish", "disable", "restore"]
|
||||
AuditAction = Literal[
|
||||
"upload_created",
|
||||
"upload_received",
|
||||
"import_ready",
|
||||
"import_failed",
|
||||
"import_discarded",
|
||||
"import_expired",
|
||||
"published",
|
||||
"disabled",
|
||||
"restored",
|
||||
"authority_bootstrap",
|
||||
"authority_grant",
|
||||
"authority_revoke",
|
||||
"authority_enable",
|
||||
"authority_disable",
|
||||
]
|
||||
|
||||
|
||||
class MaintainedProblem(WireModel):
|
||||
id: Identifier
|
||||
slug: Label
|
||||
enabled: StrictBool
|
||||
active_version_id: Identifier | None
|
||||
revision: Annotated[int, Field(strict=True, ge=0)]
|
||||
title: Annotated[str | None, Field(max_length=160)]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class MaintainedProblemPage(WireModel):
|
||||
schema_version: Version = 1
|
||||
items: Annotated[tuple[MaintainedProblem, ...], Field(max_length=100)]
|
||||
next_after: Label | None
|
||||
|
||||
|
||||
class MaintainedVersion(ProblemSummary):
|
||||
published_at: datetime
|
||||
case_count: Annotated[int, Field(strict=True, ge=1, le=256)]
|
||||
|
||||
|
||||
class MaintainedVersionPage(WireModel):
|
||||
schema_version: Version = 1
|
||||
items: Annotated[tuple[MaintainedVersion, ...], Field(max_length=100)]
|
||||
next_before: Annotated[int | None, Field(strict=True, ge=1)]
|
||||
|
||||
|
||||
class PublishCandidate(WireModel):
|
||||
schema_version: Version = 1
|
||||
operation_id: Identifier
|
||||
import_operation_id: Identifier
|
||||
candidate_id: Identifier
|
||||
expected_active_version_id: Identifier | None
|
||||
|
||||
|
||||
class ChangeProblemAvailability(WireModel):
|
||||
schema_version: Version = 1
|
||||
operation_id: Identifier
|
||||
expected_active_version_id: Identifier
|
||||
expected_enabled: StrictBool
|
||||
|
||||
|
||||
class MaintenanceReceipt(WireModel):
|
||||
schema_version: Version = 1
|
||||
operation_id: Identifier
|
||||
action: MaintenanceAction
|
||||
completed_at: datetime
|
||||
problem: MaintainedProblem
|
||||
published_version_id: Identifier | None = None
|
||||
|
||||
|
||||
class MaintenanceAuditEvent(WireModel):
|
||||
id: Identifier
|
||||
created_at: datetime
|
||||
actor_kind: Literal["account", "operator"]
|
||||
actor_id: Identifier | None
|
||||
operator_name: Annotated[str | None, Field(max_length=63)]
|
||||
authorization_version: Annotated[str, Field(pattern=r"^[1-9][0-9]{0,18}$")]
|
||||
operation_id: Identifier
|
||||
action: AuditAction
|
||||
target_id: Identifier | None
|
||||
result: Literal["committed"] = "committed"
|
||||
|
||||
|
||||
class MaintenanceAuditPage(WireModel):
|
||||
schema_version: Version = 1
|
||||
items: Annotated[tuple[MaintenanceAuditEvent, ...], Field(max_length=100)]
|
||||
next_before: Annotated[str | None, Field(max_length=100)]
|
||||
|
||||
|
||||
# This alias deliberately uses the existing sanitized candidate contract. No new
|
||||
# endpoint exposes the private manifest, artifact digest or hidden case names.
|
||||
PublicCandidate = CandidatePreview
|
||||
@@ -168,6 +168,51 @@ class ArtifactStore:
|
||||
with self.exclusive():
|
||||
return self._read(reference)
|
||||
|
||||
def verify(self, reference: ArtifactReference) -> None:
|
||||
"""Verify publication inputs with a 64 KiB buffer, without exposing hidden bytes."""
|
||||
ArtifactReference.model_validate(reference)
|
||||
with self.exclusive():
|
||||
try:
|
||||
descriptor = os.open(
|
||||
reference.digest,
|
||||
os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC,
|
||||
dir_fd=self._directory,
|
||||
)
|
||||
with os.fdopen(descriptor, "rb") as source:
|
||||
before = os.fstat(source.fileno())
|
||||
self._regular(before)
|
||||
if before.st_size != reference.bytes:
|
||||
raise ArtifactError("artifact digest or length mismatch")
|
||||
digest = hashlib.sha256()
|
||||
remaining = reference.bytes
|
||||
while remaining:
|
||||
data = source.read(min(65536, remaining))
|
||||
if not data:
|
||||
raise ArtifactError("artifact digest or length mismatch")
|
||||
digest.update(data)
|
||||
remaining -= len(data)
|
||||
after = os.fstat(source.fileno())
|
||||
if (
|
||||
source.read(1)
|
||||
or digest.hexdigest() != reference.digest
|
||||
or any(
|
||||
getattr(before, k) != getattr(after, k)
|
||||
for k in (
|
||||
"st_dev",
|
||||
"st_ino",
|
||||
"st_nlink",
|
||||
"st_mode",
|
||||
"st_uid",
|
||||
"st_size",
|
||||
"st_mtime_ns",
|
||||
"st_ctime_ns",
|
||||
)
|
||||
)
|
||||
):
|
||||
raise ArtifactError("artifact input changed")
|
||||
except OSError:
|
||||
raise ArtifactError("artifact unavailable") from None
|
||||
|
||||
def put_file(self, source: int, expected: ArtifactReference) -> ArtifactReference:
|
||||
"""Adopt a validated private FD without buffering a hidden testcase in memory."""
|
||||
before = os.fstat(source)
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# operations
|
||||
|
||||
负责平台侧节点、任务积压、重试原因和审计查询的管理视图。
|
||||
对判题状态的操作必须调用协调器协议;观测模块不成为第二个状态写入入口。
|
||||
当前只有目录边界,节点登记、指标聚合和管理员接口待实现。
|
||||
维护审计通过 `public.py` 的追加端口写入调用方事务;本模块不成为评测状态的写入入口。
|
||||
上传、解析完成/失败、丢弃、发布、下架与恢复记录真实账号、授权版本、操作 UUID、对象与
|
||||
数据库时间。发布回执和审计同时提交;审计失败使整个业务事务回滚,不确认半发布。
|
||||
|
||||
本机受控身份 CLI 使用已核验的窄权限数据库函数。授权变化由固定包装函数在同一事务
|
||||
追加审计,主体类型为 operator,操作者为数据库 `session_user`,对象为实际变更账号;
|
||||
不把授予/撤销冒充成该账号自己的操作。旧账号事件保留 account 主体类型。
|
||||
|
||||
管理员 `/api/v1/admin/maintenance-audit` 按时间和 UUID 的复合游标递减读取,每页至多
|
||||
100项,可选择有限动作类型;只展示已提交事件。导入失败动作仍是失败,committed 表示
|
||||
审计事务提交。响应不含口令、会话令牌、工件引用、内部路径或任意诊断正文。
|
||||
|
||||
应用数据库角色无审计 UPDATE/DELETE 权限,数据库触发器还阻止历史改写。
|
||||
导入 Worker 只有追加权限,不能读取整个维护审计;Coordinator 无审计写入权限。
|
||||
节点管理、部署控制及跨账号业务管理仍未开放。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import BigInteger, CheckConstraint, ForeignKey, Index, String
|
||||
from sqlalchemy import BigInteger, CheckConstraint, ForeignKey, Index, String, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from kf_oj.infra.db.base import Base
|
||||
@@ -15,14 +15,24 @@ class MaintenanceEvent(CreatedAt, Base):
|
||||
CheckConstraint(
|
||||
"action IN ('upload_created','upload_received','import_ready',"
|
||||
"'import_failed','import_discarded','import_expired','published',"
|
||||
"'disabled','restored')",
|
||||
"'disabled','restored','authority_bootstrap','authority_grant',"
|
||||
"'authority_revoke','authority_enable','authority_disable')",
|
||||
name="action",
|
||||
),
|
||||
CheckConstraint("authorization_version > 0", name="authority"),
|
||||
CheckConstraint(
|
||||
"(actor_kind='account' AND actor_id IS NOT NULL AND operator_name IS NULL) "
|
||||
"OR (actor_kind='operator' AND actor_id IS NULL AND operator_name IS NOT NULL)",
|
||||
name="actor",
|
||||
),
|
||||
Index("ix_problem_maintenance_events_page", "created_at", "id"),
|
||||
)
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True)
|
||||
actor_id: Mapped[UUID] = mapped_column(ForeignKey("accounts.id"))
|
||||
actor_id: Mapped[UUID | None] = mapped_column(ForeignKey("accounts.id"))
|
||||
actor_kind: Mapped[str] = mapped_column(
|
||||
String(8), default="account", server_default=text("'account'")
|
||||
)
|
||||
operator_name: Mapped[str | None] = mapped_column(String(63))
|
||||
authorization_version: Mapped[int] = mapped_column(BigInteger)
|
||||
operation_id: Mapped[UUID] = mapped_column()
|
||||
action: Mapped[str] = mapped_column(String(24))
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Only authorized administrators read bounded append-only maintenance projections."""
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, text, tuple_
|
||||
|
||||
from kf_oj.contracts.problem_maintenance import MaintenanceAuditEvent, MaintenanceAuditPage
|
||||
from kf_oj.modules.identity.public import ProblemMaintenancePort
|
||||
from kf_oj.modules.operations.models import MaintenanceEvent
|
||||
|
||||
|
||||
class MaintenanceAuditQueries:
|
||||
def __init__(self, sessions, identity: ProblemMaintenancePort):
|
||||
self.sessions, self.identity = sessions, identity
|
||||
|
||||
async def page(self, principal, *, before=None, limit=20, action=None):
|
||||
if not 1 <= limit <= 100:
|
||||
raise ValueError("invalid page limit")
|
||||
cursor = None
|
||||
if before is not None:
|
||||
stamp, identifier = before.split("|")
|
||||
stamp = datetime.fromisoformat(stamp)
|
||||
if stamp.tzinfo is None:
|
||||
raise ValueError("invalid audit cursor")
|
||||
cursor = stamp, UUID(identifier)
|
||||
async with self.sessions.begin() as session:
|
||||
await session.execute(text("SET LOCAL statement_timeout='5s'"))
|
||||
await session.execute(text("SET LOCAL lock_timeout='2s'"))
|
||||
await self.identity.require_problem_maintenance(session, principal)
|
||||
q = select(MaintenanceEvent)
|
||||
if cursor:
|
||||
q = q.where(tuple_(MaintenanceEvent.created_at, MaintenanceEvent.id) < cursor)
|
||||
if action:
|
||||
q = q.where(MaintenanceEvent.action == action)
|
||||
rows = list(
|
||||
await session.scalars(
|
||||
q.order_by(
|
||||
MaintenanceEvent.created_at.desc(), MaintenanceEvent.id.desc()
|
||||
).limit(limit + 1)
|
||||
)
|
||||
)
|
||||
visible = rows[:limit]
|
||||
return MaintenanceAuditPage(
|
||||
items=tuple(
|
||||
MaintenanceAuditEvent(
|
||||
id=r.id,
|
||||
created_at=r.created_at,
|
||||
actor_kind=r.actor_kind,
|
||||
actor_id=r.actor_id,
|
||||
operator_name=r.operator_name,
|
||||
authorization_version=str(r.authorization_version),
|
||||
operation_id=r.operation_id,
|
||||
action=r.action,
|
||||
target_id=r.target_id,
|
||||
)
|
||||
for r in visible
|
||||
),
|
||||
next_before=f"{visible[-1].created_at.isoformat()}|{visible[-1].id}"
|
||||
if len(rows) > limit
|
||||
else None,
|
||||
)
|
||||
@@ -60,3 +60,21 @@ Web格式固定单层ZIP的stored/deflate,清单名为`problem.json`;公开
|
||||
同一普通用户的私有目录;数据库采用各自最小角色,导入Worker只通过固定身份公开端口
|
||||
验证已受理的维护授权。PrivateNetwork模板要求Unix PostgreSQL socket;实际部署前由运维
|
||||
设置外部私有配置、专用登录与目录,不包含默认口令,也不代表本轮已经生产部署。
|
||||
|
||||
管理员发布使用 `/api/v1/admin/problem-publications`,绑定独立操作 UUID、本人导入操作、
|
||||
候选 UUID 与预期活动版本(首次发布为 null)。`publication.py` 为 CLI 与 Web 共用的
|
||||
准入与不可变版本写入路径;Web 在事务内再次校验实时维护权限、候选摘要、工件实际字节
|
||||
和可信节点配置。CLI 保留本机受控入口与原有导入格式,不借用网页会话。
|
||||
|
||||
成功维护回执只追加保存,UUID 同内容重试返回原快照,不重复创建 revision 或审计。
|
||||
UUID 绑定不同内容或活动版本变化返回409;前端应核对最新状态后建立新的操作凭据。
|
||||
题目状态写入同样要求预期活动版本及 enabled;下架不修改历史版本或删除任何提交。
|
||||
新提交持有逻辑题目的共享锁直到提交事务结束,下架/恢复持有排他锁,二者明确排序。
|
||||
|
||||
管理列表按 slug、版本历史按递减 revision 分页,每页至多100条,仅返回公开元数据。
|
||||
候选仍通过本人导入操作读取;维护回执仅本人可读。隐藏测试字节、摘要和宿主路径不进入
|
||||
这些投影。所有管理读取也经实时维护权限检查,缺失发布配置明确503。
|
||||
|
||||
`for_accepted_execution` 仅在 submissions 已核对有效租约、源码归属及不可变尝试快照后
|
||||
调用,读取已发布原版本时允许逻辑题目已下架。它没有外部 UUID 直读端点,不放宽未发布
|
||||
版本或无有效租约的访问。公开读取与新提交仍检查 enabled。
|
||||
|
||||
@@ -3,14 +3,13 @@
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from uuid import uuid4, uuid5
|
||||
from uuid import uuid5
|
||||
|
||||
from kf_oj.contracts import judging_v2 as v2
|
||||
from kf_oj.contracts.admin_problems import CandidatePreview, LegacyImportBudget
|
||||
from kf_oj.contracts.judging import MAX_SOURCE_BYTES, ArtifactReference, TestCase
|
||||
from kf_oj.contracts.judging import ArtifactReference, TestCase
|
||||
from kf_oj.contracts.problems import PublicLanguageConfiguration
|
||||
from kf_oj.modules.problems.import_format import ImportEnvelope, ProblemImport
|
||||
from kf_oj.modules.problems.public import JudgingVersion
|
||||
from kf_oj.modules.problems.publication import admit_version
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -71,27 +70,5 @@ def validate_candidate(manifest_data, preview_data, references, policy) -> Valid
|
||||
)
|
||||
for c in manifest.cases
|
||||
)
|
||||
projection = JudgingVersion(
|
||||
preview.candidate_id,
|
||||
uuid4(),
|
||||
manifest.languages,
|
||||
manifest.toolchain if legacy else None,
|
||||
manifest.compile_limits if legacy else None,
|
||||
manifest.run_limits if legacy else None,
|
||||
manifest.job_wall_time_us if legacy else None,
|
||||
cases,
|
||||
manifest.schema_version,
|
||||
() if legacy else manifest.language_configurations,
|
||||
)
|
||||
for language in manifest.languages:
|
||||
v2.admit_manifest(
|
||||
policy,
|
||||
projection.manifest(
|
||||
uuid4(),
|
||||
uuid4(),
|
||||
language,
|
||||
ArtifactReference(digest="0" * 64, bytes=MAX_SOURCE_BYTES),
|
||||
),
|
||||
1,
|
||||
)
|
||||
admit_version(policy, manifest, preview.candidate_id, preview.candidate_id, cases)
|
||||
return ValidatedCandidate(manifest.model_dump(mode="json"), preview, refs)
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
"""Administrator transactions; no evaluation writes or private data in projections."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
from uuid import uuid4, uuid5
|
||||
|
||||
from sqlalchemy import func, select, text
|
||||
|
||||
from kf_oj.contracts.admin_problems import CandidatePreview
|
||||
from kf_oj.contracts.judging import ArtifactReference, TestCase
|
||||
from kf_oj.contracts.problem_maintenance import (
|
||||
MaintainedProblem,
|
||||
MaintainedProblemPage,
|
||||
MaintainedVersion,
|
||||
MaintainedVersionPage,
|
||||
MaintenanceReceipt,
|
||||
)
|
||||
from kf_oj.modules.identity.public import ProblemMaintenancePort
|
||||
from kf_oj.modules.operations.public import MaintenanceAuditPort
|
||||
from kf_oj.modules.problems import repository
|
||||
from kf_oj.modules.problems.candidate_validation import validate_candidate
|
||||
from kf_oj.modules.problems.import_format import ImportEnvelope
|
||||
from kf_oj.modules.problems.import_models import (
|
||||
CandidateCase,
|
||||
ProblemCandidate,
|
||||
ProblemImportOperation,
|
||||
)
|
||||
from kf_oj.modules.problems.imports import ImportConflict, ImportMissing, ImportUnavailable
|
||||
from kf_oj.modules.problems.maintenance_models import ProblemMaintenanceOperation
|
||||
from kf_oj.modules.problems.models import Problem, ProblemCase, ProblemVersion
|
||||
from kf_oj.modules.problems.publication import admit_version, publish_version
|
||||
from kf_oj.modules.problems.service import ProblemService
|
||||
|
||||
|
||||
def project_problem(problem, version):
|
||||
return MaintainedProblem(
|
||||
id=problem.id,
|
||||
slug=problem.slug,
|
||||
enabled=problem.enabled,
|
||||
active_version_id=problem.active_version_id,
|
||||
revision=version.revision if version else 0,
|
||||
title=version.title if version else None,
|
||||
created_at=problem.created_at,
|
||||
)
|
||||
|
||||
|
||||
class ProblemMaintenance:
|
||||
def __init__(
|
||||
self,
|
||||
sessions,
|
||||
identity: ProblemMaintenancePort,
|
||||
audit: MaintenanceAuditPort,
|
||||
artifacts=None,
|
||||
policy=None,
|
||||
):
|
||||
self.sessions, self.identity, self.audit = sessions, identity, audit
|
||||
self.artifacts, self.policy = artifacts, policy
|
||||
self._publishing = asyncio.Semaphore(2)
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self, principal):
|
||||
async with self.sessions.begin() as session:
|
||||
await session.execute(text("SET LOCAL statement_timeout='5s'"))
|
||||
await session.execute(text("SET LOCAL lock_timeout='2s'"))
|
||||
await self.identity.require_problem_maintenance(session, principal)
|
||||
yield session
|
||||
|
||||
async def page(self, principal, *, after=None, limit=20, enabled=None):
|
||||
if not 1 <= limit <= 100:
|
||||
raise ValueError("invalid page limit")
|
||||
async with self.transaction(principal) as session:
|
||||
q = select(Problem, ProblemVersion).outerjoin(
|
||||
ProblemVersion, Problem.active_version_id == ProblemVersion.id
|
||||
)
|
||||
if after is not None:
|
||||
q = q.where(Problem.slug > after)
|
||||
if enabled is not None:
|
||||
q = q.where(Problem.enabled == enabled)
|
||||
rows = list(await session.execute(q.order_by(Problem.slug).limit(limit + 1)))
|
||||
return MaintainedProblemPage(
|
||||
items=tuple(project_problem(*r) for r in rows[:limit]),
|
||||
next_after=rows[limit - 1][0].slug if len(rows) > limit else None,
|
||||
)
|
||||
|
||||
async def by_slug(self, principal, slug):
|
||||
async with self.transaction(principal) as session:
|
||||
row = (
|
||||
await session.execute(
|
||||
select(Problem, ProblemVersion)
|
||||
.outerjoin(ProblemVersion, Problem.active_version_id == ProblemVersion.id)
|
||||
.where(Problem.slug == slug)
|
||||
)
|
||||
).one_or_none()
|
||||
if row is None:
|
||||
raise ImportMissing("problem unavailable")
|
||||
return project_problem(*row)
|
||||
|
||||
async def detail(self, principal, problem_id):
|
||||
async with self.transaction(principal) as session:
|
||||
problem = await session.get(Problem, problem_id)
|
||||
if problem is None:
|
||||
raise ImportMissing("problem unavailable")
|
||||
version = (
|
||||
await session.get(ProblemVersion, problem.active_version_id)
|
||||
if problem.active_version_id
|
||||
else None
|
||||
)
|
||||
return project_problem(problem, version)
|
||||
|
||||
async def versions(self, principal, problem_id, *, before=None, limit=20):
|
||||
if not 1 <= limit <= 100 or (before is not None and before < 1):
|
||||
raise ValueError("invalid page limit")
|
||||
async with self.transaction(principal) as session:
|
||||
problem = await session.get(Problem, problem_id)
|
||||
if problem is None:
|
||||
raise ImportMissing("problem unavailable")
|
||||
count = (
|
||||
select(func.count())
|
||||
.where(ProblemCase.version_id == ProblemVersion.id)
|
||||
.scalar_subquery()
|
||||
)
|
||||
q = select(ProblemVersion, count).where(
|
||||
ProblemVersion.problem_id == problem_id, ProblemVersion.published_at.is_not(None)
|
||||
)
|
||||
if before is not None:
|
||||
q = q.where(ProblemVersion.revision < before)
|
||||
rows = list(
|
||||
await session.execute(q.order_by(ProblemVersion.revision.desc()).limit(limit + 1))
|
||||
)
|
||||
return MaintainedVersionPage(
|
||||
items=tuple(
|
||||
MaintainedVersion(
|
||||
**ProblemService._summary(problem, v).model_dump(),
|
||||
published_at=v.published_at,
|
||||
case_count=n,
|
||||
)
|
||||
for v, n in rows[:limit]
|
||||
),
|
||||
next_before=rows[limit - 1][0].revision if len(rows) > limit else None,
|
||||
)
|
||||
|
||||
async def receipt(self, principal, operation_id):
|
||||
async with self.transaction(principal) as session:
|
||||
row = await session.get(ProblemMaintenanceOperation, operation_id)
|
||||
if row is None or row.owner_id != principal.account_id:
|
||||
raise ImportMissing("operation unavailable")
|
||||
return MaintenanceReceipt.model_validate(row.receipt)
|
||||
|
||||
async def _replay(self, session, principal, action, body, target):
|
||||
digest = hashlib.sha256(
|
||||
json.dumps(
|
||||
{"action": action, "target": str(target), "request": body.model_dump(mode="json")},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
).hexdigest()
|
||||
key = int.from_bytes(hashlib.sha256(body.operation_id.bytes).digest()[:4], signed=True)
|
||||
await session.execute(text("SELECT pg_advisory_xact_lock(1262907470,:key)"), {"key": key})
|
||||
row = await session.get(ProblemMaintenanceOperation, body.operation_id)
|
||||
if row is not None:
|
||||
if row.owner_id != principal.account_id:
|
||||
raise ImportMissing("operation unavailable")
|
||||
if row.action != action or row.request_digest != digest:
|
||||
raise ImportConflict("operation UUID already bound")
|
||||
return digest, MaintenanceReceipt.model_validate(row.receipt)
|
||||
return digest, None
|
||||
|
||||
async def _record(
|
||||
self, session, principal, request, action, digest, problem, version, published=None
|
||||
):
|
||||
receipt = MaintenanceReceipt(
|
||||
operation_id=request.operation_id,
|
||||
action=action,
|
||||
completed_at=await session.scalar(select(func.clock_timestamp())),
|
||||
problem=project_problem(problem, version),
|
||||
published_version_id=published,
|
||||
)
|
||||
session.add(
|
||||
ProblemMaintenanceOperation(
|
||||
id=request.operation_id,
|
||||
owner_id=principal.account_id,
|
||||
authorization_version=principal.authorization_version,
|
||||
action=action,
|
||||
request_digest=digest,
|
||||
receipt=receipt.model_dump(mode="json"),
|
||||
)
|
||||
)
|
||||
await self.audit.record(
|
||||
session,
|
||||
principal.account_id,
|
||||
principal.authorization_version,
|
||||
request.operation_id,
|
||||
{"publish": "published", "disable": "disabled", "restore": "restored"}[action],
|
||||
published if action == "publish" else problem.id,
|
||||
)
|
||||
await session.flush()
|
||||
return receipt
|
||||
|
||||
async def publish(self, principal, request):
|
||||
if self.artifacts is None or self.policy is None or self._publishing.locked():
|
||||
raise ImportUnavailable("publication unavailable")
|
||||
async with self._publishing:
|
||||
async with self.transaction(principal) as session:
|
||||
digest, replay = await self._replay(
|
||||
session, principal, "publish", request, request.import_operation_id
|
||||
)
|
||||
if replay:
|
||||
return replay
|
||||
op = await session.get(
|
||||
ProblemImportOperation, request.import_operation_id, with_for_update=True
|
||||
)
|
||||
if op is None or op.owner_id != principal.account_id:
|
||||
raise ImportMissing("candidate unavailable")
|
||||
if (
|
||||
op.state != "ready"
|
||||
or op.candidate_id != request.candidate_id
|
||||
or op.authorization_version != principal.authorization_version
|
||||
or op.expires_at <= await session.scalar(select(func.clock_timestamp()))
|
||||
):
|
||||
raise ImportConflict("candidate no longer publishable")
|
||||
candidate = await session.get(ProblemCandidate, op.candidate_id)
|
||||
if candidate is None or candidate.operation_id != op.id:
|
||||
raise ImportUnavailable("candidate unavailable")
|
||||
manifest = ImportEnvelope.model_validate(candidate.manifest).root
|
||||
preview = CandidatePreview.model_validate(candidate.preview)
|
||||
rows = list(
|
||||
await session.scalars(
|
||||
select(CandidateCase)
|
||||
.where(CandidateCase.candidate_id == candidate.id)
|
||||
.order_by(CandidateCase.position)
|
||||
)
|
||||
)
|
||||
if len(rows) != len(manifest.cases) or len(rows) != candidate.case_count:
|
||||
raise ImportUnavailable("candidate references unavailable")
|
||||
refs = {}
|
||||
for position, (row, c) in enumerate(zip(rows, manifest.cases, strict=True)):
|
||||
if row.position != position or row.key != c.key:
|
||||
raise ImportUnavailable("candidate references unavailable")
|
||||
for name, ref in (
|
||||
(
|
||||
c.input_file,
|
||||
ArtifactReference(digest=row.input_digest, bytes=row.input_bytes),
|
||||
),
|
||||
(
|
||||
c.expected_file,
|
||||
ArtifactReference(digest=row.expected_digest, bytes=row.expected_bytes),
|
||||
),
|
||||
):
|
||||
if name in refs and refs[name] != ref:
|
||||
raise ImportUnavailable("candidate references unavailable")
|
||||
refs[name] = ref
|
||||
validated = validate_candidate(
|
||||
candidate.manifest, candidate.preview, refs, self.policy
|
||||
)
|
||||
if (
|
||||
validated.digest != candidate.manifest_digest
|
||||
or preview.candidate_id != candidate.id
|
||||
):
|
||||
raise ImportUnavailable("candidate integrity unavailable")
|
||||
await repository.lock_slug(session, manifest.slug)
|
||||
problem = await repository.lock_problem(session, manifest.slug)
|
||||
actual = problem.active_version_id if problem else None
|
||||
if actual != request.expected_active_version_id or (
|
||||
problem and not problem.enabled
|
||||
):
|
||||
raise ImportConflict("active version or availability changed")
|
||||
if problem is None:
|
||||
problem = Problem(id=uuid4(), slug=manifest.slug)
|
||||
session.add(problem)
|
||||
await session.flush()
|
||||
version_id = uuid4()
|
||||
cases = tuple(
|
||||
TestCase(
|
||||
case_id=uuid5(version_id, c.key),
|
||||
input=refs[c.input_file],
|
||||
expected=refs[c.expected_file],
|
||||
)
|
||||
for c in manifest.cases
|
||||
)
|
||||
admit_version(self.policy, manifest, version_id, problem.id, cases)
|
||||
# Candidate FKs protect all referenced ready metadata from GC. The
|
||||
# private adapter additionally re-hashes disk bytes without buffering them.
|
||||
for ref in sorted(
|
||||
{r.digest: r for r in refs.values()}.values(), key=lambda r: r.digest
|
||||
):
|
||||
await self.artifacts.check_metadata(session, ref)
|
||||
task = asyncio.create_task(asyncio.to_thread(self.artifacts.store.verify, ref))
|
||||
try:
|
||||
await asyncio.shield(task)
|
||||
except BaseException:
|
||||
await task
|
||||
raise
|
||||
version = await publish_version(
|
||||
session, problem, manifest, preview, version_id, cases
|
||||
)
|
||||
op.state, op.published_version_id = "published", version.id
|
||||
op.updated_at = await session.scalar(select(func.clock_timestamp()))
|
||||
return await self._record(
|
||||
session, principal, request, "publish", digest, problem, version, version.id
|
||||
)
|
||||
|
||||
async def availability(self, principal, problem_id, request, *, enabled):
|
||||
action = "restore" if enabled else "disable"
|
||||
async with self.transaction(principal) as session:
|
||||
digest, replay = await self._replay(session, principal, action, request, problem_id)
|
||||
if replay:
|
||||
return replay
|
||||
problem = await session.get(Problem, problem_id, with_for_update=True)
|
||||
if problem is None:
|
||||
raise ImportMissing("problem unavailable")
|
||||
if (
|
||||
problem.active_version_id != request.expected_active_version_id
|
||||
or problem.enabled != request.expected_enabled
|
||||
or problem.enabled == enabled
|
||||
):
|
||||
raise ImportConflict("active version or availability changed")
|
||||
version = await session.get(ProblemVersion, problem.active_version_id)
|
||||
if version is None or version.published_at is None:
|
||||
raise ImportUnavailable("published version unavailable")
|
||||
problem.enabled = enabled
|
||||
await session.flush()
|
||||
return await self._record(session, principal, request, action, digest, problem, version)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Append-only actor-bound receipts; replay never re-applies a published operation."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import BigInteger, CheckConstraint, ForeignKey, String
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from kf_oj.infra.db.base import Base
|
||||
from kf_oj.infra.db.columns import CreatedAt
|
||||
|
||||
|
||||
class ProblemMaintenanceOperation(CreatedAt, Base):
|
||||
__tablename__ = "problem_maintenance_operations"
|
||||
__table_args__ = (
|
||||
CheckConstraint("action IN ('publish','disable','restore')", name="action"),
|
||||
CheckConstraint("request_digest ~ '^[0-9a-f]{64}$'", name="digest"),
|
||||
CheckConstraint("authorization_version > 0", name="authority"),
|
||||
CheckConstraint("octet_length(receipt::text) <= 4096", name="receipt"),
|
||||
)
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True)
|
||||
owner_id: Mapped[UUID] = mapped_column(ForeignKey("accounts.id"))
|
||||
authorization_version: Mapped[int] = mapped_column(BigInteger)
|
||||
action: Mapped[str] = mapped_column(String(16))
|
||||
request_digest: Mapped[str] = mapped_column(String(64))
|
||||
receipt: Mapped[dict] = mapped_column(JSONB)
|
||||
@@ -78,3 +78,7 @@ class JudgingVersion:
|
||||
|
||||
class ProblemPort(Protocol):
|
||||
async def for_submission(self, session, version_id: UUID) -> JudgingVersion: ...
|
||||
|
||||
async def for_accepted_execution(self, session, version_id: UUID) -> JudgingVersion:
|
||||
"""Require an already-validated live execution lease; allow a disabled published version."""
|
||||
...
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""One admission and immutable publication path for local and web imports."""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from kf_oj.contracts import judging_v2 as v2
|
||||
from kf_oj.contracts.judging import MAX_SOURCE_BYTES, ArtifactReference
|
||||
from kf_oj.modules.problems import repository
|
||||
from kf_oj.modules.problems.import_format import ProblemImport
|
||||
from kf_oj.modules.problems.models import ProblemCase, ProblemVersion
|
||||
from kf_oj.modules.problems.public import JudgingVersion, ProblemUnavailable
|
||||
|
||||
|
||||
def admit_version(policy, manifest, version_id, problem_id, cases):
|
||||
legacy = isinstance(manifest, ProblemImport)
|
||||
projection = JudgingVersion(
|
||||
version_id,
|
||||
problem_id,
|
||||
manifest.languages,
|
||||
manifest.toolchain if legacy else None,
|
||||
manifest.compile_limits if legacy else None,
|
||||
manifest.run_limits if legacy else None,
|
||||
manifest.job_wall_time_us if legacy else None,
|
||||
cases,
|
||||
manifest.schema_version,
|
||||
() if legacy else manifest.language_configurations,
|
||||
)
|
||||
for language in manifest.languages:
|
||||
v2.admit_manifest(
|
||||
policy,
|
||||
projection.manifest(
|
||||
uuid4(),
|
||||
uuid4(),
|
||||
language,
|
||||
ArtifactReference(digest="0" * 64, bytes=MAX_SOURCE_BYTES),
|
||||
),
|
||||
1,
|
||||
)
|
||||
return projection
|
||||
|
||||
|
||||
async def publish_version(session, problem, manifest, preview, version_id, cases):
|
||||
"""Caller holds the logical slug/row locks and has made all artifacts durable."""
|
||||
if not problem.enabled:
|
||||
raise ProblemUnavailable("problem disabled")
|
||||
legacy = isinstance(manifest, ProblemImport)
|
||||
version = ProblemVersion(
|
||||
id=version_id,
|
||||
problem_id=problem.id,
|
||||
revision=await repository.next_revision(session, problem.id),
|
||||
title=manifest.title,
|
||||
statement=preview.statement,
|
||||
languages=[lang.value for lang in manifest.languages],
|
||||
judging_schema_version=manifest.schema_version,
|
||||
language_configurations=None
|
||||
if legacy
|
||||
else [c.model_dump(mode="json") for c in manifest.language_configurations],
|
||||
toolchain=manifest.toolchain.model_dump(mode="json") if legacy else None,
|
||||
compile_limits=manifest.compile_limits.model_dump(mode="json") if legacy else None,
|
||||
run_limits=manifest.run_limits.model_dump(mode="json") if legacy else None,
|
||||
job_wall_time_us=manifest.job_wall_time_us if legacy else None,
|
||||
comparison=manifest.comparison,
|
||||
public_samples=[s.model_dump(mode="json") for s in preview.samples],
|
||||
)
|
||||
session.add(version)
|
||||
await session.flush()
|
||||
for position, case in enumerate(cases):
|
||||
session.add(
|
||||
ProblemCase(
|
||||
id=case.case_id,
|
||||
version_id=version_id,
|
||||
position=position,
|
||||
input_digest=case.input.digest,
|
||||
expected_digest=case.expected.digest,
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
version.published_at = await session.scalar(select(func.now()))
|
||||
problem.active_version_id = version_id
|
||||
await session.flush()
|
||||
return version
|
||||
@@ -24,7 +24,22 @@ async def next_revision(session: AsyncSession, problem_id: UUID) -> int:
|
||||
return (latest or 0) + 1
|
||||
|
||||
|
||||
async def public_version(session: AsyncSession, version_id: UUID):
|
||||
async def published_version(session: AsyncSession, version_id: UUID, *, admission=False):
|
||||
query = (
|
||||
select(ProblemVersion, Problem)
|
||||
.join(Problem, Problem.id == ProblemVersion.problem_id)
|
||||
.where(ProblemVersion.id == version_id, ProblemVersion.published_at.is_not(None))
|
||||
)
|
||||
if admission:
|
||||
# A new submission commits while this shared problem lock is held. A
|
||||
# concurrent disable either precedes admission or follows that commit.
|
||||
query = query.where(Problem.enabled.is_(True)).with_for_update(read=True, of=Problem)
|
||||
return (await session.execute(query)).one_or_none()
|
||||
|
||||
|
||||
async def public_version(session: AsyncSession, version_id: UUID, *, admission=False):
|
||||
if admission:
|
||||
return await published_version(session, version_id, admission=True)
|
||||
return (
|
||||
await session.execute(
|
||||
select(ProblemVersion, Problem)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from uuid import UUID, uuid4, uuid5
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from kf_oj.contracts import judging_v2 as v2
|
||||
@@ -28,8 +29,9 @@ from kf_oj.infra.artifacts.repository import ArtifactRepository
|
||||
from kf_oj.modules.problems import repository
|
||||
from kf_oj.modules.problems.import_format import ImportEnvelope, ProblemImport
|
||||
from kf_oj.modules.problems.local_files import LocalImportFiles
|
||||
from kf_oj.modules.problems.models import Problem, ProblemCase, ProblemVersion
|
||||
from kf_oj.modules.problems.models import Problem
|
||||
from kf_oj.modules.problems.public import JudgingVersion, ProblemUnavailable
|
||||
from kf_oj.modules.problems.publication import admit_version, publish_version
|
||||
|
||||
|
||||
class ProblemService:
|
||||
@@ -83,27 +85,7 @@ class ProblemService:
|
||||
)
|
||||
for case, (data, answer) in zip(manifest.cases, payloads, strict=True)
|
||||
)
|
||||
legacy = isinstance(manifest, ProblemImport)
|
||||
projection = JudgingVersion(
|
||||
version_id,
|
||||
uuid4(),
|
||||
manifest.languages,
|
||||
manifest.toolchain if legacy else None,
|
||||
manifest.compile_limits if legacy else None,
|
||||
manifest.run_limits if legacy else None,
|
||||
manifest.job_wall_time_us if legacy else None,
|
||||
cases,
|
||||
manifest.schema_version,
|
||||
() if legacy else manifest.language_configurations,
|
||||
)
|
||||
for language in manifest.languages:
|
||||
check = projection.manifest(
|
||||
uuid4(),
|
||||
uuid4(),
|
||||
language,
|
||||
ArtifactReference(digest="0" * 64, bytes=MAX_SOURCE_BYTES),
|
||||
)
|
||||
v2.admit_manifest(self.policy, check, 1)
|
||||
admit_version(self.policy, manifest, version_id, uuid4(), cases)
|
||||
async with self.sessions.begin() as session:
|
||||
# Lock existing and not-yet-existing logical slugs before assigning revisions.
|
||||
await repository.lock_slug(session, manifest.slug)
|
||||
@@ -114,32 +96,6 @@ class ProblemService:
|
||||
await session.flush()
|
||||
if not problem.enabled:
|
||||
raise ProblemUnavailable("problem disabled")
|
||||
revision = await repository.next_revision(session, problem.id)
|
||||
session.add(
|
||||
ProblemVersion(
|
||||
id=version_id,
|
||||
problem_id=problem.id,
|
||||
revision=revision,
|
||||
title=manifest.title,
|
||||
statement=statement,
|
||||
languages=[lang.value for lang in manifest.languages],
|
||||
judging_schema_version=manifest.schema_version,
|
||||
language_configurations=(
|
||||
None
|
||||
if legacy
|
||||
else [c.model_dump(mode="json") for c in manifest.language_configurations]
|
||||
),
|
||||
toolchain=manifest.toolchain.model_dump(mode="json") if legacy else None,
|
||||
compile_limits=manifest.compile_limits.model_dump(mode="json")
|
||||
if legacy
|
||||
else None,
|
||||
run_limits=manifest.run_limits.model_dump(mode="json") if legacy else None,
|
||||
job_wall_time_us=manifest.job_wall_time_us if legacy else None,
|
||||
comparison=manifest.comparison,
|
||||
public_samples=[s.model_dump() for s in samples],
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
# Consistent digest lock order avoids deadlocks between overlapping imports/jobs.
|
||||
unique = {
|
||||
ref.digest: (data, ref)
|
||||
@@ -149,20 +105,14 @@ class ProblemService:
|
||||
for digest in sorted(unique):
|
||||
data, reference = unique[digest]
|
||||
await self.artifacts.put(session, data, reference)
|
||||
for position, case in enumerate(cases):
|
||||
session.add(
|
||||
ProblemCase(
|
||||
id=case.case_id,
|
||||
version_id=version_id,
|
||||
position=position,
|
||||
input_digest=case.input.digest,
|
||||
expected_digest=case.expected.digest,
|
||||
await publish_version(
|
||||
session,
|
||||
problem,
|
||||
manifest,
|
||||
SimpleNamespace(statement=statement, samples=samples),
|
||||
version_id,
|
||||
cases,
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
version = await session.get(ProblemVersion, version_id)
|
||||
version.published_at = await session.scalar(select(func.now()))
|
||||
problem.active_version_id = version_id
|
||||
return version_id
|
||||
|
||||
@staticmethod
|
||||
@@ -213,7 +163,16 @@ class ProblemService:
|
||||
async def for_submission(self, session, version_id: UUID) -> JudgingVersion:
|
||||
if self.artifacts is None:
|
||||
raise ProblemUnavailable("artifact service unavailable")
|
||||
row = await repository.public_version(session, version_id)
|
||||
row = await repository.public_version(session, version_id, admission=True)
|
||||
return await self._judging_version(session, row)
|
||||
|
||||
async def for_accepted_execution(self, session, version_id: UUID) -> JudgingVersion:
|
||||
# Only WorkerAccess calls this after validating the current lease and its
|
||||
# immutable submission/attempt binding; this is never a public UUID lookup.
|
||||
row = await repository.published_version(session, version_id)
|
||||
return await self._judging_version(session, row)
|
||||
|
||||
async def _judging_version(self, session, row):
|
||||
if row is None:
|
||||
raise ProblemUnavailable("problem version unavailable")
|
||||
version, problem = row
|
||||
|
||||
@@ -43,3 +43,8 @@ CPU 为测试点运行 CPU 微秒之和,内存为测试点运行峰值字节
|
||||
用量与桶边界以十进制字符串传输;零合法、缺测不补零。严格优于数按原整数的 `>`
|
||||
计算,同值独立计数。响应至多24个连续桶,只有最后上界包含端点;浏览器仅格式化单位
|
||||
和绘制坐标,比例以服务端严格计数除以样本数,不按人数统计。
|
||||
|
||||
下架只阻止新提交与公开题目读取。Worker 的版本读取通过 `ProblemPort.for_accepted_execution`,
|
||||
在本模块验证当前 Coordinator 租约/尝试/源码绑定之后才调用;生成并比较原 canonical
|
||||
快照,再次核对租约后才释放字节。无有效租约、外来工件、未发布版本和其他提交仍拒绝。
|
||||
本人历史和源码读取保留所有权检查,不依赖题目当前 enabled 状态。
|
||||
|
||||
@@ -62,7 +62,9 @@ class WorkerAccess:
|
||||
)
|
||||
):
|
||||
raise WorkerAccessDenied("worker snapshot rejected")
|
||||
version = await self.problems.for_submission(session, manifest.problem_version_id)
|
||||
version = await self.problems.for_accepted_execution(
|
||||
session, manifest.problem_version_id
|
||||
)
|
||||
await self.artifacts.check_metadata(session, manifest.source)
|
||||
expected = version.manifest(
|
||||
manifest.evaluation_id, manifest.submission_id, manifest.language, manifest.source
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -40,6 +41,7 @@ def postgres_url():
|
||||
|
||||
|
||||
def migrate(url: str, command: str, target: str | None = None) -> None:
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "alembic", command] + ([target] if target else []),
|
||||
cwd=Path(__file__).resolve().parents[1],
|
||||
@@ -47,6 +49,12 @@ def migrate(url: str, command: str, target: str | None = None) -> None:
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as error:
|
||||
diagnostic = ((error.stdout or b"") + (error.stderr or b"")).decode(errors="replace")[
|
||||
-8192:
|
||||
]
|
||||
diagnostic = re.sub(r"postgresql(?:\+asyncpg)?://[^\s]+", "[test database URL]", diagnostic)
|
||||
raise AssertionError(f"test migration {command} failed: " + diagnostic) from None
|
||||
|
||||
|
||||
async def connect(url: str):
|
||||
|
||||
@@ -13,7 +13,7 @@ def test_migration_roundtrip_and_role_safety(postgres_url):
|
||||
c = await connect(postgres_url)
|
||||
try:
|
||||
tables = set(await c.fetch("SELECT tablename FROM pg_tables WHERE schemaname='public'"))
|
||||
assert len(tables) == 20 # Nineteen domain/control tables and Alembic history.
|
||||
assert len(tables) == 21 # Twenty domain/control tables and Alembic history.
|
||||
roles = await c.fetch("SELECT * FROM pg_roles WHERE rolname LIKE 'kf_oj_%'")
|
||||
assert {row["rolname"] for row in roles} == {
|
||||
"kf_oj_platform",
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
"""Real maintenance concurrency/SQL/HTTP acceptance, independent capped VM only."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import resource
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from conftest import connect
|
||||
from sqlalchemy.engine import make_url
|
||||
from test_admin_import_vm import independent_import_vm as independent_import_vm
|
||||
from test_admin_import_vm import package
|
||||
|
||||
from kf_oj.contracts.judging_v2 import AdmissionRegistry
|
||||
from kf_oj.modules.identity.passwords import hash_password
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_actual_publication_receipts_concurrency_audit_and_disable(
|
||||
independent_import_vm, postgres_url, tmp_path
|
||||
):
|
||||
groups = independent_import_vm
|
||||
policy = AdmissionRegistry.model_validate_json(
|
||||
(ROOT / "tests/fixtures/contracts-v2/judge-registry.valid.json").read_bytes()
|
||||
)
|
||||
base = {
|
||||
k: v
|
||||
for k, v in os.environ.items()
|
||||
if k
|
||||
in (
|
||||
"PATH",
|
||||
"PYTHONPATH",
|
||||
"LD_LIBRARY_PATH",
|
||||
"LC_ALL",
|
||||
"PYTHONUNBUFFERED",
|
||||
"PYTHONDONTWRITEBYTECODE",
|
||||
)
|
||||
}
|
||||
roles = {"api": "import_api_" + uuid4().hex, "worker": "import_worker_" + uuid4().hex}
|
||||
password = "Synthetic import acceptance only 2026!"
|
||||
users = {name: uuid4() for name in ("web_admin", "web_backup", "web_student")}
|
||||
processes = {}
|
||||
logs = {}
|
||||
artifact = tmp_path / "artifacts"
|
||||
staging = tmp_path / "staging"
|
||||
artifact.mkdir(mode=0o700)
|
||||
staging.mkdir(mode=0o700)
|
||||
|
||||
def enter(group):
|
||||
def apply():
|
||||
(group / "cgroup.procs").write_text(str(os.getpid()))
|
||||
resource.setrlimit(resource.RLIMIT_FSIZE, (67108864, 67108864))
|
||||
resource.setrlimit(resource.RLIMIT_NOFILE, (64, 64))
|
||||
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
|
||||
|
||||
return apply
|
||||
|
||||
def start(name, *, configured=True, pair=False):
|
||||
group = groups["api" if name == "api" else "import-worker"]
|
||||
env = base | {
|
||||
"KF_OJ_DATABASE_URL": make_url(postgres_url)
|
||||
.set(username=roles[name])
|
||||
.render_as_string(hide_password=False),
|
||||
"KF_OJ_ENVIRONMENT": "test",
|
||||
"KF_OJ_ARTIFACT_ROOT": str(artifact),
|
||||
"KF_OJ_IMPORT_STAGING_ROOT": str(staging),
|
||||
"KF_OJ_JUDGE_ADMISSION": policy.model_dump_json(),
|
||||
"KF_OJ_BROWSER_ORIGINS": '["http://127.0.0.1:58000"]',
|
||||
}
|
||||
if not configured:
|
||||
env.pop("KF_OJ_IMPORT_STAGING_ROOT")
|
||||
if name == "worker":
|
||||
env["KF_OJ_IMPORT_WORKER_CGROUP"] = str(group)
|
||||
command = (
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"uvicorn",
|
||||
"kf_oj.api.app:create_app",
|
||||
"--factory",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"58000",
|
||||
"--no-proxy-headers",
|
||||
"--limit-concurrency",
|
||||
"4",
|
||||
"--backlog",
|
||||
"16",
|
||||
]
|
||||
if name == "api"
|
||||
else [sys.executable, "-m", "kf_oj.workers.problem_import"]
|
||||
)
|
||||
if pair:
|
||||
assert name == "worker"
|
||||
# Trusted fixture composition; both coroutines call the actual ordinary Worker.
|
||||
code = """
|
||||
import asyncio
|
||||
from kf_oj.core.settings import Settings
|
||||
from kf_oj.infra.db.session import build_engine,build_session_factory
|
||||
from kf_oj.infra.artifacts.store import ArtifactStore
|
||||
from kf_oj.infra.artifacts.staging import ImportStaging
|
||||
from kf_oj.infra.artifacts.repository import ArtifactRepository
|
||||
from kf_oj.modules.identity.service import ReservedMaintenance
|
||||
from kf_oj.modules.operations.service import MaintenanceAudit
|
||||
from kf_oj.modules.problems.import_worker import ImportWorker
|
||||
async def run():
|
||||
s=Settings();e=build_engine(s);store=ArtifactStore(s.artifact_root)
|
||||
staging=ImportStaging(s.import_staging_root)
|
||||
try:
|
||||
w=ImportWorker(build_session_factory(e),staging,ArtifactRepository(store),
|
||||
ReservedMaintenance(),MaintenanceAudit(),s.judge_admission,s.import_worker_cgroup)
|
||||
results=await asyncio.gather(w.once(),w.once())
|
||||
assert sorted(results)==[False,True],results
|
||||
print('ACTUAL_CONCURRENT_IMPORT_CLAIM_ACCEPTED',flush=True)
|
||||
finally:
|
||||
staging.close();store.close();await e.dispose()
|
||||
asyncio.run(run())
|
||||
"""
|
||||
command = [sys.executable, "-c", code]
|
||||
log = (tmp_path / f"{name}.log").open("a")
|
||||
logs[name] = log
|
||||
processes[name] = subprocess.Popen(
|
||||
command, env=env, stdout=log, stderr=log, preexec_fn=enter(group)
|
||||
)
|
||||
|
||||
def stop(name):
|
||||
p = processes.pop(name, None)
|
||||
if p and p.poll() is None:
|
||||
p.terminate()
|
||||
try:
|
||||
p.wait(timeout=20)
|
||||
except subprocess.TimeoutExpired:
|
||||
p.kill()
|
||||
p.wait(timeout=10)
|
||||
if name in logs:
|
||||
logs.pop(name).close()
|
||||
|
||||
async def run():
|
||||
c = await connect(postgres_url)
|
||||
try:
|
||||
for name, group in (("api", "kf_oj_platform"), ("worker", "kf_oj_problem_import")):
|
||||
await c.execute(
|
||||
f"CREATE ROLE {roles[name]} LOGIN NOSUPERUSER NOCREATEDB "
|
||||
"NOCREATEROLE NOREPLICATION NOBYPASSRLS"
|
||||
)
|
||||
await c.execute(f"GRANT {group} TO {roles[name]}")
|
||||
for name, identifier in users.items():
|
||||
await c.execute(
|
||||
"INSERT INTO accounts(id,username,password_hash) VALUES($1,$2,$3)",
|
||||
identifier,
|
||||
name,
|
||||
hash_password(name, password),
|
||||
)
|
||||
await c.execute("SELECT * FROM kf_manage_account_authority('web_admin','bootstrap')")
|
||||
await c.execute("SELECT * FROM kf_manage_account_authority('web_backup','grant')")
|
||||
start("api")
|
||||
start("worker")
|
||||
async with httpx.AsyncClient(base_url="http://127.0.0.1:58000", timeout=45) as client:
|
||||
deadline = time.monotonic() + 180
|
||||
while time.monotonic() < deadline:
|
||||
assert processes["api"].poll() is None, (tmp_path / "api.log").read_text()[
|
||||
-2000:
|
||||
]
|
||||
assert processes["worker"].poll() is None, (
|
||||
tmp_path / "worker.log"
|
||||
).read_text()[-2000:]
|
||||
try:
|
||||
if (
|
||||
await client.get("/api/v1/health/live")
|
||||
).status_code == 200 and await c.fetchval(
|
||||
"SELECT worker_ready_until>clock_timestamp() "
|
||||
"FROM problem_import_pool WHERE id=1"
|
||||
):
|
||||
break
|
||||
except httpx.ConnectError:
|
||||
pass
|
||||
await asyncio.sleep(0.1)
|
||||
else:
|
||||
raise AssertionError("real service startup deadline")
|
||||
admin = "/api/v1/admin"
|
||||
imports = admin + "/problem-imports"
|
||||
assert (await client.get(admin + "/problems")).status_code == 401
|
||||
|
||||
async def login(name):
|
||||
client.cookies.clear()
|
||||
r = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": name, "password": password},
|
||||
headers={"Origin": "http://127.0.0.1:58000"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return {
|
||||
"Origin": "http://127.0.0.1:58000",
|
||||
"X-CSRF-Token": client.cookies["kf_csrf"],
|
||||
}
|
||||
|
||||
headers = await login("web_student")
|
||||
for path in (
|
||||
"/problems",
|
||||
"/maintenance-audit",
|
||||
"/problem-maintenance/" + str(uuid4()),
|
||||
):
|
||||
assert (await client.get(admin + path)).status_code == 403
|
||||
headers = await login("web_admin")
|
||||
|
||||
async def ready(slug, *, multi=False):
|
||||
blob, payload = package(
|
||||
policy, multi=multi, mutation=lambda m: m.update(slug=slug)
|
||||
)
|
||||
key = str(uuid4())
|
||||
body = {
|
||||
"operation_id": key,
|
||||
"archive_digest": hashlib.sha256(blob).hexdigest(),
|
||||
"archive_bytes": len(blob),
|
||||
}
|
||||
end = time.monotonic() + 20
|
||||
while True:
|
||||
r = await client.post(imports, json=body, headers=headers)
|
||||
if r.status_code != 503 or time.monotonic() >= end:
|
||||
break
|
||||
await asyncio.sleep(0.2)
|
||||
assert r.status_code == 201, r.text
|
||||
end = time.monotonic() + 30
|
||||
while True:
|
||||
r = await client.put(
|
||||
imports + "/" + key + "/archive",
|
||||
content=blob,
|
||||
headers=headers | {"Content-Type": "application/zip"},
|
||||
)
|
||||
if r.status_code != 503 or time.monotonic() >= end:
|
||||
break
|
||||
await asyncio.sleep(0.2)
|
||||
assert r.status_code == 200, r.text
|
||||
end = time.monotonic() + 120
|
||||
while time.monotonic() < end:
|
||||
r = await client.get(imports + "/" + key)
|
||||
assert r.status_code == 200, r.text
|
||||
if r.json()["state"] == "ready":
|
||||
return r.json(), payload
|
||||
assert r.json()["state"] != "failed", r.text
|
||||
await asyncio.sleep(0.2)
|
||||
raise AssertionError("candidate deadline")
|
||||
|
||||
async def publish(candidate, expected=None, key=None):
|
||||
body = {
|
||||
"operation_id": str(key or uuid4()),
|
||||
"import_operation_id": candidate["operation_id"],
|
||||
"candidate_id": candidate["candidate_id"],
|
||||
"expected_active_version_id": expected,
|
||||
}
|
||||
r = await client.post(
|
||||
admin + "/problem-publications", json=body, headers=headers
|
||||
)
|
||||
return r, body
|
||||
|
||||
first, payload = await ready("managed-synthetic")
|
||||
for invalid in ({}, headers | {"Origin": "https://invalid.example"}):
|
||||
r = await client.post(
|
||||
admin + "/problem-publications",
|
||||
json={
|
||||
"operation_id": str(uuid4()),
|
||||
"import_operation_id": first["operation_id"],
|
||||
"candidate_id": first["candidate_id"],
|
||||
"expected_active_version_id": None,
|
||||
},
|
||||
headers=invalid,
|
||||
)
|
||||
assert r.status_code == 403
|
||||
r, body = await publish(first)
|
||||
assert r.status_code == 201, r.text
|
||||
receipt = r.json()
|
||||
pid = receipt["problem"]["id"]
|
||||
vid = receipt["published_version_id"]
|
||||
assert receipt["problem"]["revision"] == 1
|
||||
assert (
|
||||
await client.post(admin + "/problem-publications", json=body, headers=headers)
|
||||
).json() == receipt
|
||||
assert (
|
||||
await client.get(admin + "/problem-maintenance/" + body["operation_id"])
|
||||
).json() == receipt
|
||||
assert (
|
||||
await client.post(
|
||||
admin + "/problem-publications",
|
||||
json=body | {"expected_active_version_id": vid},
|
||||
headers=headers,
|
||||
)
|
||||
).status_code == 409
|
||||
assert (await client.get(imports + "/" + first["operation_id"])).json()[
|
||||
"state"
|
||||
] == "published"
|
||||
public = await client.get("/api/v1/problems/versions/" + vid)
|
||||
assert public.status_code == 200, public.text
|
||||
assert public.json()["statement"] == "Public statement only"
|
||||
assert "digest" not in public.text and "hidden" not in public.text
|
||||
old = await c.fetchrow("SELECT * FROM problem_versions WHERE id=$1", UUID(vid))
|
||||
second, _ = await ready("managed-synthetic")
|
||||
third, _ = await ready("managed-synthetic")
|
||||
b1 = {
|
||||
"operation_id": str(uuid4()),
|
||||
"import_operation_id": second["operation_id"],
|
||||
"candidate_id": second["candidate_id"],
|
||||
"expected_active_version_id": vid,
|
||||
}
|
||||
b2 = b1 | {
|
||||
"operation_id": str(uuid4()),
|
||||
"import_operation_id": third["operation_id"],
|
||||
"candidate_id": third["candidate_id"],
|
||||
}
|
||||
pair = await asyncio.gather(
|
||||
*(
|
||||
client.post(admin + "/problem-publications", json=b, headers=headers)
|
||||
for b in (b1, b2)
|
||||
)
|
||||
)
|
||||
assert sorted(r.status_code for r in pair) == [201, 409], [
|
||||
(r.status_code, r.text) for r in pair
|
||||
]
|
||||
latest = next(r.json() for r in pair if r.status_code == 201)
|
||||
active = latest["published_version_id"]
|
||||
assert latest["problem"]["revision"] == 2
|
||||
assert (
|
||||
await c.fetchval(
|
||||
"SELECT count(*) FROM problem_versions WHERE problem_id=$1", UUID(pid)
|
||||
)
|
||||
== 2
|
||||
)
|
||||
assert dict(
|
||||
await c.fetchrow("SELECT * FROM problem_versions WHERE id=$1", UUID(vid))
|
||||
) == dict(old)
|
||||
# The local CLI uses the same publication/admission service; rejected
|
||||
# toolchains do not create rows, and successful v1/v2 keep exact bytes.
|
||||
local = tmp_path / "cli"
|
||||
local.mkdir(mode=0o700)
|
||||
for name, data in payload.items():
|
||||
(local / name).write_bytes(data)
|
||||
env = base | {
|
||||
"KF_OJ_DATABASE_URL": make_url(postgres_url)
|
||||
.set(username=roles["api"])
|
||||
.render_as_string(hide_password=False),
|
||||
"KF_OJ_ARTIFACT_ROOT": str(artifact),
|
||||
"KF_OJ_JUDGE_ADMISSION": policy.model_dump_json(),
|
||||
}
|
||||
cli = await asyncio.to_thread(
|
||||
subprocess.run,
|
||||
[sys.executable, "-m", "kf_oj.modules.problems.cli", str(local)],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
timeout=90,
|
||||
)
|
||||
assert cli.returncode == 0, cli.stderr.decode()[-1500:]
|
||||
active = str(
|
||||
await c.fetchval(
|
||||
"SELECT active_version_id FROM problems WHERE id=$1", UUID(pid)
|
||||
)
|
||||
)
|
||||
assert (
|
||||
await c.fetchval(
|
||||
"SELECT max(revision) FROM problem_versions WHERE problem_id=$1", UUID(pid)
|
||||
)
|
||||
== 3
|
||||
)
|
||||
# Disable is preconditioned and idempotent; new admission and public
|
||||
# reads close, while immutable accepted history remains authorized.
|
||||
toggle = {
|
||||
"operation_id": str(uuid4()),
|
||||
"expected_active_version_id": active,
|
||||
"expected_enabled": True,
|
||||
}
|
||||
r = await client.post(
|
||||
admin + f"/problems/{pid}/disable", json=toggle, headers=headers
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert not r.json()["problem"]["enabled"]
|
||||
assert (
|
||||
await client.post(
|
||||
admin + f"/problems/{pid}/disable", json=toggle, headers=headers
|
||||
)
|
||||
).json() == r.json()
|
||||
assert (await client.get("/api/v1/problems/versions/" + vid)).status_code == 404
|
||||
assert not (await client.get("/api/v1/problems")).json()["items"]
|
||||
headers = await login("web_student")
|
||||
submission = {
|
||||
"problem_version_id": active,
|
||||
"language": "c17",
|
||||
"source": "synthetic source, never executed",
|
||||
"idempotency_key": str(uuid4()),
|
||||
}
|
||||
assert (
|
||||
await client.post("/api/v1/submissions", json=submission, headers=headers)
|
||||
).status_code == 404
|
||||
headers = await login("web_admin")
|
||||
cli = await asyncio.to_thread(
|
||||
subprocess.run,
|
||||
[sys.executable, "-m", "kf_oj.modules.problems.cli", str(local)],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
timeout=90,
|
||||
)
|
||||
assert cli.returncode != 0
|
||||
restore = {
|
||||
"operation_id": str(uuid4()),
|
||||
"expected_active_version_id": active,
|
||||
"expected_enabled": False,
|
||||
}
|
||||
r = await client.post(
|
||||
admin + f"/problems/{pid}/restore", json=restore, headers=headers
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["problem"]["enabled"]
|
||||
history = (await client.get(admin + f"/problems/{pid}/versions?limit=1")).json()
|
||||
assert history["items"][0]["revision"] == 3 and history["next_before"] == 3
|
||||
history2 = (
|
||||
await client.get(admin + f"/problems/{pid}/versions?limit=1&before=3")
|
||||
).json()
|
||||
assert history2["items"][0]["revision"] == 2
|
||||
assert "statement" not in json.dumps(history) and "digest" not in json.dumps(
|
||||
history
|
||||
)
|
||||
# Inject a transaction-local audit failure through a trusted test
|
||||
# DB trigger; no version/active pointer/import receipt survives rollback.
|
||||
rollback, _ = await ready("rollback-synthetic")
|
||||
await c.execute(
|
||||
"""CREATE FUNCTION reject_publish_audit() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN IF NEW.action='published' THEN
|
||||
RAISE EXCEPTION 'synthetic audit failure'; END IF; RETURN NEW; END $$"""
|
||||
)
|
||||
await c.execute(
|
||||
"CREATE TRIGGER reject_publish_audit BEFORE INSERT "
|
||||
"ON problem_maintenance_events "
|
||||
"FOR EACH ROW EXECUTE FUNCTION reject_publish_audit()"
|
||||
)
|
||||
r, rb = await publish(rollback)
|
||||
assert r.status_code == 503, r.text
|
||||
assert not await c.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM problems WHERE slug='rollback-synthetic')"
|
||||
)
|
||||
assert not await c.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM problem_maintenance_operations WHERE id=$1)",
|
||||
UUID(rb["operation_id"]),
|
||||
)
|
||||
assert (
|
||||
await c.fetchval(
|
||||
"SELECT state FROM problem_import_operations WHERE id=$1",
|
||||
UUID(rollback["operation_id"]),
|
||||
)
|
||||
== "ready"
|
||||
)
|
||||
await c.execute("DROP TRIGGER reject_publish_audit ON problem_maintenance_events")
|
||||
await c.execute("DROP FUNCTION reject_publish_audit()")
|
||||
r = await client.post(admin + "/problem-publications", json=rb, headers=headers)
|
||||
assert r.status_code == 201, r.text
|
||||
multi, _ = await ready("managed-multi", multi=True)
|
||||
r, _ = await publish(multi)
|
||||
assert r.status_code == 201, r.text
|
||||
assert (
|
||||
len(
|
||||
(
|
||||
await client.get(
|
||||
"/api/v1/problems/versions/" + r.json()["published_version_id"]
|
||||
)
|
||||
).json()["language_configurations"]
|
||||
)
|
||||
== 4
|
||||
)
|
||||
# Live foreign administrator cannot adopt another actor's candidate or receipt.
|
||||
headers = await login("web_backup")
|
||||
assert (
|
||||
await client.get(admin + "/problem-maintenance/" + body["operation_id"])
|
||||
).status_code == 404
|
||||
assert (
|
||||
await client.post(admin + "/problem-publications", json=body, headers=headers)
|
||||
).status_code == 404
|
||||
headers = await login("web_admin")
|
||||
audit = (await client.get(admin + "/maintenance-audit?limit=100")).json()["items"]
|
||||
assert any(
|
||||
e["action"] == "authority_bootstrap"
|
||||
and e["actor_kind"] == "operator"
|
||||
and e["actor_id"] is None
|
||||
for e in audit
|
||||
)
|
||||
assert (
|
||||
sum(
|
||||
e["operation_id"] == body["operation_id"] and e["action"] == "published"
|
||||
for e in audit
|
||||
)
|
||||
== 1
|
||||
)
|
||||
assert "hidden" not in json.dumps(audit) and "password" not in json.dumps(audit)
|
||||
# Finite page traversal excludes duplicates and has precise committed SQL count.
|
||||
seen = set()
|
||||
cursor = None
|
||||
for _ in range(100):
|
||||
r = await client.get(
|
||||
admin + "/maintenance-audit",
|
||||
params={"limit": 2} | ({"before": cursor} if cursor else {}),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
page = r.json()
|
||||
ids = {e["id"] for e in page["items"]}
|
||||
assert not seen & ids
|
||||
seen |= ids
|
||||
cursor = page["next_before"]
|
||||
if cursor is None:
|
||||
break
|
||||
else:
|
||||
raise AssertionError("audit page bound")
|
||||
assert len(seen) == await c.fetchval(
|
||||
"SELECT count(*) FROM problem_maintenance_events"
|
||||
)
|
||||
for role in ("kf_oj_platform", "kf_oj_problem_import", "kf_oj_coordinator"):
|
||||
await c.execute("SET ROLE " + role)
|
||||
for table in ("problem_maintenance_operations", "problem_maintenance_events"):
|
||||
for command in (f"UPDATE {table} SET id=id", f"DELETE FROM {table}"):
|
||||
with pytest.raises(Exception) as error:
|
||||
await c.execute(command)
|
||||
assert getattr(error.value, "sqlstate", None) == "42501"
|
||||
await c.execute("RESET ROLE")
|
||||
with pytest.raises(Exception) as error:
|
||||
await c.execute(
|
||||
"UPDATE problem_versions SET title=title WHERE id=$1", UUID(vid)
|
||||
)
|
||||
assert getattr(error.value, "sqlstate", None) == "23514"
|
||||
concurrent, _ = await ready("revocation-synthetic")
|
||||
revoke_body = {
|
||||
"operation_id": str(uuid4()),
|
||||
"import_operation_id": concurrent["operation_id"],
|
||||
"candidate_id": concurrent["candidate_id"],
|
||||
"expected_active_version_id": None,
|
||||
}
|
||||
published_result, _ = await asyncio.gather(
|
||||
client.post(admin + "/problem-publications", json=revoke_body, headers=headers),
|
||||
c.execute("SELECT * FROM kf_manage_account_authority('web_admin','revoke')"),
|
||||
)
|
||||
assert published_result.status_code in (201, 401, 403, 503), published_result.text
|
||||
committed = await c.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM problem_maintenance_operations WHERE id=$1)",
|
||||
UUID(revoke_body["operation_id"]),
|
||||
)
|
||||
assert committed == (published_result.status_code == 201)
|
||||
assert (
|
||||
await c.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM problems WHERE slug='revocation-synthetic')"
|
||||
)
|
||||
== committed
|
||||
)
|
||||
assert await c.fetchval(
|
||||
"SELECT count(*) FROM problem_maintenance_events "
|
||||
"WHERE operation_id=$1 AND action='published'",
|
||||
UUID(revoke_body["operation_id"]),
|
||||
) == int(committed)
|
||||
assert (await client.get(admin + "/problems")).status_code == 401
|
||||
assert (
|
||||
await client.post(admin + "/problem-publications", json=body, headers=headers)
|
||||
).status_code == 401
|
||||
print(
|
||||
"ACTUAL_MAINTENANCE_CLI_V1_V2_UUID_CONCURRENCY_AUDIT_ROLLBACK_DISABLE_ACCEPTED",
|
||||
flush=True,
|
||||
)
|
||||
finally:
|
||||
stop("api")
|
||||
stop("worker")
|
||||
for role in roles.values():
|
||||
await c.execute("DROP OWNED BY " + role)
|
||||
await c.execute("DROP ROLE " + role)
|
||||
await c.close()
|
||||
|
||||
asyncio.run(run())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"$id": "urn:kf-oj:problem-availability:v1",
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"expected_active_version_id": {
|
||||
"format": "uuid",
|
||||
"title": "Expected Active Version Id",
|
||||
"type": "string"
|
||||
},
|
||||
"expected_enabled": {
|
||||
"title": "Expected Enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"operation_id": {
|
||||
"format": "uuid",
|
||||
"title": "Operation Id",
|
||||
"type": "string"
|
||||
},
|
||||
"schema_version": {
|
||||
"const": 1,
|
||||
"default": 1,
|
||||
"title": "Schema Version",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"operation_id",
|
||||
"expected_active_version_id",
|
||||
"expected_enabled"
|
||||
],
|
||||
"title": "ChangeProblemAvailability",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
{
|
||||
"$defs": {
|
||||
"MaintenanceAuditEvent": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"action": {
|
||||
"enum": [
|
||||
"upload_created",
|
||||
"upload_received",
|
||||
"import_ready",
|
||||
"import_failed",
|
||||
"import_discarded",
|
||||
"import_expired",
|
||||
"published",
|
||||
"disabled",
|
||||
"restored",
|
||||
"authority_bootstrap",
|
||||
"authority_grant",
|
||||
"authority_revoke",
|
||||
"authority_enable",
|
||||
"authority_disable"
|
||||
],
|
||||
"title": "Action",
|
||||
"type": "string"
|
||||
},
|
||||
"actor_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Actor Id"
|
||||
},
|
||||
"actor_kind": {
|
||||
"enum": [
|
||||
"account",
|
||||
"operator"
|
||||
],
|
||||
"title": "Actor Kind",
|
||||
"type": "string"
|
||||
},
|
||||
"authorization_version": {
|
||||
"pattern": "^[1-9][0-9]{0,18}$",
|
||||
"title": "Authorization Version",
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"format": "date-time",
|
||||
"title": "Created At",
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"format": "uuid",
|
||||
"title": "Id",
|
||||
"type": "string"
|
||||
},
|
||||
"operation_id": {
|
||||
"format": "uuid",
|
||||
"title": "Operation Id",
|
||||
"type": "string"
|
||||
},
|
||||
"operator_name": {
|
||||
"anyOf": [
|
||||
{
|
||||
"maxLength": 63,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Operator Name"
|
||||
},
|
||||
"result": {
|
||||
"const": "committed",
|
||||
"default": "committed",
|
||||
"title": "Result",
|
||||
"type": "string"
|
||||
},
|
||||
"target_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Target Id"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"created_at",
|
||||
"actor_kind",
|
||||
"actor_id",
|
||||
"operator_name",
|
||||
"authorization_version",
|
||||
"operation_id",
|
||||
"action",
|
||||
"target_id"
|
||||
],
|
||||
"title": "MaintenanceAuditEvent",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"$id": "urn:kf-oj:problem-maintenance-audit:v1",
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"items": {
|
||||
"items": {
|
||||
"$ref": "#/$defs/MaintenanceAuditEvent"
|
||||
},
|
||||
"maxItems": 100,
|
||||
"title": "Items",
|
||||
"type": "array"
|
||||
},
|
||||
"next_before": {
|
||||
"anyOf": [
|
||||
{
|
||||
"maxLength": 100,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Next Before"
|
||||
},
|
||||
"schema_version": {
|
||||
"const": 1,
|
||||
"default": 1,
|
||||
"title": "Schema Version",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"items",
|
||||
"next_before"
|
||||
],
|
||||
"title": "MaintenanceAuditPage",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"$defs": {
|
||||
"MaintainedProblem": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"active_version_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Active Version Id"
|
||||
},
|
||||
"created_at": {
|
||||
"format": "date-time",
|
||||
"title": "Created At",
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"title": "Enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"format": "uuid",
|
||||
"title": "Id",
|
||||
"type": "string"
|
||||
},
|
||||
"revision": {
|
||||
"minimum": 0,
|
||||
"title": "Revision",
|
||||
"type": "integer"
|
||||
},
|
||||
"slug": {
|
||||
"pattern": "^[a-z0-9][a-z0-9_-]{0,63}$",
|
||||
"title": "Slug",
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [
|
||||
{
|
||||
"maxLength": 160,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Title"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"slug",
|
||||
"enabled",
|
||||
"active_version_id",
|
||||
"revision",
|
||||
"title",
|
||||
"created_at"
|
||||
],
|
||||
"title": "MaintainedProblem",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"$id": "urn:kf-oj:problem-maintenance:v1",
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"action": {
|
||||
"enum": [
|
||||
"publish",
|
||||
"disable",
|
||||
"restore"
|
||||
],
|
||||
"title": "Action",
|
||||
"type": "string"
|
||||
},
|
||||
"completed_at": {
|
||||
"format": "date-time",
|
||||
"title": "Completed At",
|
||||
"type": "string"
|
||||
},
|
||||
"operation_id": {
|
||||
"format": "uuid",
|
||||
"title": "Operation Id",
|
||||
"type": "string"
|
||||
},
|
||||
"problem": {
|
||||
"$ref": "#/$defs/MaintainedProblem"
|
||||
},
|
||||
"published_version_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Published Version Id"
|
||||
},
|
||||
"schema_version": {
|
||||
"const": 1,
|
||||
"default": 1,
|
||||
"title": "Schema Version",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"operation_id",
|
||||
"action",
|
||||
"completed_at",
|
||||
"problem"
|
||||
],
|
||||
"title": "MaintenanceReceipt",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"$id": "urn:kf-oj:problem-publish:v1",
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"candidate_id": {
|
||||
"format": "uuid",
|
||||
"title": "Candidate Id",
|
||||
"type": "string"
|
||||
},
|
||||
"expected_active_version_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Expected Active Version Id"
|
||||
},
|
||||
"import_operation_id": {
|
||||
"format": "uuid",
|
||||
"title": "Import Operation Id",
|
||||
"type": "string"
|
||||
},
|
||||
"operation_id": {
|
||||
"format": "uuid",
|
||||
"title": "Operation Id",
|
||||
"type": "string"
|
||||
},
|
||||
"schema_version": {
|
||||
"const": 1,
|
||||
"default": 1,
|
||||
"title": "Schema Version",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"operation_id",
|
||||
"import_operation_id",
|
||||
"candidate_id",
|
||||
"expected_active_version_id"
|
||||
],
|
||||
"title": "PublishCandidate",
|
||||
"type": "object"
|
||||
}
|
||||
+655
@@ -4,6 +4,23 @@
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
"/api/v1/admin/maintenance-audit": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Audit */
|
||||
get: operations["audit_api_v1_admin_maintenance_audit_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/problem-imports": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -90,6 +107,142 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/problem-maintenance/{operation_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Receipt */
|
||||
get: operations["receipt_api_v1_admin_problem_maintenance__operation_id__get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/problem-publications": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Publish */
|
||||
post: operations["publish_api_v1_admin_problem_publications_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/problem-slugs/{slug}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** By Slug */
|
||||
get: operations["by_slug_api_v1_admin_problem_slugs__slug__get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/problems": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Page */
|
||||
get: operations["page_api_v1_admin_problems_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/problems/{problem_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Detail */
|
||||
get: operations["detail_api_v1_admin_problems__problem_id__get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/problems/{problem_id}/disable": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Disable */
|
||||
post: operations["disable_api_v1_admin_problems__problem_id__disable_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/problems/{problem_id}/restore": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Restore */
|
||||
post: operations["restore_api_v1_admin_problems__problem_id__restore_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/problems/{problem_id}/versions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Versions */
|
||||
get: operations["versions_api_v1_admin_problems__problem_id__versions_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/auth/login": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -392,6 +545,27 @@ export interface components {
|
||||
/** Title */
|
||||
title: string;
|
||||
};
|
||||
/** ChangeProblemAvailability */
|
||||
ChangeProblemAvailability: {
|
||||
/**
|
||||
* Expected Active Version Id
|
||||
* Format: uuid
|
||||
*/
|
||||
expected_active_version_id: string;
|
||||
/** Expected Enabled */
|
||||
expected_enabled: boolean;
|
||||
/**
|
||||
* Operation Id
|
||||
* Format: uuid
|
||||
*/
|
||||
operation_id: string;
|
||||
/**
|
||||
* Schema Version
|
||||
* @default 1
|
||||
* @constant
|
||||
*/
|
||||
schema_version: 1;
|
||||
};
|
||||
/**
|
||||
* CompilationStatus
|
||||
* @enum {string}
|
||||
@@ -552,6 +726,165 @@ export interface components {
|
||||
expires_at: string;
|
||||
user: components["schemas"]["CurrentUser"];
|
||||
};
|
||||
/** MaintainedProblem */
|
||||
MaintainedProblem: {
|
||||
/** Active Version Id */
|
||||
active_version_id: string | null;
|
||||
/**
|
||||
* Created At
|
||||
* Format: date-time
|
||||
*/
|
||||
created_at: string;
|
||||
/** Enabled */
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Id
|
||||
* Format: uuid
|
||||
*/
|
||||
id: string;
|
||||
/** Revision */
|
||||
revision: number;
|
||||
/** Slug */
|
||||
slug: string;
|
||||
/** Title */
|
||||
title: string | null;
|
||||
};
|
||||
/** MaintainedProblemPage */
|
||||
MaintainedProblemPage: {
|
||||
/** Items */
|
||||
items: components["schemas"]["MaintainedProblem"][];
|
||||
/** Next After */
|
||||
next_after: string | null;
|
||||
/**
|
||||
* Schema Version
|
||||
* @default 1
|
||||
* @constant
|
||||
*/
|
||||
schema_version: 1;
|
||||
};
|
||||
/** MaintainedVersion */
|
||||
MaintainedVersion: {
|
||||
/** Case Count */
|
||||
case_count: number;
|
||||
/**
|
||||
* Id
|
||||
* Format: uuid
|
||||
*/
|
||||
id: string;
|
||||
/** Languages */
|
||||
languages: components["schemas"]["kf_oj__contracts__judging_v2__Language"][];
|
||||
/**
|
||||
* Published At
|
||||
* Format: date-time
|
||||
*/
|
||||
published_at: string;
|
||||
/** Revision */
|
||||
revision: number;
|
||||
/** Slug */
|
||||
slug: string;
|
||||
/** Title */
|
||||
title: string;
|
||||
/**
|
||||
* Version Id
|
||||
* Format: uuid
|
||||
*/
|
||||
version_id: string;
|
||||
};
|
||||
/** MaintainedVersionPage */
|
||||
MaintainedVersionPage: {
|
||||
/** Items */
|
||||
items: components["schemas"]["MaintainedVersion"][];
|
||||
/** Next Before */
|
||||
next_before: number | null;
|
||||
/**
|
||||
* Schema Version
|
||||
* @default 1
|
||||
* @constant
|
||||
*/
|
||||
schema_version: 1;
|
||||
};
|
||||
/** MaintenanceAuditEvent */
|
||||
MaintenanceAuditEvent: {
|
||||
/**
|
||||
* Action
|
||||
* @enum {string}
|
||||
*/
|
||||
action: "upload_created" | "upload_received" | "import_ready" | "import_failed" | "import_discarded" | "import_expired" | "published" | "disabled" | "restored" | "authority_bootstrap" | "authority_grant" | "authority_revoke" | "authority_enable" | "authority_disable";
|
||||
/** Actor Id */
|
||||
actor_id: string | null;
|
||||
/**
|
||||
* Actor Kind
|
||||
* @enum {string}
|
||||
*/
|
||||
actor_kind: "account" | "operator";
|
||||
/** Authorization Version */
|
||||
authorization_version: string;
|
||||
/**
|
||||
* Created At
|
||||
* Format: date-time
|
||||
*/
|
||||
created_at: string;
|
||||
/**
|
||||
* Id
|
||||
* Format: uuid
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Operation Id
|
||||
* Format: uuid
|
||||
*/
|
||||
operation_id: string;
|
||||
/** Operator Name */
|
||||
operator_name: string | null;
|
||||
/**
|
||||
* Result
|
||||
* @default committed
|
||||
* @constant
|
||||
*/
|
||||
result: "committed";
|
||||
/** Target Id */
|
||||
target_id: string | null;
|
||||
};
|
||||
/** MaintenanceAuditPage */
|
||||
MaintenanceAuditPage: {
|
||||
/** Items */
|
||||
items: components["schemas"]["MaintenanceAuditEvent"][];
|
||||
/** Next Before */
|
||||
next_before: string | null;
|
||||
/**
|
||||
* Schema Version
|
||||
* @default 1
|
||||
* @constant
|
||||
*/
|
||||
schema_version: 1;
|
||||
};
|
||||
/** MaintenanceReceipt */
|
||||
MaintenanceReceipt: {
|
||||
/**
|
||||
* Action
|
||||
* @enum {string}
|
||||
*/
|
||||
action: "publish" | "disable" | "restore";
|
||||
/**
|
||||
* Completed At
|
||||
* Format: date-time
|
||||
*/
|
||||
completed_at: string;
|
||||
/**
|
||||
* Operation Id
|
||||
* Format: uuid
|
||||
*/
|
||||
operation_id: string;
|
||||
problem: components["schemas"]["MaintainedProblem"];
|
||||
/** Published Version Id */
|
||||
published_version_id?: string | null;
|
||||
/**
|
||||
* Schema Version
|
||||
* @default 1
|
||||
* @constant
|
||||
*/
|
||||
schema_version: 1;
|
||||
};
|
||||
/** PerformanceBucket */
|
||||
PerformanceBucket: {
|
||||
/** Count */
|
||||
@@ -955,6 +1288,32 @@ export interface components {
|
||||
/** Output */
|
||||
output: string;
|
||||
};
|
||||
/** PublishCandidate */
|
||||
PublishCandidate: {
|
||||
/**
|
||||
* Candidate Id
|
||||
* Format: uuid
|
||||
*/
|
||||
candidate_id: string;
|
||||
/** Expected Active Version Id */
|
||||
expected_active_version_id: string | null;
|
||||
/**
|
||||
* Import Operation Id
|
||||
* Format: uuid
|
||||
*/
|
||||
import_operation_id: string;
|
||||
/**
|
||||
* Operation Id
|
||||
* Format: uuid
|
||||
*/
|
||||
operation_id: string;
|
||||
/**
|
||||
* Schema Version
|
||||
* @default 1
|
||||
* @constant
|
||||
*/
|
||||
schema_version: 1;
|
||||
};
|
||||
/** RunLimits */
|
||||
RunLimits: {
|
||||
/** Cpu Time Us */
|
||||
@@ -1208,6 +1567,39 @@ export interface components {
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
audit_api_v1_admin_maintenance_audit_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
before?: string | null;
|
||||
limit?: number;
|
||||
action?: ("upload_created" | "upload_received" | "import_ready" | "import_failed" | "import_discarded" | "import_expired" | "published" | "disabled" | "restored" | "authority_bootstrap" | "authority_grant" | "authority_revoke" | "authority_enable" | "authority_disable") | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MaintenanceAuditPage"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
page_api_v1_admin_problem_imports_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -1390,6 +1782,269 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
receipt_api_v1_admin_problem_maintenance__operation_id__get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
operation_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MaintenanceReceipt"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
publish_api_v1_admin_problem_publications_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["PublishCandidate"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MaintenanceReceipt"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
by_slug_api_v1_admin_problem_slugs__slug__get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
slug: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MaintainedProblem"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
page_api_v1_admin_problems_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
after?: string | null;
|
||||
limit?: number;
|
||||
enabled?: boolean | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MaintainedProblemPage"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
detail_api_v1_admin_problems__problem_id__get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
problem_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MaintainedProblem"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
disable_api_v1_admin_problems__problem_id__disable_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
problem_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ChangeProblemAvailability"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MaintenanceReceipt"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
restore_api_v1_admin_problems__problem_id__restore_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
problem_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ChangeProblemAvailability"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MaintenanceReceipt"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
versions_api_v1_admin_problems__problem_id__versions_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
before?: number | null;
|
||||
limit?: number;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
problem_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MaintainedVersionPage"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
login_api_v1_auth_login_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
Reference in New Issue
Block a user