diff --git a/mo/imports.py b/mo/imports.py
index cf73fc4445f288724f46b891a36791ca9c1a0867..1922896b8383f3f8ec89d2932f00de1860d0377b 100644
--- a/mo/imports.py
+++ b/mo/imports.py
@@ -2,7 +2,9 @@ from dataclasses import dataclass
 from enum import auto
 import io
 import re
-from typing import List, Optional, Any, Dict, Type
+from sqlalchemy import and_
+from sqlalchemy.orm import joinedload, Query
+from typing import List, Optional, Any, Dict, Type, Union
 
 import mo.csv
 from mo.csv import FileFormat, MissingHeaderError
@@ -17,6 +19,7 @@ class ImportType(db.MOEnum):
     participants = auto()
     proctors = auto()
     judges = auto()
+    points = auto()
 
     def friendly_name(self) -> str:
         return import_type_names[self]
@@ -26,6 +29,7 @@ import_type_names = {
     ImportType.participants.name: 'účastníci',
     ImportType.proctors.name: 'dozor',
     ImportType.judges.name: 'opravovatelé',
+    ImportType.points.name: 'body',
 }
 
 
@@ -37,6 +41,9 @@ class Import:
     cnt_new_participants: int = 0
     cnt_new_participations: int = 0
     cnt_new_roles: int = 0
+    cnt_set_points: int = 0
+    cnt_add_sols: int = 0
+    cnt_del_sols: int = 0
 
     # Veřejné vlastnosti importu
     template_basename: str = "sablona"
@@ -45,11 +52,13 @@ class Import:
     user: db.User
     round: Optional[db.Round]
     contest: Optional[db.Contest]
+    task: Optional[db.Task]         # pro Import bodů
+    allow_add_del: bool             # pro Import bodů: je povoleno zakládat/mazat řešení
     fmt: FileFormat
     row_class: Type[mo.csv.Row]
     row_example: mo.csv.Row
     log_msg_prefix: str
-    log_event_name: str
+    log_details: Any
 
     # Interní: Stav importu
     place_cache: Dict[str, db.Place]
@@ -76,6 +85,15 @@ class Import:
         logger.info('Import: >> %s', msg)
         return None     # Kdyby bylo otypováno správně jako -> None, při volání by si mypy stěžoval
 
+    def parse_user_id(self, user_id_str: str) -> Optional[int]:
+        if user_id_str == "":
+            return self.error('Chybí ID uživatele')
+
+        try:
+            return int(user_id_str)
+        except ValueError:
+            return self.error('ID uživatele není číslo')
+
     def parse_email(self, email: str) -> Optional[str]:
         if email == "":
             return self.error('Chybí e-mailová adresa')
@@ -187,6 +205,24 @@ class Import:
             self.new_user_ids.append(user.user_id)
         return user
 
+    def parse_points(self, points_str: str) -> Union[int, str, None]:
+        if points_str == "":
+            return self.error('Body musí být vyplněny')
+
+        points_str = points_str.upper()
+        if points_str in ['X', '?']:
+            return points_str
+
+        try:
+            pts = int(points_str)
+        except ValueError:
+            return self.error('Body nejsou celé číslo')
+
+        if pts < 0:
+            return self.error('Body nesmí být záporné')
+
+        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))
@@ -287,22 +323,37 @@ 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.task is not None:
+            args.append(f'task=#{self.task.task_id}')
 
         logger.info('Import: %s ze souboru %s: %s', self.log_msg_prefix, path, " ".join(args))
 
     def log_end(self):
