From 9f4adef4076194cb887db736b7e66074f35595c8 Mon Sep 17 00:00:00 2001
From: Robin Sonnabend <robin@fsmpi.rwth-aachen.de>
Date: Fri, 31 Mar 2017 21:37:35 +0200
Subject: [PATCH] /close #1

---
 migrations/versions/6a86c1d5682f_.py | 90 ++++++++++++++++++++++++++++
 migrations/versions/9845a330ed06_.py | 64 ++++++++++++++++++++
 models/database.py                   | 35 ++++++++++-
 server.py                            | 34 ++++++++++-
 static/css/style.css                 | 20 ++++++-
 templates/index.html                 | 18 ++++--
 templates/macros.html                | 30 ++++++++++
 templates/protocol-show.html         | 18 ++++--
 templates/protocol-tops-include.html |  2 +
 utils.py                             |  8 +++
 10 files changed, 306 insertions(+), 13 deletions(-)
 create mode 100644 migrations/versions/6a86c1d5682f_.py
 create mode 100644 migrations/versions/9845a330ed06_.py

diff --git a/migrations/versions/6a86c1d5682f_.py b/migrations/versions/6a86c1d5682f_.py
new file mode 100644
index 0000000..84de90d
--- /dev/null
+++ b/migrations/versions/6a86c1d5682f_.py
@@ -0,0 +1,90 @@
+"""empty message
+
+Revision ID: 6a86c1d5682f
+Revises: 9845a330ed06
+Create Date: 2017-03-31 20:33:32.885639
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '6a86c1d5682f'
+down_revision = '9845a330ed06'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('likeprotocolassociations',
+    sa.Column('like_id', sa.Integer(), nullable=False),
+    sa.Column('protocol_id', sa.Integer(), nullable=False),
+    sa.ForeignKeyConstraint(['like_id'], ['likes.id'], ),
+    sa.ForeignKeyConstraint(['protocol_id'], ['protocols.id'], ),
+    sa.PrimaryKeyConstraint('like_id', 'protocol_id')
+    )
+    op.create_table('liketodoassociations',
+    sa.Column('like_id', sa.Integer(), nullable=False),
+    sa.Column('todo_id', sa.Integer(), nullable=False),
+    sa.ForeignKeyConstraint(['like_id'], ['likes.id'], ),
+    sa.ForeignKeyConstraint(['todo_id'], ['todos.id'], ),
+    sa.PrimaryKeyConstraint('like_id', 'todo_id')
+    )
+    op.create_table('likedecisionassociations',
+    sa.Column('like_id', sa.Integer(), nullable=False),
+    sa.Column('decision_id', sa.Integer(), nullable=False),
+    sa.ForeignKeyConstraint(['decision_id'], ['decisions.id'], ),
+    sa.ForeignKeyConstraint(['like_id'], ['likes.id'], ),
+    sa.PrimaryKeyConstraint('like_id', 'decision_id')
+    )
+    op.create_table('liketopassociations',
+    sa.Column('like_id', sa.Integer(), nullable=False),
+    sa.Column('top_id', sa.Integer(), nullable=False),
+    sa.ForeignKeyConstraint(['like_id'], ['likes.id'], ),
+    sa.ForeignKeyConstraint(['top_id'], ['tops.id'], ),
+    sa.PrimaryKeyConstraint('like_id', 'top_id')
+    )
+    op.drop_table('like_top_association')
+    op.drop_table('like_protocol_association')
+    op.drop_table('like_todo_association')
+    op.drop_table('like_decision_association')
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('like_decision_association',
+    sa.Column('like_id', sa.INTEGER(), autoincrement=False, nullable=False),
+    sa.Column('decision_id', sa.INTEGER(), autoincrement=False, nullable=False),
+    sa.ForeignKeyConstraint(['decision_id'], ['decisions.id'], name='like_decision_association_decision_id_fkey'),
+    sa.ForeignKeyConstraint(['like_id'], ['likes.id'], name='like_decision_association_like_id_fkey'),
+    sa.PrimaryKeyConstraint('like_id', 'decision_id', name='like_decision_association_pkey')
+    )
+    op.create_table('like_todo_association',
+    sa.Column('like_id', sa.INTEGER(), autoincrement=False, nullable=False),
+    sa.Column('todo_id', sa.INTEGER(), autoincrement=False, nullable=False),
+    sa.ForeignKeyConstraint(['like_id'], ['likes.id'], name='like_todo_association_like_id_fkey'),
+    sa.ForeignKeyConstraint(['todo_id'], ['todos.id'], name='like_todo_association_todo_id_fkey'),
+    sa.PrimaryKeyConstraint('like_id', 'todo_id', name='like_todo_association_pkey')
+    )
+    op.create_table('like_protocol_association',
+    sa.Column('like_id', sa.INTEGER(), autoincrement=False, nullable=False),
+    sa.Column('protocol_id', sa.INTEGER(), autoincrement=False, nullable=False),
+    sa.ForeignKeyConstraint(['like_id'], ['likes.id'], name='like_protocol_association_like_id_fkey'),
+    sa.ForeignKeyConstraint(['protocol_id'], ['protocols.id'], name='like_protocol_association_protocol_id_fkey'),
+    sa.PrimaryKeyConstraint('like_id', 'protocol_id', name='like_protocol_association_pkey')
+    )
+    op.create_table('like_top_association',
+    sa.Column('like_id', sa.INTEGER(), autoincrement=False, nullable=False),
+    sa.Column('top_id', sa.INTEGER(), autoincrement=False, nullable=False),
+    sa.ForeignKeyConstraint(['like_id'], ['likes.id'], name='like_top_association_like_id_fkey'),
+    sa.ForeignKeyConstraint(['top_id'], ['tops.id'], name='like_top_association_top_id_fkey'),
+    sa.PrimaryKeyConstraint('like_id', 'top_id', name='like_top_association_pkey')
+    )
+    op.drop_table('liketopassociations')
+    op.drop_table('likedecisionassociations')
+    op.drop_table('liketodoassociations')
+    op.drop_table('likeprotocolassociations')
+    # ### end Alembic commands ###
diff --git a/migrations/versions/9845a330ed06_.py b/migrations/versions/9845a330ed06_.py
new file mode 100644
index 0000000..928f0f2
--- /dev/null
+++ b/migrations/versions/9845a330ed06_.py
@@ -0,0 +1,64 @@
+"""empty message
+
+Revision ID: 9845a330ed06
+Revises: 4651698510d7
+Create Date: 2017-03-31 20:31:34.199580
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '9845a330ed06'
+down_revision = '4651698510d7'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('likes',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('who', sa.String(), nullable=True),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_table('like_protocol_association',
+    sa.Column('like_id', sa.Integer(), nullable=False),
+    sa.Column('protocol_id', sa.Integer(), nullable=False),
+    sa.ForeignKeyConstraint(['like_id'], ['likes.id'], ),
+    sa.ForeignKeyConstraint(['protocol_id'], ['protocols.id'], ),
+    sa.PrimaryKeyConstraint('like_id', 'protocol_id')
+    )
+    op.create_table('like_todo_association',
+    sa.Column('like_id', sa.Integer(), nullable=False),
+    sa.Column('todo_id', sa.Integer(), nullable=False),
+    sa.ForeignKeyConstraint(['like_id'], ['likes.id'], ),
+    sa.ForeignKeyConstraint(['todo_id'], ['todos.id'], ),
+    sa.PrimaryKeyConstraint('like_id', 'todo_id')
+    )
+    op.create_table('like_decision_association',
+    sa.Column('like_id', sa.Integer(), nullable=False),
+    sa.Column('decision_id', sa.Integer(), nullable=False),
+    sa.ForeignKeyConstraint(['decision_id'], ['decisions.id'], ),
+    sa.ForeignKeyConstraint(['like_id'], ['likes.id'], ),
+    sa.PrimaryKeyConstraint('like_id', 'decision_id')
+    )
+    op.create_table('like_top_association',
+    sa.Column('like_id', sa.Integer(), nullable=False),
+    sa.Column('top_id', sa.Integer(), nullable=False),
+    sa.ForeignKeyConstraint(['like_id'], ['likes.id'], ),
+    sa.ForeignKeyConstraint(['top_id'], ['tops.id'], ),
+    sa.PrimaryKeyConstraint('like_id', 'top_id')
+    )
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_table('like_top_association')
+    op.drop_table('like_decision_association')
+    op.drop_table('like_todo_association')
+    op.drop_table('like_protocol_association')
+    op.drop_table('likes')
+    # ### end Alembic commands ###
diff --git a/models/database.py b/models/database.py
index 9e47ae5..8056820 100644
--- a/models/database.py
+++ b/models/database.py
@@ -157,6 +157,8 @@ class Protocol(DatabaseModel):
     metas = relationship("Meta", backref=backref("protocol"), cascade="all, delete-orphan")
     localtops = relationship("LocalTOP", backref=backref("protocol"), cascade="all, delete-orphan")
 
+    likes = relationship("Like", secondary="likeprotocolassociations")
+
     def get_parent(self):
         return self.protocoltype
 
@@ -341,6 +343,8 @@ class TOP(DatabaseModel):
     planned = db.Column(db.Boolean)
     description = db.Column(db.String)
 
+    likes = relationship("Like", secondary="liketopassociations")
+
     def get_parent(self):
         return self.protocol
 
@@ -496,6 +500,7 @@ class Todo(DatabaseModel):
     date = db.Column(db.Date, nullable=True)
 
     protocols = relationship("Protocol", secondary="todoprotocolassociations", backref="todos")
+    likes = relationship("Like", secondary="liketodoassociations")
 
     def get_parent(self):
         return self.protocoltype
@@ -584,6 +589,8 @@ class Decision(DatabaseModel):
 
     document = relationship("DecisionDocument", backref=backref("decision"), cascade="all, delete-orphan", uselist=False)
 
+    likes = relationship("Like", secondary="likedecisionassociations")
+
     def get_parent(self):
         return self.protocol
 
@@ -674,8 +681,34 @@ class Meta(DatabaseModel):
     def get_parent(self):
         return self.protocol
 
+class Like(DatabaseModel):
+    __tablename__ = "likes"
+    __model_name__ = "like"
+    id = db.Column(db.Integer, primary_key=True)
+    who = db.Column(db.String)
+
+class LikeProtocolAssociation(DatabaseModel):
+    __tablename__ = "likeprotocolassociations"
+    like_id = db.Column(db.Integer, db.ForeignKey("likes.id"), primary_key=True)
+    protocol_id = db.Column(db.Integer, db.ForeignKey("protocols.id"), primary_key=True)
+
+class LikeTodoAssociation(DatabaseModel):
+    __tablename__ = "liketodoassociations"
+    like_id = db.Column(db.Integer, db.ForeignKey("likes.id"), primary_key=True)
+    todo_id = db.Column(db.Integer, db.ForeignKey("todos.id"), primary_key=True)
+
+class LikeDecisionAssociation(DatabaseModel):
+    __tablename__ = "likedecisionassociations"
+    like_id = db.Column(db.Integer, db.ForeignKey("likes.id"), primary_key=True)
+    decision_id = db.Column(db.Integer, db.ForeignKey("decisions.id"), primary_key=True)
+
+class LikeTOPAssociation(DatabaseModel):
+    __tablename__ = "liketopassociations"
+    like_id = db.Column(db.Integer, db.ForeignKey("likes.id"), primary_key=True)
+    top_id = db.Column(db.Integer, db.ForeignKey("tops.id"), primary_key=True)
+
+
 ALL_MODELS = [
     ProtocolType, Protocol, DefaultTOP, TOP, Document, DecisionDocument,
     Todo, Decision, MeetingReminder, Error, DefaultMeta, Meta, DecisionCategory
 ]
-    
diff --git a/server.py b/server.py
index 5478887..f4b954d 100755
--- a/server.py
+++ b/server.py
@@ -2,7 +2,7 @@
 import locale
 locale.setlocale(locale.LC_TIME, "de_DE.utf8")
 
-from flask import Flask, g, current_app, request, session, flash, redirect, url_for, abort, render_template, Response, send_file as flask_send_file
+from flask import Flask, g, current_app, request, session, flash, redirect, url_for, abort, render_template, Response, send_file as flask_send_file, Markup
 from werkzeug.utils import secure_filename
 from flask_script import Manager, prompt
 from flask_migrate import Migrate, MigrateCommand
@@ -21,9 +21,9 @@ import mimetypes
 
 import config
 from shared import db, date_filter, datetime_filter, date_filter_long, date_filter_short, time_filter, time_filter_short, ldap_manager, security_manager, current_user, check_login, login_required, group_required, class_filter, needs_date_test, todostate_name_filter, code_filter, indent_tab_filter
-from utils import is_past, mail_manager, url_manager, get_first_unused_int, set_etherpad_text, get_etherpad_text, split_terms, optional_int_arg
+from utils import is_past, mail_manager, url_manager, get_first_unused_int, set_etherpad_text, get_etherpad_text, split_terms, optional_int_arg, fancy_join
 from decorators import db_lookup, require_public_view_right, require_private_view_right, require_modify_right, require_admin_right
-from models.database import ProtocolType, Protocol, DefaultTOP, TOP, LocalTOP, Document, Todo, Decision, MeetingReminder, Error, TodoMail, DecisionDocument, TodoState, Meta, DefaultMeta, DecisionCategory
+from models.database import ProtocolType, Protocol, DefaultTOP, TOP, LocalTOP, Document, Todo, Decision, MeetingReminder, Error, TodoMail, DecisionDocument, TodoState, Meta, DefaultMeta, DecisionCategory, Like
 from views.forms import LoginForm, ProtocolTypeForm, DefaultTopForm, MeetingReminderForm, NewProtocolForm, DocumentUploadForm, KnownProtocolSourceUploadForm, NewProtocolSourceUploadForm, ProtocolForm, TopForm, LocalTopForm, SearchForm, DecisionSearchForm, ProtocolSearchForm, TodoSearchForm, NewProtocolFileUploadForm, NewTodoForm, TodoForm, TodoMailForm, DefaultMetaForm, MetaForm, MergeTodosForm, DecisionCategoryForm
 from views.tables import ProtocolsTable, ProtocolTypesTable, ProtocolTypeTable, DefaultTOPsTable, MeetingRemindersTable, ErrorsTable, TodosTable, DocumentsTable, DecisionsTable, TodoTable, ErrorTable, TodoMailsTable, DefaultMetasTable, DecisionCategoriesTable
 from legacy import import_old_todos, import_old_protocols, import_old_todomails
@@ -59,6 +59,7 @@ app.jinja_env.filters["class"] = class_filter
 app.jinja_env.filters["todo_get_name"] = todostate_name_filter
 app.jinja_env.filters["code"] = code_filter
 app.jinja_env.filters["indent_tab"] = indent_tab_filter
+app.jinja_env.filters["fancy_join"] = fancy_join
 app.jinja_env.tests["auth_valid"] = security_manager.check_user
 app.jinja_env.tests["needs_date"] = needs_date_test
 
@@ -70,6 +71,7 @@ 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)
+app.jinja_env.globals.update(now=datetime.now)
 
 # blueprints here
 
