From a4fb76672dd57207f4c4709650841c356697e8f5 Mon Sep 17 00:00:00 2001 From: Lucas Brandstaetter <lucas@brandstaetter.tech> Date: Tue, 10 Dec 2024 18:25:07 +0100 Subject: [PATCH] Add team invitations - Make the requester_id mandatory - Add requested_id to the form - Add InvitationTeamForm - Add all the necessary logic --- .../locale/de/LC_MESSAGES/django.po | 23 +- .../locale/en/LC_MESSAGES/django.po | 21 + .../backoffice/invitations/create_edit.html | 25 +- .../templates/backoffice/teams/detail.html | 24 +- src/backoffice/tests/invitations/__init__.py | 3 + src/backoffice/tests/invitations/teams.py | 544 ++++++++++++++++++ src/backoffice/tests/teams/teams.py | 4 + src/backoffice/urls.py | 1 + src/backoffice/views/invitations/__init__.py | 27 + src/backoffice/views/invitations/send.py | 35 +- src/backoffice/views/invitations/update.py | 18 +- src/backoffice/views/teams/teams.py | 4 +- src/core/forms/__init__.py | 3 +- src/core/forms/invitations.py | 115 +++- src/core/locale/de/LC_MESSAGES/django.po | 12 +- src/core/locale/en/LC_MESSAGES/django.po | 12 +- src/core/models/invitation.py | 36 ++ 17 files changed, 877 insertions(+), 30 deletions(-) create mode 100644 src/backoffice/tests/invitations/teams.py diff --git a/src/backoffice/locale/de/LC_MESSAGES/django.po b/src/backoffice/locale/de/LC_MESSAGES/django.po index 487551bb5..cbaabc601 100644 --- a/src/backoffice/locale/de/LC_MESSAGES/django.po +++ b/src/backoffice/locale/de/LC_MESSAGES/django.po @@ -774,7 +774,7 @@ msgstr "Wiki" msgid "nav_schedules" msgstr "Schedules" -# use translation from core +# Use translation from core msgid "Invitations" msgstr "" @@ -1539,6 +1539,12 @@ msgstr "Entfernen" msgid "TeamMember__leave" msgstr "Team verlassen" +msgid "TeamMember__join" +msgstr "Beitritt beantragen" + +msgid "TeamMember__invite" +msgstr "Personen einladen" + msgid "Team__delete__title" msgstr "Team löschen" @@ -2026,6 +2032,21 @@ msgstr "Einladung zum Habitatsbeitritt" msgid "Invitation__introduction__habitat" msgstr "Mit dieser Einladung wird eine Aufnahme des Assemblies in das Habitat angefordert. Wenn die Einladung angenommen wird, wird das Assembly dem Habitat zugeordnet." +# use translation from core +msgid "Invitation__type__team_to_member" +msgstr "" + +msgid "Invitation__introduction__team" +msgstr "Mit dieser Einladung wird eine Aufnahme des Benutzers in das Team angefordert." + +# use translation from core +msgid "TeamMember" +msgstr "" + +# use translation from core +msgid "Invitation__type__member_to_team" +msgstr "" + # use translation from core msgid "Invitation__type__habitat" msgstr "" diff --git a/src/backoffice/locale/en/LC_MESSAGES/django.po b/src/backoffice/locale/en/LC_MESSAGES/django.po index ccdd21e32..0fc438364 100644 --- a/src/backoffice/locale/en/LC_MESSAGES/django.po +++ b/src/backoffice/locale/en/LC_MESSAGES/django.po @@ -1544,6 +1544,12 @@ msgstr "Remove" msgid "TeamMember__leave" msgstr "Leave team" +msgid "TeamMember__join" +msgstr "Request to join" + +msgid "TeamMember__invite" +msgstr "Invite members" + msgid "Team__delete__title" msgstr "Delete this team" @@ -2031,6 +2037,21 @@ msgstr "Invitation to join a habitat" msgid "Invitation__introduction__habitat" msgstr "This invitation requests that the assembly be included in the habitat. If the invitation is accepted, the assembly is assigned to the habitat." +# use translation from core +msgid "Invitation__type__team_to_member" +msgstr "" + +msgid "Invitation__introduction__team" +msgstr "This invitation requests that a user joins/is added to a team." + +# use translation from core +msgid "TeamMember" +msgstr "" + +# use translation from core +msgid "Invitation__type__member_to_team" +msgstr "" + # use translation from core msgid "Invitation__type__habitat" msgstr "" diff --git a/src/backoffice/templates/backoffice/invitations/create_edit.html b/src/backoffice/templates/backoffice/invitations/create_edit.html index 0e8342b0b..0094d2667 100644 --- a/src/backoffice/templates/backoffice/invitations/create_edit.html +++ b/src/backoffice/templates/backoffice/invitations/create_edit.html @@ -51,8 +51,29 @@ {% endif %} {% if form.create %} <div class="row"> - <div class="col-md-6">{% bootstrap_field form.requester_id %}</div> - <div class="col-md-6">{% bootstrap_field form.requested_id %}</div> + <div class="col-md-6"> + {% if form.requester_label %} + <label class="form-label" for="requester_text">{{ form.requester_label }}</label> + {% endif %} + {% if form.requester_text %} + <input id="requester_text" + class="form-control-plaintext" + value="{{ form.requester_text }}"> + {% endif %} + {% bootstrap_field form.requester_id %} + + </div> + <div class="col-md-6"> + {% if form.requested_label %} + <label class="form-label" for="requested_text">{{ form.requested_label }}</label> + {% endif %} + {% if form.requester_text %} + <input id="requested_text" + class="form-control-plaintext" + value="{{ form.requested_text }}"> + {% endif %} + {% bootstrap_field form.requested_id %} + </div> </div> <div class="row"> <div class="col-md-12">{% bootstrap_field form.comment %}</div> diff --git a/src/backoffice/templates/backoffice/teams/detail.html b/src/backoffice/templates/backoffice/teams/detail.html index 07abefea1..552a76a5f 100644 --- a/src/backoffice/templates/backoffice/teams/detail.html +++ b/src/backoffice/templates/backoffice/teams/detail.html @@ -116,13 +116,29 @@ </table> </div> - {% if member_id %} - <div class="card-footer text-end"> + <div class="card-footer text-end"> + {% if member_id %} <a href="{% url "backoffice:team-member-delete" team=team.uuid pk=member_id %}" class="btn btn-danger btn-sm">{% trans "TeamMember__leave" %}</a> - </div> - {% endif %} + {% else %} + <a href="{% url "backoffice:invitation-send" type="member_to_team" requester_id=user.uuid requested_id=team.uuid %}" + class="btn btn-primary btn-sm">{% trans "TeamMember__join" %}</a> + {% endif %} + {% if can_change %} + <a href="{% url "backoffice:invitation-send" type="team_to_member" requester_id=team.uuid %}" + class="btn btn-primary btn-sm">{% trans "TeamMember__invite" %}</a> + {% endif %} + </div> </div> + {% if received_invitations and can_change %} + {% trans "Invitations__list__title" as list_title %} + {% include "backoffice/components/invitations_list.html" with invitations=received_invitations border_class="border-primary" list_title=list_title requested_view=True %} + {% endif %} + + {% if sent_invitations and can_change %} + {% trans "Invitations__list__title" as list_title %} + {% include "backoffice/components/invitations_list.html" with invitations=sent_invitations border_class="border-secondary" list_title=list_title requester_view=True %} + {% endif %} {% if not form.create and can_delete %} <div class="card card-danger border-danger mt-3 mb-3"> diff --git a/src/backoffice/tests/invitations/__init__.py b/src/backoffice/tests/invitations/__init__.py index 0909f1390..7301a10f8 100644 --- a/src/backoffice/tests/invitations/__init__.py +++ b/src/backoffice/tests/invitations/__init__.py @@ -14,6 +14,7 @@ from core.models import ( from backoffice.tests.base import BackOfficeTestCase from backoffice.tests.invitations.habitat import HabitatInvitationViewsTestCase +from backoffice.tests.invitations.teams import MemberToTeamInvitationViewsTestCase, TeamToUserInvitationViewsTestCase class InvitationTestCase(BackOfficeTestCase): @@ -108,4 +109,6 @@ __all__ = [ 'HabitatInvitationViewsTestCase', 'InvitationListViewTestCase', 'InvitationTestCase', + 'MemberToTeamInvitationViewsTestCase', + 'TeamToUserInvitationViewsTestCase', ] diff --git a/src/backoffice/tests/invitations/teams.py b/src/backoffice/tests/invitations/teams.py new file mode 100644 index 000000000..e286717c3 --- /dev/null +++ b/src/backoffice/tests/invitations/teams.py @@ -0,0 +1,544 @@ +from datetime import UTC, datetime, timedelta +from unittest.mock import patch +from uuid import UUID + +from django.http import HttpResponse +from django.urls import reverse +from django.utils import timezone +from django.utils.html import escape + +from core.models import ( + ActivityLogEntry, + ConferenceMember, + Invitation, + PlatformUser, + Team, + TeamMember, +) +from core.tests.mock import mocktrans + +from backoffice.tests.invitations.mixin import InvitationTestCase + + +class TeamToUserInvitationViewsTestCase(InvitationTestCase): + def setUp(self): + super().setUp() + + self.team = Team.objects.create(name='team 1', conference=self.conf) + self.team_manager = PlatformUser.objects.create(username='assembly_manager') + self.team_manager_cm = ConferenceMember.objects.create(conference=self.conf, user=self.team_manager) + self.team_manager_am = TeamMember.objects.create( + team=self.team, + user=self.team_manager, + can_manage=True, + ) + + self.invitee = PlatformUser.objects.create(username='invitee', first_name='First', last_name='Last') + self.invitee_cm = ConferenceMember.objects.create(conference=self.conf, user=self.invitee) + + def send_successful_invitation( + self, + user: PlatformUser | None = None, + requester_id: str | UUID | None = None, + requested_id: str | UUID | None = None, + invitation_type: Invitation.InvitationType = Invitation.InvitationType.TEAM_TO_MEMBER, + **kwargs, + ) -> tuple[HttpResponse, Invitation]: + return super().send_successful_invitation( + user=self.team_manager if user is None else user, + requester_id=self.team.uuid if requester_id is None else requester_id, + requested_id=self.invitee.username if requested_id is None else requested_id, + invitation_type=invitation_type, + **kwargs, + ) + + def send_invitation( + self, + *, + user: PlatformUser | None = None, + requester_id: str | UUID | None = None, + requested_id: str | UUID | None = None, + invitation_type: Invitation.InvitationType = Invitation.InvitationType.TEAM_TO_MEMBER, + **kwargs, + ) -> HttpResponse: + return super().send_invitation( + user=self.team_manager if user is None else user, + requester_id=self.team.uuid if requester_id is None else requester_id, + requested_id=self.invitee.username if requested_id is None else requested_id, + invitation_type=invitation_type, + **kwargs, + ) + + def test_team_to_user_invitation_accept(self): + _response, invitation = self.send_successful_invitation() + + # Accept invitation + self.assertFalse(self.team.members.filter(user=self.invitee.id).exists()) + activity_log_count = ActivityLogEntry.objects.count() + self.send_action( + user=self.invitee, + invitation=invitation, + action='accept', + expected_state=Invitation.RequestsState.ACCEPTED, + ) + + self.team.refresh_from_db() + self.assertTrue(self.team.members.filter(user=self.invitee.id).exists()) + self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1) + + def test_team_to_user_invitation_requested_in_url_accept(self): + _response, invitation = self.send_successful_invitation( + url=reverse( + 'backoffice:invitation-send', + kwargs={ + 'requester_id': self.team.uuid, + 'requested_id': self.invitee.uuid, + 'type': Invitation.InvitationType.TEAM_TO_MEMBER.lower(), + }, + ), + ) + + # Accept invitation + self.assertFalse(self.team.members.filter(user=self.invitee.id).exists()) + activity_log_count = ActivityLogEntry.objects.count() + self.send_action( + user=self.invitee, + invitation=invitation, + action='accept', + expected_state=Invitation.RequestsState.ACCEPTED, + ) + + self.team.refresh_from_db() + self.assertTrue(self.team.members.filter(user=self.invitee.id).exists()) + self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1) + + def test_team_to_user_invitation_withdraw(self): + _response, invitation = self.send_successful_invitation() + + self.send_action( + user=self.team_manager, + invitation=invitation, + action='withdraw', + expected_state=Invitation.RequestsState.WITHDRAWN, + ) + + # Test if we can send the invitation again + _response, invitation = self.send_successful_invitation( + exprected_count=2, + ) + + def test_team_to_user_invitation_reject(self): + with patch.object(timezone, 'now', return_value=datetime(2042, 12, 27, 12, 34, 0, 0, tzinfo=UTC)): + _response, invitation = self.send_successful_invitation() + + self.send_action( + user=self.invitee, + invitation=invitation, + action='reject', + expected_state=Invitation.RequestsState.REJECTED, + ) + + # Try to re-Send invitation from habitat to assembly + with patch('core.models.invitation._', mocktrans): + response = self.send_invitation( + expected_code=200, + ) + + self.assertTemplateUsed(response, 'backoffice/invitations/create_edit.html') + + # After the rejection has timed out, can send invitation again + with ( + patch.object(timezone, 'now', return_value=(datetime(2042, 12, 27, 12, 34, 0, 0, tzinfo=UTC)) + timedelta(hours=4)), + patch('core.models.invitation.datetime') as mock_datetime, + ): + mock_datetime.now.return_value = datetime(2042, 12, 27, 12, 34, 0, 0, tzinfo=UTC) + timedelta(hours=4) + _response, invitation_2 = self.send_successful_invitation(exprected_count=2) + + # Accept invitation + self.assertFalse(self.team.members.filter(user=self.invitee.id).exists()) + activity_log_count = ActivityLogEntry.objects.count() + + self.send_action( + user=self.invitee, + invitation=invitation_2, + action='accept', + expected_state=Invitation.RequestsState.ACCEPTED, + ) + + self.team.refresh_from_db() + self.assertTrue(self.team.members.filter(user=self.invitee.id).exists()) + self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1) + + def test_team_to_user_invitation_update(self): + _response, invitation = self.send_successful_invitation() + + with patch('backoffice.views.invitations.update._', mocktrans): + response = self.client.get( + reverse('backoffice:invitation-edit', kwargs={'pk': invitation.id}), + ) + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'backoffice/invitations/create_edit.html') + self.assertContains(response, 'Invitation__introduction__team_translated') + self.assertContains(response, 'Invitation__type__team_to_member_translated') + + response = self.send_action( + user=self.team_manager, + invitation=invitation, + action='update', + expected_state=Invitation.RequestsState.REQUESTED, + data={ + 'requester_id': invitation.requester.id, + 'requested_id': invitation.requested.id, + 'comment': 'Updated comment', + }, + ) + invitation.refresh_from_db() + self.assertEqual(invitation.comment, 'Updated comment') + + def test_team_to_user_invitation_invalid_action(self): + _response, invitation = self.send_successful_invitation() + + self.send_action( + user=self.team_manager, + invitation=invitation, + action='invalid', + expected_state=Invitation.RequestsState.REQUESTED, + expected_code=400, + ) + + def test_team_to_user_invitation_no_permission(self): + _response, invitation = self.send_successful_invitation() + + # Cannot accept invitation + self.assertFalse(self.team.members.filter(user=self.invitee.id).exists()) + + self.send_action( + user=self.team_manager, + invitation=invitation, + action='accept', + expected_state=Invitation.RequestsState.REQUESTED, + expected_code=403, + ) + self.team.refresh_from_db() + self.assertFalse(self.team.members.filter(user=self.invitee.id).exists()) + + # Cannot reject invitation + self.send_action( + user=self.team_manager, + invitation=invitation, + action='reject', + expected_state=Invitation.RequestsState.REQUESTED, + expected_code=403, + ) + self.team.refresh_from_db() + + # Cannot withdraw invitation + self.assertFalse(self.team.members.filter(user=self.invitee.id).exists()) + self.send_action( + user=self.invitee, + invitation=invitation, + action='withdraw', + expected_state=Invitation.RequestsState.REQUESTED, + expected_code=403, + ) + self.team.refresh_from_db() + + # Cannot update invitation + self.assertFalse(self.team.members.filter(user=self.invitee.id).exists()) + self.send_action( + user=self.invitee, + invitation=invitation, + action='update', + expected_state=Invitation.RequestsState.REQUESTED, + expected_code=403, + data={ + 'requester_id': invitation.requester.id, + 'requested_id': invitation.requested.id, + 'comment': 'Updated comment', + }, + ) + + self.team.refresh_from_db() + self.assertFalse(self.team.members.filter(user=self.invitee.id).exists()) + invitation.refresh_from_db() + self.assertEqual(invitation.comment, '') + + def test_team_to_user_invitation_duplicate_invitation(self): + self.send_successful_invitation() + + # Try to send duplicate invitation + + # Send invitation from habitat to assembly + with patch('core.models.invitation._', mocktrans): + response = self.send_invitation( + expected_code=200, + ) + self.assertTemplateUsed(response, 'backoffice/invitations/create_edit.html') + self.assertContains(response, escape(f'Invitation__error__already_exists {self.team.name} {self.invitee.name} {self.team_manager.name}_translated')) + self.assertEqual(Invitation.objects.count(), 1) + + +class MemberToTeamInvitationViewsTestCase(InvitationTestCase): + def setUp(self): + super().setUp() + + self.team = Team.objects.create(name='team 1', conference=self.conf) + self.team_manager = PlatformUser.objects.create(username='assembly_manager') + self.team_manager_cm = ConferenceMember.objects.create(conference=self.conf, user=self.team_manager) + self.team_manager_am = TeamMember.objects.create( + team=self.team, + user=self.team_manager, + can_manage=True, + ) + + self.invitee = PlatformUser.objects.create(username='invitee') + self.invitee_cm = ConferenceMember.objects.create(conference=self.conf, user=self.invitee) + + def send_successful_invitation( + self, + user: PlatformUser | None = None, + requester_id: str | UUID | None = None, + requested_id: str | UUID | None = None, + invitation_type: Invitation.InvitationType = Invitation.InvitationType.MEMBER_TO_TEAM, + **kwargs, + ) -> tuple[HttpResponse, Invitation]: + return super().send_successful_invitation( + user=self.invitee if user is None else user, + requester_id=self.invitee.uuid if requester_id is None else requester_id, + requested_id=self.team.name if requested_id is None else requested_id, + invitation_type=invitation_type, + **kwargs, + ) + + def send_invitation( + self, + *, + user: PlatformUser | None = None, + requester_id: str | UUID | None = None, + requested_id: str | UUID | None = None, + invitation_type: Invitation.InvitationType = Invitation.InvitationType.MEMBER_TO_TEAM, + **kwargs, + ) -> HttpResponse: + return super().send_invitation( + user=self.invitee if user is None else user, + requester_id=self.invitee.uuid if requester_id is None else requester_id, + requested_id=self.team.name if requested_id is None else requested_id, + invitation_type=invitation_type, + **kwargs, + ) + + def test_member_to_team_invitation_accept(self): + _response, invitation = self.send_successful_invitation() + + # Accept invitation + self.assertFalse(self.team.members.filter(user=self.invitee.id).exists()) + activity_log_count = ActivityLogEntry.objects.count() + self.send_action( + user=self.team_manager, + invitation=invitation, + action='accept', + expected_state=Invitation.RequestsState.ACCEPTED, + ) + + self.team.refresh_from_db() + self.assertTrue(self.team.members.filter(user=self.invitee.id).exists()) + self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1) + + def test_team_to_user_invitation_requested_in_url_accept(self): + _response, invitation = self.send_successful_invitation( + url=reverse( + 'backoffice:invitation-send', + kwargs={ + 'requester_id': self.invitee.uuid, + 'requested_id': self.team.uuid, + 'type': Invitation.InvitationType.MEMBER_TO_TEAM.lower(), + }, + ), + ) + + # Accept invitation + self.assertFalse(self.team.members.filter(user=self.invitee.id).exists()) + activity_log_count = ActivityLogEntry.objects.count() + self.send_action( + user=self.team_manager, + invitation=invitation, + action='accept', + expected_state=Invitation.RequestsState.ACCEPTED, + ) + + self.team.refresh_from_db() + self.assertTrue(self.team.members.filter(user=self.invitee.id).exists()) + self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1) + + def test_member_to_team_invitation_withdraw(self): + _response, invitation = self.send_successful_invitation() + + self.send_action( + user=self.invitee, + invitation=invitation, + action='withdraw', + expected_state=Invitation.RequestsState.WITHDRAWN, + ) + + # Test if we can send the invitation again + _response, invitation = self.send_successful_invitation( + exprected_count=2, + ) + + def test_member_to_team_invitation_reject(self): + with patch.object(timezone, 'now', return_value=datetime(2042, 12, 27, 12, 34, 0, 0, tzinfo=UTC)): + _response, invitation = self.send_successful_invitation() + + self.send_action( + user=self.team_manager, + invitation=invitation, + action='reject', + expected_state=Invitation.RequestsState.REJECTED, + ) + + # Try to re-Send invitation from habitat to assembly + with patch('core.models.invitation._', mocktrans): + response = self.send_invitation( + expected_code=200, + ) + + self.assertTemplateUsed(response, 'backoffice/invitations/create_edit.html') + + # After the rejection has timed out, can send invitation again + with ( + patch.object(timezone, 'now', return_value=(datetime(2042, 12, 27, 12, 34, 0, 0, tzinfo=UTC)) + timedelta(hours=4)), + patch('core.models.invitation.datetime') as mock_datetime, + ): + mock_datetime.now.return_value = datetime(2042, 12, 27, 12, 34, 0, 0, tzinfo=UTC) + timedelta(hours=4) + _response, invitation_2 = self.send_successful_invitation( + exprected_count=2, + ) + + # Accept invitation + self.assertFalse(self.team.members.filter(user=self.invitee.id).exists()) + activity_log_count = ActivityLogEntry.objects.count() + + self.send_action( + user=self.team_manager, + invitation=invitation_2, + action='accept', + expected_state=Invitation.RequestsState.ACCEPTED, + ) + + self.team.refresh_from_db() + self.assertTrue(self.team.members.filter(user=self.invitee.id).exists()) + self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1) + + def test_member_to_team_invitation_update(self): + _response, invitation = self.send_successful_invitation() + + with patch('backoffice.views.invitations.update._', mocktrans): + response = self.client.get( + reverse('backoffice:invitation-edit', kwargs={'pk': invitation.id}), + ) + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'backoffice/invitations/create_edit.html') + self.assertContains(response, 'Invitation__introduction__team_translated') + self.assertContains(response, 'Invitation__type__member_to_team_translated') + + response = self.send_action( + user=self.invitee, + invitation=invitation, + action='update', + expected_state=Invitation.RequestsState.REQUESTED, + data={ + 'requester_id': invitation.requester.id, + 'requested_id': invitation.requested.id, + 'comment': 'Updated comment', + }, + ) + invitation.refresh_from_db() + self.assertEqual(invitation.comment, 'Updated comment') + + def test_member_to_team_invitation_invalid_action(self): + _response, invitation = self.send_successful_invitation() + + self.send_action( + user=self.team_manager, + invitation=invitation, + action='invalid', + expected_state=Invitation.RequestsState.REQUESTED, + expected_code=400, + ) + + def test_no_permission(self): + _response, invitation = self.send_successful_invitation() + + # Cannot accept invitation + self.assertFalse(self.team.members.filter(user=self.invitee.id).exists()) + + self.send_action( + user=self.invitee, + invitation=invitation, + action='accept', + expected_state=Invitation.RequestsState.REQUESTED, + expected_code=403, + ) + self.team.refresh_from_db() + self.assertFalse(self.team.members.filter(user=self.invitee.id).exists()) + + # Cannot reject invitation + self.assertFalse(self.team.members.filter(user=self.invitee.id).exists()) + + self.send_action( + user=self.invitee, + invitation=invitation, + action='reject', + expected_state=Invitation.RequestsState.REQUESTED, + expected_code=403, + ) + self.team.refresh_from_db() + self.assertFalse(self.team.members.filter(user=self.invitee.id).exists()) + + # Cannot withdraw invitation + self.assertFalse(self.team.members.filter(user=self.invitee.id).exists()) + self.send_action( + user=self.team_manager, + invitation=invitation, + action='withdraw', + expected_state=Invitation.RequestsState.REQUESTED, + expected_code=403, + ) + self.team.refresh_from_db() + self.assertFalse(self.team.members.filter(user=self.invitee.id).exists()) + + # Cannot update invitation + self.assertFalse(self.team.members.filter(user=self.invitee.id).exists()) + self.send_action( + user=self.team_manager, + invitation=invitation, + action='update', + expected_state=Invitation.RequestsState.REQUESTED, + expected_code=403, + data={ + 'requester_id': invitation.requester.id, + 'requested_id': invitation.requested.id, + 'comment': 'Updated comment', + }, + ) + + self.team.refresh_from_db() + self.assertFalse(self.team.members.filter(user=self.invitee.id).exists()) + invitation.refresh_from_db() + self.assertEqual(invitation.comment, '') + + def test_duplicate_invitation(self): + _response, invitation = self.send_successful_invitation() + + # Try to send duplicate invitation + + # Send invitation from habitat to assembly + with patch('core.models.invitation._', mocktrans): + response = self.send_invitation( + expected_code=200, + ) + self.assertTemplateUsed(response, 'backoffice/invitations/create_edit.html') + self.assertContains(response, f'Invitation__error__already_exists {self.invitee.name} {self.team.name} {self.invitee.name}_translated') + self.assertEqual(Invitation.objects.count(), 1) diff --git a/src/backoffice/tests/teams/teams.py b/src/backoffice/tests/teams/teams.py index 6e0bc74cd..576965c9d 100644 --- a/src/backoffice/tests/teams/teams.py +++ b/src/backoffice/tests/teams/teams.py @@ -96,6 +96,7 @@ class TeamDetailViewTestCase(BackOfficeTestCase): self.assertEqual(self.team, response.context['team']) self.assertContains(response, _('Team__edit__button')) self.assertContains(response, _('Team__delete__button')) + self.assertNotContains(response, _('TeamMember__join')) def test_team_detail_staff(self): self.client.force_login(self.staff) @@ -105,6 +106,7 @@ class TeamDetailViewTestCase(BackOfficeTestCase): self.assertEqual(self.team, response.context['team']) self.assertContains(response, _('Team__edit__button')) self.assertNotContains(response, _('Team__delete__button')) + self.assertNotContains(response, _('TeamMember__join')) def test_team_detail_team_member(self): self.client.force_login(self.user) @@ -114,6 +116,7 @@ class TeamDetailViewTestCase(BackOfficeTestCase): self.assertEqual(self.team, response.context['team']) self.assertNotContains(response, _('Team__edit__button')) self.assertNotContains(response, _('Team__delete__button')) + self.assertNotContains(response, _('TeamMember__join')) def test_team_detail_non_team_member(self): self.client.force_login(self.user_2) @@ -123,6 +126,7 @@ class TeamDetailViewTestCase(BackOfficeTestCase): self.assertEqual(self.team, response.context['team']) self.assertNotContains(response, _('Team__edit__button')) self.assertNotContains(response, _('Team__delete__button')) + self.assertContains(response, _('TeamMember__join')) class TeamCreateViewTestCase(BackOfficeTestCase): diff --git a/src/backoffice/urls.py b/src/backoffice/urls.py index 35574ee7c..b3ceb9af6 100644 --- a/src/backoffice/urls.py +++ b/src/backoffice/urls.py @@ -112,6 +112,7 @@ urlpatterns = [ path('invitation/<uuid:pk>/edit', invitations.InvitationUpdateView.as_view(), name='invitation-edit'), path('invitation/<uuid:pk>/decision', invitations.InvitationDecisionView.as_view(), name='invitation-decision'), path('invite/<str:type>/<uuid:requester_id>', invitations.InvitationCreateView.as_view(), name='invitation-send'), + path('invite/<str:type>/<uuid:requester_id>/<uuid:requested_id>', invitations.InvitationCreateView.as_view(), name='invitation-send'), path('map/floors', FloorListView.as_view(), name='map-floor-list'), path('map/floor/new', FloorCreateView.as_view(), name='map-floor-create'), path('map/floor/<uuid:pk>', FloorUpdateView.as_view(), name='map-floor-edit'), diff --git a/src/backoffice/views/invitations/__init__.py b/src/backoffice/views/invitations/__init__.py index 379de5b38..5bdf91917 100644 --- a/src/backoffice/views/invitations/__init__.py +++ b/src/backoffice/views/invitations/__init__.py @@ -50,6 +50,33 @@ class InvitationDetailView(ConferenceRuleRequiredMixin, AutoPermissionRequiredMi 'requested_link': reverse_lazy('backoffice:assembly', kwargs={'pk': invitation.requested.pk}), } ) + case Invitation.InvitationType.TEAM_TO_MEMBER: + from core.templatetags.hub_absolute import hub_absolute + + context.update( + { + 'title_text': _('Invitation__type__team_to_member'), + 'introduction_text': _('Invitation__introduction__team'), + 'requester_label': _('Team'), + 'requested_label': _('TeamMember'), + 'requester_link': reverse_lazy('backoffice:team', kwargs={'uuid': invitation.requester.uuid}), + 'requested_link': hub_absolute('plainui:user', user_slug=invitation.requested.slug), + } + ) + case Invitation.InvitationType.MEMBER_TO_TEAM: + from core.templatetags.hub_absolute import hub_absolute + + context.update( + { + 'title_text': _('Invitation__type__member_to_team'), + 'introduction_text': _('Invitation__introduction__team'), + 'requester_label': _('TeamMember'), + 'requested_label': _('Team'), + 'requester_link': hub_absolute('plainui:user', user_slug=invitation.requester.slug), + 'requested_link': reverse_lazy('backoffice:team', kwargs={'uuid': invitation.requested.uuid}), + } + ) + case _: # pragma: no cover raise NotImplementedError diff --git a/src/backoffice/views/invitations/send.py b/src/backoffice/views/invitations/send.py index 65b30cb29..6b7757e25 100644 --- a/src/backoffice/views/invitations/send.py +++ b/src/backoffice/views/invitations/send.py @@ -9,8 +9,8 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ from django.views.generic import CreateView -from core.forms import InvitationHabitatForm -from core.models import Assembly, Invitation +from core.forms import InvitationHabitatForm, InvitationTeamForm +from core.models import Assembly, Invitation, PlatformUser, Team from backoffice.views.mixins import ConferenceLoginRequiredMixin @@ -30,6 +30,12 @@ class InvitationCreateView(ConferenceLoginRequiredMixin, RedirectURLMixin, Creat case Invitation.InvitationType.HABITAT: assembly = get_object_or_404(Assembly, pk=self.kwargs.get('requester_id')) return self.request.user.has_perm('core.change_assembly', assembly) + case Invitation.InvitationType.TEAM_TO_MEMBER: + team = get_object_or_404(Team, uuid=self.kwargs.get('requester_id')) + return self.request.user.has_perm('core.change_team', team) + case Invitation.InvitationType.MEMBER_TO_TEAM: + user = get_object_or_404(PlatformUser, uuid=self.kwargs.get('requester_id')) + return user == self.request.user case _: # pragma: no cover raise NotImplementedError('Invitation type not implemented') @@ -37,16 +43,22 @@ class InvitationCreateView(ConferenceLoginRequiredMixin, RedirectURLMixin, Creat match self.invitation_type: case Invitation.InvitationType.HABITAT: return InvitationHabitatForm - case _: # pragma: no cover + case Invitation.InvitationType.TEAM_TO_MEMBER | Invitation.InvitationType.MEMBER_TO_TEAM: + return InvitationTeamForm + case '_': # pragma: no cover raise NotImplementedError('Invalid invitation type') def get_form_kwargs(self) -> dict[str, Any]: - return { + kwargs = { **super().get_form_kwargs(), 'conference': self.conference, 'user': self.request.user, 'requester_id': self.kwargs.get('requester_id'), + 'requested_id': self.kwargs.get('requested_id'), } + if self.invitation_type in [Invitation.InvitationType.TEAM_TO_MEMBER, Invitation.InvitationType.MEMBER_TO_TEAM]: + kwargs['from_team'] = self.invitation_type == Invitation.InvitationType.TEAM_TO_MEMBER + return kwargs def form_valid(self, form): response = super().form_valid(form) @@ -66,6 +78,21 @@ class InvitationCreateView(ConferenceLoginRequiredMixin, RedirectURLMixin, Creat 'invitation__type': _('Invitation__type__habitat'), } ) + + case Invitation.InvitationType.TEAM_TO_MEMBER: + context.update( + { + 'introduction_text': _('Invitation__introduction__team'), + 'invitation__type': _('Invitation__type__team_to_member'), + } + ) + case Invitation.InvitationType.MEMBER_TO_TEAM: + context.update( + { + 'introduction_text': _('Invitation__introduction__team'), + 'invitation__type': _('Invitation__type__member_to_team'), + } + ) case _: # pragma: no cover raise NotImplementedError('Invalid invitation type') return context diff --git a/src/backoffice/views/invitations/update.py b/src/backoffice/views/invitations/update.py index 8cf975a21..a80312080 100644 --- a/src/backoffice/views/invitations/update.py +++ b/src/backoffice/views/invitations/update.py @@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy as _ from django.views.generic import UpdateView, View from django.views.generic.detail import SingleObjectMixin -from core.forms import InvitationHabitatForm +from core.forms import InvitationHabitatForm, InvitationTeamForm from core.models import Invitation from backoffice.views.mixins import ConferenceLoginRequiredMixin, ConferenceRuleLoginRequiredMixin @@ -33,6 +33,8 @@ class InvitationUpdateView(ConferenceRuleLoginRequiredMixin, UpdateView): match invitation.type: case Invitation.InvitationType.HABITAT: return InvitationHabitatForm + case Invitation.InvitationType.TEAM_TO_MEMBER | Invitation.InvitationType.MEMBER_TO_TEAM: + return InvitationTeamForm case _: # pragma: no cover raise NotImplementedError('Invalid invitation type') @@ -48,6 +50,20 @@ class InvitationUpdateView(ConferenceRuleLoginRequiredMixin, UpdateView): 'invitation__type': _('Invitation__type__habitat'), } ) + case Invitation.InvitationType.TEAM_TO_MEMBER: + context.update( + { + 'invitation__type': _('Invitation__type__team_to_member'), + 'introduction_text': _('Invitation__introduction__team'), + } + ) + case Invitation.InvitationType.MEMBER_TO_TEAM: + context.update( + { + 'invitation__type': _('Invitation__type__member_to_team'), + 'introduction_text': _('Invitation__introduction__team'), + } + ) case _: # pragma: no cover raise NotImplementedError('Invalid invitation type') return context diff --git a/src/backoffice/views/teams/teams.py b/src/backoffice/views/teams/teams.py index d9a4abe6f..474a2a613 100644 --- a/src/backoffice/views/teams/teams.py +++ b/src/backoffice/views/teams/teams.py @@ -11,7 +11,7 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView from rules.contrib.views import AutoPermissionRequiredMixin from core.forms import TeamForm -from core.models import ConferenceMember, Team +from core.models import ConferenceMember, Invitation, Team from core.views.mixins import FormMesssageMixin from backoffice.views.mixins import ConferenceRuleLoginRequiredMixin, SingleUUIDObjectMixin, guess_active_sidebar_item @@ -107,6 +107,8 @@ class TeamDetailView(SingleUUIDObjectMixin, TeamNavContextMixin, AutoPermissionR return { **super().get_context_data(**kwargs), 'member_id': member_id, + 'received_invitations': team.received_invitations.filter(state=Invitation.RequestsState.REQUESTED), + 'sent_invitations': team.sent_invitations.filter(state=Invitation.RequestsState.REQUESTED), } diff --git a/src/core/forms/__init__.py b/src/core/forms/__init__.py index caa5a9cce..10852a02e 100644 --- a/src/core/forms/__init__.py +++ b/src/core/forms/__init__.py @@ -1,6 +1,6 @@ from core.forms.authentication import LoginForm, PasswordResetForm, RegistrationForm from core.forms.conferences import ConferencePublicationForm, ConferenceRegistrationForm -from core.forms.invitations import InvitationHabitatForm +from core.forms.invitations import InvitationHabitatForm, InvitationTeamForm from core.forms.links import LinkForm, LinkFormSet from core.forms.projects import ProjectForm from core.forms.teams import TeamForm @@ -9,6 +9,7 @@ __all__ = [ 'ConferencePublicationForm', 'ConferenceRegistrationForm', 'InvitationHabitatForm', + 'InvitationTeamForm', 'LinkForm', 'LinkFormSet', 'LoginForm', diff --git a/src/core/forms/invitations.py b/src/core/forms/invitations.py index df5d51ac2..fc6225e68 100644 --- a/src/core/forms/invitations.py +++ b/src/core/forms/invitations.py @@ -1,12 +1,21 @@ +from contextlib import suppress +from uuid import UUID + from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db.models import QuerySet -from django.forms import ModelForm, Select +from django.forms import HiddenInput, ModelForm, Select from django.shortcuts import get_object_or_404 from django.urls import reverse_lazy from django.utils.translation import gettext_lazy as _ -from core.models import Assembly, Conference, Invitation, PlatformUser +from core.models import ( + Assembly, + Conference, + Invitation, + PlatformUser, + Team, +) class InvitationBaseForm(ModelForm): @@ -20,6 +29,7 @@ class InvitationBaseForm(ModelForm): requester_id: str | None = None requester_link: str | None = None + requested_id: str | None = None requested_link: str | None = None def __init__( @@ -29,6 +39,7 @@ class InvitationBaseForm(ModelForm): user: PlatformUser, instance: Invitation | None, requester_id: str | None = None, + requested_id: str | None = None, **kwargs, ): kwargs['initial'] = kwargs.get('initial', {}) @@ -41,16 +52,26 @@ class InvitationBaseForm(ModelForm): if not requester_id: # pragma: no cover raise ValueError('Requester ID is required') self.requester_id = str(requester_id) + self.requested_id = str(requested_id) if requested_id else None else: self.requester_id = str(instance.requester_id) + self.requested_id = str(instance.requested_id) kwargs['initial']['requester_id'] = self.requester_id if 'data' in kwargs: kwargs['data'] = kwargs['data'].copy() kwargs['data']['requester_id'] = self.requester_id + if self.requested_id is not None: + kwargs['initial']['requested_id'] = self.requested_id + if 'data' in kwargs: + kwargs['data'] = kwargs['data'].copy() + kwargs['data']['requested_id'] = self.requested_id super().__init__(*args, instance=instance, **kwargs) + # self.fields is only available after super().__init__ is called self.fields['requester_id'].widget.attrs['disabled'] = True + if self.requested_id is not None: + self.fields['requested_id'].widget.attrs['disabled'] = True if instance: self.permissions = { 'requester': self.user.has_perm('core.change_invitation', self.instance), @@ -86,8 +107,6 @@ class InvitationHabitatForm(InvitationBaseForm): 'requested_id': Select(), } - requester_id: str | None = None - def get_queryset(self) -> QuerySet[Assembly]: return Assembly.objects.associated_with_user(conference=self.conference, user=self.user, show_public=True, staff_can_see=False) @@ -120,3 +139,91 @@ class InvitationHabitatForm(InvitationBaseForm): if commit: # pragma: no branch invitation.save() return invitation + + +class InvitationTeamForm(InvitationBaseForm): + class Meta(InvitationBaseForm.Meta): + widgets = { + 'requester_id': HiddenInput(), + } + + invitation_team: Team | None = None + invitation_user: PlatformUser | None = None + + def __init__(self, *args, instance: Invitation | None, from_team: bool = False, **kwargs): + from core.templatetags.hub_absolute import hub_absolute # pylint: disable=import-outside-toplevel + + super().__init__(*args, instance=instance, **kwargs) + if instance: # We are not in a create form + self.requester_id = str(instance.requester.uuid) + self.requested_id = str(instance.requested.uuid) + from_team = instance.requester_type.model == 'team' + self.invitation_team = instance.requester if from_team else instance.requested + self.invitation_user = instance.requested if from_team else instance.requester + assert self.invitation_team is not None + assert self.invitation_user is not None + elif from_team: + self.invitation_team = get_object_or_404(Team, uuid=self.requester_id) + if self.requested_id: + self.invitation_user = get_object_or_404(PlatformUser, uuid=self.requested_id) + else: + self.invitation_user = get_object_or_404(PlatformUser, uuid=self.requester_id) + if self.requested_id: + self.invitation_team = get_object_or_404(Team, uuid=self.requested_id) + self.from_team = from_team + + self.fields['requester_id'].label = _('Team') if from_team else _('TeamMember') + self.fields['requester_id'].widget = HiddenInput() + self.requester_text = self.invitation_team.name if from_team else self.invitation_user.name + self.requester_link = ( + reverse_lazy('backoffice:team', kwargs={'uuid': self.invitation_team.uuid}) + if from_team + else hub_absolute('plainui:user', user_slug=self.invitation_user.slug) + ) + + self.fields['requested_id'].label = _('TeamMember') if from_team else _('Team') + if self.requested_id: + self.fields['requested_id'].widget = HiddenInput() + self.requested_text = self.invitation_user.name if from_team else self.invitation_team.name + self.requested_link = ( + hub_absolute('plainui:user', user_slug=self.invitation_user.slug) + if from_team + else reverse_lazy('backoffice:team', kwargs={'uuid': self.invitation_team.uuid}) + ) + + def clean_requested_id(self): + requested_query = self.cleaned_data['requested_id'] + + requested_id = None + + with suppress(ValueError): + requested_id = get_object_or_404(PlatformUser if self.from_team else Team, uuid=UUID(requested_query, version=4)).pk + + if not requested_id: + with suppress(ValueError): + requested_id = int(requested_query) + + if not requested_id and self.from_team: + requested_id = get_object_or_404(PlatformUser, username__iexact=requested_query).pk + if not requested_id: + requested_id = get_object_or_404(Team, name__iexact=requested_query).pk + + self.cleaned_data['requested_id'] = requested_id + return super().clean_requested_id() + + def clean_requester_id(self): + self.cleaned_data['requester_id'] = self.invitation_team.pk if self.from_team else self.user.pk + return super().clean_requester_id() + + def save(self, commit: bool = True) -> Invitation: + invitation = super().save(commit=False) + types = ContentType.objects.get_for_models(Team, PlatformUser) + + invitation.type = Invitation.InvitationType.TEAM_TO_MEMBER if self.from_team else Invitation.InvitationType.MEMBER_TO_TEAM + invitation.requester_type = types[Team] if self.from_team else types[PlatformUser] + invitation.requested_type = types[PlatformUser] if self.from_team else types[Team] + if self.requested_id: + invitation.requested_id = get_object_or_404(PlatformUser if self.from_team else Team, uuid=self.requested_id).pk + if commit: # pragma: no branch + invitation.save() + return invitation diff --git a/src/core/locale/de/LC_MESSAGES/django.po b/src/core/locale/de/LC_MESSAGES/django.po index 3dd78c958..71c1fc2aa 100644 --- a/src/core/locale/de/LC_MESSAGES/django.po +++ b/src/core/locale/de/LC_MESSAGES/django.po @@ -146,6 +146,12 @@ msgstr "Eine Einladung kann nicht nachträglich geändert werden" msgid "Assembly__parent" msgstr "Habitat" +msgid "Team" +msgstr "" + +msgid "TeamMember" +msgstr "Teammitglied" + msgid "Tags" msgstr "" @@ -1981,9 +1987,6 @@ msgstr "erklärender Begleittext des Tags" msgid "ConferenceTag__description" msgstr "Beschreibung" -msgid "TeamMember" -msgstr "Teammitglied" - msgid "TeamMembers" msgstr "Teammitglieder" @@ -2005,9 +2008,6 @@ msgstr "Dieses Mitglied ist das letzte mit Verwaltungsrechten, sie können nicht msgid "TeamMember__delete__cannot_delete_last_manager" msgstr "Du kannst dieses Mitglied nicht entfernen, da es das letzte Mitglied mit Verwaltungsrechten ist." -msgid "Team" -msgstr "" - msgid "Teams" msgstr "" diff --git a/src/core/locale/en/LC_MESSAGES/django.po b/src/core/locale/en/LC_MESSAGES/django.po index be9a71840..bf783a463 100644 --- a/src/core/locale/en/LC_MESSAGES/django.po +++ b/src/core/locale/en/LC_MESSAGES/django.po @@ -146,6 +146,12 @@ msgstr "An invitation cannot be changed retrospectively" msgid "Assembly__parent" msgstr "parent" +msgid "Team" +msgstr "" + +msgid "TeamMember" +msgstr "Team member" + msgid "Tags" msgstr "" @@ -1979,9 +1985,6 @@ msgstr "additional explanation of this tag" msgid "ConferenceTag__description" msgstr "description" -msgid "TeamMember" -msgstr "Team member" - msgid "TeamMembers" msgstr "Team members" @@ -2003,9 +2006,6 @@ msgstr "You cannot remove this members rights, as they are the last manager of t msgid "TeamMember__delete__cannot_delete_last_manager" msgstr "You cannot remove this member, as it is the last manager of the team" -msgid "Team" -msgstr "" - msgid "Teams" msgstr "" diff --git a/src/core/models/invitation.py b/src/core/models/invitation.py index 0f57555e0..72b9e61e0 100644 --- a/src/core/models/invitation.py +++ b/src/core/models/invitation.py @@ -27,6 +27,10 @@ def is_requester(user: 'PlatformUser', invitation: 'Invitation | None' = None) - match invitation.type: case Invitation.InvitationType.HABITAT: return user.has_perm('core.change_assembly', invitation.requester) + case Invitation.InvitationType.TEAM_TO_MEMBER: + return user.has_perm('core.change_team', invitation.requester) + case Invitation.InvitationType.MEMBER_TO_TEAM: + return invitation.requester == user case _: # pragma: no cover raise NotImplementedError @@ -38,6 +42,10 @@ def is_requested(user: 'PlatformUser', invitation: 'Invitation | None' = None) - match invitation.type: case Invitation.InvitationType.HABITAT: return user.has_perm('core.change_assembly', invitation.requested) + case Invitation.InvitationType.TEAM_TO_MEMBER: + return invitation.requested == user + case Invitation.InvitationType.MEMBER_TO_TEAM: + return user.has_perm('core.change_team', invitation.requested) case _: # pragma: no cover raise NotImplementedError @@ -234,6 +242,8 @@ class Invitation(RulesModel): match self.type: case InvitationType.HABITAT: self.accept_habitat() + case InvitationType.TEAM_TO_MEMBER | InvitationType.MEMBER_TO_TEAM: + self.accept_team_member() case _: # pragma: no cover raise NotImplementedError @@ -269,6 +279,32 @@ class Invitation(RulesModel): children=ActivityLogChange(old=old_children, new=', '.join(habitat.children.all().values_list('name', flat=True))), ) + def accept_team_member(self): + """Accept the invitation for a joining a team. + + Raises: + ValueError: I raised when the requested or requester type is not a team. + """ + from core.models import PlatformUser, Team, TeamMember # pylint: disable=import-outside-toplevel + + from_team = self.type == self.InvitationType.TEAM_TO_MEMBER + + team = self.requester if from_team else self.requested + user = self.requested if from_team else self.requester + + if not PlatformUser.type_is(user): # pragma: no cover + raise ValueError('Invalid object type for User') + if not Team.type_is(team): # pragma: no cover + raise ValueError('Invalid object type for Team') + old_members = ', '.join(team.members.all().values_list('user__display_name', flat=True)) + old_count = str(team.members.count()) + TeamMember.objects.create(team=team, user=user) + team.log_activity( + self.decision_by, + members=ActivityLogChange(old=old_members, new=', '.join(team.members.all().values_list('user__display_name', flat=True))), + members_count=ActivityLogChange(old=old_count, new=str(team.members.count())), + ) + def reject(self, user: 'PlatformUser'): """Reject the invitation. -- GitLab