Newer
Older
gnexus-tasks / backend / app / schemas.py
"""Pydantic-схемы API M1."""

import re
from datetime import date, datetime
from typing import Any
from urllib.parse import urlsplit

from pydantic import BaseModel, ConfigDict, Field, field_validator

# --- Task ---

DEADLINE_PERIODS = ("day", "week", "month", "year")
RECUR_KINDS = ("interval", "weekdays", "monthly")

# --- Цвет и сайт проекта (0.63) ---

HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
MAX_SITE_URL = 2048


def normalize_color(value: str | None) -> str | None:
    """Цвет проекта: ''/None → None (убрать), иначе #rrggbb в нижнем регистре.

    Проверяем только формат, не палитру: палитра — представление фронта (она
    меняется вместе с китом), а из MCP валиден любой #rrggbb.
    """
    if value is None:
        return None
    text = value.strip()
    if not text:
        return None
    if not HEX_COLOR_RE.match(text):
        raise ValueError("color must be HEX #rrggbb")
    return text.lower()


def _normalize_http_url(value: str | None, field: str) -> str | None:
    """URL проекта (сайт/репозиторий): ''/None → None (убрать), иначе http(s) с хостом."""
    if value is None:
        return None
    text = value.strip()
    if not text:
        return None
    if len(text) > MAX_SITE_URL:
        raise ValueError(f"{field} is too long (max {MAX_SITE_URL})")
    parts = urlsplit(text)
    if parts.scheme not in ("http", "https") or not parts.netloc:
        raise ValueError(f"{field} must be http(s)://host/...")
    return text


def normalize_site_url(value: str | None) -> str | None:
    return _normalize_http_url(value, "site_url")


def normalize_repository_url(value: str | None) -> str | None:
    """Репозиторий проекта (0.67): форму «owner/repo» не проверяем — не угадали,
    так эндпоинт README честно ответит 404."""
    return _normalize_http_url(value, "repository_url")


class TagOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    name: str


class ProjectOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    name: str
    relevance_status: str
    is_archived: bool
    pinned: bool
    priority: int | None
    note: str
    document_id: int | None
    color: str | None
    site_url: str | None
    repository_url: str | None


class TaskCreate(BaseModel):
    title: str = Field(min_length=1, max_length=500)
    description: str = ""
    parent_task_id: int | None = None  # подзадача (M3)
    # Доступность задачи ИИ-агенту (ТЗ 3.20); агент вправе поставить её только здесь
    ai_eligible: bool = False


class TaskUpdate(BaseModel):
    """Частичное обновление метаданных задачи (ручная детализация)."""

    title: str | None = Field(None, max_length=500)
    description: str | None = None
    project_id: int | None = None
    parent_task_id: int | None = None
    tag_ids: list[int] | None = None
    priority: int | None = None
    status: str | None = None
    estimated_minutes: int | None = Field(None, ge=1, le=24 * 60)
    actual_minutes: int | None = Field(None, ge=1, le=24 * 60)
    budget_money: int | None = Field(None, ge=0)
    cost_estimate_money: int | None = Field(None, ge=0)
    # Дедлайны: строгий — конкретная дата; нестрогий — период («в течение…»)
    deadline_date: date | None = None
    deadline_period: str | None = None
    # Регулярность (ТЗ 3.5): тип задачи + правило повторения
    task_type: str | None = None
    recur_kind: str | None = Field(None, pattern="^(interval|weekdays|monthly)$")
    recur_interval_days: int | None = Field(None, ge=1, le=365)
    recur_weekdays: str | None = Field(None, pattern=r"^[1-7](,[1-7])*$")  # ISO, пн=1
    recur_day_of_month: int | None = Field(None, ge=1, le=31)
    # «Ментально сложная» (ТЗ 3.13): метка «не хочется делать», +10 XP при закрытии
    mentally_hard: bool | None = None
    # Доступность ИИ-агенту (ТЗ 3.20): меняет только владелец — агенту поле недоступно
    ai_eligible: bool | None = None
    # Не поле задачи: пометка источника закрытия для геймификации ("options" — режим «3 вариантов»)
    earned_via: str | None = None
    # Не поле задачи: комментарий к закрытию (ТЗ 3.20) — уходит в журнал, агенту обязателен
    close_comment: str | None = Field(None, max_length=2000)


class TaskOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    title: str
    description: str
    document_id: int | None
    task_type: str
    status: str
    detail_state: str
    parent_task_id: int | None
    project: ProjectOut | None
    priority: int | None
    tags: list[TagOut]
    ai_proposal: dict[str, Any] | None
    estimated_minutes: int | None
    actual_minutes: int | None
    budget_money: int | None
    cost_estimate_money: int | None
    deadline_date: date | None
    deadline_period: str | None
    recur_kind: str | None
    recur_interval_days: int | None
    recur_weekdays: str | None
    recur_day_of_month: int | None
    mentally_hard: bool
    # Мандат ИИ-агента и приёмка (ТЗ 3.20). claimed_by — подпись арендатора и только
    # пока аренда жива: протухшую аренду наружу не показываем (см. Task.claimed_by)
    ai_eligible: bool
    created_by_kind: str
    created_by_name: str | None
    done_by_kind: str | None
    accept_state: str | None
    claimed_by: str | None
    claim_expires_at: datetime | None
    created_at: datetime
    approved_at: datetime | None
    done_at: datetime | None


class TaskEventOut(BaseModel):
    """Запись журнала действий по задаче (ТЗ 3.20)."""

    model_config = ConfigDict(from_attributes=True)

    id: int
    task_id: int | None
    task_title: str
    project_id: int | None
    kind: str
    actor_kind: str
    actor_name: str
    via: str
    comment: str | None
    created_at: datetime


class TaskEventPage(BaseModel):
    """Страница журнала: единственный пагинированный список API (ТЗ 3.20)."""

    items: list[TaskEventOut]
    total: int
    page: int
    per_page: int


class AcceptIn(BaseModel):
    """Приёмка агентской работы: комментарий необязателен (у возврата — причина)."""

    comment: str | None = Field(None, max_length=2000)


class ApproveIn(BaseModel):
    """Утверждение детализации; apply_proposal=True — принять предложение LLM."""

    apply_proposal: bool = False


class SuggestIn(BaseModel):
    """Режим «3 варианта»: доступное время в минутах."""

    available_minutes: int = Field(ge=5, le=24 * 60)


# --- Project / Tag ---


class AttachmentOut(BaseModel):
    """Вложение документа (изображение)."""

    model_config = ConfigDict(from_attributes=True)

    id: int
    document_id: int
    filename: str
    original_name: str
    mime: str
    size: int
    created_at: datetime


class ProjectCreate(BaseModel):
    name: str = Field(min_length=1, max_length=200)
    priority: int | None = None
    note: str = ""
    color: str | None = None
    site_url: str | None = None
    repository_url: str | None = None

    @field_validator("color")
    @classmethod
    def _color(cls, value: str | None) -> str | None:
        return normalize_color(value)

    @field_validator("site_url")
    @classmethod
    def _site_url(cls, value: str | None) -> str | None:
        return normalize_site_url(value)

    @field_validator("repository_url")
    @classmethod
    def _repository_url(cls, value: str | None) -> str | None:
        return normalize_repository_url(value)


class ProjectUpdate(BaseModel):
    name: str | None = Field(None, max_length=200)
    relevance_status: str | None = None
    # Булавка (0.66): закрепление проекта из списка и со страницы проекта
    pinned: bool | None = None
    priority: int | None = None
    note: str | None = None
    color: str | None = None
    site_url: str | None = None
    # Репозиторий (0.67): из него подтягивается README — второе описание проекта
    repository_url: str | None = None

    @field_validator("color")
    @classmethod
    def _color(cls, value: str | None) -> str | None:
        return normalize_color(value)

    @field_validator("site_url")
    @classmethod
    def _site_url(cls, value: str | None) -> str | None:
        return normalize_site_url(value)

    @field_validator("repository_url")
    @classmethod
    def _repository_url(cls, value: str | None) -> str | None:
        return normalize_repository_url(value)


class TagCreate(BaseModel):
    name: str = Field(min_length=1, max_length=100)


CURRENCIES = ("UAH", "USD", "EUR", "GBP", "PLN")
# Языки UI: пустое language = нет переопределения (следуем SSO locale)
LANGUAGES = ("ru", "en", "uk")


class AppSettingsOut(BaseModel):
    currency: str = "UAH"
    language: str = ""


class AppSettingsUpdate(BaseModel):
    # Частичный PUT: None = поле не передано, не затираем
    currency: str | None = Field(None, pattern="^(UAH|USD|EUR|GBP|PLN)$")
    language: str | None = Field(None, pattern="^(|ru|en|uk)$")