@@ -1245,6 +1247,32 @@ def delete_decisioncategory(decisioncategory):
     flash("Beschlusskategorie {} gelöscht.".format(name), "alert-success")
     return redirect(request.args.get("next") or url_for("show_type", protocoltype_id=type_id))
 
+@app.route("/like/new")
+@login_required
+def new_like():
+    user = current_user()
+    parent = None
+    if "protocol_id" in request.args:
+        parent = Protocol.query.filter_by(id=request.args.get("protocol_id")).first()
+    elif "todo_id" in request.args:
+        parent = Todo.query.filter_by(id=request.args.get("todo_id")).first()
+    elif "decision_id" in request.args:
+        parent = Decision.query.filter_by(id=request.args.get("decision_id")).first()
+    elif "top_id" in request.args:
+        parent = TOP.query.filter_by(id=request.args.get("top_id")).first()
+    if parent is None or not parent.has_public_view_right(user):
+        flash("Missing object to like.", "alert-error")
+        return redirect(request.args.get("next") or url_for("index"))
+    if len([like for like in parent.likes if like.who == user.username]) > 0:
+        flash("You have liked this already!", "alert-error")
+        return redirect(request.args.get("next") or url_for("index"))
+    like = Like(who=user.username)
+    db.session.add(like)
+    parent.likes.append(like)
+    db.session.commit()
+    flash("Like!", "alert-success")
+    return redirect(request.args.get("next") or url_for("index"))
+
 @app.route("/login", methods=["GET", "POST"])
 def login():
     if "auth" in session:
