diff --git a/.gitignore b/.gitignore
index 8560925f72ae15f361f7151b98a54a7eeeb65b33..1c7655f492026e8c836f9848d6a979188f120eae 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,4 @@ documents/
 .rediscli_history
 .viminfo
 test/
+*.sql
diff --git a/legacy.py b/legacy.py
new file mode 100644
index 0000000000000000000000000000000000000000..73748361b78d8cd1ecaa7193c7eec70475d93412
--- /dev/null
+++ b/legacy.py
@@ -0,0 +1,150 @@
+from models.database import Todo, OldTodo
+from fuzzywuzzy import fuzz, process
+
+from shared import db
+
+import config
+
+def lookup_todo_id(old_candidates, new_who, new_description):
+    # Check for perfect matches
+    for candidate in old_candidates:
+        if candidate.who == new_who and candidate.description == new_description:
+            return candidate.old_id
+    # Accept if who has been changed
+    for candidate in old_candidates:
+        if candidate.description == new_description:
+            return candidate.old_id
+    # Do fuzzy matching on description
+    content_to_number = {
+        candidate.description: candidate.old_id
+        for candidate in old_candidates
+    }
+    best_match, best_match_score = process.extractOne(
+        new_description, content_to_number.keys())
+    if best_match_score >= config.FUZZY_MIN_SCORE:
+        print("Used fuzzy matching on '{}', got '{}' with score {}.".format(
+            new_description, best_match, best_match_score))
+        return content_to_number[best_match]
+    else:
+        print("Best match for '{}' is '{}' with score {}, rejecting.".format(
+            new_description, best_match, best_match_score))
+        return None
+
+
+def import_old_todos(sql_text):
+    protocoltype_lines = []
+    protocol_lines = []
+    todo_lines = []
+    for line in sql_text.splitlines():
+        if line.startswith("INSERT INTO `protocolManager_protocoltype`"):
+            protocoltype_lines.append(line)
+        elif line.startswith("INSERT INTO `protocolManager_protocol`"):
+            protocol_lines.append(line)
+        elif line.startswith("INSERT INTO `protocolManager_todo`"):
+            todo_lines.append(line)
+    if (len(protocoltype_lines) == 0
+    or len(protocol_lines) == 0
+    or len(todo_lines) == 0):
+        raise ValueError("Necessary lines not found.")
+    type_id_to_handle = {}
+    for type_line in protocoltype_lines:
+        for id, handle, name, mail, protocol_id in _split_insert_line(type_line):
+            type_id_to_handle[int(id)] = handle.lower()
+    protocol_id_to_key = {}
+    for protocol_line in protocol_lines:
+        for (protocol_id, type_id, date, source, textsummary, htmlsummary,
+            deleted, sent, document_id) in _split_insert_line(protocol_line):
+            handle = type_id_to_handle[int(type_id)]
+            date_string = date [2:]
+            protocol_id_to_key[int(protocol_id)] = "{}-{}".format(handle, date_string)
+    todos = []
+    for todo_line in todo_lines:
+        for old_id, protocol_id, who, what, start_time, end_time, done in _split_insert_line(todo_line):
+            protocol_id = int(protocol_id)
+            if protocol_id not in protocol_id_to_key:
+                print("Missing protocol with ID {} for Todo {}".format(protocol_id, what))
+                continue
+            todo = OldTodo(old_id=old_id, who=who, description=what,
+                protocol_key=protocol_id_to_key[protocol_id])
+            todos.append(todo)
+    OldTodo.query.delete()
+    db.session.commit()
+    for todo in todos:
+        db.session.add(todo)
+    db.session.commit()
+        
+def _split_insert_line(line):
+    insert_part, values_part = line.split("VALUES", 1)
+    return _split_base_level(values_part)
+
+def _split_base_level(text, begin="(", end=")", separator=",", string_terminator="'", line_end=";", ignore=" ", escape="\\"):
+    raw_parts = []
+    current_part = None
+    index = 0
+    in_string = False
+    escaped = False
+    for char in text:
+        if escaped:
+            current_part += char
+            escaped = False
+        elif current_part is None:
+            if char == ignore:
+                continue
+            elif char == begin:
+                current_part = ""
+            elif char == line_end:
+                break
+            elif char == separator:
+                pass
+            else:
+                raise ValueError(
+                    "Found invalid char '{}' at position {}".format(
+                        char, index))
+        else:
+            if in_string:
+                current_part += char
+                if char == escape:
+                    escaped = True
+                elif char == string_terminator:
+                    in_string = False
+            else:
+                if char == string_terminator:
+                    current_part += char
+                    in_string = True
+                elif char == end:
+                    raw_parts.append(current_part)
+                    current_part = None
+                else:
+                    current_part += char
+        index += 1
+    parts = []
+    for part in raw_parts:
+        fields = []
+        current_field = ""
+        in_string = False
+        escaped = False
+        for char in part:
+            if escaped:
+                current_field += char
+                escaped = False
+            elif in_string:
+                if char == escape:
+                    escaped = True
+                elif char == string_terminator:
+                    in_string = False
+                else:
+                    current_field += char
+            else:
+                if char == string_terminator:
+                    in_string = True
+                elif char == separator:
+                    fields.append(current_field)
+                    current_field = ""
+                else:
+                    current_field += char
+        if len(current_field) > 0:
+            fields.append(current_field)
+        parts.append(fields)
+    return parts
+        
+    
diff --git a/migrations/versions/0131d5776f8d_.py b/migrations/versions/0131d5776f8d_.py
deleted file mode 100644
index 893bf58e2ce50f49015b69faa02e153698dd0cc5..0000000000000000000000000000000000000000
--- a/migrations/versions/0131d5776f8d_.py
+++ /dev/null
@@ -1,30 +0,0 @@
-"""empty message
-
-Revision ID: 0131d5776f8d
-Revises: 24bd2198a626
-Create Date: 2017-02-24 21:03:34.294388
-
-"""
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision = '0131d5776f8d'
-down_revision = '24bd2198a626'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.add_column('todos', sa.Column('protocoltype_id', sa.Integer(), nullable=True))
-    op.create_foreign_key(None, 'todos', 'protocoltypes', ['protocoltype_id'], ['id'])
-    # ### end Alembic commands ###
-
-
-def downgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.drop_constraint(None, 'todos', type_='foreignkey')
-    op.drop_column('todos', 'protocoltype_id')
-    # ### end Alembic commands ###
diff --git a/migrations/versions/0e5220a9f169_.py b/migrations/versions/0e5220a9f169_.py
deleted file mode 100644
index 027be458abc7cfc6424dad5bfa31bea87ffbbf9b..0000000000000000000000000000000000000000
--- a/migrations/versions/0e5220a9f169_.py
+++ /dev/null
@@ -1,37 +0,0 @@
-"""empty message
-
-Revision ID: 0e5220a9f169
-Revises: 188f389b2286
-Create Date: 2017-02-26 15:53:41.410353
-
-"""
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision = '0e5220a9f169'
-down_revision = '188f389b2286'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.create_table('decisiondocuments',
-    sa.Column('id', sa.Integer(), nullable=False),
-    sa.Column('decision_id', sa.Integer(), nullable=True),
-    sa.Column('name', sa.String(), nullable=True),
-    sa.Column('filename', sa.String(), nullable=True),
-    sa.Column('is_compiled', sa.Boolean(), nullable=True),
-    sa.Column('is_private', sa.Boolean(), nullable=True),
-    sa.ForeignKeyConstraint(['decision_id'], ['decisions.id'], ),
-    sa.PrimaryKeyConstraint('id')
-    )
-    # ### end Alembic commands ###
-
-
-def downgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.drop_table('decisiondocuments')
-    # ### end Alembic commands ###
diff --git a/migrations/versions/162da8aeeb71_.py b/migrations/versions/162da8aeeb71_.py
deleted file mode 100644
index e3b0effdd0639502c3dc8e602dfa202681d0beb8..0000000000000000000000000000000000000000
--- a/migrations/versions/162da8aeeb71_.py
+++ /dev/null
@@ -1,30 +0,0 @@
-"""empty message
-
-Revision ID: 162da8aeeb71
-Revises: a3d9d1b87ba0
-Create Date: 2017-02-22 16:52:08.142214
-
-"""
-from alembic import op
-import sqlalchemy as sa
-from sqlalchemy.dialects import postgresql
-
-# revision identifiers, used by Alembic.
-revision = '162da8aeeb71'
-down_revision = 'a3d9d1b87ba0'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.add_column('meetingreminders', sa.Column('days_before', sa.Integer(), nullable=True))
-    op.drop_column('meetingreminders', 'time_before')
-    # ### end Alembic commands ###
-
-
-def downgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.add_column('meetingreminders', sa.Column('time_before', postgresql.INTERVAL(), autoincrement=False, nullable=True))
-    op.drop_column('meetingreminders', 'days_before')
-    # ### end Alembic commands ###
diff --git a/migrations/versions/188f389b2286_.py b/migrations/versions/188f389b2286_.py
deleted file mode 100644
index 6e7f9ecd6af55e3649db8e41541ee1073a375e2c..0000000000000000000000000000000000000000
--- a/migrations/versions/188f389b2286_.py
+++ /dev/null
@@ -1,34 +0,0 @@
-"""empty message
-
-Revision ID: 188f389b2286
-Revises: 515d261a624b
-Create Date: 2017-02-26 12:55:43.761405
-
-"""
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision = '188f389b2286'
-down_revision = '515d261a624b'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.create_table('todomails',
-    sa.Column('id', sa.Integer(), nullable=False),
-    sa.Column('name', sa.String(), nullable=True),
-    sa.Column('mail', sa.String(), nullable=True),
-    sa.PrimaryKeyConstraint('id'),
-    sa.UniqueConstraint('name')
-    )
-    # ### end Alembic commands ###
-
-
-def downgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.drop_table('todomails')
-    # ### end Alembic commands ###
diff --git a/migrations/versions/24bd2198a626_.py b/migrations/versions/24bd2198a626_.py
deleted file mode 100644
index 78cbf6c47d5ac36c5915f530e72a5d7134e1ba8b..0000000000000000000000000000000000000000
--- a/migrations/versions/24bd2198a626_.py
+++ /dev/null
@@ -1,28 +0,0 @@
-"""empty message
-
-Revision ID: 24bd2198a626
-Revises: 2e2682dfac21
-Create Date: 2017-02-24 17:20:07.135782
-
-"""
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision = '24bd2198a626'
-down_revision = '2e2682dfac21'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.add_column('todos', sa.Column('number', sa.Integer(), nullable=True))
-    # ### end Alembic commands ###
-
-
-def downgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.drop_column('todos', 'number')
-    # ### end Alembic commands ###
diff --git a/migrations/versions/2e2682dfac21_.py b/migrations/versions/2e2682dfac21_.py
deleted file mode 100644
index 60d7a0f5320d4f82c8f1d21a2349a31138cba552..0000000000000000000000000000000000000000
--- a/migrations/versions/2e2682dfac21_.py
+++ /dev/null
@@ -1,28 +0,0 @@
-"""empty message
-
-Revision ID: 2e2682dfac21
-Revises: aebae2c4523d
-Create Date: 2017-02-24 16:31:01.729972
-
-"""
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision = '2e2682dfac21'
-down_revision = 'aebae2c4523d'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.add_column('todos', sa.Column('is_id_fixed', sa.Boolean(), nullable=True))
-    # ### end Alembic commands ###
-
-
-def downgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.drop_column('todos', 'is_id_fixed')
-    # ### end Alembic commands ###
diff --git a/migrations/versions/310d9ab321b8_.py b/migrations/versions/310d9ab321b8_.py
deleted file mode 100644
index 1ebf1e1b726413f66fdee5cd862c355cdfa04e3e..0000000000000000000000000000000000000000
--- a/migrations/versions/310d9ab321b8_.py
+++ /dev/null
@@ -1,32 +0,0 @@
-"""empty message
-
-Revision ID: 310d9ab321b8
-Revises: 0131d5776f8d
-Create Date: 2017-02-25 17:26:34.663460
-
-"""
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision = '310d9ab321b8'
-down_revision = '0131d5776f8d'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.add_column('protocols', sa.Column('plain_text_private', sa.String(), nullable=True))
-    op.add_column('protocols', sa.Column('plain_text_public', sa.String(), nullable=True))
-    op.drop_column('todos', 'is_id_fixed')
-    # ### end Alembic commands ###
-
-
-def downgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.add_column('todos', sa.Column('is_id_fixed', sa.BOOLEAN(), autoincrement=False, nullable=True))
-    op.drop_column('protocols', 'plain_text_public')
-    op.drop_column('protocols', 'plain_text_private')
-    # ### end Alembic commands ###
diff --git a/migrations/versions/495509e8f49a_.py b/migrations/versions/495509e8f49a_.py
deleted file mode 100644
index 61fb9dc21c504f31dcab372d02e6e8b9c0d88d3f..0000000000000000000000000000000000000000
--- a/migrations/versions/495509e8f49a_.py
+++ /dev/null
@@ -1,34 +0,0 @@
-"""empty message
-
-Revision ID: 495509e8f49a
-Revises: 310d9ab321b8
-Create Date: 2017-02-25 17:34:03.830014
-
-"""
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision = '495509e8f49a'
-down_revision = '310d9ab321b8'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.add_column('protocols', sa.Column('content_private', sa.String(), nullable=True))
-    op.add_column('protocols', sa.Column('content_public', sa.String(), nullable=True))
-    op.drop_column('protocols', 'plain_text_private')
-    op.drop_column('protocols', 'plain_text_public')
-    # ### end Alembic commands ###
-
-
-def downgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.add_column('protocols', sa.Column('plain_text_public', sa.VARCHAR(), autoincrement=False, nullable=True))
-    op.add_column('protocols', sa.Column('plain_text_private', sa.VARCHAR(), autoincrement=False, nullable=True))
-    op.drop_column('protocols', 'content_public')
-    op.drop_column('protocols', 'content_private')
-    # ### end Alembic commands ###
diff --git a/migrations/versions/515d261a624b_.py b/migrations/versions/515d261a624b_.py
deleted file mode 100644
index 2b5f42eb6e83a5937ea39f00a29557c2e77fcf62..0000000000000000000000000000000000000000
--- a/migrations/versions/515d261a624b_.py
+++ /dev/null
@@ -1,28 +0,0 @@
-"""empty message
-
-Revision ID: 515d261a624b
-Revises: 77bf71eef07f
-Create Date: 2017-02-26 00:33:13.555804
-
-"""
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision = '515d261a624b'
-down_revision = '77bf71eef07f'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.add_column('protocoltypes', sa.Column('usual_time', sa.Time(), nullable=True))
-    # ### end Alembic commands ###
-
-
-def downgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.drop_column('protocoltypes', 'usual_time')
-    # ### end Alembic commands ###
diff --git a/migrations/versions/77bf71eef07f_.py b/migrations/versions/77bf71eef07f_.py
deleted file mode 100644
index 71827d3377ee44516c47eeb45eb44f3609e8fffe..0000000000000000000000000000000000000000
--- a/migrations/versions/77bf71eef07f_.py
+++ /dev/null
@@ -1,28 +0,0 @@
-"""empty message
-
-Revision ID: 77bf71eef07f
-Revises: f91d760158dc
-Create Date: 2017-02-26 00:26:48.499578
-
-"""
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision = '77bf71eef07f'
-down_revision = 'f91d760158dc'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.add_column('meetingreminders', sa.Column('additional_text', sa.String(), nullable=True))
-    # ### end Alembic commands ###
-
-
-def downgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.drop_column('meetingreminders', 'additional_text')
-    # ### end Alembic commands ###
diff --git a/migrations/versions/97cf1913e60d_.py b/migrations/versions/97cf1913e60d_.py
deleted file mode 100644
index 18377e9e13ce5039ae4bfb5749a0a8a9cc514935..0000000000000000000000000000000000000000
--- a/migrations/versions/97cf1913e60d_.py
+++ /dev/null
@@ -1,28 +0,0 @@
-"""empty message
-
-Revision ID: 97cf1913e60d
-Revises: bbc1782c0999
-Create Date: 2017-02-22 23:36:29.467493
-
-"""
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision = '97cf1913e60d'
-down_revision = 'bbc1782c0999'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.add_column('tops', sa.Column('number', sa.Integer(), nullable=True))
-    # ### end Alembic commands ###
-
-
-def downgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.drop_column('tops', 'number')
-    # ### end Alembic commands ###
diff --git a/migrations/versions/a3d9d1b87ba0_.py b/migrations/versions/a3d9d1b87ba0_.py
deleted file mode 100644
index 989795b9615d87f3b587b1da658a63e9d2eed57e..0000000000000000000000000000000000000000
--- a/migrations/versions/a3d9d1b87ba0_.py
+++ /dev/null
@@ -1,28 +0,0 @@
-"""empty message
-
-Revision ID: a3d9d1b87ba0
-Revises: efaa3b4fd3e8
-Create Date: 2017-02-22 16:00:02.816515
-
-"""
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision = 'a3d9d1b87ba0'
-down_revision = 'efaa3b4fd3e8'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.add_column('protocols', sa.Column('done', sa.Boolean(), nullable=True))
-    # ### end Alembic commands ###
-
-
-def downgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.drop_column('protocols', 'done')
-    # ### end Alembic commands ###
diff --git a/migrations/versions/aebae2c4523d_.py b/migrations/versions/aebae2c4523d_.py
deleted file mode 100644
index e70a55cf9c2c3be79e781d8af8ec507fb3fd8303..0000000000000000000000000000000000000000
--- a/migrations/versions/aebae2c4523d_.py
+++ /dev/null
@@ -1,28 +0,0 @@
-"""empty message
-
-Revision ID: aebae2c4523d
-Revises: b114754024fb
-Create Date: 2017-02-24 05:58:37.240601
-
-"""
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision = 'aebae2c4523d'
-down_revision = 'b114754024fb'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.drop_constraint('documents_filename_key', 'documents', type_='unique')
-    # ### end Alembic commands ###
-
-
-def downgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.create_unique_constraint('documents_filename_key', 'documents', ['filename'])
-    # ### end Alembic commands ###
diff --git a/migrations/versions/b114754024fb_.py b/migrations/versions/b114754024fb_.py
deleted file mode 100644
index a381a071d6d2a82d07d7c70925e71ad03673de2c..0000000000000000000000000000000000000000
--- a/migrations/versions/b114754024fb_.py
+++ /dev/null
@@ -1,28 +0,0 @@
-"""empty message
-
-Revision ID: b114754024fb
-Revises: 97cf1913e60d
-Create Date: 2017-02-23 20:33:56.446729
-
-"""
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision = 'b114754024fb'
-down_revision = '97cf1913e60d'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.add_column('documents', sa.Column('is_private', sa.Boolean(), nullable=True))
-    # ### end Alembic commands ###
-
-
-def downgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.drop_column('documents', 'is_private')
-    # ### end Alembic commands ###
diff --git a/migrations/versions/bbc1782c0999_.py b/migrations/versions/bbc1782c0999_.py
deleted file mode 100644
index d1787c079b8dfa99374803fe409d265cb618bf22..0000000000000000000000000000000000000000
--- a/migrations/versions/bbc1782c0999_.py
+++ /dev/null
@@ -1,28 +0,0 @@
-"""empty message
-
-Revision ID: bbc1782c0999
-Revises: 162da8aeeb71
-Create Date: 2017-02-22 23:36:11.613892
-
-"""
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision = 'bbc1782c0999'
-down_revision = '162da8aeeb71'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.drop_column('tops', 'number')
-    # ### end Alembic commands ###
-
-
-def downgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.add_column('tops', sa.Column('number', sa.VARCHAR(), autoincrement=False, nullable=True))
-    # ### end Alembic commands ###
diff --git a/migrations/versions/efaa3b4fd3e8_.py b/migrations/versions/d543c6a2ea6e_.py
similarity index 69%
rename from migrations/versions/efaa3b4fd3e8_.py
rename to migrations/versions/d543c6a2ea6e_.py
index 3b712ee6b23d28fc4831787acffa121b70352f14..33ba88798e3da9a710eed4560737d36fe5f1f825 100644
--- a/migrations/versions/efaa3b4fd3e8_.py
+++ b/migrations/versions/d543c6a2ea6e_.py
@@ -1,8 +1,8 @@
 """empty message
 
-Revision ID: efaa3b4fd3e8
+Revision ID: d543c6a2ea6e
 Revises: 
-Create Date: 2017-02-22 05:27:41.905321
+Create Date: 2017-02-27 20:41:51.001496
 
 """
 from alembic import op
