diff --git a/db/db.ddl b/db/db.ddl
index 01b95a95bcd602355f97dd7647f0b8b77e79f0d3..ae71eae76c401b591c1141c55d4aa1514e736f58 100644
--- a/db/db.ddl
+++ b/db/db.ddl
@@ -83,6 +83,11 @@ CREATE TYPE round_state AS ENUM (
 	-- jen se odevzdaná řešení zobrazují jako opožděná.
 );
 
+CREATE TYPE score_mode AS ENUM (
+	'basic',			-- základní mód výsledkovky se sdílenými místy
+	'mo'				-- jednoznačné pořadí podle pravidel MO
+);
+
 CREATE TABLE rounds (
 	round_id	serial		PRIMARY KEY,
 	year		int		NOT NULL,			-- ročník MO
@@ -96,6 +101,9 @@ CREATE TABLE rounds (
 	ct_submit_end	timestamp with time zone	DEFAULT NULL,	-- do kdy účastníci mohou regulérně odevzdávat
 	pr_tasks_start	timestamp with time zone	DEFAULT NULL,	-- od kdy dozor vidí zadání
 	pr_submit_end	timestamp with time zone	DEFAULT NULL,	-- do kdy dozor může regulérně odevzdávat
+	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
 	UNIQUE (year, category, seq)
 );
 
@@ -238,7 +246,7 @@ CREATE TABLE log (
 	log_entry_id	serial		PRIMARY KEY,
 	changed_by	int		REFERENCES users(user_id),		-- kdo změnu provedl
 	changed_at	timestamp with time zone	NOT NULL DEFAULT CURRENT_TIMESTAMP,	-- a kdy
-	type		log_type	NOT NULL,	
+	type		log_type	NOT NULL,
 	id		int		NOT NULL,					-- jakého záznamu se změna týká
 	details		jsonb		NOT NULL					-- detaily (závislé na typu)
 );
diff --git a/db/upgrade-20210221.sql b/db/upgrade-20210221.sql
new file mode 100644
index 0000000000000000000000000000000000000000..6cc6ad17a9bb78e94a897aa4d41fec200330c58d
--- /dev/null
+++ b/db/upgrade-20210221.sql
@@ -0,0 +1,11 @@
+SET ROLE 'mo_osmo';
+
+CREATE TYPE score_mode AS ENUM (
+	'basic',			-- základní mód výsledkovky se sdílenými místy
+	'mo'				-- jednoznačné pořadí podle pravidel MO
+);
+
+ALTER TABLE rounds
+	ADD COLUMN	score_mode	score_mode	NOT NULL DEFAULT 'basic',	-- mód výsledkovky
+	ADD COLUMN	score_winner_limit	int	DEFAULT NULL,			-- bodový limit na označení za vítěze
+	ADD COLUMN	score_successful_limit	int	DEFAULT NULL;			-- bodový limit na označení za úspěšného řešitele
diff --git a/mo/db.py b/mo/db.py
index 19b573e1c0c4613f40484c7f47d508bd478a7d83..95184e96b0eb92f939fdc8199f61aa3407b3dc29 100644
--- a/mo/db.py
+++ b/mo/db.py
@@ -177,6 +177,20 @@ round_state_names = {
 }
 
 
+class RoundScoreMode(MOEnum):
+    basic = auto()
+    mo = auto()
+
+    def friendly_name(self) -> str:
+        return round_score_mode_names[self]
+
+
+round_score_mode_names = {
+    RoundScoreMode.basic: "Základní se sdílenými místy",
+    RoundScoreMode.mo: "Jednoznačné pořadí podle pravidel MO",
+}
+
+
 class Round(Base):
     __tablename__ = 'rounds'
     __table_args__ = (
@@ -195,6 +209,9 @@ class Round(Base):
     ct_submit_end = Column(DateTime(True))
     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)
 
     def round_code(self):
         return f"{self.year}-{self.category}-{self.seq}"
diff --git a/mo/score.py b/mo/score.py
new file mode 100644
index 0000000000000000000000000000000000000000..b7f4ed5d36fbf561fc29d520d02dd50f3a4e6448
--- /dev/null
+++ b/mo/score.py
@@ -0,0 +1,383 @@
+from fractions import Fraction
+from sqlalchemy import and_
+from sqlalchemy.orm import joinedload
+from sqlalchemy.orm.query import Query
+from sqlalchemy.sql.expression import select
+from typing import Any, List, Tuple, Optional, Dict
+
+import mo.db as db
+from mo.util import normalize_grade
+
+
+class ScoreOrder:
+    place: int
+    span: int  # u nedělených míst 1, u dělených počet spojených míst (2.-5. -> span 4)
+    continuation: bool  # jestli je pokračováním místa o jedno předcházející
+
+    def __init__(self, place: int, span: int = 1, continuation: bool = False):
+        self.place = place
+        self.span = span
+        self.continuation = continuation
+
+
+class ScoreResult:
+    user: db.User
+    pant: db.Participant
+    pion: db.Participation
+    order: ScoreOrder
+    successful: bool
+    winner: bool
+
+    # Řešení jednotlivých kol (pro některá řazení je potřeba znát i výsledky
+    # z předcházejících kol). První index je krok (0 = toto kolo, 1 = předcházející, ...)
+    # a druhý index je task_id z db.Solution.
+    _sols: Dict[int, Dict[int, db.Solution]]
+    # Třídící klíč je n-tice klíčů podle kterých třídit, všechny klíče tříděny
+    # vzestupně (pro sestupné třídění podle čísla je potřeba klíč vynásobit -1)
+    _order_key: List[Any]
+
+    def __init__(self, user: db.User, pant: db.Participant, pion: db.Participation):
+        self.user = user
+        self.pant = pant
+        self.pion = pion
+
+        self._sols = {}
+        self.order = 1
+        self.place_span = 1
+        self.place_continuation = False
+        self.winner = False
+        self.successful = False
+        self._order_key = []
+
+    def get_sols(self) -> List[db.Solution]:
+        return list(self._sols[0].values())
+
+    def get_sols_map(self) -> Dict[int, db.Solution]:
+        return self._sols[0]
+
+    def get_total_points(self) -> int:
+        sum = 0
+        for sol in self.get_sols():
+            if sol.points:
+                sum += sol.points
+        return sum
+
+
+class ScoreTask:
+    task: db.Task
+    num_solutions: int
+    sum_points: int
+
+    def __init__(self, task: db.Task):
+        self.task = task
+        self.num_solutions = 0
+        self.sum_points = 0
+
+    def get_difficulty(self) -> Fraction:
+        if self.num_solutions == 0:
+            return 0
+        return Fraction(self.sum_points, self.num_solutions)
+
+
+class Score:
+    round: db.Round
+    contest: Optional[db.Contest]
+    part_states: List[db.PartState]
+
+    # Řádky výsledkovky
+    _results: Dict[int, ScoreResult]
+    # Úlohy jednotlivých kol (pro některá řazení je potřeba znát i úlohy
+    # z předcházejících kol. První index je krok (0 = toto kolo, 1 = předcházející, ...)
+    # a druhý index je task_id z db.Task.
+    _tasks: Dict[int, Dict[int, ScoreTask]]
+    # Seznam předcházejících kol indexovaných krokem výpočtu (0 = toto kolo, 1 = předcházející, ...)
+    _prev_rounds: Dict[int, db.Round]
+
+    # Zprávy o tvorbě výsledkovky, dvojice (typ, zprávy) kde typ může být info, warning nebo error
+    _messages: List[Tuple[str, str]]
+
+    def __init__(
+        self, round: db.Round, contest: Optional[db.Contest] = None,
+        # Ze kterých stavů chceme výsledkovku počítat
+        part_states: List[db.PartState] = [db.PartState.registered, db.PartState.invited, db.PartState.present],
+    ):
+        self.round = round
+        self.contest = contest
+        self.part_states = part_states
+
+        # Příprava subquery na účastníky
+        sess = db.get_session()
+        if contest:
+            contest_subq = [contest.contest_id]
+        else:
+            contest_subq = sess.query(db.Contest.contest_id).filter_by(round=round)
+
+        # Načtení účastníků
+        data: List[Tuple[db.User, db.Participation, db.Participant]] = (
+            sess.query(db.User, db.Participation, db.Participant)
+            .select_from(db.Participation)
+            .join(db.User)
+            .join(db.Participant, and_(
+                    db.Participant.user_id == db.Participation.user_id,
+                    db.Participant.year == round.year
+                )
+            ).filter(
+                db.User.is_test == False,
+                db.Participation.state.in_(part_states),
+                db.Participation.contest_id.in_(contest_subq)
+            ).options(
+                joinedload(db.Participant.school_place),
+                joinedload(db.Participation.contest).joinedload(db.Contest.place),
+            ).all()
+        )
+        self._results = {}
+        for user, pion, pant in data:
+            self._results[user.user_id] = ScoreResult(user, pant, pion)
+
+        # Načtení úloh a řešení
+        self._prev_rounds = {0: round}
+        self._tasks = {}
+        self._messages = []
+        self._load_tasks_and_sols(0, round, contest_subq)
+        self._mark_winners()
+
+    def _load_tasks_and_sols(self, step: int, round: db.Round, contest_subq: Query):
+        """Obecná funkce na načtení úloh a řešení tohoto nebo předchozího kola"""
+        if step in self._tasks:
+            return
+        sess = db.get_session()
+
+        user_id_subq = sess.query(db.Participation.user_id).join(
+            db.User, db.Participation.user_id == db.User.user_id
+        ).filter(
+            db.User.is_test == False,
+            db.Participation.state.in_(self.part_states),
+            db.Participation.contest_id.in_(contest_subq)
+        )
+
+        # Vyrobení polí
+        self._tasks[step] = {}
+        for result in self._results.values():
+            result._sols[step] = {}
+
+        # Spočítání počtu řešitelů
+        num_participants = db.get_count(user_id_subq)
+
+        # Načtení úloh
+        tasks: List[db.Task] = sess.query(db.Task).filter_by(round=round).all()
+        for task in tasks:
+            self._tasks[step][task.task_id] = ScoreTask(task)
+            self._tasks[step][task.task_id].num_solutions = num_participants
+
+        # Načtení řešení
+        task_ids = list(self._tasks[step].keys())
+        sols: List[db.Solution] = sess.query(db.Solution).filter(
+            db.Solution.user_id.in_(user_id_subq),
+            db.Solution.task_id.in_(task_ids),
+        ).all()
+        for sol in sols:
+            if sol.user_id in self._results:
+                self._results[sol.user_id]._sols[step][sol.task_id] = sol
+            if sol.points:
+                self._tasks[step][sol.task_id].sum_points += sol.points
+
+    def _mark_winners(self):
+        for result in self._results.values():
+            total_points = result.get_total_points()
+            result.winner = (
+                self.round.score_winner_limit is not None
+                and total_points >= self.round.score_winner_limit
+            )
+            result.successful = (
+                self.round.score_successful_limit is not None
+                and total_points >= self.round.score_successful_limit
+            )
+
+    def _load_prev_round(self, step: int) -> bool:
+        """Načtení úloh a řešení předchozího kola, pokud takové existuje."""
+        if step in self._tasks:
+            return True
+        sess = db.get_session()
+
+        # Zkusíme nalézt kolo o `step` kroků zpět
+        prev_round = sess.query(db.Round).filter_by(
+            year=self.round.year, category=self.round.category, seq=self.round.seq - step
+        ).one_or_none()
+        if prev_round is None:
+            return False
+        self._prev_rounds[step] = prev_round
+
+        if self.contest:
+            # Pokud tvoříme výsledkovku pro contest, tak nás zajímají jen řešení
+            # z podoblastí contestu spadajícího pod hlavní
+            desc_cte = db.place_descendant_cte(self.contest.place, max_level=prev_round.level)
+            contest_subq = sess.query(db.Contest.contest_id).filter(
+                db.Contest.round == prev_round,
+                db.Contest.place_id.in_(select([desc_cte]))
+            )
+        else:
+            # Pokud vytváříme výsledkovku pro celé kolo, bereme vše
+            contest_subq = sess.query(db.Contest.contest_id).filter_by(round=prev_round)
+
+        self._load_tasks_and_sols(step, prev_round, contest_subq)
+        return True
+
+    def get_tasks(self) -> List[db.Task]:
+        tasks = []
+        for task in self._tasks[0].values():
+            tasks.append(task.task)
+        return list(sorted(tasks, key=lambda task: task.code))
+
+    def _add_message(self, type: str, message: str):
+        self._messages.append((type, message))
+
+    def get_messages(self) -> List[Tuple[str, str]]:
+        return self._messages
+
+    def get_sorted_results(self) -> List[ScoreResult]:
+        # Vygenerování třídícího klíče podle módu výsledkovky
+        if self.round.score_mode == db.RoundScoreMode.basic:
+            # Základní mód - jen podle celkových bodů, se sdílenými místy
+            for result in self._results.values():
+                result._order_key = [-result.get_total_points()]
+
+        elif self.round.score_mode == db.RoundScoreMode.mo:
+            self._add_mo_order_key()
+        else:
+            assert False
+
+        # Kvůli pevnému pořadí při sdílených místech přidáme k finálnímu třídění
+        # ještě i příjmení, jméno a user_id
+        results: List[ScoreResult] = sorted(self._results.values(), key=lambda result: (
+            result._order_key, result.user.last_name, result.user.first_name, result.user.user_id
+        ))
+        last: ScoreResult = None
+        # Spočítáme pořadí - v prvním kroku prolinkujeme opakující se ScoreOrder na první,
+        # ve druhém kroku je pak správně rozkopírujeme s nastaveným continuation na True
+        for result in results:
+            if last is None:
+                result.order = ScoreOrder(1)
+                last = result
+            elif last._order_key == result._order_key:
+                result.order = last.order
+                last.order.span += 1
+            else:
+                result.order = ScoreOrder(last.order.place + last.order.span)
+                last = result
+        lastOrder: ScoreOrder = None
+        for result in results:
+            if result.order == lastOrder:
+                result.order = ScoreOrder(lastOrder.place, lastOrder.span, True)
+            else:
+                lastOrder = result.order
+
+        return results
+
+    def _exists_same_order_key(self) -> bool:
+        last = None
+        for result in sorted(self._results.values(), key=lambda result: result._order_key):
+            if result._order_key == last:
+                return True
+            last = result._order_key
+        return False
+
+    def _add_mo_order_key(self):
+        """Jednoznačný mód MO, iterujeme po krocích dokud buď nevyrobíme
+        výsledkovku bez sdílených míst, nebo už neexistuje předchozí kolo,
+        na které bychom se ještě mohli podívat."""
+        step = 0
+        while True:
+            if self._load_prev_round(step) is False:
+                self._add_message("info", "I po započítání všech předchozích kol stále existují sdílená místa, řadím podle ročníku")
+                break
+
+            if step != 0:
+                self._add_message(
+                    "info",
+                    f"Výpočet na úrovni kola {self._prev_rounds[step-1].round_code()} nestačil,"
+                    + f" započítávám body z kola {self._prev_rounds[step].round_code()}"
+                )
+
+            tasks_by_difficulty = sorted(
+                self._tasks[step].keys(),
+                key=lambda task_id: (self._tasks[step][task_id].get_difficulty(), self._tasks[step][task_id].task.code),
+            )
+            last_task: ScoreTask = None
+            last_difficulty: Fraction = None
+            difficulty_report = []
+            for task_id in tasks_by_difficulty:
+                task = self._tasks[step][task_id]
+                difficulty = task.get_difficulty()
+                if last_task is not None and difficulty == last_difficulty and difficulty != 0:
+                    self._add_message(
+                        "warning",
+                        f"Úlohy {last_task.task.code} a {task.task.code} mají stejnou vypočtenou obtížnost "
+                        + f" {difficulty}, pro výpočet obtížnosti je řadím podle kódu úlohy"
+                    )
+                difficulty_report.append(f"{task.task.code} ({round(float(difficulty), 2)} b)")
+                last_task, last_difficulty = task, difficulty
+
+            self._add_message(
+                "info",
+                f"Průměrné body úloh kola {self._prev_rounds[step].round_code()} od nejobtížnější: "
+                + ", ".join(difficulty_report)
+            )
+
+            for result in self._results.values():
+                sol_points = {}
+                for task_id in self._tasks[step].keys():
+                    sol_points[task_id] = 0
+                for sol in result._sols[0].values():
+                    if sol.points:
+                        sol_points[sol.task_id] = -sol.points  # sestupné třídění
+
+                total_points = sum(sol_points.values())
+                points_from_max = list(sorted(sol_points.values()))
+                points_from_difficult = [sol_points[task_id] for task_id in tasks_by_difficulty]
+
+                # Primárně podle počtu získaných bodů, sekundárně podle bodů od maxima, terciárně podle bodů od nejobtížnější
+                result._order_key.extend((total_points, points_from_max, points_from_difficult))
+
+            # Otestujeme, jestli teď existují sdílená místa
+            if not self._exists_same_order_key():
+                break
+            # Pokud jsme našli stejný klíč, opakujeme cyklus s minulým kolem
+            step += 1
+
+        # Na konec třídícího klíče přidáme ročník (menší ročník první)
+        for result in self._results.values():
+            grade = normalize_grade(result.pant.grade)
+            if grade == -1:
+                self._add_message(
+                    "warning",
+                    f"Účastník {result.user.first_name} {result.user.last_name} má neplatný ročník {result.pant.grade}"
+                )
+            result._order_key.append(grade)
+
+        if self._exists_same_order_key():
+            self._add_message(
+                "error",
+                "I po započítání všech úloh (včetně minulých kol) a ročníků účastníků existují sdílená místa. Je potřeba určit pořadí losem"
+            )
+
+        # Další kontroly
+        # Pokud kontrolujeme výsledkovku pro celé kolo
+        if self.contest is None:
+            winners = 0
+            successfulls = 0
+            participants = len(self._results)
+            for result in self._results.values():
+                if result.winner:
+                    winners += 1
+                if result.successful:
+                    successfulls += 1
+            if successfulls > participants/2:
+                self._add_message(
+                    "error",
+                    f"Počet úspěšných řešitelů ({successfulls}) převyšuje polovinu celkového počtu účastníků ({participants})"
+                )
+            if winners > successfulls/2:
+                self._add_message(
+                    "error",
+                    f"Počet vítězů ({winners}) převyšuje polovinu počtu úspěšných řešitelů ({successfulls})"
+                )
diff --git a/mo/util.py b/mo/util.py
index 7021284e21d63b3c613075e0dd375508a1859d06..aaf520c6d5d01c2eef81b4bd87e12957ec2e4a14 100644
--- a/mo/util.py
+++ b/mo/util.py
@@ -184,3 +184,25 @@ def unlink_if_exists(name: str):
         os.unlink(name)
     except FileNotFoundError:
         pass
+
+
+def normalize_grade(grade: str) -> int:
+    """Pokusí se převést třídu ve formátu 7 nebo 3/4 na číslo odpovídající
+    třídě na základní škole (maturitní ročník gymnázia je tedy 9+4 = 13).
+    * Základní škola: nic
+    * Gymnázia: /8, /6 nebo /4
+    * Nerozpoznané ročníky a chyby při převodu: -1"""
+    try:
+        parts = grade.split('/')
+        if len(parts) == 1:
+            return int(parts[0])
+        if len(parts) > 2:
+            return -1
+        year = int(parts[0])
+        school_type = int(parts[1])
+        if school_type in (8, 6, 4):
+            return year + 13 - school_type
+        else:
+            return -1
+    except ValueError:
+        return -1
diff --git a/mo/web/jinja.py b/mo/web/jinja.py
index 0e786a08965c4baeff8cf2b7e9f933d13ee20cac..893ac8ca01a617687a00fc49f20498b8843d7c73 100644
--- a/mo/web/jinja.py
+++ b/mo/web/jinja.py
@@ -72,6 +72,11 @@ def or_dash(s: Any) -> str:
     return str(s) if s else '–'
 
 
+@app.template_filter()
+def none_value(s: Any, none_value: Any) -> Any:
+    return none_value if s is None else s
+
+
 @app.template_filter()
 def json_pretty(js: Any) -> str:
     return json.dumps(js, sort_keys=True, indent=4, ensure_ascii=False)
diff --git a/mo/web/org_round.py b/mo/web/org_round.py
index 7e86f508f75e4886a3b6b5b76889140678c98dec..b66650966c95f99f263246704bfdd8043753c168 100644
--- a/mo/web/org_round.py
+++ b/mo/web/org_round.py
@@ -8,6 +8,7 @@ from typing import Optional, Tuple
 import werkzeug.exceptions
 import wtforms
 from wtforms import validators
+from wtforms.fields.html5 import IntegerField
 
 import mo
 import mo.db as db
@@ -360,6 +361,15 @@ class RoundEditForm(FlaskForm):
     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()],
