From 29f92eef3c82b669d6263760fc60ea8e1b639a71 Mon Sep 17 00:00:00 2001 From: Robin Sonnabend <robin@fsmpi.rwth-aachen.de> Date: Wed, 22 Feb 2017 16:02:49 +0100 Subject: [PATCH] Implemented default tops --- migrations/versions/a3d9d1b87ba0_.py | 28 +++ models/database.py | 29 ++- server.py | 169 +++++++++++++++++- static/css/style.css | 4 +- templates/default-top-edit.html | 9 + templates/default-top-new.html | 9 + templates/layout.html | 3 + templates/macros.html | 27 ++- ...protocol-list.html => protocols-list.html} | 0 templates/type-edit.html | 9 + templates/type-new.html | 9 + templates/type-show.html | 11 ++ templates/types-list.html | 9 + views/forms.py | 20 ++- views/tables.py | 67 ++++++- 15 files changed, 384 insertions(+), 19 deletions(-) create mode 100644 migrations/versions/a3d9d1b87ba0_.py create mode 100644 templates/default-top-edit.html create mode 100644 templates/default-top-new.html rename templates/{protocol-list.html => protocols-list.html} (100%) create mode 100644 templates/type-edit.html create mode 100644 templates/type-new.html create mode 100644 templates/type-show.html create mode 100644 templates/types-list.html diff --git a/migrations/versions/a3d9d1b87ba0_.py b/migrations/versions/a3d9d1b87ba0_.py new file mode 100644 index 0000000..989795b --- /dev/null +++ b/migrations/versions/a3d9d1b87ba0_.py @@ -0,0 +1,28 @@ +"""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/models/database.py b/models/database.py index 9479ca0..9d0a6f6 100644 --- a/models/database.py +++ b/models/database.py @@ -39,8 +39,27 @@ class ProtocolType(db.Model): self.public_mail = public_mail def __repr__(self): - return "<ProtocolType(id={}, short_name={}, name={}, organization={})>".format( - self.id, self.short_name, self.name, self.organization) + return "<ProtocolType(id={}, short_name={}, name={}, organization={}, is_public={}, private_group={}, public_group={})>".format( + self.id, self.short_name, self.name, self.organization, self.is_public, self.private_group, self.public_group) + + def get_latest_protocol(self): + candidates = sorted([protocol for protocol in self.protocols if protocol.is_done()], key=lambda p: p.data, reverse=True) + if len(candidates) == 0: + return None + return candidates[0] + + def has_public_view_right(self, user): + return (self.is_public + or (user is not None and + ((self.public_group != "" and self.public_group in user.groups) + or (self.private_group != "" and self.private_group in user.groups)))) + + def has_private_view_right(self, user): + return (self.private_group != "" and self.private_group in user.groups) + + def has_modify_right(self, user): + return self.has_private_view_right(user) + class Protocol(db.Model): __tablename__ = "protocols" @@ -53,6 +72,7 @@ class Protocol(db.Model): author = db.Column(db.String) participants = db.Column(db.String) location = db.Column(db.String) + done = db.Column(db.Boolean) tops = relationship("TOP", backref=backref("protocol"), cascade="all, delete-orphan", order_by="TOP.number") decisions = relationship("Decision", backref=backref("protocol"), cascade="all, delete-orphan", order_by="Decision.id") @@ -85,6 +105,9 @@ class Protocol(db.Model): self.participants = remarks["Anwesende"].value self.location = remarks["Ort"].value + def is_done(self): + return self.done + class DefaultTOP(db.Model): __tablename__ = "defaulttops" @@ -103,7 +126,7 @@ class DefaultTOP(db.Model): self.id, self.protocoltype_id, self.name, self.number) def is_at_end(self): - return self.number < 0 + return self.number > 0 class TOP(db.Model): __tablename__ = "tops" diff --git a/server.py b/server.py index b2c6849..03dccc6 100755 --- a/server.py +++ b/server.py @@ -13,8 +13,8 @@ import config from shared import db, date_filter, datetime_filter, ldap_manager, security_manager from utils import is_past, mail_manager, url_manager from models.database import ProtocolType, Protocol, DefaultTOP, TOP, Document, Todo, Decision, MeetingReminder, Error -from views.forms import LoginForm -from views.tables import ProtocolsTable +from views.forms import LoginForm, ProtocolTypeForm, DefaultTopForm +from views.tables import ProtocolsTable, ProtocolTypesTable, ProtocolTypeTable, DefaultTOPsTable app = Flask(__name__) app.config.from_object(config) @@ -61,16 +61,175 @@ def login_required(function): return redirect(url_for("login", next=request.url)) return decorated_function +def group_required(function, group): + @wraps(function) + def decorated_function(*args, **kwargs): + if group in current_user.groups: + return function(*args, **kwargs) + else: + flash("You do not have the necessary permissions to view this page.") + return redirect(request.args.get("next") or url_for("index")) + return decorated_function + 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) # blueprints here @app.route("/") -#@login_required def index(): return render_template("index.html") +@login_required +@app.route("/types/list") +def list_types(): + is_logged_in = check_login() + user = current_user() + types = [ + protocoltype for protocoltype in ProtocolType.query.all() + if (protocoltype.public_group in user.groups + or protocoltype.private_group in user.groups + or protocoltype.is_public)] + types_table = ProtocolTypesTable(types) + return render_template("types-list.html", types=types, types_table=types_table) + +@app.route("/type/new", methods=["GET", "POST"]) +@login_required +def new_type(): + form = ProtocolTypeForm() + if form.validate_on_submit(): + user = current_user() + if form.private_group.data not in user.groups: + flash("Du kannst keinen internen Protokolltypen anlegen, zu dem du selbst keinen Zugang hast.", "alert-error") + else: + protocoltype = ProtocolType(form.name.data, form.short_name.data, + form.organization.data, form.is_public.data, + form.private_group.data, form.public_group.data, + form.private_mail.data, form.public_mail.data) + db.session.add(protocoltype) + db.session.commit() + flash("Der Protokolltyp {} wurde angelegt.".format(protocoltype.name), "alert-success") + return redirect(request.args.get("next") or url_for("list_types")) + return render_template("type-new.html", form=form) + +@app.route("/type/edit/<int:type_id>", methods=["GET", "POST"]) +@login_required +def edit_type(type_id): + protocoltype = ProtocolType.query.filter_by(id=type_id).first() + if protocoltype is None: + flash("Dieser Protokolltyp existiert nicht.", "alert-error") + return redirect(request.args.get("next") or url_for("index")) + user = current_user() + if not protocoltype.has_private_view_right(user): + flash("Dir fehlen die nötigen Zugriffsrechte.", "alert-error") + return redirect(request.args.get("next") or url_for("index")) + form = ProtocolTypeForm(obj=protocoltype) + if form.validate_on_submit(): + if form.private_group.data not in user.groups: + flash("Du kannst keinen internen Protokolltypen anlegen, zu dem du selbst keinen Zugang hast.", "alert-error") + else: + form.populate_obj(protocoltype) + db.session.commit() + return redirect(request.args.get("next") or url_for("show_type", type_id=protocoltype.id)) + return render_template("type-edit.html", form=form, protocoltype=protocoltype) + +@app.route("/type/show/<int:type_id>") +@login_required +def show_type(type_id): + protocoltype = ProtocolType.query.filter_by(id=type_id).first() + if protocoltype is None: + flash("Dieser Protokolltyp existiert nicht.", "alert-error") + return redirect(request.args.get("next") or url_for("index")) + user = current_user() + if not protocoltype.has_private_view_right(user): + flash("Dir fehlen die nötigen Zugriffsrechte.", "alert-error") + return redirect(request.args.get("next") or url_for("index")) + protocoltype_table = ProtocolTypeTable(protocoltype) + default_tops_table = DefaultTOPsTable(protocoltype.default_tops, protocoltype) + return render_template("type-show.html", protocoltype=protocoltype, protocoltype_table=protocoltype_table, default_tops_table=default_tops_table) + +@app.route("/type/tops/new/<int:type_id>", methods=["GET", "POST"]) +@login_required +def new_default_top(type_id): + protocoltype = ProtocolType.query.filter_by(id=type_id).first() + if protocoltype is None: + flash("Dieser Protokolltyp existiert nicht.", "alert-error") + return redirect(request.args.get("next") or url_for("index")) + user = current_user() + if not protocoltype.has_modify_right(user): + flash("Dir fehlen die nötigen Zugriffsrechte.", "alert-error") + return redirect(request.args.get("next") or url_for("index")) + form = DefaultTopForm() + if form.validate_on_submit(): + default_top = DefaultTOP(protocoltype.id, form.name.data, form.number.data) + db.session.add(default_top) + db.session.commit() + flash("Der Standard-TOP {} wurde für dem Protokolltyp {} hinzugefügt.".format(default_top.name, protocoltype.name), "alert-success") + return redirect(request.args.get("next") or url_for("index")) + return render_template("default-top-new.html", form=form, protocoltype=protocoltype) + +@app.route("/type/tops/edit/<int:type_id>/<int:top_id>", methods=["GET", "POST"]) +@login_required +def edit_default_top(type_id, top_id): + protocoltype = ProtocolType.query.filter_by(id=type_id).first() + if protocoltype is None: + flash("Dieser Protokolltyp existiert nicht.", "alert-error") + return redirect(request.args.get("next") or url_for("index")) + user = current_user() + if not protocoltype.has_modify_right(user): + flash("Dir fehlen die nötigen Zugriffsrechte.", "alert-error") + return redirect(request.args.get("next") or url_for("index")) + default_top = DefaultTOP.query.filter_by(id=top_id).first() + if default_top is None or default_top.protocoltype != protocoltype: + flash("Invalider Standard-TOP.", "alert-error") + return redirect(request.args.get("nexT") or url_for("index")) + form = DefaultTopForm(obj=default_top) + if form.validate_on_submit(): + form.populate_obj(default_top) + db.session.commit() + return redirect(request.args.get("next") or url_for("show_type", type_id=protocoltype.id)) + return render_template("default-top-edit.html", form=form, protocoltype=protocoltype, default_top=default_top) + +@app.route("/type/tops/delete/<int:type_id>/<int:top_id>") +@login_required +def delete_default_top(type_id, top_id): + protocoltype = ProtocolType.query.filter_by(id=type_id).first() + if protocoltype is None: + flash("Dieser Protokolltyp existiert nicht.", "alert-error") + return redirect(request.args.get("next") or url_for("index")) + user = current_user() + if not protocoltype.has_modify_right(user): + flash("Dir fehlen die nötigen Zugriffsrechte.", "alert-error") + return redirect(request.args.get("next") or url_for("index")) + default_top = DefaultTOP.query.filter_by(id=top_id).first() + if default_top is None or default_top.protocoltype != protocoltype: + flash("Invalider Standard-TOP.", "alert-error") + return redirect(request.args.get("nexT") or url_for("index")) + db.session.delete(default_top) + db.session.commit() + return redirect(request.args.get("next") or url_for("show_type", type_id=protocoltype.id)) + +@app.route("/type/tops/move/<int:type_id>/<int:top_id>/<diff>/") +@login_required +def move_default_top(type_id, top_id, diff): + protocoltype = ProtocolType.query.filter_by(id=type_id).first() + if protocoltype is None: + flash("Dieser Protokolltyp existiert nicht.", "alert-error") + return redirect(request.args.get("next") or url_for("index")) + user = current_user() + if not protocoltype.has_modify_right(user): + flash("Dir fehlen die nötigen Zugriffsrechte.", "alert-error") + return redirect(request.args.get("next") or url_for("index")) + default_top = DefaultTOP.query.filter_by(id=top_id).first() + if default_top is None or default_top.protocoltype != protocoltype: + flash("Invalider Standard-TOP.", "alert-error") + return redirect(request.args.get("next") or url_for("index")) + default_top.number += int(diff) + db.session.commit() + return redirect(request.args.get("next") or url_for("show_type", type_id=protocoltype.id)) + + @app.route("/protocol/list") def list_protocols(): is_logged_in = check_login() @@ -82,14 +241,14 @@ def list_protocols(): protocol.protocoltype.public_group in user.groups or protocol.protocoltype.private_group in user.groups))] protocols_table = ProtocolsTable(protocols) - return render_template("protocol-list.html", protocols=protocols, protocols_table=protocols_table) + return render_template("protocols-list.html", protocols=protocols, protocols_table=protocols_table) @app.route("/login", methods=["GET", "POST"]) def login(): if "auth" in session: flash("You are already logged in.", "alert-success") - return redirect(url_for(request.args.get("next") or "index")) + return redirect(request.args.get("next") or url_for("index")) form = LoginForm() if form.validate_on_submit(): user = ldap_manager.login(form.username.data, form.password.data) diff --git a/static/css/style.css b/static/css/style.css index 895b254..062285a 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -19,4 +19,6 @@ body { color: #808000; } - +h3 > a { + font-size: 18px; +} diff --git a/templates/default-top-edit.html b/templates/default-top-edit.html new file mode 100644 index 0000000..8dee99e --- /dev/null +++ b/templates/default-top-edit.html @@ -0,0 +1,9 @@ +{% extends "layout.html" %} +{% from "macros.html" import render_form %} +{% block title %}Standard-TOP bearbeiten{% endblock %} + +{% block content %} +<div class="container"> + {{render_form(form, action_url=url_for("edit_default_top", type_id=protocoltype.id, top_id=default_top.id, next=url_for("show_type", type_id=protocoltype.id)), action_text="Ändern")}} +</div> +{% endblock %} diff --git a/templates/default-top-new.html b/templates/default-top-new.html new file mode 100644 index 0000000..d65a621 --- /dev/null +++ b/templates/default-top-new.html @@ -0,0 +1,9 @@ +{% extends "layout.html" %} +{% from "macros.html" import render_form %} +{% block title %}Standard-TOP hinzufügen{% endblock %} + +{% block content %} +<div class="container"> + {{render_form(form, action_url=url_for("new_default_top", type_id=protocoltype.id, next=url_for("show_type", type_id=protocoltype.id)), action_text="Anlegen")}} +</div> +{% endblock %} diff --git a/templates/layout.html b/templates/layout.html index 31b61d5..9156b39 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -28,6 +28,9 @@ <ul class="nav navbar-nav"> <li><a href="{{url_for("index")}}">Zuhause</a></li> <li><a href="{{url_for("list_protocols")}}">Protokolle</a></li> + {% if check_login() %} + <li><a href="{{url_for("list_types")}}">Typen</a></li> + {% endif %} {# todo: add more links #} </ul> <ul class="nav navbar-nav navbar-right"> diff --git a/templates/macros.html b/templates/macros.html index 8ef7e6e..86a8989 100644 --- a/templates/macros.html +++ b/templates/macros.html @@ -42,7 +42,7 @@ to not render a label for the CRSFTokenField --> <label> {{ field(type='checkbox', **kwargs) }} {{ 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>--> {% if field.errors %} {% for e in field.errors %} <p class="help-block">{{ e }}</p> @@ -109,6 +109,12 @@ to not render a label for the CRSFTokenField --> {%- endmacro %} {% macro render_table(table) -%} + <h3> + {{table.title}} + {% if table.newlink is not none %} + <a href="{{table.newlink}}">{{table.newtext}}</a> + {% endif %} + </h3> <table class="table table-striped"> <thead> <tr> @@ -128,3 +134,22 @@ to not render a label for the CRSFTokenField --> </tbody> </table> {%- endmacro %} + +{% macro render_single_table(table) -%} + <h3> + {{table.title}} + {% if table.newlink is not none %} + <a href="{{table.newlink}}">{{table.newtext}}</a> + {% endif %} + </h3> + <table class="table table-striped"> + <tbody> + {% for key, value in zip(table.headers(), table.row()) %} + <tr> + <td>{{key}}</td> + <td>{{value}}</td> + </tr> + {% endfor %} + </tbody> + </table> +{%- endmacro %} diff --git a/templates/protocol-list.html b/templates/protocols-list.html similarity index 100% rename from templates/protocol-list.html rename to templates/protocols-list.html diff --git a/templates/type-edit.html b/templates/type-edit.html new file mode 100644 index 0000000..1bb548a --- /dev/null +++ b/templates/type-edit.html @@ -0,0 +1,9 @@ +{% extends "layout.html" %} +{% from "macros.html" import render_form %} +{% block title %}Protokolltyp ändern{% endblock %} + +{% block content %} +<div class="container"> + {{render_form(form, action_url=url_for("edit_type", type_id=protocoltype.id), action_text="Ändern")}} +</div> +{% endblock %} diff --git a/templates/type-new.html b/templates/type-new.html new file mode 100644 index 0000000..1fa1972 --- /dev/null +++ b/templates/type-new.html @@ -0,0 +1,9 @@ +{% extends "layout.html" %} +{% from "macros.html" import render_form %} +{% block title %}Protokolltyp anlegen{% endblock %} + +{% block content %} +<div class="container"> + {{render_form(form, action_url=url_for("new_type"), action_text="Anlegen")}} +</div> +{% endblock %} diff --git a/templates/type-show.html b/templates/type-show.html new file mode 100644 index 0000000..ee4f3aa --- /dev/null +++ b/templates/type-show.html @@ -0,0 +1,11 @@ +{% extends "layout.html" %} +{% from "macros.html" import render_table, render_single_table %} +{% block title %}Protokolltyp {{protocoltype.short_name}}{% endblock %} + +{% block content %} +<div class="container"> + {{render_single_table(protocoltype_table)}} + {{render_table(default_tops_table)}} + Standard-TOPs mit negativer Sortierung werden vor und die mit positiver Sortierung nach den TOPs eingefügt. +</div> +{% endblock %} diff --git a/templates/types-list.html b/templates/types-list.html new file mode 100644 index 0000000..cb0b4ba --- /dev/null +++ b/templates/types-list.html @@ -0,0 +1,9 @@ +{% extends "layout.html" %} +{% from "macros.html" import render_table %} +{% block title %}Protokolltypen{% endblock %} + +{% block content %} +<div class="container"> + {{render_table(types_table)}} +</div> +{% endblock %} diff --git a/views/forms.py b/views/forms.py index 175fcc2..095c5b8 100644 --- a/views/forms.py +++ b/views/forms.py @@ -1,7 +1,21 @@ from flask_wtf import FlaskForm -from wtforms import StringField, PasswordField +from wtforms import StringField, PasswordField, BooleanField, DateField, HiddenField, IntegerField from wtforms.validators import InputRequired class LoginForm(FlaskForm): - username = StringField("User", validators=[InputRequired("Please input the username.")]) - password = PasswordField("Password", validators=[InputRequired("Please input the password.")]) + username = StringField("Benutzer", validators=[InputRequired("Bitte gib deinen Benutzernamen ein.")]) + password = PasswordField("Passwort", validators=[InputRequired("Bitte gib dein Passwort ein.")]) + +class ProtocolTypeForm(FlaskForm): + name = StringField("Name", validators=[InputRequired("Du musst einen Namen angeben.")]) + short_name = StringField("Abkürzung", validators=[InputRequired("Du musst eine Abkürzung angebene.")]) + organization = StringField("Organisation", validators=[InputRequired("Du musst eine zugehörige Organisation angeben.")]) + is_public = BooleanField("Öffentlich sichtbar") + private_group = StringField("Interne Gruppe") + public_group = StringField("Öffentliche Gruppe") + private_mail = StringField("Interner Verteiler") + public_mail = StringField("Öffentlicher Verteiler") + +class DefaultTopForm(FlaskForm): + name = StringField("Name", validators=[InputRequired("Du musst einen Namen angeben.")]) + number = IntegerField("Nummer", validators=[InputRequired("Du musst eine Nummer angeben.")]) diff --git a/views/tables.py b/views/tables.py index bb3a44d..034ea0a 100644 --- a/views/tables.py +++ b/views/tables.py @@ -4,11 +4,11 @@ from models.database import Protocol, ProtocolType, DefaultTOP, TOP, Todo, Decis from shared import date_filter class Table: - def __init__(self, title, values, newlink=None): + def __init__(self, title, values, newlink=None, newtext=None): self.title = title self.values = values self.newlink = newlink - self.newtext = "New" + self.newtext = newtext or "Neu" def rows(self): return [row for row in [self.row(value) for value in self.values] if row is not None] @@ -26,11 +26,11 @@ class Table: @staticmethod def bool(value): - return "Yes" if value else "No" + return "Ja" if value else "Nein" @staticmethod def concat(values): - return ", ".join(values) + return Markup(", ".join(values)) #if len(values) <= 1: # return "".join(values) #else: @@ -38,11 +38,11 @@ class Table: class SingleValueTable: - def __init__(self, title, value, newlink=None): + def __init__(self, title, value, newlink=None, newtext=None): self.title = title self.value = value self.newlink = newlink if newlink else None - self.newtext = "Edit" + self.newtext = newtext or "Ändern" def rows(self): return [self.row()] @@ -61,3 +61,58 @@ class ProtocolsTable(Table): date_filter(protocol.data) ] +class ProtocolTypesTable(Table): + def __init__(self, types): + super().__init__("Protokolltypen", types, newlink=url_for("new_type")) + + def headers(self): + return ["Typ", "Name", "Neuestes Protokoll", ""] + + def row(self, protocoltype): + return [ + Table.link(url_for("show_type", type_id=protocoltype.id), protocoltype.short_name), + protocoltype.name, + protocoltype.get_latest_protocol() or "Noch kein Protokoll", + "" # TODO: add links for new, modify, delete + ] + +class ProtocolTypeTable(SingleValueTable): + def __init__(self, protocoltype): + super().__init__(protocoltype.name, protocoltype, newlink=url_for("edit_type", type_id=protocoltype.id)) + + def headers(self): + return ["Name", "Abkürzung", "Organisation", "Öffentlich", + "Interne Gruppe", "Öffentliche Gruppe", + "Interner Verteiler", "Öffentlicher Verteiler"] + + def row(self): + return [ + self.value.name, + self.value.short_name, + self.value.organization, + Table.bool(self.value.is_public), + self.value.private_group, + self.value.public_group, + self.value.private_mail, + self.value.public_mail + ] + +class DefaultTOPsTable(Table): + def __init__(self, tops, protocoltype=None): + super().__init__("Standard-TOPs", tops, newlink=url_for("new_default_top", type_id=protocoltype.id) if protocoltype is not None else None) + self.protocoltype = protocoltype + + def headers(self): + return ["TOP", "Sortierung", ""] + + def row(self, top): + return [ + top.name, + top.number, + Table.concat([ + Table.link(url_for("move_default_top", type_id=self.protocoltype.id, top_id=top.id, diff=1), "Runter"), + Table.link(url_for("move_default_top", type_id=self.protocoltype.id, top_id=top.id, diff=-1), "Hoch"), + Table.link(url_for("edit_default_top", type_id=self.protocoltype.id, top_id=top.id), "Ändern"), + Table.link(url_for("delete_default_top", type_id=self.protocoltype.id, top_id=top.id), "Löschen", confirm="Bist du dir sicher, dass du den Standard-TOP {} löschen willst?".format(top.name)) + ]) + ] -- GitLab