feat(schedule): per-user class picker, parser fixes
- fix parser: capture tr attrs via finditer, strip HTML comments before
cell parse (was leaving '-->' in subject names)
- store class choice in redis: user:{id}:schedule_class (private) and
chat:{id}:schedule_class (groups, admin-only via /setclass)
- /schedule renders day for stored class, /setclass sets it directly
- drop teacher emoji, format grade as "N клас"
This commit is contained in:
1 parent
d2d4efb0d7
commit
bc8d74fd28
1 file changed
+138
-52
@@ -518,11 +518,28 @@ def get_schedule_class_keyboard(classes: list):
|
|||||||
return InlineKeyboardMarkup(rows)
|
return InlineKeyboardMarkup(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_chat_class(chat, user_id) -> str | None:
|
||||||
|
"""Get stored schedule class for a private user or a group chat."""
|
||||||
|
if chat.type == 'private':
|
||||||
|
return redis_client.get(f'user:{user_id}:schedule_class')
|
||||||
|
return redis_client.get(f'chat:{chat.id}:schedule_class')
|
||||||
|
|
||||||
|
|
||||||
|
def _set_chat_class(chat, user_id, class_num) -> None:
|
||||||
|
"""Save schedule class for a private user or a group chat."""
|
||||||
|
if chat.type == 'private':
|
||||||
|
redis_client.set(f'user:{user_id}:schedule_class', str(class_num))
|
||||||
|
else:
|
||||||
|
redis_client.set(f'chat:{chat.id}:schedule_class', str(class_num))
|
||||||
|
|
||||||
|
|
||||||
def _parse_schedule_cell(cell_html: str) -> list:
|
def _parse_schedule_cell(cell_html: str) -> list:
|
||||||
"""Parse a single schedule cell, returning list of {subject, note, teacher}."""
|
"""Parse a single schedule cell, returning list of {subject, note, teacher}."""
|
||||||
lessons = []
|
lessons = []
|
||||||
parts = re.split(r'<hr\s*/?>', cell_html, flags=re.IGNORECASE)
|
parts = re.split(r'<hr\s*/?>', cell_html, flags=re.IGNORECASE)
|
||||||
for part in parts:
|
for part in parts:
|
||||||
|
part = re.sub(r'<!--.*?-->', '', part, flags=re.DOTALL)
|
||||||
|
part = re.sub(r'<!--', '', part)
|
||||||
text = re.sub(r'<[^>]+>', '\n', part)
|
text = re.sub(r'<[^>]+>', '\n', part)
|
||||||
text = re.sub(r' ', ' ', text)
|
text = re.sub(r' ', ' ', text)
|
||||||
text = text.strip()
|
text = text.strip()
|
||||||
@@ -546,16 +563,15 @@ def _parse_schedule_cell(cell_html: str) -> list:
|
|||||||
def _parse_schedule_html(table_html: str) -> dict:
|
def _parse_schedule_html(table_html: str) -> dict:
|
||||||
"""Parse schedule HTML table into {classes: [...], weekdays: {day: [...]}}."""
|
"""Parse schedule HTML table into {classes: [...], weekdays: {day: [...]}}."""
|
||||||
result = {'classes': [], 'weekdays': {}}
|
result = {'classes': [], 'weekdays': {}}
|
||||||
rows = re.findall(r'<tr[^>]*>(.*?)</tr>', table_html, re.DOTALL)
|
row_matches = re.finditer(r'<tr([^>]*)>(.*?)</tr>', table_html, re.DOTALL)
|
||||||
|
|
||||||
current_day = None
|
current_day = None
|
||||||
classes_header = []
|
classes_header = []
|
||||||
|
|
||||||
for row in rows:
|
for match in row_matches:
|
||||||
row_classes_match = re.search(r'class="([^"]*)"', row)
|
tr_attrs, row = match.group(1), match.group(2)
|
||||||
row_classes = row_classes_match.group(1) if row_classes_match else ''
|
|
||||||
|
|
||||||
if 'first-row' in row_classes:
|
if 'first-row' in tr_attrs:
|
||||||
cells = re.findall(r'<td[^>]*>(.*?)</td>', row, re.DOTALL)
|
cells = re.findall(r'<td[^>]*>(.*?)</td>', row, re.DOTALL)
|
||||||
if cells:
|
if cells:
|
||||||
day_text = re.sub(r'<[^>]+>', '', cells[0]).strip()
|
day_text = re.sub(r'<[^>]+>', '', cells[0]).strip()
|
||||||
@@ -572,7 +588,7 @@ def _parse_schedule_html(table_html: str) -> dict:
|
|||||||
result['classes'] = classes_header
|
result['classes'] = classes_header
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if 'second-row' in row_classes:
|
if 'second-row' in tr_attrs:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if current_day is None:
|
if current_day is None:
|
||||||
@@ -670,8 +686,11 @@ async def _get_schedule_data(context: ContextTypes.DEFAULT_TYPE) -> dict | None:
|
|||||||
def format_schedule_day(data: dict, weekday: str, class_num) -> str:
|
def format_schedule_day(data: dict, weekday: str, class_num) -> str:
|
||||||
days = data.get('weekdays', {})
|
days = data.get('weekdays', {})
|
||||||
entries = days.get(weekday, [])
|
entries = days.get(weekday, [])
|
||||||
class_str = str(class_num)
|
try:
|
||||||
lines = [f'📅 <b>{weekday} — Клас {class_num}</b>', '─' * 20]
|
class_key = int(class_num)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
class_key = class_num
|
||||||
|
lines = [f'📅 <b>{weekday} — {class_num} клас</b>', '─' * 20]
|
||||||
|
|
||||||
if not entries:
|
if not entries:
|
||||||
lines.append('Немає уроків')
|
lines.append('Немає уроків')
|
||||||
@@ -680,7 +699,7 @@ def format_schedule_day(data: dict, weekday: str, class_num) -> str:
|
|||||||
time_str = entry.get('time', '').replace(' ', '–')
|
time_str = entry.get('time', '').replace(' ', '–')
|
||||||
lines.append(f'\n<b>{entry["lesson_num"]}. </b>({time_str})')
|
lines.append(f'\n<b>{entry["lesson_num"]}. </b>({time_str})')
|
||||||
|
|
||||||
lessons = entry.get('classes', {}).get(class_num, [])
|
lessons = entry.get('classes', {}).get(class_key, [])
|
||||||
if not lessons:
|
if not lessons:
|
||||||
lines.append(' Вільно')
|
lines.append(' Вільно')
|
||||||
else:
|
else:
|
||||||
@@ -688,7 +707,7 @@ def format_schedule_day(data: dict, weekday: str, class_num) -> str:
|
|||||||
note_part = f' ({lesson["note"]})' if lesson.get('note') else ''
|
note_part = f' ({lesson["note"]})' if lesson.get('note') else ''
|
||||||
lines.append(f' 📌 <b>{lesson["subject"]}</b>{note_part}')
|
lines.append(f' 📌 <b>{lesson["subject"]}</b>{note_part}')
|
||||||
if lesson.get('teacher'):
|
if lesson.get('teacher'):
|
||||||
lines.append(f' 👨🏫 {lesson["teacher"]}')
|
lines.append(f' {lesson["teacher"]}')
|
||||||
if i < len(lessons) - 1:
|
if i < len(lessons) - 1:
|
||||||
lines.append(' ──')
|
lines.append(' ──')
|
||||||
|
|
||||||
@@ -896,14 +915,86 @@ async def diary_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||||||
# --- Schedule Command Handlers ---
|
# --- Schedule Command Handlers ---
|
||||||
|
|
||||||
|
|
||||||
async def schedule_command(update: Update, _context: ContextTypes.DEFAULT_TYPE):
|
async def _schedule_text_for(context, chat, user_id, weekday) -> tuple:
|
||||||
|
"""Load schedule data and render one weekday for the stored class."""
|
||||||
|
schedule_data = await _get_schedule_data(context)
|
||||||
|
if not schedule_data:
|
||||||
|
return None, '❌ Не вдалося завантажити розклад.'
|
||||||
|
class_num = _get_chat_class(chat, user_id)
|
||||||
|
if not class_num:
|
||||||
|
return None, '❌ Клас не налаштовано. Використайте /setclass.'
|
||||||
|
text = format_schedule_day(schedule_data, weekday, class_num)
|
||||||
|
if len(text) > 4096:
|
||||||
|
text = text[:4090] + '\n\n✂️ ...(обрізано)'
|
||||||
|
return text, None
|
||||||
|
|
||||||
|
|
||||||
|
async def _show_class_picker(update, context) -> bool:
|
||||||
|
"""Fetch classes from schedule and render the class-picker keyboard."""
|
||||||
|
schedule_data = await _get_schedule_data(context)
|
||||||
|
if not schedule_data:
|
||||||
|
await update.message.reply_text('❌ Не вдалося завантажити розклад.')
|
||||||
|
return False
|
||||||
|
classes = schedule_data.get('classes', [])
|
||||||
|
if not classes:
|
||||||
|
await update.message.reply_text('❌ Класи не знайдені в розкладі.')
|
||||||
|
return False
|
||||||
|
await update.message.reply_text(
|
||||||
|
'🎒 <b>Оберіть клас</b> — збережеться і більше не питатиметься:',
|
||||||
|
parse_mode='HTML',
|
||||||
|
reply_markup=get_schedule_class_keyboard(classes),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def schedule_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
user = update.effective_user
|
user = update.effective_user
|
||||||
if not is_whitelisted(user.id):
|
if not is_whitelisted(user.id):
|
||||||
await update.message.reply_text(t(user.id, 'access_denied'))
|
await update.message.reply_text(t(user.id, 'access_denied'))
|
||||||
return
|
return
|
||||||
await update.message.reply_text(
|
|
||||||
'🗓 <b>Розклад уроків</b> — виберіть день:', parse_mode='HTML', reply_markup=get_schedule_day_keyboard()
|
chat = update.effective_chat
|
||||||
)
|
stored_class = _get_chat_class(chat, user.id)
|
||||||
|
|
||||||
|
if not stored_class:
|
||||||
|
if chat.type != 'private' and not await is_group_admin(update, context):
|
||||||
|
await update.message.reply_text('⚠️ Адміністратор ще не налаштував клас для цієї групи. Використайте /setclass 11')
|
||||||
|
return
|
||||||
|
await _show_class_picker(update, context)
|
||||||
|
return
|
||||||
|
|
||||||
|
await update.message.reply_text('🔄 Завантажую розклад...')
|
||||||
|
today = datetime.now()
|
||||||
|
weekday = SCHEDULE_WEEKDAYS_FULL[today.weekday()]
|
||||||
|
text, err = await _schedule_text_for(context, chat, user.id, weekday)
|
||||||
|
if err:
|
||||||
|
await update.message.reply_text(err)
|
||||||
|
return
|
||||||
|
await update.message.reply_text(text, parse_mode='HTML', reply_markup=get_schedule_day_keyboard())
|
||||||
|
|
||||||
|
|
||||||
|
async def setclass_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
user = update.effective_user
|
||||||
|
if not is_whitelisted(user.id):
|
||||||
|
await update.message.reply_text(t(user.id, 'access_denied'))
|
||||||
|
return
|
||||||
|
|
||||||
|
chat = update.effective_chat
|
||||||
|
if chat.type != 'private' and not await is_group_admin(update, context):
|
||||||
|
await update.message.reply_text(t(user.id, 'admin_only'))
|
||||||
|
return
|
||||||
|
|
||||||
|
if context.args:
|
||||||
|
try:
|
||||||
|
class_num = int(context.args[0])
|
||||||
|
except ValueError:
|
||||||
|
await update.message.reply_text('❌ Невірний номер класу.')
|
||||||
|
return
|
||||||
|
_set_chat_class(chat, user.id, class_num)
|
||||||
|
await update.message.reply_text(f'✅ Клас <b>{class_num}</b> збережено.')
|
||||||
|
return
|
||||||
|
|
||||||
|
await _show_class_picker(update, context)
|
||||||
|
|
||||||
|
|
||||||
async def schedule_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
async def schedule_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
@@ -917,57 +1008,51 @@ async def schedule_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||||||
|
|
||||||
data = query.data
|
data = query.data
|
||||||
|
|
||||||
if data.startswith('schedule_day_'):
|
|
||||||
weekday = data[len('schedule_day_'):]
|
|
||||||
context.user_data['schedule_weekday'] = weekday
|
|
||||||
await query.edit_message_text(f'🗓 <b>{weekday}</b> — виберіть клас:', parse_mode='HTML')
|
|
||||||
|
|
||||||
schedule_data = await _get_schedule_data(context)
|
|
||||||
if not schedule_data:
|
|
||||||
await query.edit_message_text('❌ Не вдалося завантажити розклад.')
|
|
||||||
return
|
|
||||||
classes = schedule_data.get('classes', [])
|
|
||||||
if not classes:
|
|
||||||
await query.edit_message_text('❌ Класи не знайдені в розкладі.')
|
|
||||||
return
|
|
||||||
await query.edit_message_reply_markup(reply_markup=get_schedule_class_keyboard(classes))
|
|
||||||
return
|
|
||||||
|
|
||||||
if data.startswith('schedule_class_'):
|
if data.startswith('schedule_class_'):
|
||||||
class_num_str = data[len('schedule_class_'):]
|
class_num_str = data[len('schedule_class_'):]
|
||||||
weekday = context.user_data.get('schedule_weekday')
|
|
||||||
if not weekday:
|
|
||||||
await query.edit_message_text('❌ Спочатку виберіть день.')
|
|
||||||
return
|
|
||||||
|
|
||||||
await query.edit_message_text('🔄 Завантажую розклад...')
|
|
||||||
schedule_data = await _get_schedule_data(context)
|
|
||||||
if not schedule_data:
|
|
||||||
await query.edit_message_text('❌ Не вдалося завантажити розклад.')
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
class_num = int(class_num_str)
|
class_num = int(class_num_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
class_num = class_num_str
|
await query.edit_message_text('❌ Невірний номер класу.')
|
||||||
|
return
|
||||||
|
|
||||||
text = format_schedule_day(schedule_data, weekday, class_num)
|
chat = update.effective_chat
|
||||||
if len(text) > 4096:
|
if chat.type != 'private' and not await is_group_admin(update, context):
|
||||||
text = text[:4090] + '\n\n✂️ ...(обрізано)'
|
await query.edit_message_text('⚠️ Тільки адміністратор може налаштувати клас для групи.')
|
||||||
|
return
|
||||||
|
|
||||||
await query.edit_message_text(text, parse_mode='HTML')
|
_set_chat_class(chat, user_id, class_num)
|
||||||
|
await query.edit_message_text('🔄 Завантажую розклад...')
|
||||||
|
|
||||||
|
today = datetime.now()
|
||||||
|
weekday = SCHEDULE_WEEKDAYS_FULL[today.weekday()]
|
||||||
|
text, err = await _schedule_text_for(context, chat, user_id, weekday)
|
||||||
|
if err:
|
||||||
|
await query.edit_message_text(err)
|
||||||
|
return
|
||||||
|
await query.edit_message_text(text, parse_mode='HTML', reply_markup=get_schedule_day_keyboard())
|
||||||
|
return
|
||||||
|
|
||||||
|
if data.startswith('schedule_day_'):
|
||||||
|
weekday = data[len('schedule_day_'):]
|
||||||
|
await query.edit_message_text('🔄 Завантажую розклад...')
|
||||||
|
text, err = await _schedule_text_for(context, query.message.chat, user_id, weekday)
|
||||||
|
if err:
|
||||||
|
await query.edit_message_text(err)
|
||||||
|
return
|
||||||
|
await query.edit_message_text(text, parse_mode='HTML', reply_markup=get_schedule_day_keyboard())
|
||||||
return
|
return
|
||||||
|
|
||||||
if data == 'schedule_refresh':
|
if data == 'schedule_refresh':
|
||||||
context.user_data.pop('schedule_cache', None)
|
context.user_data.pop('schedule_cache', None)
|
||||||
await query.edit_message_text('🔄 Завантажую розклад...')
|
await query.edit_message_text('🔄 Завантажую розклад...')
|
||||||
schedule_data = await _get_schedule_data(context)
|
today = datetime.now()
|
||||||
if not schedule_data:
|
weekday = SCHEDULE_WEEKDAYS_FULL[today.weekday()]
|
||||||
await query.edit_message_text('❌ Не вдалося завантажити розклад.')
|
text, err = await _schedule_text_for(context, query.message.chat, user_id, weekday)
|
||||||
|
if err:
|
||||||
|
await query.edit_message_text(err)
|
||||||
return
|
return
|
||||||
await query.edit_message_text(
|
await query.edit_message_text(text, parse_mode='HTML', reply_markup=get_schedule_day_keyboard())
|
||||||
'🗓 <b>Розклад уроків</b> — виберіть день:', parse_mode='HTML', reply_markup=get_schedule_day_keyboard()
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
@@ -1244,6 +1329,7 @@ def main():
|
|||||||
app.add_handler(CommandHandler('clearhistory', clear_history))
|
app.add_handler(CommandHandler('clearhistory', clear_history))
|
||||||
app.add_handler(CommandHandler('diary', diary_command))
|
app.add_handler(CommandHandler('diary', diary_command))
|
||||||
app.add_handler(CommandHandler('schedule', schedule_command))
|
app.add_handler(CommandHandler('schedule', schedule_command))
|
||||||
|
app.add_handler(CommandHandler('setclass', setclass_command))
|
||||||
|
|
||||||
# Callback handlers - diary/schedule first, then language selection, then admin panel
|
# Callback handlers - diary/schedule first, then language selection, then admin panel
|
||||||
app.add_handler(CallbackQueryHandler(diary_callback, pattern='^diary_'))
|
app.add_handler(CallbackQueryHandler(diary_callback, pattern='^diary_'))
|
||||||
|
|||||||
Reference in new issue
Block a user