diff --git a/mo/db.py b/mo/db.py
index bb45a9d863246107868e7c43d61741717dc66936..c1de5014635fe58d8c326daed91d22dcad52c7cc 100644
--- a/mo/db.py
+++ b/mo/db.py
@@ -132,6 +132,13 @@ def get_root_place():
     return get_session().query(Place).filter_by(parent=None).one()
 
 
+def get_place_by_id(place_id: int, fetch_school: bool = False) -> Place:
+    q = get_session().query(Place)
+    if fetch_school:
+        q = q.options(joinedload(Place.school))
+    return q.filter_by(place_id=place_id).one()
+
+
 def get_place_by_code(code: str, fetch_school: bool = False) -> Optional[Place]:
     code = code.strip()
     if code == "":
diff --git a/mo/imports.py b/mo/imports.py
index 51aa689b4e9a77adbbfc85124a688f0e0e552507..dfd5e5213f8729333d7cb8d3e40db38f56972866 100644
--- a/mo/imports.py
+++ b/mo/imports.py
@@ -16,6 +16,8 @@ import mo.util
 from mo.util import logger
 from mo.util_format import format_decimal
 
+reason = "import"
+
 
 class ImportType(db.MOEnum):
     participants = auto()
@@ -149,21 +151,16 @@ class Import:
         return place
 
     def parse_school(self, kod: str) -> Optional[db.Place]:
-        if kod == "":
-            return self.error('Škola je povinná')
-
         if kod in self.school_place_cache:
             return self.school_place_cache[kod]
 
-        place = db.get_place_by_code(kod, fetch_school=True)
-        if not place:
-            return self.error(f'Škola s kódem "{kod}" nenalezena'+
-                    ('. Nechybí vám # na začátku?' if re.fullmatch(r'\d+', kod) else ''))
-
-        if place.type != db.PlaceType.school:
-            return self.error(f'Kód školy "{kod}" neodpovídá škole')
+        try:
+            place = mo.users.validate_and_find_school(kod)
+        except mo.CheckError as e:
+            return self.error(str(e))
 
         self.school_place_cache[kod] = place
+
         return place
 
     def parse_grade(self, rocnik: str, school: Optional[db.School]) -> Optional[str]:
@@ -174,48 +171,30 @@ class Import:
         # lidé připisují všechny možné i nemožné znaky, které vypadají jako apostrof :)
         rocnik = re.sub('[\'"\u00b4\u2019]', "", rocnik)
 
-        if (not re.fullmatch(r'\d(/\d)?', rocnik)):
-            return self.error(f'Ročník má neplatný formát, musí to být buď číslice, nebo číslice/číslice')
-
-        if (not school.is_zs and re.fullmatch(r'\d', rocnik)):
-            return self.error(f'Ročník pro střední školu ({school.place.name}) zapisujte ve formátu číslice/číslice')
-
-        if (not school.is_ss and re.fullmatch(r'\d/\d', rocnik)):
-            return self.error(f'Ročník pro základní školu ({school.place.name}) zapisujte jako číslici 1–9')
-
-        return rocnik
+        try:
+            return mo.users.normalize_grade(rocnik, school)
+        except mo.CheckError as e:
+            return self.error(str(e))
 
     def parse_born(self, rok: str) -> Optional[int]:
         if not re.fullmatch(r'\d{4}', rok):
             return self.error('Rok narození musí být čtyřciferné číslo')
 
         r = int(rok)
-        if r < 2000 or r > 2099:
-            return self.error('Rok narození musí být v intervalu [2000,2099]')
+
+        try:
+            mo.users.validate_born_year(r)
+        except mo.CheckError as e:
+            return self.error(str(e))
 
         return r
 
     def find_or_create_user(self, email: str, krestni: str, prijmeni: str, is_org: bool) -> Optional[db.User]:
