Skip to content
Snippets Groups Projects
Select Git revision
  • d7c9ed8ceef1b19ae638aacb7dfb8aea4fd67f43
  • devel default
  • master
  • fo
  • jirka/typing
  • fo-base
  • mj/submit-images
  • jk/issue-96
  • jk/issue-196
  • honza/add-contestant
  • honza/mr7
  • honza/mrf
  • honza/mrd
  • honza/mra
  • honza/mr6
  • honza/submit-images
  • honza/kolo-vs-soutez
  • jh-stress-test-wip
  • shorten-schools
19 results

users.py

Blame
    • Martin Mareš's avatar
      6aff4e91
      Bezpečné transakce při zakládání uživatelů/účastníků/účastí · 6aff4e91
      Martin Mareš authored
      Closes #314:
      
      Pokud organizátor odešle POST na přidání nového účastníka soutěže
      dvakrát rychle po sobě (třeba kvůli nějakému automatickému retry
      po rozpadu spojení), dvě různé DB transakce se snaží založit uživatele
      se stejným loginem. Jedna z nich selže na unikátnosti sloupce email.
      
      V defaultní úrovni izolace transakcí (READ COMMITTED) to nemá žádné
      hezké řešení. Nepomůže SELECT ... FOR UPDATE, jelikož ten zamyká pouze
      nalezené řádky, nikoliv neexistenci dalších řádků vyhovujících podmínce.
      
      Co by se dalo dělat:
      
      (1) Zvýšit úroveň izolace aspoň na READ REPEATABLE. To vyřeší problém,
          ale současně může začít víceméně jakákoliv zapisující transakce
          failovat. Vyžadovalo by dopsat retry do prakticky všech míst v OSMO,
          kde je nějaký commit.
      
      (2) Retryovat specificky transakce na zakládání užívatelů (a účastí apod.).
          Tohle nejde snadno, jelikoz jsou i součástí dlouho běžících transakcí
          v importech (zatím jsme se snažíli, aby byl celý import atomický
          a v případě selhání se celý rollbackoval). To by možná mohly vyřešit
          subtransakce.
      
      (3) Zamykat celou tabulku s uživateli, než na ní provedeme první SELECT.
          To by asi vyřešilo problém, ale byl by potřeba zápisový zámek, takže
          by paralelně nemohla běžet žádná čtení. A také by se to potenciálně
          mohlo deadlockovat (potřebujeme v jedné transakci postupně lock na
          uživatele, účastníky a účasti a locky platí až do konce transakce).
      
      (4) Používat INSERT ... ON CONFLICT <něco>. To vypadá bezpečně, jen to není
          moc pohodlné, zejména proto, že s tím nepočítá ORM, takže je potřeba
          dělat všechno ručně.
      
      Zatím jsem zvolil (4), protože mi přijde, že to změny udržuje lokální
      a funguje i s dlouhými transakcemi při importu. Výhledově bych se ale
      chtěl zamyslet nad tím, jak takové věci řešit co nejuniverzálněji
      a nejpohodlněji.
      6aff4e91
      History
      Bezpečné transakce při zakládání uživatelů/účastníků/účastí
      Martin Mareš authored
      Closes #314:
      
      Pokud organizátor odešle POST na přidání nového účastníka soutěže
      dvakrát rychle po sobě (třeba kvůli nějakému automatickému retry
      po rozpadu spojení), dvě různé DB transakce se snaží založit uživatele
      se stejným loginem. Jedna z nich selže na unikátnosti sloupce email.
      
      V defaultní úrovni izolace transakcí (READ COMMITTED) to nemá žádné
      hezké řešení. Nepomůže SELECT ... FOR UPDATE, jelikož ten zamyká pouze
      nalezené řádky, nikoliv neexistenci dalších řádků vyhovujících podmínce.
      
      Co by se dalo dělat:
      
      (1) Zvýšit úroveň izolace aspoň na READ REPEATABLE. To vyřeší problém,
          ale současně může začít víceméně jakákoliv zapisující transakce
          failovat. Vyžadovalo by dopsat retry do prakticky všech míst v OSMO,
          kde je nějaký commit.
      
      (2) Retryovat specificky transakce na zakládání užívatelů (a účastí apod.).
          Tohle nejde snadno, jelikoz jsou i součástí dlouho běžících transakcí
          v importech (zatím jsme se snažíli, aby byl celý import atomický
          a v případě selhání se celý rollbackoval). To by možná mohly vyřešit
          subtransakce.
      
      (3) Zamykat celou tabulku s uživateli, než na ní provedeme první SELECT.
          To by asi vyřešilo problém, ale byl by potřeba zápisový zámek, takže
          by paralelně nemohla běžet žádná čtení. A také by se to potenciálně
          mohlo deadlockovat (potřebujeme v jedné transakci postupně lock na
          uživatele, účastníky a účasti a locky platí až do konce transakce).
      
      (4) Používat INSERT ... ON CONFLICT <něco>. To vypadá bezpečně, jen to není
          moc pohodlné, zejména proto, že s tím nepočítá ORM, takže je potřeba
          dělat všechno ručně.
      
      Zatím jsem zvolil (4), protože mi přijde, že to změny udržuje lokální
      a funguje i s dlouhými transakcemi při importu. Výhledově bych se ale
      chtěl zamyslet nad tím, jak takové věci řešit co nejuniverzálněji
      a nejpohodlněji.
    org_users.py 21.58 KiB
    from typing import Optional, List
    from flask import render_template, g, redirect, url_for, flash, request
    from flask_wtf import FlaskForm
    import werkzeug.exceptions
    import wtforms
    from sqlalchemy import or_
    import flask_sqlalchemy
    from sqlalchemy.orm import joinedload, subqueryload
    
    from wtforms import validators
    from wtforms.fields.simple import SubmitField
    
    from wtforms.validators import Required
    
    import mo
    import mo.db as db
    import mo.email
    from mo.rights import Right
    import mo.util
    import mo.users
    from mo.web import app
    import mo.web.fields as mo_fields
    from mo.web.util import PagerForm
    
    
    class UsersFilterForm(PagerForm):
        # user
        search_name = wtforms.TextField("Jméno/příjmení", render_kw={'autofocus': True})
        search_email = wtforms.TextField("E-mail")
    
        # participants
        year = mo_fields.OptionalInt("Ročník")
        school = mo_fields.School()
    
        # rounds->participations
        round_year = mo_fields.OptionalInt("Ročník")
        round_category = wtforms.SelectField("Kategorie")
        round_seq = wtforms.SelectField("Kolo")
        contest_site = mo_fields.Place("Soutěžní oblast")
        participation_state = wtforms.SelectField('Účast', choices=[('*', '*')] + list(db.PartState.choices()))
    
        submit = wtforms.SubmitField("Filtrovat")
    
        def __init__(self, **kwargs):
            super().__init__(**kwargs)
            self.round_category.choices = ['*'] + sorted(db.get_categories())
            self.round_seq.choices = ['*'] + sorted(db.get_seqs())
    
    
    @app.route('/org/user/', methods=('GET', 'POST'))
    def org_users():
        sess = db.get_session()
        rr = g.gatekeeper.rights_generic()
    
        q = sess.query(db.User).filter_by(is_admin=False, is_org=False).options(
            subqueryload(db.User.participants).joinedload(db.Participant.school_place)
        )
        filter = UsersFilterForm(formdata=request.args)
        if request.args:
            filter.validate()
    
        if filter.search_name.data:
            q = q.filter(or_(
                db.User.first_name.ilike(f"%{filter.search_name.data}%"),
                db.User.last_name .ilike(f"%{filter.search_name.data}%")
            ))
        if filter.search_email.data:
            q = q.filter(db.User.email.ilike(f"%{filter.search_email.data}%"))
    
        if filter.year.data or filter.school.place:
            participant_filter = sess.query(db.Participant.user_id)
            if filter.year.data:
                participant_filter = participant_filter.filter_by(year=filter.year.data)
            if filter.school.place:
                participant_filter = participant_filter.filter_by(school=filter.school.place.place_id)
            q = q.filter(db.User.user_id.in_(participant_filter))
    
        round_filter = sess.query(db.Round.round_id)
        round_filter_apply = False
        if filter.round_year.data:
            round_filter = round_filter.filter_by(year=filter.round_year.data)
            round_filter_apply = True
        if filter.round_category.data and filter.round_category.data != "*":
            round_filter = round_filter.filter_by(category=filter.round_category.data)
            round_filter_apply = True
        if filter.round_seq.data and filter.round_seq.data != "*":
            round_filter = round_filter.filter_by(seq=filter.round_seq.data)
            round_filter_apply = True
    
        contest_filter = sess.query(db.Contest.contest_id)
        contest_filter_apply = False
        if round_filter_apply:
            contest_filter = contest_filter.filter(db.Contest.round_id.in_(round_filter))
            contest_filter_apply = True
        if filter.contest_site.place:
            contest_filter = contest_filter.filter_by(place_id=filter.contest_site.place.place_id)
            contest_filter_apply = True
    
        participation_filter = sess.query(db.Participation.user_id)
        participation_filter_apply = False
        if contest_filter_apply:
            participation_filter = participation_filter.filter(db.Participation.contest_id.in_(contest_filter))
            participation_filter_apply = True
        if filter.participation_state.data and filter.participation_state.data != '*':
            participation_filter = participation_filter.filter_by(state=filter.participation_state.data)
            participation_filter_apply = True
    
        if participation_filter_apply:
            q = q.filter(db.User.user_id.in_(participation_filter))
    
        # print(str(q))
        (count, q) = filter.apply_limits(q, pagesize=50)
        users = q.all()
    
        return render_template(
            'org_users.html', users=users, count=count,
            filter=filter,
            can_edit=rr.have_right(Right.edit_users),
            can_add=rr.have_right(Right.add_users),
        )
    
    
    class OrgsFilterForm(PagerForm):
        # user
        search_name = wtforms.TextField("Jméno/příjmení", render_kw={'autofocus': True})
        search_email = wtforms.TextField("E-mail")
    
        search_role = wtforms.SelectMultipleField('Role', choices=db.RoleType.choices(), coerce=db.RoleType.coerce, validators=[validators.Optional()])
        search_right_for_place = mo_fields.Place('Právo pro oblast', validators=[validators.Optional()])
        search_in_place = mo_fields.Place('V oblasti', validators=[validators.Optional()])
        search_place_level = wtforms.SelectMultipleField("Úroveň oblasti", choices=[(i.level, i.name) for i in db.place_levels], validators=[validators.Optional()], coerce=int)
        search_year = mo_fields.IntList('Ročník', validators=[validators.Optional()])
        search_category = wtforms.StringField("Kategorie", validators=[validators.Optional()])
        search_seq = mo_fields.IntList("Kolo", validators=[validators.Optional()])
    
        submit = wtforms.SubmitField("Filtrovat")
        show_role_filter = wtforms.SubmitField("Zobrazit filtrování dle rolí")
        hide_role_filter = wtforms.SubmitField("Skrýt filtrování dle rolí")
    
        is_role_filter = wtforms.HiddenField(default="") # "" -> skrýt. "yes" -> zobrazit
    
        def prepare_role_filter(self):
            if self.show_role_filter.data:
                self.is_role_filter.data = "yes"
            if self.hide_role_filter.data:
                self.is_role_filter.data = ""
            if self.is_role_filter.data:
                del self.show_role_filter
            else:
                del self.hide_role_filter
                del self.search_role
                del self.search_right_for_place
                del self.search_in_place
                del self.search_place_level
                del self.search_year
                del self.search_category
                del self.search_seq
    
    
    @app.route('/org/org/', methods=('GET', 'POST'))
    def org_orgs():
        sess = db.get_session()
        rr = g.gatekeeper.rights_generic()
        q = sess.query(db.User).filter(or_(db.User.is_admin, db.User.is_org)).options(
            subqueryload(db.User.roles).joinedload(db.UserRole.place)
        )
        filter = OrgsFilterForm(formdata=request.args)
        if request.args:
            filter.validate()
        filter.prepare_role_filter()
    
        if filter.search_name.data:
            q = q.filter(or_(
                db.User.first_name.ilike(f"%{filter.search_name.data}%"),
                db.User.last_name .ilike(f"%{filter.search_name.data}%")
            ))
        if filter.search_email.data:
            q = q.filter(db.User.email.ilike(f"%{filter.search_email.data}%"))
    
        def query_filter_role(qr: flask_sqlalchemy.BaseQuery) -> flask_sqlalchemy.BaseQuery:
            if filter.search_role.data:
                qr = qr.filter(db.UserRole.role.in_(filter.search_role.data))
            if filter.search_category.data:
                qr = qr.filter(or_(db.UserRole.category.in_(filter.search_category.data.split(",")), db.UserRole.category == None))
            if filter.search_seq.list:
                qr = qr.filter(or_(db.UserRole.seq.in_(filter.search_seq.list), db.UserRole.seq == None))
            if filter.search_year.list:
                qr = qr.filter(or_(db.UserRole.year.in_(filter.search_year.list), db.UserRole.year == None))
                pass
            if filter.search_in_place.place is not None:
                qr = qr.filter(db.UserRole.place_id.in_(db.place_descendant_cte(filter.search_in_place.place)))
            if filter.search_right_for_place.place is not None:
                qr = qr.filter(db.UserRole.place_id.in_([x.place_id for x in db.get_place_parents(filter.search_right_for_place.place)]))
                        # Po n>3 hodinách v mo.db jsem dospěl k závěru, že to hezčeji neumím (neumím vyrobit place_parents_cte)
            if filter.search_place_level.data:
                qr = qr.filter(db.UserRole.place_id.in_(
                    sess.query(db.Place.place_id).filter(db.Place.level.in_(filter.search_place_level.data))
                ))
            print(qr)
            return qr
    
        if filter.is_role_filter.data:
            qr = sess.query(db.UserRole.user_id)
            qr = query_filter_role(qr)
            q = q.filter(db.User.user_id.in_(qr))
    
        q = q.order_by(db.User.user_id)
    
        (count, q) = filter.apply_limits(q, pagesize=50)
        users = q.all()
    
        marked_roles_id: Set[int] = set()
    
        if filter.is_role_filter.data:
            qmr = sess.query(db.UserRole.user_role_id).filter(db.UserRole.user_id.in_([i.user_id for i in users]))
            qmr = query_filter_role(qmr)
            marked_roles_id = set([i[0] for i in qmr.all()])
    
        return render_template(
            'org_orgs.html', users=users, count=count,
            filter=filter,
            marked_roles_id=marked_roles_id,
            can_edit=rr.have_right(Right.edit_orgs),
            can_add=rr.have_right(Right.add_orgs),
        )
    
    
    class FormAddRole(FlaskForm):
        role = wtforms.SelectField('Role', choices=db.RoleType.choices(), coerce=db.RoleType.coerce, render_kw={'autofocus': True})
        place = mo_fields.Place()
        year = wtforms.IntegerField('Ročník', validators=[validators.Optional()])
        category = wtforms.StringField("Kategorie", validators=[validators.Length(max=2)], filters=[lambda x: x or None])
        seq = wtforms.IntegerField("Kolo", validators=[validators.Optional()])
    
        submit = wtforms.SubmitField('Přidat roli')
    
    
    class FormRemoveRole(FlaskForm):
        remove_role_id = wtforms.IntegerField()
        remove = wtforms.SubmitField('Odebrat roli')
    
    
    class ResendInviteForm(FlaskForm):
        resend_invite = SubmitField()
    
        def do(self, user: db.User):
            if user.last_login_at is None:
                token = mo.users.make_activation_token(user)
                db.get_session().commit()
                if mo.email.send_new_account_email(user, token):
                    flash('Uvítací e-mail s odkazem na aktivaci účtu odeslán na {}.'.format(user.email), 'success')
                else:
                    flash('Problém při odesílání e-mailu s odkazem na aktivaci účtu.', 'danger')
            else:
                flash('Tento uživatel už má účet aktivovaný.', 'danger')
    
    
    @app.route('/org/org/<int:id>/', methods=('GET', 'POST'))
    def org_org(id: int):
        sess = db.get_session()
        user = (sess.query(db.User)
                .options(subqueryload(db.User.roles).joinedload(db.UserRole.place, db.UserRole.assigned_by_user))
                .get(id))
        if not user or (not user.is_org and not user.is_admin):
            raise werkzeug.exceptions.NotFound()
    
        rr = g.gatekeeper.rights_generic()
        can_assign_rights = rr.have_right(Right.assign_rights)
    
        resend_invite_form: Optional[ResendInviteForm] = None
        if user.last_login_at is None and rr.can_edit_user(user):
            resend_invite_form = ResendInviteForm()
            if resend_invite_form.resend_invite.data and resend_invite_form.validate_on_submit():
                resend_invite_form.do(user)
                return redirect(url_for('org_org', id=id))
    
        form_add_role = FormAddRole()
        form_remove_role = FormRemoveRole()
        role_errors = []
        if can_assign_rights:
            if form_add_role.submit.data and form_add_role.validate_on_submit():
                new_role = db.UserRole()
                form_add_role.populate_obj(new_role)
    
                new_role.user_id = id
                new_role.place = db.get_root_place()
                new_role.assigned_by = g.user.user_id
    
                ok = True
                new_role.place = form_add_role.place.place
    
                if not g.gatekeeper.can_set_role(new_role):
                    role_errors.append(f'Roli "{new_role}" nelze přidělit, není podmnožinou žádné vaší role')
                    ok = False
    
                if ok:
                    sess.add(new_role)
                    sess.flush()
                    mo.util.log(
                        type=db.LogType.user_role,
                        what=id,
                        details={'action': 'new', 'role': db.row2dict(new_role)},
                    )
                    sess.commit()
                    app.logger.info(f"New role for user id {id} added: {db.row2dict(new_role)}")
                    flash(f'Role "{new_role}" úspěšně přidána', 'success')
                    return redirect(url_for('org_user', id=id))
    
            if form_remove_role.remove_role_id.data and form_remove_role.validate_on_submit():
                role = sess.query(db.UserRole).get(form_remove_role.remove_role_id.data)
                if not role:
                    raise werkzeug.exceptions.NotFound()
    
                if not g.gatekeeper.can_set_role(role):
                    role_errors.append(f'Roli "{role}" nelze odebrat, není podmnožinou žádné vaší role')
                else:
                    sess.delete(role)
                    mo.util.log(
                        type=db.LogType.user_role,
                        what=id,
                        details={'action': 'delete', 'role': db.row2dict(role)},
                    )
                    sess.commit()
                    app.logger.info(f"Role for user {id} removed: {db.row2dict(role)}")
                    flash(f'Role "{role}" úspěšně odebrána', 'success')
                    return redirect(url_for('org_user', id=id))
    
        return render_template(
            'org_org.html', user=user,
            can_edit=rr.can_edit_user(user), can_assign_rights=can_assign_rights,
            can_incarnate=g.user.is_admin,
            roles_by_type=mo.rights.roles_by_type, role_errors=role_errors,
            form_add_role=form_add_role, form_remove_role=form_remove_role,
            resend_invite_form=resend_invite_form,
        )
    
    
    @app.route('/org/user/<int:id>/', methods=('GET', 'POST'))
    def org_user(id: int):
        sess = db.get_session()
        user = mo.users.user_by_uid(id)
        if not user:
            raise werkzeug.exceptions.NotFound()
        if user.is_org or user.is_admin:
            return redirect(url_for('org_org', id=id))
    
        rr = g.gatekeeper.rights_generic()
    
        resend_invite_form: Optional[ResendInviteForm] = None
        if user.last_login_at is None and rr.can_edit_user(user):
            resend_invite_form = ResendInviteForm()
            if resend_invite_form.resend_invite.data and resend_invite_form.validate_on_submit():
                resend_invite_form.do(user)
                return redirect(url_for('org_user', id=id))
    
        participants = sess.query(db.Participant).filter_by(user_id=user.user_id)
        participations = (
            sess.query(db.Participation, db.Contest, db.Round)
            .select_from(db.Participation)
            .join(db.Contest, db.Contest.master_contest_id == db.Participation.contest_id)
            .join(db.Round)
            .filter(db.Participation.user == user)
            .options(joinedload(db.Contest.place))
            .order_by(db.Round.year.desc(), db.Round.category, db.Round.seq, db.Round.part)
            .all()
        )
    
        return render_template(
            'org_user.html', user=user, can_edit=rr.can_edit_user(user),
            can_incarnate=g.user.is_admin,
            participants=participants, participations=participations,
            resend_invite_form=resend_invite_form,
        )
    
    
    class UserEditForm(FlaskForm):
        first_name = mo_fields.FirstName(validators=[Required()], render_kw={'autofocus': True})
        last_name = mo_fields.LastName(validators=[Required()])
        email = mo_fields.Email(validators=[Required()])
        note = wtforms.TextAreaField("Poznámka")
        is_test = wtforms.BooleanField("Testovací účet")
        allow_duplicate_name = wtforms.BooleanField("Přidat účet s duplicitním jménem")
        submit = wtforms.SubmitField("Uložit")
    
    
    @app.route('/org/org/<int:id>/edit', methods=("GET", "POST"), endpoint="org_org_edit")
    @app.route('/org/user/<int:id>/edit', methods=("GET", "POST"))
    def org_user_edit(id: int):
        sess = db.get_session()
        user = mo.users.user_by_uid(id)
        if not user:
            raise werkzeug.exceptions.NotFound()
    
        is_org = request.endpoint == "org_org_edit"
    
        if not is_org and (user.is_admin or user.is_org):
            return redirect(url_for("org_org_edit", id=id))
        if is_org and not (user.is_admin or user.is_org):
            return redirect(url_for("org_user_edit", id=id))
    
        rr = g.gatekeeper.rights_generic()
        if not rr.can_edit_user(user):
            raise werkzeug.exceptions.Forbidden()
    
        form = UserEditForm(obj=user)
        del form.allow_duplicate_name
        if (user.is_org or user.is_admin) and not g.user.is_admin:
            # emaily u organizátorů může editovat jen správce
            del form.email
        if form.validate_on_submit():
            check = True
    
            if hasattr(form, 'email') and form.email is not None:
                other_user = mo.users.user_by_email(form.email.data)
                if other_user is not None and other_user != user:
                    flash('Zadaný e-mail nelze použít, existuje jiný účet s tímto e-mailem', 'danger')
                    check = False
    
            if check:
                form.populate_obj(user)
                if sess.is_modified(user):
                    changes = db.get_object_changes(user)
    
                    app.logger.info(f"User {id} modified, changes: {changes}")
                    mo.util.log(
                        type=db.LogType.user,
                        what=id,
                        details={'action': 'edit', 'changes': changes},
                    )
                    sess.commit()
                    flash('Změny uživatele uloženy', 'success')
                else:
                    flash('Žádné změny k uložení', 'info')
    
                return redirect(url_for('org_user', id=id))
    
        return render_template('org_user_edit.html', user=user, form=form, is_org=is_org)
    
    
    @app.route('/org/org/new/', methods=('GET', 'POST'), endpoint="org_org_new")
    @app.route('/org/user/new/', methods=('GET', 'POST'))
    def org_user_new():
        sess = db.get_session()
        rr = g.gatekeeper.rights_generic()
    
        is_org = request.endpoint == "org_org_new"
    
        if is_org and not rr.have_right(Right.add_orgs):
            raise werkzeug.exceptions.Forbidden()
        elif not rr.have_right(Right.add_users):
            raise werkzeug.exceptions.Forbidden()
    
        form = UserEditForm()
        form.submit.label.text = 'Vytvořit'
        is_duplicate_name = False
        if form.validate_on_submit():
            check = True
    
            if mo.users.user_by_email(form.email.data) is not None:
                flash('Účet s daným e-mailem již existuje', 'danger')
                check = False
    
            if is_org:
                if (mo.db.get_session().query(db.User)
                        .filter_by(first_name=form.first_name.data, last_name=form.last_name.data, is_org=True)
                        .first() is not None):
                    is_duplicate_name = True
                    if not form.allow_duplicate_name.data:
                        flash('Organizátor s daným jménem již existuje. V případě, že se nejedná o chybu, zaškrtněte políčko ve formuláři.', 'danger')
                        check = False
    
            if check:
                new_user = db.User()
                form.populate_obj(new_user)
                new_user.is_org = is_org
                sess.add(new_user)
                sess.flush()
    
                app.logger.info(f"New user created: {db.row2dict(new_user)}")
                mo.util.log(
                    type=db.LogType.user,
                    what=new_user.user_id,
                    details={'action': 'new', 'user': db.row2dict(new_user)},
                )
    
                token = mo.users.make_activation_token(new_user)
                sess.commit()
                flash('Nový uživatel vytvořen', 'success')
    
                if mo.email.send_new_account_email(new_user, token):
                    flash('E-mail s odkazem na aktivaci účtu odeslán na {}.'.format(new_user.email), 'success')
                else:
                    flash('Problém při odesílání e-mailu s odkazem na aktivaci účtu.', 'danger')
    
                if is_org:
                    return redirect(url_for('org_org', id=new_user.user_id))
                return redirect(url_for('org_user', id=new_user.user_id))
    
        if not is_duplicate_name:
            del form.allow_duplicate_name
        return render_template('org_user_new.html', form=form, is_org=is_org)
    
    
    class ParticipantEditForm(FlaskForm):
        school = mo_fields.School("Škola", validators=[Required()], render_kw={'autofocus': True})
        grade = mo_fields.Grade("Třída", validators=[Required()])
        birth_year = mo_fields.BirthYear("Rok narození", validators=[Required()])
        submit = wtforms.SubmitField("Uložit")
    
    
    @app.route('/org/user/<int:user_id>/participant/<int:year>/edit', methods=('GET', 'POST'))
    def org_user_participant_edit(user_id: int, year: int):
        sess = db.get_session()
        user = mo.users.user_by_uid(user_id)
        if not user:
            raise werkzeug.exceptions.NotFound()
    
        rr = g.gatekeeper.rights_generic()
        if not rr.can_edit_user(user):
            raise werkzeug.exceptions.Forbidden()
    
        participant = sess.query(db.Participant).filter_by(user_id=user.user_id).filter_by(year=year).one_or_none()
        if participant is None:
            raise werkzeug.exceptions.NotFound()
    
        form = ParticipantEditForm(obj=participant)
        if form.validate_on_submit():
            form.populate_obj(participant)
            if sess.is_modified(participant):
                changes = db.get_object_changes(participant)
    
                app.logger.info(f"Participant id {id} year {year} modified, changes: {changes}")
                mo.util.log(
                    type=db.LogType.participant,
                    what=user_id,
                    details={'action': 'edit-participant', 'year': year, 'changes': changes},
                )
                sess.commit()
                flash('Změny registrace uloženy', 'success')
            else:
                flash('Žádné změny k uložení', 'info')
    
            return redirect(url_for('org_user', id=user_id))
    
        return render_template('org_user_participant_edit.html', user=user, year=year, form=form)