StocKing / return_api.py
adoomy's picture
Add automatic shareholder return estimates and numeric yield sorting
992d9b8
Raw History Blame Contribute Delete
9.64 kB
"""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