+        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()],
+        description="Řešitelé s alespoň tolika body budou označeni za úspěšné řešitele, prázdná hodnota = žádné neoznačovat",
+    )
     submit = wtforms.SubmitField('Uložit')
 
 
diff --git a/mo/web/org_score.py b/mo/web/org_score.py
index c20df96183a37c63973996017c08d92ac09d6b99..4925de52983b4623069b642da1d8207bc0d6bc4a 100644
--- a/mo/web/org_score.py
+++ b/mo/web/org_score.py
@@ -1,13 +1,12 @@
 from flask import render_template, request, g
 from flask.helpers import url_for
-from sqlalchemy import and_
-from sqlalchemy.orm import joinedload
-from typing import List, Tuple, Optional, Dict
+from typing import Optional
 import werkzeug.exceptions
 
 import mo
 import mo.db as db
 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
 
@@ -42,10 +41,10 @@ class SolPointsCell(Cell):
     user: db.User
     sol: Optional[db.Solution]
 
-    def __init__(self, contest_id: int, user: db.User):
+    def __init__(self, contest_id: int, user: db.User, sol: db.Solution):
         self.contest_id = contest_id
         self.user = user
-        self.sol = None
+        self.sol = sol
 
     def __str__(self) -> str:
         if not self.sol:
