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

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.
"""
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")),
)

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -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
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,
)
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 = (

View File

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

View File

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

View File

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

View File

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

View File

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