diff --git a/.gitignore b/.gitignore
index 0fc3f2c5f475e40bdca9e34d25a379b5a9cda986..6408f0dc1d6a9d7bb92a20f8ffa3294b03cf3f54 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,7 +2,9 @@ __pycache__
 .mypy_cache
 .*.swp
 /data
+/data-test
 /extra
 /mo/config.py
 /osmo.egg-info
+/test-chrome
 /venv
diff --git a/mo/contests.py b/mo/contests.py
new file mode 100644
index 0000000000000000000000000000000000000000..dad4ba3ae0def881d2c45bf93a3cacfc599482de
--- /dev/null
+++ b/mo/contests.py
@@ -0,0 +1,66 @@
+# Pomocné funkce pro práci se soutěžemi
+
+from typing import Optional
+
+import mo.db as db
+from mo.rights import Right, Gatekeeper
+import mo.util
+
+
+class ContestError(RuntimeError):
+    pass
+
+
+def add_contest(master_round: db.Round, place: db.Place, reason: str, gatekeeper: Optional[Gatekeeper] = None):
+    # Počáteční stav soutěže
+    if master_round.state != db.RoundState.delegate:
+        state = master_round.state
+    else:
+        state = db.RoundState.running
+
+    # Soutěž vytvoříme vždy v hlavním kole
+    contest = db.Contest(round=master_round, place=place, state=state)
+    if gatekeeper is not None:
+        rr = gatekeeper.rights_for_contest(contest)
+        if not rr.have_right(Right.add_contest):
+            raise ContestError(f'Vaše role nedovoluje vytvořit soutěž {place.name_locative()}')
+
+    sess = db.get_session()
+    sess.add(contest)
+    sess.flush()
+    contest.master_contest_id = contest.contest_id
+    sess.add(contest)
+    sess.flush()
+
+    mo.util.log(
+        type=db.LogType.contest,
+        what=contest.contest_id,
+        details={'action': 'add', 'contest': db.row2dict(contest), 'reason': reason},
+    )
+    mo.util.logger.info(f"{reason.title()}: Soutěž #{contest.contest_id} založena: {db.row2dict(contest)}")
+
+    create_subcontests(master_round, contest, reason)
+
+
+def create_subcontests(master_round: db.Round, master_contest: db.Contest, reason: str):
+    if master_round.part == 0:
+        return
+
+    sess = db.get_session()
+    subrounds = master_round.get_group_rounds()
+    for subround in subrounds:
+        subcontest = db.Contest(
+            round_id=subround.round_id,
+            master_contest_id=master_contest.contest_id,
+            place_id=master_contest.place_id,
+            state=master_contest.state,
+        )
+        sess.add(subcontest)
+        sess.flush()
+
+        mo.util.log(
+            type=db.LogType.contest,
+            what=subcontest.contest_id,
+            details={'action': 'add', 'contest': db.row2dict(subcontest), 'reason': reason},
+        )
+        mo.util.logger.info(f"{reason.title()}: Podsoutěž #{subcontest.contest_id} založena: {db.row2dict(subcontest)}")
diff --git a/mo/imports.py b/mo/imports.py
index 5833b3a4039a0bd5c94117f267f3322d0137bbb7..d6f738b67137f2b1a36b4c6b078ee2d0697f0d29 100644
--- a/mo/imports.py
+++ b/mo/imports.py
@@ -7,6 +7,7 @@ from sqlalchemy import and_
 from sqlalchemy.orm import joinedload, Query
 from typing import List, Optional, Any, Dict, Type, Union, Tuple
 
+import mo.contests
 import mo.csv
 from mo.csv import FileFormat, MissingHeaderError
 import mo.db as db
@@ -27,6 +28,7 @@ class Import:
     cnt_new_participants: int = 0
     cnt_new_participations: int = 0
     cnt_new_roles: int = 0
+    cnt_new_contests: int = 0
     cnt_set_points: int = 0
     cnt_add_sols: int = 0
     cnt_del_sols: int = 0
@@ -49,6 +51,7 @@ class Import:
     log_msg_prefix: str
     log_details: Any
     allow_change_user_to_org: bool = False  # pro Import orgů: je povoleno vyrobit orga z účastníka
+    allow_create_contests: bool = False     # pro Import účastníků: je povoleno zakládat soutěže
 
     # Interní: Stav importu
     place_cache: Dict[str, db.Place]
@@ -93,7 +96,7 @@ class Import:
         try:
             # XXX: Zde si nemůžeme dovolit kontrolovat existenci domén,
             # protože import by byl příliš pomalý. Možná z něj jednou uděláme job...
