diff --git a/migrations/versions/4b813bbbd8ef_.py b/migrations/versions/4b813bbbd8ef_.py new file mode 100644 index 0000000000000000000000000000000000000000..53eb3b04c8aa10d8d2ce86386c1f413b89e082fe --- /dev/null +++ b/migrations/versions/4b813bbbd8ef_.py @@ -0,0 +1,38 @@ +"""empty message + +Revision ID: 4b813bbbd8ef +Revises: 8fdd381e6a2a +Create Date: 2017-03-15 02:18:24.986531 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '4b813bbbd8ef' +down_revision = '8fdd381e6a2a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('decisioncategories', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('protocoltype_id', sa.Integer(), nullable=True), + sa.Column('name', sa.String(), nullable=True), + sa.ForeignKeyConstraint(['protocoltype_id'], ['protocoltypes.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.add_column('decisions', sa.Column('category_id', sa.Integer(), nullable=True)) + op.create_foreign_key(None, 'decisions', 'decisioncategories', ['category_id'], ['id']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'decisions', type_='foreignkey') + op.drop_column('decisions', 'category_id') + op.drop_table('decisioncategories') + # ### end Alembic commands ### diff --git a/models/database.py b/models/database.py index 141b4cc332bbad5081a6da063f7b23ee0b4465f9..c56d9bd7ce4c85b57f7b1f942796889c86c5aeae 100644 --- a/models/database.py +++ b/models/database.py @@ -72,6 +72,7 @@ class ProtocolType(DatabaseModel): 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") metas = relationship("DefaultMeta", backref=backref("protocoltype"), cascade="all, delete-orphan") + decisioncategories = relationship("DecisionCategory", backref=backref("protocoltype"), cascade="all, delete-orphan") def get_latest_protocol(self): candidates = sorted([protocol for protocol in self.protocols if protocol.is_done()], key=lambda p: p.date, reverse=True) @@ -540,12 +541,25 @@ class Decision(DatabaseModel): id = db.Column(db.Integer, primary_key=True) protocol_id = db.Column(db.Integer, db.ForeignKey("protocols.id")) content = db.Column(db.String) + category_id = db.Column(db.Integer, db.ForeignKey("decisioncategories.id"), nullable=True) document = relationship("DecisionDocument", backref=backref("decision"), cascade="all, delete-orphan", uselist=False) def get_parent(self): return self.protocol +class DecisionCategory(DatabaseModel): + __tablename__ = "decisioncategories" + __model_name__ = "decisioncategory" + id = db.Column(db.Integer, primary_key=True) + protocoltype_id = db.Column(db.Integer, db.ForeignKey("protocoltypes.id")) + name = db.Column(db.String) + + decisions = relationship("Decision", backref=backref("category"), order_by="Decision.id") + + def get_parent(self): + return self.protocoltype + class MeetingReminder(DatabaseModel): __tablename__ = "meetingreminders" __model_name__ = "meetingreminder" @@ -621,6 +635,6 @@ class Meta(DatabaseModel): ALL_MODELS = [ ProtocolType, Protocol, DefaultTOP, TOP, Document, DecisionDocument, - Todo, Decision, MeetingReminder, Error, DefaultMeta, Meta + Todo, Decision, MeetingReminder, Error, DefaultMeta, Meta, DecisionCategory ] diff --git a/server.py b/server.py index 8a3fdb2f3e04ee8f1b9c64792531b9b54e326c01..0697ed814070fcee49485c9e459e4f167e9d9a6f 100755 --- a/server.py +++ b/server.py @@ -23,9 +23,9 @@ 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 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, Document, Todo, Decision, MeetingReminder, Error, TodoMail, DecisionDocument, TodoState, Meta, DefaultMeta -from views.forms import LoginForm, ProtocolTypeForm, DefaultTopForm, MeetingReminderForm, NewProtocolForm, DocumentUploadForm, KnownProtocolSourceUploadForm, NewProtocolSourceUploadForm, ProtocolForm, TopForm, SearchForm, NewProtocolFileUploadForm, NewTodoForm, TodoForm, TodoMailForm, DefaultMetaForm, MetaForm, MergeTodosForm -from views.tables import ProtocolsTable, ProtocolTypesTable, ProtocolTypeTable, DefaultTOPsTable, MeetingRemindersTable, ErrorsTable, TodosTable, DocumentsTable, DecisionsTable, TodoTable, ErrorTable, TodoMailsTable, DefaultMetasTable +from models.database import ProtocolType, Protocol, DefaultTOP, TOP, Document, Todo, Decision, MeetingReminder, Error, TodoMail, DecisionDocument, TodoState, Meta, DefaultMeta, DecisionCategory +from views.forms import LoginForm, ProtocolTypeForm, DefaultTopForm, MeetingReminderForm, NewProtocolForm, DocumentUploadForm, KnownProtocolSourceUploadForm, NewProtocolSourceUploadForm, ProtocolForm, TopForm, SearchForm, DecisionSearchForm, 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 app = Flask(__name__) @@ -242,7 +242,8 @@ def show_type(protocoltype): default_tops_table = DefaultTOPsTable(protocoltype.default_tops, protocoltype) reminders_table = MeetingRemindersTable(protocoltype.reminders, protocoltype) metas_table = DefaultMetasTable(protocoltype.metas, protocoltype) - return render_template("type-show.html", protocoltype=protocoltype, protocoltype_table=protocoltype_table, default_tops_table=default_tops_table, metas_table=metas_table, reminders_table=reminders_table, mail_active=config.MAIL_ACTIVE) + categories_table = DecisionCategoriesTable(protocoltype.decisioncategories, protocoltype) + return render_template("type-show.html", protocoltype=protocoltype, protocoltype_table=protocoltype_table, default_tops_table=default_tops_table, metas_table=metas_table, reminders_table=reminders_table, mail_active=config.MAIL_ACTIVE, categories_table=categories_table) @app.route("/type/delete/<int:protocoltype_id>") @login_required @@ -900,16 +901,30 @@ def list_decisions(): user = current_user() protocoltype = None protocoltype_id = None + decisioncategory = None + decisioncategory_id = None try: protocoltype_id = int(request.args.get("protocoltype_id")) except (ValueError, TypeError): pass + try: + decisioncategory_id = int(request.args.get("decisioncategory_id")) + except (ValueError, TypeError): + pass search_term = request.args.get("search") protocoltypes = ProtocolType.get_public_protocoltypes(user) - search_form = SearchForm(protocoltypes) + decisioncategories = [ + category + for protocoltype in protocoltypes + for category in protocoltype.decisioncategories + ] + search_form = DecisionSearchForm(protocoltypes, decisioncategories) if protocoltype_id is not None: search_form.protocoltype_id.data = protocoltype_id protocoltype = ProtocolType.query.filter_by(id=protocoltype_id).first() + if decisioncategory_id is not None: + search_form.decisioncategory_id.data = decisioncategory_id + decisioncategory = DecisionCategory.query.filter_by(id=decisioncategory_id).first() if search_term is not None: search_form.search.data = search_term decisions = [ @@ -921,6 +936,12 @@ def list_decisions(): decision for decision in decisions if decision.protocol.protocoltype.id == protocoltype_id ] + if decisioncategory_id is not None and decisioncategory_id != -1: + decisions = [ + decision for decision in decisions + if decision.category is not None + and decision.category.id == decisioncategory_id + ] if search_term is not None and len(search_term.strip()) > 0: decisions = [ decision for decision in decisions @@ -1126,7 +1147,47 @@ def delete_defaultmeta(defaultmeta): type_id = defaultmeta.protocoltype.id db.session.delete(meta) db.session.commit() - flash("Metadatenfeld '{}' gelöscht.".format(name), "alert-error") + flash("Metadatenfeld '{}' gelöscht.".format(name), "alert-success") + return redirect(request.args.get("next") or url_for("show_type", protocoltype_id=type_id)) + +@app.route("/decisioncategory/new/<int:protocoltype_id>", methods=["GET", "POST"]) +@login_required +@db_lookup(ProtocolType) +@require_modify_right() +def new_decisioncategory(protocoltype): + form = DecisionCategoryForm() + if form.validate_on_submit(): + category = DecisionCategory(protocoltype_id=protocoltype.id) + form.populate_obj(category) + db.session.add(category) + db.session.commit() + flash("Beschlusskategorie hinzugefügt.", "alert-success") + return redirect(request.args.get("next") or url_for("show_type", protocoltype_id=protocoltype.id)) + return render_template("decisioncategory-new.html", form=form, protocoltype=protocoltype) + +@app.route("/decisioncategory/edit/<int:decisioncategory_id>", methods=["GET", "POST"]) +@login_required +@db_lookup(DecisionCategory) +@require_modify_right() +def edit_decisioncategory(decisioncategory): + form = DecisionCategoryForm(obj=decisioncategory) + if form.validate_on_submit(): + form.populate_obj(decisioncategory) + db.session.commit() + return redirect(request.args.get("next") or url_for("show_type", protocoltype_id=decisioncategory.protocoltype.id)) + return render_template("decisioncategory-edit.html", form=form, decisioncategory=decisioncategory) + +@app.route("/decisioncategory/delete/<int:decisioncategory_id>") +@login_required +@group_required(config.ADMIN_GROUP) +@db_lookup(DecisionCategory) +@require_modify_right() +def delete_decisioncategory(decisioncategory): + name = decisioncategory.name + type_id = decisioncategory.protocoltype.id + db.session.delete(decisioncategory) + db.session.commit() + 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("/login", methods=["GET", "POST"]) diff --git a/tasks.py b/tasks.py index 18527844de3a3b909da09c3c89cf911025b815d8..84f469760062bea64397f66d8fb4372d66fe1597 100644 --- a/tasks.py +++ b/tasks.py @@ -8,7 +8,7 @@ from datetime import datetime import traceback from copy import copy -from models.database import Document, Protocol, Error, Todo, Decision, TOP, DefaultTOP, MeetingReminder, TodoMail, DecisionDocument, TodoState, OldTodo +from models.database import Document, Protocol, Error, Todo, Decision, TOP, DefaultTOP, MeetingReminder, TodoMail, DecisionDocument, TodoState, OldTodo, DecisionCategory from models.errors import DateNotMatchingException from server import celery, app from shared import db, escape_tex, unhyphen, date_filter, datetime_filter, date_filter_long, date_filter_short, time_filter, class_filter, KNOWN_KEYS @@ -281,7 +281,32 @@ def parse_protocol_async_inner(protocol, encoded_kwargs): db.session.add(error) db.session.commit() return - decision = Decision(protocol_id=protocol.id, content=decision_tag.values[0]) + decision_content = decision_tag.values[0] + decision_category_id = None + if len(decision_tag.values) > 1: + decision_category_name = decision_tag.values[1] + decision_category = DecisionCategory.query.filter_by(protocoltype_id=protocol.protocoltype.id, name=decision_category_name).first() + if decision_category is None: + category_candidates = DecisionCategory.query.filter_by(protocoltype_id=protocol.protocoltype.id).all() + category_names = [ + "'{}'".format(category.name) + for category in category_candidates + ] + error = protocol.create_error("Parsing", + "Unknown decision category", + "The decision in line {} has the category {}, " + "but there is no such category. " + "Known categories are {}".format( + decision_tag.linenumber, + decision_category_name, + ", ".join(category_names))) + db.session.add(error) + db.session.commit() + return + else: + decision_category_id = decision_category.id + decision = Decision(protocol_id=protocol.id, + content=decision_content, category_id=decision_category_id) db.session.add(decision) db.session.commit() decision_top = decision_tag.fork.get_top() diff --git a/templates/decisioncategory-edit.html b/templates/decisioncategory-edit.html new file mode 100644 index 0000000000000000000000000000000000000000..5fe0654a16b56bab754f1ca340bf9b1d221bae8c --- /dev/null +++ b/templates/decisioncategory-edit.html @@ -0,0 +1,9 @@ +{% extends "layout.html" %} +{% from "macros.html" import render_form %} +{% block title %}Beschlusskategorie ändern{% endblock %} + +{% block content %} +<div class="container"> + {{render_form(form, action_url=url_for("edit_decisioncategory", decisioncategory_id=decisioncategory.id, next=url_for("show_type", protocoltype_id=decisioncategory.protocoltype.id)), action_text="Ändern")}} +</div> +{% endblock %} diff --git a/templates/decisioncategory-new.html b/templates/decisioncategory-new.html new file mode 100644 index 0000000000000000000000000000000000000000..91d0d77dfd0bf23a9b72bacb2582824944315920 --- /dev/null +++ b/templates/decisioncategory-new.html @@ -0,0 +1,9 @@ +{% extends "layout.html" %} +{% from "macros.html" import render_form %} +{% block title %}Beschlusskategorie hinzufügen{% endblock %} + +{% block content %} +<div class="container"> + {{render_form(form, action_url=url_for("new_decisioncategory", protocoltype_id=protocoltype.id, next=url_for("show_type", protocoltype_id=protocoltype.id)), action_text="Anlegen")}} +</div> +{% endblock %} diff --git a/templates/type-show.html b/templates/type-show.html index 6049d106a96c2f7818b27baf4128d37cec1ff4f7..474bebbe6dfe6eaceebc94d3965a5059fb69ec2c 100644 --- a/templates/type-show.html +++ b/templates/type-show.html @@ -11,5 +11,6 @@ {{render_table(reminders_table)}} {% endif %} {{render_table(metas_table)}} + {{render_table(categories_table)}} </div> {% endblock %} diff --git a/views/forms.py b/views/forms.py index cff09373447f0353df827af764007ffb6e24c189..d03016f8886e87d17aebf93984988605020172f9 100644 --- a/views/forms.py +++ b/views/forms.py @@ -18,7 +18,17 @@ def get_protocoltype_choices(protocoltypes, add_all=True): in sorted(protocoltypes, key=lambda t: t.short_name) ] if add_all: - choices.insert(0, (-1, "Alle")) + choices.insert(0, (-1, "Alle Typen")) + return choices + +def get_category_choices(categories, add_all=True): + choices = [ + (category.id, category.name) + for category + in sorted(categories, key=lambda c: c.name) + ] + if add_all: + choices.insert(0, (-1, "Alle Kategorien")) return choices def get_todostate_choices(): @@ -188,6 +198,13 @@ class SearchForm(FlaskForm): super().__init__(**kwargs) self.protocoltype_id.choices = get_protocoltype_choices(protocoltypes) +class DecisionSearchForm(SearchForm): + decisioncategory_id = SelectField("Kategorie", choices=[], coerce=int) + + def __init__(self, protocoltypes, categories, **kwargs): + super().__init__(protocoltypes=protocoltypes, **kwargs) + self.decisioncategory_id.choices = get_category_choices(categories) + class NewTodoForm(FlaskForm): protocoltype_id = SelectField("Typ", choices=[], coerce=int) who = StringField("Person", validators=[InputRequired("Bitte gib an, wer das Todo erledigen soll.")]) @@ -222,6 +239,9 @@ class DefaultMetaForm(FlaskForm): key = StringField("Key", validators=[InputRequired("Bitte gib den Protokoll-Syntax-Schlüssel der Metadaten an.")]) name = StringField("Name", validators=[InputRequired("Bitte gib den Namen der Metadaten an.")]) +class DecisionCategoryForm(FlaskForm): + name = StringField("Name", validators=[InputRequired("Bitte gib den Namen der Kategorie an.")]) + class MergeTodosForm(FlaskForm): todo1 = IntegerField("todo 1", validators=[InputRequired()]) todo2 = IntegerField("todo 2", validators=[InputRequired()]) diff --git a/views/tables.py b/views/tables.py index a92f832eebb4fb44849aa9dd1bf1ced58a623fa7..253a73d2e1b51c8eef558d6bef58eec551eb1c50 100644 --- a/views/tables.py +++ b/views/tables.py @@ -373,21 +373,36 @@ class TodoTable(SingleValueTable): class DecisionsTable(Table): def __init__(self, decisions): super().__init__("Beschlüsse", decisions) + self.category_present = len([ + decision for decision in decisions + if decision.category is not None + ]) > 0 def headers(self): - return ["Sitzung", "Beschluss", ""] + content_part = ["Sitzung", "Beschluss"] + category_part = ["Kategorie"] + if not self.category_present: + category_part = [] + action_part = [""] + return content_part + category_part + action_part def row(self, decision): user = current_user() - return [ + content_part = [ Table.link(url_for("show_protocol", protocol_id=decision.protocol.id), decision.protocol.get_identifier()), - decision.content, + decision.content + ] + category_part = [decision.category.name if decision.category is not None else ""] + if not self.category_present: + category_part = [] + action_part = [ Table.link(url_for("print_decision", decisiondocument_id=decision.document.id), "Drucken") if config.PRINTING_ACTIVE and decision.protocol.protocoltype.has_modify_right(user) and decision.document is not None else "" ] + return content_part + category_part + action_part class DocumentsTable(Table): def __init__(self, documents): @@ -453,3 +468,26 @@ class DefaultMetasTable(Table): link_part = [Table.concat(links)] return general_part + link_part +class DecisionCategoriesTable(Table): + def __init__(self, categories, protocoltype): + print(categories) + super().__init__( + "Beschlusskategorien", + categories, + url_for("new_decisioncategory", protocoltype_id=protocoltype.id) + ) + + def headers(self): + return ["Name", ""] + + def row(self, category): + user = current_user() + general_part = [category.name] + action_part = [ + Table.concat([ + Table.link(url_for("edit_decisioncategory", decisioncategory_id=category.id), "Ändern"), + Table.link(url_for("delete_decisioncategory", decisioncategory_id=category.id), "Löschen", confirm="Bist du dir sicher, dass du die Beschlusskategorie {} löschen willst?".format(category.name)) + ]) + ] + return general_part + action_part +