From 621c61740014f446734eb9595a439beca6af051f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cyrill=20K=C3=BCttel?= Date: Mon, 24 Jun 2024 20:59:27 +0200 Subject: [PATCH] Agenda Items persist logic. --- src/privatim/cli/initialize_db.py | 29 ++-- src/privatim/layouts/layout.pt | 3 - src/privatim/layouts/layout.py | 4 +- src/privatim/models/meeting.py | 68 ++++++++- src/privatim/static/__init__.py | 26 +++- src/privatim/static/js/custom/sortable.js | 15 -- .../static/js/custom/sortable_custom.js | 144 ++++++++++++++++++ src/privatim/views/__init__.py | 31 +++- src/privatim/views/agenda_items.py | 6 +- src/privatim/views/meetings.py | 86 ++++++++++- src/privatim/views/templates/meeting.pt | 21 ++- tests/models/test_meetings.py | 7 +- tests/shared/utils.py | 25 ++- .../views/without_client/test_meeting_view.py | 50 +++++- 14 files changed, 436 insertions(+), 79 deletions(-) delete mode 100644 src/privatim/static/js/custom/sortable.js create mode 100644 src/privatim/static/js/custom/sortable_custom.js diff --git a/src/privatim/cli/initialize_db.py b/src/privatim/cli/initialize_db.py index 76af841..190deb7 100644 --- a/src/privatim/cli/initialize_db.py +++ b/src/privatim/cli/initialize_db.py @@ -137,13 +137,27 @@ def add_example_content( if add_meeting: attendees = [admin_user] + meeting = Meeting( + name='Cras Tristisque', + time=datetime.now(tz=DEFAULT_TIMEZONE), + attendees=attendees, + working_group=WorkingGroup( + name='1. Gremium', leader=admin_user, users=attendees + ), + ) + db.add(meeting) + db.flush() + db.refresh(meeting) agenda_items = [ - AgendaItem( + AgendaItem.create( + db, title='Begrüssung', description='Begrüssung der Anwesenden und Eröffnung der ' 'Sitzung', + meeting=meeting ), - AgendaItem( + AgendaItem.create( + db, title='Neque porro quisquam est qui dolorem', description='Lorem ipsum dolor sit amet, consectetur ' 'adipiscing elit. Nulla dui metus, viverra ' @@ -154,19 +168,10 @@ def add_example_content( 'convallis. Class aptent taciti sociosqu ad ' 'litora torquent per conubia nostra, ' 'per inceptos himenaeos. ', + meeting=meeting ), ] - meeting = Meeting( - name='Cras Tristisque', - time=datetime.now(tz=DEFAULT_TIMEZONE), - attendees=attendees, - working_group=WorkingGroup( - name='1. Gremium', leader=admin_user, users=attendees - ), - agenda_items=agenda_items - ) db.add_all(agenda_items) - db.add(meeting) db.flush() diff --git a/src/privatim/layouts/layout.pt b/src/privatim/layouts/layout.pt index 42859d9..f686ea1 100644 --- a/src/privatim/layouts/layout.pt +++ b/src/privatim/layouts/layout.pt @@ -7,9 +7,6 @@ - - - Austauschplattform privatim diff --git a/src/privatim/layouts/layout.py b/src/privatim/layouts/layout.py index 0fc8232..81b3cf1 100644 --- a/src/privatim/layouts/layout.py +++ b/src/privatim/layouts/layout.py @@ -4,7 +4,7 @@ from pyramid.decorator import reify from pyramid.renderers import get_renderer from privatim.static import (bootstrap_css, bootstrap_js, tom_select_css, - comments_css, profile_css, custom_js) + comments_css, profile_css, sortable_custom) from pytz import timezone import re from datetime import date, datetime @@ -40,7 +40,7 @@ def __init__(self, context: Any, request: 'IRequest') -> None: bootstrap_js.need() tom_select_css.need() comments_css.need() - custom_js.need() + sortable_custom.need() profile_css.need() def show_steps(self) -> bool: diff --git a/src/privatim/models/meeting.py b/src/privatim/models/meeting.py index 47d7ca1..d767f0d 100644 --- a/src/privatim/models/meeting.py +++ b/src/privatim/models/meeting.py @@ -1,6 +1,5 @@ -from uuid import uuid4 - -from sqlalchemy import Text +import uuid +from sqlalchemy import Text, Integer, select, func from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy import Table, Column, ForeignKey from sqlalchemy.orm import relationship @@ -14,12 +13,16 @@ from typing import TYPE_CHECKING - - if TYPE_CHECKING: from privatim.models import User, WorkingGroup from datetime import datetime from privatim.types import ACL + from sqlalchemy.orm import Session + + +class AgendaItemCreationError(Exception): + """Custom exception for errors in creating AgendaItem instances.""" + pass meetings_users_association = Table( @@ -44,10 +47,56 @@ class AgendaItem(Base): __tablename__ = 'agenda_items' + def __init__( + self, + title: str, + description: str, + meeting: 'Meeting', + position: int, + ): + if position is None: + raise AgendaItemCreationError( + 'AgendaItem objects must be created using the create class ' + 'method because the attribute `position` has to be set.' + ) + self.id = str(uuid.uuid4()) + self.title = title + self.description = description + self.meeting = meeting + self.position = position + + @classmethod + def create( + cls, + session: 'Session', + title: str, + description: str, + meeting: 'Meeting' + ) -> 'AgendaItem': + + meeting_id = meeting.id + max_position = session.scalar( + select(func.max(AgendaItem.position)).where( + AgendaItem.meeting_id == meeting_id + ) + ) + new_position = 0 if max_position is None else max_position + 1 + new_agenda_item = cls( + title=title, + description=description, + meeting=meeting, + position=new_position, + ) + session.add(new_agenda_item) + return new_agenda_item + id: Mapped[UUIDStrPK] title: Mapped[str] = mapped_column(Text, nullable=False) + # the custom order which may be changed by the user + position: Mapped[int] = mapped_column(Integer, nullable=False) + description: Mapped[str] = mapped_column(Text) meeting_id: Mapped[UUIDStr] = mapped_column( @@ -58,12 +107,15 @@ class AgendaItem(Base): meeting: Mapped['Meeting'] = relationship( 'Meeting', back_populates='agenda_items', + order_by='AgendaItem.position' ) def __acl__(self) -> list['ACL']: return [ (Allow, Authenticated, ['view']), ] + def __repr__(self) -> str: + return f'' class Meeting(Base, Commentable): @@ -79,7 +131,7 @@ def __init__( working_group: 'WorkingGroup', agenda_items: list[AgendaItem] | None = None, ): - self.id = str(uuid4()) + self.id = str(uuid.uuid4()) self.name = name self.time = time self.attendees = attendees @@ -99,10 +151,12 @@ def __init__( back_populates='meetings' ) - # Trantanden (=Themen) + # Traktanden (=Themen) agenda_items: Mapped[list[AgendaItem]] = relationship( AgendaItem, back_populates='meeting', + order_by="AgendaItem.position", + cascade="all, delete-orphan" ) # allfällige Beschlüsse diff --git a/src/privatim/static/__init__.py b/src/privatim/static/__init__.py index 47de3c2..15f61b1 100644 --- a/src/privatim/static/__init__.py +++ b/src/privatim/static/__init__.py @@ -1,24 +1,29 @@ from functools import lru_cache from pathlib import Path - from fanstatic import Library from fanstatic import Resource +from fanstatic.core import render_js as render_js_default + -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Callable if TYPE_CHECKING: from collections.abc import Iterable from fanstatic.core import Dependable - js_library = Library('privatim:js', 'js') css_library = Library('privatim:css', 'css') +def render_js_module(url: str) -> str: + return f'' + + def js( - relpath: str, - depends: 'Iterable[Dependable] | None' = None, + relpath: str, + depends: 'Iterable[Dependable] | None' = None, supersedes: list[Resource] | None = None, - bottom: bool = False, + bottom: bool = False, + renderer: Callable[[str], str] = render_js_default # "text/javascript" ) -> Resource: return Resource( @@ -27,6 +32,7 @@ def js( depends=depends, supersedes=supersedes, bottom=bottom, + renderer=renderer ) @@ -62,9 +68,13 @@ def get_default_profile_pic_data() -> tuple[str, bytes]: jquery = js('jquery.min.js') bootstrap_core = js('bootstrap.bundle.min.js') -bootstrap_js = js('bootstrap_custom.js', depends=[jquery, bootstrap_core]) +bootstrap_js = js( + 'bootstrap_custom.js', + depends=[jquery, bootstrap_core] +) -custom_js = js('custom/custom.js') +sortable_custom = js('custom/sortable_custom.js', depends=[jquery], + renderer=render_js_module) tom_select_css = css('tom-select.min.css') diff --git a/src/privatim/static/js/custom/sortable.js b/src/privatim/static/js/custom/sortable.js deleted file mode 100644 index c88c4fd..0000000 --- a/src/privatim/static/js/custom/sortable.js +++ /dev/null @@ -1,15 +0,0 @@ - -import Sortable from '../sortable.core.esm.js'; - - -document.addEventListener('DOMContentLoaded', () => { - Sortable.create(document.getElementById('agenda-items'), { - animation: 150, - group: 'list-1', - // draggable: '.draggable-item', - handle: '.handle', - sort: true, - filter: '.sortable-disabled', - chosenClass: 'active' - }); -}); diff --git a/src/privatim/static/js/custom/sortable_custom.js b/src/privatim/static/js/custom/sortable_custom.js new file mode 100644 index 0000000..023d2aa --- /dev/null +++ b/src/privatim/static/js/custom/sortable_custom.js @@ -0,0 +1,144 @@ +import Sortable from '../sortable.core.esm.js'; + + + +/* + This module scans the page for elements which have the + 'data-sortable' attribute set. Those that do are expected to also have a + 'data-sortable-url' attribute which should look like this: + + https://example.xxx/yyy/{subject_id}/{direction}/{target_id} + + This enables drag&drop sorting on the list. Each time an element is + moved around, the url is called with the following variables replaced: + + * subject_id: the id of the item moved around + * target_id: the id above which or below which the subject is moved + * direction: the direction relative to the target ('above' or 'below') + + For example, this url would move id 1 below id 3: + .../1/below/3 + + The ids are taken from the item's 'data-sortable-id' attribute. +*/ + +var on_move_element = function(container, element, new_index, old_index) { + var siblings = $(container).children(); + + if (siblings.length < 2) { + return; + } + + var target = new_index === 0 ? siblings[1] : siblings[new_index - 1]; + var direction = new_index === 0 ? 'above' : 'below'; + var url = decodeURIComponent($(container).data('sortable-url')) + .replace('{subject_id}', $(element).data('sortable-id')) + .replace('{target_id}', $(target).data('sortable-id')) + .replace('{direction}', direction); + + const containsUndefiendStr = (url) => { return url.includes('undefined') } + console.assert(!containsUndefiendStr(url)) + + const csrf_token = document.querySelector('input[name="csrf_token"]').value; + $.ajax({ + type: "POST", url: url, headers: { + 'X-CSRF-Token': csrf_token + }, + dataType: 'json', + cache: false, + }) + .done(function () { + $(element).addClass('flash').addClass('success'); + }) + .fail(function () { + undo_move_element(container, element, new_index, old_index); + $(element).addClass('flash').addClass('failure'); + }) + .always(function () { + setTimeout(function () { + $(element) + .removeClass('flash') + .removeClass('success') + .removeClass('failure'); + }, 1000); + }); +}; + +var undo_move_element = function(container, element, new_index, old_index) { + var siblings = $(container).children(); + + if (old_index === 0) { + $(element).insertBefore($(siblings[0])); + } else { + if (old_index <= new_index) { + // was moved down + $(element).insertAfter($(siblings[old_index - 1])); + } else { + // was moved up + $(element).insertAfter($(siblings[old_index])); + } + } +}; + +var setup_sortable_container = function(container_element) { + var container = $(container_element); + var start = null; + + var sortable = Sortable.create(container_element, { + animation: 150, + group: 'list-1', + handle: '.handle-for-dragging', // the element which is actually the "hitbox" for dragging + sort: true, + chosenClass: 'active', + onStart: function(event) { + if ($(event.element).parent().hasClass('children')) { + return; + } + + // add an element at the bottom if the last item has children, + // otherwise it's not possible to drop elements below it, as + // there's no actual drop-area + var last_element = container.children().last(); + + if (last_element.find('.children').length !== 0) { + container.append($('
 
')); + } + + start = (new Date()).getTime(); + }, + onEnd: function(event) { + container.find('> .empty').remove(); + + var new_index = event.newIndex; + + if (new_index >= container.children().length) { + new_index = container.children().length - 1; + } + + // only continue with the drag & drop operation if the whole thing + // took more than 200ms, below that we assume it was an accident + if (new_index != event.oldIndex) { + if (((new Date()).getTime() - start) <= 200) { + undo_move_element(container_element, event.item, new_index, event.oldIndex); + } else { + on_move_element(container_element, event.item, new_index, event.oldIndex); + } + } + } + }); + + container.children().each(function() { + this.addEventListener('dragstart', function(event) { + $(event.target).addClass('dragging'); + }); + }); +}; + + +(function($) { + $(document).ready(function() { + $('[data-sortable]').each(function() { + setup_sortable_container(this); + }); + }); +})(jQuery); diff --git a/src/privatim/views/__init__.py b/src/privatim/views/__init__.py index 319bb28..0982137 100644 --- a/src/privatim/views/__init__.py +++ b/src/privatim/views/__init__.py @@ -24,10 +24,9 @@ from privatim.views.home import home_view from privatim.views.login import login_view from privatim.views.logout import logout_view -from privatim.views.meetings import ( - add_meeting_view, - export_meeting_as_pdf_view, -) +from privatim.views.meetings import (add_meeting_view, + export_meeting_as_pdf_view, + sortable_agenda_items_view) from privatim.views.meetings import delete_meeting_view from privatim.views.meetings import edit_meeting_view from privatim.views.meetings import meeting_view @@ -333,6 +332,26 @@ def includeme(config: 'Configurator') -> None: xhr=True ) + config.add_route( + 'sortable_agenda_items', + '/meetings/agenda_items/{id}/move/{subject_id}/{direction}/{' + 'target_id}', + factory=meeting_factory + ) + config.add_view( + sortable_agenda_items_view, + route_name='sortable_agenda_items', + request_method='POST', + xhr=False + ) + config.add_view( + sortable_agenda_items_view, + route_name='sortable_agenda_items', + renderer='json', + request_method='POST', + xhr=True + ) + # Consultation Comments config.add_route( 'add_comment', @@ -400,7 +419,9 @@ def includeme(config: 'Configurator') -> None: # single meeting view config.add_route( - 'meeting', '/meeting/{id}', factory=default_meeting_factory) + 'meeting', '/meeting/{id}', + factory=default_meeting_factory + ) config.add_view( meeting_view, route_name='meeting', diff --git a/src/privatim/views/agenda_items.py b/src/privatim/views/agenda_items.py index b67dda6..c62a1f0 100644 --- a/src/privatim/views/agenda_items.py +++ b/src/privatim/views/agenda_items.py @@ -1,4 +1,5 @@ from pyramid.httpexceptions import HTTPFound + from privatim.utils import maybe_escape from privatim.forms.agenda_item_form import AgendaItemForm from privatim.i18n import _ @@ -30,10 +31,11 @@ def add_agenda_item_view( form = AgendaItemForm(context, request) session = request.dbsession if request.method == 'POST' and form.validate(): - agenda_item = AgendaItem( + agenda_item = AgendaItem.create( + session, title=form.title.data, description=form.description.data, - meeting=context, + meeting=context ) session.add(agenda_item) message = _( diff --git a/src/privatim/views/meetings.py b/src/privatim/views/meetings.py index 1b0f0df..76e0f28 100644 --- a/src/privatim/views/meetings.py +++ b/src/privatim/views/meetings.py @@ -1,13 +1,19 @@ from markupsafe import Markup, escape -from pyramid.httpexceptions import HTTPFound, HTTPNotFound from pyramid.response import Response from privatim.reporting.report import ( MeetingReport, ReportOptions, HTMLReportRenderer, ) +from privatim.utils import datetime_format from privatim.controls.controls import Button, Icon, IconStyle -from privatim.utils import maybe_escape, datetime_format +from pyramid.httpexceptions import ( + HTTPFound, + HTTPNotFound, + HTTPBadRequest, + HTTPMethodNotAllowed, +) +from privatim.utils import maybe_escape from sqlalchemy import select from privatim.utils import fix_utc_to_local_time @@ -44,13 +50,19 @@ def meeting_view( request.route_url('delete_meeting', id=context.id), ) - items = [] + # should already be sorted, (by 'order_by') + assert context.agenda_items == sorted( + context.agenda_items, key=lambda x: x.position + ), "Agenda items are not sorted" + + agenda_items = [] for item in context.agenda_items: - items.append( + agenda_items.append( { 'title': item.title, 'description': item.description, 'id': item.id, + 'position': item.position, 'edit_btn': Button( url=request.route_url('edit_agenda_item', id=item.id), icon='edit', @@ -59,11 +71,18 @@ def meeting_view( ), } ) - + data_sortable_url = request.route_url( + 'sortable_agenda_items', + id=context.id, + subject_id='{subject_id}', + direction='{direction}', + target_id='{target_id}', + ) return { 'time': formatted_time, 'meeting': context, - 'agenda_items': items, + 'agenda_items': agenda_items, + 'sortable_url': data_sortable_url } @@ -193,10 +212,11 @@ def add_meeting_view( ) -> 'MixedDataOrRedirect': assert isinstance(context, WorkingGroup) - target_url = request.route_url('meetings', id=context.id) + target_url = request.route_url('meetings', id=context.id) # fallback form = MeetingForm(context, request) session = request.dbsession + meeting = None if request.method == 'POST' and form.validate(): stmt = select(User).where(User.id.in_(form.attendees.raw_data)) attendees = list(session.execute(stmt).scalars().all()) @@ -220,6 +240,9 @@ def add_meeting_view( else: return HTTPFound(location=target_url) + if meeting is not None: + target_url = request.route_url('meeting', id=meeting.id) + if request.is_xhr: return {'errors': form.errors} else: @@ -296,3 +319,52 @@ def delete_meeting_view( return HTTPFound( location=request.route_url('meetings', id=working_group_id), ) + + +def sortable_agenda_items_view( + context: Meeting, request: 'IRequest' +) -> 'RenderData': + + try: + subject_id = int(request.matchdict['subject_id']) + direction = request.matchdict['direction'] + target_id = int(request.matchdict['target_id']) + except (ValueError, KeyError) as e: + raise HTTPBadRequest('Request parameters are missing or invalid') \ + from e + + agenda_items = context.agenda_items + subject_item = next( + (item for item in agenda_items if item.position == subject_id), None + ) + target_item = next( + (item for item in agenda_items if item.position == target_id), None + ) + + if subject_item is None or target_item is None: + raise HTTPMethodNotAllowed('Invalid subject or target id') + + if direction not in ['above', 'below']: + raise HTTPMethodNotAllowed('Invalid direction') + + new_position = target_item.position + if direction == 'below': + new_position += 1 + + for item in agenda_items: + match direction: + case 'above' if (new_position + <= item.position < subject_item.position): + item.position += 1 + case 'below' if (subject_item.position + < item.position <= new_position): + item.position -= 1 + + subject_item.position = new_position + + return { + 'status': 'success', + 'subject_id': subject_id, + 'direction': direction, + 'target_id': target_id, + } diff --git a/src/privatim/views/templates/meeting.pt b/src/privatim/views/templates/meeting.pt index 7aa2ec9..4710c8e 100644 --- a/src/privatim/views/templates/meeting.pt +++ b/src/privatim/views/templates/meeting.pt @@ -5,7 +5,6 @@
-

Sitzung ${meeting.name}

@@ -24,16 +23,17 @@

Attendees: +

  • ${user.first_name} ${user.last_name} ${user.email}
-

Working Group: ${meeting.working_group.name}

+
@@ -41,16 +41,15 @@

Agenda Items

-
-
-

- + +

diff --git a/tests/models/test_meetings.py b/tests/models/test_meetings.py index 8fea66c..b8371c2 100644 --- a/tests/models/test_meetings.py +++ b/tests/models/test_meetings.py @@ -68,12 +68,13 @@ def test_agenda_item_relationship_with_meeting(session): session.flush() # Creating an agenda item linked to the meeting - agenda_item = AgendaItem( + item = AgendaItem.create( + session, title='Budget Overview', description='Detailed review of the year\'s budget and spending.', - meeting_id=meeting.id, + meeting=meeting ) - session.add(agenda_item) + session.add(item) session.flush() # Retrieve and assert correct relationship mappings diff --git a/tests/shared/utils.py b/tests/shared/utils.py index 04545f9..09ccf6a 100644 --- a/tests/shared/utils.py +++ b/tests/shared/utils.py @@ -1,11 +1,17 @@ from datetime import datetime +from typing import TYPE_CHECKING + from sedate import utcnow + from privatim.layouts.layout import DEFAULT_TIMEZONE from privatim.models import (Meeting, WorkingGroup, User, Tag, Consultation, - GeneralFile, ) + GeneralFile, AgendaItem, ) from privatim.models.consultation import Status from privatim.testing import DummyRequest +if TYPE_CHECKING: + from sqlalchemy.orm import Session + def find_login_form(resp_forms): """More than one form exists on the login page. Find the one we need""" @@ -36,6 +42,23 @@ def create_meeting(attendees=None) -> Meeting: ) +def create_meeting_with_agenda_items( + agenda_items: list[dict[str, str]], session: 'Session' +) -> Meeting: + meeting = create_meeting() + for item in agenda_items: + AgendaItem.create( + session, + title=item['title'], + description=item['description'], + meeting=meeting, + ) + session.add(meeting) + session.flush() + return meeting + + + def create_consultation(documents=None, tags=None, user=None): documents = documents or [ diff --git a/tests/views/without_client/test_meeting_view.py b/tests/views/without_client/test_meeting_view.py index 33c9c38..b3954f6 100644 --- a/tests/views/without_client/test_meeting_view.py +++ b/tests/views/without_client/test_meeting_view.py @@ -1,8 +1,11 @@ import io import pypdf -from privatim.views import export_meeting_as_pdf_view +from privatim.testing import DummyRequest +from privatim.views import export_meeting_as_pdf_view, \ + sortable_agenda_items_view -from tests.shared.utils import create_meeting, CustomDummyRequest +from tests.shared.utils import create_meeting, CustomDummyRequest, \ + create_meeting_with_agenda_items def test_export_meeting_without_agenda_items(config): @@ -11,7 +14,6 @@ def test_export_meeting_without_agenda_items(config): db = config.dbsession meeting = create_meeting() - db.add(meeting) db.flush() @@ -26,3 +28,45 @@ def test_export_meeting_without_agenda_items(config): assert 'Parade' in all_text assert 'Powerpoint' in all_text assert 'Logo' in all_text + + +def test_sortable_agenda_items_view(config): + + # Add route + config.add_route( + 'sortable_agenda_items', + '/meetings/agenda_items/{id}/move/{subject_id}/{direction}/{' + 'target_id}', + ) + + # Create a meeting with agenda items + db = config.dbsession + + agenda_items = [ + {'title': 'Introduction', 'description': 'Welcome and introductions.'}, + {'title': 'Project Update', 'description': 'Update on projects.'}, + ] + meeting = create_meeting_with_agenda_items(agenda_items, db) + assert [[e.title, e.position] for e in meeting.agenda_items] == [ + ['Introduction', 0], + ['Project Update', 1] + ] + + # assert zero based indexing + all_pos = {e.position for e in meeting.agenda_items} + assert all_pos == {0, 1} + + # print([item.title for item in meeting.agenda_items]) + request = DummyRequest() + # 0 below 1 == swap the items + request.matchdict = { + 'id': str(meeting.id), + 'subject_id': '0', + 'direction': 'below', + 'target_id': '1', + } + request.method = "POST" + request.is_xhr = True + + response = sortable_agenda_items_view(meeting, request) + assert response['status'] == 'success'