-            return mo.users.normalize_email(email)
+            return mo.users.normalize_email(email, make_unique_nomail=True)
         except mo.CheckError as e:
             return self.error(str(e))
 
@@ -263,7 +266,16 @@ class Import:
                 return None
             contest = db.get_session().query(db.Contest).filter_by(round=round, place=oblast).one_or_none()
             if contest is None:
-                return self.error('V uvedené oblasti toto kolo neprobíhá')
+                if self.allow_create_contests:
+                    try:
+                        mo.contests.add_contest(round.master, oblast, reason='import', gatekeeper=self.gatekeeper)
+                        self.cnt_new_contests += 1
+                    except mo.contests.ContestError as e:
+                        return self.error(str(e))
+                    # Je jednodušší contest znovu najít, než ho vyhrabat ze zakládání soutěží ve skupině kol
+                    contest = db.get_session().query(db.Contest).filter_by(round=round, place=oblast).one()
+                else:
+                    return self.error('V uvedené oblasti toto kolo neprobíhá')
 
         return contest
 
@@ -468,7 +480,8 @@ class ContestImport(Import):
          round: db.Round,
          contest: Optional[db.Contest] = None,
          only_region: Optional[db.Place] = None,
-         default_place: Optional[db.Place] = None
+         default_place: Optional[db.Place] = None,
+         allow_create_contests: bool = False
     ):
         super().__init__(user)
         self.user = user
@@ -476,6 +489,7 @@ class ContestImport(Import):
         self.contest = contest
         self.only_region = only_region
         self.default_place = default_place
+        self.allow_create_contests = allow_create_contests
         assert not self.round.is_subround()
 
     def import_row(self, r: mo.csv.Row) -> None:
@@ -515,7 +529,7 @@ class ContestImport(Import):
         self.find_or_create_participation(user, contest, misto)
 
     def get_after_import_message(self) -> str:
-        return f'Importováno ({self.cnt_rows} řádků, založeno {self.cnt_new_users} uživatelů, {self.cnt_new_participations} účastí, {self.cnt_new_roles} rolí)'
+        return f'Importováno ({self.cnt_rows} řádků, založeno {self.cnt_new_users} uživatelů, {self.cnt_new_participations} účastí, {self.cnt_new_contests} soutěží)'
 
 
 @dataclass
diff --git a/mo/users.py b/mo/users.py
index 7c9f887e7846951776983e512c0c41bc09b1866c..bbfedf7f79469253d0576da0c69b93f68e1b4efe 100644
--- a/mo/users.py
+++ b/mo/users.py
@@ -1,5 +1,6 @@
 # Správa uživatelů
 
+import base64
 import bcrypt
 import datetime
 import dateutil.tz
@@ -292,10 +293,16 @@ def email_check_domain(domain: str):
     raise mo.CheckError(f'Doména {domain} nepřijímá poštu')
 
 
-def normalize_email(addr: str, check_existence: bool = False) -> str:
-    if not re.fullmatch(r'.+@.+', addr):
+def normalize_email(addr: str, check_existence: bool = False, make_unique_nomail: bool = False) -> str:
+    if make_unique_nomail and addr.endswith('@nomail'):
+        addr = base64.b32encode(secrets.token_bytes(10)).decode('US-ASCII').lower() + '@nomail'
+
+    if '@' not in addr:
         raise mo.CheckError('V e-mailové adrese chybí zavináč')
 
+    if not re.fullmatch(r'.*@.+', addr):
+        raise mo.CheckError('E-mailová adresa nesmí ani začínat, ani končit zavináčem')
+
     if re.search(r'[ \t]', addr):
         raise mo.CheckError('E-mailová adresa obsahuje mezeru')
 
diff --git a/mo/web/fields.py b/mo/web/fields.py
index 4c6ce5f57e226441d64a60a2fdca4c6f8437ef10..2f939534aed136830cb28b28e2287a30eef1fb4e 100644
--- a/mo/web/fields.py
+++ b/mo/web/fields.py
@@ -73,17 +73,19 @@ class Points(Decimal):
         super().__init__(label, validators, **kwargs)
 
 
-class Email(Stripped, EmailField):
+class Email(String):
     check_existence: bool
+    make_unique_nomail: bool
 
-    def __init__(self, label="E-mail", validators=None, check_existence: bool = False, **kwargs):
+    def __init__(self, label="E-mail", validators=None, check_existence: bool = False, make_unique_nomail: bool = False, **kwargs):
         self.check_existence = check_existence
+        self.make_unique_nomail = make_unique_nomail
         super().__init__(label, validators, **kwargs)
 
     def pre_validate(field, form):
         if field.data:
             try:
-                field.data = mo.users.normalize_email(field.data, check_existence=field.check_existence)
+                field.data = mo.users.normalize_email(field.data, check_existence=field.check_existence, make_unique_nomail=field.make_unique_nomail)
             except mo.CheckError as e:
                 raise wtforms.ValidationError(str(e))
 
diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py
index fea5b6e71c70f4b4fa7d399f3b63af07689d685b..64d109fbe3605c32ca1d89258097dc794c99ece3 100644
--- a/mo/web/org_contest.py
+++ b/mo/web/org_contest.py
@@ -486,7 +486,7 @@ def org_contest(ct_id: int, site_id: Optional[int] = None):
 
 
 class ContestantImportForm(ImportForm):
-    pass
+    allow_create_contests = wtforms.BooleanField("Povolit zakládání nových soutěží")
 
 
 @app.route('/org/contest/c/<int:ct_id>/import-contestant', methods=('GET', 'POST'))
@@ -499,6 +499,9 @@ def org_import_user(round_id: Optional[int] = None, hier_id: Optional[int] = Non
     default_place = contest.place if contest else ctx.hier_place
 
     form = ContestantImportForm()
+    if ctx.contest is not None:
+        del form.allow_create_contests
+
     imp = None
     if form.validate_on_submit():
         imp = ContestImport(
@@ -507,6 +510,7 @@ def org_import_user(round_id: Optional[int] = None, hier_id: Optional[int] = Non
             contest=contest,
             only_region=ctx.hier_place,
             default_place=default_place,
+            allow_create_contests=form.allow_create_contests.data if form.allow_create_contests is not None else False
         )
     return generic_import_page(
         form, imp, ctx.url_home(),
@@ -1809,7 +1813,7 @@ def check_contest_state(round: db.Round, contest: Optional[db.Contest], state: d
 
 
 class ParticipantAddForm(FlaskForm):
-    email = mo_fields.Email(validators=[validators.DataRequired()], check_existence=True)
+    email = mo_fields.Email(validators=[validators.DataRequired()], check_existence=True, make_unique_nomail=True)
     first_name = mo_fields.FirstName(validators=[validators.Optional()])
     last_name = mo_fields.LastName(validators=[validators.Optional()])
     school = mo_fields.School(validators=[validators.Optional()])
@@ -1819,7 +1823,8 @@ class ParticipantAddForm(FlaskForm):
     save = wtforms.SubmitField("Přidat")
 
     def set_descriptions(self, contest: db.Contest, place_desc: bool):
-        self.email.description = "Nepoužívejte prosím e-mailové adresy ve školních doménách, na které nejde posílat pošta z veřejné sítě."
+        self.email.description = ("Nepoužívejte prosím e-mailové adresy ve školních doménách, na které nejde posílat pošta z veřejné sítě. "
+                                  + "Pokud zatím neznáte e-mail, zadejte @nomail, ale pak adresu doplňte.")
         self.school.description = f'Kód školy najdete v <a href="{url_for("org_place", id=contest.place.place_id)}">katalogu míst</a>.'
         if place_desc:
             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í.'
diff --git a/mo/web/org_round.py b/mo/web/org_round.py
index 89fb407c5894dc1bbf3e2bc8c585e6545f9d53fe..3c75a7522beb7fef940d5a9f0325321ca3d579da 100644
--- a/mo/web/org_round.py
+++ b/mo/web/org_round.py
@@ -17,6 +17,7 @@ from wtforms import validators
 from wtforms.widgets import NumberInput
 
 import mo.config as config
+import mo.contests
 import mo.db as db
 import mo.imports
 import mo.jobs.notify
@@ -103,7 +104,10 @@ def add_contest(round: db.Round, form: AddContestForm) -> bool:
         flash(f'Pro {place.type_name()} {place.name} už toto kolo existuje', 'danger')
         return False
 
-    if not do_add_contest(round.master, place, True):
+    try:
+        mo.contests.add_contest(round.master, place, reason='web', gatekeeper=g.gatekeeper)
+    except mo.contests.ContestError as e:
+        flash(str(e), 'danger')
         return False
 
     sess.commit()
@@ -111,64 +115,6 @@ def add_contest(round: db.Round, form: AddContestForm) -> bool:
     return True
 
 
-def do_add_contest(master_round: db.Round, place: db.Place, check_rights: bool) -> bool:
-    # Počáteční stav soutěže
-    if master_round.state != db.RoundState.delegate:
-        state = master_round.state
-    else:
-        state = db.RoundState.preparing
-
-    # Soutěž vytvoříme vždy v hlavním kole
-    contest = db.Contest(round=master_round, place=place, state=state)
-    if check_rights:
-        rr = g.gatekeeper.rights_for_contest(contest)
-        if not rr.have_right(Right.add_contest):
-            flash(f'Vaše role nedovoluje vytvořit soutěž {place.name_locative()}', 'danger')
-            return False
-
-    sess = db.get_session()
-    sess.add(contest)
-    sess.flush()
-    contest.master_contest_id = contest.contest_id
-    sess.add(contest)
-    sess.flush()
-
-    mo.util.log(
-        type=db.LogType.contest,
-        what=contest.contest_id,
-        details={'action': 'add', 'contest': db.row2dict(contest)},
-    )
-    app.logger.info(f"Soutěž #{contest.contest_id} založena: {db.row2dict(contest)}")
-
-    create_subcontests(master_round, contest)
-    return True
-
-
-# XXX: Používá se i v registraci účastníků
-def create_subcontests(master_round: db.Round, master_contest: db.Contest):
-    if master_round.part == 0:
-        return
-
-    sess = db.get_session()
-    subrounds = master_round.get_group_rounds()
-    for subround in subrounds:
-        subcontest = db.Contest(
-            round_id=subround.round_id,
-            master_contest_id=master_contest.contest_id,
-            place_id=master_contest.place_id,
-            state=master_contest.state,
-        )
-        sess.add(subcontest)
-        sess.flush()
-
-        mo.util.log(
-            type=db.LogType.contest,
-            what=subcontest.contest_id,
-            details={'action': 'add', 'contest': db.row2dict(subcontest)},
-        )
-        app.logger.info(f"Podsoutěž #{subcontest.contest_id} založena: {db.row2dict(subcontest)}")
-
-
 @dataclass
 class ContestStat:
     region: db.Place
@@ -696,8 +642,7 @@ def org_round_create_contests(round_id: int):
     form = CreateContestsForm()
     if form.validate_on_submit():
         for place in new_places:
-            ok = do_add_contest(round, place, False)
-            assert ok
+            mo.contests.add_contest(round, place, reason='web')
         sess.commit()
         flash(inflect_with_number(len(new_places), 'Založena %s soutěž.', 'Založeny %s soutěže.', 'Založeno %s soutěží.'), 'success')
         return redirect(ctx.url_for('org_round'))
diff --git a/mo/web/org_users.py b/mo/web/org_users.py
index 4fabde7c9a03aa0a4b4fb66a04ce740ef08bb7ad..d09b600ecd4e14a307d26e0a57c033f58a49d344 100644
--- a/mo/web/org_users.py
+++ b/mo/web/org_users.py
@@ -412,7 +412,8 @@ def org_user(id: int):
 class UserEditForm(FlaskForm):
     first_name = mo_fields.FirstName(validators=[DataRequired()], render_kw={'autofocus': True})
     last_name = mo_fields.LastName(validators=[DataRequired()])
-    email = mo_fields.Email(validators=[DataRequired()], check_existence=True)
+    email = mo_fields.Email(validators=[DataRequired()], check_existence=True, make_unique_nomail=True,
+                            description="Pokud zadáte @nomail, vznikne účet bez e-mailové adresy, ke kterému se ale nepůjde přihlásit.")
     note = wtforms.TextAreaField("Poznámka")
     is_test = wtforms.BooleanField("Testovací účet")
     email_notify = wtforms.BooleanField("Mailové notifikace")
diff --git a/mo/web/templates/doc_import.html b/mo/web/templates/doc_import.html
index db1b61978c1f844ef5e0ce01c8bb2e3a1f40b646..6caa1f40f5d67f3fda807e684b09314c614652c1 100644
--- a/mo/web/templates/doc_import.html
+++ b/mo/web/templates/doc_import.html
@@ -34,7 +34,7 @@ když přidáte vlastní sloupce s novými názvy, budou se ignorovat.
 		nebo speciálního soutěžního místa, kde se soutěž koná. Dozor na soutěžním místě
 		má pak právo odevzdávat za účastníka řešení.
 	<tr><td>kod_oblasti<td>Pokud neimportujete do konkrétní soutěže, ale do celého kola,
-		je nutné uvést kód oblasti, ve které účastník soutěží.
+		je nutné uvést kód oblasti (školy, okresu, kraje apod.), kde které účastník soutěží.
 </table>
 
 <p>Importovaní účastníci se přidají ke stávajícím. Duplicity se ignorují. V případě
diff --git a/mo/web/templates/doc_org.html b/mo/web/templates/doc_org.html
index eaf241c3a0c10e48b86140e2e773d3c3c2106373..a6f70c79417ef903500284e12f96909661bcac56 100644
--- a/mo/web/templates/doc_org.html
+++ b/mo/web/templates/doc_org.html
@@ -161,7 +161,7 @@ Přesněji řečeno každé kolo může mít nastaven jeden ze tří režimů re
 <p>Do vyšších kol obvykle účastníky nepřihlašujeme přímo, ale používáme
 tlačítko „Postup z minulého kola“ na stránce soutěže.
 
-<h3>Import účastníku</h3>
+<h3>Import účastníků</h3>
 
 <p>Garanti mohou přihlašovat účastníky do soutěže <b>importem</b> souboru ve formátu CSV.
 Tento soubor můžete vyrobit v Excelu či jiném tabulkovém kalkulátoru a pak do CSV exportovat.
@@ -260,6 +260,7 @@ to učinit pro sdružené výsledkové listiny v hierarchii soutěžního kola).
 
 <p>Po uložení verze výsledkové listiny ji může garant soutěže <b>zveřejnit</b>.
 Tím se výsledková listina zobrazí soutěžícím v jejich rozhraní, pokud je soutěž
-ve stavu <i>ukončeno</i>.
+ve stavu <i>ukončeno</i>. V okresních a vyšších kolech kategorií Z a A-C se
+listina také zveřejní na webových stránkách MO.
 
 {% endblock %}
diff --git a/mo/web/templates/org_contestants_import.html b/mo/web/templates/org_contestants_import.html
index 82d27d7e2ca53cd63d8e6955d6f3194cd89863e4..12da9a298e1bcc23d41511e8a8828353f8066980 100644
--- a/mo/web/templates/org_contestants_import.html
+++ b/mo/web/templates/org_contestants_import.html
@@ -21,4 +21,7 @@ kód oblasti. Nechcete raději importovat do konkrétní oblasti?</em></p>
 
 
 {% block import_form %}
+	{% if form.allow_create_contests %}
+		{{ wtf.form_field(form.allow_create_contests) }}
+	{% endif %}
 {% endblock %}
diff --git a/mo/web/templates/org_org.html b/mo/web/templates/org_org.html
index 00fc4fb2f55f513a9b92997371a7f3108c66d1d2..48c8d3e5165ec7f1247b4864ff30712c7180232c 100644
--- a/mo/web/templates/org_org.html
+++ b/mo/web/templates/org_org.html
@@ -48,6 +48,8 @@
 <p>Můžete přidělit jen roli, která je podmnožinou nějaké vaší role (včetně omezení na oblast, kolo, &hellip;).</p>
 <p>Pokud roli omezíte na kategorii <code>Z</code>, bude fungovat pro všechny kategorie začínající na <code>Z</code>.
 Podobně <code>S</code> znamená všechny středoškolské kategorie <code>A</code>, <code>B</code>, <code>C</code>.
+<p>Typický krajský garant má roli omezenou jen na kraj, typický okresní na okres a kategorii Z.
+Opravovatelé a dozor mívají navíc omezení na ročník/kolo.
 {% if role_errors %}
 <div class="alert alert-danger" role="alert">
 	{{ role_errors|join(Markup("<br>")) }}
diff --git a/mo/web/user.py b/mo/web/user.py
index 677a09e735a2a936a6a3f5b62b8aa91da294e600..c5d0c48c45edbda0f5468a417548f4f5a38859fe 100644
--- a/mo/web/user.py
+++ b/mo/web/user.py
@@ -12,6 +12,7 @@ import werkzeug.exceptions
 import wtforms
 from wtforms.validators import DataRequired
 
+import mo.contests
 import mo.config as config
 import mo.email
 import mo.db as db
@@ -208,7 +209,7 @@ def join_create_contest(round: db.Round, pant: db.Participant) -> db.Contest:
             details={'action': 'created', 'reason': 'user-join'},
         )
 
-        mo.web.org_round.create_subcontests(round, c)
+        mo.contests.create_subcontests(round, c, reason='user-join')
 
     return c