Project 'mj/mo-submit' was moved to 'mo-p/osmo'. Please update any links and bookmarks that may still have the old path.
Select Git revision
org_round.py
Jiří Kalvoda authored
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,
)