@@ -10,7 +10,7 @@ import sqlalchemy as sa
 
 
 # revision identifiers, used by Alembic.
-revision = 'efaa3b4fd3e8'
+revision = 'd543c6a2ea6e'
 down_revision = None
 branch_labels = None
 depends_on = None
@@ -18,27 +18,39 @@ depends_on = None
 
 def upgrade():
     # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('oldtodos',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('old_id', sa.Integer(), nullable=True),
+    sa.Column('who', sa.String(), nullable=True),
+    sa.Column('description', sa.String(), nullable=True),
+    sa.Column('protocol_key', sa.String(), nullable=True),
+    sa.PrimaryKeyConstraint('id')
+    )
     op.create_table('protocoltypes',
     sa.Column('id', sa.Integer(), nullable=False),
     sa.Column('name', sa.String(), nullable=True),
     sa.Column('short_name', sa.String(), nullable=True),
     sa.Column('organization', sa.String(), nullable=True),
+    sa.Column('usual_time', sa.Time(), nullable=True),
     sa.Column('is_public', sa.Boolean(), nullable=True),
     sa.Column('private_group', sa.String(), nullable=True),
     sa.Column('public_group', sa.String(), nullable=True),
     sa.Column('private_mail', sa.String(), nullable=True),
     sa.Column('public_mail', sa.String(), nullable=True),
+    sa.Column('use_wiki', sa.Boolean(), nullable=True),
+    sa.Column('wiki_category', sa.String(), nullable=True),
+    sa.Column('wiki_only_public', sa.Boolean(), nullable=True),
+    sa.Column('printer', sa.String(), nullable=True),
     sa.PrimaryKeyConstraint('id'),
     sa.UniqueConstraint('name'),
     sa.UniqueConstraint('short_name')
     )
