diff --git a/db/db.ddl b/db/db.ddl
index 7e27eb955a61e9ab922a18fdf60801f9699f9e5b..fd3ce6c2c8f86c9b736993a36e2911491ddd23f6 100644
--- a/db/db.ddl
+++ b/db/db.ddl
@@ -107,6 +107,7 @@ CREATE TABLE rounds (
 	score_mode	score_mode	NOT NULL DEFAULT 'basic',	-- mód výsledkovky
 	score_winner_limit	int	DEFAULT NULL,			-- bodový limit na označení za vítěze
 	score_successful_limit	int	DEFAULT NULL,			-- bodový limit na označení za úspěšného řešitele
+	points_step	numeric(2,1)	NOT NULL DEFAULT 1,		-- s jakou přesností jsou přidělovány body (celé aneb 1, 0.5, 0.1)
 	has_messages	boolean		NOT NULL DEFAULT false,		-- má zprávičky
 	UNIQUE (year, category, seq, part)
 );
@@ -162,7 +163,7 @@ CREATE TABLE tasks (
 	round_id	int		NOT NULL REFERENCES rounds(round_id),
 	code		varchar(255)	NOT NULL,			-- např. "P-I-1"
 	name		varchar(255)	NOT NULL,
-	max_points	int		DEFAULT NULL,			-- maximální počet bodů, pokud je nastaven, nelze zadat více bodů
+	max_points	numeric(5,1)	DEFAULT NULL,			-- maximální počet bodů, pokud je nastaven, nelze zadat více bodů
 	UNIQUE (round_id, code)
 );
 
