From 5790d86e631821f4d4a047ef46752b273284d508 Mon Sep 17 00:00:00 2001 From: fmk17 <fmk17@inf.ufpr.br> Date: Sun, 13 Feb 2022 18:31:25 -0300 Subject: [PATCH] Refactor code --- .pre-commit-config.yaml | 29 ++ crawler.py | 105 ++++--- database.py | 405 +++++++++++++++++++----- main.py | 673 ++++++++++++++++++++++++++++------------ model.py | 95 ++++++ pyproject.toml | 5 + seed.py | 130 ++++++++ setup.cfg | 2 + 8 files changed, 1135 insertions(+), 309 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 model.py create mode 100644 pyproject.toml create mode 100644 seed.py create mode 100644 setup.cfg diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..f79bcf3 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,29 @@ +repos: + # Fixes the spaces + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.1.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + # Black formats the Python code + - repo: https://github.com/psf/black + rev: 22.1.0 + hooks: + - id: black + # Flake8 lints the Python code + - repo: https://github.com/pycqa/flake8 + rev: 4.0.1 + hooks: + - id: flake8 + # isort sorts the imports + - repo: https://github.com/PyCQA/isort + rev: 5.10.1 + hooks: + - id: isort + name: isort (python) + # mypy checks types + - repo: https://github.com/pre-commit/mirrors-mypy + rev: 'v0.931' + hooks: + - id: mypy + additional_dependencies: [types-requests==2.27.9] diff --git a/crawler.py b/crawler.py index 9a74b31..1c45f67 100644 --- a/crawler.py +++ b/crawler.py @@ -1,57 +1,92 @@ +import re from collections import namedtuple from datetime import date, datetime, timedelta -from bs4 import BeautifulSoup -from enum import Enum +from typing import Dict, Set + import requests -import re -import itertools +from bs4 import BeautifulSoup -Day = namedtuple('Day', 'date, date_raw, menus') -Menu = namedtuple('Menu', 'meal, items') +from model import Location, MenuItem, MenuItemIndicator -class Location(Enum): - CENTRAL = 'https://pra.ufpr.br/ru/ru-central/' - POLITECNICO = 'https://pra.ufpr.br/ru/ru-centro-politecnico/' - BOTANICO = 'https://pra.ufpr.br/ru/cardapio-ru-jardim-botanico/' - AGRARIAS = 'https://pra.ufpr.br/ru/cardapio-ru-agrarias/' +Menu = namedtuple("Menu", "meal_name, items") +Day = namedtuple("Day", "date, date_raw, menus") +LocationDays = namedtuple("LocationDays", "days, location, update_datetime") -class Meal(Enum): - BREAKFAST = "CAFÉ DA MANHÃ" - LUNCH = "ALMOÇO" - DINNER = "JANTAR" +cached_update_times: Dict[Location, datetime] = dict() +cached_responses: Dict[Location, LocationDays] = dict() -cached_update_times = dict() -cached_responses = dict() -def get_menus_by_days(location: Location): +def get_location_days(location: Location) -> LocationDays: global cached_responses global cached_update_times - if location in cached_update_times and cached_update_times[location] + timedelta(minutes=5) > datetime.now(): - return (cached_responses[location], cached_update_times[location]) + if ( + location in cached_update_times + and cached_update_times[location] + timedelta(minutes=5) + > datetime.now() + ): + return cached_responses[location] - response = requests.get(location.value) - soup = BeautifulSoup(response.text, 'lxml') + response = requests.get(location.url) + soup = BeautifulSoup(response.text, "lxml") - post = soup.select_one('#post div:nth-child(3)') + post = soup.select_one("#post div:nth-child(3)") post_children = iter(post.children) - next(post_children) + post_children = (node for node in post_children if node.text.strip() != "") days = [] - for date_node, _, table, _ in zip(post_children, post_children, post_children, post_children): + for date_node, table_node in zip(post_children, post_children): date_text = date_node.text - date_re = re.search(r'(\d{1,2})\/(\d{1,2})\/(\d{4})', date_text) - if date_re is None: break + date_re = re.search(r"(\d{1,2})\/(\d{1,2})\/(\d{4})", date_text) + if date_re is None: + break d, m, y = map(int, date_re.groups()) - table_children = iter(table.select('td')) + table_children = iter(table_node.select("td")) menus = [] - for title_node, items in zip(table_children, table_children): - menus.append(Menu(meal=Meal(title_node.text), items=list(items.stripped_strings))) - days.append( - Day(date=date(y, m, d), date_raw=date_text, menus=menus) - ) + for title_node, item_nodes in zip(table_children, table_children): + items = [] + item_name = None + item_indicators: Set[MenuItemIndicator] = set() + for child in item_nodes: + if child.name == "br" and item_name is not None: + items.append( + MenuItem(name=item_name, indicators=item_indicators) + ) + item_name = None + item_indicators = set() + 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) + elif child.text.strip() != "": + item_name = child.text.strip() + if item_name is not None: + items.append( + MenuItem(name=item_name, indicators=item_indicators) + ) + menus.append(Menu(meal_name=title_node.text, items=items)) + days.append(Day(date=date(y, m, d), date_raw=date_text, menus=menus)) - cached_responses[location] = days cached_update_times[location] = datetime.now() - return (days, cached_update_times[location]) + location_days = LocationDays( + days=days, + location=location, + update_datetime=cached_update_times[location], + ) + cached_responses[location] = location_days + return location_days diff --git a/database.py b/database.py index 789be68..cf81613 100644 --- a/database.py +++ b/database.py @@ -1,96 +1,349 @@ -from crawler import Location, Meal -from collections import namedtuple -from pprint import pformat -import sqlite3 import logging +import sqlite3 +from datetime import datetime, time +from pprint import pformat +from textwrap import dedent +from zoneinfo import ZoneInfo + +from model import Location, Meal, Schedule, WeekDay +from seed import get_seed_locations, get_seed_meals + +CURITIBA_TZ = ZoneInfo("America/Sao_Paulo") -logger = logging.getLogger("database") -Schedule = namedtuple('Schedule', 'time, day_week, location, meal, user_id, created_at') +def parse_curitiba_time(formatted_time): + hour, minute = map(int, formatted_time.split(":")) + return time(hour, minute, tzinfo=CURITIBA_TZ) -connection = sqlite3.connect("db", isolation_level=None, check_same_thread=False) + +logger = logging.getLogger("database") + +connection = sqlite3.connect( + "db", isolation_level=None, check_same_thread=False +) # Create tables with connection: - connection.execute(''' - CREATE TABLE IF NOT EXISTS schedule ( - time TEXT NOT NULL, - day_week INT NOT NULL, - location TEXT NOT NULL, - meal TEXT NOT NULL, - user_id INT NOT NULL, - created_at DATETIME NOT NULL + 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 + ) + """ + ) + ) + connection.execute( + dedent( + """\ + CREATE TABLE IF NOT EXISTS meal ( + location_name TEXT NOT NULL + REFERENCES location (name) + DEFERRABLE INITIALLY DEFERRED, + name TEXT NOT NULL, + week_day INT NOT NULL, + ordering INT NOT NULL, + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + PRIMARY KEY (location_name, name, week_day) + ) + """ + ) ) - ''') + connection.execute( + dedent( + """\ + CREATE TABLE IF NOT EXISTS schedule ( + time TEXT NOT NULL, + location_name TEXT NOT NULL REFERENCES location (name), + meal_name TEXT NOT NULL, + week_day INT NOT NULL, + user_id INT NOT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (time, location_name, meal_name, week_day, user_id), + FOREIGN KEY (location_name, meal_name, week_day) + REFERENCES meal(location_name, name, week_day) + DEFERRABLE INITIALLY DEFERRED + ) + """ + ) + ) + cur = connection.cursor() + cur.execute("BEGIN") + cur.execute("DELETE FROM location") + seed_locations = get_seed_locations() + for location in seed_locations: + cur.execute( + dedent( + """\ + INSERT INTO location (name, command, ordering, url) + VALUES (?, ?, ?, ?) + """ + ), + location, + ) + cur.execute("DELETE FROM meal") + seed_meals = get_seed_meals() + for meal in seed_meals: + cur.execute( + dedent( + """\ + INSERT INTO meal + (location_name, name, week_day, ordering, start_time, end_time) + VALUES + (?, ?, ?, ?, ?, ?) + """ + ), + meal, + ) + cur.execute("COMMIT") -def get_schedules_for_user(user_id): - cur = connection.execute(''' - SELECT time, day_week, location, meal, user_id, created_at - FROM schedule - WHERE user_id = ? - ''', (user_id,)) + +def get_schedules_query(end_query, end_params): + cur = connection.execute( + dedent( + f"""\ + SELECT + schedule.time, + schedule.week_day, + schedule.location_name, + location.command as location_command, + location.ordering as location_ordering, + location.url AS location_url, + schedule.meal_name, + meal.ordering as meal_ordering, + meal.start_time as meal_start_time, + meal.end_time as meal_end_time, + schedule.user_id, + schedule.created_at + FROM schedule + INNER JOIN location + ON location.name = schedule.location_name + INNER JOIN meal + ON meal.location_name = schedule.location_name + AND meal.name = schedule.meal_name + AND meal.week_day = schedule.week_day + {end_query} + """ + ), + end_params, + ) rows = cur.fetchall() - return [Schedule( - row[0], - row[1], - Location[row[2]], - Meal[row[3]], - row[4], - row[5] - ) for row in rows] + return [ + Schedule( + time=time, + week_day=WeekDay(week_day), + meal=Meal( + location=Location( + name=location_name, + command=location_command, + ordering=location_ordering, + url=location_url, + ), + name=meal_name, + week_day=WeekDay(week_day), + ordering=meal_ordering, + start_time=meal_start_time, + end_time=meal_end_time, + ), + user_id=user_id, + created_at=created_at, + ) + for ( + time, + week_day, + location_name, + location_command, + location_ordering, + location_url, + meal_name, + meal_ordering, + meal_start_time, + meal_end_time, + user_id, + created_at, + ) in rows + ] + + +def get_schedules_for_user(user_id): + return get_schedules_query( + dedent( + """\ + WHERE user_id = ? + ORDER BY location.ordering, meal.ordering, meal.week_day + """ + ), + (user_id,), + ) + + +def get_schedules_matching_datetime(dt: datetime): + time = dt.strftime("%H:%M") + week_day = dt.weekday() + logging.info( + f"Getting schedules matching time {time} AND week_day {week_day}" + ) + return get_schedules_query( + dedent( + """\ + WHERE schedule.time = ? AND schedule.week_day = ? + ORDER BY schedule.user_id, location.ordering, meal.ordering + """ + ), + (time, week_day), + ) + def upsert_schedule(schedule): - cur = connection.execute(''' - SELECT created_at - FROM schedule - WHERE time = ? and day_week = ? and location = ? and meal = ? and user_id = ? - ''', ( - schedule.time, - schedule.day_week, - schedule.location.name, - schedule.meal.name, - schedule.user_id - )) + cur = connection.execute( + dedent( + """\ + SELECT created_at + FROM schedule + WHERE week_day = ? AND location_name = ? + AND meal_name = ? AND user_id = ? + """ + ), + ( + schedule.week_day.value, + schedule.meal.location.name, + schedule.meal.name, + schedule.user_id, + ), + ) row = cur.fetchone() if not row: logging.info(f"Inserting {pformat(schedule)}") - connection.execute(''' - INSERT INTO schedule - (time, day_week, location, meal, user_id, created_at) - VALUES - (?, ?, ?, ?, ?, ?) - ''', ( - schedule.time, - schedule.day_week, - schedule.location.name, - schedule.meal.name, - schedule.user_id, - schedule.created_at - )) + connection.execute( + dedent( + """\ + INSERT INTO schedule + (time, week_day, location_name, meal_name, user_id, created_at) + VALUES + (?, ?, ?, ?, ?, ?) + """ + ), + ( + schedule.time.strftime("%H:%M"), + schedule.week_day.value, + schedule.meal.location.name, + schedule.meal.name, + schedule.user_id, + schedule.created_at, + ), + ) else: logging.info(f"Already inserted {pformat(schedule)}") -def get_schedules_matching_time(datetime): - time = datetime.strftime('%H:%M') - day_week = datetime.weekday() - logging.info(f"Getting schedules matching time {time} and day_week {day_week}") - cur = connection.execute(''' - SELECT time, day_week, location, meal, user_id, created_at - FROM schedule - WHERE time = ? and day_week = ? - ''', (time, day_week)) - rows = cur.fetchall() - return [Schedule( - row[0], - row[1], - Location[row[2]], - Meal[row[3]], - row[4], - row[5] - ) for row in rows] def delete_all_schedules_from_user(user_id): - connection.execute(''' - DELETE FROM schedule - WHERE user_id = ? - ''', (user_id,)) + connection.execute( + dedent( + """ + DELETE FROM schedule + WHERE user_id = ? + """ + ), + (user_id,), + ) + + +def get_locations(): + cur = connection.execute( + dedent( + """\ + SELECT name, command, ordering, url FROM location + """ + ) + ) + rows = cur.fetchall() + return [Location(*row) for row in rows] + + +def get_location_by_command(command): + cur = connection.execute( + dedent( + """\ + SELECT name, command, ordering, url FROM location + WHERE command = ? + """ + ), + (command,), + ) + return Location(*row) if (row := cur.fetchone()) is not None else None + + +def get_meals_query(end_query, end_params=()): + cur = connection.execute( + dedent( + f"""\ + SELECT + meal.location_name, + location.command as location_command, + location.ordering as location_ordering, + location.url as location_url, + meal.name, + meal.week_day, + meal.ordering, + meal.start_time, + meal.end_time + FROM meal + INNER JOIN location ON location.name = meal.location_name + {end_query} + """ + ), + end_params, + ) + rows = cur.fetchall() + return [ + Meal( + location=Location( + name=location_name, + command=location_command, + ordering=location_ordering, + url=location_url, + ), + name=name, + week_day=WeekDay(week_day), + ordering=ordering, + start_time=parse_curitiba_time(start_time), + end_time=parse_curitiba_time(end_time), + ) + for ( + location_name, + location_command, + location_ordering, + location_url, + name, + week_day, + ordering, + start_time, + end_time, + ) in rows + ] + + +def get_meals(): + return get_meals_query( + dedent( + """\ + ORDER BY location.name, meal.ordering, meal.week_day + """ + ) + ) + + +def get_meals_by_location_name(location_name): + return get_meals_query( + dedent( + """\ + WHERE meal.location_name = ? + ORDER BY meal.ordering, meal.week_day + """ + ), + (location_name,), + ) diff --git a/main.py b/main.py index 758e2b8..79fc1a2 100644 --- a/main.py +++ b/main.py @@ -1,301 +1,578 @@ #!/usr/bin/env python3 -from dotenv import load_dotenv -from telegram import Update, InlineKeyboardMarkup, InlineKeyboardButton -from telegram.constants import PARSEMODE_HTML -from telegram.ext import Updater, CommandHandler, CallbackContext, CallbackQueryHandler, InvalidCallbackData -from crawler import get_menus_by_days, Location, Meal -from database import get_schedules_matching_time, upsert_schedule, get_schedules_for_user, Schedule, delete_all_schedules_from_user -from datetime import datetime, timedelta, date +import logging +import os from collections import defaultdict from copy import deepcopy -from pprint import pformat from dataclasses import dataclass, field -import typing -import json -import logging -import os +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 dotenv import load_dotenv +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update +from telegram.constants import PARSEMODE_HTML +from telegram.ext import ( + CallbackContext, + CallbackQueryHandler, + CommandHandler, + InvalidCallbackData, + Updater, +) + +from crawler import Day, Location, LocationDays, get_location_days +from database import ( + delete_all_schedules_from_user, + get_location_by_command, + get_locations, + get_meals, + get_meals_by_location_name, + get_schedules_for_user, + get_schedules_matching_datetime, + upsert_schedule, +) +from model import Meal, Schedule + + +def add_time_and_timedelta(time, timedelta): + return (datetime.combine(date.today(), time) + timedelta).time() + load_dotenv() -file_handler = logging.FileHandler('bot.log') +file_handler = logging.FileHandler("bot.log") stream_handler = logging.StreamHandler() stream_handler.setLevel(logging.INFO) logging.basicConfig( level=logging.DEBUG, - format='%(asctime)s %(levelname)s %(name)s (%(module)s:%(funcName)s:%(lineno)d) %(message)s', - datefmt='%Y-%m-%dT%H:%M:%S', - handlers=[file_handler, stream_handler]) + format="%(asctime)s %(levelname)s %(name)s" + " (%(module)s:%(funcName)s:%(lineno)d) %(message)s", + datefmt="%Y-%m-%dT%H:%M:%S", + handlers=[file_handler, stream_handler], +) logger = logging.getLogger("bot") -def start(update: Update, context: CallbackContext) -> None: - update.message.reply_text(''' - Olá, eu sou o RU UFPR Bot, o robô de <a href="https://gitlab.c3sl.ufpr.br/caad/ru-bot-telegram">código aberto licenciado sob AGPL</a> mantido pelo <a href="https://caad.inf.ufpr.br/">CAAD (Centro Acadêmico Alexandre Direne)</a> que te mostra o cardápio do Restaurante Universitário da UFPR! Aqui vão os meus comandos: - - - /agendar · Configura notificações de cardápio - - /cardapio_central · Mostra o cardápio do RU Central - - /cardapio_poli · Mostra o cardápio do RU Centro Politécnico - - /cardapio_botanico · Mostra o cardápio do RU Jardim Botânico - - /cardapio_agrarias · Mostra o cardápio do RU Agrárias - ''', parse_mode=PARSEMODE_HTML) - - logger.info(f"User {update.effective_user.id} {update.effective_user.first_name} {update.effective_user.last_name} {update.effective_user.username} used /start") - -COMMAND_TO_LOCATION = { - 'cardapio_central': Location.CENTRAL, - 'cardapio_poli': Location.POLITECNICO, - 'cardapio_botanico': Location.BOTANICO, - 'cardapio_agrarias': Location.AGRARIAS, -} - -LOCATION_TO_HEADER = { - Location.CENTRAL: 'RU Central', - Location.POLITECNICO: 'RU Centro Politécnico', - Location.BOTANICO: 'RU Jardim Botânico', - Location.AGRARIAS: 'RU Agrárias', -} - -def cardapio(update: Update, context: CallbackContext) -> None: - command = next(iter(update.message.parse_entities(types=["bot_command"]).values()))[1:] - if '@' in command: - command = command[:command.index('@')] - - location = COMMAND_TO_LOCATION[command] - days, update_time = get_menus_by_days(location) - - header = f'<b>{LOCATION_TO_HEADER[location]}</b>' + +def format_user(user): + user_id = user.id + name = user.first_name + ( + f" {user.last_name}" if user.last_name is not None else "" + ) + username = f"@{user.username}" if user.username is not None else "" + return f'{user_id}{username}:"{name}"' + + +def format_location_days(location_days): + days, location, update_datetime = location_days + header = f"<b>RU {location.name}</b>" if len(days): - body = '\n\n'.join( - f'<b>{day.date_raw}</b>\n' + '\n'.join( - f' <b>{menu.meal.value}</b>\n' + '\n'.join(f' • {item}' for item in menu.items) for menu in day.menus - ) for day in days + body = "\n\n".join( + f"<b>{day.date_raw}</b>\n" + + "\n".join( + f" <b>{menu.meal_name}</b>\n" + + "\n".join( + f" • {item.name} " + + "".join(indicator.emoji for indicator in item.indicators) + for item in menu.items + ) + for menu in day.menus + ) + for day in days ) else: - body = '<b>Cardápio indisponível</b>' - updated_on = f'<i>Atualizado às ' + update_time.strftime('%H:%M:%S') + ' de hoje</i>' - - update.message.reply_text(header + '\n\n' + body + '\n\n' + updated_on, parse_mode=PARSEMODE_HTML) - logger.info(f"User {update.effective_user.id} {update.effective_user.first_name} {update.effective_user.last_name} {update.effective_user.username} used /{command}") - -DAY_WEEK_NUMBER_TO_STRING = { - 0: "SEG", 1: "TER", 2: "QUA", 3: "QUI", 4: "SEX", 5: "SÁB", 6: "DOM" -} + body = "<i>Cardápio indisponível</i>" + updated_on = ( + "<i>Atualizado às " + + update_datetime.strftime("%H:%M:%S") + + " de hoje</i>" + ) + return header + "\n\n" + body + "\n\n" + updated_on -DAY_WEEK_NUMBER_TO_LONG_STRING = { - 0: "Segunda-feira", 1: "Terça-feira", 2: "Quarta-feira", 3: "Quinta-feira", 4: "Sexta-feira", 5: "Sábado", 6: "Domingo" -} @dataclass -class AgendarAntesAbertura: +class ScheduleBeforeOpeningCallbackData: step: int - location: Location = None - days_week_by_meal: typing.Dict[Meal, set] = field(default_factory=dict) + location: Optional[Location] = None + timedelta_before_opening: Optional[timedelta] = None + meals_by_name: Dict[str, Set[Meal]] = field(default_factory=dict) + + +class AskDeleteAllNotificationsCallbackData: + pass -class RemoveTodasNotificacoes: + +class ReadyCallbackData: + pass + + +class DeleteAllNotificationsCallbackData: pass -def agendar(user_id): + +class ScheduleCallbackData: + pass + + +def answer_start_command(update: Update, context: CallbackContext) -> None: + locations = get_locations() + update.message.reply_text( + f"Olá, sou o @{context.bot.username}, o robô de" + """ <a href="https://gitlab.c3sl.ufpr.br/caad/ru-bot-telegram">""" + """código aberto licenciado sob AGPL</a> mantido pelo""" + """ <a href="https://caad.inf.ufpr.br/">""" + """CAAD (Centro Acadêmico Alexandre Direne)</a>""" + """ que te mostra o cardápio do Restaurante Universitário da UFPR!""" + + dedent( + """ + <b>Estes são meus comandos:</b> + • /agendar · Configura notificações de cardápio + """ + ) + + "\n".join( + f" • /cardapio_{location.command} · " + "Mostra o cardápio do RU {location.name}" + for location in locations + ), + parse_mode=PARSEMODE_HTML, + ) + + logger.info(f"User {format_user(update.effective_user)} used /start") + + +def answer_cardapio_command(update: Update, context: CallbackContext) -> None: + command = next( + iter(update.message.parse_entities(types=["bot_command"]).values()) + )[1:] + if "@" in command: + command = command[: command.index("@")] + + location = get_location_by_command(command[len("cardapio_") :]) + location_days = get_location_days(location) + update.message.reply_text( + format_location_days(location_days), parse_mode=PARSEMODE_HTML + ) + logger.info(f"User {format_user(update.effective_user)} used /{command}") + + +def answer_schedule(user_id): schedules = get_schedules_for_user(user_id) keyboard = [ - [InlineKeyboardButton('Agendar notificações para antes da abertura...', callback_data=AgendarAntesAbertura(-1))], - #[InlineKeyboardButton('Agendar notificações diárias...', callback_data="agendar_diario")], - #[InlineKeyboardButton('Agendar uma notificação individual...', callback_data="agendar_individual")], + [ + InlineKeyboardButton( + "Agendar notificações para antes da abertura...", + callback_data=ScheduleBeforeOpeningCallbackData(-1), + ) + ], + # [ + # InlineKeyboardButton( + # "Agendar notificações diárias...", + # callback_data=ScheduleDigestCallbackData(-1) + # ) + # ], + # [ + # InlineKeyboardButton( + # "Agendar uma notificação individual...", + # callback_data=ScheduleOnceCallbackData(-1) + # ) + # ], ] if len(schedules): keyboard += [ - #[InlineKeyboardButton('Remover uma notificação individual...', callback_data="remover_agendamento_individual")], - [InlineKeyboardButton('Remover todas as notificações...', callback_data=RemoveTodasNotificacoes())], + # [ + # InlineKeyboardButton( + # "Remover uma notificação individual...", + # callback_data=DeleteOnceCallbackData() + # ) + # ], + [ + InlineKeyboardButton( + "Remover todas as notificações...", + callback_data=AskDeleteAllNotificationsCallbackData(), + ) + ], ] + keyboard.append( + [InlineKeyboardButton("Pronto", callback_data=ReadyCallbackData())] + ) keyboard = InlineKeyboardMarkup(keyboard) if len(schedules): schedules_by_location = defaultdict(list) for schedule in schedules: - schedules_by_location[schedule.location].append(schedule) - day_week_by_time_meal_by_location = dict() + schedules_by_location[schedule.meal.location].append(schedule) + week_day_by_time_meal_by_location = dict() for key, schedules in schedules_by_location.items(): by_time_meal = defaultdict(set) for schedule in schedules: - by_time_meal[(schedule.time, schedule.meal)].add(schedule.day_week) - day_week_by_time_meal_by_location[key] = by_time_meal + by_time_meal[(schedule.time, schedule.meal.name)].add( + schedule.week_day + ) + week_day_by_time_meal_by_location[key] = by_time_meal return ( - f'<b>Notificações</b>\n\n' + - '\n\n'.join( - f'<b>{LOCATION_TO_HEADER[location]}</b>\n' + - '\n'.join( - f' • {time} · {meal.value} · {", ".join(DAY_WEEK_NUMBER_TO_STRING[day_week] for day_week in day_weeks)}' - for (time, meal), day_weeks in day_week_by_time_meal.items() + "<b>Notificações</b>\n\n" + + "\n\n".join( + f"<b>RU {location.name}</b>\n" + + "\n".join( + f" • {time} · {meal_name} · " + + ", ".join( + week_day.short for week_day in sorted(week_days) + ) + for ( + time, + meal_name, + ), week_days in week_day_by_time_meal.items() ) - for location, day_week_by_time_meal - in day_week_by_time_meal_by_location.items() + for ( + location, + week_day_by_time_meal, + ) in week_day_by_time_meal_by_location.items() ), - keyboard + keyboard, ) else: return ( - f'<b>Notificações</b>\n\nAtualmente você não tem nenhuma notificação agendada.', - keyboard + "<b>Notificações</b>\n\n" + "<i>Atualmente você não tem nenhuma notificação agendada.</i>", + keyboard, ) -def agendar_reply(update: Update, context: CallbackContext) -> None: - body, keyboard = agendar(update.effective_user.id) - update.message.reply_text(body, reply_markup=keyboard, parse_mode=PARSEMODE_HTML) -def agendar_callback(update: Update, context: CallbackContext) -> None: - body, keyboard = agendar(update.effective_user.id) - update.callback_query.message.edit_text(body, reply_markup=keyboard, parse_mode=PARSEMODE_HTML) +def answer_ready_callback(update: Update, context: CallbackContext) -> None: + update.callback_query.message.edit_reply_markup(None) + -VALID_WEEK_DAYS_BY_LOCATION = { - Location.CENTRAL: {0, 1, 2, 3, 4, 5, 6}, - Location.POLITECNICO: {0, 1, 2, 3, 4}, -} +def answer_schedule_command(update: Update, context: CallbackContext) -> None: + body, keyboard = answer_schedule(update.effective_user.id) + update.message.reply_text( + body, reply_markup=keyboard, parse_mode=PARSEMODE_HTML + ) + + +def answer_schedule_callback(update: Update, context: CallbackContext) -> None: + body, keyboard = answer_schedule(update.effective_user.id) + update.callback_query.message.edit_text( + body, reply_markup=keyboard, parse_mode=PARSEMODE_HTML + ) -TIME_BEFORE_OPENING_BY_MEAL = { - Meal.BREAKFAST: '06:15', - Meal.LUNCH: '10:30', - Meal.DINNER: '17:00', -} -def agendar_antes_abertura(update: Update, context: CallbackContext) -> None: +def answer_schedule_before_opening_callback( + update: Update, context: CallbackContext +) -> None: data = update.callback_query.data if data.step == -1: buttons = [] - for location, valid_days_week in VALID_WEEK_DAYS_BY_LOCATION.items(): + meals = get_meals() + for location, location_meals in groupby( + meals, key=lambda lwd: lwd.location + ): + new_data = ScheduleBeforeOpeningCallbackData( + step=0, location=location + ) + buttons.append( + [ + InlineKeyboardButton( + f"RU {location.name}", callback_data=new_data + ) + ] + ) + buttons.append( + [ + InlineKeyboardButton( + "« Voltar", callback_data=ScheduleCallbackData() + ), + ] + ) + + update.callback_query.message.edit_text( + "<b>Notificações</b>\n\n" + "De qual local você quer receber notificações?", + reply_markup=InlineKeyboardMarkup(buttons), + parse_mode=PARSEMODE_HTML, + ) + return + + meals = get_meals_by_location_name(data.location.name) + meals_by_name_tuples = [ + (key, list(values)) + for key, values in groupby(meals, key=lambda m: m.name) + ] + + if data.step == len(meals_by_name_tuples): + buttons = [] + + for text, delta in [ + ("15m", timedelta(hours=0, minutes=15)), + ("30m", timedelta(hours=0, minutes=30)), + ("45m", timedelta(hours=0, minutes=45)), + ("1h", timedelta(hours=1, minutes=0)), + ("1h15m", timedelta(hours=1, minutes=15)), + ("1h30m", timedelta(hours=1, minutes=30)), + ("1h45m", timedelta(hours=1, minutes=45)), + ("2h", timedelta(hours=2, minutes=0)), + ]: new_data = deepcopy(data) - new_data.location = location + new_data.timedelta_before_opening = delta new_data.step += 1 - for meal, old_days_week in new_data.days_week_by_meal.items(): - new_data.days_week_by_meal[meal] = old_days_week.intersection(valid_days_week) - buttons.append([ - InlineKeyboardButton(LOCATION_TO_HEADER[location], callback_data=new_data) - ]) + buttons.append(InlineKeyboardButton(text, callback_data=new_data)) + + buttons = [buttons[i : i + 4] for i in range(0, len(buttons), 4)] + + prev_data = deepcopy(data) + prev_data.step -= 1 + buttons.append( + [InlineKeyboardButton("« Voltar", callback_data=prev_data)] + ) update.callback_query.message.edit_text( - '<b>Notificações</b>\n\nDe qual local você quer receber notificações?', + "<b>Notificações</b>\n\n" + f"Quanto tempo antes da abertura do RU {data.location.name}" + " você quer receber a notificação?", reply_markup=InlineKeyboardMarkup(buttons), - parse_mode=PARSEMODE_HTML + parse_mode=PARSEMODE_HTML, ) return - if data.step == 3: - for meal, days_week in data.days_week_by_meal.items(): - for day_week in days_week: - time = TIME_BEFORE_OPENING_BY_MEAL[meal] - schedule = Schedule(time=time, day_week=day_week, location=data.location, meal=meal, user_id=update.effective_user.id, created_at=datetime.now()) - logger.info(f"User {update.effective_user.id} {update.effective_user.first_name} {update.effective_user.last_name} {update.effective_user.username} added schedule {pformat(schedule)}") + if data.step == len(meals_by_name_tuples) + 1: + for meals in data.meals_by_name.values(): + for meal in meals: + time = add_time_and_timedelta( + meal.start_time, -data.timedelta_before_opening + ) + schedule = Schedule( + time=time, + week_day=meal.week_day, + meal=meal, + user_id=update.effective_user.id, + created_at=datetime.now(), + ) + logger.info( + f"User {format_user(update.effective_user)}" + " added schedule {pformat(schedule)}" + ) upsert_schedule(schedule) - body, keyboard = agendar(update.effective_user.id) - update.callback_query.message.edit_text(body, reply_markup=keyboard, parse_mode=PARSEMODE_HTML) + body, keyboard = answer_schedule(update.effective_user.id) + update.callback_query.message.edit_text( + body, reply_markup=keyboard, parse_mode=PARSEMODE_HTML + ) return - meal_step = list(Meal.__members__.values())[data.step] - if meal_step not in data.days_week_by_meal: - data.days_week_by_meal[meal_step] = { 0, 1, 2, 3, 4 } - days_week_step = data.days_week_by_meal[meal_step] + meal_step_name, meals_step = meals_by_name_tuples[data.step] + if meal_step_name not in data.meals_by_name: + data.meals_by_name[meal_step_name] = set(meals_step) + week_days_step = data.meals_by_name[meal_step_name] buttons = [] - for day_week in VALID_WEEK_DAYS_BY_LOCATION[data.location]: - if day_week in days_week_step: + for meal in meals_step: + if meal in week_days_step: new_data = deepcopy(data) - new_data.days_week_by_meal[meal_step].remove(day_week) - buttons.append([InlineKeyboardButton(f'Remover {DAY_WEEK_NUMBER_TO_LONG_STRING[day_week]}', callback_data=new_data)]) + new_data.meals_by_name[meal_step_name].remove(meal) + buttons.append( + InlineKeyboardButton( + f"Remover {meal.week_day.long}", callback_data=new_data + ) + ) else: new_data = deepcopy(data) - new_data.days_week_by_meal[meal_step].add(day_week) - buttons.append([InlineKeyboardButton(f'Adicionar {DAY_WEEK_NUMBER_TO_LONG_STRING[day_week]}', callback_data=new_data)]) + new_data.meals_by_name[meal_step_name].add(meal) + buttons.append( + InlineKeyboardButton( + f"Adicionar {meal.week_day.long}", callback_data=new_data + ) + ) + + buttons = [buttons[i : i + 2] for i in range(0, len(buttons), 2)] next_data = deepcopy(data) next_data.step += 1 prev_data = deepcopy(data) prev_data.step -= 1 update.callback_query.message.edit_text( - f'<b>Notificações</b>\n\n<b>{LOCATION_TO_HEADER[data.location]}</b>\n' + - '\n'.join(f' • {meal.value} · {", ".join(DAY_WEEK_NUMBER_TO_STRING[day_week] for day_week in day_weeks)}' - for meal, day_weeks in data.days_week_by_meal.items()) + '\n\n' + - f'Adicione ou remova dias de semana para as notificações do {meal_step.value}, vá para o próximo quando terminar.', - reply_markup=InlineKeyboardMarkup([ - *buttons, + f"<b>Notificações</b>\n\n<b>RU {data.location.name}</b>\n" + + "\n".join( + ("<b>" if i == data.step else "") + + f" • {meal_name} · " + + ", ".join( + meal.week_day.short + for meal in sorted(meals, key=lambda m: m.week_day) + ) + + ("</b>" if i == data.step else "") + for i, (meal_name, meals) in enumerate(data.meals_by_name.items()) + ) + + "\n\n" + "Adicione ou remova dias de semana para as notificações" + f" do <b>{meal_step_name}</b>, vá para o próximo quando terminar.", + reply_markup=InlineKeyboardMarkup( [ - InlineKeyboardButton('« Voltar', callback_data=prev_data), - InlineKeyboardButton('Próximo »', callback_data=next_data) - ], - ]), - parse_mode=PARSEMODE_HTML + *buttons, + [ + InlineKeyboardButton("« Voltar", callback_data=prev_data), + InlineKeyboardButton("Próximo »", callback_data=next_data), + ], + ] + ), + parse_mode=PARSEMODE_HTML, ) -class RemoveTodasNotificacoesOk: - pass - -class Agendar: - pass -def remover_todas_notificacoes(update: Update, context: CallbackContext) -> None: +def answer_ask_delete_all_notifications_callback( + update: Update, context: CallbackContext +) -> None: update.callback_query.message.edit_text( - '<b>Notificações</b>\n\nVocê tem certeza que quer remover todas as notificações?', - reply_markup=InlineKeyboardMarkup([ - [InlineKeyboardButton('Sim, remover todas as notificações', callback_data=RemoveTodasNotificacoesOk())], - [InlineKeyboardButton('Não, mantenha minhas notificações', callback_data=Agendar())] - ]), - parse_mode=PARSEMODE_HTML + "<b>Notificações</b>\n\n" + + "Você tem certeza que quer remover todas as notificações?", + reply_markup=InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton( + "Sim, remover todas as notificações", + callback_data=DeleteAllNotificationsCallbackData(), + ) + ], + [ + InlineKeyboardButton( + "Não, mantenha minhas notificações", + callback_data=ScheduleCallbackData(), + ) + ], + ] + ), + parse_mode=PARSEMODE_HTML, ) -def remover_todas_notificacoes_ok(update: Update, context: CallbackContext) -> None: + +def answer_delete_all_notifications( + update: Update, context: CallbackContext +) -> None: delete_all_schedules_from_user(update.effective_user.id) - body, keyboard = agendar(update.effective_user.id) - update.callback_query.message.edit_text(body, reply_markup=keyboard, parse_mode=PARSEMODE_HTML) - logger.info(f"User {update.effective_user.id} {update.effective_user.first_name} {update.effective_user.last_name} {update.effective_user.username} removed all notifications") + body, keyboard = answer_schedule(update.effective_user.id) + update.callback_query.message.edit_text( + body, reply_markup=keyboard, parse_mode=PARSEMODE_HTML + ) + logger.info( + f"User {format_user(update.effective_user)} removed all notifications" + ) + + +def answer_invalid_button_callback( + update: Update, context: CallbackContext +) -> None: + update.callback_query.answer( + text="Essa mensagem não é mais válida", show_alert=True + ) + update.callback_query.message.edit_reply_markup() + + +def try_send_schedule_again(context: CallbackContext) -> None: + pass -def invalid_button(update: Update, context: CallbackContext) -> None: - update.callback_query.answer(text="Essa mensagem não é mais válida", show_alert=True) def send_scheduled(context: CallbackContext) -> None: - time = datetime.now() - time += datetime.timedelta(minutes=EVERY_X_MINUTES) - time -= datetime.timedelta(minutes=time.minute % EVERY_X_MINUTES, seconds=time.second, microseconds=time.microsecond) - logging.info(f"Getting schedules matching {time}") - schedules = get_schedules_matching_time(time) + dt = datetime.now() + dt += timedelta(minutes=EVERY_X_MINUTES) + dt -= timedelta( + minutes=dt.minute % EVERY_X_MINUTES, + seconds=dt.second, + microseconds=dt.microsecond, + ) + logging.info(f"Getting schedules matching {dt}") + schedules = get_schedules_matching_datetime(dt) + for schedule in schedules: - days, update_time = get_menus_by_days(schedule.location) - header = f'<b>{LOCATION_TO_HEADER[schedule.location]}</b>' - days = [day for day in days if date.today() == day.date] - if len(days): - day = days[0] - body = next( - f'<b>{day.date_raw}</b>\n<b> {menu.meal.value}</b>\n' + '\n'.join(f' • {item}' for item in menu.items) - for menu in day.menus if menu.meal == schedule.meal - ) - else: - body = '<b>Cardápio indisponível</b>' - updated_on = f'<i>Atualizado às ' + update_time.strftime('%H:%M:%S') + ' de hoje</i>' + days, location, update_datetime = get_location_days(schedule.location) + location_days = LocationDays( + days=[ + Day( + date=date, + date_raw=date_raw, + menus=[ + menu for menu in menus if menu.meal == schedule.meal + ], + ) + for date, date_raw, menus in days + if date.today() == schedule.day.date + ], + location=location, + update_datetime=update_datetime, + ) context.dispatcher.bot.send_message( schedule.user_id, - header + '\n\n' + body + '\n\n' + updated_on, - parse_mode=PARSEMODE_HTML + format_location_days(location_days), + parse_mode=PARSEMODE_HTML, + ) + logger.info( + f"User {schedule.user_id} received schedule {pformat(schedule)}" ) - logger.info(f"User {schedule.user_id} received schedule {pformat(schedule)}") + EVERY_X_MINUTES = 15 + def main() -> None: logger.info("Starting bot...") - updater = Updater(os.environ['TELEGRAM_BOT_TOKEN'], arbitrary_callback_data=True) - for command in COMMAND_TO_LOCATION.keys(): - updater.dispatcher.add_handler(CommandHandler(command, cardapio)) - updater.dispatcher.add_handler(CommandHandler('start', start)) - updater.dispatcher.add_handler(CommandHandler('agendar', agendar_reply)) - updater.dispatcher.add_handler(CallbackQueryHandler(remover_todas_notificacoes, pattern=RemoveTodasNotificacoes)) - updater.dispatcher.add_handler(CallbackQueryHandler(remover_todas_notificacoes_ok, pattern=RemoveTodasNotificacoesOk)) - updater.dispatcher.add_handler(CallbackQueryHandler(agendar_callback, pattern=Agendar)) - updater.dispatcher.add_handler(CallbackQueryHandler(agendar_antes_abertura, pattern=AgendarAntesAbertura)) - updater.dispatcher.add_handler(CallbackQueryHandler(invalid_button, pattern=InvalidCallbackData)) + updater = Updater( + os.environ["TELEGRAM_BOT_TOKEN"], arbitrary_callback_data=True + ) + updater.dispatcher.add_handler( + CommandHandler("start", answer_start_command) + ) + locations = get_locations() + for location in locations: + updater.dispatcher.add_handler( + CommandHandler( + f"cardapio_{location.command}", answer_cardapio_command + ) + ) + updater.dispatcher.add_handler( + CommandHandler("agendar", answer_schedule_command) + ) + updater.dispatcher.add_handler( + CallbackQueryHandler( + answer_ask_delete_all_notifications_callback, + pattern=AskDeleteAllNotificationsCallbackData, + ) + ) + updater.dispatcher.add_handler( + CallbackQueryHandler( + answer_delete_all_notifications, + pattern=DeleteAllNotificationsCallbackData, + ) + ) + updater.dispatcher.add_handler( + CallbackQueryHandler( + answer_schedule_callback, pattern=ScheduleCallbackData + ) + ) + updater.dispatcher.add_handler( + CallbackQueryHandler(answer_ready_callback, pattern=ReadyCallbackData) + ) + updater.dispatcher.add_handler( + CallbackQueryHandler( + answer_schedule_before_opening_callback, + pattern=ScheduleBeforeOpeningCallbackData, + ) + ) + updater.dispatcher.add_handler( + CallbackQueryHandler( + answer_invalid_button_callback, pattern=InvalidCallbackData + ) + ) time = datetime.now() - time -= timedelta(minutes=time.minute % EVERY_X_MINUTES, seconds=time.second, microseconds=time.microsecond) + time -= timedelta( + minutes=time.minute % EVERY_X_MINUTES, + seconds=time.second, + microseconds=time.microsecond, + ) time += timedelta(minutes=EVERY_X_MINUTES) - updater.dispatcher.job_queue.run_repeating(send_scheduled, timedelta(minutes=EVERY_X_MINUTES), first=time) + updater.dispatcher.job_queue.run_repeating( + send_scheduled, timedelta(minutes=EVERY_X_MINUTES), first=time + ) updater.start_polling() logger.info("Connected") updater.idle() logger.info("Done") -if __name__ == '__main__': + +if __name__ == "__main__": main() diff --git a/model.py b/model.py new file mode 100644 index 0000000..d1d5746 --- /dev/null +++ b/model.py @@ -0,0 +1,95 @@ +from dataclasses import dataclass +from datetime import datetime, time +from enum import Enum +from typing import Set + + +class WeekDay(Enum): + MONDAY = (0, "SEG", "Segunda-feira") + TUESDAY = (1, "TER", "Terça-feira") + WEDNESDAY = (2, "QUA", "Quarta-feira") + THURSDAY = (3, "QUI", "Quinta-feira") + FRIDAY = (4, "SEX", "Sexta-feira") + SATURDAY = (5, "SAB", "Sábado") + SUNDAY = (6, "DOM", "Domingo") + HOLIDAYS = (7, "FER", "Feriado") + + def __new__(cls, value, short, long): + obj = object.__new__(cls) + obj._value_ = value + obj.short = short + obj.long = long + return obj + + def __lt__(self, other): + if self.__class__ is other.__class__: + return self.value < other.value + return NotImplemented + + +WorkingWeekDay = [ + WeekDay.MONDAY, + WeekDay.TUESDAY, + WeekDay.WEDNESDAY, + WeekDay.THURSDAY, + WeekDay.FRIDAY, +] + + +class MenuItemIndicator(Enum): + VEGAN = ("🌱", "Indicado para veganos") + GLUTEN = ("🌾", "Não indicado para celíacos por conter glúten") + LACTOSE = ( + "🥛", + "Não indicado para intolerantes à lactose por conter lactose", + ) + ANIMAL = ("🥩", "Contém produtos de origem animal") + EGG = ("🥚", "Contém ovo") + HONEY = ("🍯", "Contém mel") + ALERGIC = ("⚠️", "Contém produto(s) alergênico(s)") + + def __new__(cls, emoji, description): + obj = object.__new__(cls) + obj._value_ = (emoji, description) + obj.emoji = emoji + obj.description = description + return obj + + +@dataclass(frozen=True) +class MenuItem: + name: str + indicators: Set[MenuItemIndicator] + + +@dataclass(frozen=True) +class Location: + name: str + command: str + ordering: int + url: str + + +@dataclass(frozen=True) +class LocationWeekDays: + location: Location + week_day: WeekDay + + +@dataclass(frozen=True) +class Meal: + location: Location + name: str + week_day: WeekDay + ordering: int + start_time: time + end_time: time + + +@dataclass(frozen=True) +class Schedule: + time: str # 18:00 + week_day: WeekDay + meal: Meal + user_id: int # Telegram user id + created_at: datetime diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1e75c2f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,5 @@ +[tool.black] +line-length = 79 + +[tool.isort] +profile = "black" diff --git a/seed.py b/seed.py new file mode 100644 index 0000000..732445b --- /dev/null +++ b/seed.py @@ -0,0 +1,130 @@ +# Este arquivo tem configurações iniciais. +# Os horários de funcionamento podem sofrer alterações. +# Atualizado em 10 de fevereiro de 2022. + +from model import WeekDay, WorkingWeekDay + + +def get_seed_locations(): + return [ + ( + "Centro Politécnico", + "poli", + 1, + "https://pra.ufpr.br/ru/ru-centro-politecnico/", + ), + ("Central", "central", 2, "https://pra.ufpr.br/ru/ru-central/"), + ( + "Jardim Botânico", + "botanico", + 3, + "https://pra.ufpr.br/ru/cardapio-ru-jardim-botanico/", + ), + ( + "Agrárias", + "agrarias", + 4, + "https://pra.ufpr.br/ru/cardapio-ru-agrarias/", + ), + ] + + +def get_seed_meals(): + return ( + [ + ("Central", "CAFÉ DA MANHÃ", week_day.value, 1, "06:45", "08:00") + for week_day in WorkingWeekDay + ] + + [ + ("Central", "ALMOÇO", week_day.value, 2, "11:00", "13:30") + for week_day in WorkingWeekDay + ] + + [ + ("Central", "JANTAR", week_day.value, 3, "17:30", "19:30") + for week_day in WorkingWeekDay + ] + + [ + ( + "Central", + "CAFÉ DA MANHÃ", + WeekDay.SATURDAY.value, + 1, + "08:15", + "09:15", + ) + ] + + [("Central", "ALMOÇO", WeekDay.SATURDAY.value, 2, "11:30", "13:30")] + + [("Central", "JANTAR", WeekDay.SATURDAY.value, 3, "17:45", "19:30")] + + [ + ("Central", "CAFÉ DA MANHÃ", week_day.value, 1, "08:30", "09:30") + for week_day in (WeekDay.SUNDAY, WeekDay.HOLIDAYS) + ] + + [ + ("Central", "ALMOÇO", week_day.value, 2, "11:30", "13:30") + for week_day in (WeekDay.SUNDAY, WeekDay.HOLIDAYS) + ] + + [ + ("Central", "JANTAR", week_day.value, 3, "17:45", "19:00") + for week_day in (WeekDay.SUNDAY, WeekDay.HOLIDAYS) + ] + + [ + ( + "Centro Politécnico", + "CAFÉ DA MANHÃ", + week_day.value, + 1, + "06:45", + "08:00", + ) + for week_day in WorkingWeekDay + ] + + [ + ( + "Centro Politécnico", + "ALMOÇO", + week_day.value, + 2, + "11:00", + "13:30", + ) + for week_day in WorkingWeekDay + ] + + [ + ( + "Centro Politécnico", + "JANTAR", + week_day.value, + 3, + "17:30", + "19:30", + ) + for week_day in WorkingWeekDay + ] + + [ + ("Agrárias", "CAFÉ DA MANHÃ", week_day.value, 1, "06:45", "08:00") + for week_day in WorkingWeekDay + ] + + [ + ("Agrárias", "ALMOÇO", week_day.value, 2, "11:00", "13:30") + for week_day in WorkingWeekDay + ] + + [ + ( + "Jardim Botânico", + "CAFÉ DA MANHÃ", + week_day.value, + 1, + "06:45", + "08:00", + ) + for week_day in WorkingWeekDay + ] + + [ + ("Jardim Botânico", "ALMOÇO", week_day.value, 2, "11:00", "13:30") + for week_day in WorkingWeekDay + ] + + [ + ("Jardim Botânico", "JANTAR", week_day.value, 3, "17:30", "19:30") + for week_day in WorkingWeekDay + ] + ) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..8434b4b --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[flake8] +ignore = E203, W503 -- GitLab