@@ -54,9 +53,6 @@ class SolPointsCell(Cell):
             return '?'
         return str(self.sol.points)
 
-    def set_sol(self, sol: db.Solution):
-        self.sol = sol
-
     def to_html(self) -> str:
         if not self.sol:
             return '<td>–'
@@ -85,66 +81,35 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None):
     format = request.args.get('format', "")
 
     sess = db.get_session()
-    user_id_subq = sess.query(db.Participation.user_id).join(
-        db.User, db.Participation.user_id == db.User.user_id
-    ).filter(
-        db.User.is_test == False,
-        db.Participation.state.notin_([db.PartState.disqualified, db.PartState.refused, db.PartState.absent]),
-    )
-
     if round_id:
         contest = None
         round = sess.query(db.Round).get(round_id)
         if not round:
             raise werkzeug.exceptions.NotFound()
         rr = g.gatekeeper.rights_for_round(round, True)
-        contest_subq = sess.query(db.Contest.contest_id).filter_by(round=round)
-        user_id_subq = user_id_subq.filter(db.Participation.contest_id.in_(contest_subq))
     else:
         contest = sess.query(db.Contest).get(contest_id)
         if not contest:
             raise werkzeug.exceptions.NotFound()
         round = contest.round
         rr = g.gatekeeper.rights_for_contest(contest)
