diff --git a/bin/create-contests b/bin/create-contests
index 17c7573fe34fb536b6f4c8d5f6d80a503649c933..a8e7ac694742d9107834f8882c5da44f37d0f6d3 100755
--- a/bin/create-contests
+++ b/bin/create-contests
@@ -2,14 +2,15 @@
 
 import argparse
 
+from mo.arg_attrs import CONTEST_ATTRS, HelpFormatter
 import mo.db as db
 import mo.util
 from mo.util import die, init_standalone
 
-parser = argparse.ArgumentParser(description='Založí soutěže pro dané kolo')
+parser = argparse.ArgumentParser(description='Založí soutěže pro dané kolo', formatter_class=HelpFormatter)
 parser.add_argument(dest='round', type=str, metavar='YY-C-S[p]', help='kód kola')
 parser.add_argument('-r', '--region', type=str, metavar='CODE', help='soutěžní oblast (default: založit všechny)')
-parser.add_argument('--online-submit', default=False, action='store_true', help='povolí elektronické odevzdávání')
+CONTEST_ATTRS.add_to_arg_parser(parser)
 parser.add_argument('-n', '--dry-run', default=False, action='store_true', help='pouze ukáže, co by bylo provedeno')
 
 args = parser.parse_args()
@@ -24,7 +25,8 @@ round = mo.util.get_round_by_code(round_code)
 if round is None:
     die("Kolo s tímto kódem neexistuje!")
 
-online_submit = round.online_submit or args.online_submit
+if args.online_submit is Ellipsis:
+    args.online_submit = round.online_submit
 
 region = None
 if args.region is not None:
@@ -42,7 +44,8 @@ if round.is_subround():
         print(f"{round.round_code()} pro místo {r.name}: zakládám (podsoutěž)")
 
         if not args.dry_run:
-            c = db.Contest(round=round, place=r, master=mc, state=round.last_state, online_submit=online_submit)
+            c = db.Contest(round=round, place=r, master=mc, state=round.last_state)
+            CONTEST_ATTRS.args_to_obj(args, c)
 
             sess.add(c)
             sess.flush()
@@ -50,6 +53,7 @@ if round.is_subround():
             mo.util.log(db.LogType.contest, c.contest_id, {
                 'action': 'created',
                 'reason': 'script',
+                'new': db.row2dict(c),
             })
 
 else:
@@ -65,7 +69,8 @@ else:
         else:
             print(f"{round.round_code()} pro místo {r.name}: zakládám")
             if not args.dry_run:
-                c = db.Contest(round=round, place=r, state=round.last_state, online_submit=online_submit)
+                c = db.Contest(round=round, place=r, state=round.last_state)
+                CONTEST_ATTRS.args_to_obj(args, c)
 
                 sess.add(c)
                 sess.flush()
@@ -74,6 +79,7 @@ else:
                 mo.util.log(db.LogType.contest, c.contest_id, {
                     'action': 'created',
                     'reason': 'script',
+                    'new': db.row2dict(c),
                 })
 
 if not args.dry_run:
diff --git a/bin/create-round b/bin/create-round
index 6059dbc4316e59add5a52c638b75aa90d694f7f8..454f568d5b94e054bff050051462d0577e3235a7 100755
--- a/bin/create-round
+++ b/bin/create-round
@@ -2,27 +2,23 @@
 
 import argparse
 
+from mo.arg_attrs import ROUND_ATTRS, HelpFormatter
 import mo.db as db
 import mo.util
 from mo.util import die
 
-parser = argparse.ArgumentParser(description='Založí soutěžní kolo')
+parser = argparse.ArgumentParser(description='Založí soutěžní kolo', formatter_class=HelpFormatter)
 parser.add_argument('-y', '--year', type=int, required=True, help='ročník')
 parser.add_argument('-c', '--cat', type=str, required=True, help='kategorie')
 parser.add_argument('-s', '--seq', type=int, required=True, help='pořadí kola')