-        sess = db.get_session()
-        user = sess.query(db.User).filter_by(email=email).one_or_none()
-        if user:
-            if user.first_name != krestni or user.last_name != prijmeni:
-                return self.error(f'Osoba již registrována s odlišným jménem {user.full_name()}')
-            if (user.is_admin or user.is_org) != is_org:
-                if is_org:
-                    return self.error('Nelze předefinovat účastníka na organizátora')
-                else:
-                    return self.error('Nelze předefinovat organizátora na účastníka')
-        else:
-            user = db.User(email=email, first_name=krestni, last_name=prijmeni, is_org=is_org)
-            sess.add(user)
-            sess.flush()    # Aby uživatel dostal user_id
-            logger.info(f'Import: Založen uživatel user=#{user.user_id} email=<{user.email}>')
-            mo.util.log(
-                type=db.LogType.user,
-                what=user.user_id,
-                details={'action': 'import', 'new': db.row2dict(user)},
-            )
+        try:
+            user, is_new = mo.users.find_or_create_user(email, krestni, prijmeni, is_org, reason=reason)
+        except mo.CheckError as e:
+            return self.error(str(e))
+        if is_new:
             self.cnt_new_users += 1
             self.new_user_ids.append(user.user_id)
         return user
@@ -235,53 +214,21 @@ class Import:
         return pts
 
     def find_or_create_participant(self, user: db.User, year: int, school_id: int, birth_year: int, grade: str) -> Optional[db.Participant]:
-        sess = db.get_session()
-        part = sess.query(db.Participant).get((user.user_id, year))
-        if part:
-            if (part.school != school_id
-                    or part.grade != grade
-                    or part.birth_year != birth_year):
-                return self.error('Účastník již zaregistrován s odlišnou školou/ročníkem/rokem narození')
-        else:
-            part = db.Participant(user=user, year=year, school=school_id, birth_year=birth_year, grade=grade)
-            sess.add(part)
-            logger.info(f'Import: Založen účastník #{user.user_id}')
-            mo.util.log(
-                type=db.LogType.participant,
-                what=user.user_id,
-                details={'action': 'import', 'new': db.row2dict(part)},
-            )
+        try:
+            part, is_new = mo.users.find_or_create_participant(user, year, school_id, birth_year, grade, reason=reason)
+        except mo.CheckError as e:
+            return self.error(str(e))
+        if is_new:
             self.cnt_new_participants += 1
-
         return part
 
     def find_or_create_participation(self, user: db.User, contest: db.Contest, place: Optional[db.Place]) -> Optional[db.Participation]:
-        if place is None:
-            place = contest.place
-
-        sess = db.get_session()
-        pions = (sess.query(db.Participation)
-                 .filter_by(user=user)
-                 .filter(db.Participation.contest.has(db.Contest.round == contest.round))
-                 .all())
-
-        if not pions:
-            pion = db.Participation(user=user, contest=contest, place_id=place.place_id, state=db.PartState.invited)
-            sess.add(pion)
-            logger.info(f'Import: Založena účast user=#{user.user_id} contest=#{contest.contest_id} place=#{place.place_id}')
-            mo.util.log(
-                type=db.LogType.participant,
-                what=user.user_id,
-                details={'action': 'add-to-contest', 'new': db.row2dict(pion)},
-            )
+        try:
+            pion, is_new = mo.users.find_or_create_participation(user, contest, place, reason=reason)
+        except mo.CheckError as e:
+            return self.error(str(e))
+        if is_new:
             self.cnt_new_participations += 1
-        elif len(pions) == 1:
-            pion = pions[0]
-            if pion.place != place:
-                return self.error(f'Již se tohoto kola účastní v {contest.round.get_level().name_locative("jiném", "jiné", "jiném")} ({pion.place.get_code()})')
-        else:
-            return self.error('Již se tohoto kola účastní ve vice oblastech, což by nemělo být možné')
-
         return pion
 
     def obtain_contest(self, oblast: Optional[db.Place], allow_none: bool = False):
diff --git a/mo/users.py b/mo/users.py
index 503276570d83c2962f82a7b67b8b28be1192d716..bb9c43b4b19158f29ab1761437cd03dc8389c7e7 100644
--- a/mo/users.py
+++ b/mo/users.py
@@ -5,14 +5,126 @@ import datetime
 import email.errors
 import email.headerregistry
 import re