-        contest_subq = [contest_id]
-        user_id_subq = user_id_subq.filter(db.Participation.contest == contest)
 
     if not rr.have_right(Right.view_submits):
         raise werkzeug.exceptions.Forbidden()
 
-    tasks_subq = sess.query(db.Task.task_id).filter_by(round=round)
-    tasks = (sess.query(db.Task)
-             .filter_by(round=round)
-             .order_by(db.Task.code)
-             .all())
-
-    sols: List[db.Solution] = sess.query(db.Solution).filter(
-        db.Solution.user_id.in_(user_id_subq),
-        db.Solution.task_id.in_(tasks_subq),
-    ).all()
-
-    data: List[Tuple[db.User, db.Participation, db.Participant]] = (
-        sess.query(db.User, db.Participation, db.Participant)
-        .select_from(db.Participation)
-        .join(db.User, isouter=True)
-        .join(db.Participant, and_(
-            db.Participant.user_id == db.Participation.user_id,
-            db.Participant.year == round.year
-        ), isouter=True).filter(
-            db.Participation.user_id.in_(user_id_subq),
-            db.Participation.contest_id.in_(contest_subq)
-        ).options(
-            joinedload(db.Participant.school_place),
-            joinedload(db.Participation.contest).joinedload(db.Contest.place),
-        ).all()
-    )
+    score = Score(round, contest)
+    tasks = score.get_tasks()
+    results = score.get_sorted_results()
+    messages = score.get_messages()
 
     # Construct columns
     is_export = (format != "")
