"""Pydantic request bodies for the bounty domain.""" from __future__ import annotations import uuid from datetime import date, datetime from decimal import Decimal from pydantic import BaseModel, ConfigDict, Field, field_validator from app.domains.bounty import constants as const class _Base(BaseModel): model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) # ── Intake / edit ─────────────────────────────────────────────────── class ReportCreateRequest(_Base): title: str = Field(min_length=1, max_length=const.MAX_TITLE_CHARS) researcher_email: str = Field(min_length=3, max_length=320) researcher_name: str | None = Field(default=None, max_length=255) researcher_handle: str | None = Field(default=None, max_length=128) researcher_country: str | None = Field(default=None, max_length=64) reported_at: datetime | None = None raw_report: str | None = Field(default=None, max_length=const.MAX_RAW_REPORT_CHARS) summary: str | None = Field(default=None, max_length=const.MAX_COMMENT_CHARS) reproduction: str | None = Field(default=None, max_length=const.MAX_COMMENT_CHARS) affected_system: str | None = Field(default=None, max_length=255) #: Optional link into the internal system registry. When set, the #: intake pre-fills the budget track and the suggested assignee from #: the registered owner — see ``router.create_report``. system_id: uuid.UUID | None = None affected_urls: list[str] | None = None vuln_type: str | None = None cve_id: str | None = Field(default=None, max_length=64) suggested_severity: str | None = Field(default=None, max_length=24) #: Optional so the router can distinguish "the operator chose a pot" #: from "nothing was said" — in the latter case a linked registry #: entry decides, and only then the platform default. A schema #: default would make those two cases indistinguishable and would #: silently charge every registry-linked IT finding to 产研. budget_track: str | None = None source: str = const.SOURCE_EMAIL @field_validator("budget_track") @classmethod def _check_track(cls, v: str | None) -> str | None: if v is not None and v not in const.BUDGET_TRACKS: raise ValueError( f"budget_track must be one of {', '.join(const.BUDGET_TRACKS)}", ) return v @field_validator("vuln_type") @classmethod def _check_vuln_type(cls, v: str | None) -> str | None: if v and v not in const.VULN_TYPES: raise ValueError(f"unknown vulnerability type '{v}'") return v or None @field_validator("source") @classmethod def _check_source(cls, v: str) -> str: if v not in const.SOURCES: raise ValueError(f"unknown source '{v}'") return v class ReportUpdateRequest(_Base): """Descriptive fields only — status / severity / money / assignment have their own endpoints so no client can bypass a state machine.""" title: str | None = Field(default=None, min_length=1, max_length=const.MAX_TITLE_CHARS) raw_report: str | None = Field(default=None, max_length=const.MAX_RAW_REPORT_CHARS) summary: str | None = Field(default=None, max_length=const.MAX_COMMENT_CHARS) reproduction: str | None = Field(default=None, max_length=const.MAX_COMMENT_CHARS) affected_system: str | None = Field(default=None, max_length=255) system_id: uuid.UUID | None = None affected_urls: list[str] | None = None vuln_type: str | None = None cve_id: str | None = Field(default=None, max_length=64) suggested_severity: str | None = Field(default=None, max_length=24) reported_at: datetime | None = None source: str | None = None @field_validator("vuln_type") @classmethod def _check_vuln_type(cls, v: str | None) -> str | None: if v and v not in const.VULN_TYPES: raise ValueError(f"unknown vulnerability type '{v}'") return v class ReportDeleteRequest(_Base): reason: str | None = Field(default=None, max_length=1000) # ── Assignment ────────────────────────────────────────────────────── class AssignRequest(_Base): person_id: uuid.UUID note: str | None = Field(default=None, max_length=const.MAX_COMMENT_CHARS) #: Push a Feishu card + inbox notification to the new owner. PR2 wires #: the delivery; the flag exists from the start so the intake flow does #: not change shape when it lands. notify: bool = True class UnassignRequest(_Base): reason: str | None = Field(default=None, max_length=1000) # ── Triage ────────────────────────────────────────────────────────── class StatusChangeRequest(_Base): status: str note: str | None = Field(default=None, max_length=const.MAX_COMMENT_CHARS) #: Only meaningful when ``status`` is ``duplicate``. One of the two #: must be given — see ``service._apply_duplicate_of`` for why the #: FK alone is not enough. duplicate_of_id: uuid.UUID | None = None duplicate_note: str | None = Field(default=None, max_length=500) @field_validator("status") @classmethod def _check(cls, v: str) -> str: if v not in const.STATUSES: raise ValueError(f"unknown status '{v}'") return v class SeverityRequest(_Base): severity: str = Field(min_length=1, max_length=24) note: str | None = Field(default=None, max_length=const.MAX_COMMENT_CHARS) #: Pull the tier's default reward onto the report in the same call — #: what the operator wants 95% of the time. apply_default_reward: bool = True class RemediationRequest(_Base): remediation_status: str fix_eta: date | None = None note: str | None = Field(default=None, max_length=const.MAX_COMMENT_CHARS) @field_validator("remediation_status") @classmethod def _check(cls, v: str) -> str: if v not in const.REMEDIATION_STATUSES: raise ValueError(f"unknown remediation status '{v}'") return v class OwnerResponseRequest(_Base): """Submitted from the business owner's confirmation page (PR2) and, for a phone-call verdict relayed by the security team, from the console on the owner's behalf.""" decision: str note: str | None = Field(default=None, max_length=const.MAX_COMMENT_CHARS) suggested_severity: str | None = Field(default=None, max_length=24) fix_eta: date | None = None remediation_status: str | None = None @field_validator("decision") @classmethod def _check_decision(cls, v: str) -> str: if v not in const.OWNER_DECISIONS: raise ValueError(f"unknown decision '{v}'") return v @field_validator("remediation_status") @classmethod def _check_remediation(cls, v: str | None) -> str | None: if v and v not in const.OWNER_SETTABLE_REMEDIATION: raise ValueError( f"'{v}' is not a remediation state the business owner may set", ) return v # ── Money ─────────────────────────────────────────────────────────── class RewardRequest(_Base): amount_usd: Decimal = Field(ge=0, le=const.MAX_REWARD_USD) note: str | None = Field(default=None, max_length=const.MAX_COMMENT_CHARS) class PaymentRequest(_Base): payment_status: str reference: str | None = Field(default=None, max_length=128) note: str | None = Field(default=None, max_length=const.MAX_COMMENT_CHARS) paid_at: datetime | None = None @field_validator("payment_status") @classmethod def _check(cls, v: str) -> str: if v not in const.PAYMENT_STATUSES: raise ValueError(f"unknown payment status '{v}'") return v # ── Correspondence ────────────────────────────────────────────────── class CommentRequest(_Base): body: str = Field(min_length=1, max_length=const.MAX_COMMENT_CHARS) #: ``shared`` lets the assigned owner read it; ``internal`` keeps it #: inside the security team. visibility: str = const.VISIBILITY_SHARED @field_validator("visibility") @classmethod def _check(cls, v: str) -> str: if v not in const.VISIBILITIES: raise ValueError(f"unknown visibility '{v}'") return v class ReplyPreviewRequest(_Base): template_key: str = Field(min_length=1, max_length=64) #: Preview "what would the letter say at this figure" without #: committing the reward. amount_usd: Decimal | None = Field(default=None, ge=0, le=const.MAX_REWARD_USD) subject: str | None = Field(default=None, max_length=500) body: str | None = Field(default=None, max_length=const.MAX_RAW_REPORT_CHARS) class ReplyLogRequest(_Base): """Attestation that the operator sent this text to the researcher.""" template_key: str | None = Field(default=None, max_length=64) subject: str = Field(min_length=1, max_length=500) body: str = Field(min_length=1, max_length=const.MAX_RAW_REPORT_CHARS) class InboundLogRequest(_Base): body: str = Field(min_length=1, max_length=const.MAX_RAW_REPORT_CHARS) received_at: datetime | None = None # ── Researchers ───────────────────────────────────────────────────── class ResearcherUpdateRequest(_Base): email: str | None = Field(default=None, max_length=320) display_name: str | None = Field(default=None, max_length=255) handle: str | None = Field(default=None, max_length=128) country: str | None = Field(default=None, max_length=64) notes: str | None = Field(default=None, max_length=const.MAX_COMMENT_CHARS) is_blocked: bool | None = None # ── Configuration ─────────────────────────────────────────────────── class TierCreateRequest(_Base): key: str = Field(min_length=1, max_length=24) label_zh: str = Field(min_length=1, max_length=64) label_en: str = Field(min_length=1, max_length=64) label_ja: str = Field(min_length=1, max_length=64) default_reward_usd: Decimal = Field(ge=0, le=const.MAX_REWARD_USD) max_reward_usd: Decimal | None = Field(default=None, ge=0, le=const.MAX_REWARD_USD) fix_sla_days: int | None = Field(default=None, ge=0, le=3650) color: str | None = Field(default=None, max_length=16) sort_order: int = Field(default=100, ge=0, le=10_000) class TierUpdateRequest(_Base): label_zh: str | None = Field(default=None, min_length=1, max_length=64) label_en: str | None = Field(default=None, min_length=1, max_length=64) label_ja: str | None = Field(default=None, min_length=1, max_length=64) default_reward_usd: Decimal | None = Field(default=None, ge=0, le=const.MAX_REWARD_USD) max_reward_usd: Decimal | None = Field(default=None, ge=0, le=const.MAX_REWARD_USD) fix_sla_days: int | None = Field(default=None, ge=0, le=3650) color: str | None = Field(default=None, max_length=16) sort_order: int | None = Field(default=None, ge=0, le=10_000) enabled: bool | None = None class TemplateCreateRequest(_Base): key: str = Field(min_length=1, max_length=64) name_zh: str = Field(min_length=1, max_length=128) name_en: str = Field(min_length=1, max_length=128) name_ja: str = Field(min_length=1, max_length=128) category: str = const.REPLY_CATEGORY_OTHER subject: str = Field(min_length=1, max_length=500) body: str = Field(min_length=1, max_length=const.MAX_RAW_REPORT_CHARS) sort_order: int = Field(default=100, ge=0, le=10_000) @field_validator("category") @classmethod def _check(cls, v: str) -> str: if v not in const.REPLY_CATEGORIES: raise ValueError(f"unknown template category '{v}'") return v class TemplateUpdateRequest(_Base): name_zh: str | None = Field(default=None, min_length=1, max_length=128) name_en: str | None = Field(default=None, min_length=1, max_length=128) name_ja: str | None = Field(default=None, min_length=1, max_length=128) category: str | None = None subject: str | None = Field(default=None, min_length=1, max_length=500) body: str | None = Field(default=None, min_length=1, max_length=const.MAX_RAW_REPORT_CHARS) sort_order: int | None = Field(default=None, ge=0, le=10_000) enabled: bool | None = None @field_validator("category") @classmethod def _check(cls, v: str | None) -> str | None: if v and v not in const.REPLY_CATEGORIES: raise ValueError(f"unknown template category '{v}'") return v class BudgetTrackPatch(_Base): default_usd: Decimal | None = Field(default=None, ge=0, le=100_000_000) #: ``{"2027": 8000}`` — per-fiscal-year override for THIS track. by_fy: dict[int, Decimal] | None = None class BudgetUpdateRequest(_Base): """Patch one or both funding tracks. An absent track keeps what it had: editing the IT ceiling must not blank the product one, which a naive full-object PUT would do. """ product: BudgetTrackPatch | None = None it: BudgetTrackPatch | None = None class BudgetTrackRequest(_Base): """Move a report between funding pots.""" budget_track: str note: str | None = Field(default=None, max_length=const.MAX_COMMENT_CHARS) @field_validator("budget_track") @classmethod def _check(cls, v: str) -> str: if v not in const.BUDGET_TRACKS: raise ValueError( f"budget_track must be one of {', '.join(const.BUDGET_TRACKS)}", ) return v class SignatureUpdateRequest(_Base): signer_name: str | None = Field(default=None, max_length=128) signer_title: str | None = Field(default=None, max_length=128) team_name: str | None = Field(default=None, max_length=128) company_name: str | None = Field(default=None, max_length=128) security_policy_url: str | None = Field(default=None, max_length=500) entity_block: str | None = Field(default=None, max_length=2000) # ── AI ────────────────────────────────────────────────────────────── class AiParseRequest(_Base): """Paste the researcher's mail; get structured fields back. Nothing is persisted — the operator reviews the extraction and submits the normal create/update call.""" raw_text: str = Field(min_length=20, max_length=const.MAX_RAW_REPORT_CHARS) class AiDraftRequest(_Base): """Ask the model for a short, report-specific opening paragraph to put in front of a template body.""" template_key: str = Field(min_length=1, max_length=64) instruction: str | None = Field(default=None, max_length=2000) # ── Backfill (bulk, internal fields only) ─────────────────────────── class BackfillSeverityItem(BaseModel): report_id: uuid.UUID severity: str = Field(min_length=1, max_length=32) class BackfillSeverityRequest(BaseModel): """Per-report grades. There is deliberately no "apply severity X to all selected" shape: one severity for a batch of different findings is a footgun dressed as a convenience.""" items: list[BackfillSeverityItem] = Field(min_length=1, max_length=100) class BackfillLinkItem(BaseModel): report_id: uuid.UUID system_id: uuid.UUID class BackfillLinkRequest(BaseModel): items: list[BackfillLinkItem] = Field(min_length=1, max_length=100) class BackfillNoSystemRequest(BaseModel): report_ids: list[uuid.UUID] = Field(min_length=1, max_length=100) #: Why no internal system applies. Stored on the report's timeline, #: because "who decided this was an open-source finding" is the #: question that gets asked later and a boolean cannot answer it. note: str | None = Field(default=None, max_length=2000) class ResearcherMergeRequest(BaseModel): """The canonical researcher this address belongs to.""" into_id: uuid.UUID class MailIntakeAcceptRequest(BaseModel): """Optional overrides when turning a queued mail into a report — the subject line is often not the title anyone wants to read later.""" title: str | None = Field(default=None, max_length=200) affected_system: str | None = Field(default=None, max_length=255) class AlertRecipientsUpdateRequest(BaseModel): """Who receives the desk's alerts. An EMPTY list restores the default (every active 安全员) — it does not mean "nobody".""" person_ids: list[uuid.UUID] = Field(default_factory=list, max_length=50) class MailIntakeAttachRequest(BaseModel): """Which existing report this queued mail is a follow-up on. An explicit id, never a guess: the queue only SUGGESTS a target (by subject), because putting a researcher's chase onto the wrong finding is worse than leaving it in the queue. """ report_id: uuid.UUID class MailIntakeIgnoreRequest(BaseModel): note: str | None = Field(default=None, max_length=1000)