diff --git a/static/css/style.css b/static/css/style.css
index c57ecdf..606b874 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -20,7 +20,7 @@ body {
     color: #808000;
 }
 
-h3 > a {
+h2 > a, h3 > a {
     font-size: 18px;
 }
 
@@ -55,3 +55,21 @@ textarea {
   outline: none;
   background-color: #d0d0d0;
 }
+
+.likes-div {
+    display: inline-block;
+    font-size: 10pt;
+    background-color: #ddddff;
+    border-radius: 3px;
+    padding: 1px;
+}
+
+.likes-div > p {
+    display: inline-block;
+    margin: 0;
+    padding: 0;
+}
+
+.like-sign {
+    font-size: 16px;
+}
diff --git a/templates/index.html b/templates/index.html
index 432f853..03c0d2d 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -1,5 +1,5 @@
 {% extends "layout.html" %}
-{% from "macros.html" import render_table %}
+{% from "macros.html" import render_table, render_likes %}
 {% block title %}Startseite{% endblock %}
 
 {% block content %}
@@ -15,7 +15,10 @@
         <ul>
             {% if open_protocols|length > 0 %}
                 {% for protocol in open_protocols %}
-                    <li><a href="{{url_for("show_protocol", protocol_id=protocol.id)}}">{{protocol.protocoltype.name}}</a> am {{protocol.date|datify}}</li>
+                    <li>
+                        <a href="{{url_for("show_protocol", protocol_id=protocol.id)}}">{{protocol.protocoltype.name}}</a> am {{protocol.date|datify}}
+                        {{render_likes(protocol.likes, protocol_id=protocol.id)}}
+                    </li>
                 {% endfor %}
             {% else %}
                 <li>Keine anstehenden Sitzungen</li>
@@ -53,7 +56,10 @@
                     {% if has_public_view_right %}
                         {% if protocol.decisions|length > 0 %}
                             {% for decision in protocol.decisions %}
-                                <li>{{decision.content}}</li>
+                                <li>
+                                    {{decision.content}}
+                                    {{render_likes(decision.likes, decision_id=decision.id)}}
+                                </li>
                             {% endfor %}
                         {% else %}
                             <li>Keine Beschlüsse</li>
@@ -71,7 +77,11 @@
         <ul>
             {% if todos|length > 0 %}
                 {% for todo in todos %}
-        <li>{{todo.render_html()|safe}} ({{todo.protocoltype.name}})</li>
+                    <li>
+                        {{todo.render_html()|safe}}
+                        ({{todo.protocoltype.name}})
+                        {{render_likes(todo.likes, todo_id=todo.id)}}
+                    </li>
                 {% endfor %}
             {% else %}
                 <li>Keine Todos</li>
diff --git a/templates/macros.html b/templates/macros.html
index f3ac249..c750b63 100644
--- a/templates/macros.html
+++ b/templates/macros.html
@@ -156,3 +156,33 @@ to not render a label for the CRSFTokenField -->
         </tbody>
     </table>
 {%- endmacro %}
+
+{% macro render_likes(likes) -%}
+    {% set timestamp = now() %}
+    {% if timestamp.month == 4 and timestamp.day == 1 %}
+        {% set user=current_user() %}
+        {% set add_link = user is not none and likes|selectattr("who", "equalto", user.username)|list|length == 0 %}
+        {% set verb = "like" %}
+        {% if likes|length == 1 %}
+            {% set verb = "likes" %}
+        {% endif %}
+        {% if add_link %}
+        <a href="{{url_for("new_like", next=request.url, **kwargs)}}">
+        {% endif %}
+        <div class="likes-div">
+            <p>{{likes|length}} <span class="like-sign">&#x1f44d;</span></p>
+                {% if user is not none or likes|length > 0 %}
+                <p>
+                    {% if likes|length == 0 %}
+                        Be the first one to like this!
+                    {% else %}
+                        {{likes|map(attribute="who")|map("capitalize")|fancy_join(" and ")}} {{verb}} this 
+                    {% endif %}
+                </p>
+            {% endif %}
+        </div>
+        {% if add_link %}
+        </a>
+        {% endif %}
+    {% endif %}
+{%- endmacro %}
diff --git a/templates/protocol-show.html b/templates/protocol-show.html
index b2f64f4..c0cf3d5 100644
--- a/templates/protocol-show.html
+++ b/templates/protocol-show.html
@@ -1,5 +1,5 @@
 {% extends "layout.html" %}
-{% from "macros.html" import render_table, render_form %}
+{% from "macros.html" import render_table, render_form, render_likes %}
 {% block title %}Protokoll{% endblock %}
 
 {% set logged_in = check_login() %}
@@ -52,9 +52,15 @@
     <div class="row">
         <div id="left-column" class="col-lg-6">
             {% if protocol.is_done() %}
-                <h2>Protokoll: {{protocol.protocoltype.name}} {% if protocol.date is not none %}vom {{protocol.date|datify}}{% endif %}</h2>
+                <h2>
+                    Protokoll: {{protocol.protocoltype.name}} {% if protocol.date is not none %}vom {{protocol.date|datify}}{% endif %}
+                    {{render_likes(protocol.likes, protocol_id=protocol.id)}}</h2>
+                </h2>
             {% else %}
-                <h2>{{protocol.protocoltype.name}} {% if protocol.date is not none %}am {{protocol.date|datify}}{% endif %}</h2>
+                <h2>
+                    {{protocol.protocoltype.name}} {% if protocol.date is not none %}am {{protocol.date|datify}}{% endif %}
+                    {{render_likes(protocol.likes, protocol_id=protocol.id)}}
+                </h2>
             {% endif %}
             {% if protocol.is_done() %}
                 {% if protocol.date is not none %}
@@ -88,6 +94,7 @@
                                     {% if config.PRINTING_ACTIVE and has_private_view_right and decision.document is not none %}
                                         <a href="{{url_for("print_decision", decisiondocument_id=decision.document.id)}}">Drucken</a>
                                     {% endif %}
+                                    {{render_likes(decision.likes, decision_id=decision.id)}}</h2>
                                 </li>
                             {% endfor %}
                         {% else %}
@@ -109,7 +116,10 @@
                 <ul>
                     {% if protocol.get_originating_todos()|length > 0 %}
                         {% for todo in protocol.get_originating_todos() %}
-                            <li>{{todo.render_html()|safe}}</li>
+                            <li>
+                                {{todo.render_html()|safe}}
+                                {{render_likes(todo.likes, todo_id=todo.id)}}
+                            </li>
                         {% endfor %}
                     {% else %}
                         <li>Keine Todos</li>
diff --git a/templates/protocol-tops-include.html b/templates/protocol-tops-include.html
index b1aefb6..7e9c43c 100644
--- a/templates/protocol-tops-include.html
+++ b/templates/protocol-tops-include.html
@@ -1,3 +1,4 @@
+{% from "macros.html" import render_likes %}
 <ul>
     {% if not protocol.has_nonplanned_tops() %}
         {% for default_top in protocol.protocoltype.default_tops %}
@@ -36,6 +37,7 @@
                     {{-top.description-}}
                 </pre>
             {% endif %}
+            {{render_likes(top.likes, top_id=top.id)}}
         </li>
     {% endfor %}
     {% if not protocol.has_nonplanned_tops() %}
diff --git a/utils.py b/utils.py
index cd843e4..22917b0 100644
--- a/utils.py
+++ b/utils.py
@@ -187,3 +187,11 @@ def check_ip_in_networks(networks_string):
         return False
     except ValueError:
         return False
+
+def fancy_join(values, sep1=" und ", sep2=", "):
+    values = list(values)
+    if len(values) <= 1:
+        return "".join(values)
+    last = values[-1]
+    start = values[:-1]
+    return "{}{}{}".format(sep2.join(start), sep1, last)
-- 
GitLab