diff --git a/mo/web/fields.py b/mo/web/fields.py
new file mode 100644
index 0000000000000000000000000000000000000000..e6f21da098528f367c161d9421dc366ee78b8135
--- /dev/null
+++ b/mo/web/fields.py
@@ -0,0 +1,140 @@
+from typing import Optional
+import wtforms
+from wtforms.widgets.html5 import NumberInput
+
+import mo
+import mo.users
+import mo.db as db
+
+
+class OptionalInt(wtforms.IntegerField):
+    widget = NumberInput()
+
+    def process_formdata(self, valuelist):
+        self.data = None
+        if valuelist:
+            if valuelist[0]:
+                try:
+                    self.data = int(valuelist[0])
+                except ValueError:
+                    raise wtforms.ValidationError('Nejedná se o číslo.')
+
+
+class Email(wtforms.StringField):
+    def __init__(self, label="E-mail", validators=None, **kwargs):
+        super().__init__(label, validators, **kwargs)
+
+    def pre_validate(field, form):
+        if field.data:
+            try:
+                field.data = mo.users.normalize_email(field.data)
+            except mo.CheckError as e:
+                raise wtforms.ValidationError(str(e))
+
+
+class Grade(wtforms.StringField):
+    """Pro validaci hledá ve formuláři form.school a podle ní rozlišuje SŠ a ZŠ """
+    default_description = "Pro základní školy je to číslo od 1 do 9, pro <var>k</var>-tý ročník <var>r</var>-leté střední školy má formát <var>k</var>/<var>r</var>."
+    validate_grade = True
+
+    def __init__(self, label="Ročník", validators=None, description=default_description, **kwargs):
+        super().__init__(label, validators, description=description, **kwargs)
+
+    def pre_validate(field, form):
+        if field.data:
+            if field.validate_grade:
+                school_place = form.school.get_place()
+                if school_place is not None:
+                    try:
+                        field.data = mo.users.normalize_grade(field.data, school_place.school)
+                    except mo.CheckError as e:
+                        raise wtforms.ValidationError(str(e))
+
+
+class BirthYear(OptionalInt):
+    def __init__(self, label="Rok narození", validators=None, **kwargs):
+        super().__init__(label, validators, **kwargs)
+
+    def pre_validate(field, form):
+        if field.data is not None:
+            r: int = field.data
+            try:
+                mo.users.validate_born_year(r)
+            except mo.CheckError as e:
+                raise wtforms.ValidationError(str(e))
+
+
+class Name(wtforms.StringField):
+    def pre_validate(field, form):
+        # XXX: Tato kontrola úmyslně není striktní, aby prošla i jména jako 'de Beer'
+        if field.data:
+            if field.data == field.data.lower():
+                raise wtforms.ValidationError('Ve jméně nejsou velká písmena.')
+            if field.data == field.data.upper():
+                raise wtforms.ValidationError('Ve jméně nejsou malá písmena.')
+
+
+class FirstName(Name):
+    def __init__(self, label="Jméno", validators=None, **kwargs):
+        super().__init__(label, validators, **kwargs)
+
+
+class LastName(Name):
+    def __init__(self, label="Příjmení", validators=None, **kwargs):
+        super().__init__(label, validators, **kwargs)
+
+
+class Place(wtforms.StringField):
+    def __init__(self, label="Místo", validators=None, **kwargs):
+        super().__init__(label, validators, **kwargs)
+
+    place_loaded: bool = False
+    place: Optional[db.Place] = None
+    place_error: str
+
+    def load_place(field) -> None:
+        field.place = None
+        field.place_error = ""
+        if field.data:
+            field.place = db.get_place_by_code(field.data)
+            if field.place is None:
+                field.place_error = "Zadané místo nenalezeno."
+
+    def get_place(field) -> Optional[db.Place]:
+        """ Kešuje výsledek v field.place"""
+        if not field.place_loaded:
+            field.place_loaded = True
+            field.load_place()
+        return field.place
+
+    def pre_validate(field, form):
+        if field.get_place() is None and field.place_error:
+            raise wtforms.ValidationError(field.place_error)
+
+    def get_place_id(field) -> int:
+        p = field.get_place()
+        if p is None:
+            return 0
+        return p.place_id
+
+    def populate_obj(field, obj, name):
+        setattr(obj, name, field.get_place_id())
+
+    def process_data(field, obj: Optional[int]):
+        if obj is not None:
+            field.data = db.get_place_by_id(obj).get_code()
+        else:
+            field.data = ""
+
+
+class School(Place):
+    def __init__(self, label="Škola", validators=None, **kwargs):
+        super().__init__(label, validators, **kwargs)
+
+    def load_place(field) -> None:
+        field.place = None
+        if field.data:
+            try:
+                field.place = mo.users.validate_and_find_school(field.data)
+            except mo.CheckError as e:
+                field.place_error = str(e)