Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android

This commit is contained in:
darkorion
2026-10-13 09:09:53 +03:00
commit c298f5a830
247 changed files with 23200 additions and 0 deletions
+911
View File
@@ -0,0 +1,911 @@
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
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
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
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 # доступен раздел «Анализ надёжности» (включён и у пользователя есть права)
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
# ----- регламентное обслуживание: узлы машины -----
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"
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
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 # раздел «Анализ надёжности»
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)
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)
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
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)