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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)