-from typing import Optional
+from typing import Optional, Tuple
 
 import mo
 import mo.db as db
 import mo.util
+from mo.util import logger
 import mo.tokens
 
 
+def normalize_grade(rocnik: str, school: db.School) -> str:
+    """ Aktuálně provádí jen kontrolu formátu. """
+
+    if not re.fullmatch(r'\d(/\d)?', rocnik):
+        raise mo.CheckError('Ročník má neplatný formát, musí to být buď číslice, nebo číslice/číslice')
+
+    if not school.is_zs and re.fullmatch(r'\d', rocnik):
+        raise mo.CheckError(f'Ročník pro střední školu ({school.place.name}) zapisujte ve formátu číslice/číslice')
+
+    if not school.is_ss and re.fullmatch(r'\d/\d', rocnik):
+        raise mo.CheckError(f'Ročník pro základní školu ({school.place.name}) zapisujte jako číslici 1–9')
+
+    return rocnik
+
+
+def validate_born_year(r: int) -> None:
+    if r < 2000 or r > 2099:
+        raise mo.CheckError('Rok narození musí být v intervalu [2000,2099]')
+
+
+def validate_and_find_school(kod: str) -> db.Place:
+    if kod == "":
+        raise mo.CheckError('Škola je povinná')
+
+    place = db.get_place_by_code(kod, fetch_school=True)
+    if not place:
+        raise mo.CheckError(f'Škola s kódem "{kod}" nenalezena' +
+                            ('. Nechybí vám # na začátku?' if re.fullmatch(r'\d+', kod) else ''))
+
+    if place.type != db.PlaceType.school:
+        raise mo.CheckError(f'Kód školy "{kod}" neodpovídá škole')
+
+    return place
+
+
+def find_or_create_user(email: str, krestni: str, prijmeni: str, is_org: bool, reason: str = "undef-reason") -> Tuple[db.User, bool]:
+    sess = db.get_session()
+    user = sess.query(db.User).filter_by(email=email).one_or_none()
+    is_new = user is None
+    if user is None:  # HACK: Podmínku je nutné zapsat znovu místo užití is_new, jinak si s tím mypy neporadí
+        user = db.User(email=email, first_name=krestni, last_name=prijmeni, is_org=is_org)
+        sess.add(user)
+        sess.flush()    # Aby uživatel dostal user_id
+        logger.info(f'{reason.title()}: Založen uživatel user=#{user.user_id} email=<{user.email}>')
+        mo.util.log(
+            type=db.LogType.user,
+            what=user.user_id,
+            details={'action': 'create-user', 'reason': reason, 'new': db.row2dict(user)},
+        )
+    else:
+        if user.first_name != krestni or user.last_name != prijmeni:
+            raise mo.CheckError(f'Osoba již registrována s odlišným jménem {user.full_name()}')
+        if (user.is_admin or user.is_org) != is_org:
+            if is_org:
+                raise mo.CheckError('Nelze předefinovat účastníka na organizátora')
+            else:
+                raise mo.CheckError('Nelze předefinovat organizátora na účastníka')
+    return user, is_new
+
+
+def find_or_create_participant(user: db.User, year: int, school_id: int, birth_year: int, grade: str, reason: str = "undef-reason") -> Tuple[db.Participant, bool]:
+    sess = db.get_session()
+    part = sess.query(db.Participant).get((user.user_id, year))
+    is_new = part is None
+    if part is None:
+        part = db.Participant(user=user, year=year, school=school_id, birth_year=birth_year, grade=grade)
+        sess.add(part)
+        logger.info(f'{reason.title()}: Založen účastník #{user.user_id}')
+        mo.util.log(
+            type=db.LogType.participant,
+            what=user.user_id,
+            details={'action': 'create-participant', 'reason': reason, 'new': db.row2dict(part)},
+        )
+    else:
+        if (part.school != school_id
+                or part.grade != grade
+                or part.birth_year != birth_year):
+            raise mo.CheckError('Účastník již zaregistrován s odlišnou školou/ročníkem/rokem narození')
+    return part, is_new
+
+
+def find_or_create_participation(user: db.User, contest: db.Contest, place: Optional[db.Place], reason: str = "undef-reason") -> Tuple[db.Participation, bool]:
+    if place is None:
+        place = contest.place
+
+    sess = db.get_session()
+    pions = (sess.query(db.Participation)
+             .filter_by(user=user)
+             .filter(db.Participation.contest.has(db.Contest.round == contest.round))
+             .all())
+
+    is_new = pions == []
+    if is_new:
+        pion = db.Participation(user=user, contest=contest, place_id=place.place_id, state=db.PartState.invited)
+        sess.add(pion)
+        logger.info(f'{reason.title()}: Založena účast user=#{user.user_id} contest=#{contest.contest_id} place=#{place.place_id}')
+        mo.util.log(
+            type=db.LogType.participant,
+            what=user.user_id,
+            details={'action': 'add-to-contest', 'reason': reason, 'new': db.row2dict(pion)},
+        )
+    elif len(pions) == 1:
+        pion = pions[0]
+        if pion.place != place:
+            raise mo.CheckError(f'Již se tohoto kola účastní v {contest.round.get_level().name_locative("jiném", "jiné", "jiném")} ({pion.place.get_code()})')
+    else:
+        raise mo.CheckError('Již se tohoto kola účastní ve více oblastech, což by nemělo být možné')
+
+    return pion, is_new
+
+
 def normalize_email(addr: str) -> str:
     if not re.fullmatch(r'.+@.+', addr):
         raise mo.CheckError('V e-mailové adrese chybí zavináč')