-    op.create_table('todos',
+    op.create_table('todomails',
     sa.Column('id', sa.Integer(), nullable=False),
-    sa.Column('who', sa.String(), nullable=True),
-    sa.Column('description', sa.String(), nullable=True),
-    sa.Column('tags', sa.String(), nullable=True),
-    sa.Column('done', sa.Boolean(), nullable=True),
-    sa.PrimaryKeyConstraint('id')
+    sa.Column('name', sa.String(), nullable=True),
+    sa.Column('mail', sa.String(), nullable=True),
+    sa.PrimaryKeyConstraint('id'),
+    sa.UniqueConstraint('name')
     )
     op.create_table('defaulttops',
     sa.Column('id', sa.Integer(), nullable=False),
@@ -51,9 +63,10 @@ def upgrade():
     op.create_table('meetingreminders',
     sa.Column('id', sa.Integer(), nullable=False),
     sa.Column('protocoltype_id', sa.Integer(), nullable=True),
-    sa.Column('time_before', sa.Interval(), nullable=True),
+    sa.Column('days_before', sa.Integer(), nullable=True),
     sa.Column('send_public', sa.Boolean(), nullable=True),
     sa.Column('send_private', sa.Boolean(), nullable=True),
+    sa.Column('additional_text', sa.String(), nullable=True),
     sa.ForeignKeyConstraint(['protocoltype_id'], ['protocoltypes.id'], ),
     sa.PrimaryKeyConstraint('id')
     )
@@ -61,12 +74,26 @@ def upgrade():
     sa.Column('id', sa.Integer(), nullable=False),
     sa.Column('protocoltype_id', sa.Integer(), nullable=True),
     sa.Column('source', sa.String(), nullable=True),
+    sa.Column('content_public', sa.String(), nullable=True),
+    sa.Column('content_private', sa.String(), nullable=True),
     sa.Column('date', sa.Date(), nullable=True),
     sa.Column('start_time', sa.Time(), nullable=True),
     sa.Column('end_time', sa.Time(), nullable=True),
     sa.Column('author', sa.String(), nullable=True),
     sa.Column('participants', sa.String(), nullable=True),
     sa.Column('location', sa.String(), nullable=True),
+    sa.Column('done', sa.Boolean(), nullable=True),
+    sa.ForeignKeyConstraint(['protocoltype_id'], ['protocoltypes.id'], ),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_table('todos',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('protocoltype_id', sa.Integer(), nullable=True),
+    sa.Column('number', sa.Integer(), nullable=True),
+    sa.Column('who', sa.String(), nullable=True),
+    sa.Column('description', sa.String(), nullable=True),
+    sa.Column('state', sa.Enum('open', 'waiting', 'in_progress', 'after', 'before', 'orphan', 'done', 'rejected', 'obsolete', name='todostate'), nullable=False),
+    sa.Column('date', sa.Date(), nullable=True),
     sa.ForeignKeyConstraint(['protocoltype_id'], ['protocoltypes.id'], ),
     sa.PrimaryKeyConstraint('id')
     )
@@ -83,9 +110,9 @@ def upgrade():
     sa.Column('name', sa.String(), nullable=True),
     sa.Column('filename', sa.String(), nullable=True),
     sa.Column('is_compiled', sa.Boolean(), nullable=True),
+    sa.Column('is_private', sa.Boolean(), nullable=True),
     sa.ForeignKeyConstraint(['protocol_id'], ['protocols.id'], ),
-    sa.PrimaryKeyConstraint('id'),
-    sa.UniqueConstraint('filename')
+    sa.PrimaryKeyConstraint('id')
     )
     op.create_table('errors',
     sa.Column('id', sa.Integer(), nullable=False),
@@ -108,24 +135,35 @@ def upgrade():
     sa.Column('id', sa.Integer(), nullable=False),
     sa.Column('protocol_id', sa.Integer(), nullable=True),
     sa.Column('name', sa.String(), nullable=True),
-    sa.Column('number', sa.String(), nullable=True),
+    sa.Column('number', sa.Integer(), nullable=True),
     sa.Column('planned', sa.Boolean(), nullable=True),
     sa.ForeignKeyConstraint(['protocol_id'], ['protocols.id'], ),
     sa.PrimaryKeyConstraint('id')
     )
+    op.create_table('decisiondocuments',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('decision_id', sa.Integer(), nullable=True),
+    sa.Column('name', sa.String(), nullable=True),
+    sa.Column('filename', sa.String(), nullable=True),
+    sa.ForeignKeyConstraint(['decision_id'], ['decisions.id'], ),
+    sa.PrimaryKeyConstraint('id')
+    )
     # ### end Alembic commands ###
 
 
 def downgrade():
     # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_table('decisiondocuments')
     op.drop_table('tops')
     op.drop_table('todoprotocolassociations')
     op.drop_table('errors')
     op.drop_table('documents')
     op.drop_table('decisions')
+    op.drop_table('todos')
     op.drop_table('protocols')
     op.drop_table('meetingreminders')
     op.drop_table('defaulttops')
-    op.drop_table('todos')
+    op.drop_table('todomails')
     op.drop_table('protocoltypes')
+    op.drop_table('oldtodos')
     # ### end Alembic commands ###
diff --git a/migrations/versions/d8c0c74b88bd_.py b/migrations/versions/d8c0c74b88bd_.py
deleted file mode 100644
index 7183f594a40357470412d931cdbae71af149aa05..0000000000000000000000000000000000000000
--- a/migrations/versions/d8c0c74b88bd_.py
+++ /dev/null
@@ -1,32 +0,0 @@
-"""empty message
-
-Revision ID: d8c0c74b88bd
-Revises: 495509e8f49a
-Create Date: 2017-02-25 20:16:05.371638
-
-"""
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision = 'd8c0c74b88bd'
-down_revision = '495509e8f49a'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.add_column('protocoltypes', sa.Column('use_wiki', sa.Boolean(), nullable=True))
-    op.add_column('protocoltypes', sa.Column('wiki_category', sa.String(), nullable=True))
-    op.add_column('protocoltypes', sa.Column('wiki_only_public', sa.Boolean(), nullable=True))
-    # ### end Alembic commands ###
-
-
-def downgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.drop_column('protocoltypes', 'wiki_only_public')
-    op.drop_column('protocoltypes', 'wiki_category')
-    op.drop_column('protocoltypes', 'use_wiki')
-    # ### end Alembic commands ###
diff --git a/migrations/versions/f91d760158dc_.py b/migrations/versions/f91d760158dc_.py
deleted file mode 100644
index 8a868a58746b47eb43b2370bbdac7a15c3c56b1c..0000000000000000000000000000000000000000
--- a/migrations/versions/f91d760158dc_.py
+++ /dev/null
@@ -1,28 +0,0 @@
-"""empty message
-
-Revision ID: f91d760158dc
-Revises: d8c0c74b88bd
-Create Date: 2017-02-25 21:52:07.654276
-
-"""
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision = 'f91d760158dc'
-down_revision = 'd8c0c74b88bd'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.add_column('protocoltypes', sa.Column('printer', sa.String(), nullable=True))
-    # ### end Alembic commands ###
-
-
-def downgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.drop_column('protocoltypes', 'printer')
-    # ### end Alembic commands ###
diff --git a/models/database.py b/models/database.py
index b041a2f0bf1a61f06345ae5dfe00530ca8a76c4b..55a0f5da20a199e10d1a7f9be3fa046ad351abff 100644
--- a/models/database.py
+++ b/models/database.py
@@ -3,8 +3,9 @@ from flask import render_template, send_file, url_for, redirect, flash, request
 from datetime import datetime, time, date, timedelta
 import math
 from io import StringIO, BytesIO
+from enum import Enum
 
-from shared import db
+from shared import db, date_filter, escape_tex, DATE_KEY, START_TIME_KEY, END_TIME_KEY, AUTHOR_KEY, PARTICIPANTS_KEY, LOCATION_KEY
 from utils import random_string, url_manager, get_etherpad_url, split_terms
 from models.errors import DateNotMatchingException
 
@@ -15,6 +16,7 @@ from sqlalchemy.orm import relationship, backref, sessionmaker
 from sqlalchemy.ext.hybrid import hybrid_method
 
 import config
+from todostates import make_states
 
 class ProtocolType(db.Model):
     __tablename__ = "protocoltypes"
@@ -147,17 +149,42 @@ class Protocol(db.Model):
         return Error(self.id, action, name, now, description)
 
     def fill_from_remarks(self, remarks):
-        new_date = datetime.strptime(remarks["Datum"].value.strip(), "%d.%m.%Y").date()
-        if self.date is not None:
-            if new_date != self.date:
-                raise DateNotMatchingException(original_date=self.date, protocol_date=new_date)
-        else:
-            self.date = new_date
-        self.start_time = datetime.strptime(remarks["Beginn"].value.strip(), "%H:%M").time()
-        self.end_time = datetime.strptime(remarks["Ende"].value.strip(), "%H:%M").time()
-        self.author = remarks["Autor"].value.strip()
-        self.participants = remarks["Anwesende"].value.strip()
-        self.location = remarks["Ort"].value.strip()
+        def _date_or_lazy(key, get_date=False, get_time=False):
+            formats = []
+            if get_date:
+                formats.append("%d.%m.%Y")
+            if get_time:
+                formats.append("%H:%M")
+            format = " ".join(formats)
+            try:
+                date = datetime.strptime(remarks[key].value.strip(), format)
+                if (get_time and get_date) or (not get_time and not get_date):
+                    return date
+                elif get_time:
+                    return date.time()
+                elif get_date:
+                    return date.date()
+            except ValueError as exc:
+                if config.PARSER_LAZY:
+                    return None
+                raise exc
+        if DATE_KEY in remarks:
+            new_date = _date_or_lazy(DATE_KEY, get_date=True)
+            if self.date is not None:
+                if new_date != self.date:
+                    raise DateNotMatchingException(original_date=self.date, protocol_date=new_date)
+            else:
+                self.date = new_date
+        if START_TIME_KEY in remarks:
+            self.start_time = _date_or_lazy(START_TIME_KEY, get_time=True)
+        if END_TIME_KEY in remarks:
+            self.end_time = _date_or_lazy(END_TIME_KEY, get_time=True)
+        if AUTHOR_KEY in remarks:
+            self.author = remarks[AUTHOR_KEY].value.strip()
+        if PARTICIPANTS_KEY in remarks:
+            self.participants = remarks[PARTICIPANTS_KEY].value.strip()
+        if LOCATION_KEY in remarks:
+            self.location = remarks[LOCATION_KEY].value.strip()
 
     def is_done(self):
         return self.done
@@ -323,7 +350,34 @@ def on_decisions_document_delete(mapper, connection, document):
         if os.path.isfile(document_path):
             os.remove(document_path)
 
+class TodoState(Enum):
+    open = 0
+    waiting = 1
+    in_progress = 2
+    after = 3
+    before = 4
+    orphan = 5
+    done = 6
+    rejected = 7
+    obsolete = 8
 
+    def get_name(self):
+        STATE_TO_NAME, NAME_TO_STATE = make_states(TodoState)
+        return STATE_TO_NAME[self]
+
+    def needs_date(self):
+        return self in [TodoState.after, TodoState.before]
+
+    def is_done(self):
+        return self in [TodoState.done, TodoState.rejected, TodoState.obsolete]
+
+    @staticmethod
+    def from_name(name):
+        name = name.strip().lower()
+        STATE_TO_NAME, NAME_TO_STATE = make_states(TodoState)
+        if name not in NAME_TO_STATE:
+            raise ValueError("Unknown state: '{}'".format(name))
+        return NAME_TO_STATE[name]
 
 class Todo(db.Model):
     __tablename__ = "todos"
@@ -332,22 +386,25 @@ class Todo(db.Model):
     number = db.Column(db.Integer)
     who = db.Column(db.String)
     description = db.Column(db.String)
-    tags = db.Column(db.String)
-    done = db.Column(db.Boolean)
+    state = db.Column(db.Enum(TodoState), nullable=False)
+    date = db.Column(db.Date, nullable=True)
 
     protocols = relationship("Protocol", secondary="todoprotocolassociations", backref="todos")
 
-    def __init__(self, type_id, who, description, tags, done, number=None):
+    def __init__(self, type_id, who, description, state, date, number=None):
         self.protocoltype_id = type_id
+        self.number = number
         self.who = who
         self.description = description
-        self.tags = tags
-        self.done = done
-        self.number = number
+        self.state = state
+        self.date = date
 
     def __repr__(self):
-        return "<Todo(id={}, number={}, who={}, description={}, tags={}, done={})>".format(
-            self.id, self.number, self.who, self.description, self.tags, self.done)
+        return "<Todo(id={}, number={}, who={}, description={}, state={}, date={})>".format(
+            self.id, self.number, self.who, self.description, self.state, self.date)
+
+    def is_done(self):
+        return self.state.is_done()
 
     def get_id(self):
         return self.number if self.number is not None else self.id
@@ -365,9 +422,12 @@ class Todo(db.Model):
         ]
 
     def get_state(self):
