"""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)$")