-    columns = [
-        Column(key='order', name='poradi', title='Pořadí'),
-        Column(key='participant', name='ucastnik', title='Účastník'),
-    ]
+    columns = []
+    columns.append(Column(key='order', name='poradi', title='Pořadí'))
+    if is_export:
+        columns.append(Column(key='winner', name='vitez'))
+        columns.append(Column(key='successful', name='uspesny_resitel'))
+    columns.append(Column(key='participant', name='ucastnik', title='Účastník'))
     if is_export:
         columns.append(Column(key='email', name='email'))
     if not contest_id:
@@ -168,12 +133,17 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None):
                 )
         columns.append(Column(key=f'task_{task.task_id}', name=task.code, title=title))
     columns.append(Column(key='total_points', name='celkove_body', title='Celkové body'))
+    # columns.append(Column(key='order_key', name='order_key', title='Třídící klíč'))
 
     # Construct rows
-    rows_map: Dict[int, Row] = {}
-    for user, pion, pant in data:
+    table_rows = []
+    for result in results:
+        user, pant, pion = result.user, result.pant, result.pion
         school = pant.school_place
         row = Row(keys={
+            'order':        OrderCell(result.order.place, result.order.span, result.order.continuation),
+            'winner':       'ano' if result.winner else '',
+            'successful':   'ano' if result.successful else '',
             'user':         user,
             'email':        user.email,
             'participant':  cell_pion_link(user, pion.contest_id, user.full_name()),
@@ -181,49 +151,20 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None):
             'pion_place':   pion.place.name,
             'school':       CellLink(school.name, url_for('org_place', id=school.place_id)),
             'grade':        pant.grade,
-            'total_points': 0,
+            'total_points': result.get_total_points(),
             'birth_year':   pant.birth_year,
+            'order_key':    result._order_key,
         })
