refactor: improve language normalization and date handling utilities
All checks were successful
CI / lint-and-test (push) Successful in 21s
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:
@@ -10,15 +10,32 @@ from sqlalchemy.orm import Session
|
||||
import duty_teller.config as config
|
||||
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.schemas import DutyWithUser
|
||||
from duty_teller.db.schemas import DUTY_EVENT_TYPES, DutyWithUser
|
||||
from duty_teller.db.session import session_scope
|
||||
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__)
|
||||
|
||||
# First language tag from Accept-Language (e.g. "ru-RU,ru;q=0.9,en;q=0.8" -> "ru")
|
||||
_ACCEPT_LANG_TAG_RE = re.compile(r"^([a-zA-Z]{2,3})(?:-[a-zA-Z0-9]+)?\s*(?:;|,|$)")
|
||||
# Extract primary language code from first Accept-Language tag (e.g. "ru-RU" -> "ru").
|
||||
_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:
|
||||
@@ -30,14 +47,8 @@ def _lang_from_accept_language(header: str | None) -> str:
|
||||
Returns:
|
||||
'ru' or 'en'.
|
||||
"""
|
||||
if not header or not header.strip():
|
||||
return 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"
|
||||
code = _parse_first_language_code(header)
|
||||
return normalize_lang(code if code is not None else config.DEFAULT_LANGUAGE)
|
||||
|
||||
|
||||
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."""
|
||||
try:
|
||||
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:
|
||||
msg = str(e)
|
||||
if "YYYY-MM-DD" in msg or "формате" in msg:
|
||||
detail = t(lang, "dates.bad_format")
|
||||
else:
|
||||
detail = t(lang, "dates.from_after_to")
|
||||
raise HTTPException(status_code=400, detail=detail) from e
|
||||
# Backward compatibility if something else raises ValueError.
|
||||
raise HTTPException(status_code=400, detail=t(lang, "dates.bad_format")) from e
|
||||
|
||||
|
||||
def get_validated_dates(
|
||||
@@ -211,9 +221,7 @@ def fetch_duties_response(
|
||||
end_at=duty.end_at,
|
||||
full_name=full_name,
|
||||
event_type=(
|
||||
duty.event_type
|
||||
if duty.event_type in ("duty", "unavailable", "vacation")
|
||||
else "duty"
|
||||
duty.event_type if duty.event_type in DUTY_EVENT_TYPES else "duty"
|
||||
),
|
||||
)
|
||||
for duty, full_name in rows
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import datetime, timezone
|
||||
from icalendar import Calendar, Event
|
||||
|
||||
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_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:
|
||||
"""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:
|
||||
event = Event()
|
||||
start_dt = _parse_utc_iso(duty.start_at)
|
||||
end_dt = _parse_utc_iso(duty.end_at)
|
||||
start_dt = parse_utc_iso(duty.start_at)
|
||||
end_dt = parse_utc_iso(duty.end_at)
|
||||
# Ensure timezone-aware for icalendar
|
||||
if start_dt.tzinfo is None:
|
||||
start_dt = start_dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
@@ -6,6 +6,8 @@ import json
|
||||
import time
|
||||
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
|
||||
# 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
|
||||
|
||||
|
||||
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(
|
||||
init_data: str,
|
||||
bot_token: str,
|
||||
@@ -107,7 +101,7 @@ def validate_init_data_with_reason(
|
||||
return (None, None, "user_invalid", "en")
|
||||
if not isinstance(user, dict):
|
||||
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")
|
||||
if raw_id is None:
|
||||
return (None, None, "no_user_id", lang)
|
||||
|
||||
@@ -4,13 +4,15 @@ BOT_TOKEN is not validated on import; call require_bot_token() in the entry poin
|
||||
when running the bot.
|
||||
"""
|
||||
|
||||
import re
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from duty_teller.i18n.lang import normalize_lang
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# 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())
|
||||
|
||||
|
||||
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]:
|
||||
"""Parse comma-separated phones into set of normalized (digits-only) strings."""
|
||||
result = set()
|
||||
@@ -113,9 +107,7 @@ class Settings:
|
||||
).strip(),
|
||||
duty_display_tz=os.getenv("DUTY_DISPLAY_TZ", "Europe/Moscow").strip()
|
||||
or "Europe/Moscow",
|
||||
default_language=_normalize_default_language(
|
||||
os.getenv("DEFAULT_LANGUAGE", "en").strip()
|
||||
),
|
||||
default_language=normalize_lang(os.getenv("DEFAULT_LANGUAGE", "en")),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import duty_teller.config as config
|
||||
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:
|
||||
@@ -168,9 +169,7 @@ def delete_duties_in_range(
|
||||
Returns:
|
||||
Number of duties deleted.
|
||||
"""
|
||||
to_next = (
|
||||
datetime.fromisoformat(to_date + "T00:00:00") + timedelta(days=1)
|
||||
).strftime("%Y-%m-%d")
|
||||
to_next = to_date_exclusive_iso(to_date)
|
||||
q = session.query(Duty).filter(
|
||||
Duty.user_id == user_id,
|
||||
Duty.start_at < to_next,
|
||||
@@ -197,9 +196,7 @@ def get_duties(
|
||||
Returns:
|
||||
List of (Duty, full_name) tuples.
|
||||
"""
|
||||
to_date_next = (
|
||||
datetime.fromisoformat(to_date + "T00:00:00") + timedelta(days=1)
|
||||
).strftime("%Y-%m-%d")
|
||||
to_date_next = to_date_exclusive_iso(to_date)
|
||||
q = (
|
||||
session.query(Duty, User.full_name)
|
||||
.join(User, Duty.user_id == User.id)
|
||||
@@ -230,9 +227,7 @@ def get_duties_for_user(
|
||||
Returns:
|
||||
List of (Duty, full_name) tuples.
|
||||
"""
|
||||
to_date_next = (
|
||||
datetime.fromisoformat(to_date + "T00:00:00") + timedelta(days=1)
|
||||
).strftime("%Y-%m-%d")
|
||||
to_date_next = to_date_exclusive_iso(to_date)
|
||||
filters = [
|
||||
Duty.user_id == user_id,
|
||||
Duty.start_at < to_date_next,
|
||||
@@ -392,9 +387,7 @@ def get_next_shift_end(session: Session, after_utc: datetime) -> datetime | None
|
||||
.first()
|
||||
)
|
||||
if current:
|
||||
return datetime.fromisoformat(current.end_at.replace("Z", "+00:00")).replace(
|
||||
tzinfo=None
|
||||
)
|
||||
return parse_utc_iso_naive(current.end_at)
|
||||
next_duty = (
|
||||
session.query(Duty)
|
||||
.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()
|
||||
)
|
||||
if next_duty:
|
||||
return datetime.fromisoformat(next_duty.end_at.replace("Z", "+00:00")).replace(
|
||||
tzinfo=None
|
||||
)
|
||||
return parse_utc_iso_naive(next_duty.end_at)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ from typing import Literal
|
||||
|
||||
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):
|
||||
"""Base user fields (full_name, username, first/last name)."""
|
||||
|
||||
@@ -11,8 +11,8 @@ from duty_teller.db.repository import (
|
||||
get_or_create_user,
|
||||
set_user_phone,
|
||||
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.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.pin_duty"),
|
||||
]
|
||||
|
||||
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:
|
||||
if await is_admin_async(update.effective_user.id):
|
||||
lines.append(t(lang, "help.import_schedule"))
|
||||
await update.message.reply_text("\n".join(lines))
|
||||
|
||||
|
||||
24
duty_teller/handlers/common.py
Normal file
24
duty_teller/handlers/common.py
Normal 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)
|
||||
@@ -7,7 +7,7 @@ from telegram import Update
|
||||
from telegram.ext import CommandHandler, ContextTypes, MessageHandler, filters
|
||||
|
||||
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.importers.duty_schedule import (
|
||||
DutyScheduleParseError,
|
||||
@@ -24,13 +24,7 @@ async def import_duty_schedule_cmd(
|
||||
if not update.message or not update.effective_user:
|
||||
return
|
||||
lang = get_lang(update.effective_user)
|
||||
|
||||
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:
|
||||
if not await is_admin_async(update.effective_user.id):
|
||||
await update.message.reply_text(t(lang, "import.admin_only"))
|
||||
return
|
||||
context.user_data["awaiting_handover_time"] = True
|
||||
@@ -45,13 +39,7 @@ async def handle_handover_time_text(
|
||||
return
|
||||
if not context.user_data.get("awaiting_handover_time"):
|
||||
return
|
||||
|
||||
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:
|
||||
if not await is_admin_async(update.effective_user.id):
|
||||
return
|
||||
lang = get_lang(update.effective_user)
|
||||
text = update.message.text.strip()
|
||||
@@ -78,13 +66,7 @@ async def handle_duty_schedule_document(
|
||||
handover = context.user_data.get("handover_utc_time")
|
||||
if not handover:
|
||||
return
|
||||
|
||||
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:
|
||||
if not await is_admin_async(update.effective_user.id):
|
||||
return
|
||||
if not (update.message.document.file_name or "").lower().endswith(".json"):
|
||||
await update.message.reply_text(t(lang, "import.need_json"))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""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.lang import normalize_lang
|
||||
from duty_teller.i18n.messages import MESSAGES
|
||||
|
||||
__all__ = ["MESSAGES", "get_lang", "t"]
|
||||
__all__ = ["MESSAGES", "get_lang", "normalize_lang", "t"]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import duty_teller.config as config
|
||||
from duty_teller.i18n.lang import normalize_lang
|
||||
from duty_teller.i18n.messages import MESSAGES
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -12,13 +13,12 @@ if TYPE_CHECKING:
|
||||
def get_lang(user: "User | None") -> str:
|
||||
"""
|
||||
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'.
|
||||
When user is None or has no language_code, return config.DEFAULT_LANGUAGE.
|
||||
Uses normalize_lang for user.language_code; when user is None or has no
|
||||
language_code, returns config.DEFAULT_LANGUAGE.
|
||||
"""
|
||||
if user is None or not getattr(user, "language_code", None):
|
||||
return config.DEFAULT_LANGUAGE
|
||||
code = (user.language_code or "").strip().lower()
|
||||
return "ru" if code.startswith("ru") else "en"
|
||||
return normalize_lang(user.language_code)
|
||||
|
||||
|
||||
def t(lang: str, key: str, **kwargs: str) -> str:
|
||||
|
||||
26
duty_teller/i18n/lang.py
Normal file
26
duty_teller/i18n/lang.py
Normal 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"
|
||||
@@ -14,6 +14,7 @@ from duty_teller.db.repository import (
|
||||
get_all_group_duty_pin_chat_ids,
|
||||
)
|
||||
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:
|
||||
@@ -35,8 +36,8 @@ def format_duty_message(duty, user, tz_name: str, lang: str = "en") -> str:
|
||||
except ZoneInfoNotFoundError:
|
||||
tz = ZoneInfo("Europe/Moscow")
|
||||
tz_name = "Europe/Moscow"
|
||||
start_dt = datetime.fromisoformat(duty.start_at.replace("Z", "+00:00"))
|
||||
end_dt = datetime.fromisoformat(duty.end_at.replace("Z", "+00:00"))
|
||||
start_dt = parse_utc_iso(duty.start_at)
|
||||
end_dt = parse_utc_iso(duty.end_at)
|
||||
start_local = start_dt.astimezone(tz)
|
||||
end_local = end_dt.astimezone(tz)
|
||||
offset_sec = (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Date and ISO helpers for duty ranges and API validation."""
|
||||
|
||||
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:
|
||||
@@ -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}$")
|
||||
|
||||
|
||||
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:
|
||||
"""Parse YYYY-MM-DD string to date. Returns None if invalid."""
|
||||
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:
|
||||
"""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 ""):
|
||||
raise ValueError("Параметры from и to должны быть в формате YYYY-MM-DD")
|
||||
raise DateRangeValidationError("bad_format")
|
||||
if from_date > to_date:
|
||||
raise ValueError("Дата from не должна быть позже to")
|
||||
raise DateRangeValidationError("from_after_to")
|
||||
|
||||
@@ -20,13 +20,8 @@ def parse_handover_time(text: str) -> tuple[int, int] | None:
|
||||
tz_str = (m.group(4) or "").strip()
|
||||
if not tz_str or tz_str.upper() == "UTC":
|
||||
return (hour % 24, minute)
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
except ImportError:
|
||||
try:
|
||||
from backports.zoneinfo import ZoneInfo # type: ignore
|
||||
except ImportError:
|
||||
return None
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
try:
|
||||
tz = ZoneInfo(tz_str)
|
||||
except Exception:
|
||||
|
||||
@@ -235,18 +235,15 @@ async def test_help_cmd_replies_with_help_text():
|
||||
user = _make_user()
|
||||
update = _make_update(message=message, effective_user=user)
|
||||
|
||||
with patch("duty_teller.handlers.commands.session_scope") as mock_scope:
|
||||
mock_session = MagicMock()
|
||||
mock_scope.return_value.__enter__.return_value = mock_session
|
||||
mock_scope.return_value.__exit__.return_value = None
|
||||
with patch(
|
||||
"duty_teller.handlers.commands.is_admin_for_telegram_user",
|
||||
return_value=False,
|
||||
):
|
||||
with patch("duty_teller.handlers.commands.get_lang", return_value="en"):
|
||||
with patch("duty_teller.handlers.commands.t") as mock_t:
|
||||
mock_t.side_effect = lambda lang, key: f"[{key}]"
|
||||
await help_cmd(update, MagicMock())
|
||||
with patch(
|
||||
"duty_teller.handlers.commands.is_admin_async",
|
||||
new_callable=AsyncMock,
|
||||
return_value=False,
|
||||
):
|
||||
with patch("duty_teller.handlers.commands.get_lang", return_value="en"):
|
||||
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()
|
||||
text = message.reply_text.call_args[0][0]
|
||||
assert "help.title" in text or "[help.title]" in text
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import date
|
||||
import pytest
|
||||
|
||||
from duty_teller.utils.dates import (
|
||||
DateRangeValidationError,
|
||||
day_start_iso,
|
||||
day_end_iso,
|
||||
duty_to_iso,
|
||||
@@ -47,15 +48,18 @@ def test_validate_date_range_ok():
|
||||
|
||||
|
||||
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")
|
||||
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")
|
||||
assert exc_info.value.kind == "bad_format"
|
||||
|
||||
|
||||
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")
|
||||
assert exc_info.value.kind == "from_after_to"
|
||||
|
||||
|
||||
# --- user ---
|
||||
|
||||
@@ -21,6 +21,27 @@ export function buildFetchOptions(initData) {
|
||||
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.
|
||||
* @param {string} from - YYYY-MM-DD
|
||||
@@ -28,17 +49,8 @@ export function buildFetchOptions(initData) {
|
||||
* @returns {Promise<object[]>}
|
||||
*/
|
||||
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 {
|
||||
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
|
||||
const res = await apiGet("/api/duties", { from, to });
|
||||
if (res.status === 403) {
|
||||
let detail = t(state.lang, "access_denied");
|
||||
try {
|
||||
@@ -63,8 +75,6 @@ export async function fetchDuties(from, to) {
|
||||
throw new Error(t(state.lang, "error_network"));
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
clearTimeout(opts.timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,22 +85,11 @@ export async function fetchDuties(from, to) {
|
||||
* @returns {Promise<object[]>}
|
||||
*/
|
||||
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 {
|
||||
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
|
||||
const res = await apiGet("/api/calendar-events", { from, to });
|
||||
if (!res.ok) return [];
|
||||
return res.json();
|
||||
} catch (e) {
|
||||
return [];
|
||||
} finally {
|
||||
clearTimeout(opts.timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user