diff --git a/src/privatim/models/__init__.py b/src/privatim/models/__init__.py index c5e2dc3..4bcb061 100644 --- a/src/privatim/models/__init__.py +++ b/src/privatim/models/__init__.py @@ -12,7 +12,11 @@ from privatim.models.consultation import Consultation from privatim.models.comment import Comment from privatim.models.meeting import Meeting, AgendaItem -from privatim.models.association_tables import MeetingUserAttendance +from privatim.models.association_tables import ( + MeetingUserAttendance, + AgendaItemDisplayState, + AgendaItemStatePreference, +) from privatim.models.file import GeneralFile, SearchableFile from privatim.models.password_change_token import PasswordChangeToken from privatim.models.tan import TAN @@ -30,6 +34,8 @@ Comment Meeting MeetingUserAttendance +AgendaItemDisplayState +AgendaItemStatePreference AgendaItem PasswordChangeToken GeneralFile diff --git a/src/privatim/models/association_tables.py b/src/privatim/models/association_tables.py index b0c4a1d..75b2dc6 100644 --- a/src/privatim/models/association_tables.py +++ b/src/privatim/models/association_tables.py @@ -1,6 +1,8 @@ from enum import Enum as PyEnum -from sqlalchemy import Enum -from sqlalchemy import ForeignKey +from enum import IntEnum +from sqlalchemy import ForeignKey, Integer, Enum +from privatim.orm.meta import UUIDStrPK + from sqlalchemy.orm import relationship, mapped_column, Mapped from privatim.orm import Base from privatim.orm.uuid_type import UUIDStr @@ -43,3 +45,38 @@ class MeetingUserAttendance(Base): def __repr__(self) -> str: return f'' + + +class AgendaItemDisplayState(IntEnum): + COLLAPSED = 0 + EXPANDED = 1 + + +class AgendaItemStatePreference(Base): + """Tracks user preferences for agenda item display states + (expanded/collapsed)""" + + __tablename__ = 'agenda_item_state_preferences' + + id: Mapped[UUIDStrPK] + + user_id: Mapped[UUIDStr] = mapped_column( + ForeignKey('users.id', ondelete='CASCADE') + ) + + agenda_item_id: Mapped[UUIDStr] = mapped_column( + ForeignKey('agenda_items.id', ondelete='CASCADE') + ) + + state: Mapped[AgendaItemDisplayState] = mapped_column( + Integer, + default=AgendaItemDisplayState.COLLAPSED + ) + + user: Mapped['User'] = relationship( + 'User', + back_populates='agenda_item_state_preferences' + ) + + def __repr__(self) -> str: + return f'' diff --git a/src/privatim/models/meeting.py b/src/privatim/models/meeting.py index 99fc170..287c8b0 100644 --- a/src/privatim/models/meeting.py +++ b/src/privatim/models/meeting.py @@ -11,7 +11,8 @@ from privatim.orm.meta import UUIDStr as UUIDStrType from privatim.models import SearchableMixin -from privatim.models.association_tables import AttendanceStatus +from privatim.models.association_tables import AttendanceStatus, \ + AgendaItemDisplayState, AgendaItemStatePreference from privatim.models.association_tables import MeetingUserAttendance from privatim.orm.uuid_type import UUIDStr from privatim.orm import Base @@ -25,6 +26,7 @@ from privatim.models import WorkingGroup from privatim.types import ACL from sqlalchemy.orm import InstrumentedAttribute + from pyramid.interfaces import IRequest class AgendaItemCreationError(Exception): @@ -103,6 +105,24 @@ def searchable_fields(cls) -> Iterator['InstrumentedAttribute[str]']: yield cls.title yield cls.description + def get_display_state_for_user( + self, + request: 'IRequest', + ) -> AgendaItemDisplayState: + session = request.dbsession + user = request.user + if not session: + return AgendaItemDisplayState.COLLAPSED + + preference = session.execute( + select(AgendaItemStatePreference).where( + AgendaItemStatePreference.agenda_item_id + == self.id, AgendaItemStatePreference.user_id == user.id, + ) + ).scalar_one_or_none() + return preference.state if preference \ + else (AgendaItemDisplayState.COLLAPSED) + def __acl__(self) -> list['ACL']: return [ (Allow, Authenticated, ['view']), diff --git a/src/privatim/models/user.py b/src/privatim/models/user.py index 4030fe8..84641ae 100644 --- a/src/privatim/models/user.py +++ b/src/privatim/models/user.py @@ -30,7 +30,11 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from privatim.models.association_tables import MeetingUserAttendance + from privatim.models.association_tables import ( + MeetingUserAttendance, + AgendaItemStatePreference, + AgendaItemDisplayState, + ) from privatim.types import ACL from sqlalchemy.orm import Session from pyramid.interfaces import IRequest @@ -180,6 +184,26 @@ def _meetings_expression(cls) -> 'ScalarSelect[Meeting]': foreign_keys='Meeting.creator_id', ) + agenda_item_state_preferences: Mapped[list['AgendaItemStatePreference']] \ + = (relationship( + 'AgendaItemStatePreference', + back_populates='user', + cascade='all, delete-orphan', + ) + ) + + def get_agenda_item_state( + self, + agenda_item_id: str + ) -> 'AgendaItemDisplayState': + """Get the display state for a specific agenda item""" + pref = next( + (p for p in self.agenda_item_state_preferences + if p.agenda_item_id == agenda_item_id), + None + ) + return pref.state if pref else AgendaItemDisplayState.COLLAPSED + def set_password(self, password: str) -> None: password = password or '' pwhash = bcrypt.hashpw(password.encode('utf8'), bcrypt.gensalt()) @@ -221,6 +245,7 @@ def fullname(self) -> str: @property def is_admin(self) -> bool: + """ This is only used for the badge in the user list (!) """ return ('admin' in self.first_name.lower() or 'admin' in self.last_name.lower()) diff --git a/src/privatim/static/js/custom/custom.js b/src/privatim/static/js/custom/custom.js index 4491fa1..4a187cd 100644 --- a/src/privatim/static/js/custom/custom.js +++ b/src/privatim/static/js/custom/custom.js @@ -5,14 +5,16 @@ document.addEventListener('DOMContentLoaded', function () { setupCommentAnswerField(); addEditorForCommentsEdit(); makeConsultationsInActivitiesClickable(); - setupAgendaItemGlobalToggle(); + setupExpandOrCollapseAll(); setupDeleteModalListeners(); autoHideSuccessMessages(); addTestSystemBadge(); fixCSSonProfilePage(); + handleSingleAgendaItemClickToggleStateUpdate(); }); + function setupDeleteModalListeners() { var active_popover = null; var popover_timeout = null; @@ -269,21 +271,31 @@ function setupCommentAnswerField() { } } -// Expand / collapse all Agenda Items -function setupAgendaItemGlobalToggle() { +function setupExpandOrCollapseAll() { if (!window.location.href.includes('/meeting')) { return; } - const toggleBtn = document.getElementById('toggleAllItems'); if (!toggleBtn) { return; } const accordionItems = document.querySelectorAll('.accordion-collapse'); - let isExpanded = false; - function toggleAll() { + // Initialize isExpanded based on actual state of items + let isExpanded = Array.from(accordionItems) + .every(item => item.classList.contains('show')); + + // Update button initial state to match + const btnText = toggleBtn.querySelector('span'); + const btnIcon = toggleBtn.querySelector('i'); + if (isExpanded) { + btnText.textContent = toggleBtn.dataset.collapseText; + btnIcon.classList.replace('fa-caret-down', 'fa-caret-up'); + } + + async function toggleAll() { isExpanded = !isExpanded; + // Update UI accordionItems.forEach(item => { const bsCollapse = new bootstrap.Collapse(item, { toggle: false @@ -294,10 +306,7 @@ function setupAgendaItemGlobalToggle() { bsCollapse.hide(); } }); - // Update button text and icon - const btnText = toggleBtn.querySelector('span'); - const btnIcon = toggleBtn.querySelector('i'); if (isExpanded) { btnText.textContent = toggleBtn.dataset.collapseText; btnIcon.classList.replace('fa-caret-down', 'fa-caret-up'); @@ -305,10 +314,30 @@ function setupAgendaItemGlobalToggle() { btnText.textContent = toggleBtn.dataset.expandText; btnIcon.classList.replace('fa-caret-up', 'fa-caret-down'); } + // Get meeting ID from URL + const id = window.location.pathname.split('/').pop(); + console.log('Meeting ID:', id); + // Update server state + try { + const response = await fetch(`/meeting/${id}/agenda_items/bulk/state`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('[name=csrf_token]').value + }, + body: JSON.stringify({ + state: isExpanded ? 1 : 0, + }) + }); + if (!response.ok) { + throw new Error('Failed to update states'); + } + } catch (error) { + console.error('Error updating agenda item states:', error); + } } toggleBtn.addEventListener('click', toggleAll); - } @@ -474,3 +503,41 @@ function fixCSSonProfilePage() { window.location.reload(true); } } + + +function handleSingleAgendaItemClickToggleStateUpdate() { + const accordion = document.querySelector('#agenda-items'); + if (!accordion) return; + + // Initialize individual item toggling + document.querySelectorAll('.agenda-item-accordion').forEach(button => { + button.addEventListener('click', async () => { + const id = button.closest('.accordion-item') + .querySelector('.accordion-collapse').id.replace('item-', ''); + const isExpanded = button.getAttribute('aria-expanded') === 'true'; + const newState = isExpanded ? 1 : 0; + console.log(`Toggling state for item ${id} to ${newState}`); + await updateItemState(id, newState); + }); + }); + +} + +// Helper function to update item state +async function updateItemState(id, newState) { + try { + const response = await fetch(`/agenda_items/${id}/state`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('[name=csrf_token]').value + }, + body: JSON.stringify({state: newState}) + }); + if (!response.ok) { + throw new Error('Failed to update state'); + } + } catch (error) { + console.error('Error updating agenda item state:', error); + } +} diff --git a/src/privatim/views/__init__.py b/src/privatim/views/__init__.py index fb8b025..07f4fa9 100644 --- a/src/privatim/views/__init__.py +++ b/src/privatim/views/__init__.py @@ -15,6 +15,8 @@ from privatim.views.agenda_items import ( add_agenda_item_view, copy_agenda_item_view, + update_single_agenda_item_state, + update_bulk_agenda_items_state ) from privatim.views.agenda_items import delete_agenda_item_view from privatim.views.agenda_items import edit_agenda_item_view @@ -462,6 +464,46 @@ def includeme(config: 'Configurator') -> None: xhr=True ) + config.add_route( + 'update_single_agenda_item_state,', + '/agenda_items/{id}/state', + factory=agenda_item_factory + ) + config.add_view( + update_single_agenda_item_state, + route_name='update_single_agenda_item_state,', + renderer='json', + request_method='POST', + xhr=False + ) + config.add_view( + update_single_agenda_item_state, + route_name='update_single_agenda_item_state,', + renderer='json', + request_method='POST', + xhr=True + ) + + config.add_route( + 'update_bulk_agenda_items_state', + '/meeting/{id}/agenda_items/bulk/state', + factory=meeting_factory + ) + config.add_view( + update_bulk_agenda_items_state, + route_name='update_bulk_agenda_items_state', + renderer='json', + request_method='POST', + xhr=False + ) + config.add_view( + update_bulk_agenda_items_state, + route_name='update_bulk_agenda_items_state', + renderer='json', + request_method='POST', + xhr=True + ) + config.add_route( 'sortable_agenda_items', '/meetings/agenda_items/{id}/move/{subject_id}/{direction}/{' diff --git a/src/privatim/views/agenda_items.py b/src/privatim/views/agenda_items.py index b8f0200..542772f 100644 --- a/src/privatim/views/agenda_items.py +++ b/src/privatim/views/agenda_items.py @@ -5,7 +5,11 @@ from privatim.forms.agenda_item_form import AgendaItemForm, AgendaItemCopyForm from privatim.i18n import _ from privatim.i18n import translate -from privatim.models import AgendaItem +from privatim.models import ( + AgendaItem, + AgendaItemDisplayState, + AgendaItemStatePreference, +) from privatim.models import Meeting from typing import TypeVar @@ -182,3 +186,68 @@ def copy_agenda_item_view( 'title': form._title, 'target_url': target_url, } + + +def update_single_agenda_item_state(request: 'IRequest') -> dict[str, str]: + """Update the expanded/collapsed state of a single agenda item for the + current user""" + session = request.dbsession + new_state = AgendaItemDisplayState(int(request.json_body['state'])) + agenda_item_id = request.matchdict['id'] + + # Look up existing preference + preference = session.execute( + select(AgendaItemStatePreference).where( + AgendaItemStatePreference.agenda_item_id == agenda_item_id, + AgendaItemStatePreference.user_id == request.user.id, + ) + ).scalar_one_or_none() + + if not preference: + preference = AgendaItemStatePreference( + user_id=request.user.id, + agenda_item_id=agenda_item_id, + state=new_state, + ) + session.add(preference) + else: + preference.state = new_state + session.add(preference) + + return {'status': 'success'} + + +def update_bulk_agenda_items_state( + context: Meeting, + request: 'IRequest' +) -> dict[str, str | int]: + """Update the expanded/collapsed state of all agenda items in a meeting + for the current user""" + + session = request.dbsession + new_state = AgendaItemDisplayState(int(request.json_body['state'])) + + # Get all agenda items for the meeting + agenda_items = context.agenda_items + + # Update or create preferences for all items + for agenda_item in agenda_items: + preference = session.execute( + select(AgendaItemStatePreference).where( + AgendaItemStatePreference.agenda_item_id == agenda_item.id, + AgendaItemStatePreference.user_id == request.user.id, + ) + ).scalar_one_or_none() + + if not preference: + preference = AgendaItemStatePreference( + user_id=request.user.id, + agenda_item_id=agenda_item.id, + state=new_state, + ) + else: + preference.state = new_state + + session.add(preference) + + return {'status': 'success', 'updated': len(agenda_items)} diff --git a/src/privatim/views/meetings.py b/src/privatim/views/meetings.py index 61ece98..29e03fa 100644 --- a/src/privatim/views/meetings.py +++ b/src/privatim/views/meetings.py @@ -1,9 +1,12 @@ import logging - from markupsafe import Markup from pyramid.response import Response from sqlalchemy import func, select -from privatim.models.association_tables import AttendanceStatus +from privatim.models.association_tables import ( + AttendanceStatus, + AgendaItemDisplayState, + AgendaItemStatePreference, +) from privatim.reporting.report import ( MeetingReport, ReportOptions, @@ -45,13 +48,29 @@ def meeting_view( ) -> 'RenderData': """ Displays a single meeting. """ assert isinstance(context, Meeting) + session = request.dbsession stmt = select(func.count(Meeting.id)).where( Meeting.working_group_id == context.working_group.id ) - meeting_count = request.dbsession.execute(stmt).scalar_one() + meeting_count = session.execute(stmt).scalar_one() disable_copy_button = meeting_count <= 1 + # Get all preferences for this user and these agenda items in one query + preferences_stmt = ( + select(AgendaItemStatePreference) + .where( + AgendaItemStatePreference.user_id == request.user.id, + AgendaItemStatePreference.agenda_item_id.in_( + item.id for item in context.agenda_items + ) + ) + ) + preferences = { + str(pref.agenda_item_id): pref.state + for pref in session.execute(preferences_stmt).scalars() + } + formatted_time = datetime_format(context.time) request.add_action_menu_entries( ( @@ -96,7 +115,15 @@ def meeting_view( ) agenda_items = [] + all_items_expanded = True for indx, item in enumerate(context.agenda_items, start=1): + is_expanded = preferences.get( + str(item.id), + AgendaItemDisplayState.COLLAPSED + ) == AgendaItemDisplayState.EXPANDED + + if not is_expanded: + all_items_expanded = False agenda_items.append( { 'title': Markup( @@ -107,6 +134,7 @@ def meeting_view( 'description': Markup(item.description), 'id': item.id, 'position': item.position, + 'is_expanded': is_expanded, 'edit_btn': Button( url=request.route_url('edit_agenda_item', id=item.id), icon='edit', @@ -142,6 +170,8 @@ def meeting_view( ), 'expand_all_text': _('Expand All'), 'collapse_all_text': _('Collapse All'), + 'all_expanded': all_items_expanded, + 'has_agenda_items': bool(agenda_items), } diff --git a/src/privatim/views/search.py b/src/privatim/views/search.py index f251b4c..cb0dcec 100644 --- a/src/privatim/views/search.py +++ b/src/privatim/views/search.py @@ -7,19 +7,15 @@ from privatim.layouts import Layout from privatim.i18n import locales from privatim.models import AgendaItem - from privatim.models.file import SearchableFile from privatim.models.searchable import searchable_models from privatim.models.comment import Comment from privatim.models.searchable import SearchableMixin - - -from typing import (TYPE_CHECKING, NamedTuple, TypedDict, Any, TypeVar, - Union) - from privatim.models.markup_text_type import MarkupText from privatim.utils import get_correct_comment_picture_for_comment + +from typing import TYPE_CHECKING, NamedTuple, TypedDict, Any, TypeVar, Union if TYPE_CHECKING: from pyramid.interfaces import IRequest from sqlalchemy.orm import Session diff --git a/src/privatim/views/templates/meeting.pt b/src/privatim/views/templates/meeting.pt index c63016a..8d6477f 100644 --- a/src/privatim/views/templates/meeting.pt +++ b/src/privatim/views/templates/meeting.pt @@ -32,32 +32,45 @@
-
+