diff --git a/mo/web/fields.py b/mo/web/fields.py
new file mode 100644
index 0000000000000000000000000000000000000000..e6f21da098528f367c161d9421dc366ee78b8135
--- /dev/null
+++ b/mo/web/fields.py
@@ -0,0 +1,140 @@
+from typing import Optional
+import wtforms
+from wtforms.widgets.html5 import NumberInput
+
+import mo
+import mo.users
+import mo.db as db
+
+
+class OptionalInt(wtforms.IntegerField):
+    widget = NumberInput()
+
+    def process_formdata(self, valuelist):
+        self.data = None
+        if valuelist:
+            if valuelist[0]:
+                try:
+                    self.data = int(valuelist[0])
+                except ValueError:
+                    raise wtforms.ValidationError('Nejedná se o číslo.')
+
+
+class Email(wtforms.StringField):
+    def __init__(self, label="E-mail", validators=None, **kwargs):
+        super().__init__(label, validators, **kwargs)
+
+    def pre_validate(field, form):
+        if field.data:
+            try:
+                field.data = mo.users.normalize_email(field.data)
+            except mo.CheckError as e:
+                raise wtforms.ValidationError(str(e))
+
+
+class Grade(wtforms.StringField):
+    """Pro validaci hledá ve formuláři form.school a podle ní rozlišuje SŠ a ZŠ """
+    default_description = "Pro základní školy je to číslo od 1 do 9, pro <var>k</var>-tý ročník <var>r</var>-leté střední školy má formát <var>k</var>/<var>r</var>."
+    validate_grade = True
+
+    def __init__(self, label="Ročník", validators=None, description=default_description, **kwargs):
+        super().__init__(label, validators, description=description, **kwargs)
+
+    def pre_validate(field, form):
+        if field.data:
+            if field.validate_grade:
+                school_place = form.school.get_place()
+                if school_place is not None:
+                    try:
+                        field.data = mo.users.normalize_grade(field.data, school_place.school)
+                    except mo.CheckError as e:
+                        raise wtforms.ValidationError(str(e))
+
+
+class BirthYear(OptionalInt):
+    def __init__(self, label="Rok narození", validators=None, **kwargs):
+        super().__init__(label, validators, **kwargs)
+
+    def pre_validate(field, form):
+        if field.data is not None:
+            r: int = field.data
+            try:
+                mo.users.validate_born_year(r)
+            except mo.CheckError as e:
+                raise wtforms.ValidationError(str(e))
+
+
+class Name(wtforms.StringField):
+    def pre_validate(field, form):
+        # XXX: Tato kontrola úmyslně není striktní, aby prošla i jména jako 'de Beer'
+        if field.data:
+            if field.data == field.data.lower():
+                raise wtforms.ValidationError('Ve jméně nejsou velká písmena.')
+            if field.data == field.data.upper():
+                raise wtforms.ValidationError('Ve jméně nejsou malá písmena.')
+
+
+class FirstName(Name):
+    def __init__(self, label="Jméno", validators=None, **kwargs):
+        super().__init__(label, validators, **kwargs)
+
+
+class LastName(Name):
+    def __init__(self, label="Příjmení", validators=None, **kwargs):
+        super().__init__(label, validators, **kwargs)
+
+
+class Place(wtforms.StringField):
+    def __init__(self, label="Místo", validators=None, **kwargs):
+        super().__init__(label, validators, **kwargs)
+
+    place_loaded: bool = False
+    place: Optional[db.Place] = None
+    place_error: str
+
+    def load_place(field) -> None:
+        field.place = None
+        field.place_error = ""
+        if field.data:
+            field.place = db.get_place_by_code(field.data)
+            if field.place is None:
+                field.place_error = "Zadané místo nenalezeno."
+
+    def get_place(field) -> Optional[db.Place]:
+        """ Kešuje výsledek v field.place"""
+        if not field.place_loaded:
+            field.place_loaded = True
+            field.load_place()
+        return field.place
+
+    def pre_validate(field, form):
+        if field.get_place() is None and field.place_error:
+            raise wtforms.ValidationError(field.place_error)
+
+    def get_place_id(field) -> int:
+        p = field.get_place()
+        if p is None:
+            return 0
+        return p.place_id
+
+    def populate_obj(field, obj, name):
+        setattr(obj, name, field.get_place_id())
+
+    def process_data(field, obj: Optional[int]):
+        if obj is not None:
+            field.data = db.get_place_by_id(obj).get_code()
+        else:
+            field.data = ""
+
+
+class School(Place):
+    def __init__(self, label="Škola", validators=None, **kwargs):
+        super().__init__(label, validators, **kwargs)
+
+    def load_place(field) -> None:
+        field.place = None
+        if field.data:
+            try:
+                field.place = mo.users.validate_and_find_school(field.data)
+            except mo.CheckError as e:
+                field.place_error = str(e)
diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py
index 0e70af2bbfa2aff3366157d811603a3b3e1fb049..40e13cd3a12d6723507cec963c679f96f9930d9e 100644
--- a/mo/web/org_contest.py
+++ b/mo/web/org_contest.py
@@ -23,6 +23,7 @@ from mo.rights import Right, ContestRights
 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 MODecimalField, PagerForm
 from mo.web.table import CellCheckbox, Table, Row, Column, cell_pion_link, cell_place_link, cell_email_link, cell_email_link_flags
