"""Admin review endpoints. Collection runs only from collect.py.""" from datetime import date, datetime from typing import Literal from urllib.parse import urlparse import re from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from auth import require_admin from models import Company, ReturnSource, ReturnDocument, ReturnEvent, ReturnPolicy, ReturnAudit from shareholder_returns import audit, document_data, load_documents, serialize from return_lifecycle import automatic_status class StrictModel(BaseModel): model_config = ConfigDict(extra="forbid", allow_inf_nan=False) class EventInput(StrictModel): year: int = Field(ge=2000, le=2100) amount: float | None = Field(default=None, ge=0) scope: Literal["common", "company", "preferred", "unknown"] = "unknown" cancellation_date: date | None = None outside_policy: bool = False evidence: str = Field(default="", max_length=10000) @model_validator(mode="after") def date_matches_year(self): if self.cancellation_date and self.cancellation_date.year != self.year: raise ValueError("소각예정일과 적용 연도가 다릅니다.") return self class PolicyInput(StrictModel): year: int = Field(ge=2000, le=2100) kind: Literal["cancellation", "total_amount", "total_ratio", "none"] scope: Literal["common", "company", "preferred", "unknown"] = "unknown" amount: float | None = Field(default=None, ge=0) ratio: float | None = Field(default=None, ge=0, le=1) cancellation_commitment: bool = False income_basis: str = Field(default="unknown", max_length=200) income_amount: float | None = None dividend_amount: float | None = Field(default=None, ge=0) evidence: str = Field(default="", max_length=10000) def validate_url(value): url = urlparse(value) if url.scheme not in ("http", "https") or not url.hostname or url.username or url.password: raise ValueError("공식 자료의 http(s) URL을 입력하세요.") return value class ReviewInput(StrictModel): ticker: str = Field(pattern=r"^[0-9A-Z]{6}$") source_key: str = Field(min_length=1, max_length=500) source_url: str = Field(max_length=2000) title: str = Field(min_length=1, max_length=500) published_at: date | None = None evidence: str = Field(default="", max_length=30000) status: Literal["active", "reference", "pending", "approved", "excluded"] = "active" supersedes_id: int | None = None is_withdrawal: bool = False events: list[EventInput] = Field(default_factory=list, max_length=100) policies: list[PolicyInput] = Field(default_factory=list, max_length=100) _url = field_validator("source_url")(validate_url) @model_validator(mode="after") def approval_requires_evidence(self): if self.status in {"active", "approved"}: if not self.evidence.strip(): raise ValueError("승인하려면 근거 문구가 필요합니다.") if self.is_withdrawal and not self.supersedes_id: raise ValueError("철회 대상 자료를 지정하세요.") if not self.is_withdrawal and not (self.events or self.policies): raise ValueError("승인할 소각 사건 또는 정책이 필요합니다.") for e in self.events: if e.amount is None or not e.evidence.strip(): raise ValueError("소각 사건에는 금액과 근거가 필요합니다.") for p in self.policies: if not p.evidence.strip(): raise ValueError("정책의 근거가 필요합니다.") if p.kind in ("cancellation", "total_amount") and p.amount is None: raise ValueError("정책의 금액이 필요합니다.") if p.kind == "total_ratio" and p.ratio is None: raise ValueError("환원율이 필요합니다. 50%는 0.5로 입력하세요.") if p.kind in ("total_amount", "total_ratio") and not p.cancellation_commitment: raise ValueError("잔여액을 소각하려는 회사 방침이 필요합니다.") return self class SourceInput(StrictModel): ticker: str = Field(pattern=r"^[0-9A-Z]{6}$") url: str = Field(max_length=2000) is_index: bool = False enabled: bool = True _url = field_validator("url")(validate_url) class StatusInput(StrictModel): action: Literal["cancel", "restore"] reason: str = Field(default="", max_length=2000) def write_review(db, payload, doc=None, actor_id=None): if not db.get(Company, payload.ticker): raise HTTPException(404, "종목을 찾을 수 없습니다.") duplicate = db.query(ReturnDocument).filter_by(source_key=payload.source_key).first() if duplicate and (doc is None or duplicate.id != doc.id): raise HTTPException(409, "이미 등록된 출처 식별자입니다.") if doc and (doc.ticker != payload.ticker or doc.source_key != payload.source_key): raise HTTPException(422, "종목과 출처 식별자는 변경할 수 없습니다.") if payload.status == "approved" and re.search(r"정정|철회|취소", payload.title) and not payload.supersedes_id: raise HTTPException(422, "정정·철회 자료는 대체하는 이전 자료를 지정하세요.") if payload.supersedes_id: parent = db.get(ReturnDocument, payload.supersedes_id) visited = {doc.id} if doc else set() if not parent or parent.ticker != payload.ticker: raise HTTPException(422, "같은 종목의 기존 자료를 지정하세요.") while parent: if parent.id in visited: raise HTTPException(422, "정정 관계가 순환합니다.") visited.add(parent.id) parent = db.get(ReturnDocument, parent.supersedes_id) if parent.supersedes_id else None if payload.status == "approved": sibling = db.query(ReturnDocument).filter_by(supersedes_id=payload.supersedes_id, status="approved").first() if sibling and (not doc or sibling.id != doc.id): raise HTTPException(409, "이미 승인된 정정 자료를 대상으로 지정하세요.") before = document_data(db, doc) if doc else None if doc is None: doc = ReturnDocument() db.add(doc) values = payload.model_dump(mode="json", exclude={"events", "policies"}) for key, value in values.items(): setattr(doc, key, value) doc.updated_at = datetime.utcnow() doc.error = None db.flush() for model in (ReturnEvent, ReturnPolicy): db.query(model).filter_by(document_id=doc.id).delete(synchronize_session=False) for key, model in (("events", ReturnEvent), ("policies", ReturnPolicy)): for item in getattr(payload, key): db.add(model(document_id=doc.id, **item.model_dump(mode="json"))) audit(db, doc, "review", before, actor_id) db.commit() return document_data(db, doc) def create_return_router(get_db, invalidate): router = APIRouter(prefix="/api/admin/returns", dependencies=[Depends(require_admin)]) @router.get("/documents") def list_documents(db=Depends(get_db)): return load_documents(db) @router.post("/documents") def add_document(payload: ReviewInput, db=Depends(get_db), user=Depends(require_admin)): result = write_review(db, payload, actor_id=user.id) invalidate() return result @router.put("/documents/{document_id}") def edit_document(document_id: int, payload: ReviewInput, db=Depends(get_db), user=Depends(require_admin)): doc = db.get(ReturnDocument, document_id) if not doc: raise HTTPException(404, "자료를 찾을 수 없습니다.") result = write_review(db, payload, doc, user.id) invalidate() return result @router.get("/documents/{document_id}/audit") def history(document_id: int, db=Depends(get_db)): return [serialize(a) for a in db.query(ReturnAudit).filter_by(document_id=document_id).order_by(ReturnAudit.id)] @router.post("/documents/{document_id}/status") def change_status(document_id: int, payload: StatusInput, db=Depends(get_db), user=Depends(require_admin)): doc = db.get(ReturnDocument, document_id) if not doc: raise HTTPException(404, "자료를 찾을 수 없습니다.") before = document_data(db, doc) desired = "excluded" if payload.action == "cancel" else automatic_status(before) if doc.status != desired: doc.status, doc.updated_at = desired, datetime.utcnow() audit(db, doc, payload.action, before, user.id, payload.reason) db.commit() invalidate() return document_data(db, doc) @router.get("/sources") def sources(db=Depends(get_db)): return [serialize(s) for s in db.query(ReturnSource).order_by(ReturnSource.id)] @router.post("/sources") def add_source(payload: SourceInput, db=Depends(get_db)): if not db.get(Company, payload.ticker): raise HTTPException(404, "종목을 찾을 수 없습니다.") source = db.query(ReturnSource).filter_by(ticker=payload.ticker, url=payload.url).first() if source is None: source = ReturnSource(ticker=payload.ticker, url=payload.url) db.add(source) source.is_index, source.enabled = payload.is_index, payload.enabled db.commit() return serialize(source) return router