Implement duty schedule import functionality and enhance user management

- Added a new command `/import_duty_schedule` for importing duty schedules via JSON, restricted to admin users.
- Introduced a two-step import process: specifying handover time and uploading a JSON file.
- Updated the database schema to allow `telegram_user_id` to be nullable for user creation by full name.
- Implemented repository functions for user management, including `get_or_create_user_by_full_name` and `delete_duties_in_range`.
- Enhanced README documentation with details on the new import command and JSON format requirements.
- Added comprehensive tests for the duty schedule parser and integration tests for the import functionality.
This commit is contained in:
2026-02-17 21:45:23 +03:00
parent 120d609b2e
commit ef5dbca5df
13 changed files with 678 additions and 8 deletions

View File

@@ -0,0 +1,92 @@
"""Tests for duty-schedule JSON parser."""
from datetime import date
import pytest
from importers.duty_schedule import (
DUTY_MARKERS,
DutyScheduleParseError,
parse_duty_schedule,
)
def test_parse_valid_schedule():
raw = (
'{"meta": {"start_date": "2026-02-16", "weeks": 2}, '
'"schedule": ['
'{"name": "Ivanov I.I.", "duty": "; ; \u0431 ; \u0411 ; \u0432 ; ;"}, '
'{"name": "Petrov P.P.", "duty": " ; \u041d ; \u041e ; ; ; ;"}'
"]}"
).encode("utf-8")
result = parse_duty_schedule(raw)
assert result.start_date == date(2026, 2, 16)
# 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)]
def test_parse_empty_duty_string():
raw = b'{"meta": {"start_date": "2026-02-01"}, "schedule": [{"name": "A", "duty": ""}]}'
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", [])]
def test_parse_invalid_json():
with pytest.raises(DutyScheduleParseError, match="Invalid JSON"):
parse_duty_schedule(b"not json")
def test_parse_missing_meta():
with pytest.raises(DutyScheduleParseError, match="meta"):
parse_duty_schedule(b'{"schedule": []}')
def test_parse_missing_start_date():
with pytest.raises(DutyScheduleParseError, match="start_date"):
parse_duty_schedule(b'{"meta": {"weeks": 1}, "schedule": []}')
def test_parse_invalid_start_date():
with pytest.raises(DutyScheduleParseError, match="start_date|Invalid"):
parse_duty_schedule(b'{"meta": {"start_date": "not-a-date"}, "schedule": []}')
def test_parse_missing_schedule():
with pytest.raises(DutyScheduleParseError, match="schedule"):
parse_duty_schedule(b'{"meta": {"start_date": "2026-02-01"}}')
def test_parse_schedule_not_array():
with pytest.raises(DutyScheduleParseError, match="schedule"):
parse_duty_schedule(b'{"meta": {"start_date": "2026-02-01"}, "schedule": {}}')
def test_parse_schedule_item_missing_name():
with pytest.raises(DutyScheduleParseError, match="name"):
parse_duty_schedule(
b'{"meta": {"start_date": "2026-02-01"}, "schedule": [{"duty": ";"}]}'
)
def test_parse_schedule_item_empty_name():
with pytest.raises(DutyScheduleParseError, match="empty"):
parse_duty_schedule(
b'{"meta": {"start_date": "2026-02-01"}, "schedule": [{"name": " ", "duty": ""}]}'
)
def test_duty_markers():
assert "б" in DUTY_MARKERS and "Б" in DUTY_MARKERS and "в" in DUTY_MARKERS

View File

