Files
darkorion 0f239d5015 feat: документация из Nextcloud, график у сотрудника, автоочистка фото, мониторинг, итог мастера, фильтр по местам; доп. сервисы в docker-compose
- docker-compose.yml: профили docs (Nextcloud), office (ONLYOFFICE Document Server), ai (Ollama + скачивание модели)
- автонастройка: адреса Nextcloud, ONLYOFFICE, портала и модели подставляются из .env или по именам сервисов
  во внутренней сети Docker; значения из интерфейса важнее
- локальная модель в своей сети (имя сервиса, частный IP) подключается без ключа API
- .env.example, docs/DEPLOY.md (раздел 7а), README
2026-10-13 13:27:20 +03:00

923 lines
29 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)