Add event type handling for duties in the system

- Introduced a new `event_type` column in the `duties` table to categorize duties as 'duty', 'unavailable', or 'vacation'.
- Updated the duty schedule import functionality to parse and store event types from the JSON input.
- Enhanced the API response to include event types for each duty, improving the calendar display logic.
- Modified the web application to visually differentiate between duty types in the calendar and duty list.
- Updated tests to cover new event type functionality and ensure correct parsing and storage of duties.
- Revised README documentation to reflect changes in duty event types and their representation in the system.
This commit is contained in:
2026-02-17 23:01:07 +03:00
parent 78a1696a69
commit 7a963eccd1
12 changed files with 279 additions and 60 deletions

View File

@@ -97,7 +97,7 @@ To add commands, define async handlers in `handlers/commands.py` (or a new modul
- **meta**: обязательное поле `start_date` (YYYY-MM-DD), опционально `weeks`; количество дней определяется по длине строки `duty`.
- **schedule**: массив объектов с полями:
- `name` — ФИО (строка);
- `duty` — строка с разделителем `;`: каждый элемент соответствует дню с `start_date` по порядку. Пусто или пробелы — нет дежурства; символы **б**, **Б**, **в**, **Н**, **О** — день дежурства.
- `duty` — строка с разделителем `;`: каждый элемент соответствует дню с `start_date` по порядку. Пусто или пробелы — нет события; **в**, **В**, **б**, **Б** — дежурство; **Н** — недоступен; **О** — отпуск.
При повторном импорте дежурства в том же диапазоне дат для каждого пользователя заменяются новыми.

View File

@@ -0,0 +1,28 @@
"""Duties: add event_type (duty | unavailable | vacation)
Revision ID: 003
Revises: 002
Create Date: 2025-02-17
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "003"
down_revision: Union[str, None] = "002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"duties",
sa.Column("event_type", sa.Text(), nullable=False, server_default="duty"),
)
def downgrade() -> None:
op.drop_column("duties", "event_type")

View File

@@ -46,6 +46,7 @@ def _fetch_duties_response(from_date: str, to_date: str) -> list[DutyWithUser]:
start_at=duty.start_at,
end_at=duty.end_at,
full_name=full_name,
event_type=duty.event_type,
)
for duty, full_name in rows
]

View File

@@ -35,5 +35,7 @@ class Duty(Base):
# UTC, ISO 8601 with Z suffix (e.g. 2025-01-15T09:00:00Z)
start_at: Mapped[str] = mapped_column(Text, nullable=False)
end_at: Mapped[str] = mapped_column(Text, nullable=False)
# duty | unavailable | vacation
event_type: Mapped[str] = mapped_column(Text, nullable=False, server_default="duty")
user: Mapped["User"] = relationship("User", back_populates="duties")

View File

@@ -102,9 +102,16 @@ def insert_duty(
user_id: int,
start_at: str,
end_at: str,
event_type: str = "duty",
) -> Duty:
"""Create a duty. start_at and end_at must be UTC, ISO 8601 with Z (e.g. 2025-01-15T09:00:00Z)."""
duty = Duty(user_id=user_id, start_at=start_at, end_at=end_at)
"""Create a duty. start_at and end_at must be UTC, ISO 8601 with Z.
event_type: 'duty' | 'unavailable' | 'vacation'."""
duty = Duty(
user_id=user_id,
start_at=start_at,
end_at=end_at,
event_type=event_type,
)
session.add(duty)
session.commit()
session.refresh(duty)

View File

@@ -1,5 +1,7 @@
"""Pydantic schemas for API and validation."""
from typing import Literal
from pydantic import BaseModel, ConfigDict
@@ -38,9 +40,10 @@ class DutyInDb(DutyBase):
class DutyWithUser(DutyInDb):
"""Duty with full_name for calendar display."""
"""Duty with full_name and event_type for calendar display."""
full_name: str
event_type: Literal["duty", "unavailable", "vacation"] = "duty"
model_config = ConfigDict(from_attributes=True)

View File

@@ -101,22 +101,37 @@ def _run_import(
result: DutyScheduleResult,
hour_utc: int,
minute_utc: int,
) -> tuple[int, int]:
) -> tuple[int, int, int, int]:
"""Returns (num_users, num_duty, num_unavailable, num_vacation)."""
session = get_session(database_url)
try:
from_date_str = result.start_date.isoformat()
to_date_str = result.end_date.isoformat()
total_duties = 0
for full_name, duty_dates in result.entries:
user = get_or_create_user_by_full_name(session, full_name)
num_duty = num_unavailable = num_vacation = 0
for entry in result.entries:
user = get_or_create_user_by_full_name(session, entry.full_name)
delete_duties_in_range(session, user.id, from_date_str, to_date_str)
for d in duty_dates:
for d in entry.duty_dates:
start_at = _duty_to_iso(d, hour_utc, minute_utc)
d_next = d + timedelta(days=1)
end_at = _duty_to_iso(d_next, hour_utc, minute_utc)
insert_duty(session, user.id, start_at, end_at)
total_duties += 1
return (len(result.entries), total_duties)
insert_duty(session, user.id, start_at, end_at, event_type="duty")
num_duty += 1
for d in entry.unavailable_dates:
start_at = _duty_to_iso(d, hour_utc, minute_utc)
d_next = d + timedelta(days=1)
end_at = _duty_to_iso(d_next, hour_utc, minute_utc)
insert_duty(
session, user.id, start_at, end_at, event_type="unavailable"
)
num_unavailable += 1
for d in entry.vacation_dates:
start_at = _duty_to_iso(d, hour_utc, minute_utc)
d_next = d + timedelta(days=1)
end_at = _duty_to_iso(d_next, hour_utc, minute_utc)
insert_duty(session, user.id, start_at, end_at, event_type="vacation")
num_vacation += 1
return (len(result.entries), num_duty, num_unavailable, num_vacation)
finally:
session.close()
@@ -149,15 +164,21 @@ async def handle_duty_schedule_document(update: Update, context: ContextTypes.DE
loop = asyncio.get_running_loop()
try:
num_users, num_duties = await loop.run_in_executor(
num_users, num_duty, num_unavailable, num_vacation = await loop.run_in_executor(
None,
lambda: _run_import(config.DATABASE_URL, result, hour_utc, minute_utc),
)
except Exception as e:
await update.message.reply_text(f"Ошибка импорта: {e}")
else:
total = num_duty + num_unavailable + num_vacation
parts = [f"{num_users} пользователей", f"{num_duty} дежурств"]
if num_unavailable:
parts.append(f"{num_unavailable} недоступностей")
if num_vacation:
parts.append(f"{num_vacation} отпусков")
await update.message.reply_text(
f"Импорт выполнен: {num_users} пользователей, {num_duties} дежурств."
"Импорт выполнен: " + ", ".join(parts) + f" (всего {total} событий)."
)
finally:
context.user_data.pop("awaiting_duty_schedule_file", None)

View File

@@ -4,17 +4,29 @@ import json
from dataclasses import dataclass
from datetime import date, timedelta
# Символы, обозначающие день дежурства в ячейке duty (CSV с разделителем ;)
DUTY_MARKERS = frozenset({"б", "Б", "в", "Н", "О"})
# Символы дежурства в ячейке duty (CSV с разделителем ;)
DUTY_MARKERS = frozenset({"б", "Б", "в", "В"})
UNAVAILABLE_MARKER = "Н"
VACATION_MARKER = "О"
@dataclass
class DutyScheduleEntry:
"""One person's schedule: full_name and three lists of dates by event type."""
full_name: str
duty_dates: list[date]
unavailable_dates: list[date]
vacation_dates: list[date]
@dataclass
class DutyScheduleResult:
"""Parsed duty schedule: start_date, end_date, and per-person duty dates."""
"""Parsed duty schedule: start_date, end_date, and per-person entries."""
start_date: date
end_date: date
entries: list[tuple[str, list[date]]] # (full_name, list of duty dates)
entries: list[DutyScheduleEntry]
class DutyScheduleParseError(Exception):
@@ -24,12 +36,12 @@ class DutyScheduleParseError(Exception):
def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult:
"""Parse duty-schedule JSON. Returns start_date, end_date, and list of (full_name, duty_dates).
"""Parse duty-schedule JSON. Returns start_date, end_date, and list of DutyScheduleEntry.
- meta.start_date (YYYY-MM-DD) and schedule (array) required.
- meta.weeks optional; number of days from max duty string length (split by ';').
- For each schedule item: name (required), duty = CSV with ';'; index i = start_date + i days.
- Cell value after strip in DUTY_MARKERS => duty day.
- Cell value after strip: в/В/б/Б => duty, Н => unavailable, О => vacation; rest ignored.
"""
try:
data = json.loads(raw_bytes.decode("utf-8"))
@@ -53,7 +65,7 @@ def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult:
raise DutyScheduleParseError("Missing or invalid 'schedule' (must be array)")
max_days = 0
entries: list[tuple[str, list[date]]] = []
entries: list[DutyScheduleEntry] = []
for row in schedule:
if not isinstance(row, dict):
@@ -75,11 +87,24 @@ def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult:
max_days = max(max_days, len(cells))
duty_dates: list[date] = []
unavailable_dates: list[date] = []
vacation_dates: list[date] = []
for i, cell in enumerate(cells):
d = start_date + timedelta(days=i)
if cell in DUTY_MARKERS:
d = start_date + timedelta(days=i)
duty_dates.append(d)
entries.append((full_name, duty_dates))
elif cell == UNAVAILABLE_MARKER:
unavailable_dates.append(d)
elif cell == VACATION_MARKER:
vacation_dates.append(d)
entries.append(
DutyScheduleEntry(
full_name=full_name,
duty_dates=duty_dates,
unavailable_dates=unavailable_dates,
vacation_dates=vacation_dates,
)
)
if max_days == 0:
end_date = start_date

View File

@@ -6,7 +6,10 @@ import pytest
from importers.duty_schedule import (
DUTY_MARKERS,
UNAVAILABLE_MARKER,
VACATION_MARKER,
DutyScheduleParseError,
DutyScheduleEntry,
parse_duty_schedule,
)
@@ -24,16 +27,19 @@ def test_parse_valid_schedule():
# Petrov has 7 cells -> end = start + 6
assert result.end_date == date(2026, 2, 22)
assert len(result.entries) == 2
names = [e[0] for e in result.entries]
assert "Ivanov I.I." in names
assert "Petrov P.P." in names
by_name = {e[0]: e[1] for e in result.entries}
# Ivanov: indices 2, 3, 4 are duty (б, Б, в) -> 2026-02-18, 19, 20
ivan_dates = sorted(by_name["Ivanov I.I."])
assert ivan_dates == [date(2026, 2, 18), date(2026, 2, 19), date(2026, 2, 20)]
# Petrov: indices 1, 2 (Н, О) -> 2026-02-17, 18
petr_dates = sorted(by_name["Petrov P.P."])
assert petr_dates == [date(2026, 2, 17), date(2026, 2, 18)]
by_name = {e.full_name: e for e in result.entries}
assert "Ivanov I.I." in by_name
assert "Petrov P.P." in by_name
# Ivanov: only duty (б, Б, в) -> 2026-02-18, 19, 20
ivan = by_name["Ivanov I.I."]
assert sorted(ivan.duty_dates) == [date(2026, 2, 18), date(2026, 2, 19), date(2026, 2, 20)]
assert ivan.unavailable_dates == []
assert ivan.vacation_dates == []
# Petrov: one Н (unavailable), one О (vacation) -> 2026-02-17, 18
petr = by_name["Petrov P.P."]
assert petr.duty_dates == []
assert petr.unavailable_dates == [date(2026, 2, 17)]
assert petr.vacation_dates == [date(2026, 2, 18)]
def test_parse_empty_duty_string():
@@ -41,7 +47,11 @@ def test_parse_empty_duty_string():
result = parse_duty_schedule(raw)
assert result.start_date == date(2026, 2, 1)
assert result.end_date == date(2026, 2, 1)
assert result.entries == [("A", [])]
assert len(result.entries) == 1
assert result.entries[0].full_name == "A"
assert result.entries[0].duty_dates == []
assert result.entries[0].unavailable_dates == []
assert result.entries[0].vacation_dates == []
def test_parse_invalid_json():
@@ -89,4 +99,19 @@ def test_parse_schedule_item_empty_name():
def test_duty_markers():
assert "б" in DUTY_MARKERS and "Б" in DUTY_MARKERS and "в" in DUTY_MARKERS
assert DUTY_MARKERS == frozenset({"б", "Б", "в", "В"})
assert "Н" not in DUTY_MARKERS and "О" not in DUTY_MARKERS
def test_unavailable_and_vacation_markers():
assert UNAVAILABLE_MARKER == "Н"
assert VACATION_MARKER == "О"
raw = (
'{"meta": {"start_date": "2026-02-01"}, "schedule": ['
'{"name": "X", "duty": "\u041d; \u041e; \u0432"}]}'
).encode("utf-8")
result = parse_duty_schedule(raw)
entry = result.entries[0]
assert entry.unavailable_dates == [date(2026, 2, 1)]
assert entry.vacation_dates == [date(2026, 2, 2)]
assert entry.duty_dates == [date(2026, 2, 3)]

View File

@@ -5,10 +5,9 @@ from datetime import date
import pytest
from db import init_db
from db.models import Base
from db.repository import get_duties
from db.session import get_session
from importers.duty_schedule import DutyScheduleResult, parse_duty_schedule
from importers.duty_schedule import DutyScheduleEntry, DutyScheduleResult, parse_duty_schedule
from handlers.import_duty_schedule import _run_import
@@ -36,17 +35,28 @@ def test_import_creates_users_and_duties(db_url):
start_date=date(2026, 2, 16),
end_date=date(2026, 2, 18),
entries=[
("Ivanov I.I.", [date(2026, 2, 16), date(2026, 2, 18)]),
("Petrov P.P.", [date(2026, 2, 17)]),
DutyScheduleEntry(
full_name="Ivanov I.I.",
duty_dates=[date(2026, 2, 16), date(2026, 2, 18)],
unavailable_dates=[],
vacation_dates=[],
),
DutyScheduleEntry(
full_name="Petrov P.P.",
duty_dates=[date(2026, 2, 17)],
unavailable_dates=[],
vacation_dates=[],
),
],
)
num_users, num_duties = _run_import(db_url, result, 6, 0)
num_users, num_duty, num_unav, num_vac = _run_import(db_url, result, 6, 0)
assert num_users == 2
assert num_duties == 3
assert num_duty == 3
assert num_unav == 0
assert num_vac == 0
session = get_session(db_url)
try:
# to_date inclusive: duty on 18th has start_at 2026-02-18T06:00:00Z, so use 2026-02-19
duties = get_duties(session, "2026-02-16", "2026-02-19")
finally:
session.close()
@@ -56,6 +66,8 @@ def test_import_creates_users_and_duties(db_url):
assert "2026-02-16T06:00:00Z" in starts
assert "2026-02-17T06:00:00Z" in starts
assert "2026-02-18T06:00:00Z" in starts
for d, _ in duties:
assert d.event_type == "duty"
def test_import_replaces_duties_in_range(db_url):
@@ -63,7 +75,14 @@ def test_import_replaces_duties_in_range(db_url):
result1 = DutyScheduleResult(
start_date=date(2026, 2, 16),
end_date=date(2026, 2, 17),
entries=[("Sidorov", [date(2026, 2, 16), date(2026, 2, 17)])],
entries=[
DutyScheduleEntry(
full_name="Sidorov",
duty_dates=[date(2026, 2, 16), date(2026, 2, 17)],
unavailable_dates=[],
vacation_dates=[],
)
],
)
_run_import(db_url, result1, 9, 0)
@@ -77,7 +96,14 @@ def test_import_replaces_duties_in_range(db_url):
result2 = DutyScheduleResult(
start_date=date(2026, 2, 16),
end_date=date(2026, 2, 17),
entries=[("Sidorov", [date(2026, 2, 17)])],
entries=[
DutyScheduleEntry(
full_name="Sidorov",
duty_dates=[date(2026, 2, 17)],
unavailable_dates=[],
vacation_dates=[],
)
],
)
_run_import(db_url, result2, 9, 0)
@@ -97,9 +123,11 @@ def test_import_full_flow_parse_then_import(db_url):
'"schedule": [{"name": "Alexey A.", "duty": "\u0431; ; \u0432"}]}'
).encode("utf-8")
parsed = parse_duty_schedule(raw)
num_users, num_duties = _run_import(db_url, parsed, 6, 0)
num_users, num_duty, num_unav, num_vac = _run_import(db_url, parsed, 6, 0)
assert num_users == 1
assert num_duties == 2
assert num_duty == 2
assert num_unav == 0
assert num_vac == 0
session = get_session(db_url)
try:
@@ -108,3 +136,32 @@ def test_import_full_flow_parse_then_import(db_url):
session.close()
assert len(duties) == 2
assert duties[0][1] == "Alexey A."
assert duties[0][0].event_type == "duty"
def test_import_event_types_unavailable_vacation(db_url):
"""Import creates records with event_type duty, unavailable, vacation."""
result = DutyScheduleResult(
start_date=date(2026, 2, 16),
end_date=date(2026, 2, 18),
entries=[
DutyScheduleEntry(
full_name="Mixed User",
duty_dates=[date(2026, 2, 16)],
unavailable_dates=[date(2026, 2, 17)],
vacation_dates=[date(2026, 2, 18)],
),
],
)
num_users, num_duty, num_unav, num_vac = _run_import(db_url, result, 6, 0)
assert num_users == 1
assert num_duty == 1 and num_unav == 1 and num_vac == 1
session = get_session(db_url)
try:
duties = get_duties(session, "2026-02-16", "2026-02-19")
finally:
session.close()
assert len(duties) == 3
types = {d[0].event_type for d in duties}
assert types == {"duty", "unavailable", "vacation"}

View File

@@ -198,18 +198,34 @@
const key = localDateString(d);
const isOther = d.getMonth() !== month;
const dayDuties = dutiesByDate[key] || [];
const dutyList = dayDuties.filter(function (x) { return x.event_type === "duty"; });
const unavailableList = dayDuties.filter(function (x) { return x.event_type === "unavailable"; });
const vacationList = dayDuties.filter(function (x) { return x.event_type === "vacation"; });
const hasAny = dayDuties.length > 0;
const isToday = key === today;
const eventSummaries = calendarEventsByDate[key] || [];
const hasEvent = eventSummaries.length > 0;
const cell = document.createElement("div");
cell.className = "day" + (isOther ? " other-month" : "") + (isToday ? " today" : "") + (dayDuties.length ? " has-duty" : "") + (hasEvent ? " holiday" : "");
const dutyNamesAttr = dayDuties.length ? escapeHtml(dayDuties.map(function (x) { return x.full_name; }).join("\n")) : "";
const dutyTitleAttr = dayDuties.length ? escapeHtml(dayDuties.map(function (x) { return x.full_name; }).join(", ")) : "";
cell.innerHTML =
"<span class=\"num\">" + d.getDate() + "</span>" +
(dayDuties.length ? "<span class=\"duty-marker\" data-duty-names=\"" + dutyNamesAttr + "\" title=\"" + dutyTitleAttr + "\" aria-label=\"Дежурные\">Д</span>" : "") +
(hasEvent ? "<button type=\"button\" class=\"info-btn\" aria-label=\"Информация о дне\" data-summary=\"" + escapeHtml(eventSummaries.join("\n")) + "\">i</button>" : "");
cell.className = "day" + (isOther ? " other-month" : "") + (isToday ? " today" : "") + (hasAny ? " has-duty" : "") + (hasEvent ? " holiday" : "");
function namesAttr(list) { return list.length ? escapeHtml(list.map(function (x) { return x.full_name; }).join("\n")) : ""; }
function titleAttr(list) { return list.length ? escapeHtml(list.map(function (x) { return x.full_name; }).join(", ")) : ""; }
let markers = "<span class=\"num\">" + d.getDate() + "</span>";
if (dutyList.length) {
markers += "<span class=\"duty-marker\" data-names=\"" + namesAttr(dutyList) + "\" title=\"" + titleAttr(dutyList) + "\" aria-label=\"Дежурные\">Д</span>";
}
if (unavailableList.length) {
markers += "<span class=\"unavailable-marker\" data-names=\"" + namesAttr(unavailableList) + "\" title=\"" + titleAttr(unavailableList) + "\" aria-label=\"Недоступен\">Н</span>";
}
if (vacationList.length) {
markers += "<span class=\"vacation-marker\" data-names=\"" + namesAttr(vacationList) + "\" title=\"" + titleAttr(vacationList) + "\" aria-label=\"Отпуск\">О</span>";
}
if (hasEvent) {
markers += "<button type=\"button\" class=\"info-btn\" aria-label=\"Информация о дне\" data-summary=\"" + escapeHtml(eventSummaries.join("\n")) + "\">i</button>";
}
cell.innerHTML = markers;
calendarEl.appendChild(cell);
d.setDate(d.getDate() + 1);
}
@@ -312,13 +328,14 @@
document.body.appendChild(hintEl);
}
var hideTimeout = null;
calendarEl.querySelectorAll(".duty-marker").forEach(function (marker) {
var selector = ".duty-marker, .unavailable-marker, .vacation-marker";
calendarEl.querySelectorAll(selector).forEach(function (marker) {
marker.addEventListener("mouseenter", function () {
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
var names = marker.getAttribute("data-duty-names") || "";
var names = marker.getAttribute("data-names") || "";
hintEl.textContent = names;
var rect = marker.getBoundingClientRect();
positionHint(hintEl, rect);
@@ -333,9 +350,11 @@
});
}
var EVENT_TYPE_LABELS = { duty: "Дежурство", unavailable: "Недоступен", vacation: "Отпуск" };
function renderDutyList(duties) {
if (duties.length === 0) {
dutyListEl.innerHTML = "<p class=\"muted\">В этом месяце дежурств нет.</p>";
dutyListEl.innerHTML = "<p class=\"muted\">В этом месяце событий нет.</p>";
return;
}
const grouped = {};
@@ -354,7 +373,9 @@
const endDate = new Date(d.end_at);
const start = String(startDate.getHours()).padStart(2, "0") + ":" + String(startDate.getMinutes()).padStart(2, "0");
const end = String(endDate.getHours()).padStart(2, "0") + ":" + String(endDate.getMinutes()).padStart(2, "0");
html += "<div class=\"duty-item\"><span class=\"name\">" + escapeHtml(d.full_name) + "</span><div class=\"time\">" + start + " " + end + "</div></div>";
const typeLabel = EVENT_TYPE_LABELS[d.event_type] || d.event_type;
const itemClass = "duty-item duty-item--" + (d.event_type || "duty");
html += "<div class=\"" + itemClass + "\"><span class=\"duty-item-type\">" + escapeHtml(typeLabel) + "</span> <span class=\"name\">" + escapeHtml(d.full_name) + "</span><div class=\"time\">" + start + " " + end + "</div></div>";
});
});
dutyListEl.innerHTML = html;

View File

@@ -164,7 +164,9 @@ body {
transform: none;
}
.duty-marker {
.duty-marker,
.unavailable-marker,
.vacation-marker {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -173,12 +175,25 @@ body {
margin-top: 2px;
font-size: 0.65rem;
font-weight: 700;
color: var(--duty);
background: rgba(158, 206, 106, 0.25);
border-radius: 50%;
flex-shrink: 0;
}
.duty-marker {
color: var(--duty);
background: rgba(158, 206, 106, 0.25);
}
.unavailable-marker {
color: #e0af68;
background: rgba(224, 175, 104, 0.25);
}
.vacation-marker {
color: #7dcfff;
background: rgba(125, 207, 255, 0.25);
}
.duty-list {
font-size: 0.9rem;
}
@@ -197,6 +212,20 @@ body {
border-left: 3px solid var(--duty);
}
.duty-item--unavailable {
border-left-color: #e0af68;
}
.duty-item--vacation {
border-left-color: #7dcfff;
}
.duty-item .duty-item-type {
font-size: 0.75rem;
color: var(--muted);
margin-right: 4px;
}
.duty-item .name {
font-weight: 600;
}