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)
|
||||
|
||||
Reference in New Issue
Block a user