-parser.add_argument('-l', '--level', type=int, required=True, help='úroveň v hierarchii oblastí')
 parser.add_argument('-p', '--part', type=int, default=0, help='část v rámci skupiny kol (default: 0)')
-parser.add_argument('-n', '--name', type=str, required=True, help='název kola')
-parser.add_argument('-S', '--step', type=float, default=1, help='bodovací krok (default: 1)')
-parser.add_argument('-t', '--type', type=str, default='other', help='typ kola (default: other)')
-parser.add_argument('--score-mode', type=str, default='basic', help='režim výsledkové listiny (default: basic)')
-parser.add_argument('--enroll-mode', type=str, default='manual', help='režim registrace (default: manual)')
-parser.add_argument('--enroll-advert', type=str, default='', help='popis v přihlášce')
-parser.add_argument('--publish-score', default=False, action='store_true', help='publikovat výsledkové listiny')
-parser.add_argument('--online-submit', default=False, action='store_true', help='povolit elektronické odevzdávání')
+ROUND_ATTRS.add_to_arg_parser(parser)
 
 args = parser.parse_args()
 
+if args.points_step is Ellipsis:
+    args.points_step = 1
+
 mo.util.init_standalone()
 sess = db.get_session()
 
@@ -40,17 +36,10 @@ rnd = db.Round(
     category=args.cat,
     seq=args.seq,
     part=args.part,
-    level=args.level,
-    name=args.name,
-    points_step=args.step,
-    round_type=db.RoundType.coerce(args.type),
-    score_mode=db.RoundScoreMode.coerce(args.score_mode),
-    enroll_mode=db.RoundEnrollMode.coerce(args.enroll_mode),
-    enroll_advert=args.enroll_advert,
-    export_score_to_mo_web=args.publish_score,
-    online_submit=args.online_submit,
 )
 
+ROUND_ATTRS.args_to_obj(args, rnd)
+
 sess.add(rnd)
 sess.flush()
 