+        sols = result.get_sols_map()
         for task in tasks:
-            row.keys[f'task_{task.task_id}'] = SolPointsCell(contest_id=pion.contest_id, user=user)
-        rows_map[user.user_id] = row
-    for sol in sols:
-        rows_map[sol.user_id].keys[f'task_{sol.task_id}'].set_sol(sol)
-        if sol.points:
-            rows_map[sol.user_id].keys['total_points'] += sol.points
-
-    rows = list(rows_map.values())
-
-    # FIXME: Pokud to chceme přetavit do finální výsledkovky, tak musíme
-    # předefinovat následující funkci (a udělat to konfigurovatelné, protože
-    # různé olympiády i různé ročníky to mohou mít různě?)
-    def get_result_cmp(result):
-        return -result.keys['total_points']
-
-    def get_result_full_cmp(result):
-        return (get_result_cmp(result), result.keys['user'].last_name, result.keys['user'].first_name)
-
-    rows.sort(key=get_result_full_cmp)
-
-    # Spočítáme pořadí - v prvním kroku prolinkujeme opakující se OrderCell na první,
-    # ve druhém kroku je pak správně rozkopírujeme s nastaveným continuation na True
-    last: Row = None
-    for row in rows:
-        if last is None:
-            row.keys['order'] = OrderCell(1)
-            last = row
-        elif get_result_cmp(last) == get_result_cmp(row):
-            row.keys['order'] = last.keys['order']
-            last.keys['order'].span += 1
-        else:
-            row.keys['order'] = OrderCell(last.keys['order'].place + last.keys['order'].span)
-            last = row
-    lastOrder: OrderCell = None
-    for row in rows:
-        if row.keys['order'] == lastOrder:
-            row.keys['order'] = OrderCell(lastOrder.place, lastOrder.span, True)
-        else:
-            lastOrder = row.keys['order']
+            row.keys[f'task_{task.task_id}'] = SolPointsCell(
+                contest_id=pion.contest_id, user=user, sol=sols.get(task.task_id)
+            )
+        if result.winner:
+            row.html_attr = {"class": "winner", "title": "Vítěz"}
+        elif result.successful:
+            row.html_attr = {"class": "successful", "title": "Úspěšný řešitel"}
+        table_rows.append(row)
 
     filename = f"vysledky_{round.year}-{round.category}-{round.level}"
     if contest_id:
