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