From f2b389221bf25f03bcb58ccba12e36c2fb1e9caa Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Wed, 29 Jan 2025 20:26:14 +0100
Subject: [PATCH] =?UTF-8?q?Nov=C3=BD=20syst=C3=A9m=20mailov=C3=BDch=20noti?=
 =?UTF-8?q?fikac=C3=AD=20o=20zm=C4=9Bn=C4=9B=20stavu=20sout=C4=9B=C5=BEe?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Maily rozesíláme při změně na graded nebo closed.
Vyjmenováváme, co všechno je v OSMO k dispozici v dané soutěži.
Informujeme jak účastníky, tak jejich školní garanty.
---
 mo/email.py           |  32 +++--
 mo/jobs/notify.py     | 314 +++++++++++++++++++++++++++++++++++-------
 mo/web/org_certs.py   |   1 +
 mo/web/org_contest.py |   3 +-
 mo/web/org_round.py   |   5 +-
 mo/web/user.py        |   1 +
 6 files changed, 289 insertions(+), 67 deletions(-)

diff --git a/mo/email.py b/mo/email.py
index 78684545..9d6c94fd 100644
--- a/mo/email.py
+++ b/mo/email.py
@@ -12,7 +12,7 @@ import subprocess
 import textwrap
 import token_bucket
 import traceback
-from typing import Mapping, Optional, Tuple
+from typing import Mapping, Optional, Tuple, List
 import urllib.parse
 
 import mo.db as db
@@ -302,19 +302,31 @@ def send_join_notify_email(dest: db.User, who: db.User, contest: db.Contest) ->
     '''), add_footer=True)
 
 
-def send_grading_info_email(dest: db.User, round: db.Round) -> bool:
-    return send_user_email(dest, f'{round.name} kategorie {round.category} opraveno', textwrap.dedent(f'''\
-        {round.name} Matematické olympiády kategorie {round.category} bylo opraveno.
+def send_grading_info_email(dest: db.User, round: db.Round, contest: db.Contest, is_teacher: bool, new_state: db.RoundState, items: List[str]) -> bool:
+    if is_teacher:
+        what = 'výsledky svých studentů'
+        url = f'{config.WEB_ROOT}org/contest/school-results/'
+    else:
+        what = 'své výsledky'
+        url = f'{config.WEB_ROOT}user/contest/{contest.contest_id}/'
 
-        Své výsledky najdete v odevzdávacím systému:
+    if new_state == db.RoundState.closed:
+        event = 'ukončeno'
+    else:
+        event = 'opraveno'
 
-                {config.WEB_ROOT}
+    formatted_items = '\n'.join([f'\t- {it}' for it in items])
 