@@ -231,7 +172,7 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None):
     table = Table(
         table_class="data full center",
         columns=columns,
-        rows=rows,
+        rows=table_rows,
         filename=filename,
     )
 
@@ -239,7 +180,7 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None):
         return render_template(
             'org_score.html',
             contest=contest, round=round, tasks=tasks,
-            table=table,
+            table=table, messages=messages,
         )
     else:
         return table.send_as(format)
diff --git a/mo/web/templates/org_round.html b/mo/web/templates/org_round.html
index 9f6d685c9e13769ad89564da968fed8236a2dced..678a29209b41a099d4af898e38f81e0df83efff9 100644
--- a/mo/web/templates/org_round.html
+++ b/mo/web/templates/org_round.html
@@ -30,6 +30,9 @@
 {% else %}
 	–
 {% endif %}
+	<tr><td>Výsledková listina<td>{{ round.score_mode.friendly_name() }}
+	<tr><td>Hranice bodů pro vítěze<td>{{ round.score_winner_limit|none_value(Markup('<i>nenastaveno</i>')) }}
+	<tr><td>Hranice bodů pro úspěšné řešitele<td>{{ round.score_successful_limit|none_value(Markup('<i>nenastaveno</i>')) }}
 </table>
 
 <div class="btn-group">
diff --git a/mo/web/templates/org_score.html b/mo/web/templates/org_score.html
index 27bd42a5f09e75739766a69b8ef7237a71d4b153..15ae4bebd5e42a2953787808516afefd50b9be27 100644
--- a/mo/web/templates/org_score.html
+++ b/mo/web/templates/org_score.html
@@ -15,12 +15,44 @@
 	{% endif %}
 </div>
 
