From b9682fbfed8e936646e08460eb2d06c0e6cf56d0 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sun, 3 Jan 2021 19:59:16 +0100
Subject: [PATCH] =?UTF-8?q?Import:=20Rozes=C3=ADl=C3=A1n=C3=AD=20mail?=
 =?UTF-8?q?=C5=AF=20a=20logov=C3=A1n=C3=AD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

O commit a logování se stará back-end v mo.imports. Maily novým
účastníkům se generují v samostatných transakcích.
---
 mo/imports.py         | 84 ++++++++++++++++++++++++++++++++++++++++---
 mo/web/org_contest.py | 27 ++------------
 2 files changed, 82 insertions(+), 29 deletions(-)

diff --git a/mo/imports.py b/mo/imports.py
index 90e14310..d5b943e7 100644
--- a/mo/imports.py
+++ b/mo/imports.py
@@ -1,4 +1,5 @@
 from dataclasses import dataclass
+import logging
 import io
 import re
 from typing import List, Optional, Any, Dict, Callable, Type, TypeVar
@@ -6,6 +7,7 @@ from typing import List, Optional, Any, Dict, Callable, Type, TypeVar
 import mo.csv
 import mo.db as db
 import mo.rights
+import mo.users
 import mo.util
 
 RowType = TypeVar('RowType', bound=mo.csv.Row)
@@ -32,16 +34,25 @@ class ProctorImportRow(mo.csv.Row):
 
 
 class Import:
-    line_errors: List[str]
+    # Výsledek importu
     errors: List[str]
+    cnt_new_users: int = 0
+    cnt_new_participants: int = 0
+    cnt_new_participations: int = 0
+    cnt_new_roles: int = 0
+
+    # Interní: Co zrovna importujeme
     user: db.User
     round: Optional[db.Round]
     contest: Optional[db.Contest]
 
+    # Interní: Stav importu
     place_cache: Dict[str, db.Place]
     school_place_cache: Dict[str, db.Place]
     rr: Optional[mo.rights.Rights]
     place_rights_cache: Dict[int, bool]
+    line_errors: List[str]
+    new_user_ids: List[int]
 
     def __init__(self, user: db.User):
         self.errors = []
@@ -50,6 +61,7 @@ class Import:
         self.place_cache = {}
         self.school_place_cache = {}
         self.place_rights_cache = {}
+        self.new_user_ids = []
 
     def error(self, msg: str) -> Any:
         self.line_errors.append(msg)
@@ -163,11 +175,14 @@ class Import:
             user = db.User(email=email, first_name=krestni, last_name=prijmeni)
             sess.add(user)
             sess.flush()    # Aby uživatel dostal user_id
+            logging.info(f'Import: Založen uživatel user=#{user.user_id} email=<{user.email}>')
             mo.util.log(
                 type=db.LogType.user,
                 what=user.user_id,
                 details={'action': 'import', 'new': db.row2dict(user)},
             )
+            self.cnt_new_users += 1
+            self.new_user_ids.append(user.user_id)
         return user
 
     def find_or_create_participant(self, user: db.User, year: int, school_id: int, birth_year: int, grade: str) -> Optional[db.Participant]:
@@ -181,11 +196,13 @@ class Import:
         else:
             part = db.Participant(user=user, year=year, school=school_id, birth_year=birth_year, grade=grade)
             sess.add(part)
+            logging.info(f'Import: Založen účastník #{user.user_id}')
             mo.util.log(
                 type=db.LogType.participant,
                 what=user.user_id,
                 details={'action': 'import', 'new': db.row2dict(part)},
             )
+            self.cnt_new_participants += 1
 
         return part
 
@@ -202,11 +219,13 @@ class Import:
         if not pions:
             pion = db.Participation(user=user, contest=contest, place_id=place.place_id, state=db.PartState.registered)
             sess.add(pion)
+            logging.info(f'Import: Založena účast user=#{user.user_id} contest=#{contest.contest_id} place=#{place.place_id}')
             mo.util.log(
                 type=db.LogType.participant,
                 what=user.user_id,
                 details={'action': 'add-to-contest', 'new': db.row2dict(pion)},
             )
+            self.cnt_new_participations += 1
         elif len(pions) == 1:
             pion = pions[0]
             if pion.place != place:
@@ -292,18 +311,20 @@ class Import:
                              category=round.category, year=round.year, seq=round.seq)
             sess.add(ur)
             sess.flush()
+            logging.info(f'Import: Dozor user=#{user.user_id} place=#{misto.place_id} user_role=#{ur.user_role_id}')
             mo.util.log(
                 type=db.LogType.user_role,
                 what=ur.user_role_id,
                 details={'action': 'import', 'new': db.row2dict(ur)},
             )
+            self.cnt_new_roles += 1
 
     def generic_import(self, path: str, row_class: Type[RowType], process_row: Callable[['Import', RowType], None]) -> bool:
         try:
             with open(path) as file:
                 rows: List[RowType] = mo.csv.read(file=file, dialect='excel', row_class=row_class)
         except Exception as e:
-            return self.error(f'Chybná struktura tabulky {e}')
+            return self.error(f'Chybná struktura tabulky: {e}')
 
         line_num = 2
         for row in rows:
@@ -316,17 +337,70 @@ class Import:
                 break
             line_num += 1
 
-        return len(self.errors) == 0
+        if self.errors:
+            logging.info('Import: Rollback')
+            return False
+        else:
+            logging.info(f'Import: Hotovo (users={self.cnt_new_users} p-ants={self.cnt_new_participants} p-ions={self.cnt_new_participations} roles={self.cnt_new_roles})')
+            return True
 
     def import_contest(self, round: db.Round, contest: Optional[db.Contest], path: str) -> bool:
         self.round = round
         self.contest = contest
