feat: add calendar subscription token functionality and ICS generation

- Introduced a new database model for calendar subscription tokens, allowing users to generate unique tokens for accessing their personal calendar.
- Implemented API endpoint to return ICS files containing only the subscribing user's duties, enhancing user experience with personalized calendar access.
- Added utility functions for generating ICS files from user duties, ensuring proper formatting and timezone handling.
- Updated command handlers to support the new calendar link feature, providing users with easy access to their personal calendar subscriptions.
- Included unit tests for the new functionality, ensuring reliability and correctness of token generation and ICS file creation.
This commit is contained in:
2026-02-19 17:04:22 +03:00
parent 4afd0ca5cc
commit dc116270b7
14 changed files with 501 additions and 12 deletions

View File

@@ -9,6 +9,7 @@ def register_handlers(app: Application) -> None:
app.add_handler(commands.start_handler)
app.add_handler(commands.help_handler)
app.add_handler(commands.set_phone_handler)
app.add_handler(commands.calendar_link_handler)
app.add_handler(import_duty_schedule.import_duty_schedule_handler)
app.add_handler(import_duty_schedule.handover_time_handler)
app.add_handler(import_duty_schedule.duty_schedule_document_handler)

View File

@@ -7,7 +7,11 @@ from telegram import Update
from telegram.ext import CommandHandler, ContextTypes
from duty_teller.db.session import session_scope
from duty_teller.db.repository import get_or_create_user, set_user_phone
from duty_teller.db.repository import (
get_or_create_user,
set_user_phone,
create_calendar_token,
)
from duty_teller.i18n import get_lang, t
from duty_teller.utils.user import build_full_name
@@ -82,6 +86,55 @@ async def set_phone(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text(t(lang, "set_phone.cleared"))
async def calendar_link(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Send personal calendar subscription URL (private chat only, access check)."""
if not update.message or not update.effective_user:
return
lang = get_lang(update.effective_user)
if update.effective_chat and update.effective_chat.type != "private":
await update.message.reply_text(t(lang, "calendar_link.private_only"))
return
telegram_user_id = update.effective_user.id
username = (update.effective_user.username or "").strip()
full_name = build_full_name(
update.effective_user.first_name, update.effective_user.last_name
)
def do_calendar_link() -> tuple[str | None, str | None]:
with session_scope(config.DATABASE_URL) as session:
user = get_or_create_user(
session,
telegram_user_id=telegram_user_id,
full_name=full_name,
username=update.effective_user.username,
first_name=update.effective_user.first_name,
last_name=update.effective_user.last_name,
)
if not config.can_access_miniapp(
username
) and not config.can_access_miniapp_by_phone(user.phone):
return (None, "denied")
token = create_calendar_token(session, user.id)
base = (config.MINI_APP_BASE_URL or "").rstrip("/")
url = f"{base}/api/calendar/ical/{token}.ics" if base else None
return (url, None)
result_url, error = await asyncio.get_running_loop().run_in_executor(
None, do_calendar_link
)
if error == "denied":
await update.message.reply_text(t(lang, "calendar_link.access_denied"))
return
if not result_url:
await update.message.reply_text(t(lang, "calendar_link.error"))
return
await update.message.reply_text(
t(lang, "calendar_link.success", url=result_url)
+ "\n\n"
+ t(lang, "calendar_link.help_hint")
)
async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not update.message or not update.effective_user:
return
@@ -91,6 +144,7 @@ async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
t(lang, "help.start"),
t(lang, "help.help"),
t(lang, "help.set_phone"),
t(lang, "help.calendar_link"),
t(lang, "help.pin_duty"),
]
if config.is_admin(update.effective_user.username or ""):
@@ -101,3 +155,4 @@ async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
start_handler = CommandHandler("start", start)
help_handler = CommandHandler("help", help_cmd)
set_phone_handler = CommandHandler("set_phone", set_phone)
calendar_link_handler = CommandHandler("calendar_link", calendar_link)