diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index 95e3f72..ee03f7e 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -9,6 +9,9 @@ on: pull_request: branches: [ master ] +env: + TOKEN: foo + jobs: build: diff --git a/README.md b/README.md index aa2cb5f..ca7eb8a 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,12 @@ pip install -e . y estas listo para trabajar. +`pip install freezegun` para correr los tests. + +### Python 3.12 + +`pip install setuptools` + ## Testeo Para correr el bot ejecutá (desde el virtualenv): @@ -58,9 +64,8 @@ En este momento ya se puede hablar con el bot. ¿Qué le digo? * `/su ` para reclamar permisos de admin, reemplazando `` por la contraseña que hayamos elegido en la envvar `PYCAMP_BOT_MASTER_KEY` -* `/agregar_pycamp ` para crear un pycamp en la deb +* `/empezar_pycamp ` inicia el flujo de creación de un pycamp. Lo carga en la db, pide fecha de inicio y duración. Lo deja activo. * `/activar_pycamp ` activa un pycamp -* `/empezar_pycamp` setea la fecha de inicio del pycamp activo * `/empezar_carga_proyectos` habilita la carga de los proyectos. En este punto los pycampistas pueden cargar sus proyectos, enviandole al bot el comando `/cargar_proyecto` * `/terminar_carga_proyectos` termina carga proyectos @@ -72,8 +77,22 @@ Para generar el schedule: * `/cronogramear` te va a preguntar cuantos dias queres cronogramear y cuantos slots por dia tenes y hacer el cronograma. * `/cambiar_slot` toma un nombre de proyecto y un slot; y te cambia ese proyecto a ese slot. +Para agendar los magos: + +1. Todos los candidatos tienen que haberse registrado con `/ser_magx` +2. Tiene que estar creado el schedule de presentaciones de proyectos (`/cronogramear`) + +* `/agendar_magx` Asigna un mago por hora durante todo el PyCamp. + * De 9 a 13 y de 14 a 19. + * El primer día arranca después del almuerzo (14hs). + * El último día termina al almuerzo (13hs). + ### Flujo pycampista * `/cargar_proyecto` carga un proyecto (si está habilitada la carga) * `/votar` envia opciones para votar (si está habilitada la votacion) * `/ver_cronograma` te muestra el cronograma! +* `/ser_magx` te registra como mago. +* `/ver_magx` Lista los magos registrados. +* `/evocar_magx` llama al mago de turno para pedirle ayuda. +* `/ver_agenda_magx completa` te muestra la agenda de magos del PyCamp. El parámetro `completa` es opcional, si se omite solo muestra los turnos pendientes. diff --git a/migrations/migrate_to_wizards_scheduling.py b/migrations/migrate_to_wizards_scheduling.py new file mode 100644 index 0000000..2a2f952 --- /dev/null +++ b/migrations/migrate_to_wizards_scheduling.py @@ -0,0 +1,33 @@ +# https://docs.peewee-orm.com/en/latest/peewee/playhouse.html#schema-migrations + +from datetime import datetime, timedelta +from playhouse.migrate import * +import peewee as pw + +from pycamp_bot.models import Pycampista, Slot, Pycamp + + +my_db = pw.SqliteDatabase('pycamp_projects.db') +migrator = SqliteMigrator(my_db) + +from pycamp_bot.models import Pycamp + + +migrate( + migrator.add_column( # wizard_slot_duration = pw.IntegerField(default=60, null=False) + Pycamp._meta.table_name, + 'wizard_slot_duration', + Pycamp.wizard_slot_duration + ), + migrator.add_column( # current_wizard = pw.ForeignKeyField(Pycampista) + Slot._meta.table_name, + 'current_wizard_id', + Slot.current_wizard + ), +) + +p = Pycamp.get() +p.end = datetime(2024,6,23,23,59,59,99) +p.end = datetime(2024,6,23,23,59,59,999999) +p.save() + diff --git a/setup.py b/setup.py index bd43de9..b30faf0 100644 --- a/setup.py +++ b/setup.py @@ -15,6 +15,8 @@ 'munch==2.5.0', 'python-telegram-bot==20.2', 'peewee==3.16.0', + 'pytest==8.2.2', + 'freezegun==1.5.1', ], test_suite='tests' ) diff --git a/src/pycamp_bot/commands/announcements.py b/src/pycamp_bot/commands/announcements.py index 24d5780..14b859a 100644 --- a/src/pycamp_bot/commands/announcements.py +++ b/src/pycamp_bot/commands/announcements.py @@ -4,6 +4,7 @@ from pycamp_bot.commands.auth import get_admins_username from pycamp_bot.logger import logger from pycamp_bot.commands.manage_pycamp import active_needed +from pycamp_bot.utils import escape_markdown PROYECTO, LUGAR, MENSAJE = ["proyecto", "lugar", "mensaje"] @@ -78,8 +79,8 @@ async def announce(update: Update, context: CallbackContext) -> str: if len(_projects) == 0: await context.bot.send_message( chat_id=update.message.chat_id, - text=f"No existe el proyecto: *{state.p_name}*.", - parse_mode='Markdown' + text=f"No existe el proyecto: *{escape_markdown(state.p_name)}*.", + parse_mode='MarkdownV2' ) return ConversationHandler.END elif not await should_be_able_to_announce(state.username, _projects[0]): @@ -92,8 +93,8 @@ async def announce(update: Update, context: CallbackContext) -> str: else: await context.bot.send_message( chat_id=update.message.chat_id, - text=f"Anunciando el proyecto: *{_projects[0].name.capitalize()}* !!!", - parse_mode='Markdown' + text=f"Anunciando el proyecto: *{escape_markdown(_projects[0].name).capitalize()}* !!!", + parse_mode='MarkdownV2' ) state.owner = _projects[0].owner.username state.current_project = _projects[0] @@ -184,20 +185,20 @@ async def message_project(update: Update, context: CallbackContext) -> str: try: await context.bot.send_message( chat_id=chat_id, - text=f'''Está por empezar el proyecto *"{(state.p_name).capitalize()}"* a cargo de *@{state.owner}*.\n*¿Dónde?* 👉🏼 {state.lugar}''', - parse_mode='Markdown' + text=f'''Está por empezar el proyecto *"{escape_markdown(state.p_name).capitalize()}"* a cargo de *@{escape_markdown(state.owner)}*.\n*¿Dónde?* 👉🏼 {escape_markdown(state.lugar)}''', + parse_mode='MarkdownV2' ) if update.message.from_user.username == state.owner: await context.bot.send_message( chat_id=chat_id, - text=f'*Project Owner says:* **{state.mensaje}**', - parse_mode='Markdown' + text=f'*Project Owner says:* **{escape_markdown(state.mensaje)}**', + parse_mode='MarkdownV2' ) else: await context.bot.send_message( chat_id=chat_id, - text=f'Admin *@{update.message.from_user.username}* says: **{state.mensaje}**', - parse_mode='Markdown' + text=f'Admin *@{escape_markdown(update.message.from_user.username)}* says: **{escape_markdown(state.mensaje)}**', + parse_mode='MarkdownV2' ) except Exception as e: logger.error(f"Error al enviar el mensaje: {e}") diff --git a/src/pycamp_bot/commands/auth.py b/src/pycamp_bot/commands/auth.py index 9089d68..f652321 100644 --- a/src/pycamp_bot/commands/auth.py +++ b/src/pycamp_bot/commands/auth.py @@ -28,7 +28,6 @@ def is_admin(update, context): def admin_needed(f): async def wrap(*args, **kargs): - logger.info('Admin nedeed wrapper') update, context = args if is_admin(*args): return await f(*args) diff --git a/src/pycamp_bot/commands/help_msg.py b/src/pycamp_bot/commands/help_msg.py index 2c1e2f8..7c388f5 100644 --- a/src/pycamp_bot/commands/help_msg.py +++ b/src/pycamp_bot/commands/help_msg.py @@ -7,6 +7,7 @@ /pycamps: lista todos los pycamps. /cargar_proyecto: empieza la conversacion de carga de proyecto. /proyectos: te muestra la informacion de todos los proyectos y sus responsables. +/mis_proyectos: te muestra día y horario de los proyectos que votaste. /ser_magx: te agrega la lista de Magx. /evocar_magx: pingea a la/el Magx de turno, informando que necesitas su\ ayuda. Con un gran poder, viene una gran responsabilidad. @@ -19,26 +20,26 @@ /ayuda: esta ayuda.''' HELP_MESSAGE = ''' -Este bot facilita la carga, administración y procesamiento de\ +Este bot facilita la carga, administración y procesamiento de \ proyectos y votos durante el PyCamp El proceso se divide en 3 etapas: -*Primera etapa*: Lxs responsables de los proyectos cargan sus proyectos\ -mediante el comando **/cargar_proyecto**. Solo un responsable carga el\ -proyecto, y luego si hay otrxs responsables adicionales, pueden\ +*Primera etapa*: Lxs responsables de los proyectos cargan sus proyectos \ +mediante el comando **/cargar_proyecto**. Solo un responsable carga el \ +proyecto, y luego si hay otrxs responsables adicionales, pueden \ agregarse con el comando /ownear. -*Segunda etapa*: Mediante el comando **/elegir_proyectos** todxs lxs participantes\ -seleccionan los proyectos que se expongan. Esto se puede hacer a medida que\ -se expone, o al haber finalizado todas las exposiciones. Si no se está\ -segurx de un proyecto, conviene no seleccionar nada, ya que luego podés\ -volver a ejecutar el comando y darle que si aquellas cosas que no tocaste. NO\ +*Segunda etapa*: Mediante el comando **/elegir_proyectos** todxs lxs participantes \ +seleccionan los proyectos que se expongan. Esto se puede hacer a medida que \ +se expone, o al haber finalizado todas las exposiciones. Si no se está \ +segurx de un proyecto, conviene no seleccionar nada, ya que luego podés \ +volver a ejecutar el comando y darle que si aquellas cosas que no tocaste. NO \ SE PUEDE CAMBIAR TU RESPUESTA UNA VEZ HECHO. -*Tercera etapa*: Lxs admins mergean los proyectos que se haya decidido\ -mergear durante las exposiciones (Por tematica similar, u otros\ -motivos), y luego se procesan los datos para obtener el cronograma\ +*Tercera etapa*: Lxs admins mergean los proyectos que se haya decidido \ +mergear durante las exposiciones (Por tematica similar, u otros \ +motivos), y luego se procesan los datos para obtener el cronograma \ final. ''' + user_commands_help @@ -49,15 +50,15 @@ Pycamp ------ /agregar_pycamp (pycamp): Agrega un pycamp. -/activar_pycamp (pycamp): Setea un pycamp como activo (si ya hay uno activo lo\ +/activar_pycamp (pycamp): Setea un pycamp como activo (si ya hay uno activo lo \ desactiva). /empezar_carga_proyectos: Habilita la carga de proyectos en el pycamp activo. /terminar_carga_proyectos: Deshabilita la carga de proyectos en el pycamp activo. /empezar_seleccion_proyectos: Habilita la seleccion sobre los proyectos del pycamp activo. /terminar_seleccion_proyectos: Deshabilita la seleccion sobre los proyectos del pycamp activo. -/empezar_pycamp: Setea el tiempo de inicio del pycamp activo.\ +/empezar_pycamp: Setea el tiempo de inicio del pycamp activo. \ Por default usa datetime.now() -/terminar_pycamp: Setea el timepo de fin del pycamp activo.\ +/terminar_pycamp: Setea el timepo de fin del pycamp activo. \ Por default usa datetime.now() /cronogramear: Te pregunta cuantos dias y que slot tiene tu pycamp \ y genera el cronograma. diff --git a/src/pycamp_bot/commands/manage_pycamp.py b/src/pycamp_bot/commands/manage_pycamp.py index 00d5b27..5207acf 100644 --- a/src/pycamp_bot/commands/manage_pycamp.py +++ b/src/pycamp_bot/commands/manage_pycamp.py @@ -1,10 +1,16 @@ import datetime -from telegram.ext import CommandHandler +from telegram.ext import ConversationHandler, CommandHandler, MessageHandler, filters from pycamp_bot.models import Pycamp from pycamp_bot.models import Pycampista from pycamp_bot.models import PycampistaAtPycamp from pycamp_bot.commands.auth import admin_needed from pycamp_bot.logger import logger +from pycamp_bot.utils import escape_markdown + + +SET_DATE_STATE = "set_fate" +SET_DURATION_STATE = "set_duration" +WRAP_UP_STATE = "wrap_up" def get_pycamp_by_name(name): @@ -68,36 +74,101 @@ async def set_active_pycamp(update, context): @admin_needed async def add_pycamp(update, context): - parameters = update.message.text.split(' ') - if not len(parameters) == 2: + parameters = update.message.text.split(' ', 1) + if len(parameters) < 2: await context.bot.send_message( chat_id=update.message.chat_id, - text="El comando necesita un parametro (pycamp name)") + text="El comando necesita un parametro (headquarters)") + return + hq = parameters[1].strip() + if not hq: + await context.bot.send_message( + chat_id=update.message.chat_id, + text="El parámetro headquarters no puede ser vacío") return - pycamp = Pycamp.get_or_create(headquarters=parameters[1])[0] + pycamp = Pycamp.get_or_create(headquarters=hq, active=True)[0] + pycamp.set_as_only_active() + logger.info('Creado: {}'.format(pycamp)) + msg = "El Pycamp {} fue creado.\n¿Cuándo empieza? (formato yyyy-mm-dd)" await context.bot.send_message( chat_id=update.message.chat_id, - text="El Pycamp {} fue creado.".format(pycamp.headquarters)) + text=msg.format(pycamp.headquarters) + ) + return SET_DATE_STATE -@active_needed -@admin_needed -async def start_pycamp(update, context): - parameters = update.message.text.split(' ') - if len(parameters) == 2: - date = datetime.datetime.fromisoformat(parameters[1]) - else: - date = datetime.datetime.now() +async def define_start_date(update, context): + text = update.message.text + try: + start_date = datetime.datetime.fromisoformat(text) + except ValueError: + await context.bot.send_message( + chat_id=update.message.chat_id, + text="mmm no entiendo esa fecha\. El formato esperado es `yyyy-mm-dd`\. ¿De nuevo?", + parse_mode="MarkdownV2" + ) + return SET_DATE_STATE + + _, pycamp = get_active_pycamp() + pycamp.init = start_date + pycamp.save() - is_active, pycamp = get_active_pycamp() - pycamp.init = date + await context.bot.send_message( + chat_id=update.message.chat_id, + text="¿Cuantos días dura el PyCamp?" + ) + return SET_DURATION_STATE + + +async def define_duration(update, context): + text = update.message.text.strip() + try: + duration = int(text) + except ValueError: + await context.bot.send_message( + chat_id=update.message.chat_id, + text="mmm no entiendo. Poné un número entero porfa." + ) + return SET_DURATION_STATE + + _, pycamp = get_active_pycamp() + pycamp.end = pycamp.init + datetime.timedelta( + days=duration - 1, + hours=23, + minutes=59, + seconds=59, + milliseconds=99 + ) pycamp.save() + msg = "Listo, el PyCamp '{}' está activo, desde el {} hasta el {}".format( + pycamp.headquarters, + pycamp.init.date(), + pycamp.end.date() + ) + await context.bot.send_message( + chat_id=update.message.chat_id, + text=msg + ) + + +async def cancel(update, context): await context.bot.send_message( chat_id=update.message.chat_id, - text="Empezó Pycamp :) ! {}".format(date)) + text="Se canceló la carga del PyCamp...") + return ConversationHandler.END + + +load_start_pycamp = ConversationHandler( + entry_points=[CommandHandler('empezar_pycamp', add_pycamp)], + states={ + SET_DATE_STATE: [MessageHandler(filters.TEXT, define_start_date)], + SET_DURATION_STATE: [MessageHandler(filters.TEXT, define_duration)] + }, + fallbacks=[CommandHandler('cancel', cancel)] +) @active_needed @@ -110,6 +181,7 @@ async def end_pycamp(update, context): date = datetime.datetime.now() is_active, pycamp = get_active_pycamp() + pycamp.active = False pycamp.end = date pycamp.save() @@ -162,19 +234,14 @@ async def list_pycampistas(update, context): def set_handlers(application): - application.add_handler( - CommandHandler('empezar_pycamp', start_pycamp)) + application.add_handler(load_start_pycamp) application.add_handler( CommandHandler('terminar_pycamp', end_pycamp)) application.add_handler( CommandHandler('activar_pycamp', set_active_pycamp)) - application.add_handler( - CommandHandler('agregar_pycamp', add_pycamp)) application.add_handler( CommandHandler('pycamps', list_pycamps)) application.add_handler( CommandHandler('voy_al_pycamp', add_pycampista_to_pycamp)) application.add_handler( CommandHandler('pycampistas', list_pycampistas)) - - diff --git a/src/pycamp_bot/commands/projects.py b/src/pycamp_bot/commands/projects.py index 8cd5b43..e5a5e13 100644 --- a/src/pycamp_bot/commands/projects.py +++ b/src/pycamp_bot/commands/projects.py @@ -1,10 +1,12 @@ import logging import peewee from telegram.ext import ConversationHandler, CommandHandler, MessageHandler, filters -from pycamp_bot.models import Pycampista, Project, Vote +from pycamp_bot.models import Pycampista, Project, Slot, Vote from pycamp_bot.commands.base import msg_to_active_pycamp_chat from pycamp_bot.commands.manage_pycamp import active_needed, get_active_pycamp from pycamp_bot.commands.auth import admin_needed, get_admins_username +from pycamp_bot.commands.schedule import DIAS +from pycamp_bot.utils import escape_markdown current_projects = {} @@ -254,6 +256,7 @@ async def show_projects(update, context): await update.message.reply_text(text) + async def show_participants(update, context): """Show participants for a project""" @@ -285,6 +288,52 @@ async def show_participants(update, context): await update.message.reply_text(response) +======= +async def show_my_projects(update, context): + """Let people see what projects they have voted for""" + + user = Pycampista.get( + Pycampista.username == update.message.from_user.username, + ) + votes = ( + Vote + .select(Project, Slot) + .join(Project) + .join(Slot) + .where( + (Vote.pycampista == user) & + Vote.interest + ) + .order_by(Slot.code) + ) + + if votes: + text_chunks = [] + + prev_slot_day_code = None + + for vote in votes: + slot_day_code = vote.project.slot.code[0] + slot_day_name = DIAS[slot_day_code] + + if slot_day_code != prev_slot_day_code: + text_chunks.append(f'*{slot_day_name}*') + + project_lines = [ + f'{vote.project.slot.start}:00', + escape_markdown(vote.project.name), + f'Owner: @{escape_markdown(vote.project.owner.username)}', + ] + + text_chunks.append('\n'.join(project_lines)) + + prev_slot_day_code = slot_day_code + + text = '\n\n'.join(text_chunks) + else: + text = "No votaste por ningún proyecto" + + await update.message.reply_text(text, parse_mode='MarkdownV2') def set_handlers(application): application.add_handler(load_project_handler) @@ -297,4 +346,7 @@ def set_handlers(application): application.add_handler( CommandHandler('proyectos', show_projects)) application.add_handler( - CommandHandler('participantes', show_participants)) + CommandHandler('participantes', show_participants)) + application.add_handler( + CommandHandler('mis_proyectos', show_my_projects)) + diff --git a/src/pycamp_bot/commands/schedule.py b/src/pycamp_bot/commands/schedule.py index 2927fe9..d4b163f 100644 --- a/src/pycamp_bot/commands/schedule.py +++ b/src/pycamp_bot/commands/schedule.py @@ -4,6 +4,7 @@ from pycamp_bot.commands.auth import admin_needed from pycamp_bot.scheduler.db_to_json import export_db_2_json from pycamp_bot.scheduler.schedule_calculator import export_scheduled_result +from pycamp_bot.utils import escape_markdown DAY_SLOT_TIME = { @@ -164,7 +165,7 @@ async def check_day_tab(day, slots, cronograma, i): async def show_schedule(update, context): slots = Slot.select() projects = Project.select() - cronograma = "*Crónograma:* \n" + cronograma = "*Cronograma:* \n" for i, slot in enumerate(slots): day = DIAS[slot.code[0]] @@ -172,13 +173,13 @@ async def show_schedule(update, context): for project in projects: if project.slot_id == slot.id: - cronograma += f'*-* {slot.start}:00hs = *{(project.name).capitalize()}.*\n' - cronograma += f'A cargo de 👉🏼 {"@" + project.owner.username}\n' + cronograma += f'*-* {slot.start}:00hs = *{escape_markdown(project.name).capitalize()}.*\n' + cronograma += f'A cargo de 👉🏼 {"@" + escape_markdown(project.owner.username)}\n' await context.bot.send_message( chat_id=update.message.chat_id, text=cronograma, - parse_mode='Markdown' + parse_mode='MarkdownV2' ) diff --git a/src/pycamp_bot/commands/wizard.py b/src/pycamp_bot/commands/wizard.py index 46d0364..b136cdc 100644 --- a/src/pycamp_bot/commands/wizard.py +++ b/src/pycamp_bot/commands/wizard.py @@ -1,6 +1,105 @@ -from telegram.ext import CommandHandler -from pycamp_bot.models import Pycampista import random +from collections import defaultdict +from datetime import datetime, timedelta +from itertools import cycle +from telegram.ext import CommandHandler +from telegram.error import BadRequest +from pycamp_bot.models import Pycampista, WizardAtPycamp +from pycamp_bot.commands.auth import admin_needed +from pycamp_bot.commands.manage_pycamp import get_active_pycamp +from pycamp_bot.logger import logger +from pycamp_bot.utils import escape_markdown + + +LUNCH_TIME_START_HOUR = 13 +LUNCH_TIME_END_HOUR = 14 +WIZARD_TIME_START_HOUR = 9 +WIZARD_TIME_END_HOUR = 20 + +MSG_MAX_LEN = 4096 + + +def is_wizard_time_slot(slot): + return slot[0].hour in range(WIZARD_TIME_START_HOUR, WIZARD_TIME_END_HOUR) + + +def is_lunch_time_slot(slot): + return slot[0].hour in range(LUNCH_TIME_START_HOUR, LUNCH_TIME_END_HOUR) + + +def is_after_first_lunch_slot(pycamp, slot): + return slot[0].day != pycamp.init.day or slot[0].hour >= LUNCH_TIME_END_HOUR + + +def is_before_last_lunch_slot(pycamp, slot): + """Must be False if slot starts after lunch the last day""" + return slot[0].day != pycamp.end.day or slot[0].hour < LUNCH_TIME_START_HOUR + + +def is_valid_wizard_slot(pycamp, slot): + """If True the slot is kept.""" + return ( + is_wizard_time_slot(slot) + and not is_lunch_time_slot(slot) + and is_after_first_lunch_slot(pycamp, slot) + and is_before_last_lunch_slot(pycamp, slot) + ) + + +def clean_wizards_free_slots(pycamp, slots): + return [slot for slot in slots if is_valid_wizard_slot(pycamp, slot)] + + +def compute_wizards_slots(pycamp): + """ + * Magxs trabajan de 9 a 19, sacando almuerzo (13 a 14). + * Magxs trabajan desde el mediodía del primer día, hasta el mediodía del último día. + Slots son [start; end) + """ + wizard_start = pycamp.init + wizard_end = pycamp.end + slots = [] + current_period = wizard_start + while current_period < wizard_end: # TODO: check fields None + slot_start = current_period + slot_end = current_period + timedelta(minutes=pycamp.wizard_slot_duration) + slots.append( + (slot_start, slot_end) + ) + current_period = slot_end + + slots = clean_wizards_free_slots(pycamp, slots) + + return slots + + +def define_wizards_schedule(pycamp): + """ + Returns a dict whose keys are times and values are wizards (Pycampistas instances). + + """ + all_wizards = pycamp.get_wizards() + if all_wizards.count() == 0: + return {} + + wizard_per_slot = {} + wizards_iter = cycle(all_wizards) + for slot in compute_wizards_slots(pycamp): + # Cycle through the wizards, asigning them to slots. + wizard = next(wizards_iter) + if wizard.is_busy(*slot): + # If the target wizard is busy in this time slot, try to find another available wizard + if all(w.is_busy(*slot) for w in all_wizards): + # Nada que hacer, todos ocupados. Queda + logger.warning( + 'Queda el magx {} con conflicto en el slot {}'.format(wizard.username, slot) + ) + else: + # Sigo hasta el próximo que esté disponible + continue + wizard_per_slot[slot] = wizard + + return wizard_per_slot async def become_wizard(update, context): @@ -19,31 +118,204 @@ async def become_wizard(update, context): await context.bot.send_message( chat_id=update.message.chat_id, - text="Felicidades! Eres el Magx de turno" + text="¡Felicidades! Has sido registrado como magx." ) +async def list_wizards(update, context): + _, pycamp = get_active_pycamp() + msg = "" + for i, wizard in enumerate(pycamp.get_wizards()): + msg += "{}) @{}\n".format(i+1, wizard.username) + try: + await context.bot.send_message( + chat_id=update.message.chat_id, + text=msg + ) + except BadRequest as e: + logger.exception("Coulnd't deliver the Wizards list to {}".format(update.message.from_user.username)) + + async def summon_wizard(update, context): - username = update.message.from_user.username - wizard_list = list(Pycampista.select().where(Pycampista.wizard==True)) - if len(wizard_list) == 0: + _, pycamp = get_active_pycamp() + wizard = pycamp.get_current_wizard() + if wizard is None: await context.bot.send_message( chat_id=update.message.chat_id, - text="No hay ningunx magx todavia" + text="No hay ningunx magx agendado a esta hora :-(" + ) + return + + username = update.message.from_user.username + if username == wizard.username: + await context.bot.send_message( + chat_id=wizard.chat_id, + text="🧙" + ) + await context.bot.send_message( + chat_id=wizard.chat_id, + text="Checkeá tu cabeza: si no ténes el sombrero de magx ¡deberías!\n(soltá la compu)" ) else: - wizard = random.choice(wizard_list) await context.bot.send_message( chat_id=wizard.chat_id, - text="PING PING PING MAGX! @{} te necesesita!".format(username) + text="PING PING PING MAGX! @{} te necesita!".format(username) ) await context.bot.send_message( chat_id=update.message.chat_id, text="Tu magx asignadx es: @{}".format(wizard.username) ) +async def notify_scheduled_slots_to_wizard(update, context, pycamp, wizard, agenda): + per_day = defaultdict(list) + for entry in agenda: + k = entry.init.strftime("%a %d de %b") + per_day[k].append(entry) + + msg = "Esta es tu agenda de magx para el PyCamp {}".format(pycamp.headquarters) + for day, items in per_day.items(): + msg += "\nEl día _{}_:\n".format(day) + for i in items: + msg += "\t \\- {} a {}\n".format( + i.init.strftime("%H:%M"), + i.end.strftime("%H:%M"), + ) + + await context.bot.send_message( + chat_id=wizard.chat_id, + text=msg, + parse_mode="MarkdownV2" + ) + + +async def notify_schedule_to_wizards(update, context, pycamp): + for wizard in pycamp.get_wizards(): + wizard_agenda = WizardAtPycamp.select().where( + (WizardAtPycamp.pycamp == pycamp) & (WizardAtPycamp.wizard == wizard) + ).order_by(WizardAtPycamp.init) + + try: + await notify_scheduled_slots_to_wizard(update, context, pycamp, wizard, wizard_agenda) + logger.debug("Notified wizard schedule to {}".format(wizard.username)) + except BadRequest: + logger.warn("Coulnd't notify its wizzard schedule to {}".format(wizard.username)) + + +def persist_wizards_schedule_in_db(pycamp): + """ + Aux function to generate the wizards schedule and persist WizardAtPycamp instances in the DB. + + """ + schedule = define_wizards_schedule(pycamp) + + for slot, wizard in schedule.items(): + start, end = slot + WizardAtPycamp.create( + pycamp=pycamp, + wizard=wizard, + init=start, + end=end + ) + + +@admin_needed +async def schedule_wizards(update, context): + _, pycamp = get_active_pycamp() + + n = pycamp.clear_wizards_schedule() + logger.info("Deleted wizards schedule ({} records)".format(n)) + + persist_wizards_schedule_in_db(pycamp) + logger.info("Wizards schedule persisted in the DB.") + + + await notify_schedule_to_wizards(update, context, pycamp) + + agenda = WizardAtPycamp.select().where(WizardAtPycamp.pycamp == pycamp) + + msg = format_wizards_schedule(agenda) + try: + await context.bot.send_message( + chat_id=update.message.chat_id, + text=msg, + parse_mode="MarkdownV2" + ) + except BadRequest as e: + m = "Coulnd't return the Wizards list to the admin. ".format(update.message.from_user.username) + if len(msg) >= MSG_MAX_LEN: + m += "The message is too long. Check the data in the DB ;-)" + logger.exception(m) + + +def format_wizards_schedule(agenda): + """Aux function to render the wizards schedule as a friendly message.""" + per_day = defaultdict(list) + for entry in agenda: + k = entry.init.strftime("%a %d de %b") + per_day[k].append(entry) + + msg = "Agenda de magxs:" + for day, items in per_day.items(): + msg += "\nEl día _{}_:\n".format(day) + for i in items: + msg += "\t \\- {} a {}:\t*{}* \n".format( + i.init.strftime("%H:%M"), + i.end.strftime("%H:%M"), + "@" + escape_markdown(i.wizard.username) + ) + return msg + +def aux_resolve_show_all(message): + show_all = False + parameters = message.text.strip().split(' ', 1) + if len(parameters) == 2: + flag = parameters[1].strip().lower() + show_all = (flag == "completa") # Once here, the only parameter must be valid + if not show_all: + # The parameter was something else... + raise ValueError("Wrong parameter") + elif len(parameters) > 2: + # Too many parameters... + raise ValueError("Wrong parameter") + return show_all + + +async def show_wizards_schedule(update, context): + try: + show_all = aux_resolve_show_all(update.message) + except ValueError: + await context.bot.send_message( + chat_id=update.message.chat_id, + text="El comando solo acepta un parámetro (opcional): 'completa'. ¿Probás de nuevo?", + ) + return + + _, pycamp = get_active_pycamp() + + agenda = WizardAtPycamp.select().where(WizardAtPycamp.pycamp == pycamp) + if not show_all: + agenda = agenda.where(WizardAtPycamp.end > datetime.now()) + agenda = agenda.order_by(WizardAtPycamp.init) + + msg = format_wizards_schedule(agenda) + + await context.bot.send_message( + chat_id=update.message.chat_id, + text=msg, + parse_mode="MarkdownV2" + ) + logger.debug("Wizards schedule delivered to {}".format(update.message.from_user.username)) + + + def set_handlers(application): application.add_handler( CommandHandler('evocar_magx', summon_wizard)) application.add_handler( CommandHandler('ser_magx', become_wizard)) + application.add_handler( + CommandHandler('ver_magx', list_wizards)) + application.add_handler( + CommandHandler('agendar_magx', schedule_wizards)) + application.add_handler( + CommandHandler('ver_agenda_magx', show_wizards_schedule)) diff --git a/src/pycamp_bot/models.py b/src/pycamp_bot/models.py index c65ebbb..b5e2bb2 100644 --- a/src/pycamp_bot/models.py +++ b/src/pycamp_bot/models.py @@ -1,11 +1,15 @@ import peewee as pw +from datetime import datetime, timedelta +from random import choice + + +DEFAULT_SLOT_PERIOD = 60 # Minutos db = pw.SqliteDatabase('pycamp_projects.db') class BaseModel(pw.Model): - class Meta: database = db @@ -36,6 +40,17 @@ def __str__(self): rv_str += 'Admin' if self.admin else 'Commoner' return rv_str + def is_busy(self, from_time, to_time): + """`from_time, to_time` are two datetime objects.""" + project_presentation_slots = Slot.select().where(Slot.current_wizard == self) + for slot in project_presentation_slots: + # https://stackoverflow.com/a/13403827/1161156 + latest_start = max(from_time, slot.start) + earliest_end = min(to_time, slot.get_end_time()) + if latest_start <= earliest_end: # Overlap + return True + return False + class Pycamp(BaseModel): ''' @@ -45,6 +60,8 @@ class Pycamp(BaseModel): end: time of end vote_authorized: the vote is auth in this pycamp project_load_authorized: the project load is auth in this pycamp + active: boolean telling wheter this PyCamp instance is active (or an old one) + wizard_slot_duration: config to compute the schedule of mages ''' headquarters = pw.CharField(unique=True) init = pw.DateTimeField(null=True) @@ -52,6 +69,7 @@ class Pycamp(BaseModel): vote_authorized = pw.BooleanField(default=False, null=True) project_load_authorized = pw.BooleanField(default=False, null=True) active = pw.BooleanField(default=False, null=True) + wizard_slot_duration = pw.IntegerField(default=60, null=False) # In minutes def __str__(self): rv_str = 'Pycamp:\n' @@ -60,6 +78,36 @@ def __str__(self): rv_str += f'{attr}: {getattr(self, attr)}\n' return rv_str + def set_as_only_active(self): + active = Pycamp.select().where(Pycamp.active) + for p in active: + p.active = False + Pycamp.bulk_update(active, fields=[Pycamp.active]) + self.active = True + self.save() + + def get_wizards(self): + return Pycampista.select().where(Pycampista.wizard == 1) + + def get_current_wizard(self): + """Return the Pycampista instance that's the currently scheduled wizard.""" + now = datetime.now() + current_wizards = WizardAtPycamp.select().where( + (WizardAtPycamp.pycamp == self) & + (WizardAtPycamp.init <= now) & + (WizardAtPycamp.end > now) + ) + + wizard = None # Default if n_wiz == 0 + if current_wizards.count() >= 1: + # Ready for an improbable future where we'll have many concurrent wizards ;-) + wizard = choice(current_wizards).wizard + + return wizard + + + def clear_wizards_schedule(self): + return WizardAtPycamp.delete().where(WizardAtPycamp.pycamp == self).execute() class PycampistaAtPycamp(BaseModel): ''' @@ -70,6 +118,17 @@ class PycampistaAtPycamp(BaseModel): pycampista = pw.ForeignKeyField(Pycampista) +class WizardAtPycamp(BaseModel): + ''' + Many to many relationship. Ona pycampista will attend many pycamps. A + pycamps will have many pycampistas + ''' + pycamp = pw.ForeignKeyField(Pycamp) + wizard = pw.ForeignKeyField(Pycampista) + init = pw.DateTimeField() + end = pw.DateTimeField() + + class Slot(BaseModel): ''' Time slot representation @@ -79,7 +138,10 @@ class Slot(BaseModel): ''' code = pw.CharField() # For example A1 for first slot first day start = pw.DateTimeField() - current_wizzard = pw.ForeignKeyField(Pycampista) + current_wizard = pw.ForeignKeyField(Pycampista, null=True) + + def get_end_time(self): + return self.start + timedelta(minutes=DEFAULT_SLOT_PERIOD) class Project(BaseModel): @@ -120,6 +182,8 @@ def models_db_connection(): Pycamp, Pycampista, PycampistaAtPycamp, + WizardAtPycamp, Project, Slot, - Vote]) + Vote], safe=True) + db.close() diff --git a/src/pycamp_bot/utils.py b/src/pycamp_bot/utils.py new file mode 100644 index 0000000..202845f --- /dev/null +++ b/src/pycamp_bot/utils.py @@ -0,0 +1,9 @@ +def escape_markdown(string): + # See: https://core.telegram.org/bots/api#markdownv2-style + + new_string = string + + for char in "_*[]()~`>#+-=|{}.!": + new_string = new_string.replace(char, f'\\{char}') + + return new_string diff --git a/test/__init__.py b/test/__init__.py index 3695ddc..e69de29 100644 --- a/test/__init__.py +++ b/test/__init__.py @@ -1,7 +0,0 @@ -"""Testing""" - -from .test_start import TestStart - -__all__ = [ - 'TestStart', -] diff --git a/test/conftest.py b/test/conftest.py index d8b399c..e132919 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,5 +1,27 @@ import os + +from datetime import datetime +from functools import wraps +from peewee import SqliteDatabase from telegram import Bot +from pycamp_bot.models import Pycampista, Slot, Pycamp, WizardAtPycamp + + +# use an in-memory SQLite for tests. +test_db = SqliteDatabase(':memory:') + +MODELS = [Pycampista, Slot, Pycamp, WizardAtPycamp] + -bot = Bot(token=os.environ['TOKEN']) +def use_test_database(fn): + """Bind the given models to the db for the duration of wrapped block.""" + @wraps(fn) + def inner(self): + with test_db.bind_ctx(MODELS): + test_db.create_tables(MODELS) + try: + fn(self) + finally: + test_db.drop_tables(MODELS) + return inner diff --git a/test/test_pycamp_model.py b/test/test_pycamp_model.py new file mode 100644 index 0000000..edd387c --- /dev/null +++ b/test/test_pycamp_model.py @@ -0,0 +1,76 @@ +from datetime import datetime +from freezegun import freeze_time +from pycamp_bot.models import Pycamp, Pycampista, WizardAtPycamp +from pycamp_bot.commands import wizard +from test.conftest import use_test_database, test_db, MODELS + + +# --------------------------- +# Module Level Setup/TearDown +# --------------------------- +def setup_module(module): + test_db.bind(MODELS, bind_refs=False, bind_backrefs=False) + test_db.connect() + +def teardown_module(module): + # Not strictly necessary since SQLite in-memory databases only live + # for the duration of the connection, and in the next step we close + # the connection...but a good practice all the same. + test_db.drop_tables(MODELS) + + # Close connection to db. + test_db.close() + # If we wanted, we could re-bind the models to their original + # database here. But for tests this is probably not necessary. + + +class TestPycampGetCurrentWizard: + + def init_pycamp(self): + self.pycamp = Pycamp.create( + headquarters="Narnia", + init=datetime(2024, 6, 20), + end=datetime(2024, 6, 24), + ) + + @use_test_database + @freeze_time("2024-06-21 11:30:00") + def test_returns_correct_wizard_within_its_turno(self): + """Integration test using persist_wizards_schedule_in_db.""" + p = Pycamp.create( + headquarters="Narnia", + init=datetime(2024,6,20), + end=datetime(2024,6,23), + ) + pycamper = Pycampista.create(username="pepe", wizard=True) + wizard.persist_wizards_schedule_in_db(p) + + assert p.get_current_wizard() == pycamper + + @use_test_database + def test_no_scheduled_wizard_then_return_none(self): + p = Pycamp.create( + headquarters="Narnia" + ) + # Wizard exists, but no time scheduled. + pycamper = Pycampista.create(username="pepe", wizard=True) + assert WizardAtPycamp.select().count() == 0 + + assert p.get_current_wizard() is None + + @use_test_database + @freeze_time("2024-06-20 10:30:00") + def test_many_scheduled_wizard_then_return_one_of_them(self): + p = Pycamp.create( + headquarters="Narnia" + ) + # Wizard exists, scheduled in the same time slot. + gandalf = Pycampista.create(username="gandalf", wizard=True) + merlin = Pycampista.create(username="merlin", wizard=True) + ini = datetime(2024,6,20,10,0,0) + end = datetime(2024,6,20,11,0,0) + WizardAtPycamp.create(pycamp=p, wizard=gandalf, init=ini, end=end) + WizardAtPycamp.create(pycamp=p, wizard=merlin, init=ini, end=end) + + w = p.get_current_wizard() + assert w == gandalf or w == merlin \ No newline at end of file diff --git a/test/test_pycampista.py b/test/test_pycampista.py new file mode 100644 index 0000000..1a7e899 --- /dev/null +++ b/test/test_pycampista.py @@ -0,0 +1,91 @@ +from datetime import datetime, timedelta +from pycamp_bot.models import Pycampista, Slot +from test.conftest import use_test_database, test_db, MODELS + + +# --------------------------- +# Module Level Setup/TearDown +# --------------------------- +def setup_module(module): + """setup any state specific to the execution of the given module.""" + test_db.bind(MODELS, bind_refs=False, bind_backrefs=False) + test_db.connect() + +def teardown_module(module): + """teardown any state that was previously setup with a setup_module method.""" + # Not strictly necessary since SQLite in-memory databases only live + # for the duration of the connection, and in the next step we close + # the connection...but a good practice all the same. + test_db.drop_tables(MODELS) + + # Close connection to db. + test_db.close() + # If we wanted, we could re-bind the models to their original + # database here. But for tests this is probably not necessary. + + +class TestPycampistaIsBusy: + + @use_test_database + def test_return_true_if_assigned_in_slot_equal_to_target_period(self): + pycamper = Pycampista.create(username="pepe") + slot = Slot.create(code = "A1", start=datetime.now(), current_wizard=pycamper) + period = (slot.start, slot.get_end_time()) + assert pycamper.is_busy(*period) + + @use_test_database + def test_return_true_if_assigned_in_slot_starting_at_target_period(self): + pycamper = Pycampista.create(username="pepe") + slot_start = datetime.now() + Slot.create(code = "A1", start=slot_start, current_wizard=pycamper) + period = (slot_start, slot_start + timedelta(minutes=5)) + assert pycamper.is_busy(*period) + + @use_test_database + def test_return_true_if_assigned_in_slot_around_target_period(self): + pycamper = Pycampista.create(username="pepe") + slot_start = datetime.now() + Slot.create(code = "A1", start=slot_start, current_wizard=pycamper) + period_start = slot_start + timedelta(minutes=5) + period = (period_start, period_start + timedelta(minutes=10)) + assert pycamper.is_busy(*period) + + @use_test_database + def test_return_true_if_assigned_in_slot_ending_after_target_period_starts(self): + pycamper = Pycampista.create(username="pepe") + slot = Slot.create(code = "A1", start=datetime.now(), current_wizard=pycamper) + period = ( + slot.start + timedelta(minutes=5), + slot.get_end_time() + timedelta(minutes=5), + ) + assert pycamper.is_busy(*period) + + @use_test_database + def test_return_true_if_assigned_in_slot_starting_before_target_period_ends(self): + pycamper = Pycampista.create(username="pepe") + slot = Slot.create(code = "A1", start=datetime.now(), current_wizard=pycamper) + period = ( + slot.start - timedelta(minutes=5), + slot.start + timedelta(minutes=5), + ) + assert pycamper.is_busy(*period) + + @use_test_database + def test_return_false_if_assigned_in_slot_ending_before_target_period_starts(self): + pycamper = Pycampista.create(username="pepe") + slot = Slot.create(code = "A1", start=datetime.now(), current_wizard=pycamper) + period = ( + slot.get_end_time() + timedelta(seconds=1), + slot.get_end_time() + timedelta(seconds=10), + ) + assert not pycamper.is_busy(*period) + + @use_test_database + def test_return_false_if_assigned_in_slot_start_after_target_period_ends(self): + pycamper = Pycampista.create(username="pepe") + slot = Slot.create(code = "A1", start=datetime.now(), current_wizard=pycamper) + period = ( + slot.start - timedelta(seconds=10), + slot.start - timedelta(seconds=1), + ) + assert not pycamper.is_busy(*period) diff --git a/test/test_start.py b/test/test_start.py deleted file mode 100644 index 9df76a5..0000000 --- a/test/test_start.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Test de comandos""" - -import os -import unittest -from .conftest import bot - - -class TestStart(unittest.TestCase): - """Test de distintos comandos sin lógica""" - - def test_start(self): - """Start""" - bot.send_message(os.environ["CHAT_ID"], '/start') diff --git a/test/test_wizard.py b/test/test_wizard.py new file mode 100644 index 0000000..fae1774 --- /dev/null +++ b/test/test_wizard.py @@ -0,0 +1,166 @@ +from datetime import datetime +from pycamp_bot.models import Pycamp, Pycampista, Slot +from pycamp_bot.commands import wizard +from test.conftest import use_test_database, test_db, MODELS + + +def setup_module(module): + """setup any state specific to the execution of the given module.""" + test_db.bind(MODELS, bind_refs=False, bind_backrefs=False) + test_db.connect() + +def teardown_module(module): + """teardown any state that was previously setup with a setup_module method.""" + # Not strictly necessary since SQLite in-memory databases only live + # for the duration of the connection, and in the next step we close + # the connection...but a good practice all the same. + test_db.drop_tables(MODELS) + + # Close connection to db. + test_db.close() + # If we wanted, we could re-bind the models to their original + # database here. But for tests this is probably not necessary. + + +class TestWizardScheduleSlots: + + def init_pycamp(self): + self.pycamp = Pycamp.create( + headquarters="Narnia", + init=datetime(2024,6,20), + end=datetime(2024,6,24), + ) + + @use_test_database + def test_correct_number_of_slots_in_one_day(self): + p = Pycamp.create( + headquarters="Narnia", + init=datetime(2024,6,20), + end=datetime(2024,6,20), + ) + slots = wizard.compute_wizards_slots(p) + assert len(slots) == 0 + + @use_test_database + def test_correct_number_of_slots_in_three_day(self): + p = Pycamp.create( + headquarters="Narnia", + init=datetime(2024,6,20), + end=datetime(2024,6,22,23,59,59,99), + ) + slots = wizard.compute_wizards_slots(p) + for i in slots: + print(i) + assert len(slots) == 20 + + + @use_test_database + def test_no_slot_before_first_day_lunch(self): + self.init_pycamp() + lunch_time_end = datetime( + self.pycamp.init.year, + self.pycamp.init.month, + self.pycamp.init.day, + wizard.LUNCH_TIME_END_HOUR + ) + for (start, end) in wizard.compute_wizards_slots(self.pycamp): + assert lunch_time_end <= start + + @use_test_database + def test_no_slot_after_coding_time(self): + self.init_pycamp() + for (start, _) in wizard.compute_wizards_slots(self.pycamp): + assert start.hour < wizard.WIZARD_TIME_END_HOUR + + @use_test_database + def test_no_slot_before_breakfast(self): + self.init_pycamp() + for (start, _) in wizard.compute_wizards_slots(self.pycamp): + assert start.hour >= wizard.WIZARD_TIME_START_HOUR + + + + @use_test_database + def test_no_slot_after_last_day_lunch(self): + self.init_pycamp() + lunch_time_end = datetime( + self.pycamp.end.year, + self.pycamp.end.month, + self.pycamp.end.day, + wizard.LUNCH_TIME_END_HOUR + ) + for (start, _) in wizard.compute_wizards_slots(self.pycamp): + print(start) + if start.day == self.pycamp.end.day: + assert start >= lunch_time_end + + +class TestDefineWizardsSchedule: + + def init_pycamp(self): + self.pycamp = Pycamp.create( + headquarters="Narnia", + init=datetime(2024,6,20), + end=datetime(2024,6,24), + ) + + # If no wizards, returns {} + @use_test_database + def test_no_wizards_then_return_empty_dict(self): + self.init_pycamp() + sched = wizard.define_wizards_schedule(self.pycamp) + assert sched == {} + + # all slots are asigned a wizard + @use_test_database + def test_all_slots_are_signed_a_wizard(self): + self.init_pycamp() + gandalf = Pycampista.create(username="gandalf", wizard=True) + sched = wizard.define_wizards_schedule(self.pycamp) + assert all( + (isinstance(s, Pycampista) and s.wizard) for s in sched.values() + ) + + # Wizards are not asigned to slots when they are busy + @use_test_database + def test_all_slots_are_signed_a_wizard(self): + self.init_pycamp() + gandalf = Pycampista.create(username="gandalf", wizard=True) + merlin = Pycampista.create(username="merlin", wizard=True) + for h in [9, 10, 11, 12]: + # Create 3 slots where Gandalf is busy + Slot.create( + code = "A1", + start=datetime(2024, 6, 21, h, 30, 0), + current_wizard=gandalf + ) + sched = wizard.define_wizards_schedule(self.pycamp) + # Verify Gandalf is not assigned to slots where he is busy + for (ini, end), w in sched.items(): + if gandalf.is_busy(ini, end): + print(ini, end, w.username) + assert w != gandalf + + # If all wizards are busy in a slot, then one is asigned all the same + @use_test_database + def test_all_slots_are_signed_a_wizard(self): + self.init_pycamp() + gandalf = Pycampista.create(username="gandalf", wizard=True) + merlin = Pycampista.create(username="merlin", wizard=True) + for h in [9, 10, 11, 12]: + # Create 3 slots where Gandalf AND Merlin are busy + Slot.create( + code = "A1", + start=datetime(2024, 6, 21, h, 30, 0), + current_wizard=gandalf + ) + Slot.create( + code = "A1", + start=datetime(2024, 6, 21, h, 30, 0), + current_wizard=merlin + ) + sched = wizard.define_wizards_schedule(self.pycamp) + + assert all( + (isinstance(s, Pycampista) and s.wizard) for s in sched.values() + )