-
Notifications
You must be signed in to change notification settings - Fork 1
/
main.py
291 lines (240 loc) · 9.38 KB
/
main.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
import collections
import io
import logging
import pathlib
import zipfile
from email import encoders
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formatdate, make_msgid
from typing import List, Optional
import github
from flask import Flask, render_template, request
from flask_caching import Cache # type: ignore
from werkzeug.exceptions import HTTPException
from werkzeug.utils import secure_filename
from algorw import utils
from algorw.app.queue import task_queue
from algorw.common.tasks import CorrectorTask, RepoSync
from algorw.corrector import corregir_entrega
from algorw.models import Alumne, Docente
from config import Modalidad, Settings, load_config
from planilla import fetch_planilla, timer_planilla
app = Flask("entregas")
app.logger.setLevel(logging.INFO)
app.config["MAX_CONTENT_LENGTH"] = 4 * 1024 * 1024 # 4 MiB
cfg: Settings = load_config()
cache: Cache = Cache(config={"CACHE_TYPE": "simple"})
cache.init_app(app)
timer_planilla.start()
File = collections.namedtuple("File", ["content", "filename"])
EXTENSIONES_ACEPTADAS = {"zip"} # TODO: volver a aceptar archivos sueltos.
class InvalidForm(Exception):
"""Excepción para cualquier error en el form."""
@app.context_processor
def inject_cfg():
return {"cfg": cfg}
@app.route("/", methods=["GET"])
def get():
planilla = fetch_planilla()
return render_template(
"index.html", entregas=cfg.entregas, correctores=planilla.correctores
)
@app.errorhandler(Exception)
def err(error):
if isinstance(error, HTTPException):
code = error.code
message = error.description
else:
code = 500
message = f"{error.__class__.__name__}: {error}"
logging.exception(error)
return render_template("result.html", error=message), code
@app.errorhandler(InvalidForm)
def warn_and_render(ex):
"""Error menos verboso que err(), apropiado para excepciones de usuario."""
logging.warning("InvalidForm: %s", ex)
return render_template("result.html", error=ex), 422 # Unprocessable Entity
def archivo_es_permitido(nombre):
return "." in nombre and nombre.rsplit(".", 1)[1].lower() in EXTENSIONES_ACEPTADAS
def get_files():
files = request.files.getlist("files")
return [
File(content=f.read(), filename=secure_filename(f.filename))
for f in files
if f and archivo_es_permitido(f.filename)
]
def make_email(
tp: str,
alulist: List[Alumne],
docente: Optional[Docente],
body: str,
) -> MIMEMultipart:
"""Prepara el correo a enviar, con cabeceras y cuerpo, sin adjunto."""
body_n = f"\n{body}\n" if body else ""
emails = sorted(x.correo for x in alulist)
nombres = sorted(x.nombre.split(",")[0].title() for x in alulist)
padrones = utils.sorted_strnum([x.legajo for x in alulist])
correo = MIMEMultipart()
correo["From"] = str(cfg.sender)
correo["To"] = ", ".join(emails)
if docente:
correo["Cc"] = docente.correo
correo["Bcc"] = cfg.sender.email
correo["Reply-To"] = correo["To"] # Responder a los alumnos
subject_text = "{tp} - {padrones} - {nombres}".format(
tp=tp, padrones=", ".join(padrones), nombres=", ".join(nombres)
)
correo["Date"] = formatdate()
correo["Subject"] = subject_text
correo["Message-ID"] = make_msgid("entregas", "algorw.turing.pink")
direcciones = "\n".join(emails)
correo.attach(
MIMEText(
f"{tp}\n{direcciones}\n{body_n}\n-- \n{cfg.title} – {request.url}",
"plain",
)
)
return correo
def oauth_credentials():
"""Caché de las credenciales OAuth."""
key = "oauth2_credentials"
creds = cache.get(key)
if creds is None:
app.logger.info("Loading OAuth2 credentials")
elif not creds.valid:
app.logger.info("Refreshing OAuth2 credentials")
else:
return creds
creds = utils.get_oauth_credentials(cfg)
cache.set(key, creds)
return creds
@app.route("/", methods=["POST"])
def post():
# Leer valores del formulario.
try:
tp = request.form["tp"]
files = get_files()
body = request.form["body"] or ""
tipo = request.form["tipo"]
legajo = request.form["legajo"]
modalidad = Modalidad(request.form.get("modalidad", "i"))
except KeyError as ex:
raise InvalidForm(f"Formulario inválido sin campo {ex.args[0]!r}") from ex
except ValueError as ex:
raise InvalidForm(f"Formulario con campo inválido: {ex.args[0]}") from ex
# Obtener alumnes que realizan la entrega.
planilla = fetch_planilla()
try:
alumne = planilla.get_alu(legajo)
except KeyError as ex:
raise InvalidForm(f"No se encuentra el legajo {legajo!r}") from ex
# Validar varios aspectos de la entrega.
if tp not in cfg.entregas:
raise InvalidForm(f"La entrega {tp!r} es inválida")
elif modalidad == Modalidad.GRUPAL and cfg.entregas[tp] != Modalidad.GRUPAL:
raise ValueError(f"La entrega {tp} debe ser individual")
elif tipo == "entrega" and not files:
raise InvalidForm("No se ha adjuntado ningún archivo con extensión válida.")
elif tipo == "ausencia" and not body:
raise InvalidForm("No se ha adjuntado una justificación para la ausencia.")
# Encontrar a le docente correspondiente.
docente = None
warning = None
if cfg.entregas[tp] == Modalidad.INDIVIDUAL:
docente = alumne.ayudante_indiv
elif cfg.entregas[tp] == Modalidad.GRUPAL:
docente = alumne.ayudante_grupal
if not docente and cfg.entregas[tp] != Modalidad.PARCIALITO:
warning = "aún no se asignó docente para corregir esta entrega"
# Encontrar la lista de alumnes a quienes pertenece la entrega, y su repo asociado.
alulist = [alumne]
alu_repo = None
if modalidad == Modalidad.GRUPAL and alumne.grupo:
try:
alulist = planilla.get_alulist(alumne.grupo)
alu_repo = planilla.repo_grupal(alumne.grupo)
except KeyError:
logging.warning("KeyError in get_alulist(%r)", alumne.group)
else:
alu_repo = alumne.repo_indiv
email = make_email(tp.upper(), alulist, docente, body)
legajos = utils.sorted_strnum([x.legajo for x in alulist])
if tipo == "ausencia":
rawzip = io.BytesIO()
email.replace_header("Subject", email["Subject"] + " (ausencia)")
with zipfile.ZipFile(rawzip, "w") as zf:
zf.writestr("ausencia.txt", body + "\n")
entrega = File(rawzip.getvalue(), f"{tp}_ausencia.zip")
commit_desc = ""
else:
entrega = zipfile_for_entrega(files)
commit_desc = body.strip()
# Incluir el único archivo ZIP.
part = MIMEBase("application", "zip")
part.set_payload(entrega.content)
encoders.encode_base64(part)
part.add_header("Content-Disposition", "attachment", filename=entrega.filename)
email.attach(part)
# Determinar la ruta en algo2_entregas (se hace caso especial para los parcialitos).
tp_id = tp.lower()
if cfg.entregas[tp] != Modalidad.PARCIALITO:
# Ruta tradicional: pila/2020_1/54321
relpath_base = pathlib.PurePath(tp_id) / cfg.cuatri
else:
# Ruta específica para parcialitos: parcialitos/2020_1/parcialito1_r2/54321
relpath_base = pathlib.PurePath("parcialitos") / cfg.cuatri / tp_id
if alu_repo is not None:
# Crear este objeto cada vez para evitar
# https://github.com/PyGithub/PyGithub/issues/2431.
gh = github.GithubIntegration(
cfg.github_app_id, open(cfg.github_app_keyfile).read()
)
try:
installation = gh.get_repo_installation(alu_repo.owner, alu_repo.name)
except github.UnknownObjectException:
installation = gh.get_org_installation(alu_repo.owner)
auth = gh.get_access_token(installation.id)
auth_token = auth.token
repo_sync = RepoSync(
alu_repo=alu_repo,
auth_token=auth_token,
github_id=alumne.github or "wachenbot",
)
else:
repo_sync = None
task = CorrectorTask(
tp_id=tp_id,
legajos=legajos,
zipfile=entrega.content,
commit_desc=commit_desc,
repo_sync=repo_sync,
orig_headers=dict(email.items()),
repo_relpath=relpath_base / "_".join(legajos),
)
task_queue.enqueue(corregir_entrega, task)
if not cfg.test:
# TODO: en lugar de enviar un mail, que es lento, hacer un commit en la
# copia local de algo2_entregas.
utils.sendmail(email, oauth_credentials())
return render_template(
"result.html",
tp=tp,
warning=warning,
email="\n".join(f"{k}: {v}" for k, v in email.items()) if cfg.test else None,
)
def zipfile_for_entrega(files: List[File]) -> File:
"""Genera un archivo ZIP para enviar al corrector.
Por el momento, se reenvía tal cual el archivo recibido (debe haber solo uno).
"""
# TODO: realizar toda la validación aquí, y no en zip_walk().
# TODO: si el archivo tiene subdirectorios o archivos no permitidos, crear un
# nuevo ZIP, y enviar ese al corrector.
assert EXTENSIONES_ACEPTADAS == {"zip"}
if len(files) != 1:
nombres = ", ".join(f.filename for f in files)
raise InvalidForm(
f"Se esperaba un único archivo ZIP en la entrega (se encontró: {nombres})"
)
return files[0]