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:
92
tests/test_duty_schedule_parser.py
Normal file
92
tests/test_duty_schedule_parser.py
Normal 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
|
||||
110
tests/test_import_duty_schedule_integration.py
Normal file
110
tests/test_import_duty_schedule_integration.py
Normal 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."
|
||||
93
tests/test_repository_duty_range.py
Normal file
93
tests/test_repository_duty_range.py
Normal 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"
|
||||
Reference in New Issue
Block a user