diff --git a/migrations/versions/6a86c1d5682f_.py b/migrations/versions/6a86c1d5682f_.py new file mode 100644 index 0000000000000000000000000000000000000000..84de90d7a8e1fe9d689926bffd5d5888dae83bcd --- /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 0000000000000000000000000000000000000000..928f0f2b63fbca5755b120a710f5094f8420df7d --- /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 9e47ae541bc89aac923e35f668a4803a1960944e..8056820ccc354ad1739f744ff77c630d04b20f23 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 5478887de05428554d267b5a78cdcc7abc8c9dc0..f4b954deb0c021cd96e7e6148098793ffb13a610 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 c57ecdf1cf96e2110386255551eed4f00137b901..606b8745f4b5414e3d5d9897def8460a2984ae29 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 432f853523f83432bbbb700ab5e206958e2b7e30..03c0d2d9dd83bbea58ef71f476a05460d1f7d65b 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 f3ac249720eb8aa5c1a96866ac08dae5d47ab62a..c750b635c2a0d0d2fd760367633870145e506296 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">👍</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 b2f64f4d9f59197385c0ed7f87dd49c59f699f7d..c0cf3d55aa50665ddc23d8eba574317167e7b825 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 b1aefb66029a677265390f1dc632015ec4c34c6b..7e9c43c4cba3fc657e96a731bd41342d8ee924ad 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 cd843e460222adf801f2c030069d17a89ed0887c..22917b0de05dcdf39d02d5a9e9034a22ecd281db 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)