@@ -1631,3 +1632,60 @@ def org_contest_edit(id: int):
         contest=contest,
         form=form,
     )
+
+
+
+class ParticipantAddForm(FlaskForm):
+    email = mo_fields.Email(validators=[validators.Required()])
+    first_name = mo_fields.FirstName(validators=[validators.Required()])
+    last_name = mo_fields.LastName(validators=[validators.Required()])
+    school = mo_fields.School(validators=[validators.Required()])
+    grade = mo_fields.Grade(validators=[validators.Required()])
+    birth_year = mo_fields.BirthYear(validators=[validators.Required()])
+    participation_place = mo_fields.Place("Kód soutěžního místa")
+    save = wtforms.SubmitField("Přidat")
+
+    def set_descriptions(self, contest: db.Contest):
+        self.school.description = f'Kód školy najdete v <a href="{url_for("org_place", id=contest.place.place_id)}">katalogu míst</a>.'
+        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>/ucastnici/pridat', methods=('GET', 'POST'))
+@app.route('/org/contest/c/<int:id>/site/<int:site_id>/ucastnici/pridat', 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)
+
+    reason = "form-add-participation"
+
+    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)
+
+    if form.validate_on_submit():
+        try:
+            user, is_new_user = mo.users.find_or_create_user(form.email.data, form.first_name.data, form.last_name.data, False, reason=reason)
+            participant, is_new_participant = mo.users.find_or_create_participant(user, contest.round.year, form.school.get_place_id(), form.birth_year.data, form.grade.data, reason=reason)
+            participation, is_new_participation = mo.users.find_or_create_participation(user, contest, form.participation_place.get_place(), reason=reason)
+        except mo.CheckError as e:
+            db.get_session().rollback()
+            flash(f"{e}", "danger")
+        else:
+            db.get_session().commit()
+            if is_new_user:
+                flash("Založen nový uživatel.", "info")
+            if is_new_participant:
+                flash("Založena nová registrace do ročníku.", "info")
+            if is_new_participation:
+                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 render_template(
+        'org_contest_add_user.html',
+        contest=master_contest, site=site,
+        form=form
+    )
diff --git a/mo/web/org_users.py b/mo/web/org_users.py
index db44ea01379bbdde91ecfeb5d973cfb09d7975a9..e885537a033c07024aed303dd034d15903535b95 100644
--- a/mo/web/org_users.py
+++ b/mo/web/org_users.py
@@ -18,6 +18,7 @@ from mo.rights import Right
 import mo.util
 import mo.users
 from mo.web import app
