Skip to content
Snippets Groups Projects
Select Git revision
  • 75f2417ed12b60d5364ec43e3c4f42f1fa13f4ec
  • 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

org_round.py

Blame
  • org_round.py 26.12 KiB
    import decimal
    from flask import render_template, g, redirect, url_for, flash, request
    import locale
    import flask_wtf.file
    from flask_wtf.form import FlaskForm
    import bleach
    from bleach.sanitizer import ALLOWED_TAGS
    import markdown
    import os
    from sqlalchemy import func
    from sqlalchemy.orm import joinedload
    from sqlalchemy.sql.functions import coalesce
    from typing import Optional, Tuple
    import werkzeug.exceptions
    import wtforms
    from wtforms import validators, ValidationError
    from wtforms.widgets.html5 import NumberInput
    
    import mo
    import mo.db as db
    import mo.imports
    from mo.rights import Right, RoundRights
    import mo.util
    from mo.web import app
    from mo.web.util import MODecimalField
    from mo.web.org_contest import ParticipantsActionForm, ParticipantsFilterForm, get_contestant_emails, get_contestants_query, make_contestant_table, \
        generic_import, generic_batch_download, generic_batch_upload, generic_batch_points
    
    
    def get_round_rr(id: int, right_needed: Optional[Right], any_place: bool) -> Tuple[db.Round, db.Round, RoundRights]:
        """Vrací round, master_round a Rights objekt pro zadané round_id.
        Pro nedělená kola platí round == master_round.
        Operace s účastníky by měly probíhat vždy přes master_round."""
        round = db.get_session().query(db.Round).options(joinedload(db.Round.master)).get(id)
        if not round:
            raise werkzeug.exceptions.NotFound()
    
        rr = g.gatekeeper.rights_for_round(round, any_place)
    
        if not (right_needed is None or rr.have_right(right_needed)):
            raise werkzeug.exceptions.Forbidden()
    
        return round, round.master, rr
    
    
    def get_task(round: db.Round, task_id: int) -> db.Task:
        task = db.get_session().query(db.Task).get(task_id)
        if not task or task.round_id != round.round_id:
            raise werkzeug.exceptions.NotFound()
        return task
    
    
    @app.route('/org/contest/')
    def org_rounds():
        sess = db.get_session()
    
        rounds = sess.query(db.Round).filter_by(year=mo.current_year).order_by(db.Round.year, db.Round.category, db.Round.seq, db.Round.part)
        return render_template('org_rounds.html', rounds=rounds)
    
    
    class TaskDeleteForm(FlaskForm):
        delete_task_id = wtforms.IntegerField()
        delete_task = wtforms.SubmitField('Smazat úlohu')
    
    
    def delete_task(round_id: int, form: TaskDeleteForm) -> bool:
        if not (request.method == 'POST' and 'delete_task_id' in request.form and form.validate_on_submit()):
            return False
    
        sess = db.get_session()
        delete_task = sess.query(db.Task).filter_by(
            round_id=round_id, task_id=form.delete_task_id.data
        ).first()
    
        if not delete_task:
            flash('Úloha s daným ID v tomto kole neexistuje', 'danger')
        elif sess.query(db.Solution).filter_by(task_id=delete_task.task_id).first() is not None:
            flash(f'Úlohu {delete_task.code} nelze smazat, existují řešení vázající se na ni', 'danger')
        elif sess.query(db.Paper).filter_by(for_task=delete_task.task_id).first() is not None:
            flash(f'Úlohu {delete_task.code} nelze smazat, existují papíry vázající se na ni', 'danger')
        elif sess.query(db.PointsHistory).filter_by(task_id=delete_task.task_id).first() is not None:
            flash(f'Úlohu {delete_task.code} nelze smazat, existují přidělené body vázající se na ni', 'danger')
        else:
            sess.delete(delete_task)
            mo.util.log(
                type=db.LogType.task,
                what=delete_task.task_id,
                details={'action': 'delete', 'task': db.row2dict(delete_task)},
            )
            app.logger.info(f"Úloha {delete_task.code} ({delete_task.task_id}) smazána: {db.row2dict(delete_task)}")
            sess.commit()
            flash(f'Úloha {delete_task.code} úspěšně smazána', 'success')
            return True
    
        return False
    
    
    class AddContestForm(FlaskForm):
        place_code = wtforms.StringField('Nová soutěž v oblasti:', validators=[validators.Required()])
        create_contest = wtforms.SubmitField('Založit')
    
    
    def add_contest(round: db.Round, form: AddContestForm) -> bool:
        if not (request.method == 'POST' and 'create_contest' in request.form and form.validate_on_submit()):
            return False
    
        place = db.get_place_by_code(form.place_code.data)
        if place is None:
            flash(f'Místo s kódem {form.place_code.data} neexistuje', 'danger')
            return False
    
        if place.level != round.level:
            flash(f'{place.type_name().title()} {place.name} není {round.get_level().name}', 'danger')
            return False
    
        sess = db.get_session()
        if sess.query(db.Contest).filter_by(round=round, place=place).one_or_none():
            flash(f'Pro {place.type_name()} {place.name} už toto kolo existuje', 'danger')
            return False
    
        # Počáteční stav soutěže
        if round.state != db.RoundState.delegate:
            state = round.state
        else:
            state = db.RoundState.preparing
    
        # Soutěž vytvoříme vždy v hlavním kole
        contest = db.Contest(round=round.master, place=place, state=state)
        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.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)}")
    
        # Přidání soutěže do podkol ve skupině
        subrounds = round.master.get_group_rounds()
        for subround in subrounds:
            subcontest = db.Contest(
                round_id=subround.round_id,
                master_contest_id=contest.contest_id,
                place_id=contest.place_id,
                state=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"Soutěž #{subcontest.contest_id} založena: {db.row2dict(subcontest)}")
    
        sess.commit()
        flash(f'Založena soutěž {place.name_locative()}', 'success')
        return True
    
    
    @app.route('/org/contest/r/<int:id>/', methods=('GET', 'POST'))
    def org_round(id: int):
        sess = db.get_session()
        round, _, rr = get_round_rr(id, None, True)
    
        can_manage_round = rr.have_right(Right.manage_round)
        can_manage_contestants = rr.have_right(Right.manage_contest)
    
        participants_count = sess.query(
            db.Participation.contest_id,
            func.count(db.Participation.user_id).label('count')
        ).group_by(db.Participation.contest_id).subquery()
    
        # účastníci jsou jen pod master contesty
        contests_counts = (sess.query(
                        db.Contest,
                        coalesce(participants_count.c.count, 0)
                    ).outerjoin(participants_count, db.Contest.master_contest_id == participants_count.c.contest_id)
                    .filter(db.Contest.round == round)
                    .options(joinedload(db.Contest.place))
                    .all())
    
        contests_counts.sort(key=lambda c: locale.strxfrm(c[0].place.name))
    
        sol_counts_q = (
            sess.query(db.Solution.task_id, func.count(db.Solution.task_id))
            .filter(db.Solution.task_id.in_(
                sess.query(db.Task.task_id).filter_by(round=round)
            ))
        )
    
        sol_counts = {}
        for task_id, count in sol_counts_q.group_by(db.Solution.task_id).all():
            sol_counts[task_id] = count
    
        tasks = sess.query(db.Task).filter_by(round=round).all()
        tasks.sort(key=lambda t: t.code)
        for task in tasks:
            task.sol_count = sol_counts[task.task_id] if task.task_id in sol_counts else 0
    
        form_delete_task = TaskDeleteForm()
        if can_manage_round and delete_task(id, form_delete_task):
            return redirect(url_for('org_round', id=id))
    
        form_add_contest = AddContestForm()
        form_add_contest.place_code.label.text = "Nová soutěž " + round.get_level().in_name()
        if add_contest(round, form_add_contest):
            return redirect(url_for('org_round', id=id))
    
        group_rounds = round.get_group_rounds(True)
        group_rounds.sort(key=lambda r: r.round_code())
    
        return render_template(
            'org_round.html',
            round=round, group_rounds=group_rounds,
            roles=[r.friendly_name() for r in rr.get_roles()],
            contests_counts=contests_counts,
            tasks=tasks, form_delete_task=form_delete_task,
            form_add_contest=form_add_contest,
            can_manage_round=can_manage_round,
            can_manage_contestants=can_manage_contestants,
            can_handle_submits=rr.have_right(Right.view_submits),
            can_upload=rr.offer_upload_feedback(),
            can_view_statement=rr.can_view_statement(),
            can_add_contest=g.gatekeeper.rights_generic().have_right(Right.add_contest),
            statement_exists=mo.web.util.task_statement_exists(round),
        )
    
    
    class TaskEditForm(FlaskForm):
        code = wtforms.StringField('Kód úlohy', validators=[
            validators.Required(),
            validators.Regexp(r'^[A-Za-z0-9-]+$', message="Kód úlohy smí obsahovat jen nediakritická písmena, čísla a znak -"),
        ], render_kw={'autofocus': True})
        name = wtforms.StringField('Název úlohy')
        max_points = MODecimalField(
            'Maximum bodů', validators=[validators.Optional(), validators.NumberRange(min=0)],
            description="Při nastavení maxima nelze udělit více bodů, pro zrušení uložte prázdnou hodnotu",
        )
        submit = wtforms.SubmitField('Uložit')
    
        def __init__(self, points_step: decimal.Decimal, *args, **kwargs):
            super().__init__(*args, **kwargs)
            self.max_points.widget = NumberInput(min=0, step=points_step)
    
    
    @app.route('/org/contest/r/<int:id>/task/new', methods=('GET', 'POST'))
    def org_round_task_new(id: int):
        sess = db.get_session()
        round, master_round, _ = get_round_rr(id, Right.manage_round, True)
    
        form = TaskEditForm(master_round.points_step)
        if form.validate_on_submit():
            task = db.Task()
            task.round = round
            form.populate_obj(task)
    
            if sess.query(db.Task).filter_by(round_id=id, code=task.code).first():
                flash('Úloha se stejným kódem již v tomto kole existuje', 'danger')
            else:
                sess.add(task)
                sess.flush()
                mo.util.log(
                    type=db.LogType.task,
                    what=task.task_id,
                    details={'action': 'add', 'task': db.row2dict(task)},
                )
                sess.commit()
                app.logger.info(f"Úloha {task.code} ({task.task_id}) přidána: {db.row2dict(task)}")
                flash('Nová úloha přidána', 'success')
                return redirect(url_for('org_round', id=id))
    
        return render_template(
            'org_round_task_edit.html',
            round=round, task=None, form=form,
        )
    
    
    @app.route('/org/contest/r/<int:id>/task/<int:task_id>/edit', methods=('GET', 'POST'))
    def org_round_task_edit(id: int, task_id: int):
        sess = db.get_session()
        round, master_round, _ = get_round_rr(id, Right.manage_round, True)
    
        task = sess.query(db.Task).get(task_id)
        # FIXME: Check contest!
        if not task:
            raise werkzeug.exceptions.NotFound()
    
        form = TaskEditForm(master_round.points_step, obj=task)
        if form.validate_on_submit():
            if sess.query(db.Task).filter(
                db.Task.task_id != task_id, db.Task.round_id == id, db.Task.code == form.code.data
            ).first():
                flash('Úloha se stejným kódem již v tomto kole existuje', 'danger')
            else:
                form.populate_obj(task)
                if sess.is_modified(task):
                    changes = db.get_object_changes(task)
    
                    mo.util.log(
                        type=db.LogType.task,
                        what=task_id,
                        details={'action': 'edit', 'changes': changes},
                    )
                    sess.commit()
                    app.logger.info(f"Úloha {task.code} ({task_id}) modifikována, změny: {changes}")
                    flash('Změny úlohy uloženy', 'success')
                else:
                    flash(u'Žádné změny k uložení', 'info')
    
                return redirect(url_for('org_round', id=id))
    
        return render_template(
            'org_round_task_edit.html',
            round=round, task=task, form=form,
        )
    
    
    @app.route('/org/contest/r/<int:round_id>/task/<int:task_id>/download', methods=('GET', 'POST'))
    def org_round_task_download(round_id: int, task_id: int):
        round, _, _ = get_round_rr(round_id, Right.view_submits, False)
        task = get_task(round, task_id)
        return generic_batch_download(round=round, contest=None, site=None, task=task)
    
    
    @app.route('/org/contest/r/<int:round_id>/task/<int:task_id>/upload', methods=('GET', 'POST'))
    def org_round_task_upload(round_id: int, task_id: int):
        round, _, rr = get_round_rr(round_id, Right.view_submits, False)
        task = get_task(round, task_id)
        return generic_batch_upload(round=round, contest=None, site=None, task=task,
                                    offer_upload_solutions=rr.offer_upload_solutions(),
                                    offer_upload_feedback=rr.offer_upload_feedback())
    
    
    @app.route('/org/contest/r/<int:round_id>/task/<int:task_id>/batch-points', methods=('GET', 'POST'))
    def org_round_task_batch_points(round_id: int, task_id: int):
        round, _, _ = get_round_rr(round_id, Right.edit_points, True)
        task = get_task(round, task_id)
        return generic_batch_points(round=round, contest=None, task=task)
    
    
    @app.route('/org/contest/r/<int:id>/list', methods=('GET', 'POST'))
    @app.route('/org/contest/r/<int:id>/list/emails', endpoint="org_round_list_emails")
    def org_round_list(id: int):
        round, master_round, rr = get_round_rr(id, Right.view_contestants, True)
        can_edit = rr.have_right(Right.manage_round) and request.endpoint != 'org_round_list_emails'
        format = request.args.get('format', "")
    
        filter = ParticipantsFilterForm(request.args)
        filter.validate()
        query = get_contestants_query(
            round=master_round,
            school=filter.f_school,
            contest_place=filter.f_contest_place,
            participation_place=filter.f_participation_place,
            participation_state=filter.f_participation_state,
        )
    
        action_form = None
        if can_edit:
            action_form = ParticipantsActionForm()
            if action_form.do_action(round=master_round, query=query):
                # Action happened, redirect
                return redirect(request.url)
    
        if format == "":
            table = None
            emails = None
            mailto_link = None
            if request.endpoint == 'org_round_list_emails':
                (emails, mailto_link) = get_contestant_emails(query,
                        mailto_subject=f'{round.name} kategorie {round.category}')
                count = len(emails)
            else:
                (count, query) = filter.apply_limits(query, pagesize=50)
                # count = db.get_count(query)
                table = make_contestant_table(query, round, add_contest_column=True, add_checkbox=True)
    
            return render_template(
                'org_round_list.html',
                round=round,
                table=table, emails=emails, mailto_link=mailto_link,
                filter=filter, count=count, action_form=action_form,
            )
        else:
            table = make_contestant_table(query, round, is_export=True)
            return table.send_as(format)
    
    
    @app.route('/org/contest/r/<int:id>/import', methods=('GET', 'POST'))
    def org_round_import(id: int):
        round, master_round, rr = get_round_rr(id, Right.manage_contest, True)
        return generic_import(round, master_round, None, None)
    
    
    class MODateTimeField(wtforms.DateTimeField):
    
        def __init__(self, label, format='%Y-%m-%d %H:%M', description='Ve formátu 2000-01-01 12:34', **kwargs):
            super().__init__(label, format=format, description=description, **kwargs)
    
        def process_data(self, valuelist):
            super().process_data(valuelist)
            if self.data is not None:
                self.data = self.data.astimezone()
    
        def process_formdata(self, valuelist):
            super().process_formdata(valuelist)
            if self.data is not None:
                self.data = self.data.astimezone()
    
    
    class RoundEditForm(FlaskForm):
        _for_round: Optional[db.Round] = None
    
        name = wtforms.StringField("Název", render_kw={'autofocus': True})
        state = wtforms.SelectField(
            "Stav kola", choices=db.RoundState.choices(), coerce=db.RoundState.coerce,
            description="Stav soutěží ve všech oblastech kola. Pokud zvolíme 'po oblastech', každá soutěž si svůj stav určuje sama.",
        )
        # Only the desktop Firefox does not support datetime-local field nowadays,
        # other browsers does provide date and time picker UI :(
        ct_tasks_start = MODateTimeField("Čas zveřejnění úloh pro účastníky", validators=[validators.Optional()])
        pr_tasks_start = MODateTimeField("Čas zveřejnění úloh pro dozor", validators=[validators.Optional()])
        ct_submit_end = MODateTimeField("Konec odevzdávání pro účastníky", validators=[validators.Optional()])
        pr_submit_end = MODateTimeField("Konec odevzdávání pro dozor", validators=[validators.Optional()])
        score_mode = wtforms.SelectField("Výsledková listina", choices=db.RoundScoreMode.choices(), coerce=db.RoundScoreMode.coerce)
        score_winner_limit = MODecimalField(
            "Hranice bodů pro vítěze", validators=[validators.Optional(), validators.NumberRange(min=0)],
            description="Řešitelé s alespoň tolika body budou označeni za vítěze, prázdná hodnota = žádné neoznačovat",
        )
        score_successful_limit = MODecimalField(
            "Hranice bodů pro úspěšné řešitele", validators=[validators.Optional(), validators.NumberRange(min=0)],
            description="Řešitelé s alespoň tolika body budou označeni za úspěšné řešitele, prázdná hodnota = žádné neoznačovat",
        )
        points_step = wtforms.SelectField(
            "Přesnost bodování", choices=db.round_points_step_choices, coerce=decimal.Decimal,
            description="Ovlivňuje možnost zadávání nových bodů, již uložené body nezmění"
        )
        has_messages = wtforms.BooleanField("Zprávičky pro účastníky (aktivuje možnost vytvářet novinky zobrazované účastníkům)")
        submit = wtforms.SubmitField('Uložit')
    
        def validate_state(self, field):
            if field.data != db.RoundState.preparing:
                if self.ct_tasks_start.data is None:
                    raise ValidationError('Není-li nastaven času začátku soutěže, stav musí být "připravuje se"')
                if self._for_round is not None:
                    num_tasks = db.get_session().query(db.Task).filter_by(round=self._for_round).count()
                    if num_tasks == 0:
                        raise ValidationError('Nejsou-li definovány žádné úlohy, stav musí být "připravuje se"')
    
        def abstract_validate_time_order(self, field):
            if field.data is not None:
                if any([i.data is not None and i.data > field.data for i in [self.ct_tasks_start, self.pr_tasks_start]]):
                    raise ValidationError('Soutěž nesmí skončit dříve než začne.')
    
        def validate_ct_submit_end(self, field):
            self.abstract_validate_time_order(field)
    
        def validate_pr_submit_end(self, field):
            self.abstract_validate_time_order(field)
    
    
    @app.route('/org/contest/r/<int:id>/edit', methods=('GET', 'POST'))
    def org_round_edit(id: int):
        sess = db.get_session()
        round, _, rr = get_round_rr(id, Right.manage_round, True)
    
        form = RoundEditForm(obj=round)
        form._for_round = round
        if round.is_subround():
            # podkolo nemá nastavení výsledkové listiny
            del form.score_mode
            del form.score_winner_limit
            del form.score_successful_limit
            del form.points_step
        if form.validate_on_submit():
            form.populate_obj(round)
    
            if sess.is_modified(round):
                changes = db.get_object_changes(round)
    
                app.logger.info(f"Round #{id} modified, changes: {changes}")
                mo.util.log(
                    type=db.LogType.round,
                    what=id,
                    details={'action': 'edit', 'changes': changes},
                )
    
                if 'state' in changes and round.state != db.RoundState.delegate:
                    # Propagujeme změny stavu do všech soutěží
                    for contest in sess.query(db.Contest).filter_by(round=round).filter(db.Contest.state != round.state):
                        contest.state = round.state
                        ct_changes = db.get_object_changes(contest)
                        app.logger.info(f"Change propagated to contest #{contest.contest_id}: {ct_changes}")
                        mo.util.log(
                            type=db.LogType.contest,
                            what=contest.contest_id,
                            details={'action': 'propagate', 'changes': ct_changes},
                        )
    
                sess.commit()
                flash('Změny kola uloženy', 'success')
            else:
                flash(u'Žádné změny k uložení', 'info')
    
            return redirect(url_for('org_round', id=id))
    
        return render_template(
            'org_round_edit.html',
            round=round,
            form=form,
        )
    
    
    @app.route('/org/contest/r/<int:id>/task-statement/zadani.pdf')
    def org_task_statement(id: int):
        round, _, rr = get_round_rr(id, None, True)
    
        if not rr.can_view_statement():
            app.logger.warn(f'Organizátor #{g.user.user_id} chce zadání, na které nemá právo')
            raise werkzeug.exceptions.Forbidden()
    
        return mo.web.util.send_task_statement(round)
    
    
    class StatementEditForm(FlaskForm):
        file = flask_wtf.file.FileField("Soubor", render_kw={'autofocus': True})
        upload = wtforms.SubmitField('Nahrát')
        delete = wtforms.SubmitField('Smazat')
    
    
    @app.route('/org/contest/r/<int:id>/task-statement/edit', methods=('GET', 'POST'))
    def org_edit_statement(id: int):
        sess = db.get_session()
        round, _, rr = get_round_rr(id, Right.manage_round, True)
    
        def log_changes():
            if sess.is_modified(round):
                changes = db.get_object_changes(round)
                app.logger.info(f"Kolo #{id} změněno, změny: {changes}")
                mo.util.log(
                    type=db.LogType.round,
                    what=id,
                    details={'action': 'edit', 'changes': changes},
                )
    
        form = StatementEditForm()
        if form.validate_on_submit():
            if form.upload.data:
                if form.file.data is not None:
                    file = form.file.data.stream
                    secure_category = werkzeug.utils.secure_filename(round.category)
                    stmt_dir = f'{round.year}-{secure_category}-{round.seq}'
                    full_dir = os.path.join(mo.util.data_dir('statements'), stmt_dir)
                    os.makedirs(full_dir, exist_ok=True)
                    full_name = mo.util.link_to_dir(file.name, full_dir, suffix='.pdf')
                    file_name = os.path.join(stmt_dir, os.path.basename(full_name))
                    app.logger.info(f'Nahráno zadání: {file_name}')
    
                    round.tasks_file = file_name
                    log_changes()
                    sess.commit()
                    flash('Zadání nahráno', 'success')
                    return redirect(url_for('org_round', id=id))
                else:
                    flash('Vyberte si prosím soubor', 'danger')
            if form.delete.data:
                round.tasks_file = None
                log_changes()
                sess.commit()
                flash('Zadání smazáno', 'success')
                return redirect(url_for('org_round', id=id))
    
        return render_template(
            'org_edit_statement.html',
            round=round,
            form=form,
        )
    
    
    class MessageAddForm(FlaskForm):
        title = wtforms.StringField('Nadpis', validators=[validators.Required()])
        markdown = wtforms.TextAreaField(
            'Text zprávičky', description='Zprávičky lze formátovat pomocí Markdownu',
            validators=[validators.Required()],
            render_kw={'rows': 10},
        )
        submit = wtforms.SubmitField(label='Vložit zprávičku')
        preview = wtforms.SubmitField(label='Zobrazit náhled')
    
    
    class MessageRemoveForm(FlaskForm):
        message_id = wtforms.IntegerField(validators=[validators.Required()])
        message_remove = wtforms.SubmitField()
    
    
    @app.route('/org/contest/r/<int:id>/messages/', methods=('GET', 'POST'))
    def org_round_messages(id: int):
        sess = db.get_session()
        round, _, rr = get_round_rr(id, None, True)
    
        if not round.has_messages:
            flash('Toto kolo nemá aktivní zprávičky pro účastníky, aktivujte je v nastavení kola', 'warning')
            return redirect(url_for('org_round', id=id))
    
        messages = sess.query(db.Message).filter_by(round_id=id).order_by(db.Message.created_at).all()
    
        add_form: Optional[MessageAddForm] = None
        remove_form: Optional[MessageRemoveForm] = None
        preview: Optional[db.Message] = None
        if rr.have_right(Right.manage_round):
            add_form = MessageAddForm()
            remove_form = MessageRemoveForm()
    
            if remove_form.validate_on_submit() and remove_form.message_remove.data:
                msg = sess.query(db.Message).get(remove_form.message_id.data)
                if not msg or msg.round_id != id:
                    raise werkzeug.exceptions.NotFound()
                sess.delete(msg)
                sess.commit()
                app.logger.info(f"Zprávička pro kolo {id} odstraněna: {db.row2dict(msg)}")
    
                flash('Zprávička odstraněna', 'success')
                return redirect(url_for('org_round_messages', id=id))
    
            if add_form.validate_on_submit():
                msg = db.Message(
                    round_id=id,
                    created_by=g.user.user_id,
                    created_at=mo.now,
                )
                add_form.populate_obj(msg)
                msg.html = bleach.clean(
                    markdown.markdown(msg.markdown),
                    tags=ALLOWED_TAGS+['p']
                )
    
                if add_form.preview.data:
                    preview = msg
                elif add_form.submit.data:
                    sess.add(msg)
                    sess.commit()
                    app.logger.info(f"Vložena nová zprávička pro kolo {id}: {db.row2dict(msg)}")
    
                    flash('Zprávička úspěšně vložena', 'success')
                    return redirect(url_for('org_round_messages', id=id))
    
        return render_template(
            'org_round_messages.html',
            round=round, rr=rr, messages=messages,
            add_form=add_form, remove_form=remove_form,
            preview=preview,
        )