-        logger.info(f'Import: Hotovo (rows={self.cnt_rows} users={self.cnt_new_users} p-ants={self.cnt_new_participants} p-ions={self.cnt_new_participations} roles={self.cnt_new_roles})')
+        args = [f'rows=#{self.cnt_rows}']
+        for key, val in [
+            ('users', self.cnt_new_users),
+            ('p-ants', self.cnt_new_participants),
+            ('p-ions', self.cnt_new_participations),
+            ('roles', self.cnt_new_roles),
+            ('points', self.cnt_set_points),
+            ('add-sols', self.cnt_add_sols),
+            ('del-sols', self.cnt_del_sols),
+        ]:
+            if val > 0:
+                args.append(f'{key}={val}')
+        logger.info('Import: Hotovo (%s)', " ".join(args))
+
         if self.contest is not None:
             mo.util.log(
                 type=db.LogType.contest,
                 what=self.contest.contest_id,
-                details={'action': 'import'}
+                details=self.log_details,
             )
         elif self.round is not None:
             mo.util.log(
                 type=db.LogType.round,
                 what=self.round.round_id,
-                details={'action': self.log_event_name}
+                details=self.log_details,
             )
         else:
             assert False
@@ -362,6 +413,7 @@ class Import:
                 sess.rollback()
 
     def get_template(self) -> str:
+        # Odvozené třídy mohou přetížit
         out = io.StringIO()
         mo.csv.write(file=out, fmt=self.fmt, row_class=self.row_class, rows=[self.row_example])
         return out.getvalue()
@@ -403,7 +455,7 @@ class ContestImport(Import):
         rok_naroz="2000",
     )
     log_msg_prefix = 'Účastníci'
-    log_event_name = 'import'
+    log_details = {'action': 'import'}
     template_basename = 'sablona-ucast'
 
     def setup(self):
@@ -462,7 +514,7 @@ class ProctorImport(Import):
         kod_mista='#3333',
     )
     log_msg_prefix = 'Dozor'
-    log_event_name = 'import-proctors'
+    log_details = {'action': 'import-proctors'}
     template_basename = 'sablona-dozor'
 
     def setup(self):
@@ -509,7 +561,7 @@ class JudgeImport(Import):
         kod_oblasti='B',
     )
     log_msg_prefix = 'Opravovatelé'
-    log_event_name = 'import-judges'
+    log_details = {'action': 'import-judges'}
     template_basename = 'sablona-oprav'
 
     def setup(self):
@@ -541,7 +593,153 @@ class JudgeImport(Import):
         self.add_role(user, place, db.RoleType.opravovatel)
 
 
