From e3331b7022ec1fb63a94dffe37cedb831ffe360f Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Fri, 19 Feb 2021 18:11:03 +0100
Subject: [PATCH] =?UTF-8?q?Import:=20Bodov=C3=A1n=C3=AD=20um=C3=AD=20i=20z?=
 =?UTF-8?q?akl=C3=A1dat/odstra=C5=88ovat=20=C5=99e=C5=A1en=C3=AD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/imports.py                                 | 87 +++++++++++++++----
 mo/web/org_contest.py                         |  7 +-
 .../templates/org_generic_batch_points.html   |  3 +-
 3 files changed, 74 insertions(+), 23 deletions(-)

diff --git a/mo/imports.py b/mo/imports.py
index 0648efb3..1922896b 100644
--- a/mo/imports.py
+++ b/mo/imports.py
@@ -4,7 +4,7 @@ import io
 import re
 from sqlalchemy import and_
 from sqlalchemy.orm import joinedload, Query
-from typing import List, Optional, Any, Dict, Type
+from typing import List, Optional, Any, Dict, Type, Union
 
 import mo.csv
 from mo.csv import FileFormat, MissingHeaderError
@@ -42,6 +42,8 @@ class Import:
     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"
@@ -50,7 +52,8 @@ class Import:
     user: db.User
     round: Optional[db.Round]
     contest: Optional[db.Contest]
-    task: Optional[db.Task]
+    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
@@ -202,9 +205,13 @@ class Import:
             self.new_user_ids.append(user.user_id)
         return user
 
-    def parse_points(self, points_str: str) -> Optional[int]:
+    def parse_points(self, points_str: str) -> Union[int, str, None]:
         if points_str == "":
-            return None
+            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)
@@ -329,6 +336,8 @@ class Import:
             ('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}')
@@ -628,14 +637,16 @@ class PointsImport(Import):
         if (len(self.errors) > num_prev_errs
                 or user_id is None
                 or krestni is None
-                or prijmeni 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.Solution.user_id == user_id)
+        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')
@@ -656,30 +667,66 @@ class PointsImport(Import):
             return self.error('Neodpovídá ID a jméno soutěžícího')
 
         if sol is None:
-            return self.error('Tento soutěžící úlohu neodevzdal')
+            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
 
-        if sol.points != body:
-            sol.points = body
+        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=body,
+                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 not None:
-                user = pion.user
-                rows.append(PointsImportRow(
-                    user_id=user.user_id,
-                    krestni=user.first_name,
-                    prijmeni=user.last_name,
-                    body=sol.points,
-                ))
+            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)
@@ -691,7 +738,8 @@ def create_import(user: db.User,
                   fmt: FileFormat,
                   round: Optional[db.Round] = None,
                   contest: Optional[db.Contest] = None,
-                  task: Optional[db.Task] = None):
+                  task: Optional[db.Task] = None,
+                  allow_add_del: bool = False):
     imp: Import
     if type == ImportType.participants:
         imp = ContestImport()
@@ -708,6 +756,7 @@ def create_import(user: db.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 839b3435..8b0d5edd 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
@@ -1161,6 +1161,7 @@ class BatchPointsForm(FlaskForm):
         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')
 
@@ -1172,7 +1173,7 @@ def generic_batch_points(round: db.Round, contest: Optional[db.Contest], task: d
     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)
+        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
@@ -1182,7 +1183,7 @@ def generic_batch_points(round: db.Round, contest: Optional[db.Contest], task: d
                     if imp.cnt_rows == 0:
                         flash('Soubor neobsahoval žádné řádky s daty', 'danger')
                     else:
-                        flash(f'Importováno ({imp.cnt_rows} řádků, změněny body u {imp.cnt_set_points} řešení)', 'success')
+                        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:
diff --git a/mo/web/templates/org_generic_batch_points.html b/mo/web/templates/org_generic_batch_points.html
index 26246028..646b7647 100644
--- a/mo/web/templates/org_generic_batch_points.html
+++ b/mo/web/templates/org_generic_batch_points.html
@@ -19,7 +19,8 @@
 {% 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.
+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'}) }}
 
-- 
GitLab