From 607b9aaec6167e099b0c96bce83f2a687e4bceb3 Mon Sep 17 00:00:00 2001
From: Robin Sonnabend <robin@fsmpi.rwth-aachen.de>
Date: Mon, 27 Feb 2017 22:40:02 +0100
Subject: [PATCH] Lazy parser, todo states, import old todo IDs

---
 .gitignore                                    |   1 +
 legacy.py                                     | 150 ++++++++++++++++++
 migrations/versions/0131d5776f8d_.py          |  30 ----
 migrations/versions/0e5220a9f169_.py          |  37 -----
 migrations/versions/162da8aeeb71_.py          |  30 ----
 migrations/versions/188f389b2286_.py          |  34 ----
 migrations/versions/24bd2198a626_.py          |  28 ----
 migrations/versions/2e2682dfac21_.py          |  28 ----
 migrations/versions/310d9ab321b8_.py          |  32 ----
 migrations/versions/495509e8f49a_.py          |  34 ----
 migrations/versions/515d261a624b_.py          |  28 ----
 migrations/versions/77bf71eef07f_.py          |  28 ----
 migrations/versions/97cf1913e60d_.py          |  28 ----
 migrations/versions/a3d9d1b87ba0_.py          |  28 ----
 migrations/versions/aebae2c4523d_.py          |  28 ----
 migrations/versions/b114754024fb_.py          |  28 ----
 migrations/versions/bbc1782c0999_.py          |  28 ----
 .../{efaa3b4fd3e8_.py => d543c6a2ea6e_.py}    |  66 ++++++--
 migrations/versions/d8c0c74b88bd_.py          |  32 ----
 migrations/versions/f91d760158dc_.py          |  28 ----
 models/database.py                            | 130 ++++++++++++---
 parser.py                                     |  48 ++++--
 requirements.txt                              |   2 +
 server.py                                     |  15 +-
 shared.py                                     |  28 ++--
 tasks.py                                      | 124 ++++++++++-----
 templates/layout.html                         |  17 +-
 templates/protocol.tex                        |  24 ++-
 templates/protocol.wiki                       |  10 +-
 todostates.py                                 |  56 +++++++
 validators.py                                 |  19 +++
 views/forms.py                                |  21 ++-
 views/tables.py                               |  10 +-
 33 files changed, 591 insertions(+), 639 deletions(-)
 create mode 100644 legacy.py
 delete mode 100644 migrations/versions/0131d5776f8d_.py
 delete mode 100644 migrations/versions/0e5220a9f169_.py
 delete mode 100644 migrations/versions/162da8aeeb71_.py
 delete mode 100644 migrations/versions/188f389b2286_.py
 delete mode 100644 migrations/versions/24bd2198a626_.py
 delete mode 100644 migrations/versions/2e2682dfac21_.py
 delete mode 100644 migrations/versions/310d9ab321b8_.py
 delete mode 100644 migrations/versions/495509e8f49a_.py
 delete mode 100644 migrations/versions/515d261a624b_.py
 delete mode 100644 migrations/versions/77bf71eef07f_.py
 delete mode 100644 migrations/versions/97cf1913e60d_.py
 delete mode 100644 migrations/versions/a3d9d1b87ba0_.py
 delete mode 100644 migrations/versions/aebae2c4523d_.py
 delete mode 100644 migrations/versions/b114754024fb_.py
 delete mode 100644 migrations/versions/bbc1782c0999_.py
 rename migrations/versions/{efaa3b4fd3e8_.py => d543c6a2ea6e_.py} (69%)
 delete mode 100644 migrations/versions/d8c0c74b88bd_.py
 delete mode 100644 migrations/versions/f91d760158dc_.py
 create mode 100644 todostates.py
 create mode 100644 validators.py

diff --git a/.gitignore b/.gitignore
index 8560925..1c7655f 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 0000000..7374836
--- /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 893bf58..0000000
--- 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 027be45..0000000
--- 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 e3b0eff..0000000
--- 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 6e7f9ec..0000000
--- 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 78cbf6c..0000000
--- 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 60d7a0f..0000000
--- 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 1ebf1e1..0000000
--- 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 61fb9dc..0000000
--- 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 2b5f42e..0000000
--- 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 71827d3..0000000
--- 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 18377e9..0000000
--- 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 989795b..0000000
--- 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 e70a55c..0000000
--- 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 a381a07..0000000
--- 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 d1787c0..0000000
--- 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 3b712ee..33ba887 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 7183f59..0000000
--- 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 8a868a5..0000000
--- 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 b041a2f..55a0f5d 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 069aa80..d2c5bed 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 73034a3..218d809 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 cbe9fd0..e1a8ac5 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 d7a6b65..1fca78b 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 4bfdca1..658f81d 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 8ea321b..da611b1 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 5acd7c3..76d742e 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 3227908..7d9c816 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 0000000..baaf391
--- /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 0000000..36847e4
--- /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 bf8d363..d910205 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 288d1b9..643fb67 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([
-- 
GitLab