-        Také tam můžete najít opravená řešení a/nebo komentáře opravovatelů,
-        pokud je opravovatelé nahráli.
+    return send_user_email(dest, f'{round.name} kategorie {round.category} {event}', textwrap.dedent(f'''\
+        {round.name} Matematické olympiády kategorie {round.category} ({contest.place.name}) bylo {event}.
 
-        Váš OSMO
-    '''), add_footer=True)
+        Na stránce Odevzdávacího systému MO:
+
+                {url}
+
+        najdete {what}:
+
+    ''') + formatted_items + '\n\nVáš OSMO\n', add_footer=True)
 
 
 # Omezení rychlosti odesílání mailů o chybách děravým kyblíkem
diff --git a/mo/jobs/notify.py b/mo/jobs/notify.py
index 50097dea..0616855d 100644
--- a/mo/jobs/notify.py
+++ b/mo/jobs/notify.py
@@ -1,20 +1,28 @@
-# Implementace jobů na posílání notifikací
+# Implementace jobů na posílání notifikací při změně stavu soutěže
 
+from enum import Enum, auto
+from collections import defaultdict
+from dataclasses import dataclass
 from sqlalchemy import and_
-from typing import Optional
+from sqlalchemy.orm import joinedload
+from typing import Optional, List, Set, DefaultDict, Callable, Dict
 
 import mo.db as db
 import mo.email
+import mo.rights
+from mo.ext.json_walker import Walker
 from mo.jobs import TheJob, job_handler
+from mo.util import logger
 from mo.util_format import inflect_number
 
 
 #
-# Job send_grading_info: Pošle upozornění na opravené úlohy
+# Job send_grading_info: Pošle upozornění při změně stavu soutěže
 #
 # Vstupní JSON:
 #        { 'round_id': id_kola,
-#          'contest_id': id_soutěže,    // může být null
+#          'contest_ids': [id_soutěží],    // může být null
+#          'new_state': nový_stav_soutěží,
 #        }
 #
 # Výstupní JSON:
@@ -22,25 +30,249 @@ from mo.util_format import inflect_number
 #
 
 
-def schedule_send_grading_info(round: db.Round, contest: Optional[db.Contest], for_user: db.User) -> None:
+def schedule_send_grading_info(round: db.Round, contests: Optional[List[db.Contest]], new_state: db.RoundState, for_user: db.User) -> bool:
+    if new_state not in [db.RoundState.graded, db.RoundState.closed]:
+        return False
+
+    if round.is_subround():
+        return False
+
     the_job = TheJob()
     job = the_job.create(db.JobType.send_grading_info, for_user)
     job.description = f'E-maily účastníkům {round.round_code_short()}'
-    if contest is not None:
-        job.description += f' {contest.place.name_locative()}'
     job.in_json = {
         'round_id': round.round_id,
-        'contest_id': contest.contest_id if contest else None,
+        'contest_ids': None,
+        'new_state': new_state.name,
     }
+    if contests is not None:
+        job.in_json['contest_ids'] = [c.contest_id for c in contests]
+        if len(contests) == 1:
+            job.description += f' {contests[0].place.name_locative()}'
+        else:
+            job.description += ' (' + inflect_number(len(contests), 'soutěž', 'soutěže', 'soutěží') + ')'
     the_job.submit()
+    return True
+
+
+class Topic(Enum):
+    corrected = auto()
+    points = auto()
+    score = auto()
+    certificate = auto()
+
+
+topic_to_item = {
+    Topic.corrected: 'opravená řešení',
+    Topic.points: 'bodové ohodnocení úloh',
+    Topic.score: 'výsledkovou listinu',
+    Topic.certificate: 'diplomy',
+}
+
+
+@dataclass
+class NotifyUser:
+    user: db.User
+    school_id: int
+    topics: Set[Topic]
+
+
+@dataclass
+class NotifyStats:
+    num_total:int = 0
+    num_sent:int = 0
+    num_before:int = 0
+    num_skipped:int = 0
+    num_dont_want:int = 0
+    num_failed:int = 0
+
+    def merge_from(self, f: 'NotifyStats') -> None:
+        self.num_total += f.num_total
+        self.num_sent += f.num_sent
+        self.num_before += f.num_before
+        self.num_skipped += f.num_skipped
+        self.num_dont_want += f.num_dont_want
+        self.num_failed += f.num_failed
+
+    def format(self, is_teacher: bool) -> str:
+        if is_teacher:
+            base = inflect_number(self.num_total, 'zpráva pro učitele', 'zprávy pro učitele', 'zprávy pro učitelů')
+        else:
+            base = inflect_number(self.num_total, 'zpráva pro účastníka', 'zprávy pro účastníky', 'zprávy pro účastníků')
+
+        stats = []
+        if self.num_sent > 0:
+            stats.append(inflect_number(self.num_sent, 'odeslána', 'odeslány', 'odesláno'))
+        if self.num_before > 0:
+            stats.append(inflect_number(self.num_before, 'odeslána', 'odeslány', 'odesláno') + ' dříve')
+        if self.num_skipped > 0:
+            stats.append(inflect_number(self.num_skipped, 'prázdná vynechána', 'prázdné vynechány', 'prázdných vynecháno'))
+        if self.num_dont_want > 0:
+            stats.append(inflect_number(self.num_dont_want, 'nechce', 'nechtějí', 'nechce'))
+        if self.num_failed > 0:
+            stats.append(inflect_number(self.num_skipped, 'selhal', 'selhaly', 'selhalo'))
+
+        return base + ' (' + ', '.join(stats) + ')'
+
+
+class Notifier:
+    the_job: TheJob
+    round: db.Round
+    contest: db.Contest
+    new_state: db.RoundState
+    notify_teachers: bool
+
+    ct_notifies: Dict[int, NotifyUser]
+    teacher_notifies: Dict[int, NotifyUser]
+
+    ct_stats: NotifyStats
+    teacher_stats: NotifyStats
+
+    def __init__(self, the_job: TheJob, round: db.Round, contest: db.Contest, new_state: db.RoundState):
+        self.the_job = the_job
+        self.round = round
+        self.contest = contest
+        self.new_state = new_state
+        self.notify_teachers = new_state == db.RoundState.closed and round.level < 4
+        logger.debug(f'{self.the_job.log_prefix} Notifikace pro soutěž #{contest.contest_id} ({contest.place.name}), stav={new_state.name} teachers={self.notify_teachers}')
+
+    def run(self) -> None:
+        self.load_users()
+        self.find_topics()
+        if self.notify_teachers:
+            self.find_teachers()
+        self.ct_stats = self.notify(notifies=self.ct_notifies, email_key=f'{self.new_state.name}:{self.round.round_id}', sender=self.notify_contestant)
+        if self.notify_teachers:
+            self.teacher_stats = self.notify(notifies=self.teacher_notifies, email_key=f't-{self.new_state.name}:{self.round.round_id}', sender=self.notify_teacher)
+
+    def load_users(self) -> None:
+        sess = db.get_session()
+        q = (sess.query(db.User, db.Participant.school)
+             .select_from(db.Participation)
+             .filter(db.Participation.contest == self.contest,
+                     db.Participation.state == db.PartState.active)
+             .join(db.User, db.User.user_id == db.Participation.user_id)
+             .join(db.Participant, and_(db.Participant.user_id == db.User.user_id,
+                                        db.Participant.year == self.round.year)))
+
+        self.ct_notifies = {user.user_id: NotifyUser(user, school_id, set()) for user, school_id in q.all()}
+
+    def find_topics(self) -> None:
+        sess = db.get_session()
+
+        # Body a opravená řešení
+        sq = (sess.query(db.Solution)
+              .join(db.Task, db.Task.task_id == db.Solution.task_id)
+              .join(db.Round, db.Round.master_round_id == self.round.round_id)
+              .filter(db.Task.round_id == db.Round.round_id,
+                      db.Solution.user_id.in_(self.ct_notifies.keys())))
+        for sol in sq.all():
+            nu = self.ct_notifies[sol.user_id]
+            if sol.final_feedback is not None:
+                nu.topics.add(Topic.corrected)
+            if sol.points is not None:
+                nu.topics.add(Topic.points)
+
+        # Diplomy
+        cq = (sess.query(db.Certificate.user_id)
+              .join(db.Certificate.cert_file)
+              .filter(db.Certificate.cert_set_id == self.contest.contest_id,
+                      db.Certificate.user_id.in_(self.ct_notifies.keys()),
+                      db.CertFile.approved))
+        for user_id, in cq.all():
+            self.ct_notifies[user_id].topics.add(Topic.certificate)
+
+        # Výsledková listina
+        if self.contest.scoretable_id is not None:
+            for nu in self.ct_notifies.values():
+                nu.topics.add(Topic.score)
+
+    def find_teachers(self) -> None:
+        topics_by_school_id: DefaultDict[int, Set[Topic]] = defaultdict(set)
+        for nu in self.ct_notifies.values():
+            topics_by_school_id[nu.school_id] |= nu.topics & {Topic.score, Topic.certificate}
+        school_ids = list(topics_by_school_id.keys())
+
+        sess = db.get_session()
+        teachers_by_school_id: DefaultDict[int, Set[db.User]] = defaultdict(set)
+        query = mo.rights.filter_query_rights_for(sess.query(db.UserRole), place_ids=school_ids, cat=self.round.category)
+        for ur in query.options(joinedload(db.UserRole.user)).all():
+            teachers_by_school_id[ur.place_id].add(ur.user)
+
+        self.teacher_notifies = {}
+        for school_id, users in teachers_by_school_id.items():
+            for user in users:
+                self.teacher_notifies[user.user_id] = NotifyUser(user, school_id, set(topics_by_school_id[school_id]))
+
+    def notify(self, notifies: Dict[int, NotifyUser], email_key: str, sender: Callable[[NotifyUser], Optional[bool]]) -> NotifyStats:
+        stats = NotifyStats()
+        sess = db.get_session()
+
+        # Podobně jako u importů i zde budeme vytvářet malé transakce
+        sess.commit()
+
+        for nu in notifies.values():
+            stats.num_total += 1
+
+            topic_names = " ".join([t.name for t in nu.topics])
+            logger.debug(f'{self.the_job.log_prefix} Notify: key={email_key} user=#{nu.user.user_id} topics=<{topic_names}>')
+
+            if not nu.user.wants_notify:
+                stats.num_dont_want += 1
+                continue
+
+            # Musíme se zeptat až tady, protože to mezitím mohl poslat jiný proces.
+            sent = sess.query(db.SentEmail).filter_by(user=nu.user, key=email_key).with_for_update().one_or_none()
+            if sent:
+                stats.num_before += 1
+            else:
+                success = sender(nu)
+                if success is None:
+                    stats.num_skipped += 1
+                elif success:
+                    stats.num_sent += 1
+                    sent = db.SentEmail(user=nu.user, key=email_key)
+                    sess.add(sent)
+                else:
+                    stats.num_failed += 1
+
+            sess.commit()
+
+        return stats
+
+    def notify_contestant(self, nu: NotifyUser) -> Optional[bool]:
+        return self.notify_generic(nu, False)
+
+    def notify_teacher(self, nu: NotifyUser) -> Optional[bool]:
+        return self.notify_generic(nu, True)
+
+    def notify_generic(self, nu: NotifyUser, is_teacher: bool) -> Optional[bool]:
+        if not nu.topics:
+            return None
+
+        items = [topic_to_item[topic] for topic in sorted(nu.topics, key=lambda topic: topic.value)]
+
+        return mo.email.send_grading_info_email(
+            dest=nu.user,
+            round=self.round,
+            contest=self.contest,
+            is_teacher=is_teacher,
+            new_state=self.new_state,
+            items=items,
+        )
 
 
 @job_handler(db.JobType.send_grading_info)
 def handle_send_grading_info(the_job: TheJob):
     job = the_job.job
     assert job.in_json is not None
-    round_id: int = job.in_json['round_id']  # type: ignore
-    contest_id: int = job.in_json['contest_id']  # type: ignore
+    with Walker(job.in_json).enter_object() as w:
+        round_id = w['round_id'].as_int()
+        if w['contest_ids'].is_null():
+            contest_ids = None
+        else:
+            contest_ids = [x.as_int() for x in w['contest_ids'].array_values()]
+        new_state = db.RoundState[w['new_state'].as_str()]
 
     sess = db.get_session()
     round = sess.query(db.Round).get(round_id)
@@ -48,49 +280,25 @@ def handle_send_grading_info(the_job: TheJob):
         the_job.error('Kolo nebylo nalezeno')
         return
 
-    email_key = f'graded:{round_id}'
-    todo = (sess.query(db.User, db.SentEmail)
-            .select_from(db.Contest)
-            .filter(db.Contest.round_id == round.master_round_id))
-    if contest_id is not None:
-        todo = todo.filter(db.Contest.contest_id == contest_id)
-    todo = (todo
-            .join(db.Participation, db.Participation.contest_id == db.Contest.contest_id)
-            .join(db.User, db.User.user_id == db.Participation.user_id)
-            .outerjoin(db.SentEmail, and_(db.SentEmail.user_id == db.User.user_id, db.SentEmail.key == email_key))
-            .all())
-
-    # Podobně jako u importů i zde budeme vytvářet malé transakce
+    ct_q = sess.query(db.Contest)
+    if contest_ids is None:
+        ct_q = ct_q.filter(db.Contest.round_id == round.round_id)
+    else:
+        ct_q = ct_q.filter(db.Contest.contest_id.in_(contest_ids))
+    ct_q = ct_q.options(joinedload(db.Contest.place))
+
     sess.commit()
 
-    num_total = 0
-    num_sent = 0
-    num_before = 0
-    num_dont_want = 0
-
-    for user, sent in todo:
-        num_total += 1
-        if not user.wants_notify:
-            num_dont_want += 1
-        elif sent:
-            num_before += 1
-        else:
-            # Musíme se zeptat znovu, protože to mezitím mohl poslat jiný proces.
-            sent = sess.query(db.SentEmail).filter_by(user=user, key=email_key).with_for_update().one_or_none()
-            if sent:
-                num_before += 1
-            else:
-                mo.email.send_grading_info_email(user, round)
-                sent = db.SentEmail(user=user, key=email_key)
-                sess.add(sent)
-                num_sent += 1
-        sess.commit()
+    ct_stats = NotifyStats()
+    teacher_stats = NotifyStats()
+
+    for ct in ct_q.all():
+        n = Notifier(the_job, round, ct, new_state)
+        n.run()
+        ct_stats.merge_from(n.ct_stats)
+        if n.notify_teachers:
+            teacher_stats.merge_from(n.teacher_stats)
 
-    stats = []
-    if num_sent > 0:
-        stats.append(inflect_number(num_sent, 'odeslána', 'odeslány', 'odesláno'))
-    if num_before > 0:
-        stats.append(inflect_number(num_before, 'odeslána', 'odeslány', 'odesláno') + ' dříve')
-    if num_dont_want > 0:
-        stats.append(inflect_number(num_dont_want, 'účastník nechce', 'účastníci nechtějí', 'účastníků nechce'))
-    job.result = 'Celkem ' + inflect_number(num_total, 'zpráva', 'zprávy', 'zpráv') + ': ' + ', '.join(stats) + '.'
+    job.result = ct_stats.format(is_teacher=False)
+    if teacher_stats.num_total > 0:
+        job.result += ', ' + teacher_stats.format(is_teacher=True)
diff --git a/mo/web/org_certs.py b/mo/web/org_certs.py
index 8b3f901d..8af0e0c9 100644
--- a/mo/web/org_certs.py
+++ b/mo/web/org_certs.py
@@ -384,6 +384,7 @@ def org_school_results(school_id: int):
     )
 
 
+# URL je zadrátované do mo.email.send_grading_info_email
 @app.route('/org/contest/school-results/')
 def org_school_results_all():
     school_place_ids = set(ur.place_id for ur in g.gatekeeper.roles if ur.role == db.RoleType.garant_skola)
diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py
index e6764f95..57062dc7 100644
--- a/mo/web/org_contest.py
+++ b/mo/web/org_contest.py
@@ -1790,8 +1790,7 @@ def org_contest_edit(ct_id: int):
             sess.commit()
             flash('Změny soutěže uloženy.', 'success')
 
-            if 'state' in changes and contest.state == db.RoundState.graded:
-                mo.jobs.notify.schedule_send_grading_info(round, contest, g.user)
+            if 'state' in changes and mo.jobs.notify.schedule_send_grading_info(round, [contest], contest.state, g.user):
                 flash('Založena dávka na rozeslání e-mailů účastníkům.', 'success')
         else:
             flash('Žádné změny k uložení.', 'info')
diff --git a/mo/web/org_round.py b/mo/web/org_round.py
index a7f78bab..b0b9dfc5 100644
--- a/mo/web/org_round.py
+++ b/mo/web/org_round.py
@@ -481,8 +481,7 @@ def org_round_edit(round_id: int):
             sess.commit()
             flash('Změny kola uloženy.', 'success')
 
-            if 'state' in changes and round.state == db.RoundState.graded:
-                mo.jobs.notify.schedule_send_grading_info(round, None, g.user)
+            if 'state' in changes and mo.jobs.notify.schedule_send_grading_info(round, None, round.state, g.user):
                 flash('Založena dávka na rozeslání e-mailů účastníkům.', 'success')
         else:
             flash('Žádné změny k uložení.', 'info')
@@ -808,6 +807,8 @@ def org_close_contests(round_id: int, hier_id: Optional[int] = None):
 
             sess.commit()
             flash(inflect_with_number(len(contests), 'Ukončena %s soutěž.', 'Ukončeny %s soutěže.', 'Ukončeno %s soutěží.'), 'success')
+            if mo.jobs.notify.schedule_send_grading_info(ctx.round, contests, db.RoundState.closed, g.user):
+                flash('Založena dávka na rozeslání e-mailů účastníkům.', 'success')
             return redirect(ctx.url_for('org_round'))
 
     num_contests = db.get_count(contests_q)
diff --git a/mo/web/user.py b/mo/web/user.py
index 61cd6812..be5f57f5 100644
--- a/mo/web/user.py
+++ b/mo/web/user.py
@@ -325,6 +325,7 @@ def get_task(contest: db.Contest, id: int) -> db.Task:
     return task
 
 
+# URL je zadrátované do mo.email.send_grading_info_email
 @app.route('/user/contest/<int:id>/')
 def user_contest(id: int):
     sess = db.get_session()
-- 
GitLab