diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py index 90692599af26ad01b6ffa9955c5020560562fc4b..c02bd849a1704548e27dc77db9b4331e075ddff1 100644 --- a/mo/web/org_contest.py +++ b/mo/web/org_contest.py @@ -5,6 +5,7 @@ import locale import os import secrets from sqlalchemy.orm import joinedload +from sqlalchemy.orm.query import Query from typing import List, Tuple, Optional, Sequence import werkzeug.exceptions import wtforms @@ -16,7 +17,8 @@ import mo.imports import mo.rights import mo.util from mo.web import app -from mo.web.table import Table, Column, cell_place_link, cell_user_link, cell_email_link +from mo.web.util import PagerForm +from mo.web.table import CellCheckbox, Table, Column, cell_place_link, cell_user_link, cell_email_link import wtforms.validators as validators @@ -25,6 +27,132 @@ class ImportForm(FlaskForm): submit = wtforms.SubmitField('Importovat') +class ParticipantsFilterForm(PagerForm): + school = wtforms.StringField("Škola") + participation_place = wtforms.StringField("Soutěžní místo") + contest_place = wtforms.StringField("Soutěžní oblast") + participation_state = wtforms.SelectField('Stav účasti', choices=[('*', '*')] + list(db.PartState.choices()), default='*') + + # format = wtforms.RadioField(choices=[('', 'Zobrazit'), ('csv', 'Stáhnout vše v CSV'), ('tsv', 'Stáhnout vše v TSV')]) + submit = wtforms.SubmitField("Zobrazit") + download_csv = wtforms.SubmitField("↓ CSV") + download_tsv = wtforms.SubmitField("↓ TSV") + + +class ParticipantsActionForm(FlaskForm): + action_on = wtforms.RadioField( + "Provést akci na", validators=[validators.DataRequired()], + choices=[('all', 'všech vyfiltrovaných soutěžících'), ('checked', 'označených soutěžících')] + # checkboxes are handled not through FlaskForm, see below + ) + + participation_state = wtforms.SelectField('Stav účasti', choices=list(db.PartState.choices())) + set_participation_state = wtforms.SubmitField("Nastavit stav účasti") + + participation_place = wtforms.StringField( + 'Soutěžní místo', description='Zadejte kód nebo ID ve tvaru <code>#123</code>' + ) + set_participation_place = wtforms.SubmitField("Nastavit soutěžní místo") + + contest_place = wtforms.StringField( + 'Soutěžní oblast', + description='Musí existovat soutěž v dané oblasti pro stejné kolo. Oblast zadejte pomocí kódu nebo ID ve tvaru <code>#123</code>.' + ) + set_contest = wtforms.SubmitField("Přesunout do jiné soutěžní oblasti") + + remove_participation = wtforms.SubmitField("Smazat záznam o účasti") + + def do_action(self, round: db.Round, rights: mo.rights.Rights, query: Query) -> bool: + """Do participation modification on partipations from given query + (possibly filtered by checkboxes). Expects that rights for round/contest + are checked before calling this function, `rights` param are used only + for checking that we can move participation to another contest.""" + + if not self.validate_on_submit(): + return False + + sess = db.get_session() + + # Check that operation is valid + if self.set_participation_state.data: + pass + elif self.set_participation_place.data: + participation_place = db.get_place_by_code(self.participation_place.data) + if not participation_place: + flash('Nenalezeno místo s daným kódem', 'danger') + return False + elif self.set_contest.data: + contest_place = db.get_place_by_code(self.contest_place.data) + if not contest_place: + flash("Nepovedlo se najít zadanou soutěžní oblast", 'danger') + return False + contest = sess.query(db.Contest).filter_by(round_id=round.round_id, place_id=contest_place.place_id).one_or_none() + if not contest: + flash(f"Nepovedlo se najít soutěž v kole {round.round_code()} v oblasti {contest_place.name}", 'danger') + return False + rights.get_for_contest(contest) + if not rights.have_right(mo.rights.Right.manage_contest): + flash(f"Nemáte právo ke správě soutěže v kole {round.round_code()} v oblasti {contest_place.name}, nelze do ní přesunout soutěžící", 'danger') + elif self.remove_participation.data: + pass + else: + flash('Neznámá operace', 'danger') + return False + + try: + user_ids = list(map(int, request.form.getlist('checked'))) + except ValueError: + flash('Data v checkboxech nelze převést na čísla, kontaktujte správce', 'danger') + return False + + count = 0 + ctants = query.all() + for pion, _, _ in ctants: + u = pion.user + if self.action_on.data == 'checked' and u.user_id not in user_ids: + continue + + if self.remove_participation.data: + sess.delete(pion) + app.logger.info(f"Participation of user {u.user_id} in contest {pion.contest} removed") + mo.util.log( + type=db.LogType.participant, + what=u.user_id, + details={'action': 'participation-removed', 'participation': db.row2dict(pion)}, + ) + else: + if self.set_participation_state.data: + pion.state = self.participation_state.data + elif self.set_participation_place.data: + pion.place = participation_place + elif self.set_contest.data: + pion.contest = contest + + changes = db.get_object_changes(pion) + app.logger.info(f"Participation of user {u.user_id} modified, changes: {changes}") + mo.util.log( + type=db.LogType.participant, + what=u.user_id, + details={'action': 'participation-changed', 'changes': changes}, + ) + sess.flush() + count += 1 + + sess.commit() + if count == 0: + flash('Žádní vybraní soutěžící', 'warning') + elif self.set_participation_state.data: + flash(f'Nastaven stav {db.part_state_names[self.participation_state.data]} {count} řešitelům', 'success') + elif self.set_participation_place.data: + flash(f'Nastaveno soutěžní místo {participation_place.name} {count} řešitelům', 'success') + elif self.set_contest.data: + flash(f'{count} řešitelů přesunuto do soutěže v oblasti {contest_place.name}', 'success') + elif self.remove_participation.data: + flash(f'Odstraněno {count} soutěžících z této soutěže', 'success') + + return True + + @app.route('/org/contest/') def org_contest_root(): sess = db.get_session() @@ -73,20 +201,39 @@ def org_round(id: int): ) -@app.route('/org/contest/r/<int:id>/list') +@app.route('/org/contest/r/<int:id>/list', methods=('GET', 'POST')) def org_round_list(id: int): round, rr = get_round_rr(id, mo.rights.Right.manage_contest) format = request.args.get('format', "") - table = make_contestant_table(round, None) + filter = ParticipantsFilterForm(request.args) + filter.validate() + query = get_contestants_query( + round=round, + school=db.get_place_by_code(filter.school.data), + contest_place=db.get_place_by_code(filter.contest_place.data), + participation_place=db.get_place_by_code(filter.participation_place.data), + participation_state=None if filter.participation_state.data == '*' else filter.participation_state.data + ) + + action_form = ParticipantsActionForm() + if action_form.do_action(round=round, rights=rr, query=query): + # Action happened, redirect + return redirect(request.url) + + (count, query) = filter.apply_limits(query, pagesize=50) + # count = query.count() if format == "": + table = make_contestant_table(query, add_contest_column=True, add_checkbox=True) return render_template( 'org_round_list.html', round=round, table=table, + filter=filter, count=count, action_form=action_form, ) else: + table = make_contestant_table(query) return table.send_as(format) @@ -191,20 +338,39 @@ def org_contest_import_template(): return resp -@app.route('/org/contest/c/<int:id>/ucastnici') +@app.route('/org/contest/c/<int:id>/ucastnici', methods=('GET', 'POST')) def org_contest_list(id: int): contest, rr = get_contest_rr(id, mo.rights.Right.manage_contest) format = request.args.get('format', "") - table = make_contestant_table(contest.round, contest) + filter = ParticipantsFilterForm(request.args) + filter.validate() + query = get_contestants_query( + round=contest.round, contest=contest, + school=db.get_place_by_code(filter.school.data), + # contest_place=db.get_place_by_code(filter.contest_place.data), + participation_place=db.get_place_by_code(filter.participation_place.data), + participation_state=None if filter.participation_state.data == '*' else filter.participation_state.data + ) + + action_form = ParticipantsActionForm() + if action_form.do_action(round=contest.round, rights=rr, query=query): + # Action happened, redirect + return redirect(request.url) + + # (count, query) = filter.apply_limits(query, pagesize=50) + count = query.count() if format == "": + table = make_contestant_table(query, add_checkbox=True) return render_template( 'org_contest_list.html', contest=contest, table=table, + filter=filter, count=count, action_form=action_form, ) else: + table = make_contestant_table(query) return table.send_as(format) @@ -221,7 +387,12 @@ contest_list_columns = ( ) -def make_contestant_table(round: db.Round, contest: Optional[db.Contest]) -> Table: +def get_contestants_query( + round: db.Round, contest: Optional[db.Contest] = None, + contest_place: Optional[db.Place] = None, + participation_place: Optional[db.Place] = None, + participation_state: Optional[db.PartState] = None, + school: Optional[db.Place] = None) -> Query: query = (db.get_session() .query(db.Participation, db.Participant, db.Contest) .select_from(db.Participation) @@ -233,10 +404,22 @@ def make_contestant_table(round: db.Round, contest: Optional[db.Contest]) -> Tab query = query.filter(db.Contest.round == round) query = query.options(joinedload(db.Contest.place)) query = query.filter(db.Participation.contest_id == db.Contest.contest_id) + if contest_place: + query = query.filter(db.Contest.place_id == contest_place.place_id) + if participation_place: + query = query.filter(db.Participation.place_id == participation_place.place_id) + if school: + query = query.filter(db.Participant.school == school.place_id) + if participation_state: + query = query.filter(db.Participation.state == participation_state) query = query.options(joinedload(db.Participation.user), joinedload(db.Participation.place), joinedload(db.Participant.school_place)) + return query + + +def make_contestant_table(query: Query, add_checkbox: bool = False, add_contest_column: bool = False): ctants = query.all() rows: List[dict] = [] @@ -254,18 +437,22 @@ def make_contestant_table(round: db.Round, contest: Optional[db.Contest]) -> Tab 'region_code': cell_place_link(ct.place, ct.place.get_code()), 'place_code': cell_place_link(pion.place, pion.place.get_code()), 'status': pion.state.friendly_name(), + 'checkbox': CellCheckbox('checked', u.user_id, False), }) rows.sort(key=lambda r: r['sort_key']) cols: Sequence[Column] = contest_list_columns - if not contest: + if add_checkbox: + cols = [Column(key='checkbox', name=' ', title=' ')] + list(cols) + if add_contest_column: cols = list(cols) + [Column(key='region_code', name='kod_oblasti', title='Oblast')] return Table( columns=cols, rows=rows, filename='ucastnici', + show_downlink=False, # downlinks are in filter ) diff --git a/mo/web/templates/org_contest_list.html b/mo/web/templates/org_contest_list.html index cb61b0768013d09f4da6ab873bc2a5a53861781c..4448ebaa832f99df59a4d133be6212b0ad675bba 100644 --- a/mo/web/templates/org_contest_list.html +++ b/mo/web/templates/org_contest_list.html @@ -1,7 +1,21 @@ {% extends "base.html" %} +{% import "bootstrap/wtf.html" as wtf %} {% block body %} <h2>Účastníci {{ contest.round.round_code() }}: {{ contest.place.name }}</h2> -{{ table.to_html() }} +<form action="" method="GET" class="form form-inline" role="form"> + {{ wtf.form_field(filter.participation_place, placeholder='Kód / #ID', size=8) }} + {{ wtf.form_field(filter.school, placeholder='Kód / #ID', size=8) }} + {{ wtf.form_field(filter.participation_state) }} + <div class="btn-group"> + {{ wtf.form_field(filter.submit, class='btn btn-primary') }} + <button class="btn btn-default" name="format" value="csv" title="Stáhnout celý výsledek v CSV">↓ CSV</button> + <button class="btn btn-default" name="format" value="tsv" title="Stáhnout celý výsledek v TSV">↓ TSV</button> + </div> +</form> + +<p>Celkem <b>{{count}} nalezených soutěžících</b>.</p> + +{% include 'parts/org_participants_table_actions.html' %} {% endblock %} diff --git a/mo/web/templates/org_round_list.html b/mo/web/templates/org_round_list.html index fa958549d713fdf241eed2e9c364b30c678bdd24..59108cf521d1c573ecedbb10dda547edcf361a9f 100644 --- a/mo/web/templates/org_round_list.html +++ b/mo/web/templates/org_round_list.html @@ -1,7 +1,41 @@ {% extends "base.html" %} +{% import "bootstrap/wtf.html" as wtf %} {% block body %} <h2>Účastníci kola {{ round.round_code() }}</h2> -{{ table.to_html() }} +<form action="" method="GET" class="form form-inline" role="form"> + {{ wtf.form_field(filter.contest_place, placeholder='Kód / #ID', size=8) }} + {{ wtf.form_field(filter.participation_place, placeholder='Kód / #ID', size=8) }} + {{ wtf.form_field(filter.school, placeholder='Kód / #ID', size=8) }} + {{ wtf.form_field(filter.participation_state) }} + <div class="btn-group"> + {{ wtf.form_field(filter.submit, class='btn btn-primary') }} + <button class="btn btn-default" name="format" value="csv" title="Stáhnout celý výsledek v CSV">↓ CSV</button> + <button class="btn btn-default" name="format" value="tsv" title="Stáhnout celý výsledek v TSV">↓ TSV</button> + </div> + + <div style="float: right"> + Stránka {{ filter.offset.data // filter.limit.data + 1}} z {{ (count / filter.limit.data)|round(0, 'ceil')|int }}: + <div class="btn-group"> + {% if filter.offset.data > 0 %} + {{ wtf.form_field(filter.previous) }} + {% else %} + <button class="btn" disabled>Předchozí</button> + {% endif %} + {% if count > filter.offset.data + filter.limit.data %} + {{ wtf.form_field(filter.next) }} + {% else %} + <button class="btn" disabled>Další</button> + {% endif %} + </div> + <input type="hidden" name="offset" value="{{filter.offset.data}}"> + <input type="hidden" name="limit" value="{{filter.limit.data}}"> + </div> +</form> + +{% set max = filter.offset.data + filter.limit.data if filter.offset.data + filter.limit.data < count else count %} +<p>Zobrazuji záznamy <b>{{filter.offset.data + 1}}</b> až <b>{{ max }}</b> z <b>{{count}} nalezených soutěžících</b>.</p> + +{% include 'parts/org_participants_table_actions.html' %} {% endblock %} diff --git a/mo/web/templates/parts/org_participants_table_actions.html b/mo/web/templates/parts/org_participants_table_actions.html new file mode 100644 index 0000000000000000000000000000000000000000..56f33a324dc9d677d61999e1f8c7cfadd8395838 --- /dev/null +++ b/mo/web/templates/parts/org_participants_table_actions.html @@ -0,0 +1,34 @@ +<form action="" method="POST" class="form form-horizontal" role="form"> + {{ table.to_html() }} + + {{ action_form.csrf_token }} + <h3>Provést akci</h3> + <div class="form-group"> + <label class="col-form-label col-sm-2">Provést akci na:</label> + <div class="col-sm-10"> + <div class="radio"> + <label> + <input id="action_on-0" name="action_on" type="radio" value="all" required{% if action_form.action_on.data == 'all' %} checked{% endif %}> + všech vyfiltrovaných soutěžících + </label> + </div> + <div class="radio"> + <label> + <input id="action_on-1" name="action_on" type="radio" value="checked" required{% if action_form.action_on.data == 'all' %} checked{% endif %}> + označených soutěžících + </label> + </div> + </div> + </div> + <hr> + {{ wtf.form_field(action_form.participation_state, form_type='horizontal', horizontal_columns=('sm', 2, 10)) }} + {{ wtf.form_field(action_form.set_participation_state, form_type='horizontal', class='btn btn-primary', horizontal_columns=('sm', 2, 10)) }} + <hr> + {{ wtf.form_field(action_form.participation_place, form_type='horizontal', horizontal_columns=('sm', 2, 10)) }} + {{ wtf.form_field(action_form.set_participation_place, form_type='horizontal', class='btn btn-primary', horizontal_columns=('sm', 2, 10)) }} + <hr> + {{ wtf.form_field(action_form.contest_place, form_type='horizontal', horizontal_columns=('sm', 2, 10)) }} + {{ wtf.form_field(action_form.set_contest, form_type='horizontal', class='btn btn-primary', horizontal_columns=('sm', 2, 10)) }} + <hr> + {{ wtf.form_field(action_form.remove_participation, form_type='horizontal', class='btn btn-danger', horizontal_columns=('sm', 2, 10)) }} +</form>