import re from datetime import datetime as _datetime, date, time from typing import Annotated, Literal, Optional from pydantic import AfterValidator, BaseModel, BeforeValidator, ConfigDict, Field, PlainSerializer, create_model, field_validator from . import config from .netcheck import is_allowed_push_endpoint def _dt_in(v): """Вход: ISO с часовым поясом приводим к локальному naive; без пояса — считаем локальным.""" if isinstance(v, str): v = _datetime.fromisoformat(v.replace("Z", "+00:00")) if isinstance(v, _datetime) and v.tzinfo: v = v.astimezone(config.TZ).replace(tzinfo=None) return v def _dt_out(v): return v.replace(tzinfo=config.TZ).isoformat() if v.tzinfo is None else v.isoformat() # Все даты-время в API: на выходе ISO 8601 со смещением (напр. 2026-10-07T11:25:00+03:00) datetime = Annotated[_datetime, BeforeValidator(_dt_in), PlainSerializer(_dt_out, return_type=str, when_used="json")] ICON_RE = re.compile(r"builtin:[a-z0-9_-]{1,40}|file:[0-9a-f]{32}\.(jpg|png|webp)") AnswerType = Literal["status", "choice", "number", "text"] class ORM(BaseModel): model_config = ConfigDict(from_attributes=True) def partial(model: type[BaseModel]) -> type[BaseModel]: """Копия схемы, где все поля необязательны (для PATCH).""" fields = {n: (Optional[f.annotation], None) for n, f in model.model_fields.items()} return create_model(model.__name__ + "Patch", **fields) # ----- auth / users ----- class LoginIn(BaseModel): username: str password: str device: str = "" # название устройства, для списка сессий class TokenOut(BaseModel): access_token: str refresh_token: str token_type: str = "bearer" expires_in: int # секунд до истечения access_token class RefreshIn(BaseModel): refresh_token: str device: str = "" PASSWORD_MIN = 10 def _pw_bytes(v: str) -> str: if len(v.encode("utf-8")) > 72: # предел bcrypt: лишнее молча отбрасывалось бы raise ValueError("Пароль слишком длинный (не более 72 байт)") return v # Пароль: от 10 символов, не длиннее 72 байт Password = Annotated[str, Field(min_length=PASSWORD_MIN), AfterValidator(_pw_bytes)] class PasswordIn(BaseModel): old_password: str new_password: Password class UserIn(BaseModel): username: str = Field(min_length=2, max_length=64) full_name: str = "" password: Password is_admin: bool = False is_moderator: bool = False is_storekeeper: bool = False portal_admin: bool = False is_active: bool = True position_id: Optional[int] = None schedule_id: Optional[int] = None # график работы сотрудника (пусто — всегда на смене) department_id: Optional[int] = None skills: str = Field(default="", max_length=4000) phone: str = Field(default="", max_length=40) class UserPatch(BaseModel): username: Optional[str] = None full_name: Optional[str] = None password: Optional[Password] = None is_admin: Optional[bool] = None is_moderator: Optional[bool] = None is_storekeeper: Optional[bool] = None portal_admin: Optional[bool] = None is_active: Optional[bool] = None position_id: Optional[int] = None schedule_id: Optional[int] = None department_id: Optional[int] = None skills: Optional[str] = Field(default=None, max_length=4000) phone: Optional[str] = Field(default=None, max_length=40) class DepartmentBrief(ORM): id: int name: str class DepartmentIn(BaseModel): name: str = Field(min_length=1, max_length=150) parent_id: Optional[int] = None head_user_id: Optional[int] = None notes: str = "" class DepartmentOut(DepartmentIn, ORM): id: int members: int = 0 class PositionBrief(ORM): id: int name: str kind: str class UserOut(ORM): id: int username: str full_name: str is_admin: bool is_moderator: bool is_storekeeper: bool = False is_active: bool position_id: Optional[int] position: Optional[PositionBrief] = None schedule_id: Optional[int] = None department_id: Optional[int] = None department: Optional["DepartmentBrief"] = None has_photo: bool = False skills: str = "" phone: str = "" on_shift: Optional[bool] = None demo_mode: bool = False crane_map: bool = False # включена отдельная карта кранов reliability: bool = False # доступен раздел «Анализ надёжности» (включён и у пользователя есть права) docs: bool = False # подключена документация из Nextcloud portal_admin: bool = False # администратор портала: настройки сервера и обратная связь is_root: bool = False # администратор по умолчанию (admin): выдаёт права администратора портала class FeedbackIn(BaseModel): kind: Literal["idea", "bug", "other"] = "idea" text: str = Field(min_length=3, max_length=4000) page: str = Field(default="", max_length=200) client: Literal["web", "m", "android"] = "web" class FeedbackPatch(BaseModel): status: Optional[Literal["new", "taken", "done", "rejected"]] = None answer: Optional[str] = Field(default=None, max_length=4000) class FeedbackOut(ORM): id: int created_at: datetime kind: str text: str page: str client: str status: str answer: str user_id: Optional[int] = None user_name: Optional[str] = None answered_by_name: Optional[str] = None answered_at: Optional[datetime] = None # ----- справочники ----- class PatternStep(BaseModel): """Шаг цикла: несколько подряд идущих дней одного типа.""" type: Literal["work", "off"] = "work" days: int = Field(default=1, ge=1, le=60) start_time: time = time(8, 0) # для work end_time: time = time(20, 0) # для work; end <= start — смена переходит на следующие сутки (равно — ровно 24 ч) label: str = Field(default="", max_length=40) class ScheduleIn(BaseModel): name: str kind: Literal["weekly", "cycle", "pattern"] = "weekly" pattern: Optional[list[PatternStep]] = None weekdays: list[int] = [0, 1, 2, 3, 4] work_days: int = 2 off_days: int = 2 cycle_start: Optional[date] = None start_time: time = time(8, 0) end_time: time = time(20, 0) class ScheduleOut(ScheduleIn, ORM): id: int class SchedulePreviewIn(BaseModel): schedule: ScheduleIn from_date: Optional[date] = None days: int = Field(default=28, ge=1, le=120) class PreviewDay(BaseModel): date: date weekday: int work: bool start: Optional[datetime] = None end: Optional[datetime] = None label: str = "" class PositionIn(BaseModel): name: str kind: Literal["technician", "engineer"] = "technician" schedule_id: Optional[int] = None # устарело и не используется: график задаётся сотруднику class PositionOut(ORM): id: int name: str kind: str schedule_id: Optional[int] class LocationIn(BaseModel): name: str parent_id: Optional[int] = None class LocationOut(LocationIn, ORM): id: int class EquipmentIn(BaseModel): name: str inventory_no: str = "" location_id: Optional[int] = None notes: str = "" is_active: bool = True icon: str = Field(default="", max_length=300) # builtin:<имя>; загруженное фото — через /equipment/{id}/icon department_id: Optional[int] = None # за каким отделом закреплено (пусто — видят все) is_crane: bool = False # кран: показывается на отдельной карте кранов @field_validator("icon") @classmethod def _icon_ok(cls, v): if v and not ICON_RE.fullmatch(v): raise ValueError("Недопустимая иконка") return v class EquipmentOut(EquipmentIn, ORM): id: int meter_hours: float = 0.0 meter_updated_at: Optional[datetime] = None map_x: Optional[float] = None map_y: Optional[float] = None docs_path: str = "" # папка документации в Nextcloud (привязывается через PUT /equipment/{id}/docs) # ----- регламентное обслуживание: узлы машины ----- ServiceMode = Literal["hours", "calendar", "both"] IntervalUnit = Literal["day", "week", "month"] class ServiceUnitIn(BaseModel): name: str = Field(min_length=1, max_length=150) notes: str = "" mode: ServiceMode = "hours" interval_hours: Optional[float] = Field(default=None, gt=0, le=1_000_000) interval_value: Optional[int] = Field(default=None, ge=1, le=3650) interval_unit: Optional[IntervalUnit] = None warn_percent: int = Field(default=10, ge=0, le=90) is_active: bool = True # начальное состояние (при создании): когда и при какой наработке узел обслуживался в последний раз last_service_at: Optional[datetime] = None last_service_hours: Optional[float] = Field(default=None, ge=0) def check_rule(self): if self.mode in ("hours", "both") and not self.interval_hours: raise ValueError("Укажите, через сколько моточасов обслуживать") if self.mode in ("calendar", "both") and not (self.interval_value and self.interval_unit): raise ValueError("Укажите календарный интервал (число и единицу)") class ServiceUnitPatch(BaseModel): name: Optional[str] = Field(default=None, min_length=1, max_length=150) notes: Optional[str] = None mode: Optional[ServiceMode] = None interval_hours: Optional[float] = Field(default=None, gt=0, le=1_000_000) interval_value: Optional[int] = Field(default=None, ge=1, le=3650) interval_unit: Optional[IntervalUnit] = None warn_percent: Optional[int] = Field(default=None, ge=0, le=90) is_active: Optional[bool] = None class ServiceUnitOut(ORM): id: int equipment_id: int name: str notes: str mode: str interval_hours: Optional[float] interval_value: Optional[int] interval_unit: Optional[str] warn_percent: int is_active: bool last_service_at: Optional[datetime] last_service_hours: float # расчётные поля rule: str = "" state: str = "ok" # ok | soon | due | nodata percent: int = 0 used_hours: Optional[float] = None remaining_hours: Optional[float] = None due_date: Optional[str] = None remaining_days: Optional[int] = None forecast_date: Optional[str] = None class ServiceResetIn(BaseModel): at: Optional[datetime] = None # когда обслужено (по умолчанию — сейчас) hours: Optional[float] = Field(default=None, ge=0) # наработка на тот момент (по умолчанию — текущая) note: str = Field(default="", max_length=1000) class ServiceEventOut(ORM): id: int unit_id: int at: datetime hours: float kind: str order_id: Optional[int] user_id: Optional[int] user_name: Optional[str] = None note: str class MeterIn(BaseModel): hours: float = Field(ge=0, le=100_000_000) at: Optional[datetime] = None correction: bool = False # счётчик заменён/обнулён: допускает значение меньше текущего note: str = Field(default="", max_length=500) class MeterOut(ORM): id: int equipment_id: int hours: float at: datetime source: str order_id: Optional[int] user_id: Optional[int] note: str # ----- чек-листы ----- class ChecklistItemIn(BaseModel): text: str answer_type: AnswerType = "status" options: Optional[list[str]] = None unit: str = "" min_value: Optional[float] = None max_value: Optional[float] = None required: bool = True photo: Literal["none", "optional", "required"] = "none" photo_keep: bool = False # фото хранить более года (не удаляются автоочисткой) class ChecklistItemOut(ChecklistItemIn, ORM): id: int position: int ChecklistKind = Literal["inspection", "regular"] class ChecklistIn(BaseModel): name: str description: str = "" kind: ChecklistKind = "inspection" # regular — регламентное обслуживание (узлы берутся из машины) only_due: bool = False # regular: включать только узлы, которым «пора» или «скоро» items: list[ChecklistItemIn] = [] class ChecklistOut(ORM): id: int name: str description: str kind: str = "inspection" only_due: bool = False items: list[ChecklistItemOut] # ----- шаблоны нарядов (расписание) ----- class OrderTemplateIn(BaseModel): title: str checklist_id: int equipment_id: Optional[int] = None location_id: Optional[int] = None assignee_user_id: Optional[int] = None assignee_position_id: Optional[int] = None reviewer_user_id: Optional[int] = None reviewer_position_id: Optional[int] = None due_hours: int = 24 recurrence: Literal["daily", "weekly", "monthly", "interval"] = "daily" weekdays: list[int] = [] month_day: int = 1 interval_days: int = 7 run_time: time = time(8, 0) start_date: Optional[date] = None is_active: bool = True class OrderTemplateOut(OrderTemplateIn, ORM): id: int start_date: date next_run: Optional[datetime] last_run: Optional[datetime] # ----- наряды ----- class OrderIn(BaseModel): title: str checklist_id: int equipment_id: Optional[int] = None location_id: Optional[int] = None assignee_user_id: Optional[int] = None assignee_position_id: Optional[int] = None reviewer_user_id: Optional[int] = None reviewer_position_id: Optional[int] = None due_at: Optional[datetime] = None class PhotoOut(ORM): id: int created_at: datetime user_id: Optional[int] class OrderItemOut(ORM): id: int position: int text: str answer_type: str options: Optional[list[str]] unit: str min_value: Optional[float] max_value: Optional[float] required: bool value: Optional[str] answered_at: Optional[datetime] out_of_range: bool photo: str photo_keep: bool = False service_unit_id: Optional[int] = None is_meter: bool = False photos: list[PhotoOut] = [] class OrderOut(BaseModel): id: int title: str status: str is_overdue: bool template_id: Optional[int] checklist_id: Optional[int] equipment_id: Optional[int] equipment_name: Optional[str] location_id: Optional[int] location_name: Optional[str] assignee_user_id: Optional[int] assignee_position_id: Optional[int] assignee_name: Optional[str] reviewer_user_id: Optional[int] reviewer_position_id: Optional[int] reviewer_name: Optional[str] executor_id: Optional[int] executor_name: Optional[str] review_comment: str created_at: datetime updated_at: Optional[datetime] = None due_at: Optional[datetime] started_at: Optional[datetime] submitted_at: Optional[datetime] completed_at: Optional[datetime] done_count: int total_count: int comments_count: int = 0 can_execute: bool = False can_review: bool = False items: Optional[list[OrderItemOut]] = None class AnswerIn(BaseModel): value: Optional[str] = None class ReviewIn(BaseModel): approve: bool comment: str = "" class CommentIn(BaseModel): text: str = Field(min_length=1, max_length=4000) class CommentOut(BaseModel): id: int user_id: Optional[int] user_name: Optional[str] text: str created_at: datetime class SettingsIn(BaseModel): photo_dir: Optional[str] = None max_photo_mb: Optional[int] = Field(default=None, ge=1, le=100) vapid_contact: Optional[str] = None fcm_credentials_path: Optional[str] = None public_incidents: Optional[bool] = None # публичная форма /report включена backup_auto_hours: Optional[int] = Field(default=None, ge=0, le=24 * 30) backup_keep: Optional[int] = Field(default=None, ge=1, le=365) backup_media: Optional[bool] = None setup_done: Optional[str] = Field(default=None, max_length=1000) crane_map: Optional[bool] = None # отдельная карта кранов reliability: Optional[bool] = None # раздел «Анализ надёжности» photo_keep_days: Optional[int] = Field(default=None, ge=0, le=36500) # удалять фото старше N дней (0 — не удалять) class PushSubIn(BaseModel): endpoint: str = Field(max_length=1000) keys: dict = {} @field_validator("endpoint") @classmethod def _endpoint_ok(cls, v): if not is_allowed_push_endpoint(v): raise ValueError("Недопустимый адрес push-сервиса") return v class SyncItemIn(BaseModel): item_id: int value: Optional[str] = None answered_at: Optional[datetime] = None # когда ответ дан на устройстве (для разрешения конфликтов) class SyncIn(BaseModel): items: list[SyncItemIn] = [] submit: bool = False class SyncItemResult(BaseModel): item_id: int status: Literal["ok", "stale", "error"] detail: str = "" class SyncOut(BaseModel): results: list[SyncItemResult] submitted: bool = False submit_error: str = "" order: OrderOut class DeviceIn(BaseModel): token: str = Field(min_length=10, max_length=300) platform: str = "android" # ================= Портал ================= Qty = Annotated[float, Field(gt=0, le=1_000_000)] class WarehouseIn(BaseModel): name: str = Field(min_length=1, max_length=150) notes: str = "" is_active: bool = True class Zone(BaseModel): """Прямоугольник на карте, % от размеров картинки.""" x: float = Field(ge=0, le=100) y: float = Field(ge=0, le=100) w: float = Field(gt=0, le=100) h: float = Field(gt=0, le=100) class WarehouseOut(ORM): id: int name: str notes: str is_active: bool zone: Optional[Zone] = None positions: int = 0 # сколько разных позиций лежит total_qty: float = 0.0 class ZoneIn(BaseModel): zone: Optional[Zone] = None # null — убрать зону с карты class InventoryItemIn(BaseModel): name: str = Field(min_length=1, max_length=200) category: str = Field(default="", max_length=100) unit: str = Field(default="шт", max_length=20) inventory_no: str = Field(default="", max_length=64) notes: str = "" is_active: bool = True class InventoryItemOut(InventoryItemIn, ORM): id: int in_stock: float = 0.0 # на складах issued: float = 0.0 # выдано сотрудникам class StockOut(BaseModel): item_id: int item_name: str category: str unit: str inventory_no: str warehouse_id: Optional[int] = None warehouse_name: Optional[str] = None user_id: Optional[int] = None user_name: Optional[str] = None qty: float since: datetime class Place(BaseModel): """Где находится: склад или сотрудник (ровно одно).""" warehouse_id: Optional[int] = None user_id: Optional[int] = None class MoveIn(BaseModel): """kind=receive: только to (поступление); writeoff: только from (списание); move: from и to (выдача, возврат, перемещение).""" kind: Literal["receive", "move", "writeoff"] item_id: int qty: Qty = 1 src: Optional[Place] = Field(default=None, alias="from") dst: Optional[Place] = Field(default=None, alias="to") note: str = Field(default="", max_length=1000) model_config = ConfigDict(populate_by_name=True) class MoveOut(BaseModel): id: int item_id: int item_name: str unit: str qty: float kind: str from_text: str to_text: str by_name: Optional[str] at: datetime note: str RequestStatus = Literal["new", "approved", "ordered", "done", "rejected"] class ToolRequestIn(BaseModel): what: str = Field(min_length=2, max_length=300) why: str = Field(min_length=2, max_length=4000) link: str = Field(default="", max_length=1000) qty: Qty = 1 @field_validator("link") @classmethod def _link_ok(cls, v): v = v.strip() if v and not re.match(r"https?://[^\s/$.?#][^\s]*$", v, re.I): raise ValueError("Ссылка должна начинаться с http:// или https://") return v class ToolRequestAnswer(BaseModel): status: RequestStatus answer: str = Field(default="", max_length=4000) class ToolRequestOut(ORM): id: int author_id: int author_name: Optional[str] = None what: str why: str link: str qty: float status: str answer: str handled_by_id: Optional[int] handled_by_name: Optional[str] = None created_at: datetime updated_at: datetime class IncidentUnitIn(BaseModel): unit_id: int replaced: bool = False IncidentKind = Literal["incident", "fault"] class IncidentIn(BaseModel): kind: IncidentKind = "incident" # incident — авария; fault — сообщение о неисправности (оборудование работает) equipment_id: int title: str = Field(min_length=2, max_length=200) description: str = Field(default="", max_length=8000) occurred_at: Optional[datetime] = None units: list[IncidentUnitIn] = [] replaced: bool = False # была ли замена (в целом) replacement_note: str = Field(default="", max_length=4000) class PublicIncidentIn(BaseModel): """Публичная форма (без входа): место, машина, описание и контакты обязательны.""" location_id: int equipment_id: int kind: IncidentKind = "incident" # оборудование остановлено (incident) или работает с неисправностью (fault) description: str = Field(min_length=5, max_length=4000) title: str = Field(default="", max_length=200) reporter_name: str = Field(min_length=2, max_length=150) reporter_contact: str = Field(min_length=5, max_length=200) website: str = "" # ловушка для ботов: люди это поле не видят и не заполняют class ExternalIncidentIn(BaseModel): """Инцидент из внешней системы. Машина — по id портала или по инвентарному номеру.""" equipment_id: Optional[int] = None inventory_no: Optional[str] = Field(default=None, max_length=64) kind: IncidentKind = "incident" title: str = Field(min_length=2, max_length=200) description: str = Field(default="", max_length=8000) occurred_at: Optional[datetime] = None reporter_name: str = Field(default="", max_length=150) reporter_contact: str = Field(default="", max_length=200) external_id: str = Field(default="", max_length=100) class ApiKeyIn(BaseModel): name: str = Field(min_length=2, max_length=150) class ApiKeyOut(ORM): id: int name: str prefix: str is_active: bool created_at: datetime last_used_at: Optional[datetime] key: Optional[str] = None # только в ответе на создание class IncidentCloseIn(BaseModel): """Закрытие отказа или неисправности. Для отказа незаполненные поля сервер подставляет сам (начало работ — момент отказа, ввод — сейчас, наработка — снимок при регистрации), поэтому старые клиенты работают.""" note: str = Field(default="", max_length=4000) # что сделано (описание устранения) repair_started_at: Optional[datetime] = None restored_at: Optional[datetime] = None parts_wait_hours: Optional[float] = Field(default=None, ge=0, le=100000) hours_at_failure: Optional[float] = Field(default=None, ge=0, le=10_000_000) meter_at_restore: Optional[float] = Field(default=None, ge=0, le=10_000_000) cause_unit_id: Optional[int] = None cause_unit_other: str = Field(default="", max_length=200) action: Optional[Literal["repair", "replace", "adjust", "restart"]] = None nature: Optional[Literal["sudden", "gradual"]] = None cause: Optional[Literal["wear", "operation", "external", "power", "defect", "secondary", "unknown"]] = None precursor: Optional[Literal["inspection", "fault_report", "none"]] = None replaced_units: list[int] = [] # узлы, заменённые при ремонте (сброс ресурса) class IncidentNoteIn(BaseModel): note: str = Field(default="", max_length=4000) class IncidentUnitOut(ORM): unit_id: Optional[int] unit_name: str replaced: bool class IncidentOut(ORM): id: int kind: str = "incident" equipment_id: int equipment_name: Optional[str] = None author_id: Optional[int] author_name: Optional[str] = None occurred_at: datetime created_at: datetime title: str description: str replaced: bool replacement_note: str status: str closed_at: Optional[datetime] closed_by_name: Optional[str] = None close_note: str source: str = "staff" reporter_name: str = "" reporter_contact: str = "" external_id: str = "" units: list[IncidentUnitOut] = [] hours_at_open: Optional[float] = None escalated_at: Optional[datetime] = None repair_started_at: Optional[datetime] = None restored_at: Optional[datetime] = None parts_wait_hours: Optional[float] = None hours_at_failure: Optional[float] = None meter_at_restore: Optional[float] = None cause_unit_id: Optional[int] = None cause_unit_name: Optional[str] = None cause_unit_other: str = "" action: str = "" nature: str = "" cause: str = "" precursor: str = "" TaskStatus = Literal["assigned", "in_progress", "paused", "done", "cancelled"] class TaskIn(BaseModel): title: str = Field(min_length=2, max_length=200) description: str = Field(default="", max_length=8000) day: Optional[date] = None assignee_id: Optional[int] = None # пусто — себе equipment_id: Optional[int] = None unit_id: Optional[int] = None labor_hours: Optional[float] = Field(default=None, ge=0, le=1000) photo_keep: bool = False # фото к заданию хранить более года class TaskPatch(BaseModel): title: Optional[str] = Field(default=None, min_length=2, max_length=200) description: Optional[str] = Field(default=None, max_length=8000) day: Optional[date] = None assignee_id: Optional[int] = None equipment_id: Optional[int] = None unit_id: Optional[int] = None labor_hours: Optional[float] = Field(default=None, ge=0, le=1000) photo_keep: Optional[bool] = None class TaskStatusIn(BaseModel): status: TaskStatus comment: str = Field(default="", max_length=4000) class TaskEventOut(BaseModel): at: datetime user_name: Optional[str] from_status: str to_status: str comment: str kind: str = "status" from_user_name: Optional[str] = None to_user_name: Optional[str] = None class TaskTransferIn(BaseModel): assignee_id: int comment: str = Field(min_length=1, max_length=4000) class TaskOut(BaseModel): id: int title: str description: str day: date assignee_id: int assignee_name: Optional[str] created_by_id: Optional[int] created_by_name: Optional[str] equipment_id: Optional[int] equipment_name: Optional[str] unit_id: Optional[int] unit_name: Optional[str] status: str created_at: datetime updated_at: datetime finished_at: Optional[datetime] labor_hours: Optional[float] = None photo_keep: bool = False last_comment: str = "" photos: list[int] = [] can_change: bool = False can_edit: bool = False can_transfer: bool = False events: Optional[list[TaskEventOut]] = None class MapMarkerIn(BaseModel): """Положение оборудования на карте; x/y = null — убрать с карты.""" x: Optional[float] = Field(default=None, ge=0, le=100) y: Optional[float] = Field(default=None, ge=0, le=100) class ProfileIn(BaseModel): skills: Optional[str] = Field(default=None, max_length=4000) phone: Optional[str] = Field(default=None, max_length=40)