-        return "[Erledigt]" if self.done else "[Offen]"
+        return "[{}]".format(self.get_state_plain())
     def get_state_plain(self):
-        return "Erledigt" if self.done else "Aktiv"
+        result = self.state.get_name()
+        if self.state.needs_date():
+            result = "{} {}".format(result, date_filter(self.state.date))
+        return result
     def get_state_tex(self):
         return self.get_state_plain()
 
@@ -387,9 +447,9 @@ class Todo(db.Model):
     def render_latex(self, current_protocol=None):
         return r"\textbf{{{}}}: {}: {} -- {}".format(
             "Neuer Todo" if self.is_new(current_protocol) else "Todo",
-            self.who,
-            self.description,
-            self.get_state_tex()
+            escape_tex(self.who),
+            escape_tex(self.description),
+            escape_tex(self.get_state_tex())
         )
 
     def render_wikitext(self, current_protocol=None):
@@ -401,7 +461,6 @@ class Todo(db.Model):
         )
 
 
-
 class TodoProtocolAssociation(db.Model):
     __tablename__ = "todoprotocolassociations"
     todo_id = db.Column(db.Integer, db.ForeignKey("todos.id"), primary_key=True)
@@ -485,3 +544,22 @@ class TodoMail(db.Model):
 
     def get_formatted_mail(self):
         return "{} <{}>".format(self.name, self.mail)