diff --git a/bin/set-contest b/bin/set-contest
new file mode 100755
index 0000000000000000000000000000000000000000..bd711172f3d2cd557650955aa1c9dd760cef71f3
--- /dev/null
+++ b/bin/set-contest
@@ -0,0 +1,55 @@
+#!/usr/bin/env python3
+
+import argparse
+
+from mo.arg_attrs import CONTEST_ATTRS, HelpFormatter
+import mo.db as db
+import mo.util
+from mo.util import die
+
+
+parser = argparse.ArgumentParser(description='Nastaví parametry soutěží', formatter_class=HelpFormatter)
+parser.add_argument('--in-round', type=str, metavar='YY-C-S[p]', help='nastaví všem soutěžím v daném kole')
+parser.add_argument('--id', type=int, metavar='ID', help='nastaví soutěži s daným ID')
+CONTEST_ATTRS.add_to_arg_parser(parser)
+
+args = parser.parse_args()
+
+mo.util.init_standalone()
+sess = db.get_session()
+
+if args.in_round is not None:
+    round_code = mo.util.RoundCode.parse(args.in_round)
+    if round_code is None:
+        die("Chybná syntaxe kódu kola")
+    rnd = mo.util.get_round_by_code(round_code)
+    if rnd is None:
+        die("Kolo s tímto kódem neexistuje!")
+    contests = sess.query(db.Contest).filter_by(round=rnd).all()
+elif args.id is not None:
+    contests = sess.query(db.Contest).filter_by(contest_id=args.id).all()
+    if not contests:
+        die("Soutěž s tímto ID neexistuje")
+else:
+    die("Není vybraná žádná soutěž")
+
+num_modified = 0
+
+for c in contests:
+    CONTEST_ATTRS.args_to_obj(args, c)
+
+    if sess.is_modified(c):
+        num_modified += 1
+        changes = db.get_object_changes(c)
+        mo.util.log(
+            type=db.LogType.contest,
+            what=c.contest_id,
+            details={
+                'action': 'edit',
+                'reason': 'script',
+                'changes': changes,
+            },
+        )
+
+sess.commit()
+print(f'Modifikováno {num_modified} soutěží z {len(contests)}')
diff --git a/bin/set-round b/bin/set-round
new file mode 100755
index 0000000000000000000000000000000000000000..3d18c55c8f253afdd7681dd85a179acbe9be748a
--- /dev/null
+++ b/bin/set-round
@@ -0,0 +1,42 @@
+#!/usr/bin/env python3
+
+import argparse
+
+from mo.arg_attrs import ROUND_ATTRS, HelpFormatter
+import mo.db as db
+import mo.util
+from mo.util import die
+
+
+parser = argparse.ArgumentParser(description='Nastaví parametry soutěžního kola', formatter_class=HelpFormatter)
+parser.add_argument(dest='round', type=str, metavar='YY-C-S[p]', help='kód kola')
+ROUND_ATTRS.add_to_arg_parser(parser)
+
+args = parser.parse_args()
+
+mo.util.init_standalone()
+sess = db.get_session()
+
+round_code = mo.util.RoundCode.parse(args.round)
+if round_code is None:
+    die("Chybná syntaxe kódu kola")
+rnd = mo.util.get_round_by_code(round_code)
+if rnd is None:
+    die("Kolo s tímto kódem neexistuje!")
+
+ROUND_ATTRS.args_to_obj(args, rnd)
+
+if sess.is_modified(rnd):
+    changes = db.get_object_changes(rnd)
+
+    mo.util.log(
+        type=db.LogType.round,
+        what=rnd.round_id,
+        details={
+            'action': 'edit',
+            'reason': 'script',
+            'changes': changes,
+        },
+    )
+
+    sess.commit()
diff --git a/mo/arg_attrs.py b/mo/arg_attrs.py
new file mode 100644
index 0000000000000000000000000000000000000000..0db755364a85fb9448be9ea1382f0464856851b5
--- /dev/null
+++ b/mo/arg_attrs.py
@@ -0,0 +1,141 @@
+# Automatický generátor command-line argumentů pro atributy databázových objektů
+
+import argparse
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Callable, Any, Optional, Type, List
+
+import mo.db as db
+
+
+class HelpFormatter(argparse.HelpFormatter):
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, max_help_position=50, **kwargs)
+
+
+@dataclass
+class Attr:
+    name: str
+    parser: Callable[[str], Any]
+    help: str
+    long: Optional[str] = None
+    short: Optional[str] = None
+    nullable: bool = False
+
+    def add_to_arg_parser(self, parser: argparse.ArgumentParser) -> None:
+        arg_names = []
+        if self.short is not None:
+            arg_names.append('-' + self.short)
+        if self.long is not None:
+            arg_names.append('--' + self.long)
+        else:
+            arg_names.append('--' + self.name.replace('_', '-'))
+
+        other = {
+            'metavar': 'X',
+        }
+
+        typ = self.parser
+        if typ is parse_bool:
+            other['nargs'] = '?'
+            other['const'] = 'true'
+            other['metavar'] = 'BOOL'
+        if self.nullable:
+            typ = make_nullable_parser(typ)
+
+        parser.add_argument(*arg_names, type=typ, default=Ellipsis, help=self.help, **other)
+
+    def args_to_obj(self, args: argparse.Namespace, obj: Any) -> None:
+        key = self.long.replace('-', '_') if self.long else self.name
+        val = getattr(args, key)
+        if val is not Ellipsis:
+            setattr(obj, self.name, val)
+
+
+@dataclass
+class AttrList:
+    attrs: List[Attr]
+
+    def add_to_arg_parser(self, parser: argparse.ArgumentParser) -> None:
+        for a in self.attrs:
+            a.add_to_arg_parser(parser)
+
+    def args_to_obj(self, args: argparse.Namespace, obj: Any) -> None:
+        for a in self.attrs:
+            a.args_to_obj(args, obj)
+
+
+def parse_enum(e: Type[db.MOEnum], s: str) -> Any:
+    try:
+        return e.coerce(s)
+    except ValueError:
+        choices = ", ".join([x.name for x in e])
+        raise argparse.ArgumentTypeError(f'Invalid value {s} (must be one of {choices})')
+
+
+def parse_type(s: str) -> db.RoundType:
+    return parse_enum(db.RoundType, s)
+
+
+def parse_time(s: str) -> Optional[datetime]:
+    return datetime.fromisoformat(s).astimezone()
+
+
+def parse_bool(s: str) -> bool:
+    if s in ('true', '1', 'yes', 't', 'y'):
+        return True
+    if s in ('false', '0', 'no', 'f', 'n'):
+        return False
+    raise ValueError(f'Cannot parse {s} as Boolean value')
+
+
+def parse_score_mode(s: str) -> db.RoundScoreMode:
+    return parse_enum(db.RoundScoreMode, s)
+
+
+def parse_enroll_mode(s: str) -> db.RoundEnrollMode:
+    return parse_enum(db.RoundEnrollMode, s)
+
+
+def make_nullable_parser(typ: Callable[[str], Any]) -> Any:
+    def parse(s: str) -> Any:
+        if s == '-' or s == "":
+            return None
+        else:
+            return typ(s)
+
+    return parse
+
+
+ROUND_ATTRS = AttrList([
+    Attr('level',                   int,                'úroveň v hierarchii oblastí', short='l'),
+    Attr('name',                    str,                'název kola', short='n'),
+    Attr('round_type',              parse_type,         'typ kola', long='type', short='t'),
+    # FIXME: state, last_state
+    # tasks_file nenastavujeme
+    Attr('ct_tasks_start',          parse_time,         'začátek pro účastníky', nullable=True),
+    Attr('ct_submit_end',           parse_time,         'konec odevzdávání pro účastníky'),
+    Attr('pr_tasks_start',          parse_time,         'začátek pro dozor'),
+    Attr('pr_submit_end',           parse_time,         'konec odevzdávání pro dozor'),
+    Attr('online_submit',           parse_bool,         'povoleno elektronické odevzdávání'),
+    Attr('score_mode',              parse_score_mode,   'režim výsledkové listiny'),
+    Attr('score_winner_limit',      float,              'hranice bodů pro vítěze', nullable=True),
+    Attr('score_successful_limit',  float,              'hranice bodů pro úspěšného řešitele', nullable=True),
+    Attr('score_advance_limit',     float,              'hranice bodů pro postup', nullable=True),
+    Attr('points_step',             float,              'bodovací krok', short='S'),
+    Attr('has_messages',            parse_bool,         'povoleny zprávičky'),
+    Attr('enroll_mode',             parse_enroll_mode,  'režim registrace'),
+    Attr('enroll_advert',           str,                'popis v přihlášce'),
+    Attr('enroll_deadline',         parse_time,         'deadline přihlašování', nullable=True),
+    Attr('switch_to_grading',       parse_time,         'čas automatického přepnutí na opravování', nullable=True),
+    Attr('min_rec_grade',           int,                'minimální doporučený ročník (1-12)', nullable=True),
+    Attr('max_rec_grade',           int,                'maximální doporučený ročník (1-12)', nullable=True),
+    Attr('export_score_to_web',     parse_bool,         'exportovat výsledkovky na web', long='publish-score'),
+])
+
+
+CONTEST_ATTRS = AttrList([
+    Attr('online_submit',           parse_bool,         'povoleno elektronické odevzdávání'),
+    Attr('tex_hacks',               str,                'hacky pro sazbu výsledkových listin'),
+])