@@ -0,0 +1,110 @@
"""Integration tests for duty-schedule import (parser + repo, no bot)."""
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 handlers.import_duty_schedule import _run_import
@pytest.fixture
def db_url():
return "sqlite:///:memory:"
@pytest.fixture(autouse=True)
def _reset_db_session(db_url):
"""Ensure each test uses a fresh engine for :memory: (clear global cache for test URL)."""
import db.session as session_module
session_module._engine = None
session_module._SessionLocal = None
init_db(db_url)
yield
session_module._engine = None
session_module._SessionLocal = None
def test_import_creates_users_and_duties(db_url):
"""Import creates users by full_name and correct duty records."""
result = DutyScheduleResult(
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)]),
],
)
num_users, num_duties = _run_import(db_url, result, 6, 0)
assert num_users == 2
assert num_duties == 3
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()
assert len(duties) == 3
starts = {d[0].start_at for d in duties}
assert "2026-02-16T06:00:00Z" in starts
assert "2026-02-17T06:00:00Z" in starts
assert "2026-02-18T06:00:00Z" in starts
def test_import_replaces_duties_in_range(db_url):
"""Re-importing same range replaces old duties."""
result1 = DutyScheduleResult(
start_date=date(2026, 2, 16),
end_date=date(2026, 2, 17),
entries=[("Sidorov", [date(2026, 2, 16), date(2026, 2, 17)])],
)
_run_import(db_url, result1, 9, 0)
session = get_session(db_url)
try:
duties_first = get_duties(session, "2026-02-16", "2026-02-18")
finally:
session.close()
assert len(duties_first) == 2
result2 = DutyScheduleResult(
start_date=date(2026, 2, 16),
end_date=date(2026, 2, 17),
entries=[("Sidorov", [date(2026, 2, 17)])],
)
_run_import(db_url, result2, 9, 0)
session = get_session(db_url)
try:
duties_second = get_duties(session, "2026-02-16", "2026-02-18")
finally:
session.close()
assert len(duties_second) == 1
assert duties_second[0][0].start_at == "2026-02-17T09:00:00Z"
def test_import_full_flow_parse_then_import(db_url):
"""Parse real-looking JSON then run import."""
raw = (
'{"meta": {"start_date": "2026-02-16"}, '
'"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)
assert num_users == 1
assert num_duties == 2
session = get_session(db_url)
try:
duties = get_duties(session, "2026-02-16", "2026-02-19")
finally:
session.close()
assert len(duties) == 2
assert duties[0][1] == "Alexey A."

View File

@@ -0,0 +1,93 @@
"""Tests for delete_duties_in_range and get_or_create_user_by_full_name."""
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from db.models import Base, User, Duty
from db.repository import (
delete_duties_in_range,
get_or_create_user_by_full_name,
get_duties,
insert_duty,
)
@pytest.fixture
def session():
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine, autocommit=False, autoflush=False)
s = Session()
try:
yield s
finally:
s.close()
@pytest.fixture
def user_a(session):
u = User(
telegram_user_id=None,
full_name="User A",
username=None,
first_name=None,
last_name=None,
)
session.add(u)
session.commit()
session.refresh(u)
return u
def test_get_or_create_user_by_full_name_creates(session):
u = get_or_create_user_by_full_name(session, "Новый Пользователь")
assert u.id is not None
assert u.full_name == "Новый Пользователь"
assert u.telegram_user_id is None
def test_get_or_create_user_by_full_name_returns_existing(session, user_a):
u = get_or_create_user_by_full_name(session, "User A")
assert u.id == user_a.id
assert u.full_name == "User A"
def test_delete_duties_in_range_removes_only_in_range(session, user_a):
# Duties: 2026-02-01 06:00 - 2026-02-02 06:00; 2026-02-15 - 2026-02-16; 2026-02-28 - 2026-03-01
insert_duty(
session,
user_a.id,
"2026-02-01T06:00:00Z",
"2026-02-02T06:00:00Z",
)
insert_duty(
session,
user_a.id,
"2026-02-15T06:00:00Z",
"2026-02-16T06:00:00Z",
)
insert_duty(
session,
user_a.id,
"2026-02-28T06:00:00Z",
"2026-03-01T06:00:00Z",
)
deleted = delete_duties_in_range(session, user_a.id, "2026-02-10", "2026-02-20")
assert deleted == 1
remaining = get_duties(session, "2026-01-01", "2026-03-31")
assert len(remaining) == 2
starts = [d[0].start_at for d in remaining]
assert "2026-02-01T06:00:00Z" in starts
assert "2026-02-28T06:00:00Z" in starts
assert "2026-02-15T06:00:00Z" not in starts
def test_delete_duties_in_range_other_user_unchanged(session, user_a):
user_b = get_or_create_user_by_full_name(session, "User B")
insert_duty(session, user_a.id, "2026-02-10T06:00:00Z", "2026-02-11T06:00:00Z")
insert_duty(session, user_b.id, "2026-02-10T06:00:00Z", "2026-02-11T06:00:00Z")
delete_duties_in_range(session, user_a.id, "2026-02-01", "2026-02-28")
remaining = get_duties(session, "2026-02-01", "2026-02-28")
assert len(remaining) == 1
assert remaining[0][1] == "User B"