From f62d577e4de2bedff4b3e7156f5b22ee8b8c33e0 Mon Sep 17 00:00:00 2001
From: Robin Sonnabend <robin@fsmpi.rwth-aachen.de>
Date: Fri, 24 Feb 2017 21:19:19 +0100
Subject: [PATCH] Search and paginate todos

---
 migrations/versions/0131d5776f8d_.py | 30 ++++++++++++++
 models/database.py                   | 13 +++++-
 server.py                            | 60 +++++++++++++++++++++++-----
 static/css/style.css                 | 11 ++++-
 tasks.py                             |  4 +-
 templates/macros.html                | 10 ++---
 templates/todos-list.html            | 22 +++++++++-
 utils.py                             |  1 +
 views/forms.py                       |  9 +++++
 views/tables.py                      |  3 +-
 10 files changed, 142 insertions(+), 21 deletions(-)
 create mode 100644 migrations/versions/0131d5776f8d_.py

diff --git a/migrations/versions/0131d5776f8d_.py b/migrations/versions/0131d5776f8d_.py
new file mode 100644
index 0000000..893bf58
--- /dev/null
+++ b/migrations/versions/0131d5776f8d_.py
@@ -0,0 +1,30 @@
+"""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/models/database.py b/models/database.py
index 2ca9abc..928b9cc 100644
--- a/models/database.py
+++ b/models/database.py
@@ -29,6 +29,7 @@ class ProtocolType(db.Model):
     protocols = relationship("Protocol", backref=backref("protocoltype"), cascade="all, delete-orphan", order_by="Protocol.id")
     default_tops = relationship("DefaultTOP", backref=backref("protocoltype"), cascade="all, delete-orphan", order_by="DefaultTOP.number")
     reminders = relationship("MeetingReminder", backref=backref("protocoltype"), cascade="all, delete-orphan", order_by="MeetingReminder.days_before")
+    todos = relationship("Todo", backref=backref("protocoltype"), order_by="Todo.id")
 
     def __init__(self, name, short_name, organization,
         is_public, private_group, public_group, private_mail, public_mail):
@@ -63,6 +64,14 @@ class ProtocolType(db.Model):
     def has_modify_right(self, user):
         return self.has_private_view_right(user)
 
+    @staticmethod
+    def get_available_protocoltypes(user):
+        return [
+            protocoltype for protocoltype in ProtocolType.query.all()
+            if protocoltype.has_modify_right(user)
+        ]
+
+
 
 class Protocol(db.Model):
     __tablename__ = "protocols"
@@ -244,6 +253,7 @@ def on_document_delete(mapper, connection, document):
 class Todo(db.Model):
     __tablename__ = "todos"
     id = db.Column(db.Integer, primary_key=True)
+    protocoltype_id = db.Column(db.Integer, db.ForeignKey("protocoltypes.id"))
     number = db.Column(db.Integer)
     who = db.Column(db.String)
     description = db.Column(db.String)
@@ -253,7 +263,8 @@ class Todo(db.Model):
 
     protocols = relationship("Protocol", secondary="todoprotocolassociations", backref="todos")
 
-    def __init__(self, who, description, tags, done, number=None):
+    def __init__(self, type_id, who, description, tags, done, number=None):
+        self.protocoltype_id = type_id
         self.who = who
         self.description = description
         self.tags = tags
diff --git a/server.py b/server.py
index befafac..51f75ca 100755
--- a/server.py
+++ b/server.py
@@ -11,12 +11,13 @@ from celery import Celery
 from io import StringIO, BytesIO
 import os
 from datetime import datetime
+import math
 
 import config
 from shared import db, date_filter, datetime_filter, date_filter_long, time_filter, ldap_manager, security_manager, current_user, check_login, login_required, group_required
 from utils import is_past, mail_manager, url_manager, get_first_unused_int, set_etherpad_text, get_etherpad_text
 from models.database import ProtocolType, Protocol, DefaultTOP, TOP, Document, Todo, Decision, MeetingReminder, Error
-from views.forms import LoginForm, ProtocolTypeForm, DefaultTopForm, MeetingReminderForm, NewProtocolForm, DocumentUploadForm, KnownProtocolSourceUploadForm, NewProtocolSourceUploadForm, ProtocolForm, TopForm
+from views.forms import LoginForm, ProtocolTypeForm, DefaultTopForm, MeetingReminderForm, NewProtocolForm, DocumentUploadForm, KnownProtocolSourceUploadForm, NewProtocolSourceUploadForm, ProtocolForm, TopForm, SearchForm
 from views.tables import ProtocolsTable, ProtocolTypesTable, ProtocolTypeTable, DefaultTOPsTable, MeetingRemindersTable, ErrorsTable, TodosTable, DocumentsTable
 
 app = Flask(__name__)
@@ -51,6 +52,9 @@ import tasks
 app.jinja_env.globals.update(check_login=check_login)
 app.jinja_env.globals.update(current_user=current_user)
 app.jinja_env.globals.update(zip=zip)
+app.jinja_env.globals.update(min=min)
+app.jinja_env.globals.update(max=max)
+app.jinja_env.globals.update(dir=dir)
 
 # blueprints here
 
@@ -289,10 +293,7 @@ def list_protocols():
 @login_required
 def new_protocol():
     user = current_user()
-    protocoltypes = [
-        protocoltype for protocoltype in ProtocolType.query.all()
-        if protocoltype.has_modify_right(user)
-    ]
+    protocoltypes = ProtocolType.get_available_protocoltypes(user)
     form = NewProtocolForm(protocoltypes)
     upload_form = NewProtocolSourceUploadForm(protocoltypes)
     if form.validate_on_submit():
@@ -527,16 +528,57 @@ def move_top(top_id, diff):
     except ValueError:
         flash("Die angegebene Differenz ist keine Zahl.", "alert-error")
     return redirect(request.args.get("next") or url_for("show_protocol", protocol_id=top.protocol.id))
-        
 
-@app.route("/todos/list")
+def _get_page():
+    try:
+        page = request.args.get("page")
+        if page is None:
+            return 0
+        return int(page)
+    except ValueError:
+        return 0
+
+@app.route("/todos/list", methods=["GET", "POST"])
 def list_todos():
     is_logged_in = check_login()
     user = current_user()
-    todos = Todo.query.all()
+    protocoltype = None
+    protocoltype_id = None
+    try:
+        protocoltype_id = int(request.args.get("type_id"))
+    except (ValueError, TypeError):
+        pass
+    search_term = request.args.get("search")
+    protocoltypes = ProtocolType.get_available_protocoltypes(user)
+    search_form = SearchForm(protocoltypes)
+    if search_form.validate_on_submit():
+        if search_form.search.data is not None:
+            search_term = search_form.search.data.strip()
+        if search_form.protocoltype.data is not None:
+            protocoltype_id = search_form.protocoltype.data
+    else:
+        if protocoltype_id is not None:
+            search_form.protocoltype.data = protocoltype_id
+        if search_term is not None:
+            search_form.search.data = search_term
+    if protocoltype_id is not None:
+        protocoltype = ProtocolType.query.filter_by(id=protocoltype_id).first()
+    base_query = Todo.query
+    if protocoltype_id is not None and protocoltype_id != -1:
+        base_query = base_query.filter(ProtocolType.id == protocoltype_id)
+    print(search_term)
+    if search_term is not None and len(search_term.strip()) > 0:
+        base_query = base_query.filter(Todo.description.match("%{}%".format(search_term)))
+    page = _get_page()
+    page_count = int(math.ceil(base_query.count() / config.PAGE_LENGTH))
+    if page >= page_count:
+        page = 0
+    begin_index = page * config.PAGE_LENGTH
+    end_index = (page + 1) * config.PAGE_LENGTH
+    todos = base_query.slice(begin_index, end_index).all()
     # TODO: paginate and search
     todos_table = TodosTable(todos)
-    return render_template("todos-list.html", todos=todos, todos_table=todos_table)
+    return render_template("todos-list.html", todos=todos, todos_table=todos_table, search_form=search_form, page=page, page_count=page_count, page_diff=config.PAGE_DIFF, protocoltype_id=protocoltype_id, search_term=search_term)
 
 @app.route("/document/download/<int:document_id>")
 def download_document(document_id):
diff --git a/static/css/style.css b/static/css/style.css
index 0bc8dc2..5a0045e 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -23,7 +23,7 @@ h3 > a {
     font-size: 18px;
 }
 
-form {
+form:not(.form-inline) {
     max-width: 350px;
     margin: 0 auto;
 }
@@ -31,3 +31,12 @@ form {
 input[type="file"] {
     padding: 0;
 }
+
+.centered {
+    margin: 0 auto;
+    max-width: 300px;
+}
+
+.centered > a {
+    margin: 10px;
+}
diff --git a/tasks.py b/tasks.py
index 7da102c..972edea 100644
--- a/tasks.py
+++ b/tasks.py
@@ -145,14 +145,14 @@ def parse_protocol_async(protocol_id, encoded_kwargs):
                             candidate.number = field_id
                             todo = candidate
                         else:
-                            todo = Todo(who=who, description=what, tags="", done=False)
+                            todo = Todo(type_id=protocol.protocoltype.id, who=who, description=what, tags="", done=False)
                             todo.number = field_id
                     else:
                         candidate = Todo.query.filter_by(who=who, description=what).first()
                         if candidate is not None:
                             todo = candidate
                         else:
-                            todo = Todo(who=who, description=what, tags="", done=False)
+                            todo = Todo(type_id=protocol.protocoltype.id, who=who, description=what, tags="", done=False)
                             db.session.add(todo)
                 todo.protocols.append(protocol)
                 todo_tags_internal = todo.tags.split(";")
diff --git a/templates/macros.html b/templates/macros.html
index 11d3f2c..0a790ce 100644
--- a/templates/macros.html
+++ b/templates/macros.html
@@ -16,16 +16,16 @@ to not render a label for the CRSFTokenField -->
     <div class="form-group {% if field.errors %}has-error{% endif %} {{ kwargs.pop('class_', '') }}">
         {% if field.type != 'HiddenField' and field.type !='CSRFTokenField' and label_visible %}
             <label for="{{ field.id }}" class="control-label">{{ field.label }}</label>
-            <!--<span onclick="el=document.getElementById('{{field.id}}-description');el.style.display=(el.style.display=='none'?'flex':'none')" class="field-description-questionmark">?</span>-->
+            {#<span onclick="el=document.getElementById('{{field.id}}-description');el.style.display=(el.style.display=='none'?'flex':'none')" class="field-description-questionmark">?</span>#}
         {% endif %}
-        {{ field(title=field.description, class_='form-control', **kwargs) }}
+        {{ field(title=field.description, placeholder=field.label.text, class_='form-control', **kwargs) }}
         {% if field.errors %}
             {% for e in field.errors %}
                 <p class="help-block">{{ e }}</p>
             {% endfor %}
         {% endif %}
     </div>
-    <div id="{{field.id}}-description" style="display:none" class="field-description">{{field.description}}</div>
+    {#<div id="{{field.id}}-description" style="display:none" class="field-description">{{field.description}}</div>#}
 {%- endmacro %}
 
 {# Renders checkbox fields since they are represented differently in bootstrap
@@ -87,7 +87,7 @@ to not render a label for the CRSFTokenField -->
         action_text - text of submit button
         class_ - sets a class for form
     #}
-{% macro render_form(form, action_url='', action_text='Submit', class_='', btn_class='btn btn-default', enctype=None) -%}
+{% macro render_form(form, action_url='', action_text='Submit', class_='', btn_class='btn btn-default', enctype=None, labels_visible=True) -%}
 
     <form method="POST" action="{{ action_url }}" role="form" class="{{ class_ }}"{% if enctype is not none %}enctype="{{enctype}}"{% endif %}>
         {{ form.hidden_tag() if form.hidden_tag }}
@@ -100,7 +100,7 @@ to not render a label for the CRSFTokenField -->
                 {% elif f.type == 'RadioField' %}
                     {{ render_radio_field(f) }}
                 {% else %}
-                    {{ render_field(f) }}
+                    {{ render_field(f, label_visible=labels_visible) }}
                 {% endif %}
             {% endfor %}
         {% endif %}
diff --git a/templates/todos-list.html b/templates/todos-list.html
index 56cdba9..841b969 100644
--- a/templates/todos-list.html
+++ b/templates/todos-list.html
@@ -1,9 +1,29 @@
 {% extends "layout.html" %}
-{% from "macros.html" import render_table %}
+{% from "macros.html" import render_table, render_form %}
 {% block title %}Todos{% endblock %}
 
+{% macro page_link(page, text) %}
+    <a href="{{url_for(request.endpoint, page=page, type_id=protocoltype_id, search=search_term)}}">{{text}}</a>
+{% endmacro %}
+
 {% block content %}
 <div class="container">
+    {{render_form(search_form, class_="form-inline", labels_visible=False)}}
     {{render_table(todos_table)}}
+    <div class="centered">
+        {% if page > page_diff %}
+            {{page_link(0, "<<")}}
+        {% endif %}
+        {% for p in range(max(0, page - page_diff), min(page_count, page + page_diff)) %}
+            {% if p != page %}
+                {{page_link(p, p + 1)}}
+            {% else %}
+                Seite {{p + 1}}
+            {% endif %}
+        {% endfor %}
+        {% if page < page_count - page_diff %}
+            {{page_link(page_count - 1, ">>")}}
+        {% endif %}
+    </div>
 </div>
 {% endblock %}
diff --git a/utils.py b/utils.py
index 6d818bc..2c78e15 100644
--- a/utils.py
+++ b/utils.py
@@ -124,3 +124,4 @@ def set_etherpad_text(pad, text, only_if_default=True):
     req = requests.post(get_etherpad_import_url(pad), files=files)
     return req.status_code == 200
     
+
diff --git a/views/forms.py b/views/forms.py
index 3392ba2..24afb7b 100644
--- a/views/forms.py
+++ b/views/forms.py
@@ -61,3 +61,12 @@ class TopForm(FlaskForm):
     name = StringField("TOP", validators=[InputRequired("Du musst den Namen des TOPs angeben.")])
     number = IntegerField("Sortierung", validators=[InputRequired("Du musst eine Sortierung in der Reihenfolge angebene.")])
 
+class SearchForm(FlaskForm):
+    search = StringField("Suchbegriff")
+    protocoltype = SelectField("Typ", choices=[], coerce=int)
+
+    def __init__(self, protocoltypes, **kwargs):
+        super().__init__(**kwargs)
+        choices = [(protocoltype.id, protocoltype.short_name) for protocoltype in protocoltypes]
+        choices.insert(0, (-1, ""))
+        self.protocoltype.choices = choices
diff --git a/views/tables.py b/views/tables.py
index a03970e..7701342 100644
--- a/views/tables.py
+++ b/views/tables.py
@@ -177,12 +177,11 @@ class TodosTable(Table):
         super().__init__("Todos", todos)
 
     def headers(self):
-        return ["ID", "Status", "Sitzung", "Name", "Aufgabe"]
+        return ["Status", "Sitzung", "Name", "Aufgabe"]
 
     def row(self, todo):
         protocol = todo.get_first_protocol()
         return [
-            todo.get_id(),
             todo.get_state(),
             Table.link(url_for("show_protocol", protocol_id=protocol.id), protocol.get_identifier()) if protocol is not None else "",
             todo.who,
-- 
GitLab