@@ -200,7 +201,7 @@ CREATE TABLE solutions (
 	user_id		int		NOT NULL REFERENCES users(user_id),
 	final_submit	int		DEFAULT NULL REFERENCES papers(paper_id),	-- verze odevzdání, která se má hodnotit
 	final_feedback	int		DEFAULT NULL REFERENCES papers(paper_id),	-- verze komentáře opravovatelů, kterou má vidět účastník
-	points		int		DEFAULT NULL,
+	points		numeric(5,1)	DEFAULT NULL,
 	note		text		NOT NULL DEFAULT '',			-- komentář pro řešitele
 	org_note	text		NOT NULL DEFAULT '',			-- komentář viditelný jen organizátorům
 	PRIMARY KEY (task_id, user_id)
@@ -213,7 +214,7 @@ CREATE TABLE points_history (
 	points_history_id	serial	PRIMARY KEY,
 	task_id		int		NOT NULL REFERENCES tasks(task_id),
 	participant_id	int		NOT NULL REFERENCES users(user_id),
-	points		int		DEFAULT NULL,
+	points		numeric(5,1)	DEFAULT NULL,
 	points_by	int		NOT NULL REFERENCES users(user_id),		-- kdo přidělil body
 	points_at	timestamp with time zone	NOT NULL			-- a kdy
 );
diff --git a/db/upgrade-20210328.sql b/db/upgrade-20210328.sql
new file mode 100644
index 0000000000000000000000000000000000000000..5d2b29cbaff1d1dcd0961bea710abf436d183e82
--- /dev/null
+++ b/db/upgrade-20210328.sql
@@ -0,0 +1,15 @@
+SET ROLE 'mo_osmo';
+
+ALTER TABLE rounds
+        ADD COLUMN points_step	numeric(2,1)	NOT NULL DEFAULT 1,		-- s jakou přesností jsou přidělovány body (celé aneb 1, 0.5, 0.1)
+        ALTER COLUMN score_winner_limit SET DATA TYPE numeric(5,1),
+        ALTER COLUMN score_successful_limit SET DATA TYPE numeric(5,1);
+
+ALTER TABLE solutions
+        ALTER COLUMN points SET DATA TYPE numeric(5,1);
+
+ALTER TABLE points_history
+        ALTER COLUMN points SET DATA TYPE numeric(5,1);
+
+ALTER TABLE tasks
+        ALTER COLUMN max_points SET DATA TYPE numeric(5,1);
diff --git a/etc/config.py.example b/etc/config.py.example
index 1e93f3d23f0f1e66854fe33b9d08eb6e96128357..9b9e4ec05976ee108f147230b9bcea884241682c 100644
--- a/etc/config.py.example
+++ b/etc/config.py.example
@@ -19,7 +19,7 @@ SESSION_COOKIE_PATH = '/'
 SESSION_COOKIE_NAME = 'mo_session'
 # SESSION_COOKIE_SECURE=True
 
-# Kontaktní email (v patičce a také používaný jako adresát při generování pošty s Bcc)
+# Kontaktní email (v patičce, Reply-To a také používaný jako adresát při generování pošty s Bcc)
 MAIL_CONTACT = "osmo@mo.mff.cuni.cz"
 
 # Odesilatel generovaných mailů (není-li definován, neposílají se)
diff --git a/mo/db.py b/mo/db.py
index 5d2c50189a63c27e52d5a01d9a96d216c8eb8e14..141fb727a38de304e0b935506332fa0cadf52c3f 100644
--- a/mo/db.py
+++ b/mo/db.py
@@ -2,6 +2,7 @@
 # Generated by sqlacodegen and then heavily edited.
 
 import datetime
+import decimal
 from enum import Enum as PythonEnum, auto
 import locale
 import re
@@ -15,6 +16,7 @@ from sqlalchemy.orm.attributes import get_history
 from sqlalchemy.dialects.postgresql import JSONB
 from sqlalchemy.ext.declarative import declarative_base
 from sqlalchemy.sql.expression import CTE
+from sqlalchemy.sql.sqltypes import Numeric
 from typing import Optional, List, Tuple
 
 import mo
@@ -186,6 +188,15 @@ round_score_mode_names = {
 }
 
 
+# V DB jako numeric(2,1), používá se tak snadněji, než enum
+round_points_step_names = {
+    1: "Celé body",
+    0.5: "Půlbody",
+    0.1: "Desetinné body",
+}
+round_points_step_choices = round_points_step_names.items()
+
+
 class Round(Base):
     __tablename__ = 'rounds'
     __table_args__ = (
@@ -207,8 +218,9 @@ class Round(Base):
     pr_tasks_start = Column(DateTime(True))
     pr_submit_end = Column(DateTime(True))
     score_mode = Column(Enum(RoundScoreMode, name='score_mode'), nullable=False, server_default=text("'basic'::score_mode"))
-    score_winner_limit = Column(Integer)
-    score_successful_limit = Column(Integer)
+    score_winner_limit = Column(Numeric)
+    score_successful_limit = Column(Numeric)
+    points_step = Column(Numeric, nullable=False)
     has_messages = Column(Boolean, nullable=False, server_default=text("false"))
 
     master = relationship('Round', primaryjoin='Round.master_round_id == Round.round_id', remote_side='Round.round_id', post_update=True)
@@ -246,6 +258,11 @@ class Round(Base):
         else:
             return self.state
 
+    def points_step_name(self) -> str:
+        if float(self.points_step) in round_points_step_names:
+            return round_points_step_names[float(self.points_step)]
+        return str(self.points_step)
+
 
 class User(Base):
     __tablename__ = 'users'
@@ -415,7 +432,7 @@ class Task(Base):
     round_id = Column(Integer, ForeignKey('rounds.round_id'), nullable=False)
     code = Column(String(255), nullable=False)
     name = Column(String(255), nullable=False)
-    max_points = Column(Integer)
+    max_points = Column(Numeric)
 
     round = relationship('Round')
 
@@ -524,7 +541,7 @@ class PointsHistory(Base):
     points_history_id = Column(Integer, primary_key=True, server_default=text("nextval('points_history_points_history_id_seq'::regclass)"))
     task_id = Column(Integer, ForeignKey('tasks.task_id'), nullable=False)
     participant_id = Column(Integer, ForeignKey('users.user_id'), nullable=False)
-    points = Column(Integer)
+    points = Column(Numeric)
     points_by = Column(Integer, ForeignKey('users.user_id'), nullable=False)
     points_at = Column(DateTime(True), nullable=False)
 
@@ -540,7 +557,7 @@ class Solution(Base):
     user_id = Column(Integer, ForeignKey('users.user_id'), primary_key=True, nullable=False)
     final_submit = Column(Integer, ForeignKey('papers.paper_id'))
     final_feedback = Column(Integer, ForeignKey('papers.paper_id'))
-    points = Column(Integer)
+    points = Column(Numeric)
     note = Column(Text, nullable=False, server_default=text("''::text"))
     org_note = Column(Text, nullable=False, server_default=text("''::text"))
 
@@ -713,6 +730,8 @@ def row2dict(row):
         if isinstance(val, datetime.datetime):
             # datetime neumíme serializovat do JSONu, ale nevadí to, protože ho stejně nemá smysl logovat
             pass
+        elif isinstance(val, decimal.Decimal):
+            d[column.name] = float(val)
         else:
             d[column.name] = getattr(row, column.name)
 
diff --git a/mo/imports.py b/mo/imports.py
index fcc4b1d2e9172026a1e39beca4ff400ce267b665..03ec65e05912774c76d23662cde2c4a568b24b4b 100644
--- a/mo/imports.py
+++ b/mo/imports.py
@@ -1,4 +1,5 @@
 from dataclasses import dataclass
+import decimal
 from enum import auto
 import io
 import re
@@ -13,6 +14,7 @@ import mo.rights
 import mo.users
 import mo.util
 from mo.util import logger
+from mo.util_format import format_decimal
 
 
 class ImportType(db.MOEnum):
@@ -124,7 +126,7 @@ class Import:
         rights = self.gatekeeper.rights_for(place, round.year, round.category, round.seq)
         return rights.have_right(mo.rights.Right.manage_contest)
 
-    def parse_opt_place(self, kod: str) -> Optional[db.Place]:
+    def parse_opt_place(self, kod: str, what: str) -> Optional[db.Place]:
         if kod == "":
             return None
 
@@ -133,7 +135,8 @@ class Import:
 
         place = db.get_place_by_code(kod)
         if not place:
-            return self.error(f'Místo "{kod}" nenalezeno')
+            return self.error(f'{what.title()} s kódem "{kod}" neexistuje'+
+                    ('. Nechybí vám # na začátku?' if re.fullmatch(r'\d+', kod) else ''))
 
         if not self.check_rights(place):
             return self.error(f'K místu "{kod}" nemáte práva na správu soutěže')
@@ -150,7 +153,8 @@ class Import:
 
         place = db.get_place_by_code(kod, fetch_school=True)
         if not place:
-            return self.error(f'Škola "{kod}" nenalezena')
+            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')
@@ -212,7 +216,7 @@ class Import:
             self.new_user_ids.append(user.user_id)
         return user
 
-    def parse_points(self, points_str: str) -> Union[int, str, None]:
+    def parse_points(self, points_str: str) -> Union[decimal.Decimal, str, None]:
         if points_str == "":
             return self.error('Body musí být vyplněny')
 
@@ -220,17 +224,9 @@ class Import:
         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é')
-
-        assert self.task is not None
-        if self.task.max_points is not None and pts > self.task.max_points:
-            return self.error(f'Body převyšují maximální počet bodů pro tuto úlohu ({self.task.max_points})')
+        pts, error = mo.util.parse_points(points_str, self.task, self.round)
+        if error:
+            return self.error(error)
 
         return pts
 
@@ -483,8 +479,8 @@ class ContestImport(Import):
         school_place = self.parse_school(r.kod_skoly)
         rocnik = self.parse_grade(r.rocnik, (school_place.school if school_place else None))
         rok_naroz = self.parse_born(r.rok_naroz)
-        misto = self.parse_opt_place(r.kod_mista)
-        oblast = self.parse_opt_place(r.kod_oblasti)
+        misto = self.parse_opt_place(r.kod_mista, 'místo')
+        oblast = self.parse_opt_place(r.kod_oblasti, 'oblast')
 
         if (len(self.errors) > num_prev_errs
                 or email is None
@@ -539,7 +535,7 @@ class ProctorImport(Import):
         email = self.parse_email(r.email)
         krestni = self.parse_name(r.krestni)
         prijmeni = self.parse_name(r.prijmeni)
-        misto = self.parse_opt_place(r.kod_mista)
+        misto = self.parse_opt_place(r.kod_mista, 'místo')
 
         if misto is None:
             return self.error('Kód místa je povinné uvést')
@@ -588,7 +584,7 @@ class JudgeImport(Import):
         email = self.parse_email(r.email)
         krestni = self.parse_name(r.krestni)
         prijmeni = self.parse_name(r.prijmeni)
-        oblast = self.parse_opt_place(r.kod_oblasti)
+        oblast = self.parse_opt_place(r.kod_oblasti, 'oblast')
 
         if (len(self.errors) > num_prev_errs
                 or email is None
@@ -714,7 +710,7 @@ class PointsImport(Import):
             sess.delete(sol)
             return
 
-        points = body if isinstance(body, int) else None
+        points = body if isinstance(body, decimal.Decimal) else None
         if sol.points != points:
             sol.points = points
             sess.add(db.PointsHistory(
@@ -734,7 +730,7 @@ class PointsImport(Import):
             elif sol.points is None:
                 pts = '?'
             else:
-                pts = str(sol.points)
+                pts = format_decimal(sol.points)
             user = pion.user
             rows.append(PointsImportRow(
                 user_id=user.user_id,
diff --git a/mo/jobs/__init__.py b/mo/jobs/__init__.py
index 2566a295c0f5fa9f233e2b94cc126246f6fa00ed..eaf9782b892cd8781b3b0a3e987f198e07ae2439 100644
--- a/mo/jobs/__init__.py
+++ b/mo/jobs/__init__.py
@@ -110,7 +110,7 @@ class TheJob:
         sess = db.get_session()
         if not self.load() or self.job.state != db.JobState.ready:
             # Někdo ho mezitím smazal nebo vyřídil
-            logger.info(f'Job: Job #{self.job.job_id} vyřizuje někdo jiný')
+            logger.info(f'Job: Job #{self.job_id} vyřizuje někdo jiný')
             sess.rollback()
             return
 
diff --git a/mo/score.py b/mo/score.py
index ccbefbf0909b9c72a216bc2855a1ce85a3cd42fb..01a26d786d8157cb36364e19f69d24ace9abf42c 100644
--- a/mo/score.py
+++ b/mo/score.py
@@ -1,3 +1,4 @@
+import decimal
 from fractions import Fraction
 from sqlalchemy import and_
 from sqlalchemy.orm import joinedload
@@ -56,8 +57,8 @@ class ScoreResult:
     def get_sols_map(self) -> Dict[int, db.Solution]:
         return self._sols[0]
 
-    def get_total_points(self) -> int:
-        sum = 0
+    def get_total_points(self) -> decimal.Decimal:
+        sum = decimal.Decimal(0)
         for sol in self.get_sols():
             if sol.points:
                 sum += sol.points
@@ -67,17 +68,17 @@ class ScoreResult:
 class ScoreTask:
     task: db.Task
     num_solutions: int
-    sum_points: int
+    sum_points: decimal.Decimal
 
     def __init__(self, task: db.Task):
         self.task = task
         self.num_solutions = 0
-        self.sum_points = 0
+        self.sum_points = decimal.Decimal(0)
 
     def get_difficulty(self) -> Fraction:
         if self.num_solutions == 0:
             return Fraction(0)
-        return Fraction(self.sum_points, self.num_solutions)
+        return Fraction(Fraction(self.sum_points), self.num_solutions)
 
     def get_difficulty_str(self) -> str:
         return f'{self.sum_points}/{self.num_solutions}'
diff --git a/mo/submit.py b/mo/submit.py
index ac46677b87b98a11dc35ca9d8068d370b0d3885d..10ff0f6330bcb36ee3b4d9897f30091a3a67dc1a 100644
--- a/mo/submit.py
+++ b/mo/submit.py
@@ -92,11 +92,11 @@ class Submitter:
 
         if 'error' in result:
             logger.info('Submit: PDF error: %s', result['error'])
-            if result['pdf-like'] and allow_broken:
+            if result.get('pdf-like', False) and allow_broken:
                 logger.info('Submit: Soubor akceptován s varováním')
                 broken = True
             else:
-                raise SubmitException('Soubor není korektní PDF.')
+                raise SubmitException(result.get('user-error', 'Soubor není korektní PDF.'))
         else:
             paper.pages = result['pages']
             broken = False
@@ -114,6 +114,9 @@ class Submitter:
         except pikepdf.PdfError as e:
             result['error'] = str(e)
             result['pdf-like'] = Submitter._looks_like_pdf(tmpfile)
+        except pikepdf.PasswordError:
+            result['error'] = 'Soubor je chráněný heslem'
+            result['user-error'] = 'Soubor je chráněný heslem'
         pipe.send(result)
 
     @staticmethod
diff --git a/mo/users.py b/mo/users.py
index 0bbbb587ba07ef0a4b67e0160a331f54d08b4525..503276570d83c2962f82a7b67b8b28be1192d716 100644
--- a/mo/users.py
+++ b/mo/users.py
@@ -15,13 +15,17 @@ import mo.tokens
 
 def normalize_email(addr: str) -> str:
     if not re.fullmatch(r'.+@.+', addr):
-        raise mo.CheckError('Chybný formát mailové adresy')
+        raise mo.CheckError('V e-mailové adrese chybí zavináč')
 
     if re.search(r'[ \t]', addr):
         raise mo.CheckError('E-mailová adresa obsahuje mezeru')
 
-    if not re.fullmatch(r'[!-~]+', addr):
-        raise mo.CheckError('E-mailová adresa obsahuje znaky mimo ASCII')
+    m = re.search(r'[^!-~]+', addr)
+    if m:
+        if m[0].isprintable():
+            raise mo.CheckError(f'E-mailová adresa obsahuje nepovolené znaky: {m[0]}')
+        else:
+            raise mo.CheckError('E-mailová adresa obsahuje netisknutelné znaky: '+repr(m[0]))
 
     try:
         # Tady úmyslně používáme knihovnu jen ke kontrole a ne k normalizaci,
diff --git a/mo/util.py b/mo/util.py
index 790db555fc28e51c1fd33ea05670008d1d48f283..a72c5bd6fdf34800d2c58501adec3d719c18aa32 100644
--- a/mo/util.py
+++ b/mo/util.py
@@ -2,6 +2,7 @@
 
 from dataclasses import dataclass
 import datetime
+import decimal
 import dateutil.tz
 import email.message
 import email.headerregistry
@@ -12,13 +13,14 @@ import re
 import secrets
 import subprocess
 import sys
-from typing import Any, Optional, NoReturn
+from typing import Any, Optional, NoReturn, Tuple
 import textwrap
 import urllib.parse
 
 import mo
 import mo.db as db
 import mo.config as config
+from mo.util_format import format_decimal
 
 # Uživatel, který se uvádí jako pachatel v databázovém logu
 current_log_user: Optional[db.User] = None
@@ -77,6 +79,10 @@ def send_user_email(user: db.User, subject: str, body: str) -> bool:
             addr_spec=user.email,
         )
     ]
+    msg['Reply-To'] = email.headerregistry.Address(
+        display_name='Správce OSMO',
+        addr_spec=config.MAIL_CONTACT,
+    )
     msg['Subject'] = 'OSMO – ' + subject
     msg['Date'] = datetime.datetime.now()
 
@@ -209,3 +215,35 @@ def normalize_grade(grade: str) -> int:
             return -1
     except ValueError:
         return -1
+
+
+def parse_points(
+    raw_points: str, for_task: Optional[db.Task] = None, for_round: Optional[db.Round] = None,
+) -> Tuple[Optional[decimal.Decimal], Optional[str]]:
+    """Naparsuje a zkontroluje body. Vrátí body (jako decimal.Decimal nebo None
+    při prázdných bodech) a případný error (None pokud nenastal, jinak text chyby)."""
+    if raw_points == "":
+        return None, None
+    try:
+        points = decimal.Decimal(raw_points.replace(',', '.'))
+    except decimal.InvalidOperation:
+        return 0, f"Hodnota '{raw_points}' není číslo"
+
+    return points, check_points(points, for_task, for_round)
+
+
+def check_points(points: decimal.Decimal, for_task: Optional[db.Task] = None, for_round: Optional[db.Round] = None) -> Optional[str]:
+    """Zkontroluje body. Pokud je vše ok, tak vrátí None, jinak vrátí text chyby."""
+    if points < 0:
+        return f'Nelze zadat záporné body (zadáno {format_decimal(points)})'
+    if for_task and for_task.max_points is not None and points > for_task.max_points:
+        return f'Maximální počet bodů za úlohu je {format_decimal(for_task.max_points)}, nelze zadat více (zadáno {format_decimal(points)})'
+    if for_round and (points % for_round.master.points_step) != 0:
+        points_step = for_round.master.points_step
+        if points_step == 1:
+            return f'Podle nastavení kola lze zadat pouze celé body (hodnota {points} je neplatná)'
+        elif points_step == 0.5:
+            return f'Podle nastavení kola nelze zadat body s libovolnými desetinami, pouze půlbody (hodnota {points} je neplatná)'
+        else:
+            return f'Podle nastavení kola zadat body jen s krokem {points_step} (hodnota {points} je neplatná)'
+    return None
diff --git a/mo/util_format.py b/mo/util_format.py
index 6ed6a48591fafb6b5f4f950e663dc06f78452286..7b20df1ca28b965a0c012909aa50377e20125c95 100644
--- a/mo/util_format.py
+++ b/mo/util_format.py
@@ -1,6 +1,7 @@
 # Utils that do not depend on any other in mo (to avoid circular dependency)
 
 from datetime import datetime
+import decimal
 from typing import Optional
 
 import mo
@@ -120,3 +121,12 @@ def data_size(bytes: int) -> str:
         return f'{bytes/(1<<20):.1f} MiB'
     else:
         return f'{bytes/(1<<10):.1f} KiB'
+
+
+def format_decimal(points: Optional[decimal.Decimal]) -> Optional[str]:
+    if points is None:
+        return None
+    elif points % 1 == 0:
+        return str(int(points))
+    else:
+        return str(points)
diff --git a/mo/web/jinja.py b/mo/web/jinja.py
index f16c2e5c4efec31f9bfc7c637e1655456936775e..9ccf3e5e419a69be102b08ac89fe07fa5a6c5c16 100644
--- a/mo/web/jinja.py
+++ b/mo/web/jinja.py
@@ -24,9 +24,11 @@ app.jinja_env.trim_blocks = True
 
 app.jinja_env.filters.update(timeformat=util_format.timeformat)
 app.jinja_env.filters.update(inflected=util_format.inflect_number)
+app.jinja_env.filters.update(inflected_by=util_format.inflect_by_number)
 app.jinja_env.filters.update(timedelta=util_format.timedelta)
 app.jinja_env.filters.update(time_and_timedelta=util_format.time_and_timedelta)
 app.jinja_env.filters.update(data_size=util_format.data_size)
+app.jinja_env.filters.update(decimal=util_format.format_decimal)
 
 # Exporty proměnných
 
diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py
index 367ef5368d8f63be704653522eb16d14a8cf7b5b..f9bdb12ca0b40f1696aa7399f6a881aa6413d81f 100644
--- a/mo/web/org_contest.py
+++ b/mo/web/org_contest.py
@@ -24,10 +24,9 @@ import mo.util
 from mo.util_format import inflect_number, inflect_by_number
 from mo.web import app
 import mo.web.util
-from mo.web.util import PagerForm
+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
 import wtforms.validators as validators
-from wtforms.fields.html5 import IntegerField
 from wtforms.widgets.html5 import NumberInput
 
 
@@ -89,7 +88,8 @@ class ParticipantsFilterForm(PagerForm):
 class ParticipantsActionForm(FlaskForm):
     action_on = wtforms.RadioField(
         "Provést akci na", validators=[validators.DataRequired()],
-        choices=[('all', 'všech vyfiltrovaných účastnících'), ('checked', 'označených účastnících')]
+        choices=[('all', 'všech vyfiltrovaných účastnících'), ('checked', 'zaškrtnutých účastnících')],
+        default='checked',
         # checkboxes are handled not through FlaskForm, see below
     )
 
@@ -480,7 +480,7 @@ def org_contest_list(id: int, site_id: Optional[int] = None):
             filter=filter, count=count, action_form=action_form,
         )
     else:
-        table = make_contestant_table(query)
+        table = make_contestant_table(query, is_export=True)
         return table.send_as(format)
 
 
@@ -535,7 +535,7 @@ def get_contestants_query(
     return query
 
 
-def make_contestant_table(query: Query, add_checkbox: bool = False, add_contest_column: bool = False):
+def make_contestant_table(query: Query, add_checkbox: bool = False, add_contest_column: bool = False, is_export: bool = False):
     ctants = query.all()
 
     rows: List[Row] = []
@@ -550,6 +550,7 @@ def make_contestant_table(query: Query, add_checkbox: bool = False, add_contest_
         rows.append(Row(
             keys={
                 'sort_key': u.sort_key(),
+                'user_id': u.user_id,
                 'first_name': cell_pion_link(u, pion.contest_id, u.first_name),
                 'last_name': cell_pion_link(u, pion.contest_id, u.last_name),
                 'email': cell_email_link(u),
@@ -567,11 +568,13 @@ def make_contestant_table(query: Query, add_checkbox: bool = False, add_contest_
 
     rows.sort(key=lambda r: r.keys['sort_key'])
 
-    cols: Sequence[Column] = contest_list_columns
+    cols: List[Column] = list(contest_list_columns)
     if add_checkbox:
-        cols = [Column(key='checkbox', name=' ', title=' ')] + list(cols)
+        cols = [Column(key='checkbox', name=' ', title=' ')] + cols
     if add_contest_column:
-        cols = list(cols) + [Column(key='region_code', name='kod_oblasti', title='Oblast')]
+        cols.append(Column(key='region_code', name='kod_oblasti', title='Oblast'))
+    if is_export:
+        cols.append(Column(key='user_id', name='user_id'))
 
     return Table(
         columns=cols,
@@ -680,8 +683,7 @@ def get_solution_context(contest_id: int, user_id: Optional[int], task_id: Optio
 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")
-    # Validátory k points budou přidány podle počtu maximálních bodů úlohy v org_submit_list
-    points = IntegerField('Body', description="Účastník po uzavření kola uvidí jen naposledy zadané body")
+    points = MODecimalField('Body', description="Účastník po uzavření kola uvidí jen naposledy zadané body", validators=[validators.Optional()])
     submit = wtforms.SubmitField('Uložit')
 
     file = flask_wtf.file.FileField("Soubor")
@@ -759,11 +761,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.validators = [
-        validators.Optional(),
-        validators.NumberRange(min=0, max=sc.task.max_points, message="Počet bodů musí být mezi %(min)s a %(max)s")
-    ]
-    form.points.widget = NumberInput(min=0, max=sc.task.max_points)  # min a max v HTML
+    form.points.widget = NumberInput(min=0, max=sc.task.max_points, step=sc.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:
@@ -788,6 +786,11 @@ def org_submit_list(contest_id: int, user_id: int, task_id: int, site_id: Option
         if (form.submit_sol.data or form.submit_fb.data) and form.file.data is None:
             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)
+            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):
             flash('Řešení založeno', 'success')
@@ -1026,17 +1029,13 @@ def org_contest_task(contest_id: int, task_id: int, site_id: Optional[int] = Non
             for _, sol in rows:
                 if sol is None:
                     continue
-                points = request.form.get(f"points_{sol.user_id}", type=int)
-                if points and points < 0:
-                    flash('Nelze zadat záporné body', 'danger')
-                    ok = False
-                    break
-                elif points and sc.task.max_points is not None and points > sc.task.max_points:
-                    flash(f'Maximální počet bodů za úlohu je {sc.task.max_points}, nelze zadat více', 'danger')
+
+                points, error = mo.util.parse_points(request.form.get(f"points_{sol.user_id}"), for_task=sc.task, for_round=sc.round)
+                if error:
+                    flash(f'{sol.user.first_name} {sol.user.last_name}: {error}', 'danger')
                     ok = False
-                    break
 
-                if points != sol.points:
+                if ok and points != sol.points:
                     # Save points
                     sol.points = points
                     sess.add(db.PointsHistory(
@@ -1434,7 +1433,7 @@ def org_contest_user(contest_id: int, user_id: int):
 
 
 class AdvanceForm(FlaskForm):
-    boundary = IntegerField(
+    boundary = MODecimalField(
         'Bodová hranice', render_kw={'autofocus': True},
         description="Postoupí všichni účastníci, kteří v minulém kole získali aspoň tolik bodů.",
         validators=[validators.InputRequired()]
diff --git a/mo/web/org_round.py b/mo/web/org_round.py
index 96b94c61c878b66ee0b828bd20545aaa07261e87..08864412158c168f58e4f9da01678caa96939e78 100644
--- a/mo/web/org_round.py
+++ b/mo/web/org_round.py
@@ -1,9 +1,12 @@
+import decimal
 from flask import render_template, g, redirect, url_for, flash, request
 import locale
+import flask_wtf.file
 from flask_wtf.form import FlaskForm
 import bleach
 from bleach.sanitizer import ALLOWED_TAGS
 import markdown
+import os
 from sqlalchemy import func
 from sqlalchemy.orm import joinedload
 from sqlalchemy.sql.functions import coalesce
@@ -11,7 +14,7 @@ from typing import Optional, Tuple
 import werkzeug.exceptions
 import wtforms
 from wtforms import validators, ValidationError
-from wtforms.fields.html5 import IntegerField
+from wtforms.widgets.html5 import NumberInput
 
 import mo
 import mo.db as db
@@ -19,6 +22,7 @@ import mo.imports
 from mo.rights import Right, RoundRights
 import mo.util
 from mo.web import app
+from mo.web.util import MODecimalField
 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
 
@@ -229,6 +233,7 @@ def org_round(id: int):
         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),
     )
 
 
@@ -238,19 +243,23 @@ class TaskEditForm(FlaskForm):
         validators.Regexp(r'^[A-Za-z0-9-]+$', message="Kód úlohy smí obsahovat jen nediakritická písmena, čísla a znak -"),
     ], render_kw={'autofocus': True})
     name = wtforms.StringField('Název úlohy')
-    max_points = IntegerField(
-        'Maximum bodů', validators=[validators.Optional()],
+    max_points = MODecimalField(
+        'Maximum bodů', validators=[validators.Optional(), validators.NumberRange(min=0)],
         description="Při nastavení maxima nelze udělit více bodů, pro zrušení uložte prázdnou hodnotu",
     )
     submit = wtforms.SubmitField('Uložit')
 
+    def __init__(self, points_step: decimal.Decimal, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        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):
     sess = db.get_session()
-    round, _, _ = get_round_rr(id, Right.manage_round, True)
+    round, master_round, _ = get_round_rr(id, Right.manage_round, True)
 
-    form = TaskEditForm()
+    form = TaskEditForm(master_round.points_step)
     if form.validate_on_submit():
         task = db.Task()
         task.round = round
@@ -280,14 +289,14 @@ def org_round_task_new(id: int):
 @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):
     sess = db.get_session()
-    round, _, _ = get_round_rr(id, Right.manage_round, True)
+    round, master_round, _ = get_round_rr(id, Right.manage_round, True)
 
     task = sess.query(db.Task).get(task_id)
     # FIXME: Check contest!
     if not task:
         raise werkzeug.exceptions.NotFound()
 
-    form = TaskEditForm(obj=task)
+    form = TaskEditForm(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
@@ -383,7 +392,7 @@ def org_round_list(id: int):
             filter=filter, count=count, action_form=action_form,
         )
     else:
-        table = make_contestant_table(query)
+        table = make_contestant_table(query, is_export=True)
         return table.send_as(format)
 
 
@@ -410,6 +419,8 @@ class MODateTimeField(wtforms.DateTimeField):
 
 
 class RoundEditForm(FlaskForm):
+    _for_round: Optional[db.Round] = None
+
     name = wtforms.StringField("Název", render_kw={'autofocus': True})
     state = wtforms.SelectField(
         "Stav kola", choices=db.RoundState.choices(), coerce=db.RoundState.coerce,
@@ -417,26 +428,34 @@ class RoundEditForm(FlaskForm):
     )
     # Only the desktop Firefox does not support datetime-local field nowadays,
     # other browsers does provide date and time picker UI :(
-    tasks_file = wtforms.StringField("Soubor se zadáním", description="Cesta k ručně uploadovanému souboru", filters=[lambda x: x or None])
     ct_tasks_start = MODateTimeField("Čas zveřejnění úloh pro účastníky", validators=[validators.Optional()])
     pr_tasks_start = MODateTimeField("Čas zveřejnění úloh pro dozor", validators=[validators.Optional()])
     ct_submit_end = MODateTimeField("Konec odevzdávání pro účastníky", validators=[validators.Optional()])
     pr_submit_end = MODateTimeField("Konec odevzdávání pro dozor", validators=[validators.Optional()])
     score_mode = wtforms.SelectField("Výsledková listina", choices=db.RoundScoreMode.choices(), coerce=db.RoundScoreMode.coerce)
-    score_winner_limit = IntegerField(
-        "Hranice bodů pro vítěze", validators=[validators.Optional()],
+    score_winner_limit = MODecimalField(
+        "Hranice bodů pro vítěze", validators=[validators.Optional(), validators.NumberRange(min=0)],
         description="Řešitelé s alespoň tolika body budou označeni za vítěze, prázdná hodnota = žádné neoznačovat",
     )
-    score_successful_limit = IntegerField(
-        "Hranice bodů pro úspěšné řešitele", validators=[validators.Optional()],
+    score_successful_limit = MODecimalField(
+        "Hranice bodů pro úspěšné řešitele", validators=[validators.Optional(), validators.NumberRange(min=0)],
         description="Řešitelé s alespoň tolika body budou označeni za úspěšné řešitele, prázdná hodnota = žádné neoznačovat",
     )
+    points_step = wtforms.SelectField(
+        "Přesnost bodování", choices=db.round_points_step_choices,
+        description="Ovlivňuje možnost zadávání nových bodů, již uložené body nezmění"
+    )
     has_messages = wtforms.BooleanField("Zprávičky pro účastníky (aktivuje možnost vytvářet novinky zobrazované účastníkům)")
     submit = wtforms.SubmitField('Uložit')
 
     def validate_state(self, field):
-        if field.data != db.RoundState.preparing and self.ct_tasks_start.data is None:
-            raise ValidationError('Není-li nastaven času začátku soutěže, stav musí být "připravuje se"')
+        if field.data != db.RoundState.preparing:
+            if self.ct_tasks_start.data is None:
+                raise ValidationError('Není-li nastaven času začátku soutěže, stav musí být "připravuje se"')
+            if self._for_round is not None:
+                num_tasks = db.get_session().query(db.Task).filter_by(round=self._for_round).count()
+                if num_tasks == 0:
+                    raise ValidationError('Nejsou-li definovány žádné úlohy, stav musí být "připravuje se"')
 
 
 @app.route('/org/contest/r/<int:id>/edit', methods=('GET', 'POST'))
@@ -445,11 +464,13 @@ def org_round_edit(id: int):
     round, _, rr = get_round_rr(id, Right.manage_round, True)
 
     form = RoundEditForm(obj=round)
+    form._for_round = round
     if round.is_subround():
         # podkolo nemá nastavení výsledkové listiny
         del form.score_mode
         del form.score_winner_limit
         del form.score_successful_limit
+        del form.points_step
     if form.validate_on_submit():
         form.populate_obj(round)
 
@@ -500,6 +521,61 @@ def org_task_statement(id: int):
     return mo.web.util.send_task_statement(round)
 
 
+class StatementEditForm(FlaskForm):
+    file = flask_wtf.file.FileField("Soubor", render_kw={'autofocus': True})
+    upload = wtforms.SubmitField('Nahrát')
+    delete = wtforms.SubmitField('Smazat')
+
+
+@app.route('/org/contest/r/<int:id>/task-statement/edit', methods=('GET', 'POST'))
+def org_edit_statement(id: int):
+    sess = db.get_session()
+    round, _, rr = get_round_rr(id, Right.manage_round, True)
+
+    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}")
+            mo.util.log(
+                type=db.LogType.round,
+                what=id,
+                details={'action': 'edit', 'changes': changes},
+            )
+
+    form = StatementEditForm()
+    if form.validate_on_submit():
+        if form.upload.data:
+            if form.file.data is not None:
+                file = form.file.data.stream
+                secure_category = werkzeug.utils.secure_filename(round.category)
+                stmt_dir = f'{round.year}-{secure_category}-{round.seq}'
+                full_dir = os.path.join(mo.util.data_dir('statements'), stmt_dir)
+                os.makedirs(full_dir, exist_ok=True)
+                full_name = mo.util.link_to_dir(file.name, full_dir, suffix='.pdf')
+                file_name = os.path.join(stmt_dir, os.path.basename(full_name))
+                app.logger.info(f'Nahráno zadání: {file_name}')
+
+                round.tasks_file = file_name
+                log_changes()
+                sess.commit()
+                flash('Zadání nahráno', 'success')
+                return redirect(url_for('org_round', id=id))
+            else:
+                flash('Vyberte si prosím soubor', 'danger')
+        if form.delete.data:
+            round.tasks_file = None
+            log_changes()
+            sess.commit()
+            flash('Zadání smazáno', 'success')
+            return redirect(url_for('org_round', id=id))
+
+    return render_template(
+        'org_edit_statement.html',
+        round=round,
+        form=form,
+    )
+
+
 class MessageAddForm(FlaskForm):
     title = wtforms.StringField('Nadpis', validators=[validators.Required()])
     markdown = wtforms.TextAreaField(
diff --git a/mo/web/org_score.py b/mo/web/org_score.py
index cbebbcda83cdf883faccb1a583864d98a2ac1204..02c5e1cd5b25495be2ec2f02f074ee2123365920 100644
--- a/mo/web/org_score.py
+++ b/mo/web/org_score.py
@@ -10,6 +10,7 @@ from mo.rights import Right
 from mo.score import Score
 from mo.web import app
 from mo.web.table import Cell, CellLink, Column, Row, Table, cell_pion_link
+from mo.util_format import format_decimal
 
 
 class OrderCell(Cell):
@@ -52,15 +53,15 @@ class SolPointsCell(Cell):
             return '–'
         elif self.sol.points is None:
             return '?'
-        return str(self.sol.points)
+        return format_decimal(self.sol.points)
 
     def to_html(self) -> str:
         if not self.sol:
             return '<td>–'
         elif self.sol.points is None:
-            points = '<span class="unknown">?</span></a>'
+            points = '<span class="unknown">?</span>'
         else:
-            points = str(self.sol.points)
+            points = format_decimal(self.sol.points)
 
         if self.sol.final_feedback_obj:
             url = mo.web.util.org_paper_link(self.contest_id, None, self.user, self.sol.final_feedback_obj)
@@ -180,7 +181,7 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None):
             'pion_place':   pion.place.name,
             'school':       CellLink(school.name or "?", url_for('org_place', id=school.place_id)),
             'grade':        pant.grade,
-            'total_points': result.get_total_points(),
+            'total_points': format_decimal(result.get_total_points()),
             'birth_year':   pant.birth_year,
             'order_key':    result._order_key,
         })
diff --git a/mo/web/templates/org_contest.html b/mo/web/templates/org_contest.html
index 203a7ddc8f9075ad185ef850aef8f8591174351c..36eb3b62772f8e11c69e1632c2796e9c2bfc361b 100644
--- a/mo/web/templates/org_contest.html
+++ b/mo/web/templates/org_contest.html
@@ -60,7 +60,7 @@
 	<a class="btn btn-default" href='{{ url_for('org_contest_import', id=contest.contest_id) }}'>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) }}'>Editovat nastavení</a>
+	<a class="btn btn-default" href='{{ url_for('org_contest_edit', id=contest.contest_id) }}'>Nastavení</a>
 	{% endif %}
 	{% if g.user.is_admin %}
 	<a class="btn btn-default" href="{{ log_url('contest', contest.contest_id) }}">Historie</a>
@@ -111,7 +111,7 @@
 		<td>{{ task.code }}
 		<td>{{ task.name }}
 		<td>{{ task.sol_count }}
-		<td>{{ task.max_points|none_value('–') }}
+		<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>
 			{% if not site and can_edit_points %}
diff --git a/mo/web/templates/org_contest_advance.html b/mo/web/templates/org_contest_advance.html
index f7a86b84908938777a2f9327a3e063d4fc28c6b7..3bea35e52ec7000651dd1ad460b0cb18cec97a70 100644
--- a/mo/web/templates/org_contest_advance.html
+++ b/mo/web/templates/org_contest_advance.html
@@ -21,19 +21,21 @@
 
 <table class='data'>
 	<thead>
-		<tr><th>Oblast<th>Postoupilo<th>Nepostoupilo
+		<tr><th>Oblast<th>Postoupilo<th>Nepostoupilo<th>
 	<tbody>
 		{% for c in prev_contests %}
 		<tr>
-			<td>{{ c.place.name }}
+			<td><a href='{{ url_for('org_contest', 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>
 		{% endfor %}
 	<tfoot>
 		<tr>
 			<th>Celkem
 			<th>{{ accept_by_place_id.values()|sum }}
 			<th>{{ reject_by_place_id.values()|sum }}
+			<th>
 	</tfoot>
 </table>
 {% endif %}
diff --git a/mo/web/templates/org_contest_solutions.html b/mo/web/templates/org_contest_solutions.html
index 0f24a7fb0abacdb2c23f26f7eab3893324030233..99f04c1e347b98721d57dcc77746a0fdbf6997cf 100644
--- a/mo/web/templates/org_contest_solutions.html
+++ b/mo/web/templates/org_contest_solutions.html
@@ -93,7 +93,7 @@ konkrétní úlohu. Symbol <span class="icon">🗐</span> značí, že existuje
 				{% endif %}
 				<td class="sol">
 					{% if sol.points is not none %}
-						{{ sol.points }}
+						{{ sol.points|decimal }}
 						{% if sum_points.append(sol.points) %}{% endif %}
 					{% else %}
 						<span class="unknown">?</span>
@@ -111,7 +111,7 @@ konkrétní úlohu. Symbol <span class="icon">🗐</span> značí, že existuje
 			{% 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>
 		{% endfor %}
-		<th>{{ sum_points|sum }}</th>
+		<th>{{ sum_points|sum|decimal }}</th>
 	</tr>
 	{% endfor %}
 	<tfoot>
diff --git a/mo/web/templates/org_contest_user.html b/mo/web/templates/org_contest_user.html
index f4a4944e46447e869d5fd3357d29cea9fa167447..c0420489eaa4faa094e9cdcb0039a012d132603c 100644
--- a/mo/web/templates/org_contest_user.html
+++ b/mo/web/templates/org_contest_user.html
@@ -36,7 +36,7 @@
 	<tr><td>Škola:<td><a href='{{ url_for('org_place', id=pant.school) }}'>{{ pant.school_place.name }}</a>
 	<tr><td>Třída:<td>{{ pant.grade }}
 	<tr><td>Rok narození:<td>{{ pant.birth_year }}
-	<tr><td>Poznámka:<td style="white-space: pre;">{{ user.note }}
+	<tr><td>Poznámka:<td style="white-space: pre-line;">{{ user.note }}
 	<thead>
 			<tr><th colspan='2'>Účast v kole
 	</thead>
diff --git a/mo/web/templates/org_edit_statement.html b/mo/web/templates/org_edit_statement.html
new file mode 100644
index 0000000000000000000000000000000000000000..3f5a3cf06d560096db40821cc50e27b9ac22cfb6
--- /dev/null
+++ b/mo/web/templates/org_edit_statement.html
@@ -0,0 +1,12 @@
+{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+
+{% block title %}Zadání kola {{ round.round_code() }}{% endblock %}
+{% block breadcrumbs %}
+{{ contest_breadcrumbs(round=round, action="Zadáni") }}
+{% endblock %}
+{% block body %}
+
+{{ wtf.quick_form(form, form_type='horizontal', button_map={'upload': 'primary', 'delete': 'danger'}) }}
+
+{% endblock %}
diff --git a/mo/web/templates/org_generic_batch_points.html b/mo/web/templates/org_generic_batch_points.html
index e3c728b385f6e650c5edbdc4eac166d2092866c0..074f4265deee32e6383d322e8bfc8b86ae49091b 100644
--- a/mo/web/templates/org_generic_batch_points.html
+++ b/mo/web/templates/org_generic_batch_points.html
@@ -31,6 +31,7 @@
 <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é.
+{% if round.points_step < 1 %}Při zadávání desetinných bodů můžete použít desetinnou tečku i čárku.{% endif %}
 
 {{ wtf.quick_form(form, form_type='simple', button_map={'submit': 'primary'}) }}
 
diff --git a/mo/web/templates/org_org.html b/mo/web/templates/org_org.html
index f9b25110c6bbb99209ebd3f5647134d148ca17ac..4cdbe0bc68203db7f8f4a5c0841f8dae40188ddc 100644
--- a/mo/web/templates/org_org.html
+++ b/mo/web/templates/org_org.html
@@ -12,7 +12,7 @@
 <tr><td>Účet založen:<td>{{ user.created_at|timeformat }}
 <tr><td>Poslední přihlášení:<td>{{ user.last_login_at|timeformat }}
 {% if user.reset_at %}<tr><td>Reset hesla:<td>{{ user.reset_at|timeformat }}{% endif %}
-<tr><td>Poznámka:<td style="white-space: pre;">{{ user.note }}
+<tr><td>Poznámka:<td style="white-space: pre-line;">{{ user.note }}
 </table>
 
 <div class="btn-group" role="group">
diff --git a/mo/web/templates/org_round.html b/mo/web/templates/org_round.html
index aa1a62a677497f02ed0459b0d5b7c2edb15e8b05..17637aeaae19e82a285d533061e2d7b8c4dec380 100644
--- a/mo/web/templates/org_round.html
+++ b/mo/web/templates/org_round.html
@@ -37,7 +37,7 @@
 </table>
 <table class=data style="float: left;">
 	<thead>
-		<tr><th colspan=2>Termíny
+		<tr><th colspan=2>Termíny a zadání
 	</thead>
 	<tr><td>Účastníci vidí zadání od<td>{{ round.ct_tasks_start|time_and_timedelta }}
 	<tr><td>Účastníci odevzdávají do<td>{{ round.ct_submit_end|time_and_timedelta }}
@@ -45,7 +45,9 @@
 	<tr><td>Dozor odevzdává do<td>{{ round.pr_submit_end|time_and_timedelta }}
 	<tr><td>Zadání<td>
 {% if round.tasks_file %}
-	{% if can_view_statement %}
+	{% 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>
 	{% else %}
 		není dostupné
@@ -59,8 +61,9 @@
 		{% endif %}
 	</thead>
 	<tr><td>Výsledková listina<td>{{ round.master.score_mode.friendly_name() }}
-	<tr><td>Hranice bodů pro vítěze<td>{{ round.master.score_winner_limit|none_value(Markup('<i>nenastaveno</i>')) }}
-	<tr><td>Hranice bodů pro úspěšné řešitele<td>{{ round.master.score_successful_limit|none_value(Markup('<i>nenastaveno</i>')) }}
+	<tr><td>Hranice bodů pro vítěze<td>{{ round.master.score_winner_limit|decimal|none_value(Markup('<i>nenastaveno</i>')) }}
+	<tr><td>Hranice bodů pro úspěšné řešitele<td>{{ round.master.score_successful_limit|decimal|none_value(Markup('<i>nenastaveno</i>')) }}
+	<tr><td>Přesnost bodování<td>{{ round.points_step_name() }}
 </table>
 <div style="clear: both;"></div>
 
@@ -73,7 +76,8 @@
 	<a class="btn btn-default" href='{{ url_for('org_round_import', id=round.round_id) }}'>Importovat data</a>
 	{% endif %}
 	{% if can_manage_round %}
-	<a class="btn btn-default" href='{{ url_for('org_round_edit', id=round.round_id) }}'>Editovat nastavení a termíny</a>
+	<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>
 	{% endif %}
 	{% if round.has_messages %}
 	<a class="btn btn-default" href='{{ url_for('org_round_messages', id=round.round_id) }}'>Zprávičky</a>
@@ -139,7 +143,7 @@
 			<td>{{ task.code }}
 			<td>{{ task.name }}
 			<td>{{ task.sol_count }}
-			<td>{{ task.max_points|none_value('–') }}
+			<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>
diff --git a/mo/web/templates/org_score.html b/mo/web/templates/org_score.html
index 21740346c8516b19cb4868f69edb124a0b5cbc34..8220ad36f33b6e58fd839288f2ad6dd5c915b3f7 100644
--- a/mo/web/templates/org_score.html
+++ b/mo/web/templates/org_score.html
@@ -55,10 +55,10 @@ Rozkliknutím bodů se lze dostat na detail daného řešení.</p>
 {% if master.score_winner_limit is not none or master.score_successful_limit is not none %}
 <p>
 {% if master.score_winner_limit is not none %}
-<b>Vítězi</b> se stávají účastníci s alespoň <b>{{ master.score_winner_limit|inflected("bodem", "body", "body") }}</b>.
+<b>Vítězi</b> se stávají účastníci s alespoň <b>{{ master.score_winner_limit|decimal }} {{ master.score_winner_limit|inflected_by("bodem", "body", "body") }}</b>.
 {% endif %}
 {% if master.score_successful_limit is not none %}
-<b>Úspěšnými řešiteli</b> se stávají účastníci s alespoň <b>{{ master.score_successful_limit|inflected("bodem", "body", "body") }}</b>.
+<b>Úspěšnými řešiteli</b> se stávají účastníci s alespoň <b>{{ master.score_successful_limit|decimal }} {{ master.score_successful_limit|inflected_by("bodem", "body", "body") }}</b>.
 {% endif %}
 {% endif %}
 
diff --git a/mo/web/templates/org_submit_list.html b/mo/web/templates/org_submit_list.html
index 49f9b8f4cd800c7084b8120ff6e9d430ba323164..de2b4e61978bcfa29e4a95b554faf75390612d1d 100644
--- a/mo/web/templates/org_submit_list.html
+++ b/mo/web/templates/org_submit_list.html
@@ -1,6 +1,6 @@
 {% extends "base.html" %}
 {% import "bootstrap/wtf.html" as wtf %}
-{% block title %}Řešení {{ sc.task.code }} {{ sc.task.name }} &ndash; {{ sc.user.full_name() }}{% endblock %}
+{% block title %}{{ sc.user.full_name() }} – řešení úlohy {{ sc.task.code }} {{ sc.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í") }}
 {% endblock %}
@@ -13,12 +13,12 @@
 	<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>
 	{% if solution %}
 	<tr><th>Body<td>
-		{% if solution.points is not none %}{{solution.points}}{% else %}<span class="unknown">?</span>{% endif %}
-		{% if sc.task.max_points is not none %}<span class="hint"> / {{ sc.task.max_points }}</span>{% endif %}
+		{{ 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 %}
 	<tr title="Viditelná účastníkovi po uzavření kola">
-		<th>Poznámka k řešení:<td style="white-space: pre;">{{ solution.note|or_dash }}</td>
+		<th>Poznámka k řešení:<td style="white-space: pre-line;">{{ solution.note|or_dash }}</td>
 	<tr title="Viditelná jen organizátorům">
-		<th>Interní poznámka:<td style="white-space: pre;">{{ solution.org_note|or_dash }}</td>
+		<th>Interní poznámka:<td style="white-space: pre-line;">{{ solution.org_note|or_dash }}</td>
 	{% endif %}
 </table>
 
@@ -153,7 +153,7 @@ Existuje více než jedna verze oprav, finální je podbarvená.
 	{% for p in points_history %}
 	<tr {% if loop.index == 1 %} class='sol-active'{% endif %}>
 		<td>{{ p.points_at|timeformat }}
-		<td>{% if p.points is not none %}{{ p.points }}{% else %}–{% endif %}
+		<td>{{ p.points|decimal|none_value('–') }}
 		<td>{{ p.user|user_link }}
 	</tr>
 	{% endfor %}
diff --git a/mo/web/templates/org_user.html b/mo/web/templates/org_user.html
index 51d9027b4efec843b05a47a408c6ca3f0060d905..839868169385401f09ddd1475a36892949a86c1c 100644
--- a/mo/web/templates/org_user.html
+++ b/mo/web/templates/org_user.html
@@ -12,7 +12,7 @@
 <tr><td>Účet založen:<td>{{ user.created_at|timeformat }}
 <tr><td>Poslední přihlášení:{% if user.last_login_at %}<td>{{ user.last_login_at|timeformat }}{% else %}<td class="error"><i>Zatím nepřihlášen</i>{% endif %}
 {% if user.reset_at %}<tr><td>Reset hesla:<td>{{ user.reset_at|timeformat }}{% endif %}
-<tr><td>Poznámka:<td style="white-space: pre;">{{ user.note }}
+<tr><td>Poznámka:<td style="white-space: pre-line;">{{ user.note }}
 </table>
 
 <div class="btn-group" role="group">
diff --git a/mo/web/templates/org_user_edit.html b/mo/web/templates/org_user_edit.html
index c4e9308c16d2dfea137c21e3ac2c1c33d4c8cd36..1930c9abd4ac0c848efd86d52c18577a4fa82c35 100644
--- a/mo/web/templates/org_user_edit.html
+++ b/mo/web/templates/org_user_edit.html
@@ -9,7 +9,7 @@
 <tr><td>E-mail:</td><td>{{ user.email|mailto }}</td></tr>
 {% if user.is_admin %}<tr><td>Správce:</td><td>ano</td></tr>{% endif %}
 {% if user.is_org %}<tr><td>Organizátor:</td><td>ano</td></tr>{% endif %}
-<tr><td>Poznámka:</td><td style="white-space: pre;">{{ user.note }}</td></tr>
+<tr><td>Poznámka:</td><td style="white-space: pre-line;">{{ user.note }}</td></tr>
 </table>
 
 <a href='{% if is_org %}{{ url_for('org_org', id=user.user_id) }}{% else %}{{ url_for('org_user', id=user.user_id) }}{% endif %}'>Zpět na detail</a>
diff --git a/mo/web/templates/parts/org_participants_table_actions.html b/mo/web/templates/parts/org_participants_table_actions.html
index 9838f8348e226f31e1775771f141005dbe8bb811..1e27b5f97e2ea0c4b696bf50d0bcc7385e72521e 100644
--- a/mo/web/templates/parts/org_participants_table_actions.html
+++ b/mo/web/templates/parts/org_participants_table_actions.html
@@ -23,8 +23,8 @@
 			</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 == 'all' %} checked{% endif %}>
-				označených účastnících
+				<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>
diff --git a/mo/web/templates/parts/org_solution_table.html b/mo/web/templates/parts/org_solution_table.html
index aa7f0df1b108f9a11db11947d9d9735d7309fdc3..6223a92c5a84f2968b5c654c19302788949d248d 100644
--- a/mo/web/templates/parts/org_solution_table.html
+++ b/mo/web/templates/parts/org_solution_table.html
@@ -86,10 +86,15 @@ finální (ve výchozím stavu poslední nahrané).{% elif sc.allow_upload_solut
 			{% if sol.org_note %} <span class="icon" title="Interní poznámka: {{ sol.org_note }}">🗩</span>{% endif %}
 		<td>
 			{% if points_form %}
-				<input type="number" min=0 {% if task.max_points is not none %}max={{ task.max_points }}{% endif %} class="form-control" name="points_{{u.user_id}}" value="{{ request_form.get("points_{}".format(u.user_id)) or sol.points }}" size="4" tabindex={{ tabindex.value }} autofocus>
-				{% set tabindex.value = tabindex.value + 1%}
+				<input
+					type="number" class="form-control" name="points_{{u.user_id}}"
+					min=0 {% if task.max_points is not none %}max={{ task.max_points }}{% endif %}
+					step="{{ round.points_step }}"
+					value="{{ request_form.get("points_{}".format(u.user_id))|none_value(sol.points|decimal) }}"
+					size="4" tabindex={{ tabindex.value }} autofocus
+				>
 			{% else %}
-				{% if sol.points is not none %}{{ sol.points}}{% else %}<span class="unknown">?</span>{% endif %}
+				{{ sol.points|decimal|none_value(Markup('<span class="unknown">?</span>')) }}
 			{% endif %}
 		{% else %}
 		<td colspan="4" class="text-center">
diff --git a/mo/web/templates/user_contest_task.html b/mo/web/templates/user_contest_task.html
index 32308295ab3757e3044308856810e9b2b888b6cc..acb0086de6868f64c8227b524c28f36b87e5d43a 100644
--- a/mo/web/templates/user_contest_task.html
+++ b/mo/web/templates/user_contest_task.html
@@ -50,7 +50,7 @@
 		({{ sol.final_feedback_obj.pages|inflected('stránka', 'stránky', 'stránek') }}, {{ sol.final_feedback_obj.bytes }} bajtů)
 	{% endif %}
 	{% if sol.note %}
-	<tr><th>Poznámka od organizátorů:<td style="white-space: pre;">{{ sol.note }}
+	<tr><th>Poznámka od organizátorů:<td style="white-space: pre-line;">{{ sol.note }}
 	{% endif %}
 </table>
 {% else %}
diff --git a/mo/web/util.py b/mo/web/util.py
index 40e6ecd8f296dc69799ab6df69a0cec8f6209379..4f10a68d52fc20246453ea9ddf7b88a9f8a88ba5 100644
--- a/mo/web/util.py
+++ b/mo/web/util.py
@@ -1,3 +1,4 @@
+import decimal
 from flask import Response, send_file, url_for
 from flask_wtf import FlaskForm
 import os
@@ -7,6 +8,7 @@ import unicodedata
 import werkzeug.exceptions
 import werkzeug.utils
 import wtforms
+from wtforms.fields.html5 import DecimalField
 
 import mo.db as db
 import mo.jobs
@@ -40,6 +42,13 @@ class PagerForm(FlaskForm):
         return (count, query)
 
 
+def task_statement_exists(round: db.Round) -> bool:
+    if round.tasks_file is None:
+        return False
+    file = os.path.join(mo.util.data_dir('statements'), round.tasks_file)
+    return os.path.isfile(file)
+
+
 def send_task_statement(round: db.Round) -> Response:
     assert round.tasks_file is not None
     file = os.path.join(mo.util.data_dir('statements'), round.tasks_file)
@@ -119,3 +128,20 @@ def send_job_result(job: db.Job) -> Response:
     else:
         logger.error(f'Soubor {file} je výsledkem jobu, ale ve FS neexistuje')
         raise werkzeug.exceptions.NotFound()
+
+
+class MODecimalField(DecimalField):
+    """Upravený DecimalField, který formátuje číslo podle jeho skutečného počtu
+    desetinných míst a zadané `places` používá jen jako maximální počet desetinných míst."""
+    def _value(self):
+        if self.data is not None:
+            # Spočítání počtu desetinných míst, zbytek necháme na původní implementaci
+            max_places = self.places
+            self.places = 0
+            d = decimal.Decimal(1)
+
+            while self.data % d != 0 and self.places < max_places:
+                self.places += 1
+                d /= 10
+
+        return super(MODecimalField, self)._value()