File size: 9,640 Bytes
992d9b8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 | """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
|