-<p>Toto je polotovar výsledkové listiny určený k ručnímu dodělání.
-Pořadí je neoficiální (seřazené podle součtu bodů s dělenými místy).
+{% if messages %}
+<div class="collapsible">
+	{% set error_count = messages | selectattr(0, "equalto", "error") | list | count %}
+
+	<input type="checkbox" class="toggle" id="messages-toggle">
+	<label for="messages-toggle" class="toggle">
+		Log vytváření výsledkové listiny ({{ messages|length|inflected('zpráva', 'zprávy', 'zpráv') }}
+		{%- if error_count > 0 %}, <span class="error">{{ error_count|inflected('chyba', 'chyby', 'chyb') }}</span>{% endif %})
+	</label>
+	<div class="collapsible-inner">
+		<div class="alert alert-warning">
+			<ul>
+			{% for (type, msg) in messages %}
+				{% if type == "error" %}<li class="error"><b>Chyba: {{ msg }}</b>
+				{% elif type == "warning" %}<li>Varování: {{ msg }}
+				{% else %}<li class="text-info">Info: {{ msg }}{% endif %}
+			{% endfor %}
+			</ul></div>
+		</div>
+	</div>
+</div>
+{% endif %}
+
+<p>Mód této výsledkové listiny je <b>{{ round.score_mode.friendly_name() }}</b>.
 Diskvalifikovaní, odmítnuvší a nepřítomní účastníci jsou skryti, stejně tak testovací uživatelé.
 Export pod tabulkou obsahuje sloupce navíc.
 Rozkliknutím bodů se lze dostat na detail daného řešení.</p>
 
+{% if round.score_winner_limit is not none or round.score_successful_limit is not none %}
+<p>
+{% if round.score_winner_limit is not none %}
+<b>Vítězi</b> se stávají účastníci s alespoň <b>{{ round.score_winner_limit|inflected("bodem", "body", "body") }}</b>.
+{% endif %}
+{% if round.score_successful_limit is not none %}
+<b>Úspěšnými řešiteli</b> se stávají účastníci s alespoň <b>{{ round.score_successful_limit|inflected("bodem", "body", "body") }}</b>.
+{% endif %}
+{% endif %}
+
 {{ table.to_html() }}
 
 {% endblock %}
diff --git a/static/mo.css b/static/mo.css
index 8978f2cd51d677e19ab53875e9175b7793006476..1eda5be8253ae8b24cc5c04f31fada18a84d6bd6 100644
--- a/static/mo.css
+++ b/static/mo.css
@@ -130,6 +130,11 @@ table tr.state-refused, table tr.state-refused a:not(.btn) {
 	color: #888;
 }
 
+table tbody tr.winner { background-color: #fe5; }
+table tbody tr.winner:hover { background-color: #dc3; }
+table tbody tr.successful { background-color: #9e9; }
+table tbody tr.successful:hover { background-color: #7c7; }
+
 nav#main-menu {
 	display: flex;
 	flex-wrap: wrap;
@@ -330,3 +335,41 @@ div.alert + div.alert {
 	-webkit-font-smoothing: antialiased;
 	-moz-osx-font-smoothing: grayscale;
 }
+
+/* Collapsible */
+
+.collapsible {
+	position: relative;
+}
+.collapsible input[type="checkbox"].toggle {
+	position: absolute;
+	top: 0;
+	left: 0;
+	opacity: 0;
+}
+.collapsible label.toggle {
+	cursor: pointer;
+	margin-left: 15px;
+}
+.collapsible label.toggle::before {
+	position: absolute;
+	content: "";
+	width: 0;
+	height: 0;
+	left: 0px;
+	border-left: 8px solid black;
+	border-top: 8px solid transparent;
+	border-bottom: 8px solid transparent;
+	transition: 0.5s ease;
+}
+.collapsible input[type="checkbox"].toggle:checked ~ label.toggle::before {
+	transform: rotate(90deg);
+}
+.collapsible .collapsible-inner {
+	max-height: 0;
+	overflow-y: hidden;
+	transition: 0.5s ease;
+}
+.collapsible input[type="checkbox"].toggle:checked ~ .collapsible-inner {
+	max-height: 100vh;
+}