diff --git a/migrations/versions/97cf1913e60d_.py b/migrations/versions/97cf1913e60d_.py new file mode 100644 index 0000000000000000000000000000000000000000..18377e9e13ce5039ae4bfb5749a0a8a9cc514935 --- /dev/null +++ b/migrations/versions/97cf1913e60d_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 97cf1913e60d +Revises: bbc1782c0999 +Create Date: 2017-02-22 23:36:29.467493 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '97cf1913e60d' +down_revision = 'bbc1782c0999' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('tops', sa.Column('number', sa.Integer(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('tops', 'number') + # ### end Alembic commands ### diff --git a/migrations/versions/bbc1782c0999_.py b/migrations/versions/bbc1782c0999_.py new file mode 100644 index 0000000000000000000000000000000000000000..d1787c079b8dfa99374803fe409d265cb618bf22 --- /dev/null +++ b/migrations/versions/bbc1782c0999_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: bbc1782c0999 +Revises: 162da8aeeb71 +Create Date: 2017-02-22 23:36:11.613892 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'bbc1782c0999' +down_revision = '162da8aeeb71' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('tops', 'number') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('tops', sa.Column('number', sa.VARCHAR(), autoincrement=False, nullable=True)) + # ### end Alembic commands ### diff --git a/models/database.py b/models/database.py index 6dd11e89585e4e76a26235fda989ce1164b37a73..9c511e3df9dad627138e1a06d9d7a450bc4623dd 100644 --- a/models/database.py +++ b/models/database.py @@ -1,7 +1,6 @@ from flask import render_template, send_file, url_for, redirect, flash, request -from datetime import datetime, date, timedelta -import time +from datetime import datetime, time, date, timedelta import math from shared import db @@ -11,6 +10,8 @@ from utils import random_string, url_manager from sqlalchemy.orm import relationship, backref +import config + class ProtocolType(db.Model): __tablename__ = "protocoltypes" id = db.Column(db.Integer, primary_key=True) @@ -79,7 +80,7 @@ class Protocol(db.Model): documents = relationship("Document", backref=backref("protocol"), cascade="all, delete-orphan", order_by="Document.is_compiled") errors = relationship("Error", backref=backref("protocol"), cascade="all, delete-orphan", order_by="Error.id") - def __init__(self, protocoltype_id, date, source=None, start_time=None, end_time=None, author=None, participants=None, location=None): + def __init__(self, protocoltype_id, date, source=None, start_time=None, end_time=None, author=None, participants=None, location=None, done=False): self.protocoltype_id = protocoltype_id self.date = date self.source = source @@ -88,6 +89,7 @@ class Protocol(db.Model): self.author = author self.participants = participants self.location = location + self.done = done def __repr__(self): return "<Protocol(id={}, protocoltype_id={})>".format( @@ -99,8 +101,8 @@ class Protocol(db.Model): def fill_from_remarks(self, remarks): self.date = datetime.strptime(remarks["Datum"].value, "%d.%m.%Y") - self.start_time = time.strptime(remarks["Beginn"].value, "%H:%M") - self.end_time = time.strptime(remarks["Ende"].value, "%H:%M") + self.start_time = datetime.strptime(remarks["Beginn"].value, "%H:%M").time() + self.end_time = datetime.strptime(remarks["Ende"].value, "%H:%M").time() self.author = remarks["Autor"].value self.participants = remarks["Anwesende"].value self.location = remarks["Ort"].value @@ -108,6 +110,22 @@ class Protocol(db.Model): def is_done(self): return self.done + def get_identifier(self): + return "{}-{}".format( + self.protocoltype.short_name.lower(), + self.date.strftime("%y-%m-%d")) + + def get_etherpad_link(self): + return config.ETHERPAD_URL + self.get_identifier() + + def get_etherpad_source_link(self): + return self.get_etherpad_link() + "/export/txt" + + def has_nonplanned_tops(self): + return len([top for top in self.tops if not top.planned]) > 0 + + def get_originating_todos(self): + return [todo for todo in self.todos if self == todo.get_first_protocol()] class DefaultTOP(db.Model): __tablename__ = "defaulttops" @@ -133,7 +151,7 @@ class TOP(db.Model): id = db.Column(db.Integer, primary_key=True) protocol_id = db.Column(db.Integer, db.ForeignKey("protocols.id")) name = db.Column(db.String) - number = db.Column(db.String) + number = db.Column(db.Integer) planned = db.Column(db.Boolean) def __init__(self, protocol_id, name, number, planned): @@ -184,6 +202,24 @@ class Todo(db.Model): return "<Todo(id={}, who={}, description={}, tags={}, done={})>".format( self.id, self.who, self.description, self.tags, self.done) + def get_first_protocol(self): + candidates = sorted(self.protocols, key=lambda p: p.date) + if len(candidates) == 0: + return None + return candidates[0] + + def get_state(self): + return "[Erledigt]" if self.done else "[Offen]" + + def render_html(self): + parts = [ + self.get_state(), + "<strong>{}:</strong>".format(self.who), + self.description + ] + return " ".join(parts) + + class TodoProtocolAssociation(db.Model): __tablename__ = "todoprotocolassociations" todo_id = db.Column(db.Integer, db.ForeignKey("todos.id"), primary_key=True) diff --git a/parser.py b/parser.py index 26e2a13ddb44991662e04566d3bb77ea67860b87..5ded4dad626690dfe4976927f8a355bb5ab899db 100644 --- a/parser.py +++ b/parser.py @@ -221,6 +221,9 @@ class Remark(Element): level = 0 print("{}remark: {}: {}".format(" " * level, self.name, self.value)) + def get_tags(self, tags): + return tags + @staticmethod def parse(match, current, linenumber=None): linenumber = Element.parse_inner(match, current, linenumber) diff --git a/requirements.txt b/requirements.txt index 928fb5c88ed319661823139d7094bf5c06c041dd..5b99e670aed8f6717aac26d6ed92001613f8988d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,6 +29,7 @@ pytz==2016.10 PyYAML==3.12 redis==2.10.5 regex==2017.2.8 +requests==2.13.0 six==1.10.0 SQLAlchemy==1.1.5 vine==1.1.3 diff --git a/server.py b/server.py index 04c5f4e784d0bc1ee4f3721ecff81dd4fa79c356..030978ceffa89c8453c51731f2d5ef08c6682e52 100755 --- a/server.py +++ b/server.py @@ -2,19 +2,21 @@ 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 +from flask import Flask, g, current_app, request, session, flash, redirect, url_for, abort, render_template, Response, send_file from flask_script import Manager, prompt from flask_migrate import Migrate, MigrateCommand #from flask_socketio import SocketIO from celery import Celery from functools import wraps +import requests +from io import StringIO, BytesIO import config -from shared import db, date_filter, datetime_filter, ldap_manager, security_manager +from shared import db, date_filter, datetime_filter, date_filter_long, time_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, ProtocolTypeForm, DefaultTopForm, MeetingReminderForm -from views.tables import ProtocolsTable, ProtocolTypesTable, ProtocolTypeTable, DefaultTOPsTable, MeetingRemindersTable +from views.forms import LoginForm, ProtocolTypeForm, DefaultTopForm, MeetingReminderForm, NewProtocolForm +from views.tables import ProtocolsTable, ProtocolTypesTable, ProtocolTypeTable, DefaultTOPsTable, MeetingRemindersTable, ErrorsTable, TodosTable app = Flask(__name__) app.config.from_object(config) @@ -38,6 +40,8 @@ app.jinja_env.trim_blocks = True app.jinja_env.lstrip_blocks = True app.jinja_env.filters["datify"] = date_filter app.jinja_env.filters["datetimify"] = datetime_filter +app.jinja_env.filters["timify"] = time_filter +app.jinja_env.filters["datify_long"] = date_filter_long app.jinja_env.filters["url_complete"] = url_manager.complete app.jinja_env.tests["auth_valid"] = security_manager.check_user @@ -290,9 +294,9 @@ def move_default_top(type_id, top_id, diff): 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") + +@app.route("/protocols/list") def list_protocols(): is_logged_in = check_login() user = current_user() @@ -302,9 +306,87 @@ def list_protocols(): or (is_logged_in and ( protocol.protocoltype.public_group in user.groups or protocol.protocoltype.private_group in user.groups))] + # TODO: sort by date and paginate protocols_table = ProtocolsTable(protocols) return render_template("protocols-list.html", protocols=protocols, protocols_table=protocols_table) - + +@app.route("/protocol/new", methods=["GET", "POST"]) +@login_required +def new_protocol(): + user = current_user() + protocoltypes = ProtocolType.query.all() + form = NewProtocolForm(protocoltypes) + if form.validate_on_submit(): + protocoltype = ProtocolType.query.filter_by(id=form.protocoltype.data).first() + if protocoltype is None or 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")) + protocol = Protocol(protocoltype.id, form.date.data) + db.session.add(protocol) + db.session.commit() + return redirect(request.args.get("next") or url_for("list_protocols")) + return render_template("protocol-new.html", form=form, protocoltypes=protocoltypes) + +@app.route("/protocol/show/<int:protocol_id>") +def show_protocol(protocol_id): + user = current_user() + protocol = Protocol.query.filter_by(id=protocol_id).first() + if protocol is None or not protocol.protocoltype.has_public_view_right(user): + flash("Invalides Protokoll.", "alert-error") + return redirect(request.args.get("next") or url_for("index")) + errors_table = ErrorsTable(protocol.errors) + return render_template("protocol-show.html", protocol=protocol, errors_table=errors_table) + +@app.route("/protocol/etherpull/<int:protocol_id>") +def etherpull_protocol(protocol_id): + user = current_user() + protocol = Protocol.query.filter_by(id=protocol_id).first() + if protocol is None or not protocol.protocoltype.has_modify_right(user): + flash("Invalides Protokoll oder keine Berechtigung.", "alert-error") + return redirect(request.args.get("next") or url_for("index")) + source_req = requests.get(protocol.get_etherpad_source_link()) + #source = source_req.content.decode("utf-8") + source = source_req.text + print(source.split("\r")) + #print(source.split("\n")) + protocol.source = source + db.session.commit() + tasks.parse_protocol(protocol) + flash("Das Protokoll wird kompiliert.", "alert-success") + return redirect(request.args.get("next") or url_for("show_protocol", protocol_id=protocol.id)) + +@app.route("/protocol/source/<int:protocol_id>") +@login_required +def get_protocol_source(protocol_id): + user = current_user() + protocol = Protocol.query.filter_by(id=protocol_id).first() + if protocol is None or not protocol.protocoltype.has_modify_right(user): + flash("Invalides Protokoll oder keine Berechtigung.", "alert-error") + return redirect(request.args.get("next") or url_for("index")) + file_like = BytesIO(protocol.source.encode("utf-8")) + return send_file(file_like, cache_timeout=1, as_attachment=True, attachment_filename="{}.txt".format(protocol.get_identifier())) + +@app.route("/protocol/update/<int:protocol_id>") +@login_required +def update_protocol(protocol_id): + user = current_user() + protocol = Protocol.query.filter_by(id=protocol_id).first() + if protocol is None or not protocol.protocoltype.has_modify_right(user): + flash("Invalides Protokoll oder keine Berechtigung.", "alert-error") + return redirect(request.args.get("next") or url_for("index")) + # TODO: render form to upload a new version + + +@app.route("/todos/list") +def list_todos(): + is_logged_in = check_login() + user = current_user() + todos = Todos.query.all() + # TODO: paginate + todos_table = TodosTable(todos) + return render_template("todos-list.html", todos=todos, todos_table=todos_table) + + @app.route("/login", methods=["GET", "POST"]) def login(): diff --git a/static/css/style.css b/static/css/style.css index 062285afd2a44356446c3f0aae333d05e3d2865e..5d8cdaaa21cf49de678da8d245dff82e89d8c307 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -22,3 +22,8 @@ body { h3 > a { font-size: 18px; } + +form { + max-width: 350px; + margin: 0 auto; +} diff --git a/tasks.py b/tasks.py index 3add3d17b22147169c5d5f5a04532005aca80d2b..47102caae0914d6a86f471210b712a3df8623479 100644 --- a/tasks.py +++ b/tasks.py @@ -4,7 +4,7 @@ import os import subprocess import shutil -from models.database import Document, Protocol, Error, Todo, Decision +from models.database import Document, Protocol, Error, Todo, Decision, TOP, DefaultTOP from server import celery, app from shared import db, escape_tex, unhyphen, date_filter, datetime_filter, date_filter_long, time_filter from utils import mail_manager, url_manager, encode_kwargs, decode_kwargs @@ -49,6 +49,9 @@ def parse_protocol_async(protocol_id, encoded_kwargs): protocol = Protocol.query.filter_by(id=protocol_id).first() if protocol is None: raise Exception("No protocol given. Aborting parsing.") + for error in protocol.errors: + protocol.errors.remove(error) + db.session.commit() if protocol.source is None: error = protocol.create_error("Parsing", "Protocol source is None", "") db.session.add(error) @@ -68,9 +71,9 @@ def parse_protocol_async(protocol_id, encoded_kwargs): db.session.add(error) db.session.commit() return - remarks = {element.name: element for element in tree.children is isinstance(element, Remark)} + remarks = {element.name: element for element in tree.children if isinstance(element, Remark)} required_fields = ["Datum", "Anwesende", "Beginn", "Ende", "Autor", "Ort"] - missing_fields = [field for field in required_files if field not in remarks] + missing_fields = [field for field in required_fields if field not in remarks] if len(missing_fields) > 0: error = protocol.create_error("Parsing", "Missing fields", ", ".join(missing_fields)) db.session.add(error) @@ -147,6 +150,16 @@ def parse_protocol_async(protocol_id, encoded_kwargs): decision = Decision(protocol_id=protocol.id, content=decision_tag.values[0]) db.session.add(decision) db.session.commit() + old_tops = list(protocol.tops) + for top in old_tops: + protocol.tops.remove(top) + tops = [] + for index, fork in enumerate((child for child in tree.children if isinstance(child, Fork))): + top = TOP(protocol.id, fork.name, index, False) + db.session.add(top) + db.session.commit() + protocol.done = True + db.session.commit() diff --git a/templates/protocol-new.html b/templates/protocol-new.html new file mode 100644 index 0000000000000000000000000000000000000000..56d303e23064bbbc9ca85cc1a4a240b727a47a63 --- /dev/null +++ b/templates/protocol-new.html @@ -0,0 +1,9 @@ +{% extends "layout.html" %} +{% from "macros.html" import render_form %} +{% block title %}Protokoll anlegen{% endblock %} + +{% block content %} +<div class="container"> + {{render_form(form, action_url=url_for("new_protocol"), action_text="Anlegen")}} +</div> +{% endblock %} diff --git a/templates/protocol-show.html b/templates/protocol-show.html new file mode 100644 index 0000000000000000000000000000000000000000..55f9311ca29c469a806a52c59f70bf9b61eefd63 --- /dev/null +++ b/templates/protocol-show.html @@ -0,0 +1,80 @@ +{% extends "layout.html" %} +{% from "macros.html" import render_table %} +{% block title %}Protokoll{% endblock %} + +{% block content %} +<div class="container"> + <div class="btn-group"> + <a class="btn {% if protocol.source is none %}btn-primary{% else %}btn-default{% endif %}" href="{{url_for("etherpull_protocol", protocol_id=protocol.id)}}">From etherpad</a> + {% if protocol.source is not none %} + <a class="btn btn-primary" href="{{url_for("get_protocol_source", protocol_id=protocol.id)}}">Download Quelltext</a> + {% endif %} + {% if protocol.is_done() %} + <a class="btn btn-success" href="{{url_for("update_protocol", protocol_id=protocol.id)}}">Protokoll editieren</a> + {% endif %} + <a class="btn btn-default" href="{{protocol.get_etherpad_link()}}" target="_blank">Etherpad</a> + <a class="btn btn-default" href="{{url_for("show_type", type_id=protocol.protocoltype.id)}}">Typ</a> + </div> + <div class="row"> + <div id="left-column" class="col-lg-6"> + <h2>Protokoll: {{protocol.protocoltype.name}} vom {{protocol.date|datify}}</h2> + {% if protocol.is_done() %} + <p><strong>Datum:</strong> {{protocol.date|datify_long}}</p> + <p><strong>Zeit:</strong> von {{protocol.start_time|timify}} bis {{protocol.end_time|timify}}</p> + <p><strong>Ort:</strong> {{protocol.location}}</p> + <p><strong>Protokollant:</strong> {{protocol.author}}</p> + <p><strong>Anwesende:</strong> {{protocol.participants}}</p> + {% else %} + <p><strong>Geplant:</strong> {{protocol.date|datify_long}}</p> + {% endif %} + + <h3>Tagesordnung</h3> + <ul> + {% if not protocol.has_nonplanned_tops() %} + {% for default_top in protocol.protocoltype.default_tops %} + {% if not default_top.is_at_end() %} + <li>{{default_top.name}}</li> + {% endif %} + {% endfor %} + {% endif %} + {% for top in protocol.tops %} + <li>{{top.name}}</li> + {% endfor %} + {% if not protocol.has_nonplanned_tops() %} + {% for default_top in protocol.protocoltype.default_tops %} + {% if default_top.is_at_end() %} + <li>{{default_top.name}}</li> + {% endif %} + {% endfor %} + {% endif %} + </ul> + + {% if protocol.is_done() %} + <h3>Beschlüsse</h3> + <ul> + {% for decision in protocol.decisions %} + <li>{{decision.content}}</li> + {% endfor %} + </ul> + {% endif %} + </div> + <div id="right-column" class="col-lg-6"> + {% if protocol.is_done() %} + <h3>Todos dieser Sitzung <a href="{{url_for("list_todos")}}">Aktuelle Todos</a></h3> + <ul> + {% for todo in protocol.get_originating_todos() %} + <li>{{todo.render_html()|safe}}</li> + {% endfor %} + </ul> + {% endif %} + {% if protocol.errors|length > 0 %} + {{render_table(errors_table)}} + {% endif %} + {% if protocol.documents|length > 0 %} + <h3>Anhang</h3> + {# TODO: render documents table here #} + {% endif %} + </div> + </div> +</div> +{% endblock %} diff --git a/views/forms.py b/views/forms.py index 166d39b2d213d1222b6df4252a6d2cfa81c9e7fa..6f6bb5840ed37cc5bf9674808d2a9f95e0f17a3c 100644 --- a/views/forms.py +++ b/views/forms.py @@ -1,5 +1,5 @@ from flask_wtf import FlaskForm -from wtforms import StringField, PasswordField, BooleanField, DateField, HiddenField, IntegerField +from wtforms import StringField, PasswordField, BooleanField, DateField, HiddenField, IntegerField, SelectField from wtforms.validators import InputRequired class LoginForm(FlaskForm): @@ -24,3 +24,11 @@ class MeetingReminderForm(FlaskForm): days_before = IntegerField("Tage vor Sitzung", validators=[InputRequired("Du musst eine Dauer angeben.")]) send_public = BooleanField("Öffentlich einladen") send_private = BooleanField("Intern einladen") + +class NewProtocolForm(FlaskForm): + protocoltype = SelectField("Typ", choices=[], coerce=int) + date = DateField("Datum", validators=[InputRequired("Du musst ein Datum angeben.")], format="%d.%m.%Y") + + def __init__(self, protocoltypes, **kwargs): + super().__init__(**kwargs) + self.protocoltype.choices = [(protocoltype.id, protocoltype.short_name) for protocoltype in protocoltypes] diff --git a/views/tables.py b/views/tables.py index 50a2128c8751ded02bf98b7525ed7808f72af91a..1fcc0a991ea226839f45a4a0dce1798d1ff2006d 100644 --- a/views/tables.py +++ b/views/tables.py @@ -1,7 +1,7 @@ # coding: utf-8 from flask import Markup, url_for, request from models.database import Protocol, ProtocolType, DefaultTOP, TOP, Todo, Decision -from shared import date_filter +from shared import date_filter, datetime_filter class Table: def __init__(self, title, values, newlink=None, newtext=None): @@ -49,16 +49,17 @@ class SingleValueTable: class ProtocolsTable(Table): def __init__(self, protocols): - super().__init__("Protokolle", protocols, newlink=None) + super().__init__("Protokolle", protocols, newlink=url_for("new_protocol")) def headers(self): - return ["ID", "Sitzung", "Datum"] + return ["ID", "Sitzung", "Status", "Datum"] def row(self, protocol): return [ - Table.link(url_for("protocol_view", protocol_id=protocol.id), str(protocol.id)), - protocol.protocoltype.name, - date_filter(protocol.data) + Table.link(url_for("show_protocol", protocol_id=protocol.id), str(protocol.id)), + Table.link(url_for("show_type", type_id=protocol.protocoltype.id), protocol.protocoltype.name), + Table.link(url_for("show_protocol", protocol_id=protocol.id), "Fertig" if protocol.is_done() else "Geplant"), + date_filter(protocol.date) ] class ProtocolTypesTable(Table): @@ -142,3 +143,34 @@ class MeetingRemindersTable(Table): if reminder.send_private: parts.append("Intern") return " und ".join(parts) + +class ErrorsTable(Table): + def __init__(self, errors): + super().__init__("Fehler", errors) + + def headers(self): + return ["Protokoll", "Fehler", "Zeitpunkt", "Beschreibung"] + + def row(self, error): + return [ + Table.link(url_for("show_protocol", protocol_id=error.protocol.id), error.protocol.get_identifier()), + error.name, + datetime_filter(error.datetime), + error.description + ] + +class TodosTable(Table): + def __init__(self, todos): + super().__init__("Todos", todos) + + def headers(self): + return ["Status", "Sitzung", "Name", "Aufgabe"] + + def row(self, todo): + protocol = todo.get_first_protocol() + return [ + todo.get_state(), + Table.link(url_for("show_protocol", protocol_id=protocol.id), protocol.get_identifier()), + todo.who, + todo.description + ]