+
+class OldTodo(db.Model):
+    __tablename__ = "oldtodos"
+    id = db.Column(db.Integer, primary_key=True)
+    old_id = db.Column(db.Integer)
+    who = db.Column(db.String)
+    description = db.Column(db.String)
+    protocol_key = db.Column(db.String)
+
+    def __init__(self, old_id, who, description, protocol_key):
+        self.old_id = old_id
+        self.who = who
+        self.description = description
+        self.protocol_key = protocol_key
+
+    def __repr__(self):
+        return ("<OldTodo(id={}, old_id={}, who='{}', description='{}', "
+            "protocol={}".format(self.id, self.old_id, self.who,
+            self.description, self.protocol_key))
diff --git a/parser.py b/parser.py
index 069aa807b4a5c7fa9c8be3a1e27bf255762fc8ea..d2c5bedd265e978b832a1ea1d205920ff0988610 100644
--- a/parser.py
+++ b/parser.py
@@ -7,13 +7,16 @@ from shared import escape_tex
 
 import config
 
+INDENT_LETTER = "-"
+
 class ParserException(Exception):
     name = "Parser Exception"
     has_explanation = False
     #explanation = "The source did generally not match the expected protocol syntax."
-    def __init__(self, message, linenumber=None):
+    def __init__(self, message, linenumber=None, tree=None):
         self.message = message
         self.linenumber = linenumber
+        self.tree = tree
 
     def __str__(self):
         result = ""
@@ -49,7 +52,7 @@ class Element:
     def dump(self, level=None):
         if level is None:
             level = 0
-        print("{}element".format(" " * level))
+        return "{}element".format(INDENT_LETTER * level)
 
     @staticmethod
     def parse(match, current, linenumber=None):
@@ -111,9 +114,10 @@ class Content(Element):
     def dump(self, level=None):
         if level is None:
             level = 0
-        print("{}content:".format(" " * level))
+        result_lines = ["{}content:".format(INDENT_LETTER * level)]
         for child in self.children:
-            child.dump(level + 1)
+            result_lines.append(child.dump(level + 1))
+        return "\n".join(result_lines)
 
     def get_tags(self, tags):
         tags.extend([child for child in self.children if isinstance(child, Tag)])
@@ -150,7 +154,9 @@ class Content(Element):
     # v1: has problems with missing semicolons
     #PATTERN = r"\s*(?<content>(?:[^\[\];]+)?(?:\[[^\]]+\][^;\[\]]*)*);"
     # v2: does not require the semicolon, but the newline
-    PATTERN = r"\s*(?<content>(?:[^\[\];\r\n]+)?(?:\[[^\]\r\n]+\][^;\[\]\r\n]*)*);?"
+    #PATTERN = r"\s*(?<content>(?:[^\[\];\r\n]+)?(?:\[[^\]\r\n]+\][^;\[\]\r\n]*)*);?"
+    # v3: does not allow braces in the content
+    PATTERN = r"\s*(?<content>(?:[^\[\];\r\n{}]+)?(?:\[[^\]\r\n]+\][^;\[\]\r\n]*)*);?"
 
 class Text:
     def __init__(self, text, linenumber, fork):
@@ -171,7 +177,7 @@ class Text:
     def dump(self, level=None):
         if level is None:
             level = 0
-        print("{}text: {}".format(" " * level, self.text))
+        return "{}text: {}".format(INDENT_LETTER * level, self.text)
 
     @staticmethod
     def parse(match, current, linenumber):
@@ -223,7 +229,7 @@ class Tag:
     def dump(self, level=None):
         if level is None:
             level = 0
-        print("{}tag: {}: {}".format(" " * level, self.name, "; ".join(self.values)))
+        return "{}tag: {}: {}".format(INDENT_LETTER * level, self.name, "; ".join(self.values))
 
     @staticmethod
     def parse(match, current, linenumber):
@@ -247,7 +253,7 @@ class Empty(Element):
     def dump(self, level=None):
         if level is None:
             level = 0
-        print("{}empty".format(" " * level))
+        return "{}empty".format(INDENT_LETTER * level)
 
     @staticmethod
     def parse(match, current, linenumber=None):
@@ -273,7 +279,7 @@ class Remark(Element):
     def dump(self, level=None):
         if level is None:
             level = 0
-        print("{}remark: {}: {}".format(" " * level, self.name, self.value))
+        return "{}remark: {}: {}".format(INDENT_LETTER * level, self.name, self.value)
 
     def get_tags(self, tags):
         return tags
@@ -305,16 +311,22 @@ class Fork(Element):
     def dump(self, level=None):
         if level is None:
             level = 0
-        print("{}fork: {}".format(" " * level, self.name))
+        result_lines = ["{}fork: {}".format(INDENT_LETTER * level, self.name)]
         for child in self.children:
-            child.dump(level + 1)
+            result_lines.append(child.dump(level + 1))
+        return "\n".join(result_lines)
 
     def test_private(self, name):
         stripped_name = name.replace(":", "").strip()
         return stripped_name in config.PRIVATE_KEYWORDS
 
     def render(self, render_type, show_private, level, protocol=None):
-        name_line = self.name if self.name is not None and len(self.name) > 0 else ""
+        name_parts = []
+        if self.environment is not None:
+            name_parts.append(self.environment)
+        if self.name is not None:
+            name_parts.append(self.name)
+        name_line = " ".join(name_parts)
         if level == 0 and self.name == "Todos" and not show_private:
             return ""
         if render_type == RenderType.latex:
@@ -400,7 +412,10 @@ class Fork(Element):
         if name1 is not None:
             name = name1
         if name2 is not None:
-            name += " {}".format(name2)
+            if len(name) > 0:
+                name += " {}".format(name2)
+            else:
+                name = name2
         element = Fork(environment, name, current, linenumber)
         current = Element.parse_outer(element, current)
         return current, linenumber
@@ -416,7 +431,10 @@ class Fork(Element):
     def append(self, element):
         self.children.append(element)
 