-def create_import(user: db.User, type: ImportType, fmt: FileFormat, round: Optional[db.Round] = None, contest: Optional[db.Contest] = None):
+@dataclass
+class PointsImportRow(mo.csv.Row):
+    user_id: str = ""
+    krestni: str = ""
+    prijmeni: str = ""
+    body: str = ""
+
+
+class PointsImport(Import):
+    row_class = PointsImportRow
+    log_msg_prefix = 'Body'
+
+    def setup(self):
+        assert self.round is not None
+        assert self.task is not None
+        self.log_details = {'action': 'import-points', 'task': self.task.code}
+        self.template_basename = 'body-' + self.task.code
+
+    def _pion_sol_query(self) -> Query:
+        sess = db.get_session()
+        query = (sess.query(db.Participation, db.Solution)
+                 .select_from(db.Participation)
+                 .outerjoin(db.Solution, and_(db.Solution.user_id == db.Participation.user_id, db.Solution.task == self.task))
+                 .options(joinedload(db.Participation.user)))
+
+        if self.contest is not None:
+            query = query.filter(db.Participation.contest == self.contest)
+        else:
+            contest_query = sess.query(db.Contest.contest_id).filter_by(round=self.round)
+            query = query.filter(db.Participation.contest_id.in_(contest_query.subquery()))
+
+        return query
+
+    def import_row(self, r: mo.csv.Row):
+        assert isinstance(r, PointsImportRow)
+        num_prev_errs = len(self.errors)
+        user_id = self.parse_user_id(r.user_id)
+        krestni = self.parse_name(r.krestni)
+        prijmeni = self.parse_name(r.prijmeni)
+        body = self.parse_points(r.body)
+
+        if (len(self.errors) > num_prev_errs
+                or user_id is None
+                or krestni is None
+                or prijmeni is None
+                or body is None):
+            return
+
+        assert self.round is not None
+        assert self.task is not None
+        task_id = self.task.task_id
+
+        sess = db.get_session()
+        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')
+        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]
+
+        if self.contest is not None:
+            if pion.contest != self.contest:
+                return self.error('Soutěžící nesoutěží v této oblasti')
+
+        rights = self.gatekeeper.rights_for_contest(pion.contest)
+        if not rights.can_edit_points(self.round):
+            return self.error('Nemáte právo na úpravu bodů')
+
+        user = pion.user
+        if user.first_name != krestni or user.last_name != prijmeni:
+            return self.error('Neodpovídá ID a jméno soutěžícího')
+
+        if sol is None:
+            if body == 'X':
+                return
+            if not self.allow_add_del:
+                return self.error('Tento soutěžící úlohu neodevzdal')
+            if not rights.can_upload_solutions(round):
+                return self.error('Nemáte právo na zakládání nových řešení')
+            sol = db.Solution(user_id=user_id, task_id=task_id)
+            sess.add(sol)
+            logger.info(f'Import: Založeno řešení user=#{user_id} task=#{task_id}')
+            mo.util.log(
+                type=db.LogType.participant,
+                what=user_id,
+                details={'action': 'solution-created', 'task': task_id},
+            )
+            self.cnt_add_sols += 1
+        elif body == 'X':
+            if not self.allow_add_del:
+                return self.error('Tento soutěžící úlohu odevzdal')
+            if sol.final_submit is not None or sol.final_feedback is not None:
+                return self.error('Nelze smazat řešení, ke kterému existují odevzdané soubory')
+            if not rights.can_upload_solutions(round):
+                return self.error('Nemáte právo na mazání řešení')
+            logger.info(f'Import: Smazáno řešení user=#{user_id} task=#{task_id}')
+            mo.util.log(
+                type=db.LogType.participant,
+                what=user_id,
+                details={'action': 'solution-removed', 'task': task_id},
+            )
+            self.cnt_del_sols += 1
+            sess.delete(sol)
+            return
+
+        points = body if isinstance(body, int) else None
+        if sol.points != points:
+            sol.points = points
+            sess.add(db.PointsHistory(
+                task=self.task,
+                participant_id=user_id,
+                user=self.user,
+                points_at=mo.now,
+                points=points,
+            ))
+            self.cnt_set_points += 1
+
+    def get_template(self) -> str:
+        rows = []
+        for pion, sol in sorted(self._pion_sol_query().all(), key=lambda pair: pair[0].user.sort_key()):
+            if sol is None:
+                pts = 'X'
+            elif sol.points is None:
+                pts = '?'
+            else:
+                pts = str(sol.points)
+            user = pion.user
+            rows.append(PointsImportRow(
+                user_id=user.user_id,
+                krestni=user.first_name,
+                prijmeni=user.last_name,
+                body=pts,
+            ))
+
+        out = io.StringIO()
+        mo.csv.write(file=out, fmt=self.fmt, row_class=self.row_class, rows=rows)
+        return out.getvalue()
+
+
+def create_import(user: db.User,
+                  type: ImportType,
+                  fmt: FileFormat,
+                  round: Optional[db.Round] = None,
+                  contest: Optional[db.Contest] = None,
+                  task: Optional[db.Task] = None,
+                  allow_add_del: bool = False):
     imp: Import
     if type == ImportType.participants:
         imp = ContestImport()