-        return self.generic_import(path, ContestImportRow, Import.import_contest_row)
+        logging.info(f'Import: Účastníci ze souboru {path}: uid={self.user.user_id} round=#{round.round_id}')
+        if not self.generic_import(path, ContestImportRow, Import.import_contest_row):
+            db.get_session().rollback()
+            return False
+
+            mo.util.log(
+                type=db.LogType.contest,
+                what=contest.contest_id,
+                details={'action': 'import'}
+            )
+            db.get_session().commit()
+
+        mo.util.log(
+            type=db.LogType.round,
+            what=round.round_id,
+            details={'action': 'import'}
+        )
+        db.get_session().commit()
+
+        self.notify_users()
+        return True
 
     def import_proctors(self, round: db.Round, path: str) -> bool:
         self.round = round
         self.contest = None
-        return self.generic_import(path, ProctorImportRow, Import.import_proctor_row)
+        logging.info(f'Import: Dozor ze souboru {path}: uid={self.user.user_id}')
+        if not self.generic_import(path, ProctorImportRow, Import.import_proctor_row):
+            return False
+
+        mo.util.log(
+            type=db.LogType.round,
+            what=round.round_id,
+            details={'action': 'import-proctors'}
+        )
+        db.get_session().commit()
+
+        self.notify_users()
+        return True
+
+    def notify_users(self):
+        # Projde všechny uživatele a těm, kteří ještě nemají nastavené heslo,
+        # ani nepožádali o jeho reset, pošle mail s odkazem na reset. Každého
+        # uživatele zpracováváme ve zvlášť transakci, aby se po chybě neztratila
+        # informace o tom, že už jsme nějaké maily rozeslali.
+
+        sess = db.get_session()
+        for uid in self.new_user_ids:
+            u = sess.query(db.User).get(uid)
+            if u and not u.password_hash and not u.reset_at:
+                token = mo.users.ask_reset_password(u)
+                sess.commit()
+                mo.util.send_new_account_email(u, token)
+            else:
+                sess.rollback()
 
 
 def generic_template(row_class: Type[RowType]) -> str:
diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py
index 15047ba1..4488c26c 100644
--- a/mo/web/org_contest.py
+++ b/mo/web/org_contest.py
@@ -100,17 +100,10 @@ def org_round_import(id: int):
         tmp_name = secrets.token_hex(16) + '.csv'
         tmp_path = os.path.join(app.instance_path, 'imports', tmp_name)
         form.file.data.save(tmp_path)
-        app.logger.info('Import: Zpracovávám soubor %s pro round=%s, uid=%s', tmp_name, round.round_code(), g.user.user_id)
 
         imp = mo.imports.Import(g.user)
         if imp.import_contest(round, None, tmp_path):
-            mo.util.log(
-                type=db.LogType.round,
-                what=round.round_id,
-                details={'action': 'import'}
-            )
-            db.get_session().commit()
-            flash('Účastníci importováni', 'success')
+            flash(f'Účastníci importováni (založeno {imp.cnt_new_users} uživatelů, {imp.cnt_new_participations} účastí)', 'success')
             return redirect(url_for('org_round', id=round.round_id))
         else:
             flash('Došlo k chybě při importu (detaily níže)', 'danger')
@@ -168,17 +161,10 @@ def org_contest_import(id: int):
         tmp_name = secrets.token_hex(16) + '.csv'
         tmp_path = os.path.join(app.instance_path, 'imports', tmp_name)
         form.file.data.save(tmp_path)
-        app.logger.info('Import: Zpracovávám soubor %s pro contest_id=%s, uid=%s', tmp_name, contest.contest_id, g.user.user_id)
 
         imp = mo.imports.Import(g.user)
         if imp.import_contest(contest.round, contest, tmp_path):
-            mo.util.log(
-                type=db.LogType.contest,
-                what=contest.contest_id,
-                details={'action': 'import'}
-            )
-            db.get_session().commit()
-            flash('Účastníci importováni', 'success')
+            flash(f'Účastníci importováni (založeno {imp.cnt_new_users} uživatelů, {imp.cnt_new_participations} účastí)', 'success')
             return redirect(url_for('org_contest', id=contest.contest_id))
         else:
             flash('Došlo k chybě při importu (detaily níže)', 'danger')
@@ -293,17 +279,10 @@ def org_proctor_import(id: int):
         tmp_name = secrets.token_hex(16) + '.csv'
         tmp_path = os.path.join(app.instance_path, 'imports', tmp_name)
         form.file.data.save(tmp_path)
-        app.logger.info('Import dozoru: Zpracovávám soubor %s pro contest_id=%s, uid=%s', tmp_name, contest.contest_id, g.user.user_id)
 
         imp = mo.imports.Import(g.user)
         if imp.import_proctors(contest.round, tmp_path):
-            mo.util.log(
-                type=db.LogType.contest,
-                what=contest.contest_id,
-                details={'action': 'import-proctors'}
-            )
-            db.get_session().commit()
-            flash('Dozor importován', 'success')
+            flash(f'Dozor importován (založeno {imp.cnt_new_users} uživatelů, {imp.cnt_new_roles} rolí)', 'success')
             return redirect(url_for('org_contest', id=contest.contest_id))
         else:
             flash('Došlo k chybě při importu (detaily níže)', 'danger')
-- 
GitLab