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