diff --git a/crawler.py b/crawler.py index 1c45f67ba3e24916e8747ff396fc5d12d2ea0b00..417e139004cda71ed053290767f2c40d79d2c364 100644 --- a/crawler.py +++ b/crawler.py @@ -1,7 +1,7 @@ import re from collections import namedtuple from datetime import date, datetime, timedelta -from typing import Dict, Set +from typing import Dict, List, Set import requests from bs4 import BeautifulSoup @@ -16,7 +16,9 @@ cached_update_times: Dict[Location, datetime] = dict() cached_responses: Dict[Location, LocationDays] = dict() -def get_location_days(location: Location) -> LocationDays: +def get_location_days( + location: Location, menu_item_indicators: List[MenuItemIndicator] +) -> LocationDays: global cached_responses global cached_update_times @@ -58,21 +60,17 @@ def get_location_days(location: Location) -> LocationDays: elif child.name == "a": if not child.select_one("img"): continue - href = child["href"] - if "Vegano" in href: - item_indicators.add(MenuItemIndicator.VEGAN) - elif "Gluten" in href: - item_indicators.add(MenuItemIndicator.GLUTEN) - elif "Leite" in href: - item_indicators.add(MenuItemIndicator.LACTOSE) - elif "animal" in href: - item_indicators.add(MenuItemIndicator.ANIMAL) - elif "Ovo" in href: - item_indicators.add(MenuItemIndicator.EGG) - elif "Mel" in href: - item_indicators.add(MenuItemIndicator.HONEY) - elif "Alergenicos" in href: - item_indicators.add(MenuItemIndicator.ALERGIC) + href = child["href"].lower() + indicator = next( + ( + indicator + for indicator in menu_item_indicators + if indicator.find_text in href + ), + None, + ) + if indicator is not None: + item_indicators.add(indicator) elif child.text.strip() != "": item_name = child.text.strip() if item_name is not None: diff --git a/database.py b/database.py index d1080c237a9329ba18612affe9d42e45bfa21ed6..16078b4ba941eacaa3954e62d42230cf1da90b7c 100644 --- a/database.py +++ b/database.py @@ -9,8 +9,12 @@ try: except ImportError: from backports.zoneinfo import ZoneInfo # type: ignore -from model import Location, Meal, Schedule, WeekDay -from seed import get_seed_locations, get_seed_meals +from model import Location, Meal, MenuItemIndicator, Schedule, WeekDay +from seed import ( + get_seed_locations, + get_seed_meals, + get_seed_menu_item_indicators, +) CURITIBA_TZ = ZoneInfo("America/Sao_Paulo") @@ -31,13 +35,13 @@ with connection: connection.execute( dedent( """\ - CREATE TABLE IF NOT EXISTS location ( - name TEXT NOT NULL PRIMARY KEY, - command TEXT NOT NULL, - ordering INT NOT NULL, - url TEXT NOT NULL + CREATE TABLE IF NOT EXISTS location ( + name TEXT NOT NULL PRIMARY KEY, + command TEXT NOT NULL, + ordering INT NOT NULL, + url TEXT NOT NULL ) - """ + """ ) ) connection.execute( @@ -54,7 +58,7 @@ with connection: end_time TEXT NOT NULL, PRIMARY KEY (location_name, name, week_day) ) - """ + """ ) ) connection.execute( @@ -72,7 +76,33 @@ with connection: REFERENCES meal(location_name, name, week_day) DEFERRABLE INITIALLY DEFERRED ) - """ + """ + ) + ) + connection.execute( + dedent( + """ + CREATE TABLE IF NOT EXISTS menu_item_indicator ( + emoji TEXT NOT NULL PRIMARY KEY, + description TEXT NOT NULL, + find_text TEXT NOT NULL, + ordering INT NOT NULL + ) + """ + ) + ) + connection.execute( + dedent( + """ + CREATE TABLE IF NOT EXISTS menu_item_indicator_preferences ( + menu_item_indicator_emoji TEXT NOT NULL + REFERENCES menu_item_indicator(emoji) + DEFERRABLE INITIALLY DEFERRED, + user_id INT NOT NULL, + show BOOLEAN TEXT NOT NULL, + PRIMARY KEY (menu_item_indicator_emoji, user_id) + ) + """ ) ) cur = connection.cursor() @@ -83,9 +113,9 @@ with connection: cur.execute( dedent( """\ - INSERT INTO location (name, command, ordering, url) - VALUES (?, ?, ?, ?) - """ + INSERT INTO location (name, command, ordering, url) + VALUES (?, ?, ?, ?) + """ ), location, ) @@ -103,6 +133,20 @@ with connection: ), meal, ) + cur.execute("DELETE FROM menu_item_indicator") + seed_menu_item_indicators = get_seed_menu_item_indicators() + for menu_item_indicator in seed_menu_item_indicators: + cur.execute( + dedent( + """\ + INSERT INTO menu_item_indicator + (emoji, description, find_text, ordering) + VALUES + (?, ?, ?, ?) + """ + ), + menu_item_indicator, + ) cur.execute("COMMIT") @@ -336,7 +380,7 @@ def get_meals(): return get_meals_query( dedent( """\ - ORDER BY location.name, meal.ordering, meal.week_day + ORDER BY location.ordering, meal.ordering, meal.week_day """ ) ) @@ -352,3 +396,97 @@ def get_meals_by_location_name(location_name): ), (location_name,), ) + + +def get_menu_item_indicators(): + cur = connection.execute( + dedent( + """\ + SELECT + menu_item_indicator.emoji, + menu_item_indicator.description, + menu_item_indicator.find_text, + menu_item_indicator.ordering + FROM menu_item_indicator + ORDER BY menu_item_indicator.ordering + """ + ), + ) + rows = cur.fetchall() + return [ + MenuItemIndicator( + emoji=emoji, + description=description, + find_text=find_text, + ordering=ordering, + ) + for ( + emoji, + description, + find_text, + ordering, + ) in rows + ] + + +def get_shown_menu_item_indicators_for_user(user_id): + cur = connection.execute( + dedent( + """\ + SELECT + menu_item_indicator.emoji, + menu_item_indicator.description, + menu_item_indicator.find_text, + menu_item_indicator.ordering + FROM menu_item_indicator + LEFT JOIN menu_item_indicator_preferences + ON menu_item_indicator_preferences.menu_item_indicator_emoji + = menu_item_indicator.emoji + AND menu_item_indicator_preferences.user_id = ? + WHERE (menu_item_indicator_preferences.show IS NULL + OR menu_item_indicator_preferences.show = 1) + ORDER BY menu_item_indicator.ordering + """ + ), + (user_id,), + ) + rows = cur.fetchall() + return [ + MenuItemIndicator( + emoji=emoji, + description=description, + find_text=find_text, + ordering=ordering, + ) + for ( + emoji, + description, + find_text, + ordering, + ) in rows + ] + + +def delete_menu_item_indicator_preference(emoji, user_id): + connection.execute( + dedent( + """\ + DELETE FROM menu_item_indicator_preferences + WHERE menu_item_indicator_emoji = ? AND user_id = ? + """ + ), + (emoji, user_id), + ) + + +def insert_menu_item_indicator_preference(emoji, user_id, show): + connection.execute( + dedent( + """\ + INSERT INTO menu_item_indicator_preferences + (menu_item_indicator_emoji, user_id, show) + VALUES (?, ?, ?) + """ + ), + (emoji, user_id, show), + ) diff --git a/main.py b/main.py index 63a426165e9f04f4a70cb70ef5b9b1a74aabdfd5..6336572b3cdc241d3a10aff0390304e6d2f241f4 100644 --- a/main.py +++ b/main.py @@ -8,7 +8,7 @@ from datetime import date, datetime, timedelta from itertools import groupby from pprint import pformat from textwrap import dedent -from typing import Dict, Optional, Set +from typing import Dict, Literal, Optional, Set from dotenv import load_dotenv from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update @@ -24,12 +24,16 @@ from telegram.ext import ( from crawler import Day, Location, LocationDays, get_location_days from database import ( delete_all_schedules_from_user, + delete_menu_item_indicator_preference, get_location_by_command, get_locations, get_meals, get_meals_by_location_name, + get_menu_item_indicators, get_schedules_for_user, get_schedules_matching_datetime, + get_shown_menu_item_indicators_for_user, + insert_menu_item_indicator_preference, upsert_schedule, ) from model import Meal, Schedule @@ -65,8 +69,9 @@ def format_user(user): return f'{user_id}{username}:"{name}"' -def format_location_days(location_days): +def format_location_days(location_days, visible_menu_item_indicators): days, location, update_datetime = location_days + visible_menu_item_indicators = set(visible_menu_item_indicators) used_indicators = set() strings = [] strings.append(f"<b>RU {location.name}</b>") @@ -74,7 +79,9 @@ def format_location_days(location_days): for day in days: for menu in day.menus: for item in menu.items: - used_indicators |= item.indicators + used_indicators |= ( + item.indicators & visible_menu_item_indicators + ) strings.append( "\n\n".join( f"<b>{day.date_raw}</b>\n" @@ -85,6 +92,7 @@ def format_location_days(location_days): + "".join( indicator.emoji for indicator in sorted(item.indicators) + if indicator in visible_menu_item_indicators ) for item in menu.items ) @@ -150,6 +158,7 @@ def answer_start_command(update: Update, context: CallbackContext) -> None: """ <b>Estes são meus comandos:</b> • /agendar · Configura notificações de cardápio + • /indicadores · Configura visiblidade dos indicadores """ ) + "\n".join( @@ -171,13 +180,89 @@ def answer_cardapio_command(update: Update, context: CallbackContext) -> None: command = command[: command.index("@")] location = get_location_by_command(command[len("cardapio_") :]) - location_days = get_location_days(location) + menu_item_indicators = get_menu_item_indicators() + location_days = get_location_days(location, menu_item_indicators) + visible_menu_item_indicators = get_shown_menu_item_indicators_for_user( + update.effective_user.id + ) update.message.reply_text( - format_location_days(location_days), parse_mode=PARSEMODE_HTML + format_location_days(location_days, visible_menu_item_indicators), + parse_mode=PARSEMODE_HTML, ) logger.info(f"User {format_user(update.effective_user)} used /{command}") +@dataclass +class CustomizeIndicatorsCallbackData: + action: Literal["insert", "delete"] + emoji: str + + +def answer_customize_indicators(user_id): + menu_item_indicators = get_menu_item_indicators() + visible_menu_item_indicators = set( + get_shown_menu_item_indicators_for_user(user_id) + ) + buttons = [] + for indicator in menu_item_indicators: + if indicator in visible_menu_item_indicators: + buttons.append( + InlineKeyboardButton( + f"Remover {indicator.emoji}", + callback_data=CustomizeIndicatorsCallbackData( + "delete", indicator.emoji + ), + ) + ) + else: + buttons.append( + InlineKeyboardButton( + f"Adicionar {indicator.emoji}", + callback_data=CustomizeIndicatorsCallbackData( + "insert", indicator.emoji + ), + ) + ) + buttons = [buttons[i : i + 3] for i in range(0, len(buttons), 3)] + buttons.append( + [InlineKeyboardButton("Pronto", callback_data=ReadyCallbackData())] + ) + + return ( + "<b>Indicadores</b>\n\n" + + "\n".join( + f' • {"✅" if indicator in visible_menu_item_indicators else "❌"}' + f" · {indicator.emoji} · {indicator.description}" + for indicator in menu_item_indicators + ), + InlineKeyboardMarkup(buttons), + ) + + +def answer_customize_indicators_command( + update: Update, context: CallbackContext +) -> None: + body, keyboard = answer_customize_indicators(update.effective_user.id) + update.message.reply_text( + body, reply_markup=keyboard, parse_mode=PARSEMODE_HTML + ) + + +def answer_customize_indicators_callback( + update: Update, context: CallbackContext +) -> None: + data = update.callback_query.data + user_id = update.effective_user.id + if data.action == "insert": + delete_menu_item_indicator_preference(data.emoji, user_id) + else: + insert_menu_item_indicator_preference(data.emoji, user_id, False) + body, keyboard = answer_customize_indicators(user_id) + update.callback_query.message.edit_text( + body, reply_markup=keyboard, parse_mode=PARSEMODE_HTML + ) + + def answer_schedule(user_id): schedules = get_schedules_for_user(user_id) keyboard = [ @@ -386,8 +471,8 @@ def answer_schedule_before_opening_callback( week_days_step = data.meals_by_name[meal_step_name] buttons = [] for meal in meals_step: + new_data = deepcopy(data) if meal in week_days_step: - new_data = deepcopy(data) new_data.meals_by_name[meal_step_name].remove(meal) buttons.append( InlineKeyboardButton( @@ -395,7 +480,6 @@ def answer_schedule_before_opening_callback( ) ) else: - new_data = deepcopy(data) new_data.meals_by_name[meal_step_name].add(meal) buttons.append( InlineKeyboardButton( @@ -499,9 +583,12 @@ def send_scheduled(context: CallbackContext) -> None: ) logging.info(f"Getting schedules matching {dt}") schedules = get_schedules_matching_datetime(dt) + menu_item_indicators = get_menu_item_indicators() for schedule in schedules: - days, location, update_datetime = get_location_days(schedule.location) + days, location, update_datetime = get_location_days( + schedule.location, menu_item_indicators + ) location_days = LocationDays( days=[ Day( @@ -517,9 +604,12 @@ def send_scheduled(context: CallbackContext) -> None: location=location, update_datetime=update_datetime, ) + visible_menu_item_indicators = get_shown_menu_item_indicators_for_user( + schedule.user_id + ) context.dispatcher.bot.send_message( schedule.user_id, - format_location_days(location_days), + format_location_days(location_days, visible_menu_item_indicators), parse_mode=PARSEMODE_HTML, ) logger.info( @@ -565,6 +655,15 @@ def main() -> None: answer_schedule_callback, pattern=ScheduleCallbackData ) ) + updater.dispatcher.add_handler( + CommandHandler("indicadores", answer_customize_indicators_command) + ) + updater.dispatcher.add_handler( + CallbackQueryHandler( + answer_customize_indicators_callback, + pattern=CustomizeIndicatorsCallbackData, + ) + ) updater.dispatcher.add_handler( CallbackQueryHandler(answer_ready_callback, pattern=ReadyCallbackData) ) diff --git a/model.py b/model.py index fe0219b58c4ad4a3bb48a84840fb2fce27a7131b..45d0b24ec4c6496503d407120539de724394d4c4 100644 --- a/model.py +++ b/model.py @@ -36,26 +36,12 @@ WorkingWeekDay = [ ] -class MenuItemIndicator(Enum): - VEGAN = (1, "🌱", "Indicado para veganos") - GLUTEN = (2, "🌾", "Não indicado para celíacos por conter glúten") - LACTOSE = ( - 3, - "🥛", - "Não indicado para intolerantes à lactose por conter lactose", - ) - ANIMAL = (4, "🥩", "Contém produtos de origem animal") - EGG = (5, "🥚", "Contém ovo") - HONEY = (6, "🍯", "Contém mel") - ALERGIC = (7, "⚠️", "Contém produto(s) alergênico(s)") - - def __new__(cls, ordering, emoji, description): - obj = object.__new__(cls) - obj._value_ = (emoji, description) - obj.ordering = ordering - obj.emoji = emoji - obj.description = description - return obj +@dataclass(frozen=True) +class MenuItemIndicator: + emoji: str + description: str + find_text: str + ordering: int def __lt__(self, other): if self.__class__ is other.__class__: diff --git a/pyproject.toml b/pyproject.toml index 1e75c2fbdb5a9d9880b5d8adda2aeed67e656e7e..d84cc51b8629f633aaf6b38791ef418e6f1d52f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,3 +3,4 @@ line-length = 79 [tool.isort] profile = "black" +line_length = 79 diff --git a/seed.py b/seed.py index 732445b2b7adfe19a3ae4d635626c7721064cc39..a2d16df982cdb920755e22f82ba8aceb3f40c281 100644 --- a/seed.py +++ b/seed.py @@ -128,3 +128,50 @@ def get_seed_meals(): for week_day in WorkingWeekDay ] ) + + +def get_seed_menu_item_indicators(): + return [ + ( + "🌱", + "Indicado para veganos", + "vegano", + 1, + ), + ( + "🌾", + "Não indicado para celíacos por conter glúten", + "gluten", + 2, + ), + ( + "🥛", + "Não indicado para intolerantes à lactose por conter lactose", + "leite", + 3, + ), + ( + "🥩", + "Contém produtos de origem animal", + "animal", + 4, + ), + ( + "🥚", + "Contém ovo", + "ovo", + 5, + ), + ( + "🍯", + "Contém mel", + "mel", + 6, + ), + ( + "⚠️", + "Contém produto(s) alergênico(s)", + "alergenicos", + 7, + ), + ]