diff --git a/.gitignore b/.gitignore index 0fc3f2c5f475e40bdca9e34d25a379b5a9cda986..6408f0dc1d6a9d7bb92a20f8ffa3294b03cf3f54 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,9 @@ __pycache__ .mypy_cache .*.swp /data +/data-test /extra /mo/config.py /osmo.egg-info +/test-chrome /venv diff --git a/mo/contests.py b/mo/contests.py new file mode 100644 index 0000000000000000000000000000000000000000..dad4ba3ae0def881d2c45bf93a3cacfc599482de --- /dev/null +++ b/mo/contests.py @@ -0,0 +1,66 @@ +# Pomocné funkce pro práci se soutěžemi + +from typing import Optional + +import mo.db as db +from mo.rights import Right, Gatekeeper +import mo.util + + +class ContestError(RuntimeError): + pass + + +def add_contest(master_round: db.Round, place: db.Place, reason: str, gatekeeper: Optional[Gatekeeper] = None): + # Počáteční stav soutěže + if master_round.state != db.RoundState.delegate: + state = master_round.state + else: + state = db.RoundState.running + + # Soutěž vytvoříme vždy v hlavním kole + contest = db.Contest(round=master_round, place=place, state=state) + if gatekeeper is not None: + rr = gatekeeper.rights_for_contest(contest) + if not rr.have_right(Right.add_contest): + raise ContestError(f'Vaše role nedovoluje vytvořit soutěž {place.name_locative()}') + + sess = db.get_session() + sess.add(contest) + sess.flush() + contest.master_contest_id = contest.contest_id + sess.add(contest) + sess.flush() + + mo.util.log( + type=db.LogType.contest, + what=contest.contest_id, + details={'action': 'add', 'contest': db.row2dict(contest), 'reason': reason}, + ) + mo.util.logger.info(f"{reason.title()}: Soutěž #{contest.contest_id} založena: {db.row2dict(contest)}") + + create_subcontests(master_round, contest, reason) + + +def create_subcontests(master_round: db.Round, master_contest: db.Contest, reason: str): + if master_round.part == 0: + return + + sess = db.get_session() + subrounds = master_round.get_group_rounds() + for subround in subrounds: + subcontest = db.Contest( + round_id=subround.round_id, + master_contest_id=master_contest.contest_id, + place_id=master_contest.place_id, + state=master_contest.state, + ) + sess.add(subcontest) + sess.flush() + + mo.util.log( + type=db.LogType.contest, + what=subcontest.contest_id, + details={'action': 'add', 'contest': db.row2dict(subcontest), 'reason': reason}, + ) + mo.util.logger.info(f"{reason.title()}: Podsoutěž #{subcontest.contest_id} založena: {db.row2dict(subcontest)}") diff --git a/mo/imports.py b/mo/imports.py index 5833b3a4039a0bd5c94117f267f3322d0137bbb7..d6f738b67137f2b1a36b4c6b078ee2d0697f0d29 100644 --- a/mo/imports.py +++ b/mo/imports.py @@ -7,6 +7,7 @@ from sqlalchemy import and_ from sqlalchemy.orm import joinedload, Query from typing import List, Optional, Any, Dict, Type, Union, Tuple +import mo.contests import mo.csv from mo.csv import FileFormat, MissingHeaderError import mo.db as db @@ -27,6 +28,7 @@ class Import: cnt_new_participants: int = 0 cnt_new_participations: int = 0 cnt_new_roles: int = 0 + cnt_new_contests: int = 0 cnt_set_points: int = 0 cnt_add_sols: int = 0 cnt_del_sols: int = 0 @@ -49,6 +51,7 @@ class Import: log_msg_prefix: str log_details: Any allow_change_user_to_org: bool = False # pro Import orgů: je povoleno vyrobit orga z účastníka + allow_create_contests: bool = False # pro Import účastníků: je povoleno zakládat soutěže # Interní: Stav importu place_cache: Dict[str, db.Place] @@ -93,7 +96,7 @@ class Import: try: # XXX: Zde si nemůžeme dovolit kontrolovat existenci domén, # protože import by byl příliš pomalý. Možná z něj jednou uděláme job... - return mo.users.normalize_email(email) + return mo.users.normalize_email(email, make_unique_nomail=True) except mo.CheckError as e: return self.error(str(e)) @@ -263,7 +266,16 @@ class Import: return None contest = db.get_session().query(db.Contest).filter_by(round=round, place=oblast).one_or_none() if contest is None: - return self.error('V uvedené oblasti toto kolo neprobíhá') + if self.allow_create_contests: + try: + mo.contests.add_contest(round.master, oblast, reason='import', gatekeeper=self.gatekeeper) + self.cnt_new_contests += 1 + except mo.contests.ContestError as e: + return self.error(str(e)) + # Je jednodušší contest znovu najít, než ho vyhrabat ze zakládání soutěží ve skupině kol + contest = db.get_session().query(db.Contest).filter_by(round=round, place=oblast).one() + else: + return self.error('V uvedené oblasti toto kolo neprobíhá') return contest @@ -468,7 +480,8 @@ class ContestImport(Import): round: db.Round, contest: Optional[db.Contest] = None, only_region: Optional[db.Place] = None, - default_place: Optional[db.Place] = None + default_place: Optional[db.Place] = None, + allow_create_contests: bool = False ): super().__init__(user) self.user = user @@ -476,6 +489,7 @@ class ContestImport(Import): self.contest = contest self.only_region = only_region self.default_place = default_place + self.allow_create_contests = allow_create_contests assert not self.round.is_subround() def import_row(self, r: mo.csv.Row) -> None: @@ -515,7 +529,7 @@ class ContestImport(Import): self.find_or_create_participation(user, contest, misto) def get_after_import_message(self) -> str: - return f'Importováno ({self.cnt_rows} řádků, založeno {self.cnt_new_users} uživatelů, {self.cnt_new_participations} účastí, {self.cnt_new_roles} rolí)' + return f'Importováno ({self.cnt_rows} řádků, založeno {self.cnt_new_users} uživatelů, {self.cnt_new_participations} účastí, {self.cnt_new_contests} soutěží)' @dataclass diff --git a/mo/users.py b/mo/users.py index 7c9f887e7846951776983e512c0c41bc09b1866c..bbfedf7f79469253d0576da0c69b93f68e1b4efe 100644 --- a/mo/users.py +++ b/mo/users.py @@ -1,5 +1,6 @@ # Správa uživatelů +import base64 import bcrypt import datetime import dateutil.tz @@ -292,10 +293,16 @@ def email_check_domain(domain: str): raise mo.CheckError(f'Doména {domain} nepřijímá poštu') -def normalize_email(addr: str, check_existence: bool = False) -> str: - if not re.fullmatch(r'.+@.+', addr): +def normalize_email(addr: str, check_existence: bool = False, make_unique_nomail: bool = False) -> str: + if make_unique_nomail and addr.endswith('@nomail'): + addr = base64.b32encode(secrets.token_bytes(10)).decode('US-ASCII').lower() + '@nomail' + + if '@' not in addr: raise mo.CheckError('V e-mailové adrese chybí zavináč') + if not re.fullmatch(r'.*@.+', addr): + raise mo.CheckError('E-mailová adresa nesmí ani začínat, ani končit zavináčem') + if re.search(r'[ \t]', addr): raise mo.CheckError('E-mailová adresa obsahuje mezeru') diff --git a/mo/web/fields.py b/mo/web/fields.py index 4c6ce5f57e226441d64a60a2fdca4c6f8437ef10..2f939534aed136830cb28b28e2287a30eef1fb4e 100644 --- a/mo/web/fields.py +++ b/mo/web/fields.py @@ -73,17 +73,19 @@ class Points(Decimal): super().__init__(label, validators, **kwargs) -class Email(Stripped, EmailField): +class Email(String): check_existence: bool + make_unique_nomail: bool - def __init__(self, label="E-mail", validators=None, check_existence: bool = False, **kwargs): + def __init__(self, label="E-mail", validators=None, check_existence: bool = False, make_unique_nomail: bool = False, **kwargs): self.check_existence = check_existence + self.make_unique_nomail = make_unique_nomail super().__init__(label, validators, **kwargs) def pre_validate(field, form): if field.data: try: - field.data = mo.users.normalize_email(field.data, check_existence=field.check_existence) + field.data = mo.users.normalize_email(field.data, check_existence=field.check_existence, make_unique_nomail=field.make_unique_nomail) except mo.CheckError as e: raise wtforms.ValidationError(str(e)) diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py index fea5b6e71c70f4b4fa7d399f3b63af07689d685b..64d109fbe3605c32ca1d89258097dc794c99ece3 100644 --- a/mo/web/org_contest.py +++ b/mo/web/org_contest.py @@ -486,7 +486,7 @@ def org_contest(ct_id: int, site_id: Optional[int] = None): class ContestantImportForm(ImportForm): - pass + allow_create_contests = wtforms.BooleanField("Povolit zakládání nových soutěží") @app.route('/org/contest/c/<int:ct_id>/import-contestant', methods=('GET', 'POST')) @@ -499,6 +499,9 @@ def org_import_user(round_id: Optional[int] = None, hier_id: Optional[int] = Non default_place = contest.place if contest else ctx.hier_place form = ContestantImportForm() + if ctx.contest is not None: + del form.allow_create_contests + imp = None if form.validate_on_submit(): imp = ContestImport( @@ -507,6 +510,7 @@ def org_import_user(round_id: Optional[int] = None, hier_id: Optional[int] = Non contest=contest, only_region=ctx.hier_place, default_place=default_place, + allow_create_contests=form.allow_create_contests.data if form.allow_create_contests is not None else False ) return generic_import_page( form, imp, ctx.url_home(), @@ -1809,7 +1813,7 @@ def check_contest_state(round: db.Round, contest: Optional[db.Contest], state: d class ParticipantAddForm(FlaskForm): - email = mo_fields.Email(validators=[validators.DataRequired()], check_existence=True) + email = mo_fields.Email(validators=[validators.DataRequired()], check_existence=True, make_unique_nomail=True) first_name = mo_fields.FirstName(validators=[validators.Optional()]) last_name = mo_fields.LastName(validators=[validators.Optional()]) school = mo_fields.School(validators=[validators.Optional()]) @@ -1819,7 +1823,8 @@ class ParticipantAddForm(FlaskForm): save = wtforms.SubmitField("Přidat") def set_descriptions(self, contest: db.Contest, place_desc: bool): - self.email.description = "Nepoužívejte prosím e-mailové adresy ve školních doménách, na které nejde posílat pošta z veřejné sítě." + self.email.description = ("Nepoužívejte prosím e-mailové adresy ve školních doménách, na které nejde posílat pošta z veřejné sítě. " + + "Pokud zatím neznáte e-mail, zadejte @nomail, ale pak adresu doplňte.") self.school.description = f'Kód školy najdete v <a href="{url_for("org_place", id=contest.place.place_id)}">katalogu míst</a>.' if place_desc: self.participation_place.description = f'Pokud účastník soutěží někde jinde než {contest.place.name_locative()}, vyplňte <a href="{url_for("org_place", id=contest.place.place_id)}">kód místa</a>. Dozor na tomto místě pak může za účastníka odevzdávat řešení.' diff --git a/mo/web/org_round.py b/mo/web/org_round.py index 89fb407c5894dc1bbf3e2bc8c585e6545f9d53fe..3c75a7522beb7fef940d5a9f0325321ca3d579da 100644 --- a/mo/web/org_round.py +++ b/mo/web/org_round.py @@ -17,6 +17,7 @@ from wtforms import validators from wtforms.widgets import NumberInput import mo.config as config +import mo.contests import mo.db as db import mo.imports import mo.jobs.notify @@ -103,7 +104,10 @@ def add_contest(round: db.Round, form: AddContestForm) -> bool: flash(f'Pro {place.type_name()} {place.name} už toto kolo existuje', 'danger') return False - if not do_add_contest(round.master, place, True): + try: + mo.contests.add_contest(round.master, place, reason='web', gatekeeper=g.gatekeeper) + except mo.contests.ContestError as e: + flash(str(e), 'danger') return False sess.commit() @@ -111,64 +115,6 @@ def add_contest(round: db.Round, form: AddContestForm) -> bool: return True -def do_add_contest(master_round: db.Round, place: db.Place, check_rights: bool) -> bool: - # Počáteční stav soutěže - if master_round.state != db.RoundState.delegate: - state = master_round.state - else: - state = db.RoundState.preparing - - # Soutěž vytvoříme vždy v hlavním kole - contest = db.Contest(round=master_round, place=place, state=state) - if check_rights: - rr = g.gatekeeper.rights_for_contest(contest) - if not rr.have_right(Right.add_contest): - flash(f'Vaše role nedovoluje vytvořit soutěž {place.name_locative()}', 'danger') - return False - - sess = db.get_session() - sess.add(contest) - sess.flush() - contest.master_contest_id = contest.contest_id - sess.add(contest) - sess.flush() - - mo.util.log( - type=db.LogType.contest, - what=contest.contest_id, - details={'action': 'add', 'contest': db.row2dict(contest)}, - ) - app.logger.info(f"Soutěž #{contest.contest_id} založena: {db.row2dict(contest)}") - - create_subcontests(master_round, contest) - return True - - -# XXX: Používá se i v registraci účastníků -def create_subcontests(master_round: db.Round, master_contest: db.Contest): - if master_round.part == 0: - return - - sess = db.get_session() - subrounds = master_round.get_group_rounds() - for subround in subrounds: - subcontest = db.Contest( - round_id=subround.round_id, - master_contest_id=master_contest.contest_id, - place_id=master_contest.place_id, - state=master_contest.state, - ) - sess.add(subcontest) - sess.flush() - - mo.util.log( - type=db.LogType.contest, - what=subcontest.contest_id, - details={'action': 'add', 'contest': db.row2dict(subcontest)}, - ) - app.logger.info(f"Podsoutěž #{subcontest.contest_id} založena: {db.row2dict(subcontest)}") - - @dataclass class ContestStat: region: db.Place @@ -696,8 +642,7 @@ def org_round_create_contests(round_id: int): form = CreateContestsForm() if form.validate_on_submit(): for place in new_places: - ok = do_add_contest(round, place, False) - assert ok + mo.contests.add_contest(round, place, reason='web') sess.commit() flash(inflect_with_number(len(new_places), 'Založena %s soutěž.', 'Založeny %s soutěže.', 'Založeno %s soutěží.'), 'success') return redirect(ctx.url_for('org_round')) diff --git a/mo/web/org_users.py b/mo/web/org_users.py index 4fabde7c9a03aa0a4b4fb66a04ce740ef08bb7ad..d09b600ecd4e14a307d26e0a57c033f58a49d344 100644 --- a/mo/web/org_users.py +++ b/mo/web/org_users.py @@ -412,7 +412,8 @@ def org_user(id: int): class UserEditForm(FlaskForm): first_name = mo_fields.FirstName(validators=[DataRequired()], render_kw={'autofocus': True}) last_name = mo_fields.LastName(validators=[DataRequired()]) - email = mo_fields.Email(validators=[DataRequired()], check_existence=True) + email = mo_fields.Email(validators=[DataRequired()], check_existence=True, make_unique_nomail=True, + description="Pokud zadáte @nomail, vznikne účet bez e-mailové adresy, ke kterému se ale nepůjde přihlásit.") note = wtforms.TextAreaField("Poznámka") is_test = wtforms.BooleanField("Testovací účet") email_notify = wtforms.BooleanField("Mailové notifikace") diff --git a/mo/web/templates/doc_import.html b/mo/web/templates/doc_import.html index db1b61978c1f844ef5e0ce01c8bb2e3a1f40b646..6caa1f40f5d67f3fda807e684b09314c614652c1 100644 --- a/mo/web/templates/doc_import.html +++ b/mo/web/templates/doc_import.html @@ -34,7 +34,7 @@ když přidáte vlastní sloupce s novými názvy, budou se ignorovat. nebo speciálního soutěžního místa, kde se soutěž koná. Dozor na soutěžním místě má pak právo odevzdávat za účastníka řešení. <tr><td>kod_oblasti<td>Pokud neimportujete do konkrétní soutěže, ale do celého kola, - je nutné uvést kód oblasti, ve které účastník soutěží. + je nutné uvést kód oblasti (školy, okresu, kraje apod.), kde které účastník soutěží. </table> <p>Importovaní účastníci se přidají ke stávajícím. Duplicity se ignorují. V případě diff --git a/mo/web/templates/doc_org.html b/mo/web/templates/doc_org.html index eaf241c3a0c10e48b86140e2e773d3c3c2106373..a6f70c79417ef903500284e12f96909661bcac56 100644 --- a/mo/web/templates/doc_org.html +++ b/mo/web/templates/doc_org.html @@ -161,7 +161,7 @@ Přesněji řečeno každé kolo může mít nastaven jeden ze tří režimů re <p>Do vyšších kol obvykle účastníky nepřihlašujeme přímo, ale používáme tlačítko „Postup z minulého kola“ na stránce soutěže. -<h3>Import účastníku</h3> +<h3>Import účastníků</h3> <p>Garanti mohou přihlašovat účastníky do soutěže <b>importem</b> souboru ve formátu CSV. Tento soubor můžete vyrobit v Excelu či jiném tabulkovém kalkulátoru a pak do CSV exportovat. @@ -260,6 +260,7 @@ to učinit pro sdružené výsledkové listiny v hierarchii soutěžního kola). <p>Po uložení verze výsledkové listiny ji může garant soutěže <b>zveřejnit</b>. Tím se výsledková listina zobrazí soutěžícím v jejich rozhraní, pokud je soutěž -ve stavu <i>ukončeno</i>. +ve stavu <i>ukončeno</i>. V okresních a vyšších kolech kategorií Z a A-C se +listina také zveřejní na webových stránkách MO. {% endblock %} diff --git a/mo/web/templates/org_contestants_import.html b/mo/web/templates/org_contestants_import.html index 82d27d7e2ca53cd63d8e6955d6f3194cd89863e4..12da9a298e1bcc23d41511e8a8828353f8066980 100644 --- a/mo/web/templates/org_contestants_import.html +++ b/mo/web/templates/org_contestants_import.html @@ -21,4 +21,7 @@ kód oblasti. Nechcete raději importovat do konkrétní oblasti?</em></p> {% block import_form %} + {% if form.allow_create_contests %} + {{ wtf.form_field(form.allow_create_contests) }} + {% endif %} {% endblock %} diff --git a/mo/web/templates/org_org.html b/mo/web/templates/org_org.html index 00fc4fb2f55f513a9b92997371a7f3108c66d1d2..48c8d3e5165ec7f1247b4864ff30712c7180232c 100644 --- a/mo/web/templates/org_org.html +++ b/mo/web/templates/org_org.html @@ -48,6 +48,8 @@ <p>Můžete přidělit jen roli, která je podmnožinou nějaké vaší role (včetně omezení na oblast, kolo, …).</p> <p>Pokud roli omezíte na kategorii <code>Z</code>, bude fungovat pro všechny kategorie začínající na <code>Z</code>. Podobně <code>S</code> znamená všechny středoškolské kategorie <code>A</code>, <code>B</code>, <code>C</code>. +<p>Typický krajský garant má roli omezenou jen na kraj, typický okresní na okres a kategorii Z. +Opravovatelé a dozor mívají navíc omezení na ročník/kolo. {% if role_errors %} <div class="alert alert-danger" role="alert"> {{ role_errors|join(Markup("<br>")) }} diff --git a/mo/web/user.py b/mo/web/user.py index 677a09e735a2a936a6a3f5b62b8aa91da294e600..c5d0c48c45edbda0f5468a417548f4f5a38859fe 100644 --- a/mo/web/user.py +++ b/mo/web/user.py @@ -12,6 +12,7 @@ import werkzeug.exceptions import wtforms from wtforms.validators import DataRequired +import mo.contests import mo.config as config import mo.email import mo.db as db @@ -208,7 +209,7 @@ def join_create_contest(round: db.Round, pant: db.Participant) -> db.Contest: details={'action': 'created', 'reason': 'user-join'}, ) - mo.web.org_round.create_subcontests(round, c) + mo.contests.create_subcontests(round, c, reason='user-join') return c