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