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/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 fd8d1264f2bd23878f6fc31947a00e6edf41f780..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):
@@ -214,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')
 
@@ -222,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
 
@@ -716,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(
@@ -736,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/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/util.py b/mo/util.py
index 790db555fc28e51c1fd33ea05670008d1d48f283..391097af852b5014aed763530dd1fd30e2e1e6ee 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
@@ -209,3 +211,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 5f9744f31ce0e1fed37d4620be1253721e59d611..d5710d507b53dac571933d68b1965de446b4e4d0 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
 
 
@@ -681,8 +680,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")
@@ -760,11 +758,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:
@@ -789,6 +783,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')
@@ -1027,17 +1026,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(
@@ -1435,7 +1430,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..8d8b16ff8c50e32b1bea8648aeb20a096826f7d5 100644
--- a/mo/web/org_round.py
+++ b/mo/web/org_round.py
@@ -1,3 +1,4 @@
+import decimal
 from flask import render_template, g, redirect, url_for, flash, request
 import locale
 from flask_wtf.form import FlaskForm
@@ -11,7 +12,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 +20,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
 
@@ -238,19 +240,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 +286,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
@@ -423,14 +429,18 @@ class RoundEditForm(FlaskForm):
     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')
 
@@ -450,6 +460,7 @@ def org_round_edit(id: int):
         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)
 
diff --git a/mo/web/org_score.py b/mo/web/org_score.py
index 82540beac2145cd0f9b7cb2e0c9f04d003b2f6c5..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,7 +53,7 @@ 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:
@@ -60,7 +61,7 @@ class SolPointsCell(Cell):
         elif self.sol.points is None:
             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 3cdfaf74bce212d18a0658306b1e1ef32aad6d55..36eb3b62772f8e11c69e1632c2796e9c2bfc361b 100644
--- a/mo/web/templates/org_contest.html
+++ b/mo/web/templates/org_contest.html
@@ -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_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_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_round.html b/mo/web/templates/org_round.html
index fd14ad4c3019e683405e08eef39f4613b9057359..83aebd0afd2136206d826303af70daf091be066b 100644
--- a/mo/web/templates/org_round.html
+++ b/mo/web/templates/org_round.html
@@ -59,8 +59,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>
 
@@ -139,7 +140,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 e6b1d7b066232b290bbc33c5ff185cb910ef4dcf..23be7e69516b006f47d87adce99589171296e297 100644
--- a/mo/web/templates/org_submit_list.html
+++ b/mo/web/templates/org_submit_list.html
@@ -13,8 +13,8 @@
 	<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-line;">{{ solution.note|or_dash }}</td>
 	<tr title="Viditelná jen organizátorům">
@@ -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/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/util.py b/mo/web/util.py
index 40e6ecd8f296dc69799ab6df69a0cec8f6209379..482e0de8b388cff99da6f2cae73bed6ec7b760d0 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
@@ -119,3 +121,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()