@@ -549,12 +747,16 @@ def create_import(user: db.User, type: ImportType, fmt: FileFormat, round: Optio
         imp = ProctorImport()
     elif type == ImportType.judges:
         imp = JudgeImport()
+    elif type == ImportType.points:
+        imp = PointsImport()
     else:
         assert False, "Neznámý typ importu"
 
     imp.user = user
     imp.round = round
     imp.contest = contest
+    imp.task = task
+    imp.allow_add_del = allow_add_del
     imp.fmt = fmt
     imp.gatekeeper = mo.rights.Gatekeeper(user)
     imp.setup()
diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py
index 930d1060aaacdeb904ca80f4943f7a4647bcafda..8b0d5edd4a3e56b8ac1aed91cf37c9816a0d3cbb 100644
--- a/mo/web/org_contest.py
+++ b/mo/web/org_contest.py
@@ -15,7 +15,7 @@ import wtforms
 import mo
 from mo.csv import FileFormat
 import mo.db as db
-from mo.imports import ImportType, create_import
+from mo.imports import ImportType, Import, create_import
 import mo.jobs.submit
 from mo.rights import Right, Rights
 import mo.util
@@ -32,7 +32,8 @@ class ImportForm(FlaskForm):
     file = flask_wtf.file.FileField("Soubor")
     typ = wtforms.SelectField(
         "Typ dat",
-        choices=ImportType.choices(), coerce=ImportType.coerce,
+        choices=[(x.name, x.friendly_name()) for x in (ImportType.participants, ImportType.proctors, ImportType.judges)],
+        coerce=ImportType.coerce,
         default=ImportType.participants,
     )
     fmt = wtforms.SelectField(
@@ -614,6 +615,7 @@ def get_solution_context(contest_id: int, user_id: Optional[int], task_id: Optio
         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=rr.can_upload_solutions(round),
         allow_upload_feedback=rr.can_upload_feedback(round),
@@ -1152,6 +1154,70 @@ def org_contest_task_upload(contest_id: int, task_id: int, site_id: Optional[int
                                 can_upload_feedback=sc.allow_upload_feedback)
 
 
+class BatchPointsForm(FlaskForm):
+    file = flask_wtf.file.FileField("Soubor")
+    fmt = wtforms.SelectField(
+        "Formát souboru",
+        choices=FileFormat.choices(), coerce=FileFormat.coerce,
+        default=FileFormat.cs_csv,
+    )
+    add_del_sols = wtforms.BooleanField('Zakládat / mazat řešení', description='Xyzzy')
+    submit = wtforms.SubmitField('Nahrát body')
+    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."""
+
+    form = BatchPointsForm()
+    errs = []
+    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)
+        if form.submit.data:
+            if form.file.data is not None:
+                file = form.file.data.stream
+                import_tmp = mo.util.link_to_dir(file.name, mo.util.data_dir('imports'), suffix='.csv')
+
+                if imp.run(import_tmp):
+                    if imp.cnt_rows == 0:
+                        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))
+                else:
+                    errs = imp.errors
+            else:
+                flash('Vyberte si prosím soubor', 'danger')
+        elif form.get_template.data:
+            out = imp.get_template()
+            resp = app.make_response(out)
+            resp.content_type = fmt.get_content_type()
+            resp.headers.add('Content-Disposition', 'attachment; filename=OSMO-' + imp.template_basename + '.' + fmt.get_extension())
+            return resp
+
+    return render_template(
+        'org_generic_batch_points.html',
+        round=round, contest=contest, task=task,
+        form=form,
+        errs=errs,
+    )
+
+
+@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)
diff --git a/mo/web/org_round.py b/mo/web/org_round.py
index 63b268b20c8f04609a3e533582e9563e334b24e0..7e86f508f75e4886a3b6b5b76889140678c98dec 100644
--- a/mo/web/org_round.py
+++ b/mo/web/org_round.py
@@ -1,4 +1,3 @@
-import datetime
 from flask import render_template, g, redirect, url_for, flash, request
 import locale
 from flask_wtf.form import FlaskForm
@@ -17,7 +16,7 @@ from mo.rights import Right, Rights
 import mo.util
 from mo.web import app
 from mo.web.org_contest import ParticipantsActionForm, ParticipantsFilterForm, get_contestants_query, make_contestant_table, \
-    generic_import, generic_batch_download, generic_batch_upload
+    generic_import, generic_batch_download, generic_batch_upload, generic_batch_points
 
 
 def get_round(id: int) -> db.Round:
@@ -38,6 +37,13 @@ def get_round_rr(id: int, right_needed: Optional[Right], any_place: bool) -> Tup
     return round, 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
+
+
 @app.route('/org/contest/')
 def org_rounds():
     sess = db.get_session()
@@ -271,30 +277,27 @@ def org_round_task_edit(id: int, task_id: int):
 
 @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):
-    sess = db.get_session()
     round, rr = get_round_rr(round_id, Right.view_submits, False)
-
-    task = sess.query(db.Task).get(task_id)
-    if not task or task.round_id != round_id:
-        raise werkzeug.exceptions.NotFound()
-
+    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):
-    sess = db.get_session()
     round, rr = get_round_rr(round_id, Right.view_submits, False)
-
-    task = sess.query(db.Task).get(task_id)
-    if not task or task.round_id != round_id:
-        raise werkzeug.exceptions.NotFound()
-
+    task = get_task(round, task_id)
     return generic_batch_upload(round=round, contest=None, site=None, task=task,
                                 can_upload_solutions=rr.can_upload_solutions(round),
                                 can_upload_feedback=rr.can_upload_feedback(round))
 
 
+@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, rr = 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'))
 def org_round_list(id: int):
     round, rr = get_round_rr(id, Right.manage_contest, True)
diff --git a/mo/web/templates/org_contest.html b/mo/web/templates/org_contest.html
index 46315999d2421c81e6ce88c16de79982645e6574..ada65912f6a48801b89e8b4fd29d18141befc395 100644
--- a/mo/web/templates/org_contest.html
+++ b/mo/web/templates/org_contest.html
@@ -100,6 +100,7 @@
 			{% endif %}
 			{% if not site and can_edit_points %}
 				<a class="btn btn-xs btn-primary" 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="{{ url_for('org_contest_task_batch_points', contest_id=contest.contest_id, task_id=task.task_id) }}">Nahrát body</a>
 			{% endif %}
 		</div>
 	</tr>
diff --git a/mo/web/templates/org_generic_batch_points.html b/mo/web/templates/org_generic_batch_points.html
new file mode 100644
index 0000000000000000000000000000000000000000..646b76471c760832182e312a2336aeb3a642fefd
--- /dev/null
+++ b/mo/web/templates/org_generic_batch_points.html
@@ -0,0 +1,27 @@
+{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+
+{% 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í") }}
+{% endblock %}
+
+{% block body %}
+
+{% if errs %}
+<h3>Chyby při importu</h3>
+
+<pre><div class="alert alert-danger" role="alert">{{ "" -}}
+{% for e in errs %}
+{{ e }}
+{% endfor %}
+</div></pre>
+{% endif %}
+
+<p>Zde si můžete stáhnout bodovací formulář v zadaném formátu a pak ho nahrát zpět
+s vyplněnými body. "<code>?</code>" místo bodů značí dosud neobodované řešení,
+"<code>X</code>" značí řešení neodevzdané.
+
+{{ wtf.quick_form(form, form_type='simple', button_map={'submit': 'primary'}) }}
+
+{% endblock %}
diff --git a/mo/web/templates/org_round.html b/mo/web/templates/org_round.html
index 10e32a7b75ca9746b3be31f258cb997b832f48e8..3296d22beeeb1486e96d26f526c4c4666c6e619b 100644
--- a/mo/web/templates/org_round.html
+++ b/mo/web/templates/org_round.html
@@ -112,6 +112,9 @@
 				{% 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</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>
+				{% endif %}
 				{% if g.user.is_admin %}
 					<a class="btn btn-xs btn-default" href="{{ log_url('task', task.task_id) }}">Historie</a>
 				{% endif %}