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:
@@ -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`.
|
- **meta**: обязательное поле `start_date` (YYYY-MM-DD), опционально `weeks`; количество дней определяется по длине строки `duty`.
|
||||||
- **schedule**: массив объектов с полями:
|
- **schedule**: массив объектов с полями:
|
||||||
- `name` — ФИО (строка);
|
- `name` — ФИО (строка);
|
||||||
- `duty` — строка с разделителем `;`: каждый элемент соответствует дню с `start_date` по порядку. Пусто или пробелы — нет дежурства; символы **б**, **Б**, **в**, **Н**, **О** — день дежурства.
|
- `duty` — строка с разделителем `;`: каждый элемент соответствует дню с `start_date` по порядку. Пусто или пробелы — нет события; **в**, **В**, **б**, **Б** — дежурство; **Н** — недоступен; **О** — отпуск.
|
||||||
|
|
||||||
При повторном импорте дежурства в том же диапазоне дат для каждого пользователя заменяются новыми.
|
При повторном импорте дежурства в том же диапазоне дат для каждого пользователя заменяются новыми.
|
||||||
|
|
||||||
|
|||||||
28
alembic/versions/003_duties_event_type.py
Normal file
28
alembic/versions/003_duties_event_type.py
Normal 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")
|
||||||
@@ -46,6 +46,7 @@ def _fetch_duties_response(from_date: str, to_date: str) -> list[DutyWithUser]:
|
|||||||
start_at=duty.start_at,
|
start_at=duty.start_at,
|
||||||
end_at=duty.end_at,
|
end_at=duty.end_at,
|
||||||
full_name=full_name,
|
full_name=full_name,
|
||||||
|
event_type=duty.event_type,
|
||||||
)
|
)
|
||||||
for duty, full_name in rows
|
for duty, full_name in rows
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -35,5 +35,7 @@ class Duty(Base):
|
|||||||
# UTC, ISO 8601 with Z suffix (e.g. 2025-01-15T09:00:00Z)
|
# UTC, ISO 8601 with Z suffix (e.g. 2025-01-15T09:00:00Z)
|
||||||
start_at: Mapped[str] = mapped_column(Text, nullable=False)
|
start_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
end_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")
|
user: Mapped["User"] = relationship("User", back_populates="duties")
|
||||||
|
|||||||
@@ -102,9 +102,16 @@ def insert_duty(
|
|||||||
user_id: int,
|
user_id: int,
|
||||||
start_at: str,
|
start_at: str,
|
||||||
end_at: str,
|
end_at: str,
|
||||||
|
event_type: str = "duty",
|
||||||
) -> Duty:
|
) -> Duty:
|
||||||
"""Create a duty. start_at and end_at must be UTC, ISO 8601 with Z (e.g. 2025-01-15T09:00:00Z)."""
|
"""Create a duty. start_at and end_at must be UTC, ISO 8601 with Z.
|
||||||
duty = Duty(user_id=user_id, start_at=start_at, end_at=end_at)
|
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.add(duty)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(duty)
|
session.refresh(duty)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Pydantic schemas for API and validation."""
|
"""Pydantic schemas for API and validation."""
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
@@ -38,9 +40,10 @@ class DutyInDb(DutyBase):
|
|||||||
|
|
||||||
|
|
||||||
class DutyWithUser(DutyInDb):
|
class DutyWithUser(DutyInDb):
|
||||||
"""Duty with full_name for calendar display."""
|
"""Duty with full_name and event_type for calendar display."""
|
||||||
|
|
||||||
full_name: str
|
full_name: str
|
||||||
|
event_type: Literal["duty", "unavailable", "vacation"] = "duty"
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|||||||
@@ -101,22 +101,37 @@ def _run_import(
|
|||||||
result: DutyScheduleResult,
|
result: DutyScheduleResult,
|
||||||
hour_utc: int,
|
hour_utc: int,
|
||||||
minute_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)
|
session = get_session(database_url)
|
||||||
try:
|
try:
|
||||||
from_date_str = result.start_date.isoformat()
|
from_date_str = result.start_date.isoformat()
|
||||||
to_date_str = result.end_date.isoformat()
|
to_date_str = result.end_date.isoformat()
|
||||||
total_duties = 0
|
num_duty = num_unavailable = num_vacation = 0
|
||||||
for full_name, duty_dates in result.entries:
|
for entry in result.entries:
|
||||||
user = get_or_create_user_by_full_name(session, full_name)
|
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)
|
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)
|
start_at = _duty_to_iso(d, hour_utc, minute_utc)
|
||||||
d_next = d + timedelta(days=1)
|
d_next = d + timedelta(days=1)
|
||||||
end_at = _duty_to_iso(d_next, hour_utc, minute_utc)
|
end_at = _duty_to_iso(d_next, hour_utc, minute_utc)
|
||||||
insert_duty(session, user.id, start_at, end_at)
|
insert_duty(session, user.id, start_at, end_at, event_type="duty")
|
||||||
total_duties += 1
|
num_duty += 1
|
||||||
return (len(result.entries), total_duties)
|
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:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
@@ -149,15 +164,21 @@ async def handle_duty_schedule_document(update: Update, context: ContextTypes.DE
|
|||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
try:
|
try:
|
||||||
num_users, num_duties = await loop.run_in_executor(
|
num_users, num_duty, num_unavailable, num_vacation = await loop.run_in_executor(
|
||||||
None,
|
None,
|
||||||
lambda: _run_import(config.DATABASE_URL, result, hour_utc, minute_utc),
|
lambda: _run_import(config.DATABASE_URL, result, hour_utc, minute_utc),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await update.message.reply_text(f"Ошибка импорта: {e}")
|
await update.message.reply_text(f"Ошибка импорта: {e}")
|
||||||
else:
|
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(
|
await update.message.reply_text(
|
||||||
f"Импорт выполнен: {num_users} пользователей, {num_duties} дежурств."
|
"Импорт выполнен: " + ", ".join(parts) + f" (всего {total} событий)."
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
context.user_data.pop("awaiting_duty_schedule_file", None)
|
context.user_data.pop("awaiting_duty_schedule_file", None)
|
||||||
|
|||||||
@@ -4,17 +4,29 @@ import json
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
# Символы, обозначающие день дежурства в ячейке duty (CSV с разделителем ;)
|
# Символы дежурства в ячейке duty (CSV с разделителем ;)
|
||||||
DUTY_MARKERS = frozenset({"б", "Б", "в", "Н", "О"})
|
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
|
@dataclass
|
||||||
class DutyScheduleResult:
|
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
|
start_date: date
|
||||||
end_date: date
|
end_date: date
|
||||||
entries: list[tuple[str, list[date]]] # (full_name, list of duty dates)
|
entries: list[DutyScheduleEntry]
|
||||||
|
|
||||||
|
|
||||||
class DutyScheduleParseError(Exception):
|
class DutyScheduleParseError(Exception):
|
||||||
@@ -24,12 +36,12 @@ class DutyScheduleParseError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult:
|
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.start_date (YYYY-MM-DD) and schedule (array) required.
|
||||||
- meta.weeks optional; number of days from max duty string length (split by ';').
|
- 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.
|
- 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:
|
try:
|
||||||
data = json.loads(raw_bytes.decode("utf-8"))
|
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)")
|
raise DutyScheduleParseError("Missing or invalid 'schedule' (must be array)")
|
||||||
|
|
||||||
max_days = 0
|
max_days = 0
|
||||||
entries: list[tuple[str, list[date]]] = []
|
entries: list[DutyScheduleEntry] = []
|
||||||
|
|
||||||
for row in schedule:
|
for row in schedule:
|
||||||
if not isinstance(row, dict):
|
if not isinstance(row, dict):
|
||||||
@@ -75,11 +87,24 @@ def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult:
|
|||||||
max_days = max(max_days, len(cells))
|
max_days = max(max_days, len(cells))
|
||||||
|
|
||||||
duty_dates: list[date] = []
|
duty_dates: list[date] = []
|
||||||
|
unavailable_dates: list[date] = []
|
||||||
|
vacation_dates: list[date] = []
|
||||||
for i, cell in enumerate(cells):
|
for i, cell in enumerate(cells):
|
||||||
|
d = start_date + timedelta(days=i)
|
||||||
if cell in DUTY_MARKERS:
|
if cell in DUTY_MARKERS:
|
||||||
d = start_date + timedelta(days=i)
|
|
||||||
duty_dates.append(d)
|
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:
|
if max_days == 0:
|
||||||
end_date = start_date
|
end_date = start_date
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import pytest
|
|||||||
|
|
||||||
from importers.duty_schedule import (
|
from importers.duty_schedule import (
|
||||||
DUTY_MARKERS,
|
DUTY_MARKERS,
|
||||||
|
UNAVAILABLE_MARKER,
|
||||||
|
VACATION_MARKER,
|
||||||
DutyScheduleParseError,
|
DutyScheduleParseError,
|
||||||
|
DutyScheduleEntry,
|
||||||
parse_duty_schedule,
|
parse_duty_schedule,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,16 +27,19 @@ def test_parse_valid_schedule():
|
|||||||
# Petrov has 7 cells -> end = start + 6
|
# Petrov has 7 cells -> end = start + 6
|
||||||
assert result.end_date == date(2026, 2, 22)
|
assert result.end_date == date(2026, 2, 22)
|
||||||
assert len(result.entries) == 2
|
assert len(result.entries) == 2
|
||||||
names = [e[0] for e in result.entries]
|
by_name = {e.full_name: e for e in result.entries}
|
||||||
assert "Ivanov I.I." in names
|
assert "Ivanov I.I." in by_name
|
||||||
assert "Petrov P.P." in names
|
assert "Petrov P.P." in by_name
|
||||||
by_name = {e[0]: e[1] for e in result.entries}
|
# Ivanov: only duty (б, Б, в) -> 2026-02-18, 19, 20
|
||||||
# Ivanov: indices 2, 3, 4 are duty (б, Б, в) -> 2026-02-18, 19, 20
|
ivan = by_name["Ivanov I.I."]
|
||||||
ivan_dates = sorted(by_name["Ivanov I.I."])
|
assert sorted(ivan.duty_dates) == [date(2026, 2, 18), date(2026, 2, 19), date(2026, 2, 20)]
|
||||||
assert ivan_dates == [date(2026, 2, 18), date(2026, 2, 19), date(2026, 2, 20)]
|
assert ivan.unavailable_dates == []
|
||||||
# Petrov: indices 1, 2 (Н, О) -> 2026-02-17, 18
|
assert ivan.vacation_dates == []
|
||||||
petr_dates = sorted(by_name["Petrov P.P."])
|
# Petrov: one Н (unavailable), one О (vacation) -> 2026-02-17, 18
|
||||||
assert petr_dates == [date(2026, 2, 17), date(2026, 2, 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():
|
def test_parse_empty_duty_string():
|
||||||
@@ -41,7 +47,11 @@ def test_parse_empty_duty_string():
|
|||||||
result = parse_duty_schedule(raw)
|
result = parse_duty_schedule(raw)
|
||||||
assert result.start_date == date(2026, 2, 1)
|
assert result.start_date == date(2026, 2, 1)
|
||||||
assert result.end_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():
|
def test_parse_invalid_json():
|
||||||
@@ -89,4 +99,19 @@ def test_parse_schedule_item_empty_name():
|
|||||||
|
|
||||||
|
|
||||||
def test_duty_markers():
|
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)]
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ from datetime import date
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from db import init_db
|
from db import init_db
|
||||||
from db.models import Base
|
|
||||||
from db.repository import get_duties
|
from db.repository import get_duties
|
||||||
from db.session import get_session
|
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
|
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),
|
start_date=date(2026, 2, 16),
|
||||||
end_date=date(2026, 2, 18),
|
end_date=date(2026, 2, 18),
|
||||||
entries=[
|
entries=[
|
||||||
("Ivanov I.I.", [date(2026, 2, 16), date(2026, 2, 18)]),
|
DutyScheduleEntry(
|
||||||
("Petrov P.P.", [date(2026, 2, 17)]),
|
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_users == 2
|
||||||
assert num_duties == 3
|
assert num_duty == 3
|
||||||
|
assert num_unav == 0
|
||||||
|
assert num_vac == 0
|
||||||
|
|
||||||
session = get_session(db_url)
|
session = get_session(db_url)
|
||||||
try:
|
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")
|
duties = get_duties(session, "2026-02-16", "2026-02-19")
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
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-16T06:00:00Z" in starts
|
||||||
assert "2026-02-17T06:00:00Z" in starts
|
assert "2026-02-17T06:00:00Z" in starts
|
||||||
assert "2026-02-18T06: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):
|
def test_import_replaces_duties_in_range(db_url):
|
||||||
@@ -63,7 +75,14 @@ def test_import_replaces_duties_in_range(db_url):
|
|||||||
result1 = DutyScheduleResult(
|
result1 = DutyScheduleResult(
|
||||||
start_date=date(2026, 2, 16),
|
start_date=date(2026, 2, 16),
|
||||||
end_date=date(2026, 2, 17),
|
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)
|
_run_import(db_url, result1, 9, 0)
|
||||||
|
|
||||||
@@ -77,7 +96,14 @@ def test_import_replaces_duties_in_range(db_url):
|
|||||||
result2 = DutyScheduleResult(
|
result2 = DutyScheduleResult(
|
||||||
start_date=date(2026, 2, 16),
|
start_date=date(2026, 2, 16),
|
||||||
end_date=date(2026, 2, 17),
|
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)
|
_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"}]}'
|
'"schedule": [{"name": "Alexey A.", "duty": "\u0431; ; \u0432"}]}'
|
||||||
).encode("utf-8")
|
).encode("utf-8")
|
||||||
parsed = parse_duty_schedule(raw)
|
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_users == 1
|
||||||
assert num_duties == 2
|
assert num_duty == 2
|
||||||
|
assert num_unav == 0
|
||||||
|
assert num_vac == 0
|
||||||
|
|
||||||
session = get_session(db_url)
|
session = get_session(db_url)
|
||||||
try:
|
try:
|
||||||
@@ -108,3 +136,32 @@ def test_import_full_flow_parse_then_import(db_url):
|
|||||||
session.close()
|
session.close()
|
||||||
assert len(duties) == 2
|
assert len(duties) == 2
|
||||||
assert duties[0][1] == "Alexey A."
|
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"}
|
||||||
|
|||||||
@@ -198,18 +198,34 @@
|
|||||||
const key = localDateString(d);
|
const key = localDateString(d);
|
||||||
const isOther = d.getMonth() !== month;
|
const isOther = d.getMonth() !== month;
|
||||||
const dayDuties = dutiesByDate[key] || [];
|
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 isToday = key === today;
|
||||||
const eventSummaries = calendarEventsByDate[key] || [];
|
const eventSummaries = calendarEventsByDate[key] || [];
|
||||||
const hasEvent = eventSummaries.length > 0;
|
const hasEvent = eventSummaries.length > 0;
|
||||||
|
|
||||||
const cell = document.createElement("div");
|
const cell = document.createElement("div");
|
||||||
cell.className = "day" + (isOther ? " other-month" : "") + (isToday ? " today" : "") + (dayDuties.length ? " has-duty" : "") + (hasEvent ? " holiday" : "");
|
cell.className = "day" + (isOther ? " other-month" : "") + (isToday ? " today" : "") + (hasAny ? " 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(", ")) : "";
|
function namesAttr(list) { return list.length ? escapeHtml(list.map(function (x) { return x.full_name; }).join("\n")) : ""; }
|
||||||
cell.innerHTML =
|
function titleAttr(list) { return list.length ? escapeHtml(list.map(function (x) { return x.full_name; }).join(", ")) : ""; }
|
||||||
"<span class=\"num\">" + d.getDate() + "</span>" +
|
|
||||||
(dayDuties.length ? "<span class=\"duty-marker\" data-duty-names=\"" + dutyNamesAttr + "\" title=\"" + dutyTitleAttr + "\" aria-label=\"Дежурные\">Д</span>" : "") +
|
let markers = "<span class=\"num\">" + d.getDate() + "</span>";
|
||||||
(hasEvent ? "<button type=\"button\" class=\"info-btn\" aria-label=\"Информация о дне\" data-summary=\"" + escapeHtml(eventSummaries.join("\n")) + "\">i</button>" : "");
|
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);
|
calendarEl.appendChild(cell);
|
||||||
d.setDate(d.getDate() + 1);
|
d.setDate(d.getDate() + 1);
|
||||||
}
|
}
|
||||||
@@ -312,13 +328,14 @@
|
|||||||
document.body.appendChild(hintEl);
|
document.body.appendChild(hintEl);
|
||||||
}
|
}
|
||||||
var hideTimeout = null;
|
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 () {
|
marker.addEventListener("mouseenter", function () {
|
||||||
if (hideTimeout) {
|
if (hideTimeout) {
|
||||||
clearTimeout(hideTimeout);
|
clearTimeout(hideTimeout);
|
||||||
hideTimeout = null;
|
hideTimeout = null;
|
||||||
}
|
}
|
||||||
var names = marker.getAttribute("data-duty-names") || "";
|
var names = marker.getAttribute("data-names") || "";
|
||||||
hintEl.textContent = names;
|
hintEl.textContent = names;
|
||||||
var rect = marker.getBoundingClientRect();
|
var rect = marker.getBoundingClientRect();
|
||||||
positionHint(hintEl, rect);
|
positionHint(hintEl, rect);
|
||||||
@@ -333,9 +350,11 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var EVENT_TYPE_LABELS = { duty: "Дежурство", unavailable: "Недоступен", vacation: "Отпуск" };
|
||||||
|
|
||||||
function renderDutyList(duties) {
|
function renderDutyList(duties) {
|
||||||
if (duties.length === 0) {
|
if (duties.length === 0) {
|
||||||
dutyListEl.innerHTML = "<p class=\"muted\">В этом месяце дежурств нет.</p>";
|
dutyListEl.innerHTML = "<p class=\"muted\">В этом месяце событий нет.</p>";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const grouped = {};
|
const grouped = {};
|
||||||
@@ -354,7 +373,9 @@
|
|||||||
const endDate = new Date(d.end_at);
|
const endDate = new Date(d.end_at);
|
||||||
const start = String(startDate.getHours()).padStart(2, "0") + ":" + String(startDate.getMinutes()).padStart(2, "0");
|
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");
|
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;
|
dutyListEl.innerHTML = html;
|
||||||
|
|||||||
@@ -164,7 +164,9 @@ body {
|
|||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.duty-marker {
|
.duty-marker,
|
||||||
|
.unavailable-marker,
|
||||||
|
.vacation-marker {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -173,12 +175,25 @@ body {
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--duty);
|
|
||||||
background: rgba(158, 206, 106, 0.25);
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
flex-shrink: 0;
|
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 {
|
.duty-list {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
@@ -197,6 +212,20 @@ body {
|
|||||||
border-left: 3px solid var(--duty);
|
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 {
|
.duty-item .name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user