+import mo.web.fields as mo_fields
 from mo.web.util import PagerForm
 
 
@@ -513,7 +514,7 @@ def org_user_edit(id: int):
                 sess.commit()
                 flash('Změny uživatele 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_user', id=id))
 
@@ -585,3 +586,47 @@ def org_user_new():
     if not is_duplicate_name:
         del form.allow_duplicate_name
     return render_template('org_user_new.html', form=form, is_org=is_org)
+
+
+class ParticipantEditForm(FlaskForm):
+    school = mo_fields.School("Škola", validators=[Required()], render_kw={'autofocus': True})
+    grade = mo_fields.Grade("Třída", validators=[Required()])
+    birth_year = mo_fields.BirthYear("Rok narození", validators=[Required()])
+    submit = wtforms.SubmitField("Uložit")
+
+
+@app.route('/org/user/<int:user_id>/participant/<int:year>/edit', methods=('GET', 'POST'))
+def org_user_participant_edit(user_id: int, year: int):
+    sess = db.get_session()
+    user = mo.users.user_by_uid(user_id)
+    if not user:
+        raise werkzeug.exceptions.NotFound()
+
+    rr = g.gatekeeper.rights_generic()
+    if not rr.can_edit_user(user):
+        raise werkzeug.exceptions.Forbidden()
+
+    participant = sess.query(db.Participant).filter_by(user_id=user.user_id).filter_by(year=year).one_or_none()
+    if participant is None:
+        raise werkzeug.exceptions.NotFound()
+
+    form = ParticipantEditForm(obj=participant)
+    if form.validate_on_submit():
+        form.populate_obj(participant)
+        if sess.is_modified(participant):
+            changes = db.get_object_changes(participant)
+
+            app.logger.info(f"Participant id {id} year {year} modified, changes: {changes}")
+            mo.util.log(
+                type=db.LogType.participant,
+                what=user_id,
+                details={'action': 'edit-participant', 'year': year, 'changes': changes},
+            )
+            sess.commit()
+            flash('Změny registrace uloženy', 'success')
+        else:
+            flash('Žádné změny k uložení', 'info')
+
+        return redirect(url_for('org_user', id=user_id))
+
+    return render_template('org_user_participant_edit.html', user=user, year=year, form=form)
diff --git a/mo/web/templates/org_contest.html b/mo/web/templates/org_contest.html
index 36eb3b62772f8e11c69e1632c2796e9c2bfc361b..f78ea9c2fbaaf0635885c1abef6ba9f3a06d6c0a 100644
--- a/mo/web/templates/org_contest.html
+++ b/mo/web/templates/org_contest.html
@@ -49,6 +49,9 @@
 	{% 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>
 	{% 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>
+	{% 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>
@@ -73,25 +76,37 @@
 {% if places_counts %}
 <table class=data>
 	<thead>
-		<tr><th>Místo<th>Počet účastníků
+		<tr><th>Místo<th>Počet účastníků<th>Akce
 	</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>{{ 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>
+				{% 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>
+			</div>
+				{% endif %}
 		</tr>
 	{% endfor %}
 	<tfoot>
 		<tr>
 			<th>Celkem
 			<th>{{ places_counts|sum(attribute=1) }}
+			<th>
 		</tr>
 	</tfoot>
 </table>
 {% else %}
-<i>Žádní účastníci a žádná soutěžní místa.</i>
+<p><i>Žádní účastníci a žádná soutěžní místa.</i></p>
 {% endif %}
 {% 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>
+	{% endif %}
+</div>
 
 <h3>Úlohy</h3>
 {% if tasks %}
@@ -134,7 +149,7 @@
 	{% endfor %}
 </table>
 {% else %}
-<p>Zatím nebyly přidány žádné úlohy.</p>
+<p><i>Zatím nebyly přidány žádné úlohy.</i></p>
 {% endif %}
 
 <!--
diff --git a/mo/web/templates/org_contest_add_user.html b/mo/web/templates/org_contest_add_user.html
new file mode 100644
index 0000000000000000000000000000000000000000..297d6ce7b40fb6c007f9b8fe18a7d587ec67e6c7
--- /dev/null
+++ b/mo/web/templates/org_contest_add_user.html
@@ -0,0 +1,19 @@
+{% 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") }}
+{% endblock %}
+
+{% block body %}
+
+{% if errs %}
+{% endif %}
+
+{{ wtf.quick_form(form, form_type='simple', button_map={'save': 'primary'}) }}
+
+{% endblock %}
diff --git a/mo/web/templates/org_user.html b/mo/web/templates/org_user.html
index e76644b20ea3f1dd6eb8292f942428556b15c25b..966d4b94591f593b6f012aca424ba3a5ddaabc6b 100644
--- a/mo/web/templates/org_user.html
+++ b/mo/web/templates/org_user.html
@@ -43,7 +43,7 @@
 <table class="data full">
 	<thead>
 		<tr>
-			<th>Ročník<th>Škola<th>Třída<th>Rok narození
+			<th>Ročník<th>Škola<th>Třída<th>Rok narození<th>Akce
 		</tr>
 	</thead>
 {% for participant in participants %}
@@ -52,6 +52,7 @@
 		<td><a href="{{ url_for('org_place', id=participant.school) }}">{{ participant.school_place.name }}</a>
 		<td>{{ participant.grade }}
 		<td>{{ participant.birth_year }}
+		<td><a class="btn btn-xs btn-primary" href="{{ url_for('org_user_participant_edit', user_id=user.user_id, year=participant.year) }}">Editovat</a>
 	</tr>
 {% endfor %}
 </table>
diff --git a/mo/web/templates/org_user_participant_edit.html b/mo/web/templates/org_user_participant_edit.html
new file mode 100644
index 0000000000000000000000000000000000000000..a4245de62fb650d49beda17948a7fc6f5af16c23
--- /dev/null
+++ b/mo/web/templates/org_user_participant_edit.html
@@ -0,0 +1,8 @@
+{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+{% block title %}Editace registrace soutěžícího {{ user.full_name() }} v {{ year }}. ročníku{% endblock %}
+{% block body %}
+
+{{ wtf.quick_form(form, form_type='horizontal', button_map={'submit': 'primary'}) }}
+
+{% endblock %}
diff --git a/mo/web/templates/parts/org_participants_table_actions.html b/mo/web/templates/parts/org_participants_table_actions.html
index 24eb85aa361049b900d3a1672d8637673e50cfda..0eec0d02a253fa76728b4d2623192de5c44acd3e 100644
--- a/mo/web/templates/parts/org_participants_table_actions.html
+++ b/mo/web/templates/parts/org_participants_table_actions.html
@@ -4,7 +4,10 @@
 
 	{{ table.to_html() }}
 
-	<a class="btn btn-primary pull-right"
+	{% 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