-    PATTERN = r"\s*(?<name1>[^{};]+)?{(?<environment>\S+)?\h*(?<name2>[^\n]+)?"
+    # v1: has a problem with old protocols that do not use a lot of semicolons
+    #PATTERN = r"\s*(?<name1>[^{};]+)?{(?<environment>\S+)?\h*(?<name2>[^\n]+)?"
+    # v2: do not allow newlines in name1 or semicolons in name2
+    PATTERN = r"\s*(?<name1>[^{};\n]+)?{(?<environment>\S+)?\h*(?<name2>[^;\n]+)?"
     END_PATTERN = r"\s*};?"
 
 PATTERNS = OrderedDict([
@@ -448,7 +466,7 @@ def parse(source):
         if not found:
             raise ParserException("No matching syntax element found!", linenumber)
     if current is not tree:
-        raise ParserException("Source ended within fork! (started at line {})".format(current.linenumber))
+        raise ParserException("Source ended within fork! (started at line {})".format(current.linenumber), linenumber=current.linenumber, tree=tree)
     return tree
 
 def main(test_file_name=None):
diff --git a/requirements.txt b/requirements.txt
index 73034a329f1f111288cb4eb183fdea310aaf1ccb..218d809d2873d46c835358172d46e6ca057a48f7 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -16,6 +16,7 @@ Flask-Script==2.0.5
 Flask-SocketIO==2.8.4
 Flask-SQLAlchemy==2.1
 Flask-WTF==0.14.2
+fuzzywuzzy==0.15.0
 greenlet==0.4.12
 itsdangerous==0.24
 Jinja2==2.9.5
@@ -30,6 +31,7 @@ pyldap==2.4.28
 pyparsing==2.1.10
 python-editor==1.0.3
 python-engineio==1.2.2
+python-Levenshtein==0.12.0
 python-socketio==1.7.1
 pytz==2016.10
 PyYAML==3.12
diff --git a/server.py b/server.py
index cbe9fd0535499bb101c17cb1e9c187faec9f96b0..e1a8ac59fdc2c01325e694f5bc48590819cfa706 100755
--- a/server.py
+++ b/server.py
@@ -24,6 +24,7 @@ from utils import is_past, mail_manager, url_manager, get_first_unused_int, set_
 from models.database import ProtocolType, Protocol, DefaultTOP, TOP, Document, Todo, Decision, MeetingReminder, Error, TodoMail, DecisionDocument
 from views.forms import LoginForm, ProtocolTypeForm, DefaultTopForm, MeetingReminderForm, NewProtocolForm, DocumentUploadForm, KnownProtocolSourceUploadForm, NewProtocolSourceUploadForm, ProtocolForm, TopForm, SearchForm, NewProtocolFileUploadForm, NewTodoForm, TodoForm, TodoMailForm
 from views.tables import ProtocolsTable, ProtocolTypesTable, ProtocolTypeTable, DefaultTOPsTable, MeetingRemindersTable, ErrorsTable, TodosTable, DocumentsTable, DecisionsTable, TodoTable, ErrorTable, TodoMailsTable
+from legacy import import_old_todos
 
 app = Flask(__name__)
 app.config.from_object(config)
@@ -75,6 +76,13 @@ app.jinja_env.globals.update(dir=dir)
 
 # blueprints here
 
+@manager.command
+def import_legacy():
+    """Import the old todos from an sql dump"""
+    filename = prompt("SQL-file")
+    with open(filename, "r") as sqlfile:
+        import_old_todos(sqlfile.read())
+
 @app.route("/")
 def index():
     user = current_user()
@@ -103,8 +111,9 @@ def index():
     todos = None
     if check_login():
         todos = [
-            todo for todo in Todo.query.filter(Todo.done == False).all()
+            todo for todo in Todo.query.all()
             if todo.protocoltype.has_public_view_right(user)
+            and not todo.is_done()
         ]
     todos_table = TodosTable(todos) if todos is not None else None
     return render_template("index.html", open_protocols=open_protocols, protocol=protocol, todos=todos, todos_table=todos_table)
@@ -763,7 +772,7 @@ def list_todos():
         ]
     def _sort_key(todo):
         first_protocol = todo.get_first_protocol()
-        result = (not todo.done, first_protocol.date if first_protocol is not None else datetime.now().date())
+        result = (not todo.is_done(), first_protocol.date if first_protocol is not None else datetime.now().date())
         return result
     todos = sorted(todos, key=_sort_key, reverse=True)
     page = _get_page()
@@ -855,8 +864,6 @@ def delete_todo(todo_id):
     db.session.commit()
     flash("Todo gelöscht.", "alert-success")
     return redirect(request.args.get("next") or url_for("list_todos", protocoltype=type_id))
-    
-
 
 @app.route("/decisions/list")
 def list_decisions():
diff --git a/shared.py b/shared.py
index d7a6b65e4dbe57be217c8b55f32737d2f36cd1c1..1fca78b3e0b8bbff63bc5536b0e05b5be6208471 100644
--- a/shared.py
+++ b/shared.py
@@ -21,16 +21,16 @@ latex_chars = [
     #('[', '\\['),
     #(']', '\\]'),
     #('"', '"\''),
-    ('~', '$\\sim{}$'),
-    ('^', '\\textasciicircum{}'),
-    ('Ë„', '\\textasciicircum{}'),
+    ('~', r'$\sim{}$'),
+    ('^', r'\textasciicircum{}'),
+    ('Ë„', r'\textasciicircum{}'),
     ('`', '{}`'),
-    ('-->', '$\longrightarrow$'),
-    ('->', '$\rightarrow$'),
-    ('==>', '$\Longrightarrow$'),
-    ('=>', '$\Rightarrow$'),
-    ('>=', '$\geq$'),
-    ('=<', '$\leq$'),
+    ('-->', r'$\longrightarrow$'),
+    ('->', r'$\rightarrow$'),
+    ('==>', r'$\Longrightarrow$'),
+    ('=>', r'$\Rightarrow$'),
+    ('>=', r'$\geq$'),
+    ('=<', r'$\leq$'),
     ('<', '$<$'),
     ('>', '$>$'),
     ('\\backslashin', '$\\in$'),
@@ -111,4 +111,12 @@ def group_required(function, group):
             return redirect(request.args.get("next") or url_for("index"))
     return decorated_function
 
-
+DATE_KEY = "Datum"
+START_TIME_KEY = "Beginn"
+END_TIME_KEY = "Ende"
+AUTHOR_KEY = "Autor"
+PARTICIPANTS_KEY = "Anwesende"
+LOCATION_KEY = "Ort"
+KNOWN_KEYS = [DATE_KEY, START_TIME_KEY, END_TIME_KEY, AUTHOR_KEY,
+    PARTICIPANTS_KEY, LOCATION_KEY
+]
diff --git a/tasks.py b/tasks.py
index 4bfdca1fc4ab92b2555f63b3724248825dbed041..658f81dc2cd7d24156f39b1751d22c5136440189 100644
--- a/tasks.py
+++ b/tasks.py
@@ -4,14 +4,16 @@ import os
 import subprocess
 import shutil
 import tempfile
+from datetime import datetime
 
-from models.database import Document, Protocol, Error, Todo, Decision, TOP, DefaultTOP, MeetingReminder, TodoMail, DecisionDocument
+from models.database import Document, Protocol, Error, Todo, Decision, TOP, DefaultTOP, MeetingReminder, TodoMail, DecisionDocument, TodoState, OldTodo
 from models.errors import DateNotMatchingException
 from server import celery, app
-from shared import db, escape_tex, unhyphen, date_filter, datetime_filter, date_filter_long, date_filter_short, time_filter, class_filter
+from shared import db, escape_tex, unhyphen, date_filter, datetime_filter, date_filter_long, date_filter_short, time_filter, class_filter, KNOWN_KEYS
 from utils import mail_manager, url_manager, encode_kwargs, decode_kwargs
 from parser import parse, ParserException, Element, Content, Text, Tag, Remark, Fork, RenderType
 from wiki import WikiClient, WikiException
+from legacy import lookup_todo_id, import_old_todos
 
 import config
 
@@ -69,22 +71,25 @@ def parse_protocol_async(protocol_id, encoded_kwargs):
             except ParserException as exc:
                 context = ""
                 if exc.linenumber is not None:
-                    source_lines = source.splitlines()
+                    source_lines = protocol.source.splitlines()
                     start_index = max(0, exc.linenumber - config.ERROR_CONTEXT_LINES)
                     end_index = min(len(source_lines) - 1, exc.linenumber + config.ERROR_CONTEXT_LINES)
                     context = "\n".join(source_lines[start_index:end_index])
+                if exc.tree is not None:
+                    context += "\n\nParsed syntax tree was:\n" + str(exc.tree.dump())
                 error = protocol.create_error("Parsing", str(exc), context)
                 db.session.add(error)
                 db.session.commit()
                 return
             remarks = {element.name: element for element in tree.children if isinstance(element, Remark)}
-            required_fields = ["Datum", "Anwesende", "Beginn", "Ende", "Autor", "Ort"]
-            missing_fields = [field for field in required_fields if field not in remarks]
-            if len(missing_fields) > 0:
-                error = protocol.create_error("Parsing", "Missing fields", ", ".join(missing_fields))
-                db.session.add(error)
-                db.session.commit()
-                return
+            required_fields = KNOWN_KEYS
+            if not config.PARSER_LAZY:
+                missing_fields = [field for field in required_fields if field not in remarks]
+                if len(missing_fields) > 0:
+                    error = protocol.create_error("Parsing", "Missing fields", ", ".join(missing_fields))
+                    db.session.add(error)
+                    db.session.commit()
+                    return
             try:
                 protocol.fill_from_remarks(remarks)
             except ValueError:
@@ -92,7 +97,11 @@ def parse_protocol_async(protocol_id, encoded_kwargs):
                     "Parsing", "Invalid fields",
                     "Date or time fields are not '%d.%m.%Y' respectively '%H:%M', "
                     "but rather {}".format(
-                    ", ".join([remarks["Datum"], remarks["Beginn"], remarks["Ende"]])))
+                    ", ".join([
+                        remarks["Datum"].value.strip(),
+                        remarks["Beginn"].value.strip(),
+                        remarks["Ende"].value.strip()
+                    ])))
                 db.session.add(error)
                 db.session.commit()
                 return
@@ -109,6 +118,7 @@ def parse_protocol_async(protocol_id, encoded_kwargs):
                 protocol.todos.remove(todo)
             db.session.commit()
             tags = tree.get_tags()
+            # todos
             todo_tags = [tag for tag in tags if tag.name == "todo"]
             for todo_tag in todo_tags:
                 if len(todo_tag.values) < 2:
@@ -121,9 +131,12 @@ def parse_protocol_async(protocol_id, encoded_kwargs):
                     return
                 who = todo_tag.values[0]
                 what = todo_tag.values[1]
