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 %}