diff --git a/db/db.ddl b/db/db.ddl
index b20d86522fb9feecd227c592cc519bcf22335de7..aa5d3d1b73bc5bc10c05a98b676667a0b836f075 100644
--- a/db/db.ddl
+++ b/db/db.ddl
@@ -355,3 +355,42 @@ CREATE TABLE reg_requests (
 	user_id		int		DEFAULT NULL REFERENCES users(user_id) ON DELETE CASCADE,
 	client		varchar(255)	NOT NULL			-- kdo si registraci vyžádal
 );
+
+-- Statistiky
+
+-- Pro každý region spočítáme všechna podřízená místa
+CREATE VIEW region_descendants AS
+	WITH RECURSIVE descendant_regions(place_id, descendant) AS (
+		SELECT place_id, place_id FROM places
+		UNION SELECT r.place_id, p.place_id
+		      FROM descendant_regions r, places p
+		      WHERE p.parent = r.descendant
+	) SELECT place_id AS region, descendant FROM descendant_regions;
+
+-- Pro každou trojici (kolo, region, stav soutěže) spočítáme soutěže.
+CREATE VIEW region_contest_stats AS
+	SELECT c.round_id, rd.region, c.state, count(*) AS count
+	FROM contests c
+	JOIN region_descendants rd ON rd.descendant = c.place_id
+	WHERE rd.region <> c.place_id
+	GROUP BY c.round_id, rd.region, c.state;
+
+-- Pro každou trojici (kolo, region, stav účasti) spočítáme účastníky.
+-- Pokud se to ukáže být příliš pomalé, přejdeme na materializovaná views.
+CREATE VIEW region_participant_stats AS
+	SELECT c.round_id, rd.region, p.state, count(*) AS count
+	FROM participations p
+	JOIN contests c USING(contest_id)
+	JOIN region_descendants rd ON rd.descendant = c.place_id
+	GROUP BY c.round_id, rd.region, p.state;
+
+-- Pro každou trojici (kolo, region, úloha) spočítáme řešení.
+CREATE VIEW region_task_stats AS
+	SELECT r.round_id, rd.region, t.task_id, count(*) AS count
+	FROM rounds r
+	JOIN contests c USING(round_id)
+	JOIN participations p USING(contest_id)
+	JOIN tasks t USING(round_id)
+	JOIN solutions s USING(user_id, task_id)
+	JOIN region_descendants rd ON rd.descendant = c.place_id
+	GROUP BY r.round_id, rd.region, t.task_id;
diff --git a/db/upgrade-20210924.sql b/db/upgrade-20210924.sql
new file mode 100644
index 0000000000000000000000000000000000000000..c0418ba5f35f60493a881ec5a09442303f88475a
--- /dev/null
+++ b/db/upgrade-20210924.sql
@@ -0,0 +1,38 @@
+SET ROLE 'mo_osmo';
+
+-- Pro každý region spočítáme všechna podřízená místa
+CREATE VIEW region_descendants AS
+	WITH RECURSIVE descendant_regions(place_id, descendant) AS (
+		SELECT place_id, place_id FROM places
+		UNION SELECT r.place_id, p.place_id
+		      FROM descendant_regions r, places p
+		      WHERE p.parent = r.descendant
+	) SELECT place_id AS region, descendant FROM descendant_regions;
+
+-- Pro každou trojici (kolo, region, stav soutěže) spočítáme soutěže.
+CREATE VIEW region_contest_stats AS
+	SELECT c.round_id, rd.region, c.state, count(*) AS count
+	FROM contests c
+	JOIN region_descendants rd ON rd.descendant = c.place_id
+	WHERE rd.region <> c.place_id
+	GROUP BY c.round_id, rd.region, c.state;
+
+-- Pro každou trojici (kolo, region, stav účasti) spočítáme účastníky.
+-- Pokud se to ukáže být příliš pomalé, přejdeme na materializovaná views.
+CREATE VIEW region_participant_stats AS
+	SELECT c.round_id, rd.region, p.state, count(*) AS count
+	FROM participations p
+	JOIN contests c USING(contest_id)
+	JOIN region_descendants rd ON rd.descendant = c.place_id
+	GROUP BY c.round_id, rd.region, p.state;
+
+-- Pro každou trojici (kolo, region, úloha) spočítáme řešení.
+CREATE VIEW region_task_stats AS
+	SELECT r.round_id, rd.region, t.task_id, count(*) AS count
+	FROM rounds r
+	JOIN contests c USING(round_id)
+	JOIN participations p USING(contest_id)
+	JOIN tasks t USING(round_id)
+	JOIN solutions s USING(user_id, task_id)
+	JOIN region_descendants rd ON rd.descendant = c.place_id
+	GROUP BY r.round_id, rd.region, t.task_id;
diff --git a/etc/config.py.example b/etc/config.py.example
index f7db910c8d24c676a30bba7a76e80c983ab6f1db..af31c885e4b8e54e644833d85064433a921fb6df 100644
--- a/etc/config.py.example
+++ b/etc/config.py.example
@@ -53,3 +53,6 @@ REG_MAX_PER_MINUTE = 10
 
 # Jak dlouho vydrží tokeny používané při registraci a změnách e-mailu [min]
 REG_TOKEN_VALIDITY = 10
+
+# Aktuální ročník MO
+CURRENT_YEAR = 71
diff --git a/mo/__init__.py b/mo/__init__.py
index 98d16b9ad30321087b30790a71fe913ae7eec036..d98ba045b8e901ab953bcc40df33a46e096b8c94 100644
--- a/mo/__init__.py
+++ b/mo/__init__.py
@@ -2,9 +2,6 @@
 
 import datetime
 
-# Aktuální ročník
-current_year = 71
-
 # Referenční čas nastavovaný v initu requestu (web) nebo při volání skriptu
 now: datetime.datetime
 
diff --git a/mo/db.py b/mo/db.py
index 000d01ca6fd196edc465f7d3a1afa00177514c47..8794e71534d095c6e15a1ebf24a19adde424da16 100644
--- a/mo/db.py
+++ b/mo/db.py
@@ -11,8 +11,9 @@ from sqlalchemy import \
     text, func, \
     create_engine, inspect, select
 from sqlalchemy.engine import Engine
-from sqlalchemy.orm import relationship, sessionmaker, Session, class_mapper, joinedload
+from sqlalchemy.orm import relationship, sessionmaker, Session, class_mapper, joinedload, aliased
 from sqlalchemy.orm.attributes import get_history
+from sqlalchemy.orm.query import Query
 from sqlalchemy.dialects.postgresql import JSONB
 from sqlalchemy.ext.declarative import declarative_base
 from sqlalchemy.sql.expression import CTE
@@ -705,6 +706,50 @@ class RegRequest(Base):
     user = relationship('User')
 
 
+class RegionDescendant(Base):
+    __tablename__ = 'region_descendants'
+
+    region = Column(Integer, ForeignKey('places.place_id'), primary_key=True)
+    descendant = Column(Integer, ForeignKey('places.place_id'), primary_key=True)
+
+
+class RegionContestStat(Base):
+    __tablename__ = 'region_contest_stats'
+
+    round_id = Column(Integer, ForeignKey('rounds.round_id'), primary_key=True)
+    region = Column(Integer, ForeignKey('places.place_id'), primary_key=True)
+    state = Column(Enum(RoundState, name='round_state'), primary_key=True)
+    count = Column(Integer, nullable=False)
+
+    round = relationship('Round')
+    region_place = relationship('Place', primaryjoin='RegionContestStat.region == Place.place_id', remote_side='Place.place_id')
+
+
+class RegionParticipantStat(Base):
+    __tablename__ = 'region_participant_stats'
+
+    round_id = Column(Integer, ForeignKey('rounds.round_id'), primary_key=True)
+    region = Column(Integer, ForeignKey('places.place_id'), primary_key=True)
+    state = Column(Enum(PartState, name='part_state'), primary_key=True)
+    count = Column(Integer, nullable=False)
+
+    round = relationship('Round')
+    region_place = relationship('Place', primaryjoin='RegionParticipantStat.region == Place.place_id', remote_side='Place.place_id')
+
+
+class RegionTaskStat(Base):
+    __tablename__ = 'region_task_stats'
+
+    round_id = Column(Integer, ForeignKey('rounds.round_id'), primary_key=True)
+    region = Column(Integer, ForeignKey('places.place_id'), primary_key=True)
+    task_id = Column(Integer, ForeignKey('tasks.task_id'), primary_key=True)
+    count = Column(Integer, nullable=False)
+
+    round = relationship('Round')
+    region_place = relationship('Place', primaryjoin='RegionTaskStat.region == Place.place_id', remote_side='Place.place_id')
+    task = relationship('Task')
+
+
 _engine: Optional[Engine] = None
 _session: Optional[Session] = None
 flask_db: Any = None
@@ -731,22 +776,24 @@ def get_seqs() -> List[int]:
     return [seq for (seq,) in get_session().query(Round.seq).distinct()]
 
 
-def get_place_parents(place: Place) -> List[Place]:
+def get_place_ancestors(place: Place) -> List[Place]:
     """Low-level funkce pro zjištění předků místa.
-    Obvykle voláme mo.rights.Gatekeeper.get_parents(), které kešuje."""
+    Obvykle voláme mo.rights.Gatekeeper.get_ancestors(), které kešuje.
+    Pozor, výsledkem není plnohodnotný objekt Place, ale jen named tuple.
+    """
 
     sess = get_session()
 
     topq = (sess.query(Place)
             .filter(Place.place_id == place.place_id)
-            .cte('parents', recursive=True))
+            .cte('ancestors', recursive=True))
 
     botq = (sess.query(Place)
             .join(topq, Place.place_id == topq.c.parent))
 
     recq = topq.union(botq)
 
-    return sess.query(recq).all()
+    return sorted(sess.query(recq).all(), key=lambda p: p.level)
 
 
 def place_descendant_cte(place: Place, max_level: Optional[int] = None) -> CTE:
@@ -776,6 +823,15 @@ def get_place_descendants(place: Place, min_level: Optional[int] = None, max_lev
     return q.all()
 
 
+def filter_place_nth_parent(query: Query, place_attr: Any, n: int, parent_id: int) -> Query:
+    assert n >= 0
+    for _ in range(n):
+        pp = aliased(Place)
+        query = query.join(pp, pp.place_id == place_attr)
+        place_attr = pp.parent
+    return query.filter(place_attr == parent_id)
+
+
 def get_object_changes(obj):
     """ Given a model instance, returns dict of pending
     changes waiting for database flush/commit.
diff --git a/mo/imports.py b/mo/imports.py
index 005f62f029da3f0a8840b9c16d462352ce53b38a..8e894710bdca8d88b1a5d845b368d1d40a2c33e0 100644
--- a/mo/imports.py
+++ b/mo/imports.py
@@ -5,8 +5,9 @@ import io
 import re
 from sqlalchemy import and_
 from sqlalchemy.orm import joinedload, Query
-from typing import List, Optional, Any, Dict, Type, Union
+from typing import List, Optional, Any, Dict, Type, Union, Set
 
+import mo.config as config
 import mo.csv
 from mo.csv import FileFormat, MissingHeaderError
 import mo.db as db
@@ -56,6 +57,7 @@ class Import:
     user: db.User
     round: Optional[db.Round]
     contest: Optional[db.Contest]
+    only_region: Optional[db.Place]
     task: Optional[db.Task]         # pro Import bodů
     allow_add_del: bool             # pro Import bodů: je povoleno zakládat/mazat řešení
     fmt: FileFormat
@@ -230,19 +232,30 @@ class Import:
             self.cnt_new_participations += 1
         return pion
 
+    def place_is_allowed(self, place: db.Place) -> bool:
+        if self.contest is not None and self.contest.place_id != place.place_id:
+            return False
+        if self.only_region is not None and not self.gatekeeper.is_ancestor_of(self.only_region, place):
+            return False
+        return True
+
     def obtain_contest(self, oblast: Optional[db.Place], allow_none: bool = False):
+        assert self.round
+        if oblast is not None and not self.place_is_allowed(oblast):
+            return self.error('Oblast neodpovídá té, do které se importuje')
         if self.contest:
             contest = self.contest
-            if oblast is not None and oblast.place_id != contest.place.place_id:
-                return self.error('Oblast neodpovídá té, do které se importuje')
         else:
+            # Zde mluvíme o oblastech, místo abychom používali place_levels,
+            # protože sloupec má ve jménu oblast a také je potřeba rozlišovat školu
+            # účastníka a školu jako oblast.
             if oblast is None:
                 if not allow_none:
-                    self.error('Je nutné uvést ' + self.round.get_level().name)
+                    self.error('Je nutné uvést kód oblasti')
                 return None
             contest = db.get_session().query(db.Contest).filter_by(round=self.round, place=oblast).one_or_none()
             if contest is None:
-                return self.error('V ' + self.round.get_level().name_locative("uvedeném", "uvedené", "uvedeném") + ' toto kolo neprobíhá')
+                return self.error('V uvedené oblasti toto kolo neprobíhá')
 
         return contest
 
@@ -280,6 +293,8 @@ class Import:
             args.append(f'round=#{self.round.round_id}')
         if self.contest is not None:
             args.append(f'contest=#{self.contest.contest_id}')
+        if self.only_region is not None:
+            args.append(f'region=#{self.only_region.place_id}')
         if self.task is not None:
             args.append(f'task=#{self.task.task_id}')
 
@@ -300,17 +315,21 @@ class Import:
                 args.append(f'{key}={val}')
         logger.info('Import: Hotovo (%s)', " ".join(args))
 
+        details = self.log_details.copy()
+        if self.only_region:
+            details['region'] = self.only_region.place_id
+
         if self.contest is not None:
             mo.util.log(
                 type=db.LogType.contest,
                 what=self.contest.contest_id,
-                details=self.log_details,
+                details=details,
             )
         elif self.round is not None:
             mo.util.log(
                 type=db.LogType.round,
                 what=self.round.round_id,
-                details=self.log_details,
+                details=details,
             )
         else:
             assert False
@@ -449,7 +468,7 @@ class ContestImport(Import):
         if user is None:
             return
 
-        part = self.find_or_create_participant(user, mo.current_year, school_place.place_id if school_place else None, rok_naroz, rocnik)
+        part = self.find_or_create_participant(user, config.CURRENT_YEAR, school_place.place_id if school_place else None, rok_naroz, rocnik)
         if part is None:
             return
 
@@ -587,6 +606,9 @@ class PointsImport(Import):
             query = query.filter(db.Participation.contest_id == self.contest.master_contest_id)
         else:
             contest_query = sess.query(db.Contest.master_contest_id).filter_by(round=self.round)
+            if self.only_region:
+                assert self.round
+                contest_query = db.filter_place_nth_parent(contest_query, db.Contest.place_id, self.round.level - self.only_region.level, self.only_region.place_id)
             query = query.filter(db.Participation.contest_id.in_(contest_query.subquery()))
 
         return query
@@ -614,7 +636,13 @@ class PointsImport(Import):
         query = self._pion_sol_query().filter(db.Participation.user_id == user_id)
         pion_sols = query.all()
         if not pion_sols:
-            return self.error('Soutěžící nenalezen v tomto kole')
+            if self.contest is not None:
+                msg = self.round.get_level().name_locative('tomto', 'této', 'tomto')
+            elif self.only_region is not None:
+                msg = self.only_region.get_level().name_locative('tomto', 'této', 'tomto')
+            else:
+                msg = 'tomto kole'
+            return self.error(f'Soutěžící nenalezen v {msg}')
         elif len(pion_sols) > 1:
             return self.error('Soutěžící v tomto kole soutěží vícekrát, neumím zpracovat')
         pion, sol = pion_sols[0]
@@ -624,10 +652,6 @@ class PointsImport(Import):
         else:
             contest = sess.query(db.Contest).filter_by(round=self.round, master_contest_id=pion.contest_id).one()
 
-        if self.contest is not None:
-            if contest != self.contest:
-                return self.error('Soutěžící nesoutěží v ' + self.round.get_level().name_locative('tomto', 'této', 'tomto'))
-
         rights = self.gatekeeper.rights_for_contest(contest)
         if not rights.can_edit_points():
             return self.error('Nemáte právo na úpravu bodů')
@@ -708,6 +732,7 @@ def create_import(user: db.User,
                   fmt: FileFormat,
                   round: Optional[db.Round] = None,
                   contest: Optional[db.Contest] = None,
+                  only_region: Optional[db.Place] = None,
                   task: Optional[db.Task] = None,
                   allow_add_del: bool = False):
     imp: Import
@@ -725,6 +750,7 @@ def create_import(user: db.User,
     imp.user = user
     imp.round = round
     imp.contest = contest
+    imp.only_region = only_region
     imp.task = task
     imp.allow_add_del = allow_add_del
     imp.fmt = fmt
diff --git a/mo/jobs/submit.py b/mo/jobs/submit.py
index 129d3302f4931958f6d081fd55ee03ea1ab85f16..c6e52c2f849414360b90fc6e0fb123993ec510eb 100644
--- a/mo/jobs/submit.py
+++ b/mo/jobs/submit.py
@@ -5,6 +5,7 @@ import os
 import re
 import shutil
 from sqlalchemy import and_
+from sqlalchemy.orm import joinedload
 from tempfile import NamedTemporaryFile
 from typing import List, Optional
 import unicodedata
@@ -84,6 +85,7 @@ def handle_download_submits(the_job: TheJob):
 def schedule_upload_feedback(round: db.Round, tmp_file: str, description: str, for_user: db.User,
                              only_contest: Optional[db.Contest],
                              only_site: Optional[db.Place],
+                             only_region: Optional[db.Place],
                              only_task: Optional[db.Task]):
     the_job = TheJob()
     job = the_job.create(db.JobType.upload_feedback, for_user)
@@ -92,6 +94,7 @@ def schedule_upload_feedback(round: db.Round, tmp_file: str, description: str, f
         'round_id': round.round_id,
         'only_contest_id': only_contest.contest_id if only_contest is not None else None,
         'only_site_id': only_site.place_id if only_site is not None else None,
+        'only_region_id': only_region.place_id if only_region is not None else None,
         'only_task_id': only_task.task_id if only_task is not None else None,
     }
     job.in_file = the_job.attach_file(tmp_file, '.zip')
@@ -165,12 +168,19 @@ def handle_upload_feedback(the_job: TheJob):
     round_id: int = in_json['round_id']  # type: ignore
     only_contest_id: Optional[int] = in_json['only_contest_id']  # type: ignore
     only_site_id: Optional[int] = in_json['only_site_id']  # type: ignore
+    only_region_id: Optional[int] = in_json['only_region_id']  # type: ignore
     only_task_id: Optional[int] = in_json['only_task_id']  # type: ignore
 
     sess = db.get_session()
     round = sess.query(db.Round).get(round_id)
     assert round is not None
 
+    if only_region_id is not None:
+        only_region = sess.query(db.Place).get(only_region_id)
+        assert only_region is not None
+    else:
+        only_region = None
+
     files: List[UploadFeedback] = []
 
     def parse_zip(in_path: str):
@@ -219,6 +229,7 @@ def handle_upload_feedback(the_job: TheJob):
                 .join(db.Contest, db.Contest.master_contest_id == db.Participation.contest_id)
                 .filter(db.Contest.round == round)
                 .filter(db.Participation.user_id.in_(user_dict.keys()))
+                .options(joinedload(db.Contest.place))
                 .all())
 
         user_rights = {}
@@ -237,6 +248,8 @@ def handle_upload_feedback(the_job: TheJob):
                 the_job.error(f'{f.file_name}: Účastník leží mimo vybranou soutěž')
             elif only_site_id is not None and site_id_dict[f.user_id] != only_site_id:
                 the_job.error(f'{f.file_name}: Účastník leží mimo vybrané soutěžní místo')
+            elif only_region is not None and not the_job.gatekeeper.is_ancestor_of(only_region, contest_dict[f.user_id].place):
+                the_job.error(f'{f.file_name}: Účastník leží mimo vybraný region')
             elif not user_rights[f.user_id]:
                 the_job.error(f'{f.file_name}: K tomuto účastníkovi nemáte dostatečná oprávnění')
 
diff --git a/mo/rights.py b/mo/rights.py
index e7873c327954b593fdfcdb7d1fbe96222f3ff13b..c8d549a3d6133702f9917217f795ff441dd436b0 100644
--- a/mo/rights.py
+++ b/mo/rights.py
@@ -198,31 +198,6 @@ class Rights:
             return self.have_right(Right.edit_orgs)
         return self.have_right(Right.edit_users)
 
-    # Interní rozhodovaní o dostupnosti zadání
-
-    def _check_view_statement(self, round: db.Round):
-        if round.tasks_file is None:
-            return False
-
-        if self.have_right(Right.manage_round):
-            # Správce kola může vždy všechno
-            return True
-
-        # Pokud už soutěž skončila, přístup k zadání má každý org.
-        # XXX: Rozhodujeme podle stavu kola, nikoliv soutěže!
-        if round.state in [db.RoundState.grading, db.RoundState.closed]:
-            return True
-
-        # Od stanoveného času vidí zadání orgové s právem view_statement.
-        if (self.have_right(Right.view_statement)
-                and round.state != db.RoundState.preparing
-                and round.pr_tasks_start is not None
-                and mo.now >= round.pr_tasks_start):
-            return True
-
-        # Ve zbylých případech jsme konzervativní a zadání neukazujeme
-        return False
-
 
 class RoundRights(Rights):
     """Práva ke kolu."""
@@ -237,8 +212,11 @@ class RoundRights(Rights):
     # Metody offer_* testují, zda se má v UI nabízet příslušná funkce.
     # Skutečnou kontrolu práv dělá až implementace funkce podle stavu soutěže.
 
+    def _get_state(self) -> db.RoundState:
+        return self.round.state
+
     def _is_active(self) -> bool:
