diff --git a/mo/users.py b/mo/users.py index e71d9724eabda491c235cf893c0c964e8794d221..b57c6e0557861c16c31f7ac9afb63089d2d94aeb 100644 --- a/mo/users.py +++ b/mo/users.py @@ -7,6 +7,7 @@ import email.errors import email.headerregistry import re import secrets +from sqlalchemy.dialects.postgresql import insert as pgsql_insert from typing import Optional, Tuple import mo @@ -96,40 +97,57 @@ def change_user_to_org(user, reason: str): def find_or_create_user(email: str, krestni: Optional[str], prijmeni: Optional[str], is_org: bool, reason: str, allow_change_user_to_org=False) -> Tuple[db.User, bool, bool]: sess = db.get_session() - user = sess.query(db.User).with_for_update().filter_by(email=email).one_or_none() - is_new = user is None + user = sess.query(db.User).filter_by(email=email).one_or_none() + is_new = False is_change_user_to_org = False + if user is None: # HACK: Podmínku je nutné zapsat znovu místo užití is_new, jinak si s tím mypy neporadí if not krestni or not prijmeni: raise mo.CheckError('Osoba s daným e-mailem zatím neexistuje, je nutné uvést její jméno.') - user = db.User(email=email, first_name=krestni, last_name=prijmeni, is_org=is_org) - sess.add(user) - sess.flush() # Aby uživatel dostal user_id - logger.info(f'{reason.title()}: Založen uživatel user=#{user.user_id} email=<{user.email}>') - mo.util.log( - type=db.LogType.user, - what=user.user_id, - details={'action': 'create-user', 'reason': reason, 'new': db.row2dict(user)}, + + res = sess.connection().execute( + pgsql_insert(db.User.__table__) + .values( + email=email, + first_name=krestni, + last_name=prijmeni, + is_org=is_org, + ) + .on_conflict_do_nothing() + .returning(db.User.user_id) ) - else: - if (krestni and user.first_name != krestni) or (prijmeni and user.last_name != prijmeni): - raise mo.CheckError(f'Osoba již registrována s odlišným jménem {user.full_name()}') - if (user.is_admin or user.is_org) != is_org: - if is_org: - if allow_change_user_to_org: - change_user_to_org(user, reason) - is_change_user_to_org = True - else: - raise CheckErrorOrgIsUser('Nelze předefinovat účastníka na organizátora.') + + user = sess.query(db.User).filter_by(email=email).one() + + if res.fetchall(): + is_new = True + logger.info(f'{reason.title()}: Založen uživatel user=#{user.user_id} email=<{user.email}>') + mo.util.log( + type=db.LogType.user, + what=user.user_id, + details={'action': 'create-user', 'reason': reason, 'new': db.row2dict(user)}, + ) + + if (krestni and user.first_name != krestni) or (prijmeni and user.last_name != prijmeni): + raise mo.CheckError(f'Osoba již registrována s odlišným jménem {user.full_name()}') + if (user.is_admin or user.is_org) != is_org: + if is_org: + if allow_change_user_to_org: + change_user_to_org(user, reason) + is_change_user_to_org = True else: - raise mo.CheckError('Nelze předefinovat organizátora na účastníka.') + raise CheckErrorOrgIsUser('Nelze předefinovat účastníka na organizátora.') + else: + raise mo.CheckError('Nelze předefinovat organizátora na účastníka.') + return user, is_new, is_change_user_to_org def find_or_create_participant(user: db.User, year: int, school_id: Optional[int], birth_year: Optional[int], grade: Optional[str], reason: str) -> Tuple[db.Participant, bool]: sess = db.get_session() part = sess.query(db.Participant).get((user.user_id, year)) - is_new = part is None + is_new = False + if part is None: prev_part = sess.query(db.Participant).filter_by(user_id=user.user_id).order_by(db.Participant.year.desc()).limit(1).one_or_none() if not school_id: @@ -144,20 +162,37 @@ def find_or_create_participant(user: db.User, year: int, school_id: Optional[int raise mo.CheckError('Osoba s daným e-mailem zatím není zaregistrovaná do ročníku, je nutné uvést rok narození.') if not grade: raise mo.CheckError('Osoba s daným e-mailem zatím není zaregistrovaná do ročníku, je nutné uvést ročník.') - part = db.Participant(user=user, year=year, school=school_id, birth_year=birth_year, grade=grade) - sess.add(part) - sess.flush() # Kvůli logování - logger.info(f'{reason.title()}: Založen účastník #{user.user_id}') - mo.util.log( - type=db.LogType.participant, - what=user.user_id, - details={'action': 'create-participant', 'reason': reason, 'new': db.row2dict(part)}, + + res = sess.connection().execute( + pgsql_insert(db.Participant.__table__) + .values( + user_id=user.user_id, + year=year, + school=school_id, + birth_year=birth_year, + grade=grade, + ) + .on_conflict_do_nothing() + .returning(db.Participant.user_id) ) - else: - if ((school_id and part.school != school_id) - or (grade and part.grade != grade) - or (birth_year and part.birth_year != birth_year)): - raise mo.CheckError('Účastník již zaregistrován s odlišnou školou/ročníkem/rokem narození') + + part = sess.query(db.Participant).get((user.user_id, year)) + assert part is not None + + if res.fetchall(): + is_new = True + logger.info(f'{reason.title()}: Založen účastník #{user.user_id}') + mo.util.log( + type=db.LogType.participant, + what=user.user_id, + details={'action': 'create-participant', 'reason': reason, 'new': db.row2dict(part)}, + ) + + if ((school_id and part.school != school_id) + or (grade and part.grade != grade) + or (birth_year and part.birth_year != birth_year)): + raise mo.CheckError('Účastník již zaregistrován s odlišnou školou/ročníkem/rokem narození') + return part, is_new @@ -166,28 +201,47 @@ def find_or_create_participation(user: db.User, contest: db.Contest, place: Opti place = contest.place sess = db.get_session() - pions = (sess.query(db.Participation) - .filter_by(user=user) - .filter(db.Participation.contest.has(db.Contest.round == contest.round)) - .all()) + pion = None + is_new = False + retry = False + + while pion is None: + pions = (sess.query(db.Participation) + .filter_by(user=user) + .filter(db.Participation.contest.has(db.Contest.round == contest.round)) + .all()) + + if len(pions) == 0: + assert not retry + retry = True + res = sess.connection().execute( + pgsql_insert(db.Participation.__table__) + .values( + user_id=user.user_id, + contest_id=contest.contest_id, + place_id=place.place_id, + state=db.PartState.active, + ) + .on_conflict_do_nothing() + .returning(db.Participation.user_id) + ) + if res.fetchall(): + is_new = True + elif len(pions) == 1: + pion = pions[0] + else: + raise mo.CheckError('Již se tohoto kola účastní ve více oblastech, což by nemělo být možné') + + if pion.place != place: + raise mo.CheckError(f'Již se tohoto kola účastní v {contest.round.get_level().name_locative("jiném", "jiné", "jiném")} ({pion.place.get_code()})') - is_new = pions == [] if is_new: - pion = db.Participation(user=user, contest=contest, place_id=place.place_id, state=db.PartState.active) - sess.add(pion) - sess.flush() # Kvůli logování logger.info(f'{reason.title()}: 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', 'reason': reason, 'new': db.row2dict(pion)}, ) - elif len(pions) == 1: - pion = pions[0] - if pion.place != place: - raise mo.CheckError(f'Již se tohoto kola účastní v {contest.round.get_level().name_locative("jiném", "jiné", "jiném")} ({pion.place.get_code()})') - else: - raise mo.CheckError('Již se tohoto kola účastní ve více oblastech, což by nemělo být možné') return pion, is_new diff --git a/mo/web/acct.py b/mo/web/acct.py index d1497402b84a89a497b8f963d7b6c99c1f95b340..24f34bc2a38ac17937c03e88e57dd99a186e9365 100644 --- a/mo/web/acct.py +++ b/mo/web/acct.py @@ -193,19 +193,23 @@ def user_settings_personal(): sess.commit() flash('Heslo změněno.', 'success') if form.email.data != user.email: - rr = mo.users.new_reg_request(db.RegReqType.change_email, request.remote_addr) - if rr: - rr.user_id = user.user_id - rr.email = form.email.data - sess.add(rr) - sess.commit() - app.logger.info(f'Settings: Požadavek na změnu e-mailu uživatele #{user.user_id}') - flash('Odeslán e-mail s odkazem na potvrzení nové adresy.', 'success') - mo.email.send_confirm_change_email(user, rr.email_token) - else: - app.logger.info('Settings: Rate limit') - flash('Příliš mnoho požadavků na změny e-mailu. Počkejte prosím chvíli a zkuste to znovu.', 'danger') + if mo.users.user_by_email(form.email.data) is not None: + flash('Tuto e-mailovou adresu už používá jiný uživatel.', 'danger') ok = False + else: + rr = mo.users.new_reg_request(db.RegReqType.change_email, request.remote_addr) + if rr: + rr.user_id = user.user_id + rr.email = form.email.data + sess.add(rr) + sess.commit() + app.logger.info(f'Settings: Požadavek na změnu e-mailu uživatele #{user.user_id}') + flash('Odeslán e-mail s odkazem na potvrzení nové adresy.', 'success') + mo.email.send_confirm_change_email(user, rr.email_token) + else: + app.logger.info('Settings: Rate limit') + flash('Příliš mnoho požadavků na změny e-mailu. Počkejte prosím chvíli a zkuste to znovu.', 'danger') + ok = False if ok: return redirect(url_for('user_settings')) @@ -436,6 +440,7 @@ class Reg2: RegStatus.new: 'Chybný potvrzovací kód. Zkontrolujte, že jste odkaz z e-mailu zkopírovali správně.', RegStatus.expired: 'Vypršela platnost potvrzovacího kódu, požádejte prosím o změnu e-mailu znovu.', RegStatus.already_spent: 'Tento odkaz na potvrzení změny e-mailu byl již využit.', + RegStatus.already_exists: 'Tuto adresu už použivá jiný účet.', }, db.RegReqType.reset_passwd: { RegStatus.new: 'Chybný kód pro obnovení hesla. Zkontrolujte, že jste odkaz z e-mailu zkopírovali správně.', @@ -498,38 +503,37 @@ class Reg2: email = mo.users.normalize_email(rr.email) # Pro jistotu sess = db.get_session() - if db.get_session().query(db.User).with_for_update().filter_by(email=email).one_or_none(): + try: + user, is_new, _ = mo.users.find_or_create_user(email, first_name, last_name, is_org=False, reason='register') + except mo.CheckError as e: + app.logger.info(f'Reg2: Založení účtu {email} selhalo: {e}') + self.status = RegStatus.already_exists + return False + + if not is_new: # Účet mohl začít existovat mezi 1. a 2. krokem registrace app.logger.info(f'Reg2: Účet s e-mailem {email} začal během registrace existovat') self.status = RegStatus.already_exists return False - user = db.User( - email=email, - first_name=first_name, - last_name=last_name, - ) mo.users.set_password(user, passwd) - + mo.users.login(user) rr.used_at = mo.now - sess.add(user) - sess.flush() - app.logger.info(f'Reg2: Založen uživatel user=#{user.user_id} email=<{user.email}>') - mo.util.log( - type=db.LogType.user, - what=user.user_id, - details={'action': 'register', 'new': db.row2dict(user)}, - ) - - mo.users.login(user) sess.commit() self.user = user return True - def change_email(self): + def change_email(self) -> bool: sess = db.get_session() user = self.rr.user + + if mo.users.user_by_email(self.rr.email) is not None: + app.logger.info(f'Reg2: Uživatel #{user.user_id} si chce změnit email na <{user.email}>, ale už je použitý jiným účtem.') + self.status = RegStatus.already_exists + return False + + # Tady je krátké okénko, kdy může nastat race condition. Chytí ji integritní omezení v DB a vznikne výjimka. user.email = self.rr.email app.logger.info(f'Reg2: Uživatel #{user.user_id} si změnil email na <{user.email}>') @@ -544,6 +548,7 @@ class Reg2: self.rr.used_at = mo.now sess.commit() + return True def change_passwd(self, new_passwd: str): sess = db.get_session() @@ -618,13 +623,15 @@ def confirm_email(): form = ConfirmEmailForm() if form.validate_on_submit(): if form.submit.data: - reg2.change_email() - flash('E-mail změněn.', 'success') + if reg2.change_email(): + flash('E-mail změněn.', 'success') + return redirect(url_for('user_settings')) elif form.cancel.data: reg2.spend_request() flash('Požadavek na změnu e-mailu zrušen.', 'success') - return redirect(url_for('user_settings')) + return redirect(url_for('user_settings')) + reg2.flash_message() form.orig_email.data = reg2.rr.user.email form.new_email.data = reg2.rr.email diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py index d9d8263e9a23bbdfe926939c67c091df4e6748cb..ed0eda1673c5859eddf9ca618c46418f44f466f0 100644 --- a/mo/web/org_contest.py +++ b/mo/web/org_contest.py @@ -368,7 +368,7 @@ class ParticipantsActionForm(FlaskForm): if self.remove_participation.data: sess.delete(pion) - app.logger.info(f"Účast uživatele #{u.user_id} v soutěži #{pion.contest} zrušena") + app.logger.info(f"Web: Zrušena účast uživatele #{u.user_id} v soutěži #{pion.contest.contest_id}") mo.util.log( type=db.LogType.participant, what=u.user_id, @@ -387,7 +387,7 @@ class ParticipantsActionForm(FlaskForm): if sess.is_modified(pion): changes = db.get_object_changes(pion) - app.logger.info(f"Účast uživatele #{u.user_id} upravena, změny: {changes}") + app.logger.info(f"Web: Upravena účast uživatele #{u.user_id} v soutěži #{pion.contest.contest_id}, změny: {changes}") mo.util.log( type=db.LogType.participant, what=u.user_id, diff --git a/mo/web/user.py b/mo/web/user.py index cc30786a34d495153151611256bbfbdb1bb268b7..677a09e735a2a936a6a3f5b62b8aa91da294e600 100644 --- a/mo/web/user.py +++ b/mo/web/user.py @@ -5,6 +5,7 @@ import flask_wtf.file import hashlib import hmac from sqlalchemy import and_ +from sqlalchemy.dialects.postgresql import insert as pgsql_insert from sqlalchemy.orm import joinedload from typing import List, Tuple, Optional import werkzeug.exceptions @@ -221,7 +222,6 @@ def join_create_pion(c: db.Contest) -> None: state = db.PartState.registered p = db.Participation(user=g.user, contest=c, place=c.place, state=state) sess.add(p) - sess.flush() # Kvůli logování logger.info(f'Join: Účastník #{g.user.user_id} přihlášen do soutěže #{c.contest_id}') mo.util.log( @@ -410,22 +410,25 @@ def user_contest_task(contest_id: int, task_id: int): flash(f'Chyba: {e}', 'danger') return redirect(url_for('user_contest_task', contest_id=contest_id, task_id=task_id)) + is_broken = paper.is_broken() sess.add(paper) - - # FIXME: Bylo by hezké použít INSERT ... ON CONFLICT UPDATE - # (SQLAlchemy to umí, ale ne přes ORM, jen core rozhraním) - sol = (sess.query(db.Solution) - .filter_by(task=task, user=g.user) - .with_for_update() - .one_or_none()) - if sol is None: - sol = db.Solution(task=task, user=g.user) - sess.add(sol) - sol.final_submit_obj = paper - + sess.flush() + + sess.connection().execute( + pgsql_insert(db.Solution.__table__) + .values( + task_id=task.task_id, + user_id=g.user.user_id, + final_submit=paper.paper_id, + ) + .on_conflict_do_update( + constraint='solutions_pkey', + set_={'final_submit': paper.paper_id}, + ) + ) sess.commit() - if paper.is_broken(): + if is_broken: flash('Soubor není korektní PDF, ale přesto jsme ho přijali a pokusíme se ho zpracovat. ' + 'Zkontrolujte prosím, že se na vašem počítači zobrazuje správně.', 'warning')