refactor: improve language normalization and date handling utilities
All checks were successful
CI / lint-and-test (push) Successful in 21s

- Introduced a new `normalize_lang` function to standardize language codes across the application, ensuring consistent handling of user language preferences.
- Refactored date handling utilities by adding `parse_utc_iso` and `parse_utc_iso_naive` functions for better parsing of ISO 8601 date strings, enhancing timezone awareness.
- Updated various modules to utilize the new language normalization and date parsing functions, improving code clarity and maintainability.
- Enhanced error handling in date validation to raise specific `DateRangeValidationError` exceptions, providing clearer feedback on validation issues.
- Improved test coverage for date range validation and language normalization functionalities, ensuring robustness and reliability.
This commit is contained in:
2026-02-20 22:42:54 +03:00
parent f53ef81306
commit d02d0a1835
19 changed files with 216 additions and 158 deletions

BIN
.coverage

Binary file not shown.

View File

@@ -10,15 +10,32 @@ from sqlalchemy.orm import Session
import duty_teller.config as config import duty_teller.config as config
from duty_teller.api.telegram_auth import validate_init_data_with_reason from duty_teller.api.telegram_auth import validate_init_data_with_reason
from duty_teller.db.repository import get_duties, get_user_by_telegram_id from duty_teller.db.repository import get_duties, get_user_by_telegram_id
from duty_teller.db.schemas import DutyWithUser from duty_teller.db.schemas import DUTY_EVENT_TYPES, DutyWithUser
from duty_teller.db.session import session_scope from duty_teller.db.session import session_scope
from duty_teller.i18n import t from duty_teller.i18n import t
from duty_teller.utils.dates import validate_date_range from duty_teller.i18n.lang import normalize_lang
from duty_teller.utils.dates import DateRangeValidationError, validate_date_range
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# First language tag from Accept-Language (e.g. "ru-RU,ru;q=0.9,en;q=0.8" -> "ru") # Extract primary language code from first Accept-Language tag (e.g. "ru-RU" -> "ru").
_ACCEPT_LANG_TAG_RE = re.compile(r"^([a-zA-Z]{2,3})(?:-[a-zA-Z0-9]+)?\s*(?:;|,|$)") _ACCEPT_LANG_CODE_RE = re.compile(r"^([a-zA-Z]{2,3})(?:-|;|,|\s|$)")
def _parse_first_language_code(header: str | None) -> str | None:
"""Extract the first language code from Accept-Language header.
Args:
header: Raw Accept-Language value (e.g. "ru-RU,ru;q=0.9,en;q=0.8").
Returns:
Two- or three-letter code (e.g. 'ru', 'en') or None if missing/invalid.
"""
if not header or not header.strip():
return None
first = header.strip().split(",")[0].strip()
m = _ACCEPT_LANG_CODE_RE.match(first)
return m.group(1).lower() if m else None
def _lang_from_accept_language(header: str | None) -> str: def _lang_from_accept_language(header: str | None) -> str:
@@ -30,14 +47,8 @@ def _lang_from_accept_language(header: str | None) -> str:
Returns: Returns:
'ru' or 'en'. 'ru' or 'en'.
""" """
if not header or not header.strip(): code = _parse_first_language_code(header)
return config.DEFAULT_LANGUAGE return normalize_lang(code if code is not None else config.DEFAULT_LANGUAGE)
first = header.strip().split(",")[0].strip()
m = _ACCEPT_LANG_TAG_RE.match(first)
if not m:
return "en"
code = m.group(1).lower()
return "ru" if code.startswith("ru") else "en"
def _auth_error_detail(auth_reason: str, lang: str) -> str: def _auth_error_detail(auth_reason: str, lang: str) -> str:
@@ -51,13 +62,12 @@ def _validate_duty_dates(from_date: str, to_date: str, lang: str) -> None:
"""Validate date range; raise HTTPException with translated detail.""" """Validate date range; raise HTTPException with translated detail."""
try: try:
validate_date_range(from_date, to_date) validate_date_range(from_date, to_date)
except DateRangeValidationError as e:
key = "dates.bad_format" if e.kind == "bad_format" else "dates.from_after_to"
raise HTTPException(status_code=400, detail=t(lang, key)) from e
except ValueError as e: except ValueError as e:
msg = str(e) # Backward compatibility if something else raises ValueError.
if "YYYY-MM-DD" in msg or "формате" in msg: raise HTTPException(status_code=400, detail=t(lang, "dates.bad_format")) from e
detail = t(lang, "dates.bad_format")
else:
detail = t(lang, "dates.from_after_to")
raise HTTPException(status_code=400, detail=detail) from e
def get_validated_dates( def get_validated_dates(
@@ -211,9 +221,7 @@ def fetch_duties_response(
end_at=duty.end_at, end_at=duty.end_at,
full_name=full_name, full_name=full_name,
event_type=( event_type=(
duty.event_type duty.event_type if duty.event_type in DUTY_EVENT_TYPES else "duty"
if duty.event_type in ("duty", "unavailable", "vacation")
else "duty"
), ),
) )
for duty, full_name in rows for duty, full_name in rows

View File

@@ -5,6 +5,7 @@ from datetime import datetime, timezone
from icalendar import Calendar, Event from icalendar import Calendar, Event
from duty_teller.db.models import Duty from duty_teller.db.models import Duty
from duty_teller.utils.dates import parse_utc_iso
# Summary labels by event_type (duty | unavailable | vacation) # Summary labels by event_type (duty | unavailable | vacation)
SUMMARY_BY_TYPE: dict[str, str] = { SUMMARY_BY_TYPE: dict[str, str] = {
@@ -14,16 +15,6 @@ SUMMARY_BY_TYPE: dict[str, str] = {
} }
def _parse_utc_iso(iso_str: str) -> datetime:
"""Parse ISO 8601 UTC string (e.g. 2025-01-15T09:00:00Z) to timezone-aware datetime."""
s = iso_str.strip().rstrip("Z")
if "Z" in s:
s = s.replace("Z", "+00:00")
else:
s = s + "+00:00"
return datetime.fromisoformat(s)
def build_personal_ics(duties_with_name: list[tuple[Duty, str]]) -> bytes: def build_personal_ics(duties_with_name: list[tuple[Duty, str]]) -> bytes:
"""Build a VCALENDAR (ICS) with one VEVENT per duty. """Build a VCALENDAR (ICS) with one VEVENT per duty.
@@ -41,8 +32,8 @@ def build_personal_ics(duties_with_name: list[tuple[Duty, str]]) -> bytes:
for duty, _full_name in duties_with_name: for duty, _full_name in duties_with_name:
event = Event() event = Event()
start_dt = _parse_utc_iso(duty.start_at) start_dt = parse_utc_iso(duty.start_at)
end_dt = _parse_utc_iso(duty.end_at) end_dt = parse_utc_iso(duty.end_at)
# Ensure timezone-aware for icalendar # Ensure timezone-aware for icalendar
if start_dt.tzinfo is None: if start_dt.tzinfo is None:
start_dt = start_dt.replace(tzinfo=timezone.utc) start_dt = start_dt.replace(tzinfo=timezone.utc)

View File

@@ -6,6 +6,8 @@ import json
import time import time
from urllib.parse import unquote from urllib.parse import unquote
from duty_teller.i18n.lang import normalize_lang
# Telegram algorithm: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app # Telegram algorithm: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
# Data-check string: sorted key=value with URL-decoded values, then HMAC-SHA256(WebAppData, token) as secret. # Data-check string: sorted key=value with URL-decoded values, then HMAC-SHA256(WebAppData, token) as secret.
@@ -31,14 +33,6 @@ def validate_init_data(
return username return username
def _normalize_lang(language_code: str | None) -> str:
"""Normalize to 'ru' or 'en' for i18n."""
if not language_code or not isinstance(language_code, str):
return "en"
code = language_code.strip().lower()
return "ru" if code.startswith("ru") else "en"
def validate_init_data_with_reason( def validate_init_data_with_reason(
init_data: str, init_data: str,
bot_token: str, bot_token: str,
@@ -107,7 +101,7 @@ def validate_init_data_with_reason(
return (None, None, "user_invalid", "en") return (None, None, "user_invalid", "en")
if not isinstance(user, dict): if not isinstance(user, dict):
return (None, None, "user_invalid", "en") return (None, None, "user_invalid", "en")
lang = _normalize_lang(user.get("language_code")) lang = normalize_lang(user.get("language_code"))
raw_id = user.get("id") raw_id = user.get("id")
if raw_id is None: if raw_id is None:
return (None, None, "no_user_id", lang) return (None, None, "no_user_id", lang)

View File

@@ -4,13 +4,15 @@ BOT_TOKEN is not validated on import; call require_bot_token() in the entry poin
when running the bot. when running the bot.
""" """
import re
import os import os
import re
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
from duty_teller.i18n.lang import normalize_lang
load_dotenv() load_dotenv()
# Project root (parent of duty_teller package). Used for webapp path, etc. # Project root (parent of duty_teller package). Used for webapp path, etc.
@@ -34,14 +36,6 @@ def normalize_phone(phone: str | None) -> str:
return _PHONE_DIGITS_RE.sub("", phone.strip()) return _PHONE_DIGITS_RE.sub("", phone.strip())
def _normalize_default_language(value: str) -> str:
"""Normalize DEFAULT_LANGUAGE from env to 'ru' or 'en'."""
if not value:
return "en"
v = value.strip().lower()
return "ru" if v.startswith("ru") else "en"
def _parse_phone_list(raw: str) -> set[str]: def _parse_phone_list(raw: str) -> set[str]:
"""Parse comma-separated phones into set of normalized (digits-only) strings.""" """Parse comma-separated phones into set of normalized (digits-only) strings."""
result = set() result = set()
@@ -113,9 +107,7 @@ class Settings:
).strip(), ).strip(),
duty_display_tz=os.getenv("DUTY_DISPLAY_TZ", "Europe/Moscow").strip() duty_display_tz=os.getenv("DUTY_DISPLAY_TZ", "Europe/Moscow").strip()
or "Europe/Moscow", or "Europe/Moscow",
default_language=_normalize_default_language( default_language=normalize_lang(os.getenv("DEFAULT_LANGUAGE", "en")),
os.getenv("DEFAULT_LANGUAGE", "en").strip()
),
) )

View File

@@ -2,12 +2,13 @@
import hashlib import hashlib
import secrets import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timezone
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
import duty_teller.config as config import duty_teller.config as config
from duty_teller.db.models import User, Duty, GroupDutyPin, CalendarSubscriptionToken from duty_teller.db.models import User, Duty, GroupDutyPin, CalendarSubscriptionToken
from duty_teller.utils.dates import parse_utc_iso_naive, to_date_exclusive_iso
def get_user_by_telegram_id(session: Session, telegram_user_id: int) -> User | None: def get_user_by_telegram_id(session: Session, telegram_user_id: int) -> User | None:
@@ -168,9 +169,7 @@ def delete_duties_in_range(
Returns: Returns:
Number of duties deleted. Number of duties deleted.
""" """
to_next = ( to_next = to_date_exclusive_iso(to_date)
datetime.fromisoformat(to_date + "T00:00:00") + timedelta(days=1)
).strftime("%Y-%m-%d")
q = session.query(Duty).filter( q = session.query(Duty).filter(
Duty.user_id == user_id, Duty.user_id == user_id,
Duty.start_at < to_next, Duty.start_at < to_next,
@@ -197,9 +196,7 @@ def get_duties(
Returns: Returns:
List of (Duty, full_name) tuples. List of (Duty, full_name) tuples.
""" """
to_date_next = ( to_date_next = to_date_exclusive_iso(to_date)
datetime.fromisoformat(to_date + "T00:00:00") + timedelta(days=1)
).strftime("%Y-%m-%d")
q = ( q = (
session.query(Duty, User.full_name) session.query(Duty, User.full_name)
.join(User, Duty.user_id == User.id) .join(User, Duty.user_id == User.id)
@@ -230,9 +227,7 @@ def get_duties_for_user(
Returns: Returns:
List of (Duty, full_name) tuples. List of (Duty, full_name) tuples.
""" """
to_date_next = ( to_date_next = to_date_exclusive_iso(to_date)
datetime.fromisoformat(to_date + "T00:00:00") + timedelta(days=1)
).strftime("%Y-%m-%d")
filters = [ filters = [
Duty.user_id == user_id, Duty.user_id == user_id,
Duty.start_at < to_date_next, Duty.start_at < to_date_next,
@@ -392,9 +387,7 @@ def get_next_shift_end(session: Session, after_utc: datetime) -> datetime | None
.first() .first()
) )
if current: if current:
return datetime.fromisoformat(current.end_at.replace("Z", "+00:00")).replace( return parse_utc_iso_naive(current.end_at)
tzinfo=None
)
next_duty = ( next_duty = (
session.query(Duty) session.query(Duty)
.filter(Duty.event_type == "duty", Duty.start_at > after_iso) .filter(Duty.event_type == "duty", Duty.start_at > after_iso)
@@ -402,9 +395,7 @@ def get_next_shift_end(session: Session, after_utc: datetime) -> datetime | None
.first() .first()
) )
if next_duty: if next_duty:
return datetime.fromisoformat(next_duty.end_at.replace("Z", "+00:00")).replace( return parse_utc_iso_naive(next_duty.end_at)
tzinfo=None
)
return None return None

View File

@@ -4,6 +4,9 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
# Allowed duty event types; API maps unknown DB values to "duty".
DUTY_EVENT_TYPES = ("duty", "unavailable", "vacation")
class UserBase(BaseModel): class UserBase(BaseModel):
"""Base user fields (full_name, username, first/last name).""" """Base user fields (full_name, username, first/last name)."""

View File

@@ -11,8 +11,8 @@ from duty_teller.db.repository import (
get_or_create_user, get_or_create_user,
set_user_phone, set_user_phone,
create_calendar_token, create_calendar_token,
is_admin_for_telegram_user,
) )
from duty_teller.handlers.common import is_admin_async
from duty_teller.i18n import get_lang, t from duty_teller.i18n import get_lang, t
from duty_teller.utils.user import build_full_name from duty_teller.utils.user import build_full_name
@@ -151,13 +151,7 @@ async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
t(lang, "help.calendar_link"), t(lang, "help.calendar_link"),
t(lang, "help.pin_duty"), t(lang, "help.pin_duty"),
] ]
if await is_admin_async(update.effective_user.id):
def check_admin() -> bool:
with session_scope(config.DATABASE_URL) as session:
return is_admin_for_telegram_user(session, update.effective_user.id)
is_admin_user = await asyncio.get_running_loop().run_in_executor(None, check_admin)
if is_admin_user:
lines.append(t(lang, "help.import_schedule")) lines.append(t(lang, "help.import_schedule"))
await update.message.reply_text("\n".join(lines)) await update.message.reply_text("\n".join(lines))

View File

@@ -0,0 +1,24 @@
"""Shared handler helpers (e.g. async admin check)."""
import asyncio
import duty_teller.config as config
from duty_teller.db.repository import is_admin_for_telegram_user
from duty_teller.db.session import session_scope
async def is_admin_async(telegram_user_id: int) -> bool:
"""Check if Telegram user is admin (username or phone). Runs DB check in executor.
Args:
telegram_user_id: Telegram user id.
Returns:
True if user is in ADMIN_USERNAMES or their stored phone is in ADMIN_PHONES.
"""
def _check() -> bool:
with session_scope(config.DATABASE_URL) as session:
return is_admin_for_telegram_user(session, telegram_user_id)
return await asyncio.get_running_loop().run_in_executor(None, _check)

View File

@@ -7,7 +7,7 @@ from telegram import Update
from telegram.ext import CommandHandler, ContextTypes, MessageHandler, filters from telegram.ext import CommandHandler, ContextTypes, MessageHandler, filters
from duty_teller.db.session import session_scope from duty_teller.db.session import session_scope
from duty_teller.db.repository import is_admin_for_telegram_user from duty_teller.handlers.common import is_admin_async
from duty_teller.i18n import get_lang, t from duty_teller.i18n import get_lang, t
from duty_teller.importers.duty_schedule import ( from duty_teller.importers.duty_schedule import (
DutyScheduleParseError, DutyScheduleParseError,
@@ -24,13 +24,7 @@ async def import_duty_schedule_cmd(
if not update.message or not update.effective_user: if not update.message or not update.effective_user:
return return
lang = get_lang(update.effective_user) lang = get_lang(update.effective_user)
if not await is_admin_async(update.effective_user.id):
def check_admin() -> bool:
with session_scope(config.DATABASE_URL) as session:
return is_admin_for_telegram_user(session, update.effective_user.id)
is_admin_user = await asyncio.get_running_loop().run_in_executor(None, check_admin)
if not is_admin_user:
await update.message.reply_text(t(lang, "import.admin_only")) await update.message.reply_text(t(lang, "import.admin_only"))
return return
context.user_data["awaiting_handover_time"] = True context.user_data["awaiting_handover_time"] = True
@@ -45,13 +39,7 @@ async def handle_handover_time_text(
return return
if not context.user_data.get("awaiting_handover_time"): if not context.user_data.get("awaiting_handover_time"):
return return
if not await is_admin_async(update.effective_user.id):
def check_admin() -> bool:
with session_scope(config.DATABASE_URL) as session:
return is_admin_for_telegram_user(session, update.effective_user.id)
is_admin_user = await asyncio.get_running_loop().run_in_executor(None, check_admin)
if not is_admin_user:
return return
lang = get_lang(update.effective_user) lang = get_lang(update.effective_user)
text = update.message.text.strip() text = update.message.text.strip()
@@ -78,13 +66,7 @@ async def handle_duty_schedule_document(
handover = context.user_data.get("handover_utc_time") handover = context.user_data.get("handover_utc_time")
if not handover: if not handover:
return return
if not await is_admin_async(update.effective_user.id):
def check_admin() -> bool:
with session_scope(config.DATABASE_URL) as session:
return is_admin_for_telegram_user(session, update.effective_user.id)
is_admin_user = await asyncio.get_running_loop().run_in_executor(None, check_admin)
if not is_admin_user:
return return
if not (update.message.document.file_name or "").lower().endswith(".json"): if not (update.message.document.file_name or "").lower().endswith(".json"):
await update.message.reply_text(t(lang, "import.need_json")) await update.message.reply_text(t(lang, "import.need_json"))

View File

@@ -1,6 +1,7 @@
"""Internationalization: RU/EN by Telegram language_code. Normalize to 'ru' or 'en'.""" """Internationalization: RU/EN by Telegram language_code. Normalize to 'ru' or 'en'."""
from duty_teller.i18n.messages import MESSAGES
from duty_teller.i18n.core import get_lang, t from duty_teller.i18n.core import get_lang, t
from duty_teller.i18n.lang import normalize_lang
from duty_teller.i18n.messages import MESSAGES
__all__ = ["MESSAGES", "get_lang", "t"] __all__ = ["MESSAGES", "get_lang", "normalize_lang", "t"]

View File

@@ -3,6 +3,7 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import duty_teller.config as config import duty_teller.config as config
from duty_teller.i18n.lang import normalize_lang
from duty_teller.i18n.messages import MESSAGES from duty_teller.i18n.messages import MESSAGES
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -12,13 +13,12 @@ if TYPE_CHECKING:
def get_lang(user: "User | None") -> str: def get_lang(user: "User | None") -> str:
""" """
Normalize Telegram user language to 'ru' or 'en'. Normalize Telegram user language to 'ru' or 'en'.
If user has language_code starting with 'ru' (e.g. ru, ru-RU) return 'ru', else 'en'. Uses normalize_lang for user.language_code; when user is None or has no
When user is None or has no language_code, return config.DEFAULT_LANGUAGE. language_code, returns config.DEFAULT_LANGUAGE.
""" """
if user is None or not getattr(user, "language_code", None): if user is None or not getattr(user, "language_code", None):
return config.DEFAULT_LANGUAGE return config.DEFAULT_LANGUAGE
code = (user.language_code or "").strip().lower() return normalize_lang(user.language_code)
return "ru" if code.startswith("ru") else "en"
def t(lang: str, key: str, **kwargs: str) -> str: def t(lang: str, key: str, **kwargs: str) -> str:

26
duty_teller/i18n/lang.py Normal file
View File

@@ -0,0 +1,26 @@
"""Single source of truth for normalizing language codes to 'ru' or 'en'.
Use for: env DEFAULT_LANGUAGE, Accept-Language header, Telegram user.language_code,
and initData user object in Miniapp auth.
"""
from typing import Literal
def normalize_lang(code: str | None) -> Literal["ru", "en"]:
"""Normalize a language code to 'ru' or 'en'.
Suitable for: env DEFAULT_LANGUAGE, Accept-Language header,
Telegram user.language_code, and initData user in Miniapp auth.
Args:
code: Raw language code (e.g. 'ru', 'ru-RU', 'en', 'en-US') or None.
Returns:
'ru' if code starts with 'ru' (after strip/lower), else 'en'.
Returns 'en' when code is empty or not a string.
"""
if not code or not isinstance(code, str):
return "en"
code = code.strip().lower()
return "ru" if code.startswith("ru") else "en"

View File

@@ -14,6 +14,7 @@ from duty_teller.db.repository import (
get_all_group_duty_pin_chat_ids, get_all_group_duty_pin_chat_ids,
) )
from duty_teller.i18n import t from duty_teller.i18n import t
from duty_teller.utils.dates import parse_utc_iso
def format_duty_message(duty, user, tz_name: str, lang: str = "en") -> str: def format_duty_message(duty, user, tz_name: str, lang: str = "en") -> str:
@@ -35,8 +36,8 @@ def format_duty_message(duty, user, tz_name: str, lang: str = "en") -> str:
except ZoneInfoNotFoundError: except ZoneInfoNotFoundError:
tz = ZoneInfo("Europe/Moscow") tz = ZoneInfo("Europe/Moscow")
tz_name = "Europe/Moscow" tz_name = "Europe/Moscow"
start_dt = datetime.fromisoformat(duty.start_at.replace("Z", "+00:00")) start_dt = parse_utc_iso(duty.start_at)
end_dt = datetime.fromisoformat(duty.end_at.replace("Z", "+00:00")) end_dt = parse_utc_iso(duty.end_at)
start_local = start_dt.astimezone(tz) start_local = start_dt.astimezone(tz)
end_local = end_dt.astimezone(tz) end_local = end_dt.astimezone(tz)
offset_sec = ( offset_sec = (

View File

@@ -1,7 +1,8 @@
"""Date and ISO helpers for duty ranges and API validation.""" """Date and ISO helpers for duty ranges and API validation."""
import re import re
from datetime import date, datetime, timezone from datetime import date, datetime, timedelta, timezone
from typing import Literal
def day_start_iso(d: date) -> str: def day_start_iso(d: date) -> str:
@@ -23,6 +24,57 @@ def duty_to_iso(d: date, hour_utc: int, minute_utc: int) -> str:
_ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") _ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
class DateRangeValidationError(ValueError):
"""Raised when from_date/to_date validation fails. API uses kind for i18n key."""
def __init__(self, kind: Literal["bad_format", "from_after_to"]) -> None:
self.kind = kind
super().__init__(kind)
def to_date_exclusive_iso(to_date: str) -> str:
"""Return the day after to_date in YYYY-MM-DD for exclusive range end.
Use for queries like start_at < to_date_next (e.g. filter end before next day).
Args:
to_date: End date in YYYY-MM-DD.
Returns:
(to_date + 1 day) in YYYY-MM-DD.
"""
dt = datetime.fromisoformat(to_date + "T00:00:00") + timedelta(days=1)
return dt.strftime("%Y-%m-%d")
def parse_utc_iso(iso_str: str) -> datetime:
"""Parse UTC ISO 8601 string with Z suffix to timezone-aware datetime (UTC).
Args:
iso_str: e.g. '2025-01-15T09:00:00Z'.
Returns:
Timezone-aware datetime in UTC.
"""
normalized = (iso_str or "").strip().replace("Z", "+00:00")
return datetime.fromisoformat(normalized)
def parse_utc_iso_naive(iso_str: str) -> datetime:
"""Parse UTC ISO 8601 string with Z to naive datetime in UTC.
Use for job queue or code that expects naive UTC.
Args:
iso_str: e.g. '2025-01-15T09:00:00Z'.
Returns:
Naive datetime with same UTC wall time.
"""
dt = parse_utc_iso(iso_str)
return dt.replace(tzinfo=None)
def parse_iso_date(s: str) -> date | None: def parse_iso_date(s: str) -> date | None:
"""Parse YYYY-MM-DD string to date. Returns None if invalid.""" """Parse YYYY-MM-DD string to date. Returns None if invalid."""
if not s or not _ISO_DATE_RE.match(s.strip()): if not s or not _ISO_DATE_RE.match(s.strip()):
@@ -34,8 +86,12 @@ def parse_iso_date(s: str) -> date | None:
def validate_date_range(from_date: str, to_date: str) -> None: def validate_date_range(from_date: str, to_date: str) -> None:
"""Validate from_date and to_date are YYYY-MM-DD and from_date <= to_date. Raises ValueError if invalid.""" """Validate from_date and to_date are YYYY-MM-DD and from_date <= to_date.
Raises:
DateRangeValidationError: bad_format if format invalid, from_after_to if from > to.
"""
if not _ISO_DATE_RE.match(from_date or "") or not _ISO_DATE_RE.match(to_date or ""): if not _ISO_DATE_RE.match(from_date or "") or not _ISO_DATE_RE.match(to_date or ""):
raise ValueError("Параметры from и to должны быть в формате YYYY-MM-DD") raise DateRangeValidationError("bad_format")
if from_date > to_date: if from_date > to_date:
raise ValueError("Дата from не должна быть позже to") raise DateRangeValidationError("from_after_to")

View File

@@ -20,13 +20,8 @@ def parse_handover_time(text: str) -> tuple[int, int] | None:
tz_str = (m.group(4) or "").strip() tz_str = (m.group(4) or "").strip()
if not tz_str or tz_str.upper() == "UTC": if not tz_str or tz_str.upper() == "UTC":
return (hour % 24, minute) return (hour % 24, minute)
try: from zoneinfo import ZoneInfo
from zoneinfo import ZoneInfo
except ImportError:
try:
from backports.zoneinfo import ZoneInfo # type: ignore
except ImportError:
return None
try: try:
tz = ZoneInfo(tz_str) tz = ZoneInfo(tz_str)
except Exception: except Exception:

View File

@@ -235,18 +235,15 @@ async def test_help_cmd_replies_with_help_text():
user = _make_user() user = _make_user()
update = _make_update(message=message, effective_user=user) update = _make_update(message=message, effective_user=user)
with patch("duty_teller.handlers.commands.session_scope") as mock_scope: with patch(
mock_session = MagicMock() "duty_teller.handlers.commands.is_admin_async",
mock_scope.return_value.__enter__.return_value = mock_session new_callable=AsyncMock,
mock_scope.return_value.__exit__.return_value = None return_value=False,
with patch( ):
"duty_teller.handlers.commands.is_admin_for_telegram_user", with patch("duty_teller.handlers.commands.get_lang", return_value="en"):
return_value=False, with patch("duty_teller.handlers.commands.t") as mock_t:
): mock_t.side_effect = lambda lang, key: f"[{key}]"
with patch("duty_teller.handlers.commands.get_lang", return_value="en"): await help_cmd(update, MagicMock())
with patch("duty_teller.handlers.commands.t") as mock_t:
mock_t.side_effect = lambda lang, key: f"[{key}]"
await help_cmd(update, MagicMock())
message.reply_text.assert_called_once() message.reply_text.assert_called_once()
text = message.reply_text.call_args[0][0] text = message.reply_text.call_args[0][0]
assert "help.title" in text or "[help.title]" in text assert "help.title" in text or "[help.title]" in text

View File

@@ -5,6 +5,7 @@ from datetime import date
import pytest import pytest
from duty_teller.utils.dates import ( from duty_teller.utils.dates import (
DateRangeValidationError,
day_start_iso, day_start_iso,
day_end_iso, day_end_iso,
duty_to_iso, duty_to_iso,
@@ -47,15 +48,18 @@ def test_validate_date_range_ok():
def test_validate_date_range_bad_format(): def test_validate_date_range_bad_format():
with pytest.raises(ValueError, match="формате YYYY-MM-DD"): with pytest.raises(DateRangeValidationError) as exc_info:
validate_date_range("01-01-2025", "2025-01-31") validate_date_range("01-01-2025", "2025-01-31")
with pytest.raises(ValueError, match="формате YYYY-MM-DD"): assert exc_info.value.kind == "bad_format"
with pytest.raises(DateRangeValidationError) as exc_info:
validate_date_range("2025-01-01", "invalid") validate_date_range("2025-01-01", "invalid")
assert exc_info.value.kind == "bad_format"
def test_validate_date_range_from_after_to(): def test_validate_date_range_from_after_to():
with pytest.raises(ValueError, match="from не должна быть позже"): with pytest.raises(DateRangeValidationError) as exc_info:
validate_date_range("2025-02-01", "2025-01-01") validate_date_range("2025-02-01", "2025-01-01")
assert exc_info.value.kind == "from_after_to"
# --- user --- # --- user ---

View File

@@ -21,6 +21,27 @@ export function buildFetchOptions(initData) {
return { headers, signal: controller.signal, timeoutId }; return { headers, signal: controller.signal, timeoutId };
} }
/**
* GET request to API with init data, Accept-Language, timeout.
* Caller checks res.ok, res.status, res.json().
* @param {string} path - e.g. "/api/duties"
* @param {{ from?: string, to?: string }} params - query params
* @returns {Promise<Response>} - raw response
*/
export async function apiGet(path, params = {}) {
const base = window.location.origin;
const query = new URLSearchParams(params).toString();
const url = query ? `${base}${path}?${query}` : `${base}${path}`;
const initData = getInitData();
const opts = buildFetchOptions(initData);
try {
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
return res;
} finally {
clearTimeout(opts.timeoutId);
}
}
/** /**
* Fetch duties for date range. Throws ACCESS_DENIED error on 403. * Fetch duties for date range. Throws ACCESS_DENIED error on 403.
* @param {string} from - YYYY-MM-DD * @param {string} from - YYYY-MM-DD
@@ -28,17 +49,8 @@ export function buildFetchOptions(initData) {
* @returns {Promise<object[]>} * @returns {Promise<object[]>}
*/ */
export async function fetchDuties(from, to) { export async function fetchDuties(from, to) {
const base = window.location.origin;
const url =
base +
"/api/duties?from=" +
encodeURIComponent(from) +
"&to=" +
encodeURIComponent(to);
const initData = getInitData();
const opts = buildFetchOptions(initData);
try { try {
const res = await fetch(url, { headers: opts.headers, signal: opts.signal }); const res = await apiGet("/api/duties", { from, to });
if (res.status === 403) { if (res.status === 403) {
let detail = t(state.lang, "access_denied"); let detail = t(state.lang, "access_denied");
try { try {
@@ -63,8 +75,6 @@ export async function fetchDuties(from, to) {
throw new Error(t(state.lang, "error_network")); throw new Error(t(state.lang, "error_network"));
} }
throw e; throw e;
} finally {
clearTimeout(opts.timeoutId);
} }
} }
@@ -75,22 +85,11 @@ export async function fetchDuties(from, to) {
* @returns {Promise<object[]>} * @returns {Promise<object[]>}
*/ */
export async function fetchCalendarEvents(from, to) { export async function fetchCalendarEvents(from, to) {
const base = window.location.origin;
const url =
base +
"/api/calendar-events?from=" +
encodeURIComponent(from) +
"&to=" +
encodeURIComponent(to);
const initData = getInitData();
const opts = buildFetchOptions(initData);
try { try {
const res = await fetch(url, { headers: opts.headers, signal: opts.signal }); const res = await apiGet("/api/calendar-events", { from, to });
if (!res.ok) return []; if (!res.ok) return [];
return res.json(); return res.json();
} catch (e) { } catch (e) {
return []; return [];
} finally {
clearTimeout(opts.timeoutId);
} }
} }