-        return self.round.state not in [db.RoundState.preparing, db.RoundState.closed]
+        return self._get_state() not in [db.RoundState.preparing, db.RoundState.closed]
 
     def offer_upload_solutions(self) -> bool:
         return (self.have_right(Right.upload_submits)
@@ -252,11 +230,47 @@ class RoundRights(Rights):
         return (self.have_right(Right.manage_contest)
                 or (self.have_right(Right.edit_points) and self._is_active()))
 
-    def can_view_statement(self) -> bool:
-        return self._check_view_statement(self.round)
+    def can_upload_solutions(self) -> bool:
+        return (self.have_right(Right.upload_submits)
+                or self.have_right(Right.upload_solutions) and self._get_state() == db.RoundState.running)
+
+    def can_upload_feedback(self) -> bool:
+        return (self.have_right(Right.upload_submits)
+                or self.have_right(Right.upload_feedback) and self._get_state() == db.RoundState.grading)
+
+    def can_edit_points(self) -> bool:
+        return (self.have_right(Right.edit_points) and self._get_state() == db.RoundState.grading
+                or self.have_right(Right.manage_contest))
 
+    def can_create_solutions(self) -> bool:
+        return self.can_upload_solutions() or self.can_upload_feedback()
 
-class ContestRights(Rights):
+    def can_view_statement(self):
+        round = self.round
+        if round.tasks_file is None:
+            return False
+
+        if self.have_right(Right.manage_round):
+            # Správce kola může vždy všechno
+            return True
+
+        # Pokud už soutěž skončila, přístup k zadání má každý org.
+        # XXX: Rozhodujeme podle stavu kola, nikoliv soutěže!
+        if round.state in [db.RoundState.grading, db.RoundState.closed]:
+            return True
+
+        # Od stanoveného času vidí zadání orgové s právem view_statement.
+        if (self.have_right(Right.view_statement)
+                and round.state != db.RoundState.preparing
+                and round.pr_tasks_start is not None
+                and mo.now >= round.pr_tasks_start):
+            return True
+
+        # Ve zbylých případech jsme konzervativní a zadání neukazujeme
+        return False
+
+
+class ContestRights(RoundRights):
     """Práva k soutěži."""
 
     contest: db.Contest
@@ -264,22 +278,10 @@ class ContestRights(Rights):
     def __repr__(self):
         ros = " ".join([r.role.name for r in self.user_roles])
         ris = " ".join([r.name for r in self.rights])
-        return f"ContestRights(uid={self.user.user_id} is_admin={self.user.is_admin} contest=#{self.contest.contest_id} roles=<{ros}> rights=<{ris}>)"
-
-    def can_upload_solutions(self) -> bool:
-        return (self.have_right(Right.upload_submits)
-                or self.have_right(Right.upload_solutions) and self.contest.state == db.RoundState.running)
-
-    def can_upload_feedback(self) -> bool:
-        return (self.have_right(Right.upload_submits)
-                or self.have_right(Right.upload_feedback) and self.contest.state == db.RoundState.grading)
+        return f"ContestRights(uid={self.user.user_id} is_admin={self.user.is_admin} round=#{self.round.round_id} contest=#{self.contest.contest_id} roles=<{ros}> rights=<{ris}>)"
 
-    def can_edit_points(self) -> bool:
-        return (self.have_right(Right.edit_points) and self.contest.state == db.RoundState.grading
-                or self.have_right(Right.manage_contest))
-
-    def can_view_statement(self) -> bool:
-        return self._check_view_statement(self.contest.round)
+    def _get_state(self) -> db.RoundState:
+        return self.contest.state
 
 
 class Gatekeeper:
@@ -299,12 +301,17 @@ class Gatekeeper:
         self.parent_cache = {}
         self.rights_cache = {}
 
-    def get_parents(self, place: db.Place) -> List[db.Place]:
+    def get_ancestors(self, place: db.Place) -> List[db.Place]:
         pid = place.place_id
         if pid not in self.parent_cache:
-            self.parent_cache[pid] = db.get_place_parents(place)
+            self.parent_cache[pid] = db.get_place_ancestors(place)
         return self.parent_cache[pid]
 
+    def is_ancestor_of(self, ancestor: db.Place, of: db.Place) -> bool:
+        ancestors = self.get_ancestors(of)
+        parent_ids = set(p.place_id for p in ancestors)
+        return ancestor.place_id in parent_ids
+
     def rights_for(
             self, place: Optional[db.Place] = None, year: Optional[int] = None,
             cat: Optional[str] = None, seq: Optional[int] = None,
@@ -336,7 +343,7 @@ class Gatekeeper:
             for role in self.roles:
                 try_role(role, None)
         else:
-            for at in self.get_parents(place):
+            for at in self.get_ancestors(place):
                 for role in self.roles:
                     try_role(role, at)
 
@@ -347,9 +354,11 @@ class Gatekeeper:
         """Posbírá role a práva, ale ignoruje omezení rolí na místa a soutěže. Hodí se pro práva k editaci uživatelů apod."""
         return self.rights_for()
 
-    def rights_for_round(self, round: db.Round, any_place: bool) -> RoundRights:
+    def rights_for_round(self, round: db.Round, any_place: bool = False, for_place: Optional[db.Place] = None) -> RoundRights:
         if any_place:
             place = None
+        elif for_place:
+            place = for_place
         else:
             place = db.get_root_place()
         rights = RoundRights()
@@ -364,6 +373,7 @@ class Gatekeeper:
 
     def rights_for_contest(self, contest: db.Contest, site: Optional[db.Place] = None) -> ContestRights:
         rights = ContestRights()
+        rights.round = contest.round
         rights.contest = contest
         rights._clone_from(self.rights_for(
             place=site or contest.place,
diff --git a/mo/web/acct.py b/mo/web/acct.py
index cd3bc74e15d377bc877887ec678a6f6a2fbcab9d..a07888d3dd7b1682c5e3bbf0102374e26097384a 100644
--- a/mo/web/acct.py
+++ b/mo/web/acct.py
@@ -120,7 +120,7 @@ def user_settings():
     if g.user.is_org or g.user.is_admin:
         pant = None
     else:
-        pant = sess.query(db.Participant).get((g.user.user_id, mo.current_year))
+        pant = sess.query(db.Participant).get((g.user.user_id, config.CURRENT_YEAR))
 
     return render_template('settings.html', user=g.user, pant=pant, roles=roles, roles_by_type=mo.rights.roles_by_type)
 
diff --git a/mo/web/jinja.py b/mo/web/jinja.py
index c81543bad49d165a1fd15c3582881a363bd5fd18..04cb8b0c66dc5aeac382aa43f86c59639a642749 100644
--- a/mo/web/jinja.py
+++ b/mo/web/jinja.py
@@ -9,9 +9,9 @@ import urllib.parse
 
 import mo.config as config
 import mo.db as db
+from mo.rights import Right
 import mo.util_format as util_format
 from mo.web import app
-from mo.web.org_contest import contest_breadcrumbs
 from mo.web.org_place import place_breadcrumbs
 from mo.web.util import user_html_flags
 
@@ -47,10 +47,10 @@ app.jinja_env.globals.update(JobState=db.JobState)
 
 # Další typy:
 app.jinja_env.globals.update(Markup=Markup)
+app.jinja_env.globals.update(Right=Right)
 
 # Vlastní pomocné funkce
 
-app.jinja_env.globals.update(contest_breadcrumbs=contest_breadcrumbs)
 app.jinja_env.globals.update(place_breadcrumbs=place_breadcrumbs)
 # Funkce asset_url se přidává v mo.ext.assets
 
@@ -60,6 +60,7 @@ def user_link(u: db.User) -> Markup:
     return Markup('<a href="{url}">{name}{test}</a>').format(url=user_url(u), name=u.full_name(), test=" (test)" if u.is_test else "")
 
 
+@app.template_filter()
 def user_url(u: db.User) -> str:
     if u.is_admin or u.is_org:
         return url_for('org_org', id=u.user_id)
@@ -69,7 +70,7 @@ def user_url(u: db.User) -> str:
 
 @app.template_filter()
 def pion_link(u: db.User, contest_id: int) -> Markup:
-    url = url_for('org_contest_user', contest_id=contest_id, user_id=u.user_id)
+    url = url_for('org_contest_user', ct_id=contest_id, user_id=u.user_id)
     return Markup('<a href="{url}">{name}{test}</a>').format(url=url, name=u.full_name(), test=" (test)" if u.is_test else "")
 
 
diff --git a/mo/web/org.py b/mo/web/org.py
index aebbfae957f8f0677327553d3a9a4e20b7671015..4830309acbfad5967c549b10761912eb9e5d962b 100644
--- a/mo/web/org.py
+++ b/mo/web/org.py
@@ -1,8 +1,10 @@
+from dataclasses import dataclass, field
 from flask import render_template, redirect, url_for, request, flash, g
 from sqlalchemy import and_, or_
 from sqlalchemy.orm import aliased, joinedload
-from typing import List, Set, Dict
+from typing import List, Set, Optional
 
+import mo.config as config
 import mo.db as db
 import mo.rights
 import mo.users
@@ -11,6 +13,15 @@ from mo.web.jinja import user_url
 from mo.web.table import Table, Row, Column
 
 
+@dataclass
+class OrgOverview:
+    round: db.Round
+    place: db.Place
+    contest: Optional[db.Contest]
+    role_set: Set[db.RoleType] = field(default_factory=set)
+    role_list: List[db.RoleType] = field(default_factory=list)
+
+
 @app.route('/org/')
 def org_index():
     if 'place' in request.args:
@@ -32,36 +43,33 @@ def org_index():
             flash('ID uživatele musí být číslo', 'danger')
 
     sess = db.get_session()
-    ctr = (sess.query(db.Contest, db.UserRole)
-               .select_from(db.UserRole, db.Round, db.Contest)
-               .filter(and_(db.UserRole.user_id == g.user.user_id,
-                            or_(db.UserRole.category == None, db.UserRole.category == db.Round.category),
-                            or_(db.UserRole.year == None, db.UserRole.year == db.Round.year),
-                            or_(db.UserRole.seq == None, db.UserRole.seq == db.Round.seq),
-                            db.Round.year == mo.current_year,
-                            db.Contest.round_id == db.Round.round_id,
-                            db.Contest.place_id == db.UserRole.place_id))
-               .options(joinedload(db.Contest.place))
+    rcu = (sess.query(db.Round, db.Contest, db.UserRole)
+               .select_from(db.UserRole)
+               .join(db.Place)
+               .join(db.Round, and_(db.UserRole.user_id == g.user.user_id,
+                    or_(db.UserRole.category == None, db.UserRole.category == db.Round.category),
+                    or_(db.UserRole.year == None, db.UserRole.year == db.Round.year),
+                    or_(db.UserRole.seq == None, db.UserRole.seq == db.Round.seq),
+                    db.Place.level <= db.Round.level))
+               .outerjoin(db.Contest, and_(db.Contest.round_id == db.Round.round_id, db.Contest.place_id == db.UserRole.place_id))
+               .filter(db.Round.year == config.CURRENT_YEAR)
+               .options(joinedload(db.UserRole.place))
                .order_by(db.Round.level, db.Round.category, db.Round.seq, db.Round.part,
                          db.Contest.place_id, db.Contest.contest_id)
                .all())
 
-    # Pokud máme pro jednu soutěž více rolí, zkombinujeme je
-    contests: List[db.Contest] = []
-    contest_role_sets: Dict[db.Contest, Set[db.RoleType]] = {}
-    for ct, ur in ctr:
-        if len(contests) == 0 or contests[-1] != ct:
-            contests.append(ct)
-            contest_role_sets[ct.contest_id] = set()
-        contest_role_sets[ct.contest_id].add(ur.role)
-
-    # Role pro každou soutěž setřídíme podle důležitosti
-    contest_roles: Dict[db.Contest, List[db.RoleType]] = {
-        ct_id: sorted(list(contest_role_sets[ct_id]), key=lambda r: mo.rights.role_order_by_type[r])
-        for ct_id in contest_role_sets.keys()
-    }
-
-    return render_template('org_index.html', contests=contests, contest_roles=contest_roles, role_type_names=db.role_type_names)
+    overview: List[OrgOverview] = []
+    for r, ct, ur in rcu:
+        o = overview[-1] if overview else None
+        if not (o and o.round == r and o.place == ur.place):
+            o = OrgOverview(round=r, place=ur.place, contest=ct)
+            overview.append(o)
+        o.role_set.add(ur.role)
+
+    for o in overview:
+        o.role_list = sorted(o.role_set, key=lambda r: mo.rights.role_order_by_type[r])
+
+    return render_template('org_index.html', overview=overview, role_type_names=db.role_type_names)
 
 
 school_export_columns = (
diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py
index 0a17429c8203b2b150d4357ebb562b8ca3db7910..675e5f490f787fd03e9850d3d8b1b1f39c1c9389 100644
--- a/mo/web/org_contest.py
+++ b/mo/web/org_contest.py
@@ -1,4 +1,3 @@
-from dataclasses import dataclass
 from flask import render_template, g, redirect, url_for, flash, request
 from flask_wtf import FlaskForm
 import flask_wtf.file
@@ -8,10 +7,13 @@ 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 sqlalchemy.sql.schema
+from typing import Any, List, Tuple, Optional, Dict
 import urllib.parse
 import werkzeug.exceptions
 import wtforms
+import wtforms.validators as validators
+from wtforms.widgets.html5 import NumberInput
 
 import mo
 from mo.csv import FileFormat
@@ -19,33 +21,190 @@ import mo.config as config
 import mo.db as db
 from mo.imports import ImportType, create_import
 import mo.jobs.submit
-from mo.rights import Right, ContestRights
+from mo.rights import Right, RoundRights
 import mo.util
 from mo.util_format import inflect_number, inflect_by_number
 from mo.web import app
 import mo.web.fields as mo_fields
 import mo.web.util
 from mo.web.util import PagerForm
-from mo.web.table import CellCheckbox, Table, Row, Column, cell_pion_link, cell_place_link, cell_email_link, cell_email_link_flags
-import wtforms.validators as validators
-from wtforms.widgets.html5 import NumberInput
+from mo.web.table import CellCheckbox, Table, Row, Column, cell_pion_link, cell_place_link, cell_email_link_flags
 
 
-class ImportForm(FlaskForm):
-    file = flask_wtf.file.FileField("Soubor", render_kw={'autofocus': True})
-    typ = wtforms.SelectField(
-        "Typ dat",
-        choices=[(x.name, x.friendly_name()) for x in (ImportType.participants, ImportType.proctors, ImportType.judges)],
-        coerce=ImportType.coerce,
-        default=ImportType.participants,
-    )
-    fmt = wtforms.SelectField(
-        "Formát souboru",
-        choices=FileFormat.choices(), coerce=FileFormat.coerce,
-        default=FileFormat.cs_csv,
-    )
-    submit = wtforms.SubmitField('Importovat')
-    get_template = wtforms.SubmitField('Stáhnout šablonu')
+class Context:
+    # Kolo máme vždy
+    round: db.Round
+    master_round: db.Round
+
+    # Vypočtená práva: Může být implementováno i jako ContestRights
+    rights: RoundRights
+
+    # Můžeme mít vybranou soutěž a místo
+    # Pro nedělená kola platí contest == master_contest.
+    # Operace s účastníky by měly probíhat vždy přes master_contest.
+    contest: Optional[db.Contest] = None
+    master_contest: Optional[db.Contest] = None
+    site: Optional[db.Place] = None
+
+    # Můžeme se omezit na soutěže v dané oblasti
+    hier_place: Optional[db.Place] = None
+
+    # Účastník a úloha
+    pion: Optional[db.Participation] = None
+    user: Optional[db.User] = None
+    task: Optional[db.Task] = None
+
+    # IDčka, jak je předáváme do URL
+    round_id: Optional[int] = None
+    ct_id: Optional[int] = None
+    site_id: Optional[int] = None
+    hier_id: Optional[int] = None
+    user_id: Optional[int] = None
+    task_id: Optional[int] = None
+
+    def url_for(self, endpoint: str, **kwargs):
+        a = {}
+        round_id = kwargs.get('round_id', self.round_id)
+        ct_id = kwargs.get('ct_id', self.ct_id)
+        if ct_id is not None:
+            a['ct_id'] = ct_id
+        else:
+            assert round_id is not None
+            a['round_id'] = round_id
+        for arg in ('site_id', 'hier_id', 'user_id', 'task_id'):
+            val = getattr(self, arg)
+            if val is not None:
+                a[arg] = val
+        for arg, val in kwargs.items():
+            a[arg] = val
+        return url_for(endpoint, **a)
+
+    def url_home(self):
+        if self.ct_id:
+            return url_for('org_contest', ct_id=self.ct_id)
+        else:
+            return url_for('org_round', round_id=self.round_id, hier_id=self.hier_id)
+
+    def breadcrumbs(self, table: bool = False, action: Optional[str] = None) -> Markup:
+        elements = [(url_for('org_rounds'), 'Soutěže')]
+        elements.append((url_for('org_round', round_id=self.round_id), self.round.round_code()))
+        if self.hier_place:
+            parents = g.gatekeeper.get_ancestors(self.hier_place)
+            for p in parents[1:]:
+                elements.append((url_for('org_round', round_id=self.round_id, hier_id=p.place_id), p.name or '???'))
+        if self.contest:
+            if self.round.level >= 2:
+                parents = g.gatekeeper.get_ancestors(self.contest.place)
+                for i in range(1, len(parents) - 1):
+                    p = parents[i]
+                    if p.level >= 3:
+                        break
+                    elements.append((url_for('org_round', round_id=self.round_id, hier_id=p.place_id), db.Place.get_code(p)))
+            elements.append((url_for('org_contest', ct_id=self.ct_id), self.contest.place.name or '???'))
+        if self.site:
+            elements.append((url_for('org_contest', ct_id=self.ct_id, site_id=self.site_id), f"soutěžní místo {self.site.name}"))
+        if self.task:
+            elements.append((
+                url_for('org_contest_task', ct_id=self.ct_id, site_id=self.site_id, task_id=self.task_id) if self.contest
+                else url_for('org_round_task_edit', round_id=self.round_id, task_id=self.task_id),
+                f"{self.task.code} {self.task.name}"
+            ))
+        if self.user:
+            elements.append((url_for('org_contest_user', ct_id=self.ct_id, user_id=self.user_id), self.user.full_name()))
+        if table:
+            if self.contest:
+                elements.append((url_for('org_generic_list', ct_id=self.ct_id, site=self.site_id), "Seznam účastníků"))
+            else:
+                elements.append((url_for('org_generic_list', round_id=self.round_id), "Seznam účastníků"))
+        if action:
+            elements.append(('', action))
+
+        return Markup(
+            "\n".join([f"<li><a href='{url}'>{name}</a>" for url, name in elements[:-1]])
+            + "<li>" + elements[-1][1]
+        )
+
+
+def get_context(round_id: Optional[int] = None,
+                ct_id: Optional[int] = None,
+                site_id: Optional[int] = None,
+                hier_id: Optional[int] = None,
+                user_id: Optional[int] = None,
+                task_id: Optional[int] = None,
+                right_needed: Optional[Right] = None,
+                ) -> Context:
+
+    ctx = Context()
+    ctx.round_id = round_id
+    ctx.ct_id = ct_id
+    ctx.site_id = site_id
+    ctx.hier_id = hier_id
+    ctx.user_id = user_id
+    ctx.task_id = task_id
+
+    sess = db.get_session()
+
+    if site_id is not None:
+        assert ct_id is not None
+        ctx.site = db.get_session().query(db.Place).get(site_id)
+        if not ctx.site:
+            raise werkzeug.exceptions.NotFound()
+
+    if hier_id is not None:
+        assert ct_id is None
+        ctx.hier_place = db.get_session().query(db.Place).get(hier_id)
+        if not ctx.hier_place:
+            raise werkzeug.exceptions.NotFound()
+
+    if ct_id is not None:
+        ctx.contest = (db.get_session().query(db.Contest)
+                       .options(joinedload(db.Contest.place),
+                                joinedload(db.Contest.round),
+                                joinedload(db.Contest.master).joinedload(db.Contest.round))
+                       .get(ct_id))
+        if not ctx.contest:
+            raise werkzeug.exceptions.NotFound()
+        ctx.master_contest = ctx.contest.master
+        ctx.round, ctx.master_round = ctx.contest.round, ctx.master_contest.round
+        if round_id is not None and ctx.round.round_id != round_id:
+            raise werkzeug.exceptions.NotFound()
+        ctx.round_id = ctx.round.round_id
+        ctx.rights = g.gatekeeper.rights_for_contest(ctx.contest, ctx.site)
+    else:
+        ctx.round = sess.query(db.Round).options(joinedload(db.Round.master)).get(round_id)
+        if not ctx.round:
+            raise werkzeug.exceptions.NotFound()
+        if hier_id is not None and ctx.hier_place.level > ctx.round.level:
+            raise werkzeug.exceptions.NotFound()
+        ctx.master_round = ctx.round.master
+        ctx.rights = g.gatekeeper.rights_for_round(ctx.round, for_place=ctx.hier_place)
+
+    # Zkontrolujeme, zda se účastník opravdu účastní soutěže
+    if user_id is not None:
+        assert ctx.master_contest is not None
+        ctx.pion = (sess.query(db.Participation)
+                    .filter_by(user_id=user_id, contest_id=ctx.master_contest.contest_id)
+                    .options(joinedload(db.Participation.place),
+                             joinedload(db.Participation.user))
+                    .one_or_none())
+        if not ctx.pion:
+            raise werkzeug.exceptions.NotFound()
+        ctx.user = ctx.pion.user
+
+        # A zda soutěží na zadaném soutěžním místě, je-li určeno
+        if site_id is not None and site_id != ctx.pion.place_id:
+            raise werkzeug.exceptions.NotFound()
+
+    # Najdeme úlohu a ověříme, že je součástí kola
+    if task_id is not None:
+        ctx.task = sess.query(db.Task).get(task_id)
+        if not ctx.task or ctx.task.round != ctx.round:
+            raise werkzeug.exceptions.NotFound()
+
+    if not (right_needed is None or ctx.rights.have_right(right_needed)):
+        raise werkzeug.exceptions.Forbidden()
+
+    return ctx
 
 
 class ParticipantsFilterForm(PagerForm):
@@ -59,6 +218,7 @@ class ParticipantsFilterForm(PagerForm):
     download_csv = wtforms.SubmitField("↓ CSV")
     download_tsv = wtforms.SubmitField("↓ TSV")
 
+
 class ParticipantsActionForm(FlaskForm):
     action_on = wtforms.RadioField(
         "Provést akci na", validators=[validators.DataRequired()],
@@ -120,20 +280,7 @@ class ParticipantsActionForm(FlaskForm):
             flash('Data v checkboxech nelze převést na čísla, kontaktujte správce', 'danger')
             return False
 
-        # Check all participations if we can edit them
         ctants: List[Tuple[db.Participation, Any, Any]] = query.all()
-        for pion, _, _ in ctants:
-            u = pion.user
-            if self.action_on.data == 'checked' and u.user_id not in user_ids:
-                continue
-            rr = g.gatekeeper.rights_for_contest(pion.contest)
-            if not rr.have_right(Right.manage_contest):
-                flash(
-                    f"Nemáte právo ke správě soutěže v kole {round.round_code_short()} {pion.contest.place.name_locative()} "
-                    + f"(účastník {u.full_name()}). Žádná akce nebyla provedena.", 'danger'
-                )
-                return False
-
         count = 0
         unchanged = 0
         for pion, _, _ in ctants:
@@ -154,6 +301,7 @@ class ParticipantsActionForm(FlaskForm):
                 if self.set_participation_state.data:
                     pion.state = self.participation_state.data
                 elif self.set_participation_place.data:
+                    assert participation_place
                     pion.place_id = participation_place.place_id
                 elif self.set_contest.data:
                     pion.contest_id = contest.contest_id
@@ -183,12 +331,14 @@ class ParticipantsActionForm(FlaskForm):
                 'success'
             )
         elif self.set_participation_place.data:
+            assert participation_place
             flash(
                 f'Nastaveno soutěžní místo {participation_place.name} '
                 + inflect_number(count, 'účastníkovi', 'účastníkům', 'účastníkům'),
                 'success'
             )
         elif self.set_contest.data:
+            assert contest_place
             flash(
                 inflect_number(count, 'účastník přesunut', 'účastníci přesunuti', 'účastníků přesunuto')
                 + f' do soutěže {contest_place.name_locative()}',
@@ -204,109 +354,19 @@ class ParticipantsActionForm(FlaskForm):
         return True
 
 
-def get_contest(id: int) -> Tuple[db.Contest, db.Contest]:
-    """Vrací contest a master_contest pro zadané contest_id.
-    Pro nedělená kola platí contest == master_contest.
-    Operace s účastníky by měly probíhat vždy přes master_contest."""
-    contest = (db.get_session().query(db.Contest)
-               .options(joinedload(db.Contest.place),
-                        joinedload(db.Contest.round),
-                        joinedload(db.Contest.master).joinedload(db.Contest.round))
-               .get(id))
-    if not contest:
-        raise werkzeug.exceptions.NotFound()
-    return contest, contest.master
-
-
-def get_contest_rr(id: int, right_needed: Optional[Right] = None) -> Tuple[db.Contest, db.Contest, ContestRights]:
-    """Vrací contest, master_contest a ContestRights objekt pro zadané contest_id.
-    Pro nedělená kola platí contest == master_contest.
-    Operace s účastníky by měly probíhat vždy přes master_contest."""
-    contest, master_contest = get_contest(id)
-
-    rr = g.gatekeeper.rights_for_contest(contest)
-
-    if not (right_needed is None or rr.have_right(right_needed)):
-        raise werkzeug.exceptions.Forbidden()
-
-    return contest, master_contest, rr
-
-
-def get_contest_site_rr(id: int, site_id: Optional[int], right_needed: Optional[Right] = None) -> Tuple[db.Contest, db.Contest, Optional[db.Place], ContestRights]:
-    """Vrací contest, master_contest, optional site a ContestRights objekt pro zadané contest_id a site_id.
-    Pro nedělená kola platí contest == master_contest.
-    Operace s účastníky by měly probíhat vždy přes master_contest."""
-    if site_id is None:
-        contest, master_contest, rr = get_contest_rr(id, right_needed)
-        return contest, master_contest, None, rr
-
-    contest, master_contest = get_contest(id)
-    site = db.get_session().query(db.Place).get(site_id)
-    if not site:
-        raise werkzeug.exceptions.NotFound()
-
-    rr = g.gatekeeper.rights_for_contest(contest, site)
-
-    if not (right_needed is None or rr.have_right(right_needed)):
-        raise werkzeug.exceptions.Forbidden()
-
-    return contest, master_contest, site, rr
-
-
-def contest_breadcrumbs(
-    round: Optional[db.Round] = None, contest: Optional[db.Contest] = None,
-    site: Optional[db.Place] = None, task: Optional[db.Task] = None,
-    user: Optional[db.User] = None, action: Optional[str] = None,
-    table: Optional[bool] = False
-) -> Markup:
-    elements = [(url_for('org_rounds'), 'Soutěže')]
-    round_id = None
-    if round:
-        round_id = round.round_id
-        elements.append((url_for('org_round', id=round_id), round.round_code()))
-    ct_id = None
-    if contest:
-        ct_id = contest.contest_id
-        elements.append((url_for('org_contest', id=ct_id), contest.place.name))
-    site_id = None
-    if site:
-        site_id = site.place_id
-        elements.append((url_for('org_contest', id=ct_id, site_id=site_id), f"soutěžní místo {site.name}"))
-    if task:
-        task_id = task.task_id
-        elements.append((
-            url_for('org_contest_task', contest_id=ct_id, site_id=site_id, task_id=task_id) if ct_id
-            else url_for('org_round_task_edit', id=round_id, task_id=task_id),
-            f"{task.code} {task.name}"
-        ))
-    if user:
-        user_id = user.user_id
-        elements.append((url_for('org_contest_user', contest_id=ct_id, user_id=user_id), user.full_name()))
-    if table:
-        if contest:
-            elements.append((url_for('org_contest_list', id=ct_id, site=site_id), "Seznam účastníků"))
-        else:
-            elements.append((url_for('org_round_list', id=round_id), "Seznam účastníků"))
-    if action:
-        elements.append(('', action))
-
-    return Markup(
-        "\n".join([f"<li><a href='{url}'>{name}</a>" for url, name in elements[:-1]])
-        + "<li>" + elements[-1][1]
-    )
-
-
-@app.route('/org/contest/c/<int:id>')
-@app.route('/org/contest/c/<int:id>/site/<int:site_id>/')
-def org_contest(id: int, site_id: Optional[int] = None):
+@app.route('/org/contest/c/<int:ct_id>/')
+@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/')
+def org_contest(ct_id: int, site_id: Optional[int] = None):
     sess = db.get_session()
-    contest, master_contest, site, rr = get_contest_site_rr(id, site_id, None)
-    round = contest.round
-
-    tasks_subq = sess.query(db.Task.task_id).filter_by(round=contest.round)
-    pions_subq = sess.query(db.Participation.user_id).filter_by(contest=master_contest)
-    if site:
-        pions_subq = pions_subq.filter_by(place=site)
+    ctx = get_context(ct_id=ct_id, site_id=site_id)
+    contest = ctx.contest
+    assert contest
+    rights = ctx.rights
+
+    tasks_subq = sess.query(db.Task.task_id).filter_by(round=ctx.round)
+    pions_subq = sess.query(db.Participation.user_id).filter_by(contest=ctx.master_contest)
+    if ctx.site:
+        pions_subq = pions_subq.filter_by(place=ctx.site)
     sol_counts_q = (
         sess.query(db.Solution.task_id, func.count(db.Solution.task_id))
         .filter(
@@ -319,7 +379,7 @@ def org_contest(id: int, site_id: Optional[int] = None):
     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 = sess.query(db.Task).filter_by(round=ctx.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
@@ -330,7 +390,7 @@ def org_contest(id: int, site_id: Optional[int] = None):
             sess.query(db.Place, func.count('*'))
             .select_from(db.Participation).join(db.Place)
             .group_by(db.Place)
-            .filter(db.Participation.contest == master_contest).all()
+            .filter(db.Participation.contest == ctx.master_contest).all()
         )
 
     group_contests = contest.get_group_contests(True)
@@ -338,27 +398,50 @@ def org_contest(id: int, site_id: Optional[int] = None):
 
     return render_template(
         'org_contest.html',
-        contest=contest, group_contests=group_contests, site=site,
-        rights=sorted(rr.rights, key=lambda r: r.name),
-        roles=[r.friendly_name() for r in rr.get_roles()],
-        can_manage=rr.have_right(Right.manage_contest),
-        can_upload=rr.can_upload_feedback(),
-        can_edit_points=rr.can_edit_points(),
-        can_create_solutions=rr.can_upload_feedback() or rr.can_upload_solutions(),
-        can_view_statement=rr.can_view_statement(),
+        ctx=ctx, rights=rights,
+        round=ctx.round, contest=contest, site=ctx.site,
+        group_contests=group_contests,
+        rights_list=sorted(rights.rights, key=lambda r: r.name),
+        roles=[r.friendly_name() for r in rights.get_roles()],
         tasks=tasks, places_counts=places_counts,
     )
 
 
-def generic_import(round: db.Round, master_round: db.Round, contest: Optional[db.Contest], master_contest: Optional[db.Contest]):
-    """Společná funkce pro importování do soutěží a kol"""
+@app.route('/doc/import')
+def doc_import():
+    return render_template('doc_import.html')
+
+
+class ImportForm(FlaskForm):
+    file = flask_wtf.file.FileField("Soubor", render_kw={'autofocus': True})
+    typ = wtforms.SelectField(
+        "Typ dat",
+        choices=[(x.name, x.friendly_name()) for x in (ImportType.participants, ImportType.proctors, ImportType.judges)],
+        coerce=ImportType.coerce,
+        default=ImportType.participants,
+    )
+    fmt = wtforms.SelectField(
+        "Formát souboru",
+        choices=FileFormat.choices(), coerce=FileFormat.coerce,
+        default=FileFormat.cs_csv,
+    )
+    submit = wtforms.SubmitField('Importovat')
+    get_template = wtforms.SubmitField('Stáhnout šablonu')
+
+
+@app.route('/org/contest/c/<int:ct_id>/import', methods=('GET', 'POST'))
+@app.route('/org/contest/r/<int:round_id>/import', methods=('GET', 'POST'))
+@app.route('/org/contest/r/<int:round_id>/h/<int:hier_id>/import', methods=('GET', 'POST'))
+def org_generic_import(round_id: Optional[int] = None, hier_id: Optional[int] = None, ct_id: Optional[int] = None):
+    ctx = get_context(round_id=round_id, hier_id=hier_id, ct_id=ct_id, right_needed=Right.manage_contest)
+    round, contest = ctx.master_round, ctx.master_contest
 
     form = ImportForm()
     errs = []
     warnings = []
     if form.validate_on_submit():
         fmt = form.fmt.data
-        imp = create_import(user=g.user, type=form.typ.data, fmt=fmt, round=master_round, contest=master_contest)
+        imp = create_import(user=g.user, type=form.typ.data, fmt=fmt, round=round, contest=contest, only_region=ctx.hier_place)
         if form.submit.data:
             if form.file.data is not None:
                 file = form.file.data.stream
@@ -369,10 +452,7 @@ def generic_import(round: db.Round, master_round: db.Round, contest: Optional[db
                         flash('Soubor neobsahoval žádné řádky s daty', 'danger')
                     else:
                         flash(f'Importováno ({imp.cnt_rows} řádků, založeno {imp.cnt_new_users} uživatelů, {imp.cnt_new_participations} účastí, {imp.cnt_new_roles} rolí)', 'success')
-                        if contest is not None:
-                            return redirect(url_for('org_contest', id=contest.contest_id))
-                        else:
-                            return redirect(url_for('org_round', id=round.round_id))
+                        return redirect(ctx.url_home())
                 else:
                     errs = imp.errors
                     warnings = imp.warnings
@@ -387,6 +467,7 @@ def generic_import(round: db.Round, master_round: db.Round, contest: Optional[db
 
     return render_template(
         'org_generic_import.html',
+        ctx=ctx,
         contest=contest,
         round=round,
         form=form,
@@ -395,28 +476,21 @@ def generic_import(round: db.Round, master_round: db.Round, contest: Optional[db
     )
 
 
-@app.route('/doc/import')
-def doc_import():
-    return render_template('doc_import.html')
-
-
-@app.route('/org/contest/c/<int:id>/import', methods=('GET', 'POST'))
-def org_contest_import(id: int):
-    contest, master_contest, rr = get_contest_rr(id, Right.manage_contest)
-    return generic_import(
-        round=contest.round, master_round=master_contest.round,
-        contest=contest, master_contest=master_contest
-    )
-
-
 # URL je explicitně uvedeno v mo.email.contestant_list_url
-@app.route('/org/contest/c/<int:id>/participants', methods=('GET', 'POST'))
-@app.route('/org/contest/c/<int:id>/site/<int:site_id>/participants', methods=('GET', 'POST'))
-@app.route('/org/contest/c/<int:id>/participants/emails', endpoint="org_contest_list_emails")
-@app.route('/org/contest/c/<int:id>/site/<int:site_id>/participants/emails', endpoint="org_contest_list_emails")
-def org_contest_list(id: int, site_id: Optional[int] = None):
-    contest, master_contest, site, rr = get_contest_site_rr(id, site_id, Right.view_contestants)
-    can_edit = rr.have_right(Right.manage_contest) and request.endpoint != 'org_contest_list_emails'
+@app.route('/org/contest/c/<int:ct_id>/participants', methods=('GET', 'POST'))
+@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/participants', methods=('GET', 'POST'))
+@app.route('/org/contest/c/<int:ct_id>/participants/emails', endpoint="org_generic_list_emails")
+@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/participants/emails', endpoint="org_generic_list_emails")
+@app.route('/org/contest/r/<int:round_id>/participants', methods=('GET', 'POST'))
+@app.route('/org/contest/r/<int:round_id>/participants/emails', endpoint="org_generic_list_emails")
+@app.route('/org/contest/r/<int:round_id>/h/<int:hier_id>/participants', methods=('GET', 'POST'))
+@app.route('/org/contest/r/<int:round_id>/h/<int:hier_id>/participants/emails', endpoint="org_generic_list_emails")
+def org_generic_list(round_id: Optional[int] = None, hier_id: Optional[int] = None,
+                     ct_id: Optional[int] = None, site_id: Optional[int] = None):
+    ctx = get_context(round_id=round_id, hier_id=hier_id, ct_id=ct_id, site_id=site_id, right_needed=Right.view_contestants)
+    round, contest = ctx.master_round, ctx.master_contest
+    rr = ctx.rights
+    can_edit = rr.have_right(Right.manage_contest) and request.endpoint != 'org_generic_list_emails'
     format = request.args.get('format', "")
 
     filter = ParticipantsFilterForm(formdata=request.args)
@@ -424,7 +498,7 @@ def org_contest_list(id: int, site_id: Optional[int] = None):
         filter.validate()
 
     query = get_contestants_query(
-        round=master_contest.round, contest=master_contest, site=site,
+        ctx=ctx,
         school=filter.school.place,
         contest_place=filter.contest_place.place,
         participation_place=filter.participation_place.place,
@@ -434,7 +508,7 @@ def org_contest_list(id: int, site_id: Optional[int] = None):
     action_form = None
     if can_edit:
         action_form = ParticipantsActionForm()
-        if action_form.do_action(round=contest.round, query=query):
+        if action_form.do_action(round=round, query=query):
             # Action happened, redirect
             return redirect(request.url)
 
@@ -442,23 +516,26 @@ def org_contest_list(id: int, site_id: Optional[int] = None):
         table = None
         emails = None
         mailto_link = None
-        if request.endpoint == 'org_contest_list_emails':
-            (emails, mailto_link) = get_contestant_emails(query,
-                    mailto_subject=f'{contest.round.name} {contest.round.category} {contest.place.name_locative()}')
+        if request.endpoint == 'org_generic_list_emails':
+            if contest:
+                subj = f'{contest.round.name} {contest.round.category} {contest.place.name_locative()}'
+            else:
+                subj = f'{round.name} kategorie {round.category}'
+            (emails, mailto_link) = get_contestant_emails(query, mailto_subject=subj)
             count = len(emails)
         else:
-            # (count, query) = filter.apply_limits(query, pagesize=50)
-            count = db.get_count(query)
-            table = make_contestant_table(query, master_contest.round, add_checkbox=can_edit)
+            (count, query) = filter.apply_limits(query, pagesize=50)
+            table = make_contestant_table(query, round, add_contest_column=(contest is None), add_checkbox=can_edit)
 
         return render_template(
-            'org_contest_list.html',
-            contest=contest, site=site,
+            'org_generic_list.html',
+            ctx=ctx,
+            contest=contest, round=round, site=ctx.site,
             table=table, emails=emails, mailto_link=mailto_link,
             filter=filter, count=count, action_form=action_form,
         )
     else:
-        table = make_contestant_table(query, master_contest.round, is_export=True)
+        table = make_contestant_table(query, round, is_export=True)
         return table.send_as(format)
 
 
@@ -476,8 +553,7 @@ contest_list_columns = (
 
 
 def get_contestants_query(
-        round: db.Round, contest: Optional[db.Contest] = None,
-        site: Optional[db.Place] = None,
+        ctx: Context,
         contest_place: Optional[db.Place] = None,
         participation_place: Optional[db.Place] = None,
         participation_state: Optional[db.PartState] = None,
@@ -487,15 +563,18 @@ def get_contestants_query(
              .select_from(db.Participation)
              .join(db.Participant, db.Participant.user_id == db.Participation.user_id)
              .join(db.User, db.User.user_id == db.Participation.user_id)
-             .filter(db.Participant.year == round.year))
-    if contest:
-        query = query.join(db.Contest, db.Contest.contest_id == contest.contest_id)
+             .join(db.Contest)
+             .filter(db.Participant.year == ctx.round.year)
+             .filter(db.Participation.contest_id == db.Contest.contest_id))
+    if ctx.contest:
+        query = query.filter(db.Contest.contest_id == ctx.contest.contest_id)
+        if ctx.site:
+            query = query.filter(db.Participation.place_id == ctx.site.place_id)
     else:
-        query = query.filter(db.Contest.round == round)
+        query = query.filter(db.Contest.round == ctx.round)
+        if ctx.hier_place:
+            query = db.filter_place_nth_parent(query, db.Contest.place_id, ctx.round.level - ctx.hier_place.level, ctx.hier_place.place_id)
         query = query.options(joinedload(db.Contest.place))
-    query = query.filter(db.Participation.contest_id == db.Contest.contest_id)
-    if site:
-        query = query.filter(db.Participation.place_id == site.place_id)
     if contest_place:
         query = query.filter(db.Contest.place_id == contest_place.place_id)
     if participation_place:
@@ -570,91 +649,6 @@ def get_contestant_emails(query: Query, mailto_subject: str = '[OSMO] Zpráva pr
     return (emails, mailto_link)
 
 
-@dataclass
-class SolutionContext:
-    contest: db.Contest
-    master_contest: db.Contest
-    round: db.Round
-    master_round: db.Round
-    pion: Optional[db.Participation]
-    user: Optional[db.User]
-    task: Optional[db.Task]
-    site: Optional[db.Place]
-    allow_view: bool
-    allow_upload_solutions: bool
-    allow_upload_feedback: bool
-    allow_create_solutions: bool
-    allow_edit_points: bool
-
-
-def get_solution_context(contest_id: int, user_id: Optional[int], task_id: Optional[int], site_id: Optional[int]) -> SolutionContext:
-    sess = db.get_session()
-
-    # Nejprve zjistíme, zda existuje soutěž
-    contest, master_contest = get_contest(contest_id)
-    round = contest.round
-    master_round = master_contest.round
-
-    # Najdeme úlohu a ověříme, že je součástí soutěže
-    if task_id is not None:
-        task = sess.query(db.Task).get(task_id)
-        if not task or task.round != round:
-            raise werkzeug.exceptions.NotFound()
-    else:
-        task = None
-
-    site = None
-    user = None
-    if user_id is not None:
-        # Zkontrolujeme, zda se účastník opravdu účastní soutěže
-        pion = (sess.query(db.Participation)
-                .filter_by(user_id=user_id, contest_id=master_contest.contest_id)
-                .options(joinedload(db.Participation.place),
-                         joinedload(db.Participation.user))
-                .one_or_none())
-        if not pion:
-            raise werkzeug.exceptions.NotFound()
-        user = pion.user
-
-        # A zda soutěží na zadaném soutěžním místě, je-li určeno
-        if site_id is not None and site_id != pion.place_id:
-            raise werkzeug.exceptions.NotFound()
-
-        # Pokud je uvedeno soutěžní místo, hledáme práva k němu, jinak k soutěži
-        if site_id is not None:
-            site = pion.place
-    else:
-        pion = None
-
-        if site_id is not None:
-            site = sess.query(db.Place).get(site_id)
-            if not site:
-                raise werkzeug.exceptions.NotFound()
-
-    rr = g.gatekeeper.rights_for_contest(contest, site)
-
-    allow_view = rr.have_right(Right.view_submits)
-    if not allow_view:
-        raise werkzeug.exceptions.Forbidden()
-
-    allow_upload_solutions = rr.can_upload_solutions()
-    allow_upload_feedback = rr.can_upload_feedback()
-    return SolutionContext(
-        contest=contest, master_contest=master_contest,
-        round=round, master_round=master_round,
-        pion=pion,
-        user=user,
-        task=task,
-        site=site,
-        # XXX: Potřebujeme tohle všechno? Nechceme spíš vracet rr a nechat každého, ať na něm volá metody?
-        allow_view=allow_view,
-        allow_upload_solutions=allow_upload_solutions,
-        allow_upload_feedback=allow_upload_feedback,
-        allow_create_solutions=allow_upload_solutions or allow_upload_feedback,
-        allow_edit_points=rr.can_edit_points(),
-    )
-
-
 class SubmitForm(FlaskForm):
     note = wtforms.TextAreaField("Poznámka pro účastníka", description="Viditelná účastníkovi po uzavření kola", render_kw={'autofocus': True})
     org_note = wtforms.TextAreaField("Interní poznámka", description="Viditelná jen organizátorům")
@@ -674,15 +668,15 @@ class SetFinalForm(FlaskForm):
     submit_final = wtforms.SubmitField("Prohlásit za finální")
 
 
-@app.route('/org/contest/c/<int:contest_id>/submit/<int:user_id>/<int:task_id>/', methods=('GET', 'POST'))
-@app.route('/org/contest/c/<int:contest_id>/site/<int:site_id>/submit/<int:user_id>/<int:task_id>/', methods=('GET', 'POST'))
-def org_submit_list(contest_id: int, user_id: int, task_id: int, site_id: Optional[int] = None):
-    sc = get_solution_context(contest_id, user_id, task_id, site_id)
-    assert sc.user is not None
-    assert sc.task is not None
+@app.route('/org/contest/c/<int:ct_id>/submit/<int:user_id>/<int:task_id>/', methods=('GET', 'POST'))
+@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/submit/<int:user_id>/<int:task_id>/', methods=('GET', 'POST'))
+def org_submit_list(ct_id: int, user_id: int, task_id: int, site_id: Optional[int] = None):
+    ctx = get_context(ct_id=ct_id, site_id=site_id, user_id=user_id, task_id=task_id, right_needed=Right.view_submits)
+    assert ctx.contest and ctx.user and ctx.task
+    rights = ctx.rights
     sess = db.get_session()
 
-    self_url = url_for('org_submit_list', contest_id=contest_id, user_id=user_id, task_id=task_id, site_id=site_id)
+    self_url = ctx.url_for('org_submit_list')
 
     # Najdeme řešení úlohy (nemusí existovat)
     sol = (sess.query(db.Solution)
@@ -690,7 +684,7 @@ def org_submit_list(contest_id: int, user_id: int, task_id: int, site_id: Option
            .one_or_none())
 
     set_final_form: Optional[SetFinalForm] = None
-    if sol and sc.allow_upload_feedback:
+    if sol and rights.can_upload_feedback():
         set_final_form = SetFinalForm()
         if set_final_form.validate_on_submit() and set_final_form.submit_final.data:
             is_submit = set_final_form.type.data == "submit"
@@ -737,7 +731,7 @@ def org_submit_list(contest_id: int, user_id: int, task_id: int, site_id: Option
             return redirect(self_url)
 
     form = SubmitForm(obj=sol)
-    form.points.widget = NumberInput(min=0, max=sc.task.max_points, step=sc.master_round.points_step)  # min a max v HTML
+    form.points.widget = NumberInput(min=0, max=ctx.task.max_points, step=ctx.master_round.points_step)  # min a max v HTML
     if form.validate_on_submit():
         if sol and form.delete.data:
             if sol.final_submit or sol.final_feedback:
@@ -747,14 +741,14 @@ def org_submit_list(contest_id: int, user_id: int, task_id: int, site_id: Option
                 sess.delete(sol)
                 mo.util.log(
                     type=db.LogType.participant,
-                    what=sc.user.user_id,
+                    what=ctx.user.user_id,
                     details={
                         'action': 'solution-removed',
                         'task': task_id,
                     },
                 )
                 sess.commit()
-                app.logger.info(f"Řešení úlohy {sc.task.code} od účastníka {sc.user.user_id} smazáno")
+                app.logger.info(f"Řešení úlohy {ctx.task.code} od účastníka {ctx.user.user_id} smazáno")
             return redirect(self_url)
 
         points = form.points.data
@@ -763,28 +757,28 @@ def org_submit_list(contest_id: int, user_id: int, task_id: int, site_id: Option
             flash('Schází soubor k nahrání, žádné změny nebyly uloženy', 'danger')
             return redirect(self_url)
         if points:
-            error = mo.util.check_points(points, for_task=sc.task, for_round=sc.round)
+            error = mo.util.check_points(points, for_task=ctx.task, for_round=ctx.round)
             if error:
                 flash(error, 'danger')
                 return redirect(self_url)
 
-        if not sol and (sc.allow_edit_points or sc.allow_upload_solutions or sc.allow_upload_feedback):
+        if not sol and (rights.can_edit_points() or rights.can_upload_solutions() or rights.can_upload_feedback()):
             flash('Řešení založeno', 'success')
-            sol = db.Solution(task=sc.task, user=sc.user)
+            sol = db.Solution(task=ctx.task, user=ctx.user)
             sess.add(sol)
             mo.util.log(
                 type=db.LogType.participant,
-                what=sc.user.user_id,
+                what=ctx.user.user_id,
                 details={
                     'action': 'solution-created',
                     'task': task_id,
                 },
             )
             sess.commit()
-            app.logger.info(f"Řešení úlohy {sc.task.code} od účastníka {sc.user.user_id} založeno")
+            app.logger.info(f"Řešení úlohy {ctx.task.code} od účastníka {ctx.user.user_id} založeno")
 
         # Edit sol and points
-        if sol and sc.allow_edit_points:
+        if sol and rights.can_edit_points():
             # Sol edit
             sol.note = form.note.data
             sol.org_note = form.org_note.data
@@ -794,7 +788,7 @@ def org_submit_list(contest_id: int, user_id: int, task_id: int, site_id: Option
             if points != sol.points:
                 sol.points = points
                 sess.add(db.PointsHistory(
-                    task=sc.task,
+                    task=ctx.task,
                     participant=sol.user,
                     user=g.user,
                     points_at=mo.now,
@@ -806,7 +800,7 @@ def org_submit_list(contest_id: int, user_id: int, task_id: int, site_id: Option
                 changes = db.get_object_changes(sol)
                 mo.util.log(
                     type=db.LogType.participant,
-                    what=sc.user.user_id,
+                    what=ctx.user.user_id,
                     details={
                         'action': 'solution-edit',
                         'task': task_id,
@@ -814,22 +808,22 @@ def org_submit_list(contest_id: int, user_id: int, task_id: int, site_id: Option
                     },
                 )
                 sess.commit()
-                app.logger.info(f"Řešení úlohy {sc.task.code} od účastníka {sc.user.user_id} modifikováno, změny: {changes}")
+                app.logger.info(f"Řešení úlohy {ctx.task.code} od účastníka {ctx.user.user_id} modifikováno, změny: {changes}")
 
-        if (form.submit_sol.data and sc.allow_upload_solutions) or (form.submit_fb.data and sc.allow_upload_feedback):
+        if (form.submit_sol.data and rights.can_upload_solutions()) or (form.submit_fb.data and rights.can_upload_feedback()):
             file = form.file.data.stream
 
-            if sc.allow_upload_solutions and form.submit_sol.data:
+            if rights.can_upload_solutions() and form.submit_sol.data:
                 type = db.PaperType.solution
-            elif sc.allow_upload_feedback and form.submit_fb.data:
+            elif rights.can_upload_feedback() and form.submit_fb.data:
                 type = db.PaperType.feedback
             else:
                 raise werkzeug.exceptions.Forbidden()
 
-            assert sc.task is not None and sc.user is not None
-            paper = db.Paper(task=sc.task, for_user_obj=sc.user, uploaded_by_obj=g.user, type=type, note=form.file_note.data)
+            assert ctx.task is not None and ctx.user is not None
+            paper = db.Paper(task=ctx.task, for_user_obj=ctx.user, uploaded_by_obj=g.user, type=type, note=form.file_note.data)
             submitter = mo.submit.Submitter()
-            self_url = url_for('org_submit_list', contest_id=contest_id, user_id=user_id, task_id=task_id, site_id=site_id)
+            self_url = url_for('org_submit_list', ct_id=ct_id, user_id=user_id, task_id=task_id, site_id=site_id)
 
             try:
                 submitter.submit_paper(paper, file.name)
@@ -859,7 +853,7 @@ def org_submit_list(contest_id: int, user_id: int, task_id: int, site_id: Option
         return redirect(self_url)
 
     papers = (sess.query(db.Paper)
-              .filter_by(for_user_obj=sc.user, task=sc.task)
+              .filter_by(for_user_obj=ctx.user, task=ctx.task)
               .options(joinedload(db.Paper.uploaded_by_obj))
               .order_by(db.Paper.uploaded_at.desc())
               .all())
@@ -868,21 +862,21 @@ def org_submit_list(contest_id: int, user_id: int, task_id: int, site_id: Option
     fb_papers = [p for p in papers if p.type == db.PaperType.feedback]
 
     points_history = (sess.query(db.PointsHistory)
-                      .filter_by(task=sc.task, participant=sc.user)
+                      .filter_by(task=ctx.task, participant=ctx.user)
                       .options(joinedload(db.PointsHistory.user))
                       .order_by(db.PointsHistory.points_at.desc())
                       .all())
 
     return render_template(
         'org_submit_list.html',
-        sc=sc,
+        ctx=ctx, rights=rights,
         solution=sol,
         sol_papers=sol_papers,
         fb_papers=fb_papers,
         points_history=points_history,
         for_site=(site_id is not None),
-        paper_link=lambda p: mo.web.util.org_paper_link(sc.contest, sc.site, sc.user, p),
-        orig_paper_link=lambda p: mo.web.util.org_paper_link(sc.contest, sc.site, sc.user, p, orig=True),
+        paper_link=lambda p: mo.web.util.org_paper_link(ctx.contest, ctx.site, ctx.user, p),
+        orig_paper_link=lambda p: mo.web.util.org_paper_link(ctx.contest, ctx.site, ctx.user, p, orig=True),
         form=form,
         set_final_form=set_final_form,
     )
@@ -894,11 +888,11 @@ class SubmitEditForm(FlaskForm):
     submit = wtforms.SubmitField("Uložit")
 
 
-@app.route('/org/contest/c/<int:contest_id>/paper/<int:paper_id>/<filename>', endpoint='org_submit_paper')
-@app.route('/org/contest/c/<int:contest_id>/site/<int:site_id>/paper/<int:paper_id>/<filename>', endpoint='org_submit_paper')
-@app.route('/org/contest/c/<int:contest_id>/paper/orig/<int:paper_id>/<filename>', endpoint='org_submit_paper_orig')
-@app.route('/org/contest/c/<int:contest_id>/site/<int:site_id>/paper/orig/<int:paper_id>/<filename>', endpoint='org_submit_paper_orig')
-def org_submit_paper(contest_id: int, paper_id: int, filename: str, site_id: Optional[int] = None):
+@app.route('/org/contest/c/<int:ct_id>/paper/<int:paper_id>/<filename>', endpoint='org_submit_paper')
+@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/paper/<int:paper_id>/<filename>', endpoint='org_submit_paper')
+@app.route('/org/contest/c/<int:ct_id>/paper/orig/<int:paper_id>/<filename>', endpoint='org_submit_paper_orig')
+@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/paper/orig/<int:paper_id>/<filename>', endpoint='org_submit_paper_orig')
+def org_submit_paper(ct_id: int, paper_id: int, filename: str, site_id: Optional[int] = None):
     paper = (db.get_session().query(db.Paper)
              .options(joinedload(db.Paper.task))    # pro task_paper_filename()
              .get(paper_id))
@@ -908,7 +902,7 @@ def org_submit_paper(contest_id: int, paper_id: int, filename: str, site_id: Opt
     if not filename.endswith('.pdf'):
         raise werkzeug.exceptions.NotFound()
 
-    get_solution_context(contest_id, paper.for_user, paper.for_task, site_id)
+    get_context(ct_id=ct_id, user_id=paper.for_user, task_id=paper.for_task, site_id=site_id, right_needed=Right.view_submits)
 
     return mo.web.util.send_task_paper(paper, (request.endpoint == 'org_submit_paper_orig'))
 
@@ -941,25 +935,25 @@ class TaskCreateForm(FlaskForm):
     submit = wtforms.SubmitField("Založit označená řešení")
 
 
-@app.route('/org/contest/c/<int:contest_id>/task/<int:task_id>/')
-@app.route('/org/contest/c/<int:contest_id>/site/<int:site_id>/task/<int:task_id>/')
-@app.route('/org/contest/c/<int:contest_id>/task/<int:task_id>/points', methods=('GET', 'POST'), endpoint="org_contest_task_points")
-@app.route('/org/contest/c/<int:contest_id>/task/<int:task_id>/create', methods=('GET', 'POST'), endpoint="org_contest_task_create")
-@app.route('/org/contest/c/<int:contest_id>/site/<int:site_id>/task/<int:task_id>/create', methods=('GET', 'POST'), endpoint="org_contest_task_create")
-def org_contest_task(contest_id: int, task_id: int, site_id: Optional[int] = None):
-    sc = get_solution_context(contest_id, None, task_id, site_id)
-    assert sc.task is not None
+@app.route('/org/contest/c/<int:ct_id>/task/<int:task_id>/')
+@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/task/<int:task_id>/')
+@app.route('/org/contest/c/<int:ct_id>/task/<int:task_id>/points', methods=('GET', 'POST'), endpoint="org_contest_task_points")
+@app.route('/org/contest/c/<int:ct_id>/task/<int:task_id>/create', methods=('GET', 'POST'), endpoint="org_contest_task_create")
+@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/task/<int:task_id>/create', methods=('GET', 'POST'), endpoint="org_contest_task_create")
+def org_contest_task(ct_id: int, task_id: int, site_id: Optional[int] = None):
+    ctx = get_context(ct_id=ct_id, site_id=site_id, task_id=task_id, right_needed=Right.view_submits)
+    assert ctx.contest and ctx.task
 
     action_create = request.endpoint == "org_contest_task_create"
     action_points = request.endpoint == "org_contest_task_points"
-    if action_create and not sc.allow_create_solutions:
+    if action_create and not ctx.rights.can_create_solutions():
         raise werkzeug.exceptions.Forbidden()
-    if action_points and not sc.allow_edit_points:
+    if action_points and not ctx.rights.can_edit_points():
         raise werkzeug.exceptions.Forbidden()
 
     sess = db.get_session()
 
-    q = get_solutions_query(sc.task, for_contest=sc.master_contest, for_site=sc.site)
+    q = get_solutions_query(ctx.task, for_contest=ctx.master_contest, for_site=ctx.site)
     rows: List[Tuple[db.Participation, db.Solution]] = q.all()
     rows.sort(key=lambda r: r[0].user.sort_key())
 
@@ -976,7 +970,7 @@ def org_contest_task(contest_id: int, task_id: int, site_id: Optional[int] = Non
                 if not request.form.get(f"create_sol_{pion.user_id}"):
                     continue  # nikdo nežádá o vytvoření
 
-                sol = db.Solution(task=sc.task, user=pion.user)
+                sol = db.Solution(task=ctx.task, user=pion.user)
                 sess.add(sol)
                 mo.util.log(
                     type=db.LogType.participant,
@@ -986,7 +980,7 @@ def org_contest_task(contest_id: int, task_id: int, site_id: Optional[int] = Non
                         'task': task_id,
                     },
                 )
-                app.logger.info(f"Řešení úlohy {sc.task.code} od účastníka {pion.user_id} založeno")
+                app.logger.info(f"Řešení úlohy {ctx.task.code} od účastníka {pion.user_id} založeno")
                 new_sol_count += 1
 
             if new_sol_count > 0:
@@ -996,7 +990,7 @@ def org_contest_task(contest_id: int, task_id: int, site_id: Optional[int] = Non
                       "success")
             else:
                 flash("Žádné změny k uložení", "info")
-            return redirect(url_for('org_contest_task', contest_id=contest_id, task_id=task_id, site_id=site_id))
+            return redirect(ctx.url_for('org_contest_task'))
 
     if action_points:
         points_form = TaskPointsForm()
@@ -1007,7 +1001,7 @@ def org_contest_task(contest_id: int, task_id: int, site_id: Optional[int] = Non
                 if sol is None:
                     continue
 
-                points, error = mo.util.parse_points(request.form.get(f"points_{sol.user_id}"), for_task=sc.task, for_round=sc.round)
+                points, error = mo.util.parse_points(request.form.get(f"points_{sol.user_id}"), for_task=ctx.task, for_round=ctx.round)
                 if error:
                     flash(f'{sol.user.first_name} {sol.user.last_name}: {error}', 'danger')
                     ok = False
@@ -1016,7 +1010,7 @@ def org_contest_task(contest_id: int, task_id: int, site_id: Optional[int] = Non
                     # Save points
                     sol.points = points
                     sess.add(db.PointsHistory(
-                        task=sc.task,
+                        task=ctx.task,
                         participant=sol.user,
                         user=g.user,
                         points_at=mo.now,
@@ -1029,13 +1023,13 @@ def org_contest_task(contest_id: int, task_id: int, site_id: Optional[int] = Non
                     flash("Změněny body u " + inflect_number(count, "řešení", "řešení", "řešení"), "success")
                 else:
                     flash("Žádné změny k uložení", "info")
-                return redirect(url_for('org_contest_task', contest_id=contest_id, task_id=task_id))
+                return redirect(ctx.url_for('org_contest_task'))
 
     # Count papers for each solution
     paper_counts = {}
     for user_id, type, count in (
         db.get_session().query(db.Paper.for_user, db.Paper.type, func.count(db.Paper.type))
-        .filter_by(task=sc.task)
+        .filter_by(task=ctx.task)
         .group_by(db.Paper.for_user, db.Paper.type)
         .all()
     ):
@@ -1043,8 +1037,10 @@ def org_contest_task(contest_id: int, task_id: int, site_id: Optional[int] = Non
 
     return render_template(
         "org_contest_task.html",
-        sc=sc, rows=rows, paper_counts=paper_counts,
-        paper_link=lambda u, p: mo.web.util.org_paper_link(sc.contest, sc.site, u, p),
+        ctx=ctx, rights=ctx.rights,
+        round=ctx.round, contest=ctx.contest,
+        rows=rows, paper_counts=paper_counts,
+        paper_link=lambda u, p: mo.web.util.org_paper_link(ctx.contest, ctx.site, u, p),
         points_form=points_form, create_form=create_form, request_form=request.form,
     )
 
@@ -1053,33 +1049,34 @@ class ContestSolutionsEditForm(FlaskForm):
     submit = wtforms.SubmitField("Založit označená řešení")
 
 
-@app.route('/org/contest/c/<int:id>/solutions', methods=('GET', 'POST'))
-@app.route('/org/contest/c/<int:id>/site/<int:site_id>/solutions', methods=('GET', 'POST'))
-@app.route('/org/contest/c/<int:id>/solutions/edit', methods=('GET', 'POST'), endpoint="org_contest_solutions_edit")
-@app.route('/org/contest/c/<int:id>/site/<int:site_id>/solutions/edit', methods=('GET', 'POST'), endpoint="org_contest_solutions_edit")
-def org_contest_solutions(id: int, site_id: Optional[int] = None):
-    sc = get_solution_context(id, None, None, site_id)
+@app.route('/org/contest/c/<int:ct_id>/solutions', methods=('GET', 'POST'))
+@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/solutions', methods=('GET', 'POST'))
+@app.route('/org/contest/c/<int:ct_id>/solutions/edit', methods=('GET', 'POST'), endpoint="org_contest_solutions_edit")
+@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/solutions/edit', methods=('GET', 'POST'), endpoint="org_contest_solutions_edit")
+def org_contest_solutions(ct_id: int, site_id: Optional[int] = None):
     sess = db.get_session()
+    ctx = get_context(ct_id=ct_id, site_id=site_id, right_needed=Right.view_submits)
+    assert ctx.contest
 
     edit_action = request.endpoint == "org_contest_solutions_edit"
-    if edit_action and not sc.allow_create_solutions:
+    if edit_action and not ctx.rights.can_create_solutions():
         raise werkzeug.exceptions.Forbidden()
 
-    pions_subq = sess.query(db.Participation.user_id).filter_by(contest=sc.master_contest)
-    if sc.site:
-        pions_subq = pions_subq.filter_by(place=sc.site)
+    pions_subq = sess.query(db.Participation.user_id).filter_by(contest=ctx.master_contest)
+    if ctx.site:
+        pions_subq = pions_subq.filter_by(place=ctx.site)
     pions_subq = pions_subq.subquery()
     pions = (sess.query(db.Participation)
              .filter(
-                 db.Participation.contest == sc.master_contest,
+                 db.Participation.contest == ctx.master_contest,
                  db.Participation.user_id.in_(pions_subq),
              ).options(joinedload(db.Participation.user))
              .all())
     pions.sort(key=lambda p: p.user.sort_key())
 
-    tasks_subq = sess.query(db.Task.task_id).filter_by(round=sc.round).subquery()
+    tasks_subq = sess.query(db.Task.task_id).filter_by(round=ctx.round).subquery()
     tasks = (sess.query(db.Task)
-             .filter_by(round=sc.round)
+             .filter_by(round=ctx.round)
              .order_by(db.Task.code)
              .all())
 
@@ -1139,13 +1136,14 @@ def org_contest_solutions(id: int, site_id: Optional[int] = None):
                       "success")
             else:
                 flash("Žádné změny k uložení", "info")
-            return redirect(url_for('org_contest_solutions', id=id, site_id=site_id))
+            return redirect(ctx.url_for('org_contest_solutions'))
 
     return render_template(
         'org_contest_solutions.html',
-        contest=sc.contest, site=sc.site, sc=sc,
+        ctx=ctx,
+        contest=ctx.contest, site=ctx.site, rights=ctx.rights,
         pions=pions, tasks=tasks, tasks_sols=task_sols, paper_counts=paper_counts,
-        paper_link=lambda u, p: mo.web.util.org_paper_link(sc.contest, sc.site, u, p),
+        paper_link=lambda u, p: mo.web.util.org_paper_link(ctx.contest, ctx.site, u, p),
         edit_form=edit_form,
     )
 
@@ -1200,23 +1198,30 @@ def download_submits(form: DownloadSubmitsForm, round: db.Round, sol_query, pion
     return True
 
 
-def generic_batch_download(round: db.Round, contest: Optional[db.Contest], site: Optional[db.Place], task: db.Task):
-    """Společná funkce pro download submitů/feedbacku do soutěží a kol."""
-
+@app.route('/org/contest/c/<int:ct_id>/task/<int:task_id>/download', methods=('GET', 'POST'))
+@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/task/<int:task_id>/download', methods=('GET', 'POST'))
+@app.route('/org/contest/r/<int:round_id>/task/<int:task_id>/download', methods=('GET', 'POST'))
+@app.route('/org/contest/r/<int:round_id>/h/<int:hier_id>/task/<int:task_id>/download', methods=('GET', 'POST'))
+def org_generic_batch_download(task_id: int, round_id: Optional[int] = None, hier_id: Optional[int] = None, ct_id: Optional[int] = None, site_id: Optional[int] = None):
     sess = db.get_session()
+    ctx = get_context(round_id=round_id, hier_id=hier_id, ct_id=ct_id, site_id=site_id, task_id=task_id, right_needed=Right.view_submits)
+    round, hier_place, contest, site, task = ctx.round, ctx.hier_place, ctx.contest, ctx.site, ctx.task
+    assert task
 
     pion_query = sess.query(db.Participation.user_id).select_from(db.Participation)
-    if contest is not None:
+    if contest:
         pion_query = pion_query.filter_by(contest_id=contest.master_contest_id)
-        if site is not None:
+        if site:
             pion_query = pion_query.filter_by(place=site)
     else:
         pion_query = pion_query.join(db.Contest).filter(db.Contest.round_id == round.master_round_id)
+        if hier_place:
+            pion_query = db.filter_place_nth_parent(pion_query, db.Contest.place_id, round.level - hier_place.level, hier_place.place_id)
 
     sol_query = (sess.query(db.Solution)
                  .select_from(db.Solution)
                  .filter(db.Solution.task == task))
-    if contest is not None:
+    if contest or hier_place:
         sol_query = sol_query.filter(db.Solution.user_id.in_(pion_query.subquery()))
 
     form = DownloadSubmitsForm()
@@ -1226,6 +1231,8 @@ def generic_batch_download(round: db.Round, contest: Optional[db.Contest], site:
             subj = f'{subj} ({site.name})'
         elif contest is not None:
             subj = f'{subj} ({contest.place.name})'
+        elif hier_place is not None:
+            subj = f'{subj} ({hier_place.name})'
         if download_submits(form, round, sol_query, pion_query, subj, contest is None):
             return redirect(url_for('org_jobs'))
 
@@ -1244,7 +1251,8 @@ def generic_batch_download(round: db.Round, contest: Optional[db.Contest], site:
 
     return render_template(
         'org_generic_batch_download.html',
-        round=round, contest=contest, site=site, task=task,
+        ctx=ctx,
+        task=task,
         submit_count=submit_count,
         pion_count=pion_count,
         sol_count=sol_count, fb_count=fb_count,
@@ -1258,13 +1266,18 @@ class UploadSubmitsForm(FlaskForm):
     submit = wtforms.SubmitField('Odeslat')
 
 
-def generic_batch_upload(round: db.Round, contest: Optional[db.Contest], site: Optional[db.Place], task: db.Task,
-                         offer_upload_solutions: bool, offer_upload_feedback: bool):
-    """Společná funkce pro upload feedbacku do soutěží a kol."""
+@app.route('/org/contest/c/<int:ct_id>/task/<int:task_id>/upload', methods=('GET', 'POST'))
+@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/task/<int:task_id>/upload', methods=('GET', 'POST'))
+@app.route('/org/contest/r/<int:round_id>/task/<int:task_id>/upload', methods=('GET', 'POST'))
+@app.route('/org/contest/r/<int:round_id>/h/<int:hier_id>/task/<int:task_id>/upload', methods=('GET', 'POST'))
+def org_generic_batch_upload(task_id: int, round_id: Optional[int] = None, hier_id: Optional[int] = None, ct_id: Optional[int] = None, site_id: Optional[int] = None):
+    ctx = get_context(round_id=round_id, hier_id=hier_id, ct_id=ct_id, site_id=site_id, task_id=task_id)
+    round, hier_place, contest, site, task = ctx.round, ctx.hier_place, ctx.contest, ctx.site, ctx.task
+    assert task
 
-    # Základní kontrola, zda vůbec chceme akci spustit.
+    # Základní kontrola, zda vůbec chceme akci spustit. Zbytek se kontrole uvnitř jobu.
     # Zatím neumíme dávkově nahrávat řešení.
-    if not offer_upload_feedback:
+    if not ctx.rights.offer_upload_feedback():
         raise werkzeug.exceptions.Forbidden()
 
     request.custom_max_content_length = mo.config.MAX_BATCH_CONTENT_LENGTH
@@ -1274,35 +1287,18 @@ def generic_batch_upload(round: db.Round, contest: Optional[db.Contest], site: O
         file = form.file.data.stream
         mo.jobs.submit.schedule_upload_feedback(round, file.name, f'Nahrání opravených řešení {round.round_code()}',
                                                 for_user=g.user,
-                                                only_contest=contest, only_site=site, only_task=task)
+                                                only_contest=contest, only_site=site, only_region=hier_place, only_task=task)
         return redirect(url_for('org_jobs'))
 
     return render_template(
         'org_generic_batch_upload.html',
-        round=round, contest=contest, site=site, task=task,
+        ctx=ctx,
+        task=task,
         max_size=mo.config.MAX_BATCH_CONTENT_LENGTH,
         form=form,
     )
 
 
-@app.route('/org/contest/c/<int:contest_id>/task/<int:task_id>/download', methods=('GET', 'POST'))
-@app.route('/org/contest/c/<int:contest_id>/site/<int:site_id>/task/<int:task_id>/download', methods=('GET', 'POST'))
-def org_contest_task_download(contest_id: int, task_id: int, site_id: Optional[int] = None):
-    sc = get_solution_context(contest_id, None, task_id, site_id)
-    assert sc.task is not None
-    return generic_batch_download(round=sc.round, contest=sc.contest, site=sc.site, task=sc.task)
-
-
-@app.route('/org/contest/c/<int:contest_id>/task/<int:task_id>/upload', methods=('GET', 'POST'))
-@app.route('/org/contest/c/<int:contest_id>/site/<int:site_id>/task/<int:task_id>/upload', methods=('GET', 'POST'))
-def org_contest_task_upload(contest_id: int, task_id: int, site_id: Optional[int] = None):
-    sc = get_solution_context(contest_id, None, task_id, site_id)
-    assert sc.task is not None
-    return generic_batch_upload(round=sc.round, contest=sc.contest, site=sc.site, task=sc.task,
-                                offer_upload_solutions=sc.allow_upload_solutions,
-                                offer_upload_feedback=sc.allow_upload_feedback)
-
-
 class BatchPointsForm(FlaskForm):
     file = flask_wtf.file.FileField("Soubor", render_kw={'autofocus': True})
     fmt = wtforms.SelectField(
@@ -1315,15 +1311,22 @@ class BatchPointsForm(FlaskForm):
     get_template = wtforms.SubmitField('Stáhnout šablonu')
 
 
-def generic_batch_points(round: db.Round, contest: Optional[db.Contest], task: db.Task):
-    """Společná funkce pro download/upload bodů do soutěží a kol."""
+@app.route('/org/contest/c/<int:ct_id>/task/<int:task_id>/batch-points', methods=('GET', 'POST'))
+@app.route('/org/contest/r/<int:round_id>/task/<int:task_id>/batch-points', methods=('GET', 'POST'))
+@app.route('/org/contest/r/<int:round_id>/h/<int:hier_id>/task/<int:task_id>/batch-points', methods=('GET', 'POST'))
+def org_generic_batch_points(task_id: int, round_id: Optional[int] = None, hier_id: Optional[int] = None, ct_id: Optional[int] = None):
+    ctx = get_context(round_id=round_id, hier_id=hier_id, ct_id=ct_id, task_id=task_id)
+    round, hier_place, contest, task = ctx.round, ctx.hier_place, ctx.contest, ctx.task
+
+    if not ctx.rights.can_edit_points():
+        raise werkzeug.exceptions.Forbidden()
 
     form = BatchPointsForm()
     errs = []
     warnings = []
     if form.validate_on_submit():
         fmt = form.fmt.data
-        imp = create_import(user=g.user, type=ImportType.points, fmt=fmt, round=round, contest=contest, task=task, allow_add_del=form.add_del_sols.data)
+        imp = create_import(user=g.user, type=ImportType.points, fmt=fmt, round=round, only_region=hier_place, contest=contest, task=task, allow_add_del=form.add_del_sols.data)
         if form.submit.data:
             if form.file.data is not None:
                 file = form.file.data.stream
@@ -1334,10 +1337,7 @@ def generic_batch_points(round: db.Round, contest: Optional[db.Contest], task: d
                         flash('Soubor neobsahoval žádné řádky s daty', 'danger')
                     else:
                         flash(f'Importováno ({imp.cnt_rows} řádků, {imp.cnt_set_points} řešení přebodováno, {imp.cnt_add_sols} založeno a {imp.cnt_del_sols} smazáno)', 'success')
-                        if contest is not None:
-                            return redirect(url_for('org_contest', id=contest.contest_id))
-                        else:
-                            return redirect(url_for('org_round', id=round.round_id))
+                        return redirect(ctx.url_home())
                 else:
                     errs = imp.errors
                     warnings = imp.warnings
@@ -1352,6 +1352,7 @@ def generic_batch_points(round: db.Round, contest: Optional[db.Contest], task: d
 
     return render_template(
         'org_generic_batch_points.html',
+        ctx=ctx,
         round=round, contest=contest, task=task,
         form=form,
         errs=errs,
@@ -1359,37 +1360,27 @@ def generic_batch_points(round: db.Round, contest: Optional[db.Contest], task: d
     )
 
 
-@app.route('/org/contest/c/<int:contest_id>/task/<int:task_id>/batch-points', methods=('GET', 'POST'))
-def org_contest_task_batch_points(contest_id: int, task_id: int):
-    sc = get_solution_context(contest_id, None, task_id, None)
-    assert sc.task is not None
-
-    if not sc.allow_edit_points:
-        raise werkzeug.exceptions.Forbidden()
-
-    return generic_batch_points(round=sc.round, contest=sc.contest, task=sc.task)
-
-
-@app.route('/org/contest/c/<int:contest_id>/user/<int:user_id>')
-def org_contest_user(contest_id: int, user_id: int):
-    sc = get_solution_context(contest_id, user_id, None, None)
+@app.route('/org/contest/c/<int:ct_id>/user/<int:user_id>')
+def org_contest_user(ct_id: int, user_id: int):
+    ctx = get_context(ct_id=ct_id, user_id=user_id, right_needed=Right.view_contestants)
+    assert ctx.contest
     sess = db.get_session()
 
     pant = sess.query(db.Participant).filter_by(
-        user_id=user_id, year=sc.round.year
+        user_id=user_id, year=ctx.round.year
     ).options(joinedload(db.Participant.school_place)).one_or_none()
     if not pant:
         raise werkzeug.exceptions.NotFound()
 
     task_sols = sess.query(db.Task, db.Solution).select_from(db.Task).outerjoin(
-            db.Solution, and_(db.Solution.task_id == db.Task.task_id, db.Solution.user == sc.user)
-        ).filter(db.Task.round == sc.round).options(
+            db.Solution, and_(db.Solution.task_id == db.Task.task_id, db.Solution.user == ctx.user)
+        ).filter(db.Task.round == ctx.round).options(
             joinedload(db.Solution.final_submit_obj),
             joinedload(db.Solution.final_feedback_obj)
         ).order_by(db.Task.code).all()
 
     # Count papers for each task and solution
-    tasks_subq = sess.query(db.Task.task_id).filter_by(round=sc.round).subquery()
+    tasks_subq = sess.query(db.Task.task_id).filter_by(round=ctx.round).subquery()
     paper_counts = {}
     for task_id, type, count in (
         db.get_session().query(db.Paper.for_task, db.Paper.type, func.count(db.Paper.type))
@@ -1403,8 +1394,9 @@ def org_contest_user(contest_id: int, user_id: int):
 
     return render_template(
         'org_contest_user.html',
-        sc=sc, pant=pant, task_sols=task_sols,
-        paper_link=lambda u, p: mo.web.util.org_paper_link(sc.contest, None, u, p),
+        ctx=ctx, rights=ctx.rights,
+        pant=pant, task_sols=task_sols,
+        paper_link=lambda u, p: mo.web.util.org_paper_link(ctx.contest, None, u, p),
         paper_counts=paper_counts,
     )
 
@@ -1420,16 +1412,18 @@ class AdvanceForm(FlaskForm):
     execute = wtforms.SubmitField('Provést')
 
 
-@app.route('/org/contest/c/<int:contest_id>/advance', methods=('GET', 'POST'))
-def org_contest_advance(contest_id: int):
+@app.route('/org/contest/c/<int:ct_id>/advance', methods=('GET', 'POST'))
+def org_contest_advance(ct_id: int):
     sess = db.get_session()
     conn = sess.connection()
-    contest, master_contest, rr = get_contest_rr(contest_id, Right.manage_contest)
+    ctx = get_context(ct_id=ct_id, right_needed=Right.manage_contest)
+    contest, master_contest = ctx.contest, ctx.master_contest
+    round = ctx.round
+    assert contest and master_contest
 
     def redirect_back():
-        return redirect(url_for('org_contest', id=contest_id))
+        return redirect(ctx.url_for('org_contest'))
 
-    round = contest.round
     if round.state != db.RoundState.preparing:
         flash('Aktuální kolo není ve stavu přípravy', 'danger')
         return redirect_back()
@@ -1519,7 +1513,7 @@ def org_contest_advance(contest_id: int):
                 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}')
+                    app.logger.info(f'Postup: Založena účast user=#{pp.user_id} contest=#{ct_id} place=#{contest.place_id}')
                     mo.util.log(
                         type=db.LogType.participant,
                         what=pp.user_id,
@@ -1544,6 +1538,7 @@ def org_contest_advance(contest_id: int):
 
     return render_template(
         'org_contest_advance.html',
+        ctx=ctx,
         contest=contest,
         round=contest.round,
         prev_round=prev_round,
@@ -1561,11 +1556,13 @@ class ContestEditForm(FlaskForm):
     submit = wtforms.SubmitField('Uložit')
 
 
-@app.route('/org/contest/c/<int:id>/edit', methods=('GET', 'POST'))
-def org_contest_edit(id: int):
+@app.route('/org/contest/c/<int:ct_id>/edit', methods=('GET', 'POST'))
+def org_contest_edit(ct_id: int):
     sess = db.get_session()
-    contest, _, rr = get_contest_rr(id, Right.manage_contest)
-    round = contest.round
+    ctx = get_context(ct_id=ct_id, right_needed=Right.manage_contest)
+    contest = ctx.contest
+    round = ctx.round
+    assert contest and round
 
     form = ContestEditForm(obj=contest)
     if round.state != db.RoundState.delegate:
@@ -1580,31 +1577,31 @@ def org_contest_edit(id: int):
 
             if 'state' in changes and round.state != db.RoundState.delegate:
                 flash("Nastavení kola neumožňuje měnit stav soutěže", "danger")
-                return redirect(url_for('org_contest', id=id))
+                return redirect(url_for('org_contest', ct_id=ct_id))
 
-            app.logger.info(f"Contest #{id} modified, changes: {changes}")
+            app.logger.info(f"Contest #{ct_id} modified, changes: {changes}")
             mo.util.log(
                 type=db.LogType.contest,
-                what=id,
+                what=ct_id,
                 details={'action': 'edit', 'changes': changes},
             )
 
             sess.commit()
             flash('Změny soutěže uloženy', 'success')
         else:
-            flash(u'Žádné změny k uložení', 'info')
+            flash('Žádné změny k uložení', 'info')
 
-        return redirect(url_for('org_contest', id=id))
+        return redirect(ctx.url_for('org_contest'))
 
     return render_template(
         'org_contest_edit.html',
+        ctx=ctx,
         round=round,
         contest=contest,
         form=form,
     )
 
 
-
 class ParticipantAddForm(FlaskForm):
     email = mo_fields.Email(validators=[validators.Required()])
     first_name = mo_fields.FirstName(validators=[validators.Optional()])
@@ -1620,17 +1617,19 @@ class ParticipantAddForm(FlaskForm):
         self.participation_place.description = f'Pokud účastník soutěží někde jinde než {contest.place.name_locative()}, vyplňte <a href="{url_for("org_place", id=contest.place.place_id)}">kód místa</a>. Dozor na tomto místě pak může za účastníka odevzdávat řešení.'
 
 
-@app.route('/org/contest/c/<int:id>/participants/new', methods=('GET', 'POST'))
-@app.route('/org/contest/c/<int:id>/site/<int:site_id>/participants/new', methods=('GET', 'POST'))
-def org_contest_add_user(id: int, site_id: Optional[int] = None):
-    contest, master_contest, site, rr = get_contest_site_rr(id, site_id, right_needed=Right.manage_contest)
+@app.route('/org/contest/c/<int:ct_id>/participants/new', methods=('GET', 'POST'))
+@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/participants/new', methods=('GET', 'POST'))
+def org_contest_add_user(ct_id: int, site_id: Optional[int] = None):
+    ctx = get_context(ct_id=ct_id, site_id=site_id, right_needed=Right.manage_contest)
+    contest = ctx.master_contest
+    assert contest
 
     form = ParticipantAddForm()
     if site_id is not None:
         if not form.is_submitted():
             form.participation_place.process_data(site_id)
         form.participation_place.render_kw = {"readonly": True}
-    form.set_descriptions(master_contest)
+    form.set_descriptions(contest)
 
     if form.validate_on_submit():
         try:
@@ -1652,10 +1651,11 @@ def org_contest_add_user(id: int, site_id: Optional[int] = None):
                 flash("Uživatel přihlášen do soutěže.", "info")
             else:
                 flash("Žádná změna. Uživatel už byl přihlášen.", "info")
-            return redirect(url_for('org_contest_list', id=id, site_id=site_id))
+            return redirect(ctx.url_for('org_generic_list'))
 
     return render_template(
         'org_contest_add_user.html',
-        contest=master_contest, site=site,
+        ctx=ctx,
+        contest=contest, round=ctx.master_round, site=ctx.site,
         form=form
     )
diff --git a/mo/web/org_place.py b/mo/web/org_place.py
index 28fb9bab4f7adfb960e4c806a61b0feb8142a9ca..02f95161effa34162e3770ba663fc0b2afb29e87 100644
--- a/mo/web/org_place.py
+++ b/mo/web/org_place.py
@@ -81,8 +81,8 @@ class PlaceSchoolEditForm(PlaceEditForm):
 
 def place_breadcrumbs(place: db.Place, action: Optional[str] = None) -> Markup:
     elements = []
-    parents: List[db.Place] = reversed(g.gatekeeper.get_parents(place))
-    for parent in parents:
+    ancestors: List[db.Place] = g.gatekeeper.get_ancestors(place)
+    for parent in ancestors:
         elements.append((url_for('org_place', id=parent.place_id), parent.name))
     if action:
         elements.append(('', action))
@@ -215,7 +215,7 @@ def org_place_move(id: int):
             return redirect(url_for('org_place_move', id=id))
 
         new_parent = form.new_parent.place
-        new_parents = reversed(g.gatekeeper.get_parents(new_parent))
+        new_parents = g.gatekeeper.get_ancestors(new_parent)
         (_, levels) = db.place_type_names_and_levels[place.type]
 
         rr = g.gatekeeper.rights_for(new_parent)
@@ -370,7 +370,7 @@ def org_place_rights(id: int):
     if not place:
         raise werkzeug.exceptions.NotFound()
 
-    parent_ids = [p.place_id for p in g.gatekeeper.get_parents(place)]
+    parent_ids = [p.place_id for p in g.gatekeeper.get_ancestors(place)]
     roles = (sess.query(db.UserRole)
              .filter(db.UserRole.place_id.in_(parent_ids))
              .options(joinedload(db.UserRole.user))
diff --git a/mo/web/org_round.py b/mo/web/org_round.py
index 88da8505708ee535929ad8fb9ccc2cc639a9153a..5a12dbe9555119d6bb32d5e01613b8458a025295 100644
--- a/mo/web/org_round.py
+++ b/mo/web/org_round.py
@@ -1,5 +1,6 @@
+from dataclasses import dataclass, field
 import decimal
-from flask import render_template, g, redirect, url_for, flash, request
+from flask import render_template, g, redirect, flash, request
 import locale
 import flask_wtf.file
 from flask_wtf.form import FlaskForm
@@ -8,53 +9,29 @@ from bleach.sanitizer import ALLOWED_TAGS
 import markdown
 import os
 from sqlalchemy import func
-from sqlalchemy.orm import joinedload
+from sqlalchemy.orm import joinedload, aliased
 from sqlalchemy.sql.functions import coalesce
-from typing import Optional, Tuple
+from typing import Optional, List, Dict, Tuple, Set
 import werkzeug.exceptions
 import wtforms
 from wtforms import validators, ValidationError
 from wtforms.widgets.html5 import NumberInput
 
-import mo
+import mo.config as config
 import mo.db as db
 import mo.imports
-from mo.rights import Right, RoundRights
+from mo.rights import Right
 import mo.util
 from mo.web import app
 import mo.web.fields as mo_fields
-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
+from mo.web.org_contest import get_context
 
 
 @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)
+    rounds = sess.query(db.Round).filter_by(year=config.CURRENT_YEAR).order_by(db.Round.year, db.Round.category, db.Round.seq, db.Round.part)
     return render_template('org_rounds.html', rounds=rounds, history=False)
 
 
@@ -181,71 +158,122 @@ def create_subcontests(master_round: db.Round, master_contest: db.Contest):
         app.logger.info(f"Podsoutěž #{subcontest.contest_id} založena: {db.row2dict(subcontest)}")
 
 
-@app.route('/org/contest/r/<int:id>/', methods=('GET', 'POST'))
-def org_round(id: int):
+@dataclass
+class ContestStat:
+    region: db.Place
+    contest: Optional[db.Contest] = None
+    num_contests: int = 0
+    contest_states: Set[db.RoundState] = field(default_factory=set)
+    num_active_pants: int = 0
+    num_unconfirmed_pants: int = 0
+
+
+def region_stats(round: db.Round, region: db.Place) -> List[ContestStat]:
+    stats: Dict[int, ContestStat] = {}
     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)
-        ))
+
+    if region.level > round.level:
+        return []
+
+    if (region.level >= round.level - 1
+       or region.level == 2 and round.level == 4):
+        # List individual contests
+        q = sess.query(db.Contest).filter_by(round=round)
+        q = db.filter_place_nth_parent(q, db.Contest.place_id, round.level - region.level, region.place_id)
+        q = q.options(joinedload(db.Contest.place))
+        for c in q.all():
+            s = ContestStat(region=c.place, contest=c, num_contests=1)
+            stats[c.place.place_id] = s
+        have_contests = True
+    else:
+        # List sub-regions
+        regs = sess.query(db.Place).filter(db.Place.parent_place == region).all()
+        for r in regs:
+            s = ContestStat(region=r)
+            stats[r.place_id] = s
+        have_contests = False
+
+    region_ids = [s.region.place_id for s in stats.values()]
+
+    if not have_contests:
+        rcs = (sess.query(db.RegionContestStat)
+                   .filter_by(round=round)
+                   .filter(db.RegionContestStat.region.in_(region_ids))
+                   .all())
+        for r in rcs:
+            stats[r.region].num_contests += r.count
+            stats[r.region].contest_states.add(r.state)
+
+    rs = (sess.query(db.RegionParticipantStat)
+              .filter_by(round_id=round.master_round_id)
+              .filter(db.RegionParticipantStat.region.in_(region_ids))
+              .all())
+    for r in rs:
+        if r.state == db.PartState.active:
+            stats[r.region].num_active_pants = r.count
+        elif r.state == db.PartState.registered:
+            stats[r.region].num_unconfirmed_pants = r.count
+
+    out = list(stats.values())
+    out.sort(key=lambda s: locale.strxfrm(s.region.name or ""))
+    return out
+
+
+def region_totals(region: db.Place, stats: List[ContestStat]) -> ContestStat:
+    return ContestStat(
+        region=region,
+        num_contests=sum(s.num_contests for s in stats),
+        num_active_pants=sum(s.num_active_pants for s in stats),
+        num_unconfirmed_pants=sum(s.num_unconfirmed_pants for s in stats),
     )
 
-    sol_counts = {}
-    for task_id, count in sol_counts_q.group_by(db.Solution.task_id).all():
-        sol_counts[task_id] = count
 
+def task_stats(round: db.Round, region: db.Place) -> List[Tuple[db.Task, int]]:
+    sess = db.get_session()
     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
+
+    ts = (sess.query(db.RegionTaskStat)
+              .filter_by(round=round, region=region.place_id)
+              .all())
+    count_by_id = {s.task_id: s.count for s in ts}
+
+    return [(t, count_by_id.get(t.task_id, 0)) for t in tasks]
+
+
+@app.route('/org/contest/r/<int:round_id>/', methods=('GET', 'POST'))
+@app.route('/org/contest/r/<int:round_id>/h/<int:hier_id>', methods=('GET', 'POST'))
+def org_round(round_id: int, hier_id: Optional[int] = None):
+    ctx = get_context(round_id=round_id, hier_id=hier_id)
+    round = ctx.round
+    rights = ctx.rights
 
     form_delete_task = TaskDeleteForm()
-    if can_manage_round and delete_task(id, form_delete_task):
-        return redirect(url_for('org_round', id=id))
+    if rights.have_right(Right.manage_round) and delete_task(round_id, form_delete_task):
+        return redirect(ctx.url_for('org_round'))
 
     form_add_contest = AddContestForm()
     form_add_contest.place.label.text = "Nová soutěž " + round.get_level().in_name()
     if add_contest(round, form_add_contest):
-        return redirect(url_for('org_round', id=id))
+        return redirect(ctx.url_for('org_round'))
 
     group_rounds = round.get_group_rounds(True)
     group_rounds.sort(key=lambda r: r.round_code())
 
+    region = ctx.hier_place or db.get_root_place()
+    reg_stats = region_stats(round, region)
+    reg_total = region_totals(region, reg_stats)
+    task_info = task_stats(round, region)
+
     return render_template(
         'org_round.html',
+        ctx=ctx, rights=rights,
         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,
+        roles=[r.friendly_name() for r in rights.get_roles()],
+        reg_stats=reg_stats, reg_total=reg_total,
+        task_info=task_info,
+        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),
     )
 
@@ -267,18 +295,18 @@ class TaskEditForm(FlaskForm):
         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):
+@app.route('/org/contest/r/<int:round_id>/task/new', methods=('GET', 'POST'))
+def org_round_task_new(round_id: int):
     sess = db.get_session()
-    round, master_round, _ = get_round_rr(id, Right.manage_round, True)
+    ctx = get_context(round_id=round_id, right_needed=Right.manage_round)
 
-    form = TaskEditForm(master_round.points_step)
+    form = TaskEditForm(ctx.master_round.points_step)
     if form.validate_on_submit():
         task = db.Task()
-        task.round = round
+        task.round = ctx.round
         form.populate_obj(task)
 
-        if sess.query(db.Task).filter_by(round_id=id, code=task.code).first():
+        if sess.query(db.Task).filter_by(round_id=round_id, code=task.code).first():
             flash('Úloha se stejným kódem již v tomto kole existuje', 'danger')
         else:
             sess.add(task)
@@ -291,28 +319,25 @@ def org_round_task_new(id: int):
             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 redirect(ctx.url_for('org_round'))
 
     return render_template(
         'org_round_task_edit.html',
-        round=round, task=None, form=form,
+        ctx=ctx, 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):
+@app.route('/org/contest/r/<int:round_id>/task/<int:task_id>/edit', methods=('GET', 'POST'))
+def org_round_task_edit(round_id: int, task_id: int):
     sess = db.get_session()
-    round, master_round, _ = get_round_rr(id, Right.manage_round, True)
+    ctx = get_context(round_id=round_id, task_id=task_id, right_needed=Right.manage_round)
+    task = ctx.task
+    assert task
 
-    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)
+    form = TaskEditForm(ctx.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
+            db.Task.task_id != task_id, db.Task.round_id == round_id, db.Task.code == form.code.data
         ).first():
             flash('Úloha se stejným kódem již v tomto kole existuje', 'danger')
         else:
@@ -329,92 +354,15 @@ def org_round_task_edit(id: int, task_id: int):
                 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')
+                flash('Žádné změny k uložení', 'info')
 
-            return redirect(url_for('org_round', id=id))
+            return redirect(ctx.url_for('org_round', task_id=None))
 
     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(formdata=request.args)
-    filter.validate()
-    query = get_contestants_query(
-        round=master_round,
-        school=filter.school.place,
-        contest_place=filter.contest_place.place,
-        participation_place=filter.participation_place.place,
-        participation_state=mo.util.star_is_none(filter.participation_state.data),
+        ctx=ctx, form=form,
     )
 
-    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 RoundEditForm(FlaskForm):
     _for_round: Optional[db.Round] = None
@@ -469,10 +417,11 @@ class RoundEditForm(FlaskForm):
         self.abstract_validate_time_order(field)
 
 
-@app.route('/org/contest/r/<int:id>/edit', methods=('GET', 'POST'))
-def org_round_edit(id: int):
+@app.route('/org/contest/r/<int:round_id>/edit', methods=('GET', 'POST'))
+def org_round_edit(round_id: int):
     sess = db.get_session()
-    round, _, rr = get_round_rr(id, Right.manage_round, True)
+    ctx = get_context(round_id=round_id, right_needed=Right.manage_round)
+    round = ctx.round
 
     form = RoundEditForm(obj=round)
     form._for_round = round
@@ -490,10 +439,10 @@ def org_round_edit(id: int):
         if sess.is_modified(round):
             changes = db.get_object_changes(round)
 
-            app.logger.info(f"Round #{id} modified, changes: {changes}")
+            app.logger.info(f"Round #{round_id} modified, changes: {changes}")
             mo.util.log(
                 type=db.LogType.round,
-                what=id,
+                what=round_id,
                 details={'action': 'edit', 'changes': changes},
             )
 
@@ -512,26 +461,27 @@ def org_round_edit(id: int):
             sess.commit()
             flash('Změny kola uloženy', 'success')
         else:
-            flash(u'Žádné změny k uložení', 'info')
+            flash('Žádné změny k uložení', 'info')
 
-        return redirect(url_for('org_round', id=id))
+        return redirect(ctx.url_for('org_round'))
 
     return render_template(
         'org_round_edit.html',
+        ctx=ctx,
         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)
+@app.route('/org/contest/r/<int:round_id>/task-statement/zadani.pdf')
+def org_task_statement(round_id: int):
+    ctx = get_context(round_id=round_id)
 
-    if not rr.can_view_statement():
+    if not ctx.rights.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)
+    return mo.web.util.send_task_statement(ctx.round)
 
 
 class StatementEditForm(FlaskForm):
@@ -540,18 +490,19 @@ class StatementEditForm(FlaskForm):
     delete = wtforms.SubmitField('Smazat')
 
 
-@app.route('/org/contest/r/<int:id>/task-statement/edit', methods=('GET', 'POST'))
-def org_edit_statement(id: int):
+@app.route('/org/contest/r/<int:round_id>/task-statement/edit', methods=('GET', 'POST'))
+def org_edit_statement(round_id: int):
     sess = db.get_session()
-    round, _, rr = get_round_rr(id, Right.manage_round, True)
+    ctx = get_context(round_id=round_id, right_needed=Right.manage_round)
+    round = ctx.round
 
     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}")
+            app.logger.info(f"Kolo #{round_id} změněno, změny: {changes}")
             mo.util.log(
                 type=db.LogType.round,
-                what=id,
+                what=round_id,
                 details={'action': 'edit', 'changes': changes},
             )
 
@@ -572,7 +523,7 @@ def org_edit_statement(id: int):
                 log_changes()
                 sess.commit()
                 flash('Zadání nahráno', 'success')
-                return redirect(url_for('org_round', id=id))
+                return redirect(ctx.url_for('org_round'))
             else:
                 flash('Vyberte si prosím soubor', 'danger')
         if form.delete.data:
@@ -580,10 +531,11 @@ def org_edit_statement(id: int):
             log_changes()
             sess.commit()
             flash('Zadání smazáno', 'success')
-            return redirect(url_for('org_round', id=id))
+            return redirect(ctx.url_for('org_round'))
 
     return render_template(
         'org_edit_statement.html',
+        ctx=ctx,
         round=round,
         form=form,
     )
@@ -605,38 +557,39 @@ class MessageRemoveForm(FlaskForm):
     message_remove = wtforms.SubmitField()
 
 
-@app.route('/org/contest/r/<int:id>/messages/', methods=('GET', 'POST'))
-def org_round_messages(id: int):
+@app.route('/org/contest/r/<int:round_id>/messages/', methods=('GET', 'POST'))
+def org_round_messages(round_id: int):
     sess = db.get_session()
-    round, _, rr = get_round_rr(id, None, True)
+    ctx = get_context(round_id=round_id)
+    round = ctx.round
 
     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))
+        return redirect(ctx.url_for('org_round'))
 
-    messages = sess.query(db.Message).filter_by(round_id=id).order_by(db.Message.created_at).all()
+    messages = sess.query(db.Message).filter_by(round_id=round_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):
+    if ctx.rights.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:
+            if not msg or msg.round_id != round_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)}")
+            app.logger.info(f"Zprávička pro kolo {round_id} odstraněna: {db.row2dict(msg)}")
 
             flash('Zprávička odstraněna', 'success')
-            return redirect(url_for('org_round_messages', id=id))
+            return redirect(ctx.url_for('org_round_messages'))
 
         if add_form.validate_on_submit():
             msg = db.Message(
-                round_id=id,
+                round_id=round_id,
                 created_by=g.user.user_id,
                 created_at=mo.now,
             )
@@ -651,14 +604,15 @@ def org_round_messages(id: int):
             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)}")
+                app.logger.info(f"Vložena nová zprávička pro kolo {round_id}: {db.row2dict(msg)}")
 
                 flash('Zprávička úspěšně vložena', 'success')
-                return redirect(url_for('org_round_messages', id=id))
+                return redirect(ctx.url_for('org_round_messages'))
 
     return render_template(
         'org_round_messages.html',
-        round=round, rr=rr, messages=messages,
+        ctx=ctx,
+        round=round, messages=messages,
         add_form=add_form, remove_form=remove_form,
         preview=preview,
     )
diff --git a/mo/web/org_score.py b/mo/web/org_score.py
index 62932ab90a2714e90624d266ab31a5be971d14e4..2fb1585df69b07898b70181329cb231df8212298 100644
--- a/mo/web/org_score.py
+++ b/mo/web/org_score.py
@@ -9,6 +9,7 @@ import mo.db as db
 from mo.rights import Right
 from mo.score import Score
 from mo.web import app
+from mo.web.org_contest import get_context
 from mo.web.table import Cell, CellLink, Column, Row, Table, cell_pion_link
 from mo.util_format import format_decimal
 
@@ -42,11 +43,13 @@ class SolPointsCell(Cell):
     contest_id: int
     user: db.User
     sol: Optional[db.Solution]
+    link_to_paper: bool
 
-    def __init__(self, contest_id: int, user: db.User, sol: Optional[db.Solution]):
+    def __init__(self, contest_id: int, user: db.User, sol: Optional[db.Solution], link_to_paper: bool):
         self.contest_id = contest_id
         self.user = user
         self.sol = sol
+        self.link_to_paper = link_to_paper
 
     def __str__(self) -> str:
         if not self.sol:
@@ -63,7 +66,9 @@ class SolPointsCell(Cell):
         else:
             points = format_decimal(self.sol.points)
 
-        if self.sol.final_feedback_obj:
+        if not self.link_to_paper:
+            return f'<td>{points}'
+        elif self.sol.final_feedback_obj:
             url = mo.web.util.org_paper_link(self.contest_id, None, self.user, self.sol.final_feedback_obj)
             return f'<td><a href="{url}" title="Zobrazit finální opravu">{points}</a>'
         elif self.sol.final_submit_obj:
@@ -74,34 +79,18 @@ class SolPointsCell(Cell):
 
 
 @app.route('/org/contest/r/<int:round_id>/score')
-@app.route('/org/contest/c/<int:contest_id>/score')
-def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None):
-    if round_id is None and contest_id is None:
-        raise werkzeug.exceptions.BadRequest()
-    if round_id is not None and contest_id is not None:
-        raise werkzeug.exceptions.BadRequest()
+@app.route('/org/contest/c/<int:ct_id>/score')
+def org_score(round_id: Optional[int] = None, ct_id: Optional[int] = None):
+    ctx = get_context(round_id=round_id, ct_id=ct_id)
+    contest = ctx.contest
+    round = ctx.round
     format = request.args.get('format', "")
 
     sess = db.get_session()
-    if round_id:
-        contest = None
-        round = sess.query(db.Round).options(
-            joinedload(db.Round.master)
-        ).get(round_id)
-        if not round:
-            raise werkzeug.exceptions.NotFound()
-        rr = g.gatekeeper.rights_for_round(round, True)
-    else:
-        contest = sess.query(db.Contest).options(
-            joinedload(db.Contest.round).joinedload(db.Round.master)
-        ).get(contest_id)
-        if not contest:
-            raise werkzeug.exceptions.NotFound()
-        round = contest.round
-        rr = g.gatekeeper.rights_for_contest(contest)
-
-    if not rr.have_right(Right.view_submits):
+
+    if not ctx.rights.have_right(Right.view_contestants):
         raise werkzeug.exceptions.Forbidden()
+    can_view_submits = ctx.rights.have_right(Right.view_submits)
 
     score = Score(round.master, contest)
     tasks = score.get_tasks()
@@ -127,7 +116,7 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None):
     columns.append(Column(key='participant', name='ucastnik', title='Účastník'))
     if is_export:
         columns.append(Column(key='email', name='email'))
-    if not contest_id:
+    if not ct_id:
         columns.append(Column(key='contest', name='oblast', title=round.get_level().name.title()))
     if is_export:
         columns.append(Column(key='pion_place', name='soutezni_misto'))
@@ -140,12 +129,12 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None):
         if contest:
             local_ct_id = subcontest_id_map[(task.round_id, contest.master_contest_id)]
             title = '<a href="{}">{}</a>'.format(
-                url_for('org_contest_task', contest_id=local_ct_id, task_id=task.task_id),
+                url_for('org_contest_task', ct_id=local_ct_id, task_id=task.task_id),
                 task.code
             )
-            if rr.can_edit_points():
+            if ctx.rights.can_edit_points():
                 title += ' <a href="{}" title="Editovat body" class="icon">✎</a>'.format(
-                    url_for('org_contest_task_points', contest_id=local_ct_id, task_id=task.task_id),
+                    url_for('org_contest_task_points', ct_id=local_ct_id, task_id=task.task_id),
                 )
         columns.append(Column(key=f'task_{task.task_id}', name=task.code, title=title))
     columns.append(Column(key='total_points', name='celkove_body', title='Celkové body'))
@@ -177,7 +166,7 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None):
             'user':         user,
             'email':        user.email,
             'participant':  cell_pion_link(user, local_pion_ct_id, user.full_name()),
-            'contest':      CellLink(pion.contest.place.name or "?", url_for('org_contest', id=pion.contest_id)),
+            'contest':      CellLink(pion.contest.place.name or "?", url_for('org_contest', ct_id=pion.contest_id)),
             'pion_place':   pion.place.name,
             'school':       CellLink(school.name or "?", url_for('org_place', id=school.place_id)),
             'grade':        pant.grade,
@@ -190,7 +179,8 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None):
         for task in tasks:
             local_sol_ct_id = subcontest_id_map[(task.round_id, pion.contest_id)]
             row.keys[f'task_{task.task_id}'] = SolPointsCell(
-                contest_id=local_sol_ct_id, user=user, sol=sols.get(task.task_id)
+                contest_id=local_sol_ct_id, user=user, sol=sols.get(task.task_id),
+                link_to_paper=can_view_submits
             )
         if result.winner:
             row.html_attr = {"class": "winner", "title": "Vítěz"}
@@ -214,6 +204,7 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None):
     if format == "":
         return render_template(
             'org_score.html',
+            ctx=ctx,
             contest=contest, round=round, tasks=tasks,
             table=table, messages=messages,
             group_rounds=group_rounds,
diff --git a/mo/web/org_users.py b/mo/web/org_users.py
index b549096bc88cd5d154e6c9681c5458d76dc9ac43..74335fab5302cd4433c2c7e3f28d6db61129e4c5 100644
--- a/mo/web/org_users.py
+++ b/mo/web/org_users.py
@@ -190,7 +190,7 @@ def org_orgs():
         if filter.search_in_place.place is not None:
             qr = qr.filter(db.UserRole.place_id.in_(db.place_descendant_cte(filter.search_in_place.place)))
         if filter.search_right_for_place.place is not None:
-            qr = qr.filter(db.UserRole.place_id.in_([x.place_id for x in db.get_place_parents(filter.search_right_for_place.place)]))
+            qr = qr.filter(db.UserRole.place_id.in_([x.place_id for x in db.get_place_ancestors(filter.search_right_for_place.place)]))
                     # Po n>3 hodinách v mo.db jsem dospěl k závěru, že to hezčeji neumím (neumím vyrobit place_parents_cte)
         if filter.search_place_level.data:
             qr = qr.filter(db.UserRole.place_id.in_(
diff --git a/mo/web/table.py b/mo/web/table.py
index 72cbbcd14fdf0c7c501ccdb610dcbc6d52ec3543..37d61d86bc30c78683bf59f5d4cd4b38f8ee2000 100644
--- a/mo/web/table.py
+++ b/mo/web/table.py
@@ -210,7 +210,7 @@ def cell_user_link(user: db.User, text: str) -> CellLink:
 
 
 def cell_pion_link(user: db.User, contest_id: int, text: str) -> CellLink:
-    return CellLink(text, url_for('org_contest_user', contest_id=contest_id, user_id=user.user_id))
+    return CellLink(text, url_for('org_contest_user', ct_id=contest_id, user_id=user.user_id))
 
 
 def cell_place_link(place: db.Place, text: str) -> CellLink:
diff --git a/mo/web/templates/org_contest.html b/mo/web/templates/org_contest.html
index f78ea9c2fbaaf0635885c1abef6ba9f3a06d6c0a..0042d9ede74b752ee18fabeee5c614b2f87fbf78 100644
--- a/mo/web/templates/org_contest.html
+++ b/mo/web/templates/org_contest.html
@@ -2,13 +2,17 @@
 {% set round = contest.round %}
 {% set state = contest.state %}
 {% set ct_state = contest.ct_state() %}
-{% set site_id = site.place_id if site else None %}
+{% set can_manage = rights.have_right(Right.manage_contest) %}
+{% set can_upload = rights.can_upload_feedback() %}
+{% set can_edit_points = rights.can_edit_points() %}
+{% set can_create_solutions = rights.can_upload_feedback() or rights.can_upload_solutions() %}
+{% set can_view_statement = rights.can_view_statement() %}
 
 {% block title %}
 {{ round.round_code() }}: {% if site %}soutěžní místo {{ site.name }}{% else %}{{ contest.place.name }}{% endif %}
 {% endblock %}
 {% block breadcrumbs %}
-{{ contest_breadcrumbs(round=round, contest=contest, site=site) }}
+{{ ctx.breadcrumbs() }}
 {% endblock %}
 
 {% block body %}
@@ -27,7 +31,7 @@
 	{% if group_contests|length > 1 %}
 	<tr><td>Soutěže ve skupině kol:<td>
 		{% for c in group_contests %}
-			{% if c == contest %}<i>{% else %}<a href="{{ url_for('org_contest', id=c.contest_id) }}">{% endif %}
+			{% if c == contest %}<i>{% else %}<a href="{{ url_for('org_contest', ct_id=c.contest_id) }}">{% endif %}
 				{{ c.round.round_code() }}: {% if site %}soutěžní místo {{ site.name }}{% else %}{{ contest.place.name }}{% endif %}
 			{% if c == contest %} (tato soutěž)</i>{% else %}</a>{% endif %}<br>
 		{% endfor %}
@@ -35,7 +39,7 @@
 	<tr><td>Zadání<td>
 {% if round.tasks_file %}
 	{% if can_view_statement %}
-		<a href='{{ url_for('org_task_statement', id=round.round_id) }}'>stáhnout</a>
+		<a href='{{ ctx.url_for('org_task_statement', ct_id=None) }}'>stáhnout</a>
 	{% else %}
 		není dostupné
 	{% endif %}
@@ -45,28 +49,28 @@
 </table>
 
 <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>
+	<a class="btn btn-primary" href='{{ ctx.url_for('org_generic_list') }}'>Seznam účastníků</a>
 	{% if 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>
+	<a class="btn btn-primary" href='{{ ctx.url_for('org_contest_solutions') }}'>Odevzdaná řešení</a>
 	{% endif %}
 	{% if can_manage and site %}
-	<a class="btn btn-default" href="{{ url_for('org_contest_add_user', id=contest.contest_id, site_id=site_id) }}">Přidat účastníka</a>
+	<a class="btn btn-default" href="{{ ctx.url_for('org_contest_add_user') }}">Přidat účastníka</a>
 	{% endif %}
 	{% if not site %}
 	{% if state in [RoundState.grading, RoundState.closed] %}
-	<a class="btn btn-primary" href='{{ url_for('org_score', contest_id=contest.contest_id) }}'>Výsledky</a>
+	<a class="btn btn-primary" href='{{ ctx.url_for('org_score') }}'>Výsledky</a>
 	{% endif %}
 	{% if 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>
+	<a class="btn btn-primary" href='{{ ctx.url_for('org_contest_advance') }}'>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>
+	<a class="btn btn-default" href='{{ ctx.url_for('org_generic_import') }}'>Importovat data</a>
 	{% endif %}
 	{% if can_manage and not site %}
-	<a class="btn btn-default" href='{{ url_for('org_contest_edit', id=contest.contest_id) }}'>Nastavení</a>
+	<a class="btn btn-default" href='{{ ctx.url_for('org_contest_edit') }}'>Nastavení</a>
 	{% endif %}
 	{% if g.user.is_admin %}
-	<a class="btn btn-default" href="{{ log_url('contest', contest.contest_id) }}">Historie</a>
+	<a class="btn btn-default" href="{{ log_url('contest', ctx.ct_id) }}">Historie</a>
 	{% endif %}
 	{% endif %}
 </div>
@@ -80,12 +84,12 @@
 	</thead>
 	{% for (place, count) in places_counts %}
 		<tr>
-			<td><a href="{{ url_for('org_contest', id=contest.contest_id, site_id=place.place_id) }}">{{ place.name }}</a>
+			<td><a href="{{ ctx.url_for('org_contest', site_id=place.place_id) }}">{{ place.name }}</a>
 			<td>{{ count }}
 			<td><div class="btn-group">
-				<a class="btn btn-xs btn-primary" href="{{ url_for('org_contest', id=contest.contest_id, site_id=place.place_id) }}">Detail</a>
+				<a class="btn btn-xs btn-primary" href="{{ ctx.url_for('org_contest', site_id=place.place_id) }}">Detail</a>
 				{% if can_manage %}
-				<a class="btn btn-xs btn-default" href="{{ url_for('org_contest_add_user', id=contest.contest_id, site_id=place.place_id) }}">Přidat účastníka</a>
+				<a class="btn btn-xs btn-default" href="{{ ctx.url_for('org_contest_add_user', site_id=place.place_id) }}">Přidat účastníka</a>
 			</div>
 				{% endif %}
 		</tr>
@@ -104,7 +108,7 @@
 {% endif %}
 <div class="btn-group">
 	{% if can_manage and not site %}
-	<a class="btn btn-default" href='{{ url_for('org_contest_add_user', id=contest.contest_id) }}'>Přidat účastníka</a>
+	<a class="btn btn-default" href='{{ ctx.url_for('org_contest_add_user') }}'>Přidat účastníka</a>
 	{% endif %}
 </div>
 
@@ -128,21 +132,21 @@
 		<td>{{ task.sol_count }}
 		<td>{{ task.max_points|decimal|none_value('–') }}
 		<td><div class="btn-group">
-			<a class="btn btn-xs btn-primary" href="{{ url_for('org_contest_task', contest_id=contest.contest_id, task_id=task.task_id, site_id=site_id) }}">Odevzdaná řešení</a>
+			<a class="btn btn-xs btn-primary" href="{{ ctx.url_for('org_contest_task', task_id=task.task_id) }}">Odevzdaná řešení</a>
 			{% if not site and can_edit_points %}
-				<a class="btn btn-xs btn-default" href="{{ url_for('org_contest_task_points', contest_id=contest.contest_id, task_id=task.task_id) }}">Zadat body</a>
+				<a class="btn btn-xs btn-default" href="{{ ctx.url_for('org_contest_task_points', task_id=task.task_id) }}">Zadat body</a>
 			{% endif %}
 			{% if can_create_solutions %}
-				<a class="btn btn-xs btn-default" href="{{ url_for('org_contest_task_create', contest_id=contest.contest_id, task_id=task.task_id, site_id=site_id) }}">Založit řešení</a>
+				<a class="btn btn-xs btn-default" href="{{ ctx.url_for('org_contest_task_create', task_id=task.task_id) }}">Založit řešení</a>
 			{% endif %}
 		</div>
 		<td><div class="btn-group">
-			<a class="btn btn-xs btn-primary" href="{{ url_for('org_contest_task_download', contest_id=contest.contest_id, task_id=task.task_id, site_id=site_id) }}">Stáhnout ZIP</a>
+			<a class="btn btn-xs btn-primary" href="{{ ctx.url_for('org_generic_batch_download', task_id=task.task_id) }}">Stáhnout ZIP</a>
 			{% if can_upload %}
-				<a class='btn btn-xs btn-default' href="{{ url_for('org_contest_task_upload', contest_id=contest.contest_id, task_id=task.task_id, site_id=site_id) }}">Nahrát ZIP</a>
+				<a class='btn btn-xs btn-default' href="{{ ctx.url_for('org_generic_batch_upload', task_id=task.task_id) }}">Nahrát ZIP</a>
 			{% endif %}
 			{% if not site and can_edit_points %}
-				<a class="btn btn-xs btn-default" href="{{ url_for('org_contest_task_batch_points', contest_id=contest.contest_id, task_id=task.task_id) }}">Nahrát body</a>
+				<a class="btn btn-xs btn-default" href="{{ ctx.url_for('org_generic_batch_points', task_id=task.task_id) }}">Nahrát body</a>
 			{% endif %}
 		</div>
 	</tr>
@@ -156,8 +160,8 @@
 Práva k {% if site %}soutěžními místu{% else %}soutěži{% endif %}:
 {% if g.user.is_admin %}
 	admin
-{% elif rights %}
-	{% for r in rights %}
+{% elif rights_list %}
+	{% for r in rights_list %}
 	{{ r.name }}
 	{% endfor %}
 {% else %}
diff --git a/mo/web/templates/org_contest_add_user.html b/mo/web/templates/org_contest_add_user.html
index d544dbef1c6d75fe4f0bfc8b93788a3fb0a6cf33..fe181f2379b20d2247f56a900b49dcae74bbf22c 100644
--- a/mo/web/templates/org_contest_add_user.html
+++ b/mo/web/templates/org_contest_add_user.html
@@ -1,12 +1,11 @@
 {% extends "base.html" %}
 {% import "bootstrap/wtf.html" as wtf %}
-{% set round = contest.round %}
 
 {% block title %}
 {{ round.round_code() }}: Přidat účastníka {% if site %}do soutěžního místa {{ site.name }}{% else %}do oblasti {{ contest.place.name }}{% endif %}
 {% endblock %}
 {% block breadcrumbs %}
-{{ contest_breadcrumbs(round=round, contest=contest, site=site, action="Přidat účastníka") }}
+{{ ctx.breadcrumbs(action="Přidat účastníka") }}
 {% endblock %}
 
 {% block body %}
diff --git a/mo/web/templates/org_contest_advance.html b/mo/web/templates/org_contest_advance.html
index 2094c11ac51a31f771947e5a2dc0c862ffba0127..b6f22bdac3ba06ec6063ef3b93acc4a210e05552 100644
--- a/mo/web/templates/org_contest_advance.html
+++ b/mo/web/templates/org_contest_advance.html
@@ -3,7 +3,7 @@
 
 {% 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") }}
+{{ ctx.breadcrumbs(action="Postup") }}
 {% endblock %}
 {% block body %}
 
@@ -25,10 +25,10 @@
 	<tbody>
 		{% for c in prev_contests %}
 		<tr>
-			<td><a href='{{ url_for('org_contest', id=c.contest_id) }}'>{{ c.place.name }}</a>
+			<td><a href='{{ url_for('org_contest', ct_id=c.contest_id) }}'>{{ c.place.name }}</a>
 			<td>{{ accept_by_place_id[c.place.place_id] }}
 			<td>{{ reject_by_place_id[c.place.place_id] }}
-			<td><a class='btn btn-warning btn-xs' href='{{ url_for('org_score', contest_id=c.contest_id) }}'>Výsledková listina</a>
+			<td><a class='btn btn-warning btn-xs' href='{{ url_for('org_score', ct_id=c.contest_id) }}'>Výsledková listina</a>
 		{% endfor %}
 	<tfoot>
 		<tr>
diff --git a/mo/web/templates/org_contest_edit.html b/mo/web/templates/org_contest_edit.html
index 508d75fd58a9e51030aa0f60d625ed459405463c..39e4c0fb729dba67e6d6786387dbf5f43873b232 100644
--- a/mo/web/templates/org_contest_edit.html
+++ b/mo/web/templates/org_contest_edit.html
@@ -3,7 +3,7 @@
 
 {% block title %}Editace soutěže {{ round.round_code() }}: {{ contest.place.name }}{% endblock %}
 {% block breadcrumbs %}
-{{ contest_breadcrumbs(round=round, contest=contest, action="Editace") }}
+{{ ctx.breadcrumbs(action="Editace") }}
 {% endblock %}
 {% block body %}
 
diff --git a/mo/web/templates/org_contest_list.html b/mo/web/templates/org_contest_list.html
deleted file mode 100644
index e8b01899f8b27ce3ed879e8259d7adde140be7a7..0000000000000000000000000000000000000000
--- a/mo/web/templates/org_contest_list.html
+++ /dev/null
@@ -1,44 +0,0 @@
-{% extends "base.html" %}
-{% import "bootstrap/wtf.html" as wtf %}
-{% set round = contest.round %}
-
-{% block title %}
-Seznam účastníků {% if site %}v soutěžním místě {{ site.name }}{% else %}{{ contest.place.name_locative() }}{% endif %}
-{% endblock %}
-{% block breadcrumbs %}
-{{ contest_breadcrumbs(round=round, contest=contest, site=site, action="Seznam účastníků" if table else "E-maily", table=False if table else True) }}
-{% endblock %}
-{% set id = contest.contest_id %}
-{% set site_id = site.place_id if site else None %}
-
-{% block body %}
-<div class="form-frame">
-<form action="" method="GET" class="form form-inline" role="form">
-	<div class="form-row">
-		{% if not site %}
-		{{ wtf.form_field(filter.participation_place, size=8) }}
-		{% endif %}
-		{{ wtf.form_field(filter.school, size=8) }}
-		{{ wtf.form_field(filter.participation_state) }}
-		<div class="btn-group">
-			{{ wtf.form_field(filter.submit, class='btn btn-primary') }}
-			{% if table %}
-			<button class="btn btn-default" name="format" value="cs_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>
-			{% endif %}
-		</div>
-	{% if not site %}
-	</div>
-	<div class="form-row" style="margin-top: 5px;">
-	{% endif %}
-		Celkem <b>{{count|inflected('nalezený účastník', 'nalezení účastníci', 'nalezených účastníků')}}</b>.
-	</div>
-</form>
-</div>
-
-{% if table %}
-	{% include 'parts/org_participants_table_actions.html' %}
-{% else %}
-	{% include 'parts/org_participants_emails.html' %}
-{% endif %}
-{% endblock %}
diff --git a/mo/web/templates/org_contest_solutions.html b/mo/web/templates/org_contest_solutions.html
index 7df83f66e70e2cf897b4b8c8fd0e841f129dbb05..20bbd1ac58d7511eff040d90e3335d7327df6967 100644
--- a/mo/web/templates/org_contest_solutions.html
+++ b/mo/web/templates/org_contest_solutions.html
@@ -1,20 +1,19 @@
 {% extends "base.html" %}
 {% import "bootstrap/wtf.html" as wtf %}
 {% set round = contest.round %}
-{% set site_id = site.place_id if site else None %}
 
 {% block title %}
 {{ "Založení řešení" if edit_form else "Tabulka řešení" }} {% if site %}soutěžního místa {{ site.name }}{% else %}{{ contest.place.name_locative() }}{% endif %}
 {% endblock %}
 {% block breadcrumbs %}
-{{ contest_breadcrumbs(round=round, contest=contest, site=site, action="Založení řešení" if edit_form else "Tabulka řešení") }}
+{{ ctx.breadcrumbs(action="Založení řešení" if edit_form else "Tabulka řešení") }}
 {% endblock %}
 
 {% block pretitle %}
 {% if contest.state in [RoundState.grading, RoundState.closed] %}
 <div class="btn-group pull-right">
-	<a class="btn btn-default" href="{{ url_for('org_score', contest_id=contest.contest_id) }}">Výsledky {{ round.get_level().name_genitive() }}</a>
-	<a class="btn btn-default" href="{{ url_for('org_score', round_id=round.round_id) }}">Výsledky kola</a>
+	<a class="btn btn-default" href="{{ ctx.url_for('org_score') }}">Výsledky {{ round.get_level().name_genitive() }}</a>
+	<a class="btn btn-default" href="{{ ctx.url_for('org_score', ct_id=None) }}">Výsledky kola</a>
 </div>
 {% endif %}
 {% endblock %}
@@ -48,9 +47,9 @@ konkrétní úlohu. Symbol <span class="icon">🗐</span> značí, že existuje
 			<th rowspan=2>Účastník
 			<th rowspan=2>Stav účasti</th>
 			{% for task in tasks %}<th colspan=4>
-				<a href="{{ url_for('org_contest_task', contest_id=contest.contest_id, site_id=site_id, task_id=task.task_id) }}">{{ task.code }}</a>
-				{% if sc.allow_edit_points %}
-				<a title="Editovat body" href="{{ url_for('org_contest_task_points', contest_id=contest.contest_id, task_id=task.task_id) }}" class="icon pull-right">✎</a>
+				<a href="{{ ctx.url_for('org_contest_task', task_id=task.task_id) }}">{{ task.code }}</a>
+				{% if rights.can_edit_points() %}
+				<a title="Editovat body" href="{{ ctx.url_for('org_contest_task_points', task_id=task.task_id) }}" class="icon pull-right">✎</a>
 				{% endif %}
 			{% endfor %}
 			<th rowspan=2>Body celkem
@@ -61,7 +60,7 @@ konkrétní úlohu. Symbol <span class="icon">🗐</span> značí, že existuje
 	</thead>
 	{% for pion in pions %}
 	{% set u = pion.user %}
-	<tr class="state-{{ pion.state.name }}>
+	<tr class="state-{{ pion.state.name }}">
 		<th>{{ u|pion_link(contest.contest_id) }}
 		<td>{{ pion.state.friendly_name() }}
 		{% set sum_points = [] %}
@@ -109,7 +108,7 @@ konkrétní úlohu. Symbol <span class="icon">🗐</span> značí, že existuje
 				{% else %}–{% endif %}
 				<td>
 			{% endif %}
-					<a class="btn btn-xs btn-link icon" title="Detail řešení" href="{{ url_for('org_submit_list', contest_id=contest.contest_id, user_id=u.user_id, task_id=task.task_id, site_id=site_id) }}">🔍</a>
+					<a class="btn btn-xs btn-link icon" title="Detail řešení" href="{{ ctx.url_for('org_submit_list', user_id=u.user_id, task_id=task.task_id) }}">🔍</a>
 		{% endfor %}
 		<th>{{ sum_points|sum|decimal }}</th>
 	</tr>
@@ -118,9 +117,9 @@ konkrétní úlohu. Symbol <span class="icon">🗐</span> značí, že existuje
 		<tr><td><td>
 		{% for task in tasks %}
 			<td colspan=4><div class='btn-group'>
-				<a class='btn btn-xs btn-primary' href="{{ url_for('org_contest_task_download', contest_id=contest.contest_id, site_id=site_id, task_id=task.task_id) }}">Stáhnout</a>
-			{% if sc.allow_upload_feedback %}
-				<a class='btn btn-xs btn-primary' href="{{ url_for('org_contest_task_upload', contest_id=contest.contest_id, site_id=site_id, task_id=task.task_id) }}">Nahrát</a>
+				<a class='btn btn-xs btn-primary' href="{{ ctx.url_for('org_generic_batch_download', task_id=task.task_id) }}">Stáhnout</a>
+			{% if rights.can_upload_feedback() %}
+				<a class='btn btn-xs btn-primary' href="{{ ctx.url_for('org_generic_batch_upload', task_id=task.task_id) }}">Nahrát</a>
 			{% endif %}
 				</div>
 		{% endfor %}
@@ -130,13 +129,13 @@ konkrétní úlohu. Symbol <span class="icon">🗐</span> značí, že existuje
 {% if edit_form %}
 	<div class='btn-group'>
 		{{ wtf.form_field(edit_form.submit, class="btn btn-primary") }}
-		<a class="btn btn-default" href="{{ url_for('org_contest_solutions', id=contest.contest_id, site_id=site_id) }}">Zrušit</a>
+		<a class="btn btn-default" href="{{ ctx.url_for('org_contest_solutions') }}">Zrušit</a>
 	</div>
 </form>
 {% else %}
 <div class='btn-group'>
-	{% if sc.allow_create_solutions %}
-	<a class="btn btn-primary" href="{{ url_for('org_contest_solutions_edit', id=contest.contest_id, site_id=site_id) }}">Založit řešení</a>
+	{% if rights.can_create_solutions() %}
+	<a class="btn btn-primary" href="{{ ctx.url_for('org_contest_solutions_edit') }}">Založit řešení</a>
 	{% endif %}
 </div>
 {% endif %}
diff --git a/mo/web/templates/org_contest_task.html b/mo/web/templates/org_contest_task.html
index 9e8c65c00adbdfc977971e546976447ac78193b9..96fc3f4c974a7939d619b9713194257aa161bfc2 100644
--- a/mo/web/templates/org_contest_task.html
+++ b/mo/web/templates/org_contest_task.html
@@ -1,23 +1,20 @@
 {% extends "base.html" %}
 {% import "bootstrap/wtf.html" as wtf %}
-{% set contest = sc.contest %}
-{% set ct_id = contest.contest_id %}
-{% set round = sc.round %}
-{% set site = sc.site %}
-{% set site_id = site.place_id if site else None %}
-{% set task = sc.task %}
+{% set allow_edit_points=rights.can_edit_points() %}
+{% set allow_upload_solutions=rights.can_upload_solutions() %}
+{% set allow_upload_feedback=rights.can_upload_feedback() %}
 
-{% block title %}{{ "Zadávání bodů" if points_form else "Založení řešení" if create_form else "Odevzdaná řešení" }} úlohy {{ task.code }} {{ task.name }}{% endblock %}
+{% block title %}{{ "Zadávání bodů" if points_form else "Založení řešení" if create_form else "Odevzdaná řešení" }} úlohy {{ ctx.task.code }} {{ ctx.task.name }}{% endblock %}
 {% block breadcrumbs %}
-{{ contest_breadcrumbs(round=round, contest=contest, site=site, task=task, action="Zadávání bodů" if points_form else "Založení řešení" if create_form else None) }}
+{{ ctx.breadcrumbs(action="Zadávání bodů" if points_form else "Založení řešení" if create_form else None) }}
 {% endblock %}
 
 {% block pretitle %}
 <div class="btn-group pull-right">
-	<a class="btn btn-default" href="{{ url_for('org_contest_solutions', id=ct_id, site_id=site_id) }}">Všechny úlohy</a>
-	{% if contest.state in [RoundState.grading, RoundState.closed] %}
-	<a class="btn btn-default" href="{{ url_for('org_score', contest_id=ct_id) }}">Výsledky {{ round.get_level().name_genitive() }}</a>
-	<a class="btn btn-default" href="{{ url_for('org_score', round_id=round.round_id) }}">Výsledky kola</a>
+	<a class="btn btn-default" href="{{ ctx.url_for('org_contest_solutions', task_id=None) }}">Všechny úlohy</a>
+	{% if ctx.contest.state in [RoundState.grading, RoundState.closed] %}
+	<a class="btn btn-default" href="{{ ctx.url_for('org_score', task_id=None) }}">Výsledky {{ ctx.round.get_level().name_genitive() }}</a>
+	<a class="btn btn-default" href="{{ ctx.url_for('org_score', ct_id=None, task_id=None) }}">Výsledky kola</a>
 	{% endif %}
 </div>
 {% endblock %}
@@ -29,26 +26,26 @@
 <form class="form" method="POST">
 {{ form.csrf_token }}
 {% endif %}
-{% with for_user=None, for_task=task, rows=rows %}
+{% with for_user=None, for_task=ctx.task, rows=rows %}
 	{% include "parts/org_solution_table.html" %}
 {% endwith %}
 {% if form %}
 	<div class='btn-group'>
 		{{ wtf.form_field(form.submit, class="btn btn-primary" ) }}
-		<a class="btn btn-default" href="{{ url_for('org_contest_task', contest_id=ct_id, task_id=task.task_id, site_id=site_id) }}">Zrušit</a>
+		<a class="btn btn-default" href="{{ ctx.url_for('org_contest_task') }}">Zrušit</a>
 	</div>
 </form>
 {% else %}
 <div class='btn-group'>
-	<a class='btn btn-primary' href="{{ url_for('org_contest_task_download', contest_id=ct_id, site_id=site_id, task_id=task.task_id) }}">Stáhnout řešení</a>
-	{% if sc.allow_upload_feedback %}
-	<a class='btn btn-primary' href="{{ url_for('org_contest_task_upload', contest_id=ct_id, site_id=site_id, task_id=task.task_id) }}">Nahrát opravená řešení</a>
+	<a class='btn btn-primary' href="{{ ctx.url_for('org_generic_batch_download') }}">Stáhnout řešení</a>
+	{% if allow_upload_feedback %}
+	<a class='btn btn-primary' href="{{ ctx.url_for('org_generic_batch_upload') }}">Nahrát opravená řešení</a>
 	{% endif %}
-	{% if sc.allow_create_solutions %}
-	<a class="btn btn-primary" href="{{ url_for('org_contest_task_create', contest_id=ct_id, task_id=task.task_id, site_id=site_id) }}">Založit řešení</a>
+	{% if allow_create_solutions %}
+	<a class="btn btn-primary" href="{{ ctx.url_for('org_contest_task_create') }}">Založit řešení</a>
 	{% endif %}
-	{% if not site and sc.allow_edit_points %}
-	<a class="btn btn-primary" href="{{ url_for('org_contest_task_points', contest_id=ct_id, task_id=task.task_id) }}">Zadat body</a>
+	{% if not ctx.site and allow_edit_points %}
+	<a class="btn btn-primary" href="{{ ctx.url_for('org_contest_task_points') }}">Zadat body</a>
 	{% endif %}
 </div>
 {% endif %}
diff --git a/mo/web/templates/org_contest_user.html b/mo/web/templates/org_contest_user.html
index d37540fb4adf55594f1b70a185cfd2171da08811..a77e15c32ef175475d481e5728e65f53559d45b3 100644
--- a/mo/web/templates/org_contest_user.html
+++ b/mo/web/templates/org_contest_user.html
@@ -1,13 +1,13 @@
 {% extends "base.html" %}
 {% import "bootstrap/wtf.html" as wtf %}
-{% set contest = sc.contest %}
+{% set contest = ctx.contest %}
 {% set ct_id = contest.contest_id %}
-{% set round = sc.round %}
-{% set user = sc.user %}
+{% set round = ctx.round %}
+{% set user = ctx.user %}
 
 {% block title %}{{ round.round_code() }}: účastník {{ user.full_name() }}{% endblock %}
 {% block breadcrumbs %}
-{{ contest_breadcrumbs(round=round, contest=contest, user=user) }}
+{{ ctx.breadcrumbs() }}
 {% endblock %}
 
 {% block body %}
@@ -15,14 +15,14 @@
 	<h4>Rychlé odkazy</h4>
 	Soutěžní kolo:
 	<div class="btn-group">
-		<a class="btn btn-default" href="{{ url_for('org_round_list', id=round.round_id) }}">Účastníci</a>
-		<a class="btn btn-default" href="{{ url_for('org_score', round_id=round.round_id) }}">Výsledky</a>
+		<a class="btn btn-default" href="{{ ctx.url_for('org_generic_list', ct_id=None) }}">Účastníci</a>
+		<a class="btn btn-default" href="{{ ctx.url_for('org_score', ct_id=None) }}">Výsledky</a>
 	</div>
 	<br>{{ round.get_level().name|capitalize }}:
 	<div class="btn-group">
-		<a class="btn btn-default" href="{{ url_for('org_contest_solutions', id=ct_id) }}">Tabulka řešení</a>
-		<a class="btn btn-default" href="{{ url_for('org_contest_list', id=ct_id) }}">Účastníci</a>
-		<a class="btn btn-default" href="{{ url_for('org_score', contest_id=ct_id) }}">Výsledky</a>
+		<a class="btn btn-default" href="{{ ctx.url_for('org_contest_solutions') }}">Tabulka řešení</a>
+		<a class="btn btn-default" href="{{ ctx.url_for('org_generic_list') }}">Účastníci</a>
+		<a class="btn btn-default" href="{{ ctx.url_for('org_score') }}">Výsledky</a>
 	</div>
 </div>
 
@@ -38,14 +38,14 @@
 	<tr><td>Rok narození:<td>{{ pant.birth_year }}
 	<tr><td>Poznámka:<td style="white-space: pre-line;">{{ user.note }}
 	<thead>
-			<tr><th colspan='2'>Účast v kole
+		<tr><th colspan='2'>Účast v kole
 	</thead>
-	<tr><td>{{ round.get_level().name|capitalize }}:<td><a href='{{ url_for('org_contest', id=ct_id) }}'>{{ contest.place.name }}</a>
-	<tr><td>Soutěžní místo:<td><a href='{{ url_for('org_contest', id=ct_id, site_id=sc.pion.place_id) }}'>{{ sc.pion.place.name }}</a>
-	<tr><td>Stav účasti:<td>{{ sc.pion.state.friendly_name() }}
+	<tr><td>{{ round.get_level().name|capitalize }}:<td><a href='{{ ctx.url_for('org_contest') }}'>{{ contest.place.name }}</a>
+	<tr><td>Soutěžní místo:<td><a href='{{ ctx.url_for('org_contest', site_id=ctx.pion.place_id) }}'>{{ ctx.pion.place.name }}</a>
+	<tr><td>Stav účasti:<td>{{ ctx.pion.state.friendly_name() }}
 </table>
 
-<a class="btn btn-default" href="{{ url_for('org_user', id=user.user_id) }}">Detail uživatele</a>
+<a class="btn btn-default" href="{{ user|user_url }}">Detail uživatele</a>
 
 {% include "parts/org_submit_warning.html" %}
 
diff --git a/mo/web/templates/org_edit_statement.html b/mo/web/templates/org_edit_statement.html
index 3f5a3cf06d560096db40821cc50e27b9ac22cfb6..2c303e6b3238c2e99c8a3508ad470dffa67d2ad6 100644
--- a/mo/web/templates/org_edit_statement.html
+++ b/mo/web/templates/org_edit_statement.html
@@ -3,7 +3,7 @@
 
 {% block title %}Zadání kola {{ round.round_code() }}{% endblock %}
 {% block breadcrumbs %}
-{{ contest_breadcrumbs(round=round, action="Zadáni") }}
+{{ ctx.breadcrumbs(action="Zadáni") }}
 {% endblock %}
 {% block body %}
 
diff --git a/mo/web/templates/org_generic_batch_download.html b/mo/web/templates/org_generic_batch_download.html
index b2248bed84cdc41a3a6973c9f2a5c180ceaa577f..d73c788fa9bf4d937355c6c04ce25348fc421a29 100644
--- a/mo/web/templates/org_generic_batch_download.html
+++ b/mo/web/templates/org_generic_batch_download.html
@@ -3,7 +3,7 @@
 
 {% block title %}Stažení řešení úlohy {{ task.code }} {{ task.name }}{% endblock %}
 {% block breadcrumbs %}
-{{ contest_breadcrumbs(round=round, contest=contest, site=site, task=task, action="Stažení řešení") }}
+{{ ctx.breadcrumbs(action="Stažení řešení") }}
 {% endblock %}
 
 {% block body %}
@@ -14,10 +14,14 @@
 </table>
 
 <p>Zde si můžete stáhnout všechna řešení této úlohy
-{% if contest %}
-    {{ contest.place.name_locative() }}.
+{% if ctx.site %}
+	{{ ctx.site.name_locative() }}.
+{% elif contest %}
+	{{ contest.place.name_locative() }}.
+{% elif ctx.hier_place %}
+	{{ ctx.hier_place.name_locative() }}.
 {% else %}
-	ze všech oblastí tohoto kola.
+	ze všech soutěží tohoto kola.
 {% endif %}
 Pozor na to, že jich může být poměrně hodně (viz celková velikost dat výše).
 
diff --git a/mo/web/templates/org_generic_batch_points.html b/mo/web/templates/org_generic_batch_points.html
index 074f4265deee32e6383d322e8bfc8b86ae49091b..aaa82898ab73e787598fc4ab529f2faa5a8a0c14 100644
--- a/mo/web/templates/org_generic_batch_points.html
+++ b/mo/web/templates/org_generic_batch_points.html
@@ -3,7 +3,7 @@
 
 {% block title %}Dávkové bodování úlohy {{ task.code }} {{ task.name }}{% endblock %}
 {% block breadcrumbs %}
-{{ contest_breadcrumbs(round=round, contest=contest, task=task, action="Dávkové bodování") }}
+{{ ctx.breadcrumbs(action="Dávkové bodování") }}
 {% endblock %}
 
 {% block body %}
diff --git a/mo/web/templates/org_generic_batch_upload.html b/mo/web/templates/org_generic_batch_upload.html
index 99d78527a97bdd4893dcdfa6a4ac71c3bb1498a8..e085c0ae9b36bd13e7f49918284a9abcf778fca3 100644
--- a/mo/web/templates/org_generic_batch_upload.html
+++ b/mo/web/templates/org_generic_batch_upload.html
@@ -1,10 +1,9 @@
 {% extends "base.html" %}
 {% import "bootstrap/wtf.html" as wtf %}
-{% set site_id = site.place_id if site else None %}
 
 {% block title %}Nahrání opravených řešení úlohy {{ task.code }} {{ task.name }}{% endblock %}
 {% block breadcrumbs %}
-{{ contest_breadcrumbs(round=round, contest=contest, site=site, task=task, action="Nahrání opravených řešení") }}
+{{ ctx.breadcrumbs(action="Nahrání opravených řešení") }}
 {% endblock %}
 
 {% block body %}
diff --git a/mo/web/templates/org_generic_import.html b/mo/web/templates/org_generic_import.html
index 295a450726147a5b15b08460303250a439a23526..6ed927d607648ab8660340faeb70a481ebc5831b 100644
--- a/mo/web/templates/org_generic_import.html
+++ b/mo/web/templates/org_generic_import.html
@@ -5,7 +5,7 @@
 Import dat do {% if contest %}soutěže {{ contest.place.name_locative() }}{% else %}kola {{ round.round_code() }}{% endif %}
 {% endblock %}
 {% block breadcrumbs %}
-{{ contest_breadcrumbs(round=round, contest=contest, action="Import dat") }}
+{{ ctx.breadcrumbs(action="Import dat") }}
 {% endblock %}
 
 {% block body %}
@@ -33,7 +33,7 @@ Import dat do {% if contest %}soutěže {{ contest.place.name_locative() }}{% el
 Detaily fungování importu najdete v <a href='{{ url_for('doc_import') }}'>dokumentaci</a>.
 
 {% if not contest %}
-<p><em>Pozor, zde se importuje do všech oblastí najednou, takže je nutné uvádět
+<p><em>Pozor, zde se importuje do více soutěží najednou, takže je nutné uvádět
 kód oblasti. Nechcete raději importovat do konkrétní oblasti?</em>
 {% endif %}
 
diff --git a/mo/web/templates/org_generic_list.html b/mo/web/templates/org_generic_list.html
new file mode 100644
index 0000000000000000000000000000000000000000..702955b979fed8bba9fd7f047c890b417270f688
--- /dev/null
+++ b/mo/web/templates/org_generic_list.html
@@ -0,0 +1,162 @@
+{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+
+{% block title %}
+	{% if contest %}
+	Seznam účastníků {% if site %}v soutěžním místě {{ site.name }}{% else %}{{ contest.place.name_locative() }}{% endif %}
+	{% else %}
+	Seznam účastníků kola {{ round.round_code() }}
+	{% endif %}
+{% endblock %}
+{% block breadcrumbs %}
+{{ ctx.breadcrumbs(action="Seznam účastníků" if table else "E-maily", table=False if table else True) }}
+{% endblock %}
+{% set id = contest.contest_id %}
+{% set site_id = site.place_id if site else None %}
+
+{% block body %}
+<div class="form-frame">
+<form action="" method="GET" class="form form-inline" role="form">
+	<div class="form-row">
+		{% if not contest %}
+		{{ wtf.form_field(filter.contest_place, placeholder='Kód', size=8) }}
+		{% endif %}
+		{% if not site %}
+		{{ wtf.form_field(filter.participation_place, size=8) }}
+		{% endif %}
+		{{ wtf.form_field(filter.school, size=8) }}
+		{{ wtf.form_field(filter.participation_state) }}
+	</div>
+	<div class="form-row" style="margin-top: 5px;">
+		<div class="btn-group">
+			{{ wtf.form_field(filter.submit, class='btn btn-primary') }}
+			{% if table %}
+			<button class="btn btn-default" name="format" value="cs_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>
+			{% endif %}
+		</div>
+		{% if table %}
+		<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>
+		{% set max = filter.offset.data + filter.limit.data if filter.offset.data + filter.limit.data < count else count %}
+		{% if count > 0 %}
+			Zobrazuji záznamy <b>{{filter.offset.data + 1}}</b> až <b>{{ max }}</b> z <b>{{count}} nalezených účastníků</b>.
+		{% else %}
+			<b>Nebyly nalezeny žádné záznamy účastníků.</b>
+		{% endif %}
+		{% else %}
+		Celkem <b>{{count|inflected('nalezený účastník', 'nalezení účastníci', 'nalezených účastníků')}}</b>.
+		{% endif %}
+	</div>
+</form>
+</div>
+
+{% if table %}
+	{% if action_form %}
+	<form action="" method="POST" class="form form-horizontal" role="form">
+	{% endif %}
+
+		{{ table.to_html() }}
+
+		{% if contest %}
+		<a class="btn btn-primary" href="{{ url_for('org_contest_add_user', ct_id=contest.contest_id, site_id=site.place_id if site else None) }}">Přidat účastníka</a>
+		{% endif %}
+		<a class="btn btn-default"
+		   title="Zobrazí emailové adresy ve snadno zkopírovatelném formátu"
+		   href="{{ ctx.url_for('org_generic_list_emails', **request.args) }}">
+			Vypsat e-mailové adresy
+		</a>
+
+	{% if action_form %}
+		{{ action_form.csrf_token }}
+		<h3>Provést akci</h3>
+		<div class="form-frame">
+		<div class="form-group">
+			<label class="control-label col-sm-2">Provést akci na:</label>
+			<div class="col-sm-5 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 účastnících
+				</label>
+			</div><div class="col-sm-5 radio">
+				<label>
+					<input id="action_on-1" name="action_on" type="radio" value="checked" required{% if action_form.action_on.data == 'checked' %} checked{% endif %}>
+					pouze zaškrtnutých účastnících
+				</label>
+			</div>
+		</div>
+		<div class="form-group">
+			<label class="control-label col-sm-2" for="participation_state">Stav účasti</label>
+			<div class="col-sm-6">{{ wtf.form_field(action_form.participation_state, form_type='inline') }}</div>
+			<div class="col-sm-4">{{ wtf.form_field(action_form.set_participation_state, form_type='inline', class='btn btn-primary') }}</div>
+		</div>
+		<div class="form-group">
+			<label class="control-label col-sm-2" for="participation_place">Soutěžní místo</label>
+			<div class="col-sm-6">{{ wtf.form_field(action_form.participation_place, form_type='inline', placeholder='Kód místa') }}</div>
+			<div class="col-sm-4">{{ wtf.form_field(action_form.set_participation_place, form_type='inline', class='btn btn-primary') }}</div>
+		</div>
+		<div class="form-group">
+			<label class="control-label col-sm-2" for="contest_place">
+				{{ round.get_level().name|capitalize }}
+			</label>
+			<div class="col-sm-6">
+				{{ wtf.form_field(action_form.contest_place, form_type='inline', placeholder='Kód místa') }}
+				<p class="help-block">
+					{{ round.get_level().name_locative("V tomto", "V této", "V tomto") }} musí existovat soutěž pro {{ round.name|lower }} kategorie {{ round.category }}.
+				</p>
+			</div>
+			<div class="col-sm-4">{{ wtf.form_field(action_form.set_contest, form_type='inline', class='btn btn-primary', value='Přesunout do ' + round.get_level().name_genitive('jiného', 'jiné', 'jiného')) }}</div>
+		</div>
+		<div class="form-group">
+			<label class="control-label col-sm-2" for="contest_place">Smazání účasti</label>
+			<div class="col-sm-6"><p class="help-block">Dojde ke smazání účasti v tomto kole, ne účastníka z ročníku.</p></div>
+			<div class="col-sm-4">{{ wtf.form_field(action_form.remove_participation, form_type='inline', class='btn btn-danger') }}</div>
+		</div>
+		</div>
+	</form>
+	{% else %}
+	<p>
+	<i>Nemáte právo k editaci účastníků v {{ round.get_level().name_locative("v tomto", "v této", "v tomto") }}.</i>
+	</p>
+	{% endif %}
+{% else %}
+	<h3>E-mailové adresy</h3>
+
+	{% if emails %}
+	<pre>{{ emails|join('\n')|escape }}</pre>
+	<textarea id="emails-textarea" style="display: none">{{ emails|join('\n')|escape }}</textarea>
+
+	<p>
+	<a class="btn btn-primary" href="{{ mailto_link }}">Vytvořit e-mail pro {{ count|inflected("adresáta", "adresáty", "adresátů") }}</a>
+	<button class="btn btn-default" id="copy-emails">Zkopírovat všechny adresy do schránky</button>
+	<script type="text/javascript">
+	    var ta = document.getElementById('emails-textarea');
+	    document.getElementById('copy-emails').addEventListener('click', function () {
+		    ta.style.display = 'block';
+		    ta.select();
+		    document.execCommand('copy', false);
+		    ta.style.display = 'none';
+	    });
+	</script>
+	</p>
+
+	<p>E-mailové adresy účastníků prosím vkládejte do pole pro <b>skrytou kopii (Bcc)</b>, ať si navzájem nevidí své e-maily.</p>
+
+	{% else %}<i>Žádné e-mailové adresy k vypsání.</i>{% endif %}
+{% endif %}
+{% endblock %}
diff --git a/mo/web/templates/org_index.html b/mo/web/templates/org_index.html
index e4a5b0f5011b8458a24583502c2dadb683287f0e..8436ddc60af66b727be08c1ae0c43073d7c53e0e 100644
--- a/mo/web/templates/org_index.html
+++ b/mo/web/templates/org_index.html
@@ -2,34 +2,46 @@
 {% block title %}Organizátorské rozhraní{% endblock %}
 {% block body %}
 
-{% if contests %}
+{% if overview %}
 
 <h3>Moje soutěže</h3>
 <table class="table table-bordered table-condensed greyhead">
 {% set curr = namespace(level=-1) %}
-	{% for c in contests %}
-		{% if curr.level != c.round.level %}
+	{% for o in overview %}
+		{% if curr.level != o.round.level %}
 		<thead><tr>
 			<th>ID
 			<th>Kategorie
 			<th>Kolo
-			<th>{{ c.round.get_level().name|capitalize }}
+			<th>{{ o.round.get_level().name|capitalize }}
 			<th>Stav
 			<th>Moje role
 			<th>Odkazy
 		</thead>
-		{% set curr.level = c.round.level %}
+		{% set curr.level = o.round.level %}
+		{% endif %}
+
+		{% if o.contest %}
+			{% set detail_url = url_for('org_contest', ct_id=o.contest.contest_id) %}
+		{% elif o.place.level == 0 %}
+			{% set detail_url = url_for('org_round', round_id=o.round.round_id) %}
+		{% else %}
+			{% set detail_url = url_for('org_round', round_id=o.round.round_id, hier_id=o.place.place_id) %}
 		{% endif %}
 
 		<tr>
-			<td><a href='{{ url_for('org_contest', id=c.contest_id) }}'>{{ c.round.round_code() }}</a>
-			<td class="text-center"><b>{{ c.round.category }}</b>
-			<td>{{ c.round.name }}
-			<td>{{ c.place.name }}
-			<td class="rstate-{{c.state.name}}">{{ c.state.friendly_name() }}
-			<td>{% for r in contest_roles[c.contest_id] %}{{ role_type_names[r] }}{% if not loop.last %}<br>{% endif %}{% endfor %}
+			<td><a href='{{ detail_url }}'>{{ o.round.round_code() }}</a>
+			<td class="text-center"><b>{{ o.round.category }}</b>
+			<td>{{ o.round.name }}
+		{% if o.contest %}
+			<td>{{ o.place.name }}
+			<td class="rstate-{{o.contest.state.name}}">{{ o.contest.state.friendly_name() }}
+		{% else %}
+			<td><i>{{ o.place.name_locative() }}</i>
 			<td>
-				<a class="btn btn-xs btn-primary" href='{{ url_for('org_contest', id=c.contest_id) }}'>Detail</a>
+		{% endif %}
+			<td>{% for r in o.role_list %}{{ role_type_names[r] }}{% if not loop.last %}<br>{% endif %}{% endfor %}
+			<td><a class="btn btn-xs btn-primary" href='{{ detail_url }}'>Detail</a>
 	{% endfor %}
 </table>
 
diff --git a/mo/web/templates/org_place.html b/mo/web/templates/org_place.html
index 94f8df8e77ba0934a3043812c5f5f33ef4d133c2..0baf84755988aa855b3253980d56623bfbfe4afb 100644
--- a/mo/web/templates/org_place.html
+++ b/mo/web/templates/org_place.html
@@ -72,7 +72,7 @@
 	{% for c in contests %}
 		<tr>
 			{% set r = c.round %}
-			<td><a href='{{ url_for('org_contest', id=c.contest_id) }}'>{{ r.round_code() }}</a>
+			<td><a href='{{ url_for('org_contest', ct_id=c.contest_id) }}'>{{ r.round_code() }}</a>
 			<td>{{ r.year }}
 			<td>{{ r.category }}
 			<td>{{ r.name }}
diff --git a/mo/web/templates/org_round.html b/mo/web/templates/org_round.html
index bd91f5a844a660e1917ff0ee02e7c3be2ccdf16e..ddb74769aa51ad4f7a30c0c211cac1615d08454b 100644
--- a/mo/web/templates/org_round.html
+++ b/mo/web/templates/org_round.html
@@ -1,16 +1,30 @@
 {% extends "base.html" %}
 {% import "bootstrap/wtf.html" as wtf %}
+{% set in_hier = ctx.hier_id != None %}
+{% set can_manage_round = rights.have_right(Right.manage_round) and not in_hier %}
+{% set can_manage_contest = rights.have_right(Right.manage_contest) %}
+{% set can_view_contestants = rights.have_right(Right.view_contestants) %}
+{% set can_handle_submits = rights.have_right(Right.view_submits) %}
+{% set can_upload = rights.can_upload_feedback() %}
+{% set can_view_statement = rights.can_view_statement() %}
+{% set can_add_contest = g.gatekeeper.rights_generic().have_right(Right.add_contest) %}
 
-{% block title %}{{ round.name }} {{ round.round_code() }}{% endblock %}
+{% block title %}
+	{% if in_hier %}
+		{{ round.round_code() }}: {{ ctx.hier_place.name }}
+	{% else %}
+		{{ round.name }} {{ round.round_code() }}
+	{% endif %}
+{% endblock %}
 {% block breadcrumbs %}
-{{ contest_breadcrumbs(round=round) }}
+{{ ctx.breadcrumbs() }}
 {% endblock %}
 
 {% block body %}
 
 <table class=data style="float: left; margin-right: 10px;">
 	<thead>
-		<tr><th colspan=2>Parametry kola <i>(nelze editovat)</i>
+		<tr><th colspan=2>Parametry kola{% if can_manage_round %} <i>(nelze editovat)</i>{% endif %}
 	</thead>
 	<tr><td>Ročník<td>{{ round.year }}
 	<tr><td>Kategorie<td>{{ round.category }}
@@ -22,7 +36,7 @@
 	<tr><td>Skupina kol:<td>
 		{% for r in group_rounds %}
 			{% if r == round %}<i>{{ r.name }} {{ r.round_code() }} (toto kolo)</i><br>
-			{% else %}<a href="{{ url_for('org_round', id=r.round_id) }}">{{ r.name }} {{ r.round_code() }}</a><br>
+			{% else %}<a href="{{ url_for('org_round', round_id=r.round_id) }}">{{ r.name }} {{ r.round_code() }}</a><br>
 			{% endif %}
 		{% endfor %}
 	{% endif %}
@@ -50,7 +64,7 @@
 	{% if not statement_exists %}
 		<span class=error>soubor neexistuje</span>
 	{% elif can_view_statement %}
-		<a href='{{ url_for('org_task_statement', id=round.round_id) }}'>stáhnout</a>
+		<a href='{{ ctx.url_for('org_task_statement') }}'>stáhnout</a>
 	{% else %}
 		není dostupné
 	{% endif %}
@@ -59,7 +73,7 @@
 {% endif %}
 	<thead>
 		<tr><th colspan=2>Nastavení výsledkové listiny{% if round.is_subround() %}
-			<i>(přejato z <a href="{{ url_for('org_round', id=round.master.round_id) }}">{{ round.master.round_code() }}</a>)</i>
+			<i>(přejato z <a href="{{ url_for('org_round', round_id=round.master.round_id) }}">{{ round.master.round_code() }}</a>)</i>
 		{% endif %}
 	</thead>
 	<tr><td>Výsledková listina<td>{{ round.master.score_mode.friendly_name() }}
@@ -69,52 +83,77 @@
 </table>
 <div style="clear: both;"></div>
 
+{% if can_view_contestants or can_manage_contest or can_manage_round or round.has_messages %}
 <div class="btn-group">
-	<a class="btn btn-primary" href='{{ url_for('org_round_list', id=round.round_id) }}'>Seznam účastníků</a>
-	{% if round.state in [RoundState.grading, RoundState.closed, RoundState.delegate] %}
-	<a class="btn btn-primary" href='{{ url_for('org_score', round_id=round.round_id) }}'>Výsledky</a>
+	{% if can_view_contestants %}
+	<a class="btn btn-primary" href='{{ ctx.url_for('org_generic_list') }}'>Seznam účastníků</a>
 	{% endif %}
-	{% if can_manage_contestants %}
-	<a class="btn btn-default" href='{{ url_for('org_round_import', id=round.round_id) }}'>Importovat data</a>
+	{% if can_view_contestants and round.state in [RoundState.grading, RoundState.closed, RoundState.delegate] and not in_hier %}
+	<a class="btn btn-primary" href='{{ ctx.url_for('org_score') }}'>Výsledky</a>
+	{% endif %}
+	{% if can_manage_contest %}
+	<a class="btn btn-default" href='{{ ctx.url_for('org_generic_import') }}'>Importovat data</a>
 	{% endif %}
 	{% if can_manage_round %}
-	<a class="btn btn-default" href='{{ url_for('org_round_edit', id=round.round_id) }}'>Nastavení a termíny</a>
-	<a class="btn btn-default" href='{{ url_for('org_edit_statement', id=round.round_id) }}'>Zadání</a>
+	<a class="btn btn-default" href='{{ ctx.url_for('org_round_edit') }}'>Nastavení a termíny</a>
+	<a class="btn btn-default" href='{{ ctx.url_for('org_edit_statement') }}'>Zadání</a>
 	{% endif %}
 	{% if round.has_messages %}
-	<a class="btn btn-default" href='{{ url_for('org_round_messages', id=round.round_id) }}'>Zprávičky</a>
+	<a class="btn btn-default" href='{{ ctx.url_for('org_round_messages') }}'>Zprávičky</a>
 	{% endif %}
-	{% if g.user.is_admin %}
+	{% if g.user.is_admin and not in_hier %}
 	<a class="btn btn-default" href='{{ log_url('round', round.round_id) }}'>Historie</a>
 	{% endif %}
 </div>
+{% endif %}
 
 <h3>Soutěže</h3>
-{% if contests_counts %}
-<table class=data>
-	<thead>
-		<tr>
-			<th>{{ round.get_level().name|capitalize }}
-			<th>Stav
-			<th>Počet účastníků
-		</tr>
-	</thead>
-	{% for (c, count) in contests_counts %}
-	<tr>
-		<td><a href='{{ url_for('org_contest', id=c.contest_id) }}'>{{ c.place.name }}</a>
-		{% with state=c.state %}
-		<td class='rstate-{{state.name}}'>{{ state.friendly_name() }}
-		{% endwith %}
-		<td>{{ count }}
-	{% endfor %}
-	<tfoot>
+{% if reg_total.num_contests %}
+	{% set show_contests = reg_stats[0].contest != None %}
+	<table class=data>
+		<thead>
+			<tr>
+			{% if show_contests %}
+				<th>{{ round.get_level().name|capitalize }}
+				<th>Stav
+			{% else %}
+				<th>{{ reg_stats[0].region.type_name()|capitalize }}
+				<th>Počet soutěží
+				<th>Stavy soutěží
+			{% endif %}
+				<th>Počet účastníků
+				<th>Počet přihlášek
+			</tr>
+		</thead>
+		{% for rs in reg_stats %}
 		<tr>
-			<th>Celkem
-			<th>
-			<th>{{ contests_counts|sum(attribute=1) }}
-		</tr>
-	</tfoot>
-</table>
+		{% if show_contests %}
+			<td><a href='{{ url_for('org_contest', ct_id=rs.contest.contest_id) }}'>{{ rs.region.name }}</a>
+			{% with state=rs.contest.state %}
+			<td class='rstate-{{state.name}}'>{{ state.friendly_name() }}
+			{% endwith %}
+		{% else %}
+			<td><a href='{{ ctx.url_for('org_round', hier_id=rs.region.place_id) }}'>{{ rs.region.name }}</a>
+			<td>{{ rs.num_contests }}
+			<td>{% for s in rs.contest_states %}<span class='rstate-{{s.name}}'>{{ s.friendly_name() }}</span> {% endfor %}
+		{% endif %}
+			<td>{{ rs.num_active_pants }}
+			<td>{{ rs.num_unconfirmed_pants }}
+		{% endfor %}
+		<tfoot>
+			<tr>
+				<th>Celkem
+			{% if show_contests %}
+				<th>
+			{% else %}
+				<th>{{ reg_total.num_contests }}
+				<th>
+			{% endif %}
+				<th>{{ reg_total.num_active_pants }}
+				<th>{{ reg_total.num_unconfirmed_pants }}
+			</tr>
+		</tfoot>
+	</table>
 {% else %}
 <p>Zatím nebyly založeny žádné soutěže.
 {% endif %}
@@ -128,7 +167,7 @@
 {% endif %}
 
 <h3>Úlohy</h3>
-{% if tasks %}
+{% if task_info %}
 <table class=data>
 	<thead>
 		<tr>
@@ -140,15 +179,15 @@
 			{% if can_handle_submits or can_upload %}<th>Dávkové operace{% endif %}
 		</tr>
 	</thead>
-	{% for task in tasks %}
+	{% for task, sol_count in task_info %}
 		<tr>
 			<td>{{ task.code }}
 			<td>{{ task.name }}
-			<td>{{ task.sol_count }}
+			<td>{{ sol_count }}
 			<td>{{ task.max_points|decimal|none_value('–') }}
 			{% if can_manage_round %}
 			<td><div class="btn-group">
-				<a class="btn btn-xs btn-primary" href="{{ url_for('org_round_task_edit', id=round.round_id, task_id=task.task_id) }}">Editovat</a>
+				<a class="btn btn-xs btn-primary" href="{{ ctx.url_for('org_round_task_edit', task_id=task.task_id) }}">Editovat</a>
 				{% if task.sol_count == 0 %}
 				<form action="" method="POST" onsubmit="return confirm('Opravdu nenávratně smazat?')" class="btn-group">
 					{{ form_delete_task.csrf_token() }}
@@ -164,13 +203,13 @@
 			{% if can_handle_submits or can_upload %}
 			<td><div class="btn-group">
 				{% if can_handle_submits %}
-					<a class="btn btn-xs btn-primary" href="{{ url_for('org_round_task_download', round_id=round.round_id, task_id=task.task_id) }}">Stáhnout ZIP</a>
+					<a class="btn btn-xs btn-primary" href="{{ ctx.url_for('org_generic_batch_download', task_id=task.task_id) }}">Stáhnout ZIP</a>
 				{% endif %}
 				{% if can_upload %}
-					<a class="btn btn-xs btn-primary" href="{{ url_for('org_round_task_upload', round_id=round.round_id, task_id=task.task_id) }}">Nahrát ZIP</a>
+					<a class="btn btn-xs btn-primary" href="{{ ctx.url_for('org_generic_batch_upload', task_id=task.task_id) }}">Nahrát ZIP</a>
 				{% endif %}
 				{% if can_upload %}
-					<a class="btn btn-xs btn-default" href="{{ url_for('org_round_task_batch_points', round_id=round.round_id, task_id=task.task_id) }}">Nahrát body</a>
+					<a class="btn btn-xs btn-default" href="{{ ctx.url_for('org_generic_batch_points', task_id=task.task_id) }}">Nahrát body</a>
 				{% endif %}
 			</div>
 			{% endif %}
@@ -181,7 +220,7 @@
 <p>Zatím nebyly přidány žádné úlohy.</p>
 {% endif %}
 {% if can_manage_round %}
-<a class="btn btn-primary right-float" href="{{ url_for('org_round_task_new', id=round.round_id) }}">Nová úloha</a>
+<a class="btn btn-primary right-float" href="{{ ctx.url_for('org_round_task_new') }}">Nová úloha</a>
 {% endif %}
 
 {% endblock %}
diff --git a/mo/web/templates/org_round_edit.html b/mo/web/templates/org_round_edit.html
index 8e9ea08798933dd26db84422c3460f7743f338ff..0e42ebd87aba8af08e40e202dfaccb731dba5d33 100644
--- a/mo/web/templates/org_round_edit.html
+++ b/mo/web/templates/org_round_edit.html
@@ -3,7 +3,7 @@
 
 {% block title %}Editace kola {{ round.round_code() }}{% endblock %}
 {% block breadcrumbs %}
-{{ contest_breadcrumbs(round=round, action="Editace") }}
+{{ ctx.breadcrumbs(action="Editace") }}
 {% endblock %}
 {% block body %}
 
diff --git a/mo/web/templates/org_round_list.html b/mo/web/templates/org_round_list.html
deleted file mode 100644
index 4dbdcf32b3daec2c0ded2ae58d7a419bae2b4da3..0000000000000000000000000000000000000000
--- a/mo/web/templates/org_round_list.html
+++ /dev/null
@@ -1,67 +0,0 @@
-{% extends "base.html" %}
-{% import "bootstrap/wtf.html" as wtf %}
-
-{% block title %}Seznam účastníků kola {{ round.round_code() }}{% endblock %}
-{% block breadcrumbs %}
-{{ contest_breadcrumbs(round=round, action="Seznam účastníků" if table else "E-maily", table=False if table else True) }}
-{% endblock %}
-{% set id = round.round_id %}
-
-{% block body %}
-<div class="form-frame">
-<form action="" method="GET" class="form form-inline" role="form">
-	<div class="form-row">
-		{{ wtf.form_field(filter.contest_place, size=8) }}
-		{{ wtf.form_field(filter.participation_place, size=8) }}
-		{{ wtf.form_field(filter.school, size=8) }}
-		{{ wtf.form_field(filter.participation_state) }}
-	</div>
-	<div class="form-row" style="margin-top: 5px;">
-		<div class="btn-group">
-			{{ wtf.form_field(filter.submit, class='btn btn-primary') }}
-			{% if table %}
-			<button class="btn btn-default" name="format" value="cs_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>
-			{% endif %}
-		</div>
-		{% if table %}
-		<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>
-		{% set max = filter.offset.data + filter.limit.data if filter.offset.data + filter.limit.data < count else count %}
-		{% if count > 0 %}
-			Zobrazuji záznamy <b>{{filter.offset.data + 1}}</b> až <b>{{ max }}</b> z <b>{{count}} nalezených účastníků</b>.
-		{% else %}
-			<b>Nebyly nalezeny žádné záznamy účastníků.</b>
-		{% endif %}
-		{% else %}
-		Celkem <b>{{count|inflected('nalezený účastník', 'nalezení účastníci', 'nalezených účastníků')}}</b>.
-		{% endif %}
-	</div>
-</form>
-</div>
-
-{% if table %}
-	{% include 'parts/org_participants_table_actions.html' %}
-	{% if form_actions %}
-	<br>
-	<i>Upozornění: Můžete editovat jen účastníky soutěžící v oblastech, ke kterým máte právo.</i>
-	{% endif %}
-{% else %}
-	{% include 'parts/org_participants_emails.html' %}
-{% endif %}
-{% endblock %}
diff --git a/mo/web/templates/org_round_messages.html b/mo/web/templates/org_round_messages.html
index 1be2a7367b9db1ef420ee6ad65f7a19d925270f4..d5e9ec3a7a648cdcd9878dfd7b3d80787c962a17 100644
--- a/mo/web/templates/org_round_messages.html
+++ b/mo/web/templates/org_round_messages.html
@@ -3,7 +3,7 @@
 
 {% block title %}{{ round.name }} {{ round.round_code() }} – zprávičky{% endblock %}
 {% block breadcrumbs %}
-{{ contest_breadcrumbs(round=round, action='Zprávičky') }}
+{{ ctx.breadcrumbs(action='Zprávičky') }}
 {% endblock %}
 
 {% block body %}
diff --git a/mo/web/templates/org_round_task_edit.html b/mo/web/templates/org_round_task_edit.html
index fa26bbeacbf9a3f3df0c50101e627903b64c33b5..a43fbdb88b969bedf6e7160a19391dc76666b396 100644
--- a/mo/web/templates/org_round_task_edit.html
+++ b/mo/web/templates/org_round_task_edit.html
@@ -5,7 +5,7 @@
 {% if task %}Editace úlohy {{ task.code }} {{ task.name }}{% else %}Nová úloha{% endif %}
 {% endblock %}
 {% block breadcrumbs %}
-{{ contest_breadcrumbs(round=round, task=task, action="Nová úloha" if not task else None) }}
+{{ ctx.breadcrumbs(action="Nová úloha" if not ctx.task else None) }}
 {% endblock %}
 
 {% block body %}
diff --git a/mo/web/templates/org_rounds.html b/mo/web/templates/org_rounds.html
index d4b415dc7e863a0df32fd04f885522194ae67780..b37c89c4c10c8245bfd9f6204f5f2b308ce80657 100644
--- a/mo/web/templates/org_rounds.html
+++ b/mo/web/templates/org_rounds.html
@@ -14,7 +14,7 @@
 		</thead>
 	{% for r in rounds %}
 		<tr>
-			<td><a href='{{ url_for('org_round', id=r.round_id) }}'>{{ r.round_code() }}</a>
+			<td><a href='{{ url_for('org_round', round_id=r.round_id) }}'>{{ r.round_code() }}</a>
 			<td>{{ r.year }}
 			<td>{{ r.category }}
 			<td>{{ r.seq }}{{ r.part_code() }}
diff --git a/mo/web/templates/org_score.html b/mo/web/templates/org_score.html
index 8e0b6e98dc2997afa4f9b8c26d2e3e0d98ebe1df..0513cf4d27055f070c286c67d7a94396ca72a7ff 100644
--- a/mo/web/templates/org_score.html
+++ b/mo/web/templates/org_score.html
@@ -4,14 +4,14 @@
 {{ round.round_code() }}: Výsledky pro {{ round.name|lower }} kategorie {{ round.category }}{% if contest %} {{ contest.place.name_locative() }}{% endif %}
 {% endblock %}
 {% block breadcrumbs %}
-{{ contest_breadcrumbs(round=round, contest=contest, action="Výsledky oblasti" if contest else "Výsledky kola") }}
+{{ ctx.breadcrumbs(action="Výsledky oblasti" if contest else "Výsledky kola") }}
 {% endblock %}
 
 {% block pretitle %}
 <div class="btn-group pull-right">
 	{% if contest %}
-	<a class="btn btn-default" href="{{ url_for('org_contest_solutions', id=contest.contest_id) }}">Odevzdaná řešení</a>
-	<a class="btn btn-default" href="{{ url_for('org_score', round_id=round.round_id) }}">Výsledky kola</a>
+	<a class="btn btn-default" href="{{ ctx.url_for('org_contest_solutions') }}">Odevzdaná řešení</a>
+	<a class="btn btn-default" href="{{ ctx.url_for('org_score', ct_id=None) }}">Výsledky kola</a>
 	{% endif %}
 </div>
 {% endblock %}
@@ -42,7 +42,7 @@
 
 {% if group_rounds|length > 1 %}
 <p>Toto je <b>sdílená výsledková listina</b> pro několik kol:
-{% for r in group_rounds %}{% if loop.index > 1 %}, {% endif %}<a href="{{ url_for('org_round', id=r.round_id) }}">{{ r.round_code() }} {{ r.name }}</a>{% endfor %}.
+{% for r in group_rounds %}{% if loop.index > 1 %}, {% endif %}<a href="{{ url_for('org_round', round_id=r.round_id) }}">{{ r.round_code() }} {{ r.name }}</a>{% endfor %}.
 Jsou v ní započítány body ze všech úloh těchto kol.</p>
 {% endif %}
 
diff --git a/mo/web/templates/org_submit_list.html b/mo/web/templates/org_submit_list.html
index ff445d3ee105f6eeae8c395321ade119702729ed..09a62ca21fea47eea2cc23b480251453d4c50195 100644
--- a/mo/web/templates/org_submit_list.html
+++ b/mo/web/templates/org_submit_list.html
@@ -1,20 +1,23 @@
 {% extends "base.html" %}
 {% import "bootstrap/wtf.html" as wtf %}
-{% block title %}{{ sc.user.full_name() }} – řešení úlohy {{ sc.task.code }} {{ sc.task.name }}{% endblock %}
+{% set allow_edit_points=rights.can_edit_points() %}
+{% set allow_upload_solutions=rights.can_upload_solutions() %}
+{% set allow_upload_feedback=rights.can_upload_feedback() %}
+
+{% block title %}{{ ctx.user.full_name() }} – řešení úlohy {{ ctx.task.code }} {{ ctx.task.name }}{% endblock %}
 {% block breadcrumbs %}
-{{ contest_breadcrumbs(round=sc.round, contest=sc.contest, site=sc.site, task=sc.task, user=sc.user, action="Detail řešení") }}
+{{ ctx.breadcrumbs(action="Detail řešení") }}
 {% endblock %}
 
 {% block body %}
-{% set site_id = sc.site.place_id if sc.site else None %}
 
 <table class="data">
-	<tr><th>Účastník<td>{{ sc.user|pion_link(sc.contest.contest_id) }}{{ sc.user|user_flags }}
-	<tr><th>Úloha<td><a href='{{ url_for('org_contest_task', contest_id=sc.contest.contest_id, site_id=site_id, task_id=sc.task.task_id) }}'>{{ sc.task.code }} {{ sc.task.name }}</a>
+	<tr><th>Účastník<td>{{ ctx.user|pion_link(ctx.contest.contest_id) }}{{ ctx.user|user_flags }}
+	<tr><th>Úloha<td><a href='{{ ctx.url_for('org_contest_task') }}'>{{ ctx.task.code }} {{ ctx.task.name }}</a>
 	{% if solution %}
 	<tr><th>Body<td>
 		{{ solution.points|decimal|none_value(Markup('<span class="unknown">?</span>')) }}
-		{% if sc.task.max_points is not none %}<span class="hint"> / {{ sc.task.max_points|decimal }}</span>{% endif %}
+		{% if ctx.task.max_points is not none %}<span class="hint"> / {{ ctx.task.max_points|decimal }}</span>{% endif %}
 	<tr title="Viditelná účastníkovi po uzavření kola">
 		<th>Poznámka k řešení:<td style="white-space: pre-line;">{{ solution.note|or_dash }}</td>
 	<tr title="Viditelná jen organizátorům">
@@ -63,12 +66,12 @@ Existuje více než jedna verze řešení, finální je podbarvená.
 		{% set active_sol_id = None %}
 	{% endif %}
 	{% for p in sol_papers %}
-		{% set late = p.check_deadline(sc.round) %}
+		{% set late = p.check_deadline(ctx.round) %}
 		<tr{% if p.paper_id == active_sol_id %} class='sol-active'{% endif %}>
 			<td{% if late %} class='sol-warn'{% endif %}>{{ p.uploaded_at|timeformat }}
 			<td>{% if p.is_broken() %}nekorektní PDF{% else %}{{ p.pages|or_dash }}{% endif %}
 			<td>{{ p.bytes|or_dash }}
-			<td>{% if p.uploaded_by_obj == sc.user %}<i>účastník</i>{% else %}{{ p.uploaded_by_obj|user_link }}{% endif %}
+			<td>{% if p.uploaded_by_obj == ctx.user %}<i>účastník</i>{% else %}{{ p.uploaded_by_obj|user_link }}{% endif %}
 			<td>{% if late %}<span class='sol-warn'>({{ late }})</span> {% endif %}{{ p.note }}
 				{% if p.is_fixed() %}Automaticky opravené nekorektní PDF.{% endif %}
 			<td><div class="btn-group">
@@ -165,21 +168,21 @@ Existuje více než jedna verze oprav, finální je podbarvená.
 </div>
 
 {% else %}
-<p>Žádné odevzdané řešení. {% if form and sc.allow_edit_points %}Můžete ho založit pomocí formuláře níže.{% endif %}
+<p>Žádné odevzdané řešení. {% if form and allow_edit_points %}Můžete ho založit pomocí formuláře níže.{% endif %}
 {% endif %}
 
-{% if form and (sc.allow_edit_points or sc.allow_upload_feedback or sc.allow_upload_solutions) %}
+{% if form and (allow_edit_points or allow_upload_feedback or allow_upload_solutions) %}
 <form method="post" class="form-horizontal" enctype="multipart/form-data">
 <div class="form-frame">
 	{{ form.csrf_token }}
 	{% set action = 'Uložit' if solution else 'Založit řešení' %}
-	{% if sc.allow_edit_points %}
+	{% if allow_edit_points %}
 		{% if solution %}
 			<h3 style="margin-top: 10px;">Hodnocení řešení</h3>
 		{% else %}
 			<h3>Založit řešení</h3>
 			<p><i>Můžete rovnou vyplnit i poznámky a přidělené body
-				{%- if sc.allow_upload_feedback or sc.allow_upload_solutions %}, případně rovnou nahrát i soubor řešení nebo opravy{% endif %}.
+				{%- if allow_upload_feedback or allow_upload_solutions %}, případně rovnou nahrát i soubor řešení nebo opravy{% endif %}.
 			</i></p>
 		{% endif %}
 		{{ wtf.form_field(form.note, form_type='horizontal', horizontal_columns=('sm', 2, 10), rows=4)}}
@@ -187,13 +190,13 @@ Existuje více než jedna verze oprav, finální je podbarvená.
 		{{ wtf.form_field(form.points, form_type='horizontal', horizontal_columns=('sm', 2, 10) )}}
 		{{ wtf.form_field(
 			form.submit, form_type='horizontal', class='btn btn-primary', horizontal_columns=('sm', 2, 10),
-			value=action + (' bez nahrání souboru' if sc.allow_upload_feedback or sc.allow_upload_solutions else '')
+			value=action + (' bez nahrání souboru' if allow_upload_feedback or allow_upload_solutions else '')
 		)}}
 	{% endif %}
-	{% if sc.allow_upload_feedback or sc.allow_upload_solutions %}
+	{% if allow_upload_feedback or allow_upload_solutions %}
 		{% if solution %}
 			<h3>Nahrání souboru</h3>
-			{% if sc.allow_edit_points %}
+			{% if allow_edit_points %}
 			<p><i>Lze najednou editovat řešení (například zadat body) i nahrát soubor, použijte tlačítka na spodku formuláře.</i></p>
 			{% endif %}
 		{% else %}
@@ -203,10 +206,10 @@ Existuje více než jedna verze oprav, finální je podbarvená.
 		{{ wtf.form_field(form.file_note, form_type='horizontal', horizontal_columns=('sm', 2, 10)) }}
 		<div class="form-group">
 		<div class="btn btn-group col-sm-offset-2">
-			{% if sc.allow_upload_solutions %}
+			{% if allow_upload_solutions %}
 				{{ wtf.form_field(form.submit_sol, class='btn btn-primary', value=action + ' a nahrát soubor jako řešení' )}}
 			{% endif %}
-			{% if sc.allow_upload_feedback %}
+			{% if allow_upload_feedback %}
 				{{ wtf.form_field(form.submit_fb, class='btn btn-success', value=action + ' a nahrát soubor jako opravu' )}}
 			{% endif %}
 		</div>
@@ -214,7 +217,7 @@ Existuje více než jedna verze oprav, finální je podbarvená.
 	{% endif %}
 </div>
 
-{% if solution and not solution.final_submit and not solution.final_feedback and sc.allow_create_solutions %}
+{% if solution and not solution.final_submit and not solution.final_feedback and allow_create_solutions %}
 <div class="form-frame">
 	<h3 style="margin-top: 10px;">Smazání řešení</h3>
 	<p>Toto řešení zatím neobsahuje žádný soubor. Pokud bylo přidáno omylem, můžete ho smazat.</p>
diff --git a/mo/web/templates/org_user.html b/mo/web/templates/org_user.html
index 05463d5fedf7d748cda5aef70109032cae7decbb..9595d8566dcee426aad7afa1425c7ab4ee103145 100644
--- a/mo/web/templates/org_user.html
+++ b/mo/web/templates/org_user.html
@@ -79,9 +79,9 @@
 			{% endif %}
 		<td>{{ pion.state.friendly_name() }}
 		<td><div class="btn-group">
-			<a class="btn btn-xs btn-primary" href="{{ url_for('org_contest_user', contest_id=contest.contest_id, user_id=user.user_id) }}">Odevzdané úlohy</a>
-			<a class="btn btn-xs btn-default" href="{{ url_for('org_contest', id=contest.contest_id) }}">Stránka soutěže</a>
-			<a class="btn btn-xs btn-default" href="{{ url_for('org_round', id=round.round_id) }}">Stránka kola</a>
+			<a class="btn btn-xs btn-primary" href="{{ url_for('org_contest_user', ct_id=contest.contest_id, user_id=user.user_id) }}">Odevzdané úlohy</a>
+			<a class="btn btn-xs btn-default" href="{{ url_for('org_contest', ct_id=contest.contest_id) }}">Stránka soutěže</a>
+			<a class="btn btn-xs btn-default" href="{{ url_for('org_round', round_id=round.round_id) }}">Stránka kola</a>
 		{% if g.user.is_admin %}
 			<a class="btn btn-xs btn-default" href="{{ log_url('participant', user.user_id) }}">Historie</a>
 		{% endif %}
diff --git a/mo/web/templates/parts/org_participants_emails.html b/mo/web/templates/parts/org_participants_emails.html
deleted file mode 100644
index b84c2102ec95312adcf1c168346399c3e71bb3a1..0000000000000000000000000000000000000000
--- a/mo/web/templates/parts/org_participants_emails.html
+++ /dev/null
@@ -1,23 +0,0 @@
-<h3>E-mailové adresy</h3>
-
-{% if emails %}
-<pre>{{ emails|join('\n')|escape }}</pre>
-<textarea id="emails-textarea" style="display: none">{{ emails|join('\n')|escape }}</textarea>
-
-<p>
-<a class="btn btn-primary" href="{{ mailto_link }}">Vytvořit e-mail pro {{ count|inflected("adresáta", "adresáty", "adresátů") }}</a>
-<button class="btn btn-default" id="copy-emails">Zkopírovat všechny adresy do schránky</button>
-<script type="text/javascript">
-    var ta = document.getElementById('emails-textarea');
-    document.getElementById('copy-emails').addEventListener('click', function () {
-            ta.style.display = 'block';
-            ta.select();
-            document.execCommand('copy', false);
-            ta.style.display = 'none';
-    });
-</script>
-</p>
-
-<p>E-mailové adresy účastníků prosím vkládejte do pole pro <b>skrytou kopii (Bcc)</b>, ať si navzájem nevidí své e-maily.</p>
-
-{% else %}<i>Žádné e-mailové adresy k vypsání.</i>{% endif %}
diff --git a/mo/web/templates/parts/org_participants_table_actions.html b/mo/web/templates/parts/org_participants_table_actions.html
deleted file mode 100644
index 0eec0d02a253fa76728b4d2623192de5c44acd3e..0000000000000000000000000000000000000000
--- a/mo/web/templates/parts/org_participants_table_actions.html
+++ /dev/null
@@ -1,67 +0,0 @@
-{% if action_form %}
-<form action="" method="POST" class="form form-horizontal" role="form">
-{% endif %}
-
-	{{ table.to_html() }}
-
-	{% if contest %}
-	<a class="btn btn-primary" href="{{ url_for('org_contest_add_user', id=contest.contest_id, site_id=site.place_id if site else None) }}">Přidat účastníka</a>
-	{% endif %}
-	<a class="btn btn-default"
-	   title="Zobrazí emailové adresy ve snadno zkopírovatelném formátu"
-	   href="{{ url_for('org_contest_list_emails', id=id, site_id=site_id, **request.args) if contest else url_for('org_round_list_emails', id=id, **request.args) }}">
-		Vypsat e-mailové adresy
-	</a>
-
-{% if action_form %}
-	{{ action_form.csrf_token }}
-	<h3>Provést akci</h3>
-	<div class="form-frame">
-	<div class="form-group">
-		<label class="control-label col-sm-2">Provést akci na:</label>
-		<div class="col-sm-5 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 účastnících
-			</label>
-		</div><div class="col-sm-5 radio">
-			<label>
-				<input id="action_on-1" name="action_on" type="radio" value="checked" required{% if action_form.action_on.data == 'checked' %} checked{% endif %}>
-				pouze zaškrtnutých účastnících
-			</label>
-		</div>
-	</div>
-	<div class="form-group">
-		<label class="control-label col-sm-2" for="participation_state">Stav účasti</label>
-		<div class="col-sm-6">{{ wtf.form_field(action_form.participation_state, form_type='inline') }}</div>
-		<div class="col-sm-4">{{ wtf.form_field(action_form.set_participation_state, form_type='inline', class='btn btn-primary') }}</div>
-	</div>
-	<div class="form-group">
-		<label class="control-label col-sm-2" for="participation_place">Soutěžní místo</label>
-		<div class="col-sm-6">{{ wtf.form_field(action_form.participation_place, form_type='inline', placeholder='Kód místa') }}</div>
-		<div class="col-sm-4">{{ wtf.form_field(action_form.set_participation_place, form_type='inline', class='btn btn-primary') }}</div>
-	</div>
-	<div class="form-group">
-		<label class="control-label col-sm-2" for="contest_place">
-			{{ round.get_level().name|capitalize }}
-		</label>
-		<div class="col-sm-6">
-			{{ wtf.form_field(action_form.contest_place, form_type='inline', placeholder='Kód místa') }}
-			<p class="help-block">
-				{{ round.get_level().name_locative("V tomto", "V této", "V tomto") }} musí existovat soutěž pro {{ round.name|lower }} kategorie {{ round.category }}.
-			</p>
-		</div>
-		<div class="col-sm-4">{{ wtf.form_field(action_form.set_contest, form_type='inline', class='btn btn-primary', value='Přesunout do ' + round.get_level().name_genitive('jiného', 'jiné', 'jiného')) }}</div>
-	</div>
-	<div class="form-group">
-		<label class="control-label col-sm-2" for="contest_place">Smazání účasti</label>
-		<div class="col-sm-6"><p class="help-block">Dojde ke smazání účasti v tomto kole, ne účastníka z ročníku.</p></div>
-		<div class="col-sm-4">{{ wtf.form_field(action_form.remove_participation, form_type='inline', class='btn btn-danger') }}</div>
-	</div>
-	</div>
-</form>
-{% else %}
-<p>
-<i>Nemáte právo k editaci účastníků v {{ round.get_level().name_locative("v tomto", "v této", "v tomto") }}.</i>
-</p>
-{% endif %}
diff --git a/mo/web/templates/parts/org_solution_table.html b/mo/web/templates/parts/org_solution_table.html
index c8a85be235225cd49e2808b69129e8d1d39e77c4..90ac16ebdd9af009eccb4b4c0d70a784d6012f00 100644
--- a/mo/web/templates/parts/org_solution_table.html
+++ b/mo/web/templates/parts/org_solution_table.html
@@ -5,9 +5,9 @@ To se hodí, pokud se nechystáte do systému nahrávat soubory řešení, ale j
 bylo možné vyplnit body. Pokud nějaké řešení založíte omylem, lze toto prázdné řešení smazat v jeho detailu.
 {% else %}
 Historii všech odevzdání, oprav a bodů pro každé řešení naleznete v jeho detailu.
-{% if sc.allow_upload_feedback or sc.allow_edit_points %}Tamtéž můžete odevzdávat nové verze a změnit, které řešení/oprava je
-finální (ve výchozím stavu poslední nahrané).{% elif sc.allow_upload_solutions %}Tamtéž můžete odevzdat nové řešení.{% endif %}
-{% if for_task and sc.allow_create_solutions %} Hromadně založit řešení pro více řešitelů můžete pomocí tlačítek pod tabulkou.{% endif %}
+{% if rights.can_upload_feedback() or ctx.can_edit_points() %}Tamtéž můžete odevzdávat nové verze a změnit, které řešení/oprava je
+finální (ve výchozím stavu poslední nahrané).{% elif rights.can_upload_solutions() %}Tamtéž můžete odevzdat nové řešení.{% endif %}
+{% if for_task and rights.can_create_solutions() %} Hromadně založit řešení pro více řešitelů můžete pomocí tlačítek pod tabulkou.{% endif %}
 {% endif %}
 </i></p>
 
@@ -24,8 +24,8 @@ finální (ve výchozím stavu poslední nahrané).{% elif sc.allow_upload_solut
 			<th>Finální oprava
 			<th>Poznámky
 			<th>Přidělené body
-				{% if not for_user and not site and sc.allow_edit_points and not points_form %}
-				<a title="Editovat body" href="{{ url_for('org_contest_task_points', contest_id=contest.contest_id, task_id=task.task_id) }}" class="icon pull-right">✎</a>
+				{% if not for_user and not site and rights.can_edit_points() and not points_form %}
+				<a title="Editovat body" href="{{ ctx.url_for('org_contest_task_points') }}" class="icon pull-right">✎</a>
 				{% endif %}
 			<th>Akce
 		</tr>
@@ -40,7 +40,7 @@ finální (ve výchozím stavu poslední nahrané).{% elif sc.allow_upload_solut
 	<tr>
 	{% endif %}
 		<td>{% if for_user %}
-			<a href='{{ url_for('org_contest_task', contest_id=ct_id, task_id=task.task_id) }}'>{{ task.code }} {{ task.name }}</a>
+			<a href='{{ ctx.url_for('org_contest_task', task_id=task.task_id) }}'>{{ task.code }} {{ task.name }}</a>
 		    {% else %}
 			{{ u|pion_link(contest.contest_id) }}{{ u|user_flags }}</a>
 		    {% endif %}
@@ -105,7 +105,7 @@ finální (ve výchozím stavu poslední nahrané).{% elif sc.allow_upload_solut
 			{% else %}–{% endif %}
 		{% endif %}
 		<td><div class="btn-group">
-			<a class="btn btn-xs btn-primary" href="{{ url_for('org_submit_list', contest_id=ct_id, user_id=u.user_id, task_id=task.task_id, site_id=site_id) }}">Detail</a>
+				<a class="btn btn-xs btn-primary" href="{{ ctx.url_for('org_submit_list', user_id=u.user_id, task_id=task.task_id, site_id=ctx.site_id) }}">Detail</a>
 		</div>
 	</tr>
 {% endfor %}
diff --git a/mo/web/templates/parts/org_submit_warning.html b/mo/web/templates/parts/org_submit_warning.html
index a405c308c06102e832a7fd710f446db35f7fc772..4095ff3138946450dea1d9587a21bfc06122b659 100644
--- a/mo/web/templates/parts/org_submit_warning.html
+++ b/mo/web/templates/parts/org_submit_warning.html
@@ -1,10 +1,10 @@
-{% if not sc.allow_upload_solutions and sc.round.state == RoundState.running %}
+{% if not ctx.rights.can_upload_solutions() and ctx.round.state == RoundState.running %}
 <p class='alert alert-warning'>
 Soutěž stále běží. Odevzdané úlohy se ještě mohou měnit.
 </p>
 {% endif %}
 
-{% if not sc.allow_upload_feedback and sc.round.state == RoundState.grading %}
+{% if not ctx.rights.can_upload_feedback() and ctx.round.state == RoundState.grading %}
 <p class='alert alert-warning'>
 Opravování stále běží. Opravené úlohy a body se ještě mohou měnit.
 </p>
diff --git a/mo/web/user.py b/mo/web/user.py
index ee53d404bccf1edcc9f0fd24b9aeb363a922ee66..0ff79c8f55967a055ff8a378b009d34902861534 100644
--- a/mo/web/user.py
+++ b/mo/web/user.py
@@ -8,7 +8,6 @@ import werkzeug.exceptions
 import wtforms
 from wtforms.validators import Required
 
-import mo
 import mo.config as config
 import mo.email
 import mo.db as db
@@ -41,7 +40,7 @@ def load_pcrs() -> List[Tuple[db.Participation, db.Contest, db.Round]]:
             .join(db.Contest, db.Contest.master_contest_id == db.Participation.contest_id)
             .join(db.Round)
             .filter(db.Participation.user == g.user)
-            .filter(db.Round.year == mo.current_year)
+            .filter(db.Round.year == config.CURRENT_YEAR)
             .options(joinedload(db.Contest.place))
             .order_by(db.Round.category, db.Round.seq, db.Round.part)
             .all())
@@ -52,7 +51,7 @@ def user_join():
     available_rounds: List[db.Round] = (
         db.get_session().query(db.Round)
         .select_from(db.Round)
-        .filter_by(year=mo.current_year)
+        .filter_by(year=config.CURRENT_YEAR)
         .filter(db.Round.enroll_mode.in_([db.RoundEnrollMode.register, db.RoundEnrollMode.confirm]))
         .filter_by(state=db.RoundState.running)
         .order_by(db.Round.category, db.Round.seq)
@@ -87,7 +86,7 @@ def user_join_round(round_id):
     if not round:
         raise werkzeug.exceptions.NotFound()
 
-    if (round.year != mo.current_year
+    if (round.year != config.CURRENT_YEAR
             or round.part > 1
             or round.enroll_mode not in [db.RoundEnrollMode.register, db.RoundEnrollMode.confirm]
             or round.state != db.RoundState.running):
@@ -155,7 +154,7 @@ def user_join_round(round_id):
 def join_create_pant(form: JoinRoundForm) -> db.Participant:
     assert form.school.place is not None
     pant = db.Participant(user=g.user,
-                          year=mo.current_year,
+                          year=config.CURRENT_YEAR,
                           school_place=form.school.place,
                           grade=form.grade.data,
                           birth_year=form.birth_year.data)
@@ -175,7 +174,7 @@ def join_create_contest(round: db.Round, pant: db.Participant) -> db.Contest:
 
     place = pant.school_place
     if place.level != round.level:
-        parents = db.get_place_parents(pant.school_place)
+        parents = db.get_place_ancestors(pant.school_place)
         places = [p for p in parents if p.level == round.level]
         assert len(places) == 1
         place = places[0]
diff --git a/mo/web/util.py b/mo/web/util.py
index d6c0eff2142b3b8ce73409d07690c586d60bdd70..1be8eb8027ee958df4216f8bde64904d9e1f2938 100644
--- a/mo/web/util.py
+++ b/mo/web/util.py
@@ -88,7 +88,7 @@ def org_paper_link(contest_or_id: Union[db.Contest, int],
         contest_or_id = contest_or_id.contest_id
 
     return url_for('org_submit_paper' if not orig else 'org_submit_paper_orig',
-                   contest_id=contest_or_id,
+                   ct_id=contest_or_id,
                    paper_id=paper.paper_id,
                    site_id=site.place_id if site else None,
                    filename=_task_paper_filename(user, paper))