diff --git a/mo/imports.py b/mo/imports.py index cf73fc4445f288724f46b891a36791ca9c1a0867..1922896b8383f3f8ec89d2932f00de1860d0377b 100644 --- a/mo/imports.py +++ b/mo/imports.py @@ -2,7 +2,9 @@ from dataclasses import dataclass from enum import auto import io import re -from typing import List, Optional, Any, Dict, Type +from sqlalchemy import and_ +from sqlalchemy.orm import joinedload, Query +from typing import List, Optional, Any, Dict, Type, Union import mo.csv from mo.csv import FileFormat, MissingHeaderError @@ -17,6 +19,7 @@ class ImportType(db.MOEnum): participants = auto() proctors = auto() judges = auto() + points = auto() def friendly_name(self) -> str: return import_type_names[self] @@ -26,6 +29,7 @@ import_type_names = { ImportType.participants.name: 'účastníci', ImportType.proctors.name: 'dozor', ImportType.judges.name: 'opravovatelé', + ImportType.points.name: 'body', } @@ -37,6 +41,9 @@ class Import: cnt_new_participants: int = 0 cnt_new_participations: int = 0 cnt_new_roles: int = 0 + cnt_set_points: int = 0 + cnt_add_sols: int = 0 + cnt_del_sols: int = 0 # Veřejné vlastnosti importu template_basename: str = "sablona" @@ -45,11 +52,13 @@ class Import: user: db.User round: Optional[db.Round] contest: Optional[db.Contest] + task: Optional[db.Task] # pro Import bodů + allow_add_del: bool # pro Import bodů: je povoleno zakládat/mazat řešení fmt: FileFormat row_class: Type[mo.csv.Row] row_example: mo.csv.Row log_msg_prefix: str - log_event_name: str + log_details: Any # Interní: Stav importu place_cache: Dict[str, db.Place] @@ -76,6 +85,15 @@ class Import: logger.info('Import: >> %s', msg) return None # Kdyby bylo otypováno správně jako -> None, při volání by si mypy stěžoval + def parse_user_id(self, user_id_str: str) -> Optional[int]: + if user_id_str == "": + return self.error('Chybí ID uživatele') + + try: + return int(user_id_str) + except ValueError: + return self.error('ID uživatele není číslo') + def parse_email(self, email: str) -> Optional[str]: if email == "": return self.error('Chybí e-mailová adresa') @@ -187,6 +205,24 @@ class Import: self.new_user_ids.append(user.user_id) return user + def parse_points(self, points_str: str) -> Union[int, str, None]: + if points_str == "": + return self.error('Body musí být vyplněny') + + points_str = points_str.upper() + if points_str in ['X', '?']: + return points_str + + try: + pts = int(points_str) + except ValueError: + return self.error('Body nejsou celé číslo') + + if pts < 0: + return self.error('Body nesmí být záporné') + + return pts + def find_or_create_participant(self, user: db.User, year: int, school_id: int, birth_year: int, grade: str) -> Optional[db.Participant]: sess = db.get_session() part = sess.query(db.Participant).get((user.user_id, year)) @@ -287,22 +323,37 @@ class Import: args.append(f'round=#{self.round.round_id}') if self.contest is not None: args.append(f'contest=#{self.contest.contest_id}') + if self.task is not None: + args.append(f'task=#{self.task.task_id}') logger.info('Import: %s ze souboru %s: %s', self.log_msg_prefix, path, " ".join(args)) def log_end(self): - logger.info(f'Import: Hotovo (rows={self.cnt_rows} users={self.cnt_new_users} p-ants={self.cnt_new_participants} p-ions={self.cnt_new_participations} roles={self.cnt_new_roles})') + args = [f'rows=#{self.cnt_rows}'] + for key, val in [ + ('users', self.cnt_new_users), + ('p-ants', self.cnt_new_participants), + ('p-ions', self.cnt_new_participations), + ('roles', self.cnt_new_roles), + ('points', self.cnt_set_points), + ('add-sols', self.cnt_add_sols), + ('del-sols', self.cnt_del_sols), + ]: + if val > 0: + args.append(f'{key}={val}') + logger.info('Import: Hotovo (%s)', " ".join(args)) + if self.contest is not None: mo.util.log( type=db.LogType.contest, what=self.contest.contest_id, - details={'action': 'import'} + details=self.log_details, ) elif self.round is not None: mo.util.log( type=db.LogType.round, what=self.round.round_id, - details={'action': self.log_event_name} + details=self.log_details, ) else: assert False @@ -362,6 +413,7 @@ class Import: sess.rollback() def get_template(self) -> str: + # Odvozené třídy mohou přetížit out = io.StringIO() mo.csv.write(file=out, fmt=self.fmt, row_class=self.row_class, rows=[self.row_example]) return out.getvalue() @@ -403,7 +455,7 @@ class ContestImport(Import): rok_naroz="2000", ) log_msg_prefix = 'Účastníci' - log_event_name = 'import' + log_details = {'action': 'import'} template_basename = 'sablona-ucast' def setup(self): @@ -462,7 +514,7 @@ class ProctorImport(Import): kod_mista='#3333', ) log_msg_prefix = 'Dozor' - log_event_name = 'import-proctors' + log_details = {'action': 'import-proctors'} template_basename = 'sablona-dozor' def setup(self): @@ -509,7 +561,7 @@ class JudgeImport(Import): kod_oblasti='B', ) log_msg_prefix = 'Opravovatelé' - log_event_name = 'import-judges' + log_details = {'action': 'import-judges'} template_basename = 'sablona-oprav' def setup(self): @@ -541,7 +593,153 @@ class JudgeImport(Import): self.add_role(user, place, db.RoleType.opravovatel) -def create_import(user: db.User, type: ImportType, fmt: FileFormat, round: Optional[db.Round] = None, contest: Optional[db.Contest] = None): +@dataclass +class PointsImportRow(mo.csv.Row): + user_id: str = "" + krestni: str = "" + prijmeni: str = "" + body: str = "" + + +class PointsImport(Import): + row_class = PointsImportRow + log_msg_prefix = 'Body' + + def setup(self): + assert self.round is not None + assert self.task is not None + self.log_details = {'action': 'import-points', 'task': self.task.code} + self.template_basename = 'body-' + self.task.code + + def _pion_sol_query(self) -> Query: + sess = db.get_session() + query = (sess.query(db.Participation, db.Solution) + .select_from(db.Participation) + .outerjoin(db.Solution, and_(db.Solution.user_id == db.Participation.user_id, db.Solution.task == self.task)) + .options(joinedload(db.Participation.user))) + + if self.contest is not None: + query = query.filter(db.Participation.contest == self.contest) + else: + contest_query = sess.query(db.Contest.contest_id).filter_by(round=self.round) + query = query.filter(db.Participation.contest_id.in_(contest_query.subquery())) + + return query + + def import_row(self, r: mo.csv.Row): + assert isinstance(r, PointsImportRow) + num_prev_errs = len(self.errors) + user_id = self.parse_user_id(r.user_id) + krestni = self.parse_name(r.krestni) + prijmeni = self.parse_name(r.prijmeni) + body = self.parse_points(r.body) + + if (len(self.errors) > num_prev_errs + or user_id is None + or krestni is None + or prijmeni is None + or body is None): + return + + assert self.round is not None + assert self.task is not None + task_id = self.task.task_id + + sess = db.get_session() + query = self._pion_sol_query().filter(db.Participation.user_id == user_id) + pion_sols = query.all() + if not pion_sols: + return self.error('Soutěžící nenalezen v tomto kole') + elif len(pion_sols) > 1: + return self.error('Soutěžící v tomto kole soutěží vícekrát, neumím zpracovat') + pion, sol = pion_sols[0] + + if self.contest is not None: + if pion.contest != self.contest: + return self.error('Soutěžící nesoutěží v této oblasti') + + rights = self.gatekeeper.rights_for_contest(pion.contest) + if not rights.can_edit_points(self.round): + return self.error('Nemáte právo na úpravu bodů') + + user = pion.user + if user.first_name != krestni or user.last_name != prijmeni: + return self.error('Neodpovídá ID a jméno soutěžícího') + + if sol is None: + if body == 'X': + return + if not self.allow_add_del: + return self.error('Tento soutěžící úlohu neodevzdal') + if not rights.can_upload_solutions(round): + return self.error('Nemáte právo na zakládání nových řešení') + sol = db.Solution(user_id=user_id, task_id=task_id) + sess.add(sol) + logger.info(f'Import: Založeno řešení user=#{user_id} task=#{task_id}') + mo.util.log( + type=db.LogType.participant, + what=user_id, + details={'action': 'solution-created', 'task': task_id}, + ) + self.cnt_add_sols += 1 + elif body == 'X': + if not self.allow_add_del: + return self.error('Tento soutěžící úlohu odevzdal') + if sol.final_submit is not None or sol.final_feedback is not None: + return self.error('Nelze smazat řešení, ke kterému existují odevzdané soubory') + if not rights.can_upload_solutions(round): + return self.error('Nemáte právo na mazání řešení') + logger.info(f'Import: Smazáno řešení user=#{user_id} task=#{task_id}') + mo.util.log( + type=db.LogType.participant, + what=user_id, + details={'action': 'solution-removed', 'task': task_id}, + ) + self.cnt_del_sols += 1 + sess.delete(sol) + return + + points = body if isinstance(body, int) else None + if sol.points != points: + sol.points = points + sess.add(db.PointsHistory( + task=self.task, + participant_id=user_id, + user=self.user, + points_at=mo.now, + points=points, + )) + self.cnt_set_points += 1 + + def get_template(self) -> str: + rows = [] + for pion, sol in sorted(self._pion_sol_query().all(), key=lambda pair: pair[0].user.sort_key()): + if sol is None: + pts = 'X' + elif sol.points is None: + pts = '?' + else: + pts = str(sol.points) + user = pion.user + rows.append(PointsImportRow( + user_id=user.user_id, + krestni=user.first_name, + prijmeni=user.last_name, + body=pts, + )) + + out = io.StringIO() + mo.csv.write(file=out, fmt=self.fmt, row_class=self.row_class, rows=rows) + return out.getvalue() + + +def create_import(user: db.User, + type: ImportType, + fmt: FileFormat, + round: Optional[db.Round] = None, + contest: Optional[db.Contest] = None, + task: Optional[db.Task] = None, + allow_add_del: bool = False): imp: Import if type == ImportType.participants: imp = ContestImport() @@ -549,12 +747,16 @@ def create_import(user: db.User, type: ImportType, fmt: FileFormat, round: Optio imp = ProctorImport() elif type == ImportType.judges: imp = JudgeImport() + elif type == ImportType.points: + imp = PointsImport() else: assert False, "Neznámý typ importu" imp.user = user imp.round = round imp.contest = contest + imp.task = task + imp.allow_add_del = allow_add_del imp.fmt = fmt imp.gatekeeper = mo.rights.Gatekeeper(user) imp.setup() diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py index 930d1060aaacdeb904ca80f4943f7a4647bcafda..8b0d5edd4a3e56b8ac1aed91cf37c9816a0d3cbb 100644 --- a/mo/web/org_contest.py +++ b/mo/web/org_contest.py @@ -15,7 +15,7 @@ import wtforms import mo from mo.csv import FileFormat import mo.db as db -from mo.imports import ImportType, create_import +from mo.imports import ImportType, Import, create_import import mo.jobs.submit from mo.rights import Right, Rights import mo.util @@ -32,7 +32,8 @@ class ImportForm(FlaskForm): file = flask_wtf.file.FileField("Soubor") typ = wtforms.SelectField( "Typ dat", - choices=ImportType.choices(), coerce=ImportType.coerce, + choices=[(x.name, x.friendly_name()) for x in (ImportType.participants, ImportType.proctors, ImportType.judges)], + coerce=ImportType.coerce, default=ImportType.participants, ) fmt = wtforms.SelectField( @@ -614,6 +615,7 @@ def get_solution_context(contest_id: int, user_id: Optional[int], task_id: Optio user=user, task=task, site=site, + # XXX: Potřebujeme tohle všechno? Nechceme spíš vracet rr a nechat každého, ať na něm volá metody? allow_view=allow_view, allow_upload_solutions=rr.can_upload_solutions(round), allow_upload_feedback=rr.can_upload_feedback(round), @@ -1152,6 +1154,70 @@ def org_contest_task_upload(contest_id: int, task_id: int, site_id: Optional[int can_upload_feedback=sc.allow_upload_feedback) +class BatchPointsForm(FlaskForm): + file = flask_wtf.file.FileField("Soubor") + fmt = wtforms.SelectField( + "Formát souboru", + choices=FileFormat.choices(), coerce=FileFormat.coerce, + default=FileFormat.cs_csv, + ) + add_del_sols = wtforms.BooleanField('Zakládat / mazat řešení', description='Xyzzy') + submit = wtforms.SubmitField('Nahrát body') + get_template = wtforms.SubmitField('Stáhnout šablonu') + + +def generic_batch_points(round: db.Round, contest: Optional[db.Contest], task: db.Task): + """Společná funkce pro download/upload bodů do soutěží a kol.""" + + form = BatchPointsForm() + errs = [] + if form.validate_on_submit(): + fmt = form.fmt.data + imp = create_import(user=g.user, type=ImportType.points, fmt=fmt, round=round, contest=contest, task=task, allow_add_del=form.add_del_sols.data) + if form.submit.data: + if form.file.data is not None: + file = form.file.data.stream + import_tmp = mo.util.link_to_dir(file.name, mo.util.data_dir('imports'), suffix='.csv') + + if imp.run(import_tmp): + if imp.cnt_rows == 0: + flash('Soubor neobsahoval žádné řádky s daty', 'danger') + else: + flash(f'Importováno ({imp.cnt_rows} řádků, {imp.cnt_set_points} řešení přebodováno, {imp.cnt_add_sols} založeno a {imp.cnt_del_sols} smazáno)', 'success') + if contest is not None: + return redirect(url_for('org_contest', id=contest.contest_id)) + else: + return redirect(url_for('org_round', id=round.round_id)) + else: + errs = imp.errors + else: + flash('Vyberte si prosím soubor', 'danger') + elif form.get_template.data: + out = imp.get_template() + resp = app.make_response(out) + resp.content_type = fmt.get_content_type() + resp.headers.add('Content-Disposition', 'attachment; filename=OSMO-' + imp.template_basename + '.' + fmt.get_extension()) + return resp + + return render_template( + 'org_generic_batch_points.html', + round=round, contest=contest, task=task, + form=form, + errs=errs, + ) + + +@app.route('/org/contest/c/<int:contest_id>/task/<int:task_id>/batch-points', methods=('GET', 'POST')) +def org_contest_task_batch_points(contest_id: int, task_id: int): + sc = get_solution_context(contest_id, None, task_id, None) + assert sc.task is not None + + if not sc.allow_edit_points: + raise werkzeug.exceptions.Forbidden() + + return generic_batch_points(round=sc.round, contest=sc.contest, task=sc.task) + + @app.route('/org/contest/c/<int:contest_id>/user/<int:user_id>') def org_contest_user(contest_id: int, user_id: int): sc = get_solution_context(contest_id, user_id, None, None) diff --git a/mo/web/org_round.py b/mo/web/org_round.py index 63b268b20c8f04609a3e533582e9563e334b24e0..7e86f508f75e4886a3b6b5b76889140678c98dec 100644 --- a/mo/web/org_round.py +++ b/mo/web/org_round.py @@ -1,4 +1,3 @@ -import datetime from flask import render_template, g, redirect, url_for, flash, request import locale from flask_wtf.form import FlaskForm @@ -17,7 +16,7 @@ from mo.rights import Right, Rights import mo.util from mo.web import app from mo.web.org_contest import ParticipantsActionForm, ParticipantsFilterForm, get_contestants_query, make_contestant_table, \ - generic_import, generic_batch_download, generic_batch_upload + generic_import, generic_batch_download, generic_batch_upload, generic_batch_points def get_round(id: int) -> db.Round: @@ -38,6 +37,13 @@ def get_round_rr(id: int, right_needed: Optional[Right], any_place: bool) -> Tup return round, rr +def get_task(round: db.Round, task_id: int) -> db.Task: + task = db.get_session().query(db.Task).get(task_id) + if not task or task.round_id != round.round_id: + raise werkzeug.exceptions.NotFound() + return task + + @app.route('/org/contest/') def org_rounds(): sess = db.get_session() @@ -271,30 +277,27 @@ def org_round_task_edit(id: int, task_id: int): @app.route('/org/contest/r/<int:round_id>/task/<int:task_id>/download', methods=('GET', 'POST')) def org_round_task_download(round_id: int, task_id: int): - sess = db.get_session() round, rr = get_round_rr(round_id, Right.view_submits, False) - - task = sess.query(db.Task).get(task_id) - if not task or task.round_id != round_id: - raise werkzeug.exceptions.NotFound() - + task = get_task(round, task_id) return generic_batch_download(round=round, contest=None, site=None, task=task) @app.route('/org/contest/r/<int:round_id>/task/<int:task_id>/upload', methods=('GET', 'POST')) def org_round_task_upload(round_id: int, task_id: int): - sess = db.get_session() round, rr = get_round_rr(round_id, Right.view_submits, False) - - task = sess.query(db.Task).get(task_id) - if not task or task.round_id != round_id: - raise werkzeug.exceptions.NotFound() - + task = get_task(round, task_id) return generic_batch_upload(round=round, contest=None, site=None, task=task, can_upload_solutions=rr.can_upload_solutions(round), can_upload_feedback=rr.can_upload_feedback(round)) +@app.route('/org/contest/r/<int:round_id>/task/<int:task_id>/batch-points', methods=('GET', 'POST')) +def org_round_task_batch_points(round_id: int, task_id: int): + round, rr = get_round_rr(round_id, Right.edit_points, True) + task = get_task(round, task_id) + return generic_batch_points(round=round, contest=None, task=task) + + @app.route('/org/contest/r/<int:id>/list', methods=('GET', 'POST')) def org_round_list(id: int): round, rr = get_round_rr(id, Right.manage_contest, True) diff --git a/mo/web/templates/org_contest.html b/mo/web/templates/org_contest.html index 46315999d2421c81e6ce88c16de79982645e6574..ada65912f6a48801b89e8b4fd29d18141befc395 100644 --- a/mo/web/templates/org_contest.html +++ b/mo/web/templates/org_contest.html @@ -100,6 +100,7 @@ {% endif %} {% if not site and can_edit_points %} <a class="btn btn-xs btn-primary" href="{{ url_for('org_contest_task_points', contest_id=contest.contest_id, task_id=task.task_id) }}">Zadat body</a> + <a class="btn btn-xs btn-default" href="{{ url_for('org_contest_task_batch_points', contest_id=contest.contest_id, task_id=task.task_id) }}">Nahrát body</a> {% endif %} </div> </tr> diff --git a/mo/web/templates/org_generic_batch_points.html b/mo/web/templates/org_generic_batch_points.html new file mode 100644 index 0000000000000000000000000000000000000000..646b76471c760832182e312a2336aeb3a642fefd --- /dev/null +++ b/mo/web/templates/org_generic_batch_points.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% import "bootstrap/wtf.html" as wtf %} + +{% block title %}Dávkové bodování úlohy {{ task.code }} {{ task.name }}{% endblock %} +{% block breadcrumbs %} +{{ contest_breadcrumbs(round=round, contest=contest, task=task, action="Dávkové bodování") }} +{% endblock %} + +{% block body %} + +{% if errs %} +<h3>Chyby při importu</h3> + +<pre><div class="alert alert-danger" role="alert">{{ "" -}} +{% for e in errs %} +{{ e }} +{% endfor %} +</div></pre> +{% endif %} + +<p>Zde si můžete stáhnout bodovací formulář v zadaném formátu a pak ho nahrát zpět +s vyplněnými body. "<code>?</code>" místo bodů značí dosud neobodované řešení, +"<code>X</code>" značí řešení neodevzdané. + +{{ wtf.quick_form(form, form_type='simple', button_map={'submit': 'primary'}) }} + +{% endblock %} diff --git a/mo/web/templates/org_round.html b/mo/web/templates/org_round.html index 10e32a7b75ca9746b3be31f258cb997b832f48e8..3296d22beeeb1486e96d26f526c4c4666c6e619b 100644 --- a/mo/web/templates/org_round.html +++ b/mo/web/templates/org_round.html @@ -112,6 +112,9 @@ {% if can_upload %} <a class="btn btn-xs btn-primary" href="{{ url_for('org_round_task_upload', round_id=round.round_id, task_id=task.task_id) }}">Nahrát</a> {% endif %} + {% if can_upload %} + <a class="btn btn-xs btn-default" href="{{ url_for('org_round_task_batch_points', round_id=round.round_id, task_id=task.task_id) }}">Nahrát body</a> + {% endif %} {% if g.user.is_admin %} <a class="btn btn-xs btn-default" href="{{ log_url('task', task.task_id) }}">Historie</a> {% endif %}