Agenda Items

-
-
+ +
+

-

-
+

${item.description}

diff --git a/tests/views/without_client/test_views_agenda_items.py b/tests/views/without_client/test_views_agenda_items.py new file mode 100644 index 0000000..83245e9 --- /dev/null +++ b/tests/views/without_client/test_views_agenda_items.py @@ -0,0 +1,208 @@ +from tests.shared.utils import Bunch +from sedate import utcnow +from sqlalchemy import select, func +from privatim.models import ( + AgendaItem, + AgendaItemStatePreference, + AgendaItemDisplayState, + User, + Meeting, + WorkingGroup, +) +from privatim.views import update_single_agenda_item_state, \ + update_bulk_agenda_items_state + + +def test_update_agenda_item_state(pg_config): + """Test setting and updating agenda item display state preferences""" + + # Add required routes + pg_config.add_route('update_agenda_item_state', '/agenda-items/{id}/state') + + db = pg_config.dbsession + + # Create test user and agenda item + user = User(email='test@example.com') + agenda_item = AgendaItem( + title='Test Item', + description='Test Description', + meeting=Meeting( + name='Test Meeting', + time=utcnow(), + attendees=[user], + working_group=WorkingGroup(name='Test Group'), + ), + position=1, + ) + db.add_all([user, agenda_item]) + db.flush() + + # Create request with state change to EXPANDED + request = Bunch( + matchdict={'id': str(agenda_item.id)}, + json_body={'state': AgendaItemDisplayState.EXPANDED.value}, + user=user, + dbsession=db, + ) + request.user = user + + # Test creating new preference + response = update_single_agenda_item_state(request) + assert response == {'status': 'success'} + + # Verify preference was created + preference = db.execute( + select(AgendaItemStatePreference).where( + AgendaItemStatePreference.agenda_item_id == agenda_item.id, + AgendaItemStatePreference.user_id == user.id, + ) + ).scalar_one() + + assert preference is not None + assert preference.state == AgendaItemDisplayState.EXPANDED.value + + # Test updating existing preference to COLLAPSED + request.json_body = {'state': AgendaItemDisplayState.COLLAPSED.value} + response = update_single_agenda_item_state(request) + assert response == {'status': 'success'} + + # Verify preference was updated + preference = db.execute( + select(AgendaItemStatePreference).where( + AgendaItemStatePreference.agenda_item_id == agenda_item.id, + AgendaItemStatePreference.user_id == user.id, + ) + ).scalar_one() + assert preference.state == AgendaItemDisplayState.COLLAPSED.value + + # Verify only one preference exists + preference_count = db.scalar( + select(func.count()) + .select_from(AgendaItemStatePreference) + .where( + AgendaItemStatePreference.agenda_item_id == agenda_item.id, + AgendaItemStatePreference.user_id == user.id, + ) + ) + assert preference_count == 1 + + +def test_bulk_update_agenda_items_state(pg_config): + """Test bulk updating display state preferences for multiple agenda + items """ + # Add required routes + pg_config.add_route( + 'bulk_update_agenda_items_state', + '/meeting/{id}/agenda-items/bulk/state', + ) + + db = pg_config.dbsession + + # Create test user and meeting with multiple agenda items + user = User(email='test@example.com') + meeting = Meeting( + name='Test Meeting', + time=utcnow(), + attendees=[user], + working_group=WorkingGroup(name='Test Group'), + ) + + # Create three agenda items + agenda_items = [ + AgendaItem( + title=f'Test Item {i}', + description=f'Test Description {i}', + meeting=meeting, + position=i, + ) + for i in range(1, 4) + ] + + db.add_all([user, meeting] + agenda_items) + db.flush() + + # Create request with bulk state change to EXPANDED + request = Bunch( + matchdict={'id': str(meeting.id)}, + json_body={'state': AgendaItemDisplayState.EXPANDED.value}, + user=user, + dbsession=db, + ) + + # Test creating new preferences for all items + context = meeting + response = update_bulk_agenda_items_state(context, request) + assert response == {'status': 'success', 'updated': 3} + + # Verify preferences were created for all items + for agenda_item in agenda_items: + preference = db.execute( + select(AgendaItemStatePreference).where( + AgendaItemStatePreference.agenda_item_id == agenda_item.id, + AgendaItemStatePreference.user_id == user.id, + ) + ).scalar_one() + assert preference is not None + assert preference.state == AgendaItemDisplayState.EXPANDED.value + + # Test updating existing preferences to COLLAPSED + request.json_body = {'state': AgendaItemDisplayState.COLLAPSED.value} + response = update_bulk_agenda_items_state(meeting, request) + assert response == {'status': 'success', 'updated': 3} + + # Verify all preferences were updated + for agenda_item in agenda_items: + preference = db.execute( + select(AgendaItemStatePreference).where( + AgendaItemStatePreference.agenda_item_id == agenda_item.id, + AgendaItemStatePreference.user_id == user.id, + ) + ).scalar_one() + assert preference.state == AgendaItemDisplayState.COLLAPSED.value + + # Verify only one preference exists per agenda item + for agenda_item in agenda_items: + preference_count = db.scalar( + select(func.count()) + .select_from(AgendaItemStatePreference) + .where( + AgendaItemStatePreference.agenda_item_id == agenda_item.id, + AgendaItemStatePreference.user_id == user.id, + ) + ) + assert preference_count == 1 + + # Test mixed state scenario - delete one preference + first_item_preference = db.execute( + select(AgendaItemStatePreference).where( + AgendaItemStatePreference.agenda_item_id == agenda_items[0].id, + AgendaItemStatePreference.user_id == user.id, + ) + ).scalar_one() + db.delete(first_item_preference) + db.flush() + + # Update states again + request.json_body = {'state': AgendaItemDisplayState.EXPANDED.value} + response = update_bulk_agenda_items_state(meeting, request) + assert response['status'] == 'success' + + # Verify first item got new preference and others were updated + new_preference = db.execute( + select(AgendaItemStatePreference).where( + AgendaItemStatePreference.agenda_item_id == agenda_items[0].id, + AgendaItemStatePreference.user_id == user.id, + ) + ).scalar_one() + assert new_preference is not None + assert new_preference.state == AgendaItemDisplayState.EXPANDED.value + + # Check total preference count is still correct + total_preferences = db.scalar( + select(func.count()) + .select_from(AgendaItemStatePreference) + .where( + AgendaItemStatePreference.user_id == user.id, + ) + ) + assert total_preferences == len(agenda_items)