diff --git a/mo/points.py b/mo/points.py
new file mode 100644
index 0000000000000000000000000000000000000000..f97099a1f0acca6367f962d7dac361a15738c81f
--- /dev/null
+++ b/mo/points.py
@@ -0,0 +1,154 @@
+from dataclasses import dataclass, field
+from decimal import Decimal, InvalidOperation
+from enum import auto
+from typing import Optional, Tuple, List
+
+import mo.db as db
+import mo.rights
+import mo.util
+from mo.util import assert_not_none
+import mo.util_format
+
+
+@dataclass
+class GenPoints:
+    have_solution: bool = True
+    is_empty: bool = False
+    points: Optional[Decimal] = None
+    error: Optional[str] = None
+
+    @staticmethod
+    def parse(
+        inp: Optional[str],
+        for_task: Optional[db.Task] = None,
+        for_round: Optional[db.Round] = None,
+    ) -> 'GenPoints':
+        """Zobecnění mo.util.parse_points(). Naparsuje generalizované body používané při editaci."""
+
+        inp = inp.upper() if inp is not None else None
+        gp = GenPoints()
+
+        if inp is None or inp == 'X':
+            gp.have_solution = False
+            return gp
+
+        if inp == "" or inp == '?':
+            pass
+        elif inp == 'P':
+            gp.is_empty = True
+            gp.points = Decimal(0)
+        else:
+            try:
+                gp.points = Decimal(inp.replace(',', '.'))
+            except InvalidOperation:
+                gp.error = f"Hodnota '{inp}' není číslo ani X nebo P"
+                return gp
+            gp.error = mo.util.check_points(gp.points, for_task, for_round)
+        return gp
+
+
+class SolActionError(RuntimeError):
+    pass
+
+
+@dataclass
+class SolAction:
+    task: db.Task
+    user: db.User
+    sol: Optional[db.Solution]
+    gp: GenPoints
+    reason: str
+    rights: mo.rights.RoundRights
+    to_log: List[str]
+    allow_add_del: bool = True
+    did_add_or_del: bool = False
+
+    def add_or_del(self) -> Tuple[bool, bool]:
+        if (self.sol is not None) == self.gp.have_solution:
+            return False, False
+
+        sess = db.get_session()
+        self.did_add_or_del = True
+
+        if self.sol is None:
+            if not self.allow_add_del:
+                raise SolActionError('Tento soutěžící úlohu neodevzdal')
+            if not (self.rights.can_upload_solutions() or self.rights.can_upload_feedback()):
+                raise SolActionError('Nemáte právo na zakládání nových řešení, můžete jen upravovat body')
+            self.sol = db.Solution(user=self.user, task=self.task)
+            sess.add(self.sol)
+            self.to_log.append(f'Založeno řešení user=#{self.user.user_id} task=#{self.task.task_id}')
+            mo.util.log(
+                type=db.LogType.participant,
+                what=self.user.user_id,
+                details={'action': 'solution-created', 'task': self.task.task_id, 'reason': self.reason},
+            )
+            return True, False
+        else:
+            if not self.allow_add_del:
+                raise SolActionError('Tento soutěžící úlohu odevzdal')
+            if self.sol.final_submit is not None or self.sol.final_feedback is not None:
+                raise SolActionError('Nelze smazat řešení, ke kterému existují odevzdané soubory')
+            if not self.rights.can_upload_solutions():
+                raise SolActionError('Nemáte právo na mazání řešení')
+            self.to_log.append(f'Smazáno řešení user=#{self.user.user_id} task=#{self.task.task_id}')
+            mo.util.log(
+                type=db.LogType.participant,
+                what=self.user.user_id,
+                details={'action': 'solution-removed', 'task': self.task.task_id, 'reason': self.reason},
+            )
+            sess.delete(self.sol)
+            self.sol = None
+            return False, True
+
+    def set_points(self) -> bool:
+        sol = self.sol
+        gp = self.gp
+
+        if sol is None:
+            return False
+        if gp.is_empty == sol.is_empty and gp.points == sol.points:
+            return False
+
+        if not self.rights.can_edit_points():
+            raise SolActionError('Nemáte právo hodnotit řešení')
+
+        sol.points = gp.points
+        sol.is_empty = gp.is_empty
+        sess = db.get_session()
+        sess.add(db.PointsHistory(
+            task=self.task,
+            participant_id=self.user.user_id,
+            user=mo.util.current_log_user,
+            points_at=mo.now,
+            points=gp.points,
+            is_empty=gp.is_empty,
+        ))
+        return True
+
+    def log_changes(self):
+        sess = db.get_session()
+        if self.sol and not self.did_add_or_del and sess.is_modified(self.sol):
+            changes = db.get_object_changes(self.sol)
+            mo.util.log(
+                type=db.LogType.participant,
+                what=self.user.user_id,
+                details={
+                    'action': 'solution-edit',
+                    'task': self.task.task_id,
+                    'changes': changes,
+                    'reason': self.reason,
+                },
+            )
+            self.to_log.append(f"Řešení user=#{self.user.user_id} task=#{self.task.task_id} modifikováno, změny: {changes}")
+
+
+def format_sol_editable_points(s: Optional[db.Solution], none_is_qmark: bool = False) -> str:
+    if s is None:
+        return 'X'
+    elif s.is_empty:
+        return 'P'
+    elif s.points is None:
+        return '?' if none_is_qmark else ""
+    else:
+        return assert_not_none(mo.util_format.format_decimal(s.points))
diff --git a/mo/util.py b/mo/util.py
index 38ef66f0555899709bebb404699edff4131e811e..0874e3a7768c18d4d9351f52b896f4c856317ab3 100644
--- a/mo/util.py
+++ b/mo/util.py
@@ -4,7 +4,6 @@ from dataclasses import dataclass
 import datetime
 import decimal
 import dateutil.tz
-from enum import auto
 import locale
 import logging
 import os
@@ -155,34 +154,6 @@ def parse_points(
     return points, check_points(points, for_task, for_round)
 
 
-class GPAction(db.MOEnum):
-    no_solution = auto()
-    no_points = auto()
-    is_empty = auto()
-    has_points = auto()
-
-
-def parse_gen_points(
-    gen_points: Optional[str], for_task: Optional[db.Task] = None, for_round: Optional[db.Round] = None,
-) -> Tuple[GPAction, Optional[decimal.Decimal], Optional[str]]:
-    """Zobecnění parse_points(). Naparsuje generalizované body používané při editaci.
-    Vrátí typ hodnocení (GPAction), body (decimal.Decimal nebo None) a případný error."""
-    gen_points = gen_points.upper() if gen_points is not None else None
-    if gen_points is None or gen_points == 'X':
-        return GPAction.no_solution, None, None
-    elif gen_points == "" or gen_points == '?':
-        # Řešení má existovat, ale nemá přidělené body
-        return GPAction.no_points, None, None
-    elif gen_points == 'P':
-        return GPAction.is_empty, decimal.Decimal(0), None
-    else:
-        try:
-            points = decimal.Decimal(gen_points.replace(',', '.'))
-        except decimal.InvalidOperation:
-            return "", decimal.Decimal(0), f"Hodnota '{gen_points}' není číslo ani X"
-        return GPAction.points, 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: