diff --git a/mo/db.py b/mo/db.py
index c2213eab4097e6f121c1da3a8c69352cfe22dcb0..19b573e1c0c4613f40484c7f47d508bd478a7d83 100644
--- a/mo/db.py
+++ b/mo/db.py
@@ -8,12 +8,13 @@ import re
from sqlalchemy import \
Boolean, Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, \
text, func, \
- create_engine, inspect
+ create_engine, inspect, select
from sqlalchemy.engine import Engine
from sqlalchemy.orm import relationship, sessionmaker, Session, class_mapper, joinedload
from sqlalchemy.orm.attributes import get_history
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.sql.expression import CTE
from typing import Optional, List, Tuple
import mo
@@ -568,6 +569,33 @@ def get_place_parents(place: Place) -> List[Place]:
return sess.query(recq).all()
+def place_descendant_cte(place: Place, max_level: Optional[int] = None) -> CTE:
+ """Konstruuje CTE pro ID všech podřízených míst."""
+
+ sess = get_session()
+
+ topq = (sess.query(Place.place_id)
+ .filter(Place.place_id == place.place_id)
+ .cte('descendants', recursive=True))
+
+ botq = (sess.query(Place.place_id)
+ .join(topq, Place.parent == topq.c.place_id))
+ if max_level is not None:
+ botq = botq.filter(Place.level <= max_level)
+
+ return topq.union(botq)
+
+
+def get_place_descendants(place: Place, min_level: Optional[int] = None, max_level: Optional[int] = None) -> List[Place]:
+ """Zjištění všech podřízených míst v daném rozsahu úrovní."""
+ sess = get_session()
+ cte = place_descendant_cte(place, max_level)
+ q = sess.query(Place).filter(Place.place_id.in_(select([cte])))
+ if min_level is not None:
+ q = q.filter(Place.level >= min_level)
+ return q.all()
+
+
def get_object_changes(obj):
""" Given a model instance, returns dict of pending
changes waiting for database flush/commit.
diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py
index 26f566e11318e46703842d8c03d08cfafbd2b832..62b61a397a82cf492d302cbd78268437d6b87cb0 100644
--- a/mo/web/org_contest.py
+++ b/mo/web/org_contest.py
@@ -2,10 +2,12 @@ from dataclasses import dataclass
from flask import render_template, g, redirect, url_for, flash, request
from flask_wtf import FlaskForm
import flask_wtf.file
+import locale
from markupsafe import Markup
-from sqlalchemy import func, and_
+from sqlalchemy import func, and_, select
from sqlalchemy.orm import joinedload, aliased
from sqlalchemy.orm.query import Query
+from sqlalchemy.dialects.postgresql import insert as pgsql_insert
from typing import Any, List, Tuple, Optional, Sequence, Dict
import werkzeug.exceptions
import wtforms
@@ -17,7 +19,7 @@ from mo.imports import ImportType, create_import
import mo.jobs.submit
from mo.rights import Right, Rights
import mo.util
-from mo.util_format import inflect_number
+from mo.util_format import inflect_number, inflect_by_number
from mo.web import app
import mo.web.util
from mo.web.util import PagerForm
@@ -1187,3 +1189,137 @@ def org_contest_user(contest_id: int, user_id: int):
paper_link=lambda u, p: mo.web.util.org_paper_link(sc.contest, None, u, p),
paper_counts=paper_counts,
)
+
+
+class AdvanceForm(FlaskForm):
+ boundary = IntegerField('Bodová hranice', description="Postoupí všichni účastníci, kteří v minulém kole získali aspoň tolik bodů.", validators=[validators.InputRequired()])
+ status = wtforms.HiddenField()
+ preview = wtforms.SubmitField('Zobrazit návrh')
+ execute = wtforms.SubmitField('Provést')
+
+
+@app.route('/org/contest/c/<int:contest_id>/advance', methods=('GET', 'POST'))
+def org_contest_advance(contest_id: int):
+ sess = db.get_session()
+ conn = sess.connection()
+ contest, rr = get_contest_rr(contest_id, Right.manage_contest)
+
+ def redirect_back():
+ return redirect(url_for('org_contest', id=contest_id))
+
+ round = contest.round
+ if round.state != db.RoundState.preparing:
+ flash('Aktuální kolo není ve stavu přípravy', 'danger')
+ return redirect_back()
+
+ prev_round = sess.query(db.Round).filter_by(year=round.year, category=round.category, seq=round.seq - 1).one_or_none()
+ if prev_round is None:
+ flash('Předchozí kolo nenalezeno', 'danger')
+ return redirect_back()
+ elif prev_round.state != db.RoundState.closed:
+ flash('Předchozí kolo dosud nebylo ukončeno', 'danger')
+ return redirect_back()
+ elif prev_round.level < round.level:
+ flash('Předchozí kolo se koná ve vyšší oblasti než toto kolo', 'danger')
+ return redirect_back()
+
+ prev_contests: List[db.Contest] = []
+ accept_by_place_id: Dict[int, int] = {}
+ reject_by_place_id: Dict[int, int] = {}
+
+ form = AdvanceForm()
+ if form.validate_on_submit():
+ desc_cte = db.place_descendant_cte(contest.place, max_level=prev_round.level)
+ prev_contests = (sess.query(db.Contest)
+ .filter(db.Contest.round == prev_round)
+ .filter(db.Contest.place_id.in_(select([desc_cte])))
+ .options(joinedload(db.Contest.place))
+ .all())
+ prev_contests.sort(key=lambda c: locale.strxfrm(c.place.name or ""))
+
+ accept_by_place_id = {c.place_id: 0 for c in prev_contests}
+ reject_by_place_id = {c.place_id: 0 for c in prev_contests}
+
+ prev_pion_query = (sess.query(db.Participation)
+ .filter(db.Participation.contest_id.in_([c.contest_id for c in prev_contests]))
+ .filter(db.Participation.state.in_((db.PartState.registered, db.PartState.invited, db.PartState.present))))
+ prev_pions = prev_pion_query.all()
+
+ if form.boundary.data > 0:
+ accept_uids = (sess.query(db.Solution.user_id)
+ .select_from(db.Solution)
+ .join(db.Task, and_(db.Task.task_id == db.Solution.task_id, db.Task.round == prev_round))
+ .filter(db.Solution.user_id.in_(prev_pion_query.with_entities(db.Participation.user_id).subquery()))
+ .group_by(db.Solution.user_id)
+ .having(func.sum(db.Solution.points) >= form.boundary.data)
+ .all())
+ accept_uids = [a[0] for a in accept_uids]
+ else:
+ accept_uids = None
+
+ want_execute = form.execute.data
+ if want_execute:
+ app.logger.info(f'Postup: Z kola #{prev_round.round_id} do #{round.round_id}, soutěž #{contest_id}')
+ mo.util.log(
+ type=db.LogType.contest,
+ what=contest_id,
+ details={'action': 'advance'},
+ )
+
+ really_inserted = 0
+ for pp in prev_pions:
+ if accept_uids and pp.user_id not in accept_uids:
+ reject_by_place_id[pp.place_id] += 1
+ continue
+ accept_by_place_id[pp.place_id] += 1
+
+ if want_execute:
+ # ORM neumí ON CONFLICT DO NOTHING, takže musíme o vrstvu níže
+ res = conn.execute(
+ pgsql_insert(db.Participation.__table__)
+ .values(
+ user_id=pp.user_id,
+ contest_id=contest.contest_id,
+ place_id=contest.place.place_id,
+ state=db.PartState.invited,
+ )
+ .on_conflict_do_nothing()
+ .returning(db.Participation.contest_id)
+ )
+ inserted = res.fetchall()
+ if inserted:
+ # Opravdu došlo ke vložení
+ really_inserted += 1
+ app.logger.info(f'Postup: Založena účast user=#{pp.user_id} contest=#{contest_id} place=#{contest.place_id}')
+ mo.util.log(
+ type=db.LogType.participant,
+ what=pp.user_id,
+ details={
+ 'action': 'add-to-contest',
+ # Tady nemůžeme použít obvyklé row2dict, neboť nemáme v ruce ORMový objekt
+ 'new': {
+ 'contest_id': contest.contest_id,
+ 'place_id': contest.place_id,
+ },
+ },
+ )
+
+ if want_execute:
+ sess.commit()
+ msg = (inflect_by_number(really_inserted, 'Pozván', 'Pozváni', 'Pozváno')
+ + ' '
+ + inflect_number(really_inserted, 'nový soutěžící', 'noví soutěžící', 'nových soutěžících')
+ + '.')
+ flash(msg, 'success')
+ return redirect_back()
+
+ return render_template(
+ 'org_contest_advance.html',
+ contest=contest,
+ round=contest.round,
+ prev_round=prev_round,
+ prev_contests=prev_contests,
+ accept_by_place_id=accept_by_place_id,
+ reject_by_place_id=reject_by_place_id,
+ form=form,
+ )
diff --git a/mo/web/templates/org_contest.html b/mo/web/templates/org_contest.html
index b91335053dbfcc99440bfdcfb5fb5849af74d90f..46315999d2421c81e6ce88c16de79982645e6574 100644
--- a/mo/web/templates/org_contest.html
+++ b/mo/web/templates/org_contest.html
@@ -32,11 +32,16 @@
<div class="btn-group">
<a class="btn btn-primary" href='{{ url_for('org_contest_list', id=contest.contest_id, site_id=site_id) }}'>Seznam účastníků</a>
+ {% if round.state != RoundState.preparing %}
<a class="btn btn-primary" href='{{ url_for('org_contest_solutions', id=contest.contest_id, site_id=site_id) }}'>Odevzdaná řešení</a>
+ {% endif %}
{% if not site %}
{% if round.state in [RoundState.grading, RoundState.closed] %}
<a class="btn btn-primary" href='{{ url_for('org_score', contest_id=contest.contest_id) }}'>Výsledky</a>
{% endif %}
+ {% if round.state == RoundState.preparing and round.seq > 1 %}
+ <a class="btn btn-primary" href='{{ url_for('org_contest_advance', contest_id=contest.contest_id) }}'>Postup z minulého kola</a>
+ {% endif %}
{% if can_manage %}
<a class="btn btn-default" href='{{ url_for('org_contest_import', id=contest.contest_id) }}'>Importovat data</a>
{% endif %}
diff --git a/mo/web/templates/org_contest_advance.html b/mo/web/templates/org_contest_advance.html
new file mode 100644
index 0000000000000000000000000000000000000000..f7a86b84908938777a2f9327a3e063d4fc28c6b7
--- /dev/null
+++ b/mo/web/templates/org_contest_advance.html
@@ -0,0 +1,41 @@
+{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+
+{% block title %}Postup z {{ prev_round.round_code() }} ({{ prev_round.name }}) do {{ round.round_code() }} ({{ round.name }}){% endblock %}
+{% block breadcrumbs %}
+{{ contest_breadcrumbs(round=round, contest=contest, action="Postup") }}
+{% endblock %}
+{% block body %}
+
+<form method="POST" class="form form-horizontal" action="">
+ {{ form.csrf_token }}
+ {{ wtf.form_field(form.boundary, form_type='horizontal') }}
+ <div class="btn-group col-lg-offset-2">
+ {{ wtf.form_field(form.preview) }}
+ {{ wtf.form_field(form.execute, class="btn btn-primary") }}
+ </div>
+</form>
+
+{% if form.preview.data or form.execute.data %}
+<h3>Postup z oblastí</h3>
+
+<table class='data'>
+ <thead>
+ <tr><th>Oblast<th>Postoupilo<th>Nepostoupilo
+ <tbody>
+ {% for c in prev_contests %}
+ <tr>
+ <td>{{ c.place.name }}
+ <td>{{ accept_by_place_id[c.place.place_id] }}
+ <td>{{ reject_by_place_id[c.place.place_id] }}
+ {% endfor %}
+ <tfoot>
+ <tr>
+ <th>Celkem
+ <th>{{ accept_by_place_id.values()|sum }}
+ <th>{{ reject_by_place_id.values()|sum }}
+ </tfoot>
+</table>
+{% endif %}
+
+{% endblock %}