From 3539dfe1a5b557d0529b580f04a0c6364b8e3e07 Mon Sep 17 00:00:00 2001 From: Federico Barrios Date: Mon, 16 Oct 2017 17:57:23 -0300 Subject: [PATCH 1/2] Obtiene las entregas de un grupo para un dado TP. --- backoff.py | 65 ++++++++++++++++++++++++++++++++++++++++++++++++ config-sample.py | 2 ++ main.py | 20 ++++++++++----- requirements.txt | 4 +++ 4 files changed, 85 insertions(+), 6 deletions(-) create mode 100644 backoff.py diff --git a/backoff.py b/backoff.py new file mode 100644 index 0000000..483f97e --- /dev/null +++ b/backoff.py @@ -0,0 +1,65 @@ +import os.path +from datetime import datetime + + +def get_id_cursada(): + """Devuelve el identificador de la cursada según año y cuatrimestre. + + El identificador es del tipo ‘2015_2’ o ‘2016_1’, donde el segundo elemento + indica el cuatrimestre. + + El cuatrimestre es: + + - 1 si la fecha es antes del 1 de agosto; + - 2 si es igual o posterior. + """ + today = datetime.today() + cutoff = today.replace(month=8, day=1) + return "{}_{}".format(today.year, 1 if today < cutoff else 2) + + +def get_id_estudiante(planilla, padron_o_grupo): + """ + Los grupos se registran como Gxx en la planilla, pero en el repositorio se + guardan como padrón1_padrón2 (con padrón1 < padrón2). + Esta función devuelve el indentificador correspondiente de acuerdo al grupo + o padrón. + """ + # Si es un grupo formado por dos personas. + if padron_o_grupo in planilla.grupos and len(planilla.grupos[padron_o_grupo]) == 2: + padrones = planilla.grupos[padron_o_grupo] + return "{}_{}".format(min(padrones), max(padrones)) + + # Si es un grupo formado por una persona. + if padron_o_grupo in planilla.grupos and len(planilla.grupos[padron_o_grupo]) == 1: + return planilla.grupos[padron_o_grupo][0] + + # En otro caso es un padrón. + return padron_o_grupo + + +def obtener_entregas(repo, tp, id_cursada, id_grupo): + """ + Devuelve una lista con los + """ + rel_dir = os.path.join(tp, id_cursada, id_grupo) + + # Si el path no existe, entonces no hubo entregas hasta ahora. + if not os.path.exists(os.path.join(repo.working_dir, rel_dir)): + return [] + + log = repo.git.log("--format=%at", "--date=iso", rel_dir) + return [datetime.fromtimestamp(int(date)) for date in log.split("\n")] + + +def validar_backoff(repo, planilla, tp, padron_o_grupo): + """ + Levanta una excepción de tipo BackoffException cuando un alumno o grupo + hacen demasiados intentos en muy poco tiempo para realizar una entrega. + """ + tp = tp.lower() + id_cursada = get_id_cursada() + id_grupo = get_id_estudiante(planilla, padron_o_grupo) + + entregas = obtener_entregas(repo, tp, id_cursada, id_grupo) + # Usar "entregas" para implementar el backoff. diff --git a/config-sample.py b/config-sample.py index 0ab0aab..812fd97 100644 --- a/config-sample.py +++ b/config-sample.py @@ -10,6 +10,8 @@ EMAIL_TO = 'tps.7540rw@gmail.com' APP_TITLE = 'Algoritmos y Programación 1 - Entrega de TPs' +REPO_ENTREGAS = "" + SERVICE_ACCOUNT_CREDENTIALS = { "type": "service_account", "project_id": "...", diff --git a/main.py b/main.py index b8aa455..0ec5a99 100644 --- a/main.py +++ b/main.py @@ -5,11 +5,13 @@ import mimetypes import smtplib import traceback +from backoff import validar_backoff from collections import namedtuple from email import encoders from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText +from git import Repo from urllib.parse import urlencode import httplib2 @@ -20,12 +22,14 @@ from flask import request from werkzeug.utils import secure_filename -from config import SENDER_NAME, EMAIL_TO, APP_TITLE, RECAPTCHA_SECRET, RECAPTCHA_SITE_ID, TEST, CLIENT_ID, \ - CLIENT_SECRET, OAUTH_REFRESH_TOKEN, GRUPAL, INDIVIDUAL +from config import SENDER_NAME, EMAIL_TO, APP_TITLE, RECAPTCHA_SECRET,\ + RECAPTCHA_SITE_ID, TEST, CLIENT_ID, CLIENT_SECRET, OAUTH_REFRESH_TOKEN,\ + REPO_ENTREGAS, GRUPAL, INDIVIDUAL from planilla import fetch_planilla app = Flask(__name__) File = namedtuple('File', ['content', 'filename']) +repo_entregas = Repo(REPO_ENTREGAS) EXTENSIONES_ACEPTADAS = {'zip', 'tar', 'gz', 'pdf'} @@ -163,9 +167,15 @@ def get_emails_alumno(planilla, padron_o_grupo): @app.route('/', methods=['POST']) def post(): try: - validate_captcha() + validar_captcha() planilla = fetch_planilla() + tp = request.form['tp'] + padron_o_grupo = request.form['identificador'] + + # Valida que no se use el corrector como un oráculo. + validar_backoff(repo_entregas, planilla, tp, padron_o_grupo) + if tp not in planilla.entregas: raise Exception('La entrega {} es inválida'.format(tp)) @@ -173,8 +183,6 @@ def post(): if not files: raise Exception('No se ha adjuntado ningún archivo con extensión válida.') - padron_o_grupo = request.form['identificador'] - # Valida si la entrega es individual o grupal de acuerdo a lo ingresado. validate_grupo(planilla, padron_o_grupo, tp) @@ -197,7 +205,7 @@ def post(): raise e -def validate_captcha(): +def validar_captcha(): response = urlfetch.fetch( url='https://www.google.com/recaptcha/api/siteverify', params=urlencode({ diff --git a/requirements.txt b/requirements.txt index 3917453..6900344 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,8 @@ certifi==2017.7.27.1 chardet==3.0.4 click==6.7 Flask==0.12.2 +gitdb2==2.0.3 +GitPython==2.1.7 gspread==0.6.2 httplib2==0.10.3 idna==2.6 @@ -9,11 +11,13 @@ itsdangerous==0.24 Jinja2==2.9.6 MarkupSafe==1.0 oauth2client==4.1.2 +pkg-resources==0.0.0 pyasn1==0.3.2 pyasn1-modules==0.0.11 requests==2.18.4 rsa==3.4.2 six==1.10.0 +smmap2==2.0.3 urlfetch==1.0.2 urllib3==1.22 Werkzeug==0.12.2 From e3b98ee729ad606d874d6c648735d27bde949d8c Mon Sep 17 00:00:00 2001 From: mbuchwald Date: Wed, 25 Apr 2018 13:58:22 -0300 Subject: [PATCH 2/2] Calculo de Backoff y lanzando excepcion en caso de no cumplirse (#2) --- backoff.py | 59 +++++++++++++++++++++++++++++++++++++++++++++--- backoff_tests.py | 44 ++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 backoff_tests.py diff --git a/backoff.py b/backoff.py index 483f97e..6b3d1ee 100644 --- a/backoff.py +++ b/backoff.py @@ -1,6 +1,11 @@ import os.path -from datetime import datetime +from datetime import datetime, timedelta +SIN_PENALIDAD = 2 +ULTIMAS_ENTREGAS = 10 +# Por si acaso que algun error nuestro cause errores, permitimos desactivar el backoff, +# aunque sea momentaneamente +BACKOFF_ACTIVADO = True def get_id_cursada(): """Devuelve el identificador de la cursada según año y cuatrimestre. @@ -51,15 +56,63 @@ def obtener_entregas(repo, tp, id_cursada, id_grupo): log = repo.git.log("--format=%at", "--date=iso", rel_dir) return [datetime.fromtimestamp(int(date)) for date in log.split("\n")] +def backoff(x): + """ + Calcula el backoff (en horas) correspondiente a una cantidad de entregas + La funcion tiene la forma: + 0 si x <= SIN_PENALIDAD + backoff(x) = x - SIN_PENALIDAD si SIN_PEALIDAD < x < ULTIMAS_ENTREGAS + ULTIMAS_ENTREGAS - SIN_PENALIDAD en otro caso + + Siendo SIN_PENALIDAD = 2 y ULTIMAS_ENTREGAS = 10, + Es la función lineal que empieza en x = 2, y termina en x = 10 con valor 8. + """ + return 0 if x <= SIN_PENALIDAD else x - SIN_PENALIDAD + +def tiempo_siguiente_entrega(entregas): + """ + Calcula el momento a partir del cual el alumno puede volver a hacer la entrega, + teniendo en cuenta el backoff que tiene que cumplir el alumno respecto de la + ultima entrega que hizo. + """ + # Le sumo el delta a la ultima entrega + return entregas[-1] + timedelta(hours = backoff(len(entregas))) + +def _validar_backoff_entregas(entregas, tiempo_actual): + """ + Logica de la validacion. Recibe el tiempo actual para poder hacerlo + testeable + """ + # Si aun no hizo entregas, entonces cumple + if len(entregas) == 0: + return + + # Me quedo con las ultimas entregas, para poner un máximo de tiempo + entregas = entregas[-ULTIMAS_ENTREGAS:] + siguiente = tiempo_siguiente_entrega(entregas) + + if siguiente > datetime.today(): + raise BackoffException("No puede hacer esta entrega hasta: " + str(siguiente)) def validar_backoff(repo, planilla, tp, padron_o_grupo): """ Levanta una excepción de tipo BackoffException cuando un alumno o grupo - hacen demasiados intentos en muy poco tiempo para realizar una entrega. + hace demasiados intentos en muy poco tiempo para realizar una entrega. """ + if not BACKOFF_ACTIVADO: + return + tp = tp.lower() id_cursada = get_id_cursada() id_grupo = get_id_estudiante(planilla, padron_o_grupo) entregas = obtener_entregas(repo, tp, id_cursada, id_grupo) - # Usar "entregas" para implementar el backoff. + _validar_backoff_entregas(entregas, datetime.today()) + + +class BackoffException(Exception): + """ + Excepcion que se lanza cuando no se cumplen los tiempos de espera del corrector, + luego de sucesivos intentos + """ + pass diff --git a/backoff_tests.py b/backoff_tests.py new file mode 100644 index 0000000..bd712c3 --- /dev/null +++ b/backoff_tests.py @@ -0,0 +1,44 @@ +from backoff import _validar_backoff_entregas as validar +from backoff import BackoffException +from datetime import datetime, timedelta +import unittest + +HOY = datetime.today() + +class TestValidacionBackoff(unittest.TestCase): + + def test_sin_entregas(self): + validar([], HOY) + + def test_entrega_pocas_veces(self): + # Entrego hace 1 segundo + validar([HOY - timedelta(seconds = 1)], HOY) + + def test_entrega_hace_mucho(self): + # Entrego hace 5 dias + validar([HOY - timedelta(5)], HOY) + + def test_entrega_pasa_backoff(self): + # Entrego hace 6, 5, 4 y 3 horas + validar([HOY - timedelta(hours = 6), HOY - timedelta(hours = 5), HOY - timedelta(hours = 4), HOY - timedelta(hours = 3)], HOY) + + def test_entrega_no_pasa_backoff(self): + with self.assertRaises(BackoffException): + validar([HOY, HOY, HOY, HOY], HOY) + + def test_muchas_entregas_hace_tiempo(self): + tiempos = [HOY - timedelta(10, seconds = 10 - i) for i in range(10)] + validar(tiempos, HOY) + + def test_muchas_entregas_hace_poco_tiempo(self): + tiempos = [HOY - timedelta(hours = 10 - i) for i in range(8)] + with self.assertRaises(BackoffException): + validar(tiempos, HOY) + + def test_algunas_entregas_poco_tiempo(self): + tiempos = [HOY - timedelta(minutes = 7, hours = 3), HOY - timedelta(minutes = 5, hours = 3), HOY - timedelta(minutes = 3)] + with self.assertRaises(BackoffException): + validar(tiempos, HOY) + +if __name__ == "__main__": + unittest.main() \ No newline at end of file