-                todo = None
                 field_id = None
+                field_state = None
+                field_date = None
                 for other_field in todo_tag.values[2:]:
+                    if len(other_field) == 0:
+                        continue
                     if other_field.startswith(ID_FIELD_BEGINNING):
                         try:
                             field_id = int(other_field[len(ID_FIELD_BEGINNING):])
@@ -134,39 +147,73 @@ def parse_protocol_async(protocol_id, encoded_kwargs):
                             db.session.add(error)
                             db.session.commit()
                             return
-                        todo = Todo.query.filter_by(number=field_id).first()
+                    else:
+                        try:
+                            field_state = TodoState.from_name(other_field.strip())
+                        except ValueError:
+                            try:
+                                field_date = datetime.strptime(other_field.strip(), "%d.%m.%Y")
+                            except ValueError:
+                                error = protocol.create_error("Parsing",
+                                "Invalid field",
+                                "The todo in line {} has the field '{}', but"
+                                "this does neither match a date (\"%d.%m.%Y\")"
+                                "nor a state.".format(
+                                    todo_tag.linenumber, other_field))
+                                db.session.add(error)
+                                db.session.commit()
+                                return
+                if field_state is None:
+                    field_state = TodoState.open
+                if field_state.needs_date() and field_date is None:
+                    error = protocol.create_error("Parsing",
+                        "Todo missing date",
+                        "The todo in line {} has a state that needs a date, "
+                        "but the todo does not have one.".format(todo_tag.line))
+                    db.session.add(error)
+                    db.session.commit()
+                    return
                 who = who.strip()
                 what = what.strip()
+                todo = None
+                if field_id is not None:
+                    todo = Todo.query.filter_by(number=field_id).first()
+                    if todo is None and not config.PARSER_LAZY:
+                        # TODO: add non-strict mode (at least for importing old protocols)
+                        error = protocol.create_error("Parsing",
+                        "Invalid Todo ID",
+                        "The todo in line {} has the ID {}, but there is no "
+                        "Todo with that ID.".format(todo_tag.linenumber, field_id))
+                        db.session.add(error)
+                        db.session.commit()
+                        return
                 if todo is None:
-                    if field_id is not None:
-                        candidate = Todo.query.filter_by(who=who, description=what, number=None).first()
-                        if candidate is None:
-                            candidate = Todo.query.filter_by(description=what, number=None).first()
-                        if candidate is not None:
-                            candidate.number = field_id
-                            todo = candidate
-                        else:
-                            todo = Todo(type_id=protocol.protocoltype.id, who=who, description=what, tags="", done=False)
-                            todo.number = field_id
+                    protocol_key = protocol.get_identifier()
+                    old_candidates = OldTodo.query.filter(
+                        OldTodo.protocol_key == protocol_key).all()
+                    if len(old_candidates) == 0:
+                        # new protocol
+                        todo = Todo(type_id=protocol.protocoltype.id,
+                            who=who, description=what, state=field_state,
+                            date=field_date)
+                        db.session.add(todo)
+                        db.session.commit()
+                        todo.number = field_id or todo.id
+                        db.session.commit()
                     else:
-                        candidate = Todo.query.filter_by(who=who, description=what).first()
-                        if candidate is not None:
-                            todo = candidate
-                        else:
-                            todo = Todo(type_id=protocol.protocoltype.id, who=who, description=what, tags="", done=False)
+                        # old protocol
+                        number = field_id or lookup_todo_id(old_candidates, who, what)
+                        todo = Todo.query.filter_by(number=number).first()
+                        if todo is None:
+                            todo = Todo(type_id=protocol.protocoltype.id,
+                                who=who, description=what, state=field_state,
+                                date=field_date, number=number)
                             db.session.add(todo)
+                            db.session.commit()
                 todo.protocols.append(protocol)
-                todo_tags_internal = todo.tags.split(";")
-                for other_field in todo_tag.values[2:]:
-                    if other_field.startswith(ID_FIELD_BEGINNING):
-                        continue
-                    elif other_field == "done":
-                        todo.done = True
-                    elif other_field not in todo_tags_internal:
-                        todo_tags_internal.append(other_field)
-                todo.tags = ";".join(todo_tags_internal)
-                todo_tag.todo = todo
                 db.session.commit()
+                todo_tag.todo = todo
+            # Decisions
             old_decisions = list(protocol.decisions)
             for decision in old_decisions:
                 protocol.decisions.remove(decision)
@@ -183,7 +230,6 @@ def parse_protocol_async(protocol_id, encoded_kwargs):
                 db.session.add(decision)
                 db.session.commit()
                 decision_content = texenv.get_template("decision.tex").render(render_type=RenderType.latex, decision=decision, protocol=protocol, top=decision_tag.fork.get_top(), show_private=False)
-                print(decision_content)
                 compile_decision(decision_content, decision)
             old_tops = list(protocol.tops)
             for top in old_tops:
diff --git a/templates/layout.html b/templates/layout.html
index 8ea321b1aecf5583b34cdbf511b356f82b7c4ba5..da611b1d923c4ec57f146b5bc141373dfae5d183 100644
--- a/templates/layout.html
+++ b/templates/layout.html
@@ -27,7 +27,7 @@
         <div id="navbar" class="navbar-collapse collapse">
             <ul class="nav navbar-nav">
                 {% if check_login() %}
-                <li><a href="{{url_for("new_protocol")}}">Neu</a></li>
+                <li><a href="{{url_for("new_protocol")}}">Neues Protokoll</a></li>
                 {% endif %}
                 <li><a href="{{url_for("list_protocols")}}">Protokolle</a></li>
                 {% if check_login() %}
@@ -35,9 +35,15 @@
                 {% endif %}
                 <li><a href="{{url_for("list_decisions")}}">Beschlüsse</a></li>
                 {% if check_login() %}
-                <li><a href="{{url_for("list_types")}}">Typen</a></li>
-                <li><a href="{{url_for("list_errors")}}">Fehler</a></li>
-                <li><a href="{{url_for("list_todomails")}}">Todo Mails</a></li>
+                <li class="dropdown">
+                    <a class="dropdown-toggle", href="#" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Mehr <span class="caret"></span></a>
+                    <ul class="dropdown-menu">
+                        <li><a href="{{url_for("list_types")}}">Typen</a></li>
+                        <li><a href="{{url_for("list_errors")}}">Fehler</a></li>
+                        <li><a href="{{url_for("list_todomails")}}">Todo Mails</a></li>
+                        <li><a href="{{url_for("documentation")}}">Dokumentation</a></li>
+                    </ul>
+                </li>
                 {% endif %}
                 {# todo: add more links #}
             </ul>
@@ -66,9 +72,6 @@ Diese Seite ist leer.
 {% endblock %}
 
 </div>
-<footer>{# todo: check how to make a footer in bootstrap #}
-    <a href="{{url_for("documentation")}}">Dokumentation</a>
-</footer>
 <script src="{{url_for("static", filename="js/jquery.min.js")}}"></script>
 <script src="{{url_for("static", filename="js/bootstrap.min.js")}}"></script>
 </body>
diff --git a/templates/protocol.tex b/templates/protocol.tex
index 5acd7c3a677221c2f5213bce4333c2e9c4242e47..76d742e9850c7cade73d03acf778976d0504ad11 100644
--- a/templates/protocol.tex
+++ b/templates/protocol.tex
@@ -23,10 +23,18 @@
 \\\normalsize \VAR{protocol.protocoltype.organization|escape_tex}
 }{}
 \begin{tabular}{rp{14cm}}
-{\bf Datum:} & \VAR{protocol.date|datify_long|escape_tex}\\
-{\bf Ort:} & \VAR{protocol.location|escape_tex}\\
-{\bf Protokollant:} & \VAR{protocol.author|escape_tex}\\
-{\bf Anwesend:} & \VAR{protocol.participants|escape_tex}\\
+\ENV{if protocol.date is not none}
+    {\bf Datum:} & \VAR{protocol.date|datify_long|escape_tex}\\
+\ENV{endif}
+\ENV{if protocol.location is not none}
+    {\bf Ort:} & \VAR{protocol.location|escape_tex}\\
+\ENV{endif}
+\ENV{if protocol.author is not none}
+    {\bf Protokollant:} & \VAR{protocol.author|escape_tex}\\
+\ENV{endif}
+\ENV{if protocol.participants is not none}
+    {\bf Anwesend:} & \VAR{protocol.participants|escape_tex}\\
+\ENV{endif}
 \end{tabular}
 \normalsize
 
@@ -41,7 +49,9 @@
 \ENV{endif}
 \end{itemize}
 
-Beginn der Sitzung: \VAR{protocol.start_time|timify}
+\ENV{if protocol.start_time is not none}
+    Beginn der Sitzung: \VAR{protocol.start_time|timify}
+\ENV{endif}
 
 \ENV{for top in tree.children}
     \ENV{if top|class == "Fork"}
@@ -50,6 +60,8 @@ Beginn der Sitzung: \VAR{protocol.start_time|timify}
     \ENV{endif}
 \ENV{endfor}
 
-Ende der Sitzung: \VAR{protocol.end_time|timify}
+\ENV{if protocol.end_time is not none}
+    Ende der Sitzung: \VAR{protocol.end_time|timify}
+\ENV{endif}
 
 \end{document}
diff --git a/templates/protocol.wiki b/templates/protocol.wiki
index 322790827b9a09694d11eccf7dc91a1a7c3646ef..7d9c8169c6c37edc2bca611e2549ce8f0cb8f01c 100644
--- a/templates/protocol.wiki
+++ b/templates/protocol.wiki
@@ -1,9 +1,17 @@
 {{'{{'}}Infobox Protokoll
 | name = {{protocol.protocoltype.name}}
-| datum = {{protocol.date|datify}}
+{% if protocol.date is not none %}
+| datum = {{protocol.date|datify_long}}
+{% endif %}
+{% if protocol.start_time is not none and protocol.end_time is not none %}
 | zeit = von {{protocol.start_time|timify}} bis {{protocol.end_time|timify}}
+{% endif %}
+{% if protocol.author is not none %}
 | protokollant = {{protocol.author}}
+{% endif %}
+{% if protocol.participants is not none %}
 | anwesende = {{protocol.participants}}
+{% endif %}
 {{'}}'}}
 
 == Beschlüsse ==
diff --git a/todostates.py b/todostates.py
new file mode 100644
index 0000000000000000000000000000000000000000..baaf3916c6d38c3dfabdf9e577e1711cb934f833
--- /dev/null
+++ b/todostates.py
@@ -0,0 +1,56 @@
+# change this file to add additional keywords
+def make_states(TodoState):
+    # do not remove any of these
+    # any of these mappings must be in NAME_TO_STATE as well
+    STATE_TO_NAME = {
+        TodoState.open: "offen",
+        TodoState.waiting: "wartet auf Rückmeldung",
+        TodoState.in_progress: "in Bearbeitung",
+        TodoState.after: "ab",
+        TodoState.before: "vor",
+        TodoState.orphan: "verwaist",
+        TodoState.done: "erledigt",
+        TodoState.rejected: "abgewiesen",
+        TodoState.obsolete: "obsolet"
+    }
+
+    # the text version has to be in lower case
+    # Please don't add something that matches a date
+    NAME_TO_STATE = {
+        "offen": TodoState.open,
+        "open": TodoState.open,
+        "wartet auf rückmeldung": TodoState.waiting,
+        "wartet": TodoState.waiting,
+        "waiting": TodoState.waiting,
+        "in bearbeitung": TodoState.in_progress,
+        "bearbeitung": TodoState.in_progress,
+        "läuft": TodoState.in_progress,
+        "in progress": TodoState.in_progress,
+        "ab": TodoState.after,
+        "erst ab": TodoState.after,
+        "nicht vor": TodoState.after,
+        "after": TodoState.after,
+        "not before": TodoState.after,
+        "vor": TodoState.before,
+        "nur vor": TodoState.before,
+        "nicht nach": TodoState.before,
+        "before": TodoState.before,
+        "not after": TodoState.before,
+        "verwaist": TodoState.orphan,
+        "orphan": TodoState.orphan,
+        "orphaned": TodoState.orphan,
+        "erledigt": TodoState.done,
+        "fertig": TodoState.done,
+        "done": TodoState.done,
+        "abgewiesen": TodoState.rejected,
+        "abgelehnt": TodoState.rejected,
+        "passiert nicht": TodoState.rejected,
+        "nie": TodoState.rejected,
+        "niemals": TodoState.rejected,
+        "rejected": TodoState.rejected,
+        "obsolet": TodoState.obsolete,
+        "veraltet": TodoState.obsolete,
+        "zu spät": TodoState.obsolete,
+        "obsolete": TodoState.obsolete
+    }
+    return STATE_TO_NAME, NAME_TO_STATE
diff --git a/validators.py b/validators.py
new file mode 100644
index 0000000000000000000000000000000000000000..36847e467f957d0bf43ddeae26c33df3dd061fe8
--- /dev/null
+++ b/validators.py
@@ -0,0 +1,19 @@
+from models.database import TodoState
+from wtforms import ValidationError
+from wtforms.validators import InputRequired
+from shared import db
+
+class CheckTodoDateByState:
+    def __init__(self):
+        pass
+
+    def __call__(self, form, field):
+        try:
+            todostate = TodoState(field.data)
+            if todostate.needs_date():
+                date_check = InputRequired("Dieser Status benötigt ein Datum.")
+                form.date.errors = []
+                date_check(form, form.date)
+        except ValueError:
+            raise ValidationError("Invalid state.")
+
diff --git a/views/forms.py b/views/forms.py
index bf8d363c861592bd41e8dc783c88375799ae0079..d910205efdebd65cdc52f786d902ed1dd48a45df 100644
--- a/views/forms.py
+++ b/views/forms.py
@@ -2,6 +2,9 @@ from flask_wtf import FlaskForm
 from wtforms import StringField, PasswordField, BooleanField, DateField, HiddenField, IntegerField, SelectField, FileField, DateTimeField, TextAreaField
 from wtforms.validators import InputRequired, Optional
 
+from models.database import TodoState
+from validators import CheckTodoDateByState
+
 import config
 
 def get_protocoltype_choices(protocoltypes, add_all=True):
@@ -10,6 +13,12 @@ def get_protocoltype_choices(protocoltypes, add_all=True):
         choices.insert(0, (-1, "Alle"))
     return choices
 
+def get_todostate_choices():
+    return [
+        (state.value, state.get_name())
+        for state in TodoState
+    ]
+
 class LoginForm(FlaskForm):
     username = StringField("Benutzer", validators=[InputRequired("Bitte gib deinen Benutzernamen ein.")])
     password = PasswordField("Passwort", validators=[InputRequired("Bitte gib dein Passwort ein.")])
@@ -96,18 +105,22 @@ class NewTodoForm(FlaskForm):
     protocoltype_id = SelectField("Typ", choices=[], coerce=int)
     who = StringField("Person", validators=[InputRequired("Bitte gib an, wer das Todo erledigen soll.")])
     description = StringField("Aufgabe", validators=[InputRequired("Bitte gib an, was erledigt werden soll.")])
-    tags = StringField("Weitere Tags")
-    done = BooleanField("Erledigt")
+    state = SelectField("Status", choices=[], coerce=int, validators=[CheckTodoDateByState()])
+    date = DateField("Datum", format="%d.%m.%Y", validators=[Optional()])
     
     def __init__(self, protocoltypes, **kwargs):
         super().__init__(**kwargs)
         self.protocoltype_id.choices = get_protocoltype_choices(protocoltypes, add_all=False)
+        self.state.choices = get_todostate_choices()
 
 class TodoForm(FlaskForm):
     who = StringField("Person")
     description = StringField("Aufgabe", validators=[InputRequired("Bitte gib an, was erledigt werden soll.")])
-    tags = StringField("Weitere Tags")
-    done = BooleanField("Erledigt")
+    state = SelectField("Status", choices=[], coerce=int, validators=[CheckTodoDateByState()])
+    date = DateField("Datum", format="%d.%m.%Y", validators=[Optional()])
+
+    def __init__(self):
+        self.state.choices = get_todostate_choices()
 
 class TodoMailForm(FlaskForm):
     name = StringField("Name", validators=[InputRequired("Du musst den Namen angeben, der zugeordnet werden soll.")])
diff --git a/views/tables.py b/views/tables.py
index 288d1b9040e8aee81909c6d33706dddb1e1ffa74..643fb67c5566f984c0d1281a4aea7ddcdc5a91ef 100644
--- a/views/tables.py
+++ b/views/tables.py
@@ -248,7 +248,10 @@ class TodosTable(Table):
             todo.description,
         ]
         if todo.protocoltype.has_modify_right(user):
-            row.append(Table.link(url_for("edit_todo", todo_id=todo.id), "Ändern"))
+            row.append(Table.concat([
+                Table.link(url_for("edit_todo", todo_id=todo.id), "Ändern"),
+                Table.link(url_for("delete_todo", todo_id=todo.id), "Löschen")
+            ]))
         else:
             row.append("")
         return row
@@ -258,7 +261,7 @@ class TodoTable(SingleValueTable):
         super().__init__("Todo", todo)
 
     def headers(self):
-        return ["ID", "Status", "Sitzung", "Name", "Aufgabe", "Tags", ""]
+        return ["ID", "Status", "Sitzung", "Name", "Aufgabe", ""]
 
     def row(self):
         user = current_user()
@@ -270,8 +273,7 @@ class TodoTable(SingleValueTable):
                 if protocol is not None
                 else Table.link(url_for("list_protocols", protocolttype=self.value.protocoltype.id), self.value.protocoltype.short_name),
             self.value.who,
-            self.value.description,
-            self.value.tags
+            self.value.description
         ]
         if self.value.protocoltype.has_modify_right(user):
             row.append(Table.concat([