diff --git a/src/backoffice/locale/de/LC_MESSAGES/django.po b/src/backoffice/locale/de/LC_MESSAGES/django.po index c6356e8bfa7ce7626b78581c5c1f646cf56f67b5..68c1394bb21d2ec34bd6696e897eea7f15a929c9 100644 --- a/src/backoffice/locale/de/LC_MESSAGES/django.po +++ b/src/backoffice/locale/de/LC_MESSAGES/django.po @@ -98,43 +98,6 @@ msgstr "Private FTP-Zugang um Dateien im Hangar abzulegen" msgid "assembly_children" msgstr "Zugeordnete Assemblies" -msgid "Assembly__invitation_pending__title" -msgstr "Ausstehende Einladungen" - -#, python-format -msgid "Assembly__invitation__habitat_assembly %(habitat)s" -msgstr "Das Habitat \"%(habitat)s\" lädt euch ein beizutreten." - -#, python-format -msgid "Assembly__invitation__assembly_habitat %(assembly)s" -msgstr "Das Assembly \"%(assembly)s\" möchte eurem Habitat beitreten." - -msgid "Invitation__Habitat__action__detail" -msgstr "Details" - -msgid "Invitation__Habitat__action__accept" -msgstr "Akzeptieren" - -msgid "Invitation__Habitat__action__reject" -msgstr "Ablehnen" - -msgid "Assembly__invitation_sent__title" -msgstr "Versendete Einladungen" - -#, python-format -msgid "Assembly__invitation_waiting__assembly_habitat %(habitat)s" -msgstr "Ihr hab angefragt dem Habitat \"%(habitat)s\" beizutreten." - -#, python-format -msgid "Assembly__invitation_waiting__habitat_assembly %(assembly)s" -msgstr "Das Assembly \"%(assembly)s\" wurde eingeladen dem Habitat beizutreten." - -msgid "Invitation__Habitat__action__edit" -msgstr "Bearbeiten" - -msgid "Invitation__Habitat__action__withdraw" -msgstr "Zurückziehen" - msgid "Assembly__additional_field__required" msgstr "Dieses Feld darf nicht leer bleiben." @@ -389,6 +352,12 @@ msgstr "Habitat verlassen" msgid "assembly__parent__request" msgstr "Antrag auf Beitritt zu einem Habitat" +msgid "Invitation__list_pending__title" +msgstr "Ausstehende Einladungen" + +msgid "Invitation__list_sent__title" +msgstr "Versendete Einladungen" + msgid "Assembly__edit__base_data" msgstr "Allgemeine Angaben" @@ -801,6 +770,10 @@ msgstr "Wiki" msgid "nav_schedules" msgstr "Schedules" +# use translation from core +msgid "Invitations" +msgstr "" + msgid "Conference__Selection" msgstr "Konferenz auswählen" @@ -843,6 +816,25 @@ msgstr "Speichern" msgid "ActivityLog_no_entries" msgstr "Keine Activity Log Einträge" +#, python-format +msgid "Invitation__description %(requester)s %(requested)s %(type)s" +msgstr "Die Einladung von \"%(requester)s\" an \"%(requested)s\" (Art: %(type)s)" + +msgid "Invitation__action__edit" +msgstr "Bearbeiten" + +msgid "Invitation__action__withdraw" +msgstr "Zurückziehen" + +msgid "Invitation__action__detail" +msgstr "Details" + +msgid "Invitation__action__accept" +msgstr "Akzeptieren" + +msgid "Invitation__action__reject" +msgstr "Ablehnen" + msgid "create" msgstr "Erstellen" @@ -952,20 +944,17 @@ msgstr "Registrierung nicht mehr möglich" msgid "welcome_text" msgstr "Hier können Assemblies ihre Mitglieder, Veranstaltungen und Räume pflegen. Bitte einloggen." -msgid "Invitation__Habitat__type" -msgstr "Habitatseinladung" +msgid "Invitation__action__submit" +msgstr "Einladung senden" -msgid "Invitation__Habitat__title" -msgstr "Einladung zum Habitatsbeitritt" - -msgid "Invitation__Habitat__introduction" -msgstr "Mit dieser Einladung wird eine Aufnahme des Assemblies in das Habitat angefordert. Wenn die Einladung angenommen wird, wird das Assembly dem Habitat zugeordnet." +msgid "Invitation__action__update" +msgstr "Aktualisieren" -msgid "Invitation__Habitat__action__submit" -msgstr "Einladung absenden" +msgid "No comment" +msgstr "" -msgid "Invitation__Habitat__action__update" -msgstr "Aktualisieren" +msgid "Invitations__list__title" +msgstr "Ausstehende Einladungen" msgid "Links" msgstr "Links" @@ -1903,6 +1892,16 @@ msgstr "Konfigurationsfehler: Self-organized Sessions können noch nicht angeleg msgid "SoS__removed %(event_name)s" msgstr "Self-organized Session %(event_name)s gelöscht" +msgid "Invitation__title__habitat" +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__habitat" +msgstr "" + #, python-format msgid "Invitation__updated__message %(requester)s %(requested)s" msgstr "Die Einladung von \"%(requester)s\" an \"%(requested)s\" ist aktualisiert worden" diff --git a/src/backoffice/locale/en/LC_MESSAGES/django.po b/src/backoffice/locale/en/LC_MESSAGES/django.po index 5d4a4a19560b9bf022492fc1c350263d213b93ca..1d48e35e430fd81c4d66d7cb3a56897d19109fb9 100644 --- a/src/backoffice/locale/en/LC_MESSAGES/django.po +++ b/src/backoffice/locale/en/LC_MESSAGES/django.po @@ -98,43 +98,6 @@ msgstr "your private ftp access to put files on your hangar" msgid "assembly_children" msgstr "Grouped Assemblies" -msgid "Assembly__invitation_pending__title" -msgstr "pending invitations" - -#, python-format -msgid "Assembly__invitation__habitat_assembly %(habitat)s" -msgstr "The habitat \"%(habitat)s\" invites you to join." - -#, python-format -msgid "Assembly__invitation__assembly_habitat %(assembly)s" -msgstr "The assembly \"%(assembly)s\" would like to join your habitat." - -msgid "Invitation__Habitat__action__detail" -msgstr "Details" - -msgid "Invitation__Habitat__action__accept" -msgstr "Accept" - -msgid "Invitation__Habitat__action__reject" -msgstr "Reject" - -msgid "Assembly__invitation_sent__title" -msgstr "sent invitations" - -#, python-format -msgid "Assembly__invitation_waiting__assembly_habitat %(habitat)s" -msgstr "You have requested to join the habitat \"%(habitat)s\"." - -#, python-format -msgid "Assembly__invitation_waiting__habitat_assembly %(assembly)s" -msgstr "The assembly \"%(assembly)s\" was invited to join the habitat." - -msgid "Invitation__Habitat__action__edit" -msgstr "Edit" - -msgid "Invitation__Habitat__action__withdraw" -msgstr "Withdraw" - msgid "Assembly__additional_field__required" msgstr "This field must not be empty." @@ -389,6 +352,12 @@ msgstr "leave habitat" msgid "assembly__parent__request" msgstr "request to join habitat" +msgid "Invitation__list_pending__title" +msgstr "Pending invitations" + +msgid "Invitation__list_sent__title" +msgstr "Sent invitations" + msgid "Assembly__edit__base_data" msgstr "Basic data" @@ -801,6 +770,10 @@ msgstr "Wiki" msgid "nav_schedules" msgstr "Schedules" +# use translation from core +msgid "Invitations" +msgstr "" + msgid "Conference__Selection" msgstr "Select Conference" @@ -843,6 +816,25 @@ msgstr "save" msgid "ActivityLog_no_entries" msgstr "No activity log entries" +#, python-format +msgid "Invitation__description %(requester)s %(requested)s %(type)s" +msgstr "Invitation from \"%(requester)s\" to \"%(requested)s\" (type: %(type)s)" + +msgid "Invitation__action__edit" +msgstr "Edit" + +msgid "Invitation__action__withdraw" +msgstr "Withdraw" + +msgid "Invitation__action__detail" +msgstr "Details" + +msgid "Invitation__action__accept" +msgstr "Accept" + +msgid "Invitation__action__reject" +msgstr "Reject" + msgid "create" msgstr "" @@ -953,21 +945,18 @@ msgstr "registration deadline exceeded" msgid "welcome_text" msgstr "Here, assemblies can manage their members, events and rooms. Please login." -msgid "Invitation__Habitat__type" -msgstr "Habitat invitation" - -msgid "Invitation__Habitat__title" -msgstr "Invitation to join a habitat" - -msgid "Invitation__Habitat__introduction" -msgstr "This invitation requests that the assembly be included in the habitat. If the invitation is accepted, the assembly is assigned to the habitat." - -msgid "Invitation__Habitat__action__submit" +msgid "Invitation__action__submit" msgstr "Send invitation" -msgid "Invitation__Habitat__action__update" +msgid "Invitation__action__update" msgstr "Update" +msgid "No comment" +msgstr "" + +msgid "Invitations__list__title" +msgstr "Open invitations" + msgid "Links" msgstr "Links" @@ -1908,6 +1897,16 @@ msgstr "Configuration Error: Self-organized sessions cannot be created yet!" msgid "SoS__removed %(event_name)s" msgstr "Self-organized Session %(event_name)s deleted" +msgid "Invitation__title__habitat" +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__habitat" +msgstr "" + #, python-format msgid "Invitation__updated__message %(requester)s %(requested)s" msgstr "The invitation from \"%(requester)s\" to \"%(requested)s\" has been updated" diff --git a/src/backoffice/templates/backoffice/assemblies/components/invitations.html b/src/backoffice/templates/backoffice/assemblies/components/invitations.html deleted file mode 100644 index eeb93defead58aee3e284d19fe663ffb69735dec..0000000000000000000000000000000000000000 --- a/src/backoffice/templates/backoffice/assemblies/components/invitations.html +++ /dev/null @@ -1,84 +0,0 @@ -{% load i18n %} - -{% if received_invitations %} - <div class="row mt-3"> - <div class="col-md-12"> - <div class="card border-primary"> - <div class="card-header text-bg-primary">{% trans "Assembly__invitation_pending__title" %}</div> - <ul class="list-group"> - {% for invitation in received_invitations %} - <li class="list-group-item"> - {% if invitation.type == "HABITAT" and not assembly.is_cluster %} - {% blocktrans with habitat=invitation.requester.name %}Assembly__invitation__habitat_assembly {{habitat}}{% endblocktrans %} - {% elif invitation.type == "HABITAT" and assembly.is_cluster %} - {% blocktrans with assembly=invitation.requester.name %}Assembly__invitation__assembly_habitat {{assembly}}{% endblocktrans %} - {% else %} - {{ invitation.requester }} - {% endif %} - <span class="float-end"> - <form method='post' - action="{% url "backoffice:invitation" pk=invitation.pk %}"> - {% csrf_token %} - <input type="hidden" - name="next" - value="{% url "backoffice:assembly" pk=assembly.pk %}"> - <a type="submit" - class="btn btn-primary btn-sm" - href="{% url "backoffice:invitation" pk=invitation.pk %}">{% trans "Invitation__Habitat__action__detail" %}</a> - <button type="submit" - name="action" - value="accept" - class="btn btn-success btn-sm">{% trans "Invitation__Habitat__action__accept" %}</button> - <button type="submit" - name="action" - value="reject" - class="btn btn-danger btn-sm">{% trans "Invitation__Habitat__action__reject" %}</button> - , - </form> - </span> - </li> - {% endfor %} - </ul> - </div> - </div> - </div> -{% endif %} -{% if sent_invitations %} - <div class="row mt-3"> - <div class="col-md-12"> - <div class="card"> - <div class="card-header">{% trans "Assembly__invitation_sent__title" %}</div> - <ul class="list-group"> - {% for invitation in sent_invitations %} - <li class="list-group-item"> - {% if invitation.type == "HABITAT" and not assembly.is_cluster %} - {% blocktrans with habitat=invitation.requested.name %}Assembly__invitation_waiting__assembly_habitat {{habitat}}{% endblocktrans %} - {% elif invitation.type == "HABITAT" and assembly.is_cluster %} - {% blocktrans with assembly=invitation.requested.name %}Assembly__invitation_waiting__habitat_assembly {{assembly}}{% endblocktrans %} - {% else %} - {{ invitation.requester }} - {% endif %} - <span class="float-end"> - <form method='post' - action="{% url "backoffice:invitation" pk=invitation.pk %}"> - {% csrf_token %} - <input type="hidden" - name="next" - value="{% url "backoffice:assembly" pk=assembly.pk %}"> - <a class="btn btn-primary btn-sm" - href="{% url "backoffice:invitation" pk=invitation.pk %}">{% trans "Invitation__Habitat__action__edit" %}</a> - <button type="submit" - name="action" - value="withdraw" - class="btn btn-outline-secondary btn-sm"> - {% trans "Invitation__Habitat__action__withdraw" %} - </button> - </form> - </span> - </li> - {% endfor %} - </ul> - </div> - </div> - </div> -{% endif %} diff --git a/src/backoffice/templates/backoffice/assembly_detail.html b/src/backoffice/templates/backoffice/assembly_detail.html index 3e7357cb70da91697b85637c56aca7ac03d139f2..365e871f3afd948be7bc9074f4dffbb2bd3b64d1 100644 --- a/src/backoffice/templates/backoffice/assembly_detail.html +++ b/src/backoffice/templates/backoffice/assembly_detail.html @@ -1,6 +1,7 @@ {% extends "backoffice/base.html" %} {% load static %} {% load i18n %} +{% load rules %} {% block title %} {{ assembly.name }} | {{ conference.name }} @@ -33,6 +34,7 @@ $(document).ready(() => { </script> {% endblock scripts %} {% block content %} + {% has_perm 'core.change_assembly' request.user assembly as can_change %} <div class="row"> <div class="col-md-10"> <div class="card border-primary"> @@ -191,7 +193,7 @@ $(document).ready(() => { </button> </form> {% else %} - <a href="{% url "backoffice:invitation-send-with-pk" type="habitat" requester_id=assembly.pk %}" + <a href="{% url "backoffice:invitation-send" type="habitat" requester_id=assembly.pk %}" class="btn btn-primary float-end">{% trans "assembly__parent__request" %}</a> {% endif %} </div> @@ -201,5 +203,17 @@ $(document).ready(() => { {% else %} {% include "backoffice/assemblies/components/child_assemblies.html" %} {% endif %} - {% include "backoffice/assemblies/components/invitations.html" %} + {% if received_invitations %} + {% if assembly.received_invitations and can_change %} + {% trans "Invitation__list_pending__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 %} + {% endif %} + + {% if sent_invitations %} + {% if assembly.sent_invitations and can_change %} + {% trans "Invitation__list_sent__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 %} + {% endif %} {% endblock content %} diff --git a/src/backoffice/templates/backoffice/assembly_editchildren.html b/src/backoffice/templates/backoffice/assembly_editchildren.html index f184d8711c120d3ece663c3201eb1ce72f4a9916..9f582aad9012e1e020efcd8a8330229c2871cfdb 100644 --- a/src/backoffice/templates/backoffice/assembly_editchildren.html +++ b/src/backoffice/templates/backoffice/assembly_editchildren.html @@ -1,9 +1,10 @@ {% extends "backoffice/base.html" %} {% load i18n %} {% load static %} -{% load widget_tweaks %} +{% load rules %} {% block content %} + {% has_perm 'core.change_assembly' request.user assembly as can_change %} {% include "backoffice/assembly_edit_header.html" %} {% if form.errors %} @@ -20,7 +21,7 @@ <div class="card-header">{% trans "Assembly__edit_children__invite_assembly__title" %}</div> <div class="card-body"> <p>{% trans "Assembly__edit_children__invite_assembly__introduction" %}</p> - <form action="{% url "backoffice:invitation-send-with-pk" type="habitat" requester_id=assembly.id %}" + <form action="{% url "backoffice:invitation-send" type="habitat" requester_id=assembly.id %}" method="post"> {% csrf_token %} <input type="hidden" @@ -40,6 +41,19 @@ </div> {% include "backoffice/assemblies/components/child_assemblies.html" %} - {% include "backoffice/assemblies/components/invitations.html" %} + + {% if received_invitations %} + {% if assembly.received_invitations and can_change %} + {% trans "Invitation__list_pending__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 %} + {% endif %} + + {% if sent_invitations %} + {% if assembly.sent_invitations and can_change %} + {% trans "Invitation__list_sent__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 %} + {% endif %} {% endblock content %} diff --git a/src/backoffice/templates/backoffice/base.html b/src/backoffice/templates/backoffice/base.html index ac043b9518d8220c396d5a05acca341ff2ed31d4..bc7ac4bce8883bbec2435707e06886584895d1c2 100644 --- a/src/backoffice/templates/backoffice/base.html +++ b/src/backoffice/templates/backoffice/base.html @@ -120,6 +120,12 @@ </a> </li> {% endif %} + <li class="nav-item"> + <a class="nav-link{% if active_page == 'invitations' %} active{% endif %}" + href="{% url 'backoffice:invitations' %}">{% trans "Invitations" %} + {% if active_page == 'invitations' %}<span class="visually-hidden">{{ activetab_srmarker }}</span>{% endif %} + </a> + </li> </ul> <ul class="navbar-nav"> {% if conferences|length > 1 or active_page == 'conferences' %} diff --git a/src/backoffice/templates/backoffice/components/invitations_list.html b/src/backoffice/templates/backoffice/components/invitations_list.html new file mode 100644 index 0000000000000000000000000000000000000000..89b74e928c3a35810b2d0438cd8519eea44466c2 --- /dev/null +++ b/src/backoffice/templates/backoffice/components/invitations_list.html @@ -0,0 +1,49 @@ +{% load rules %} +{% load i18n %} + +<div class="card {{ border_class }} mt-3"> + <div class="card-header">{{ list_title }}</div> + <ul class="list-group"> + {% with requester_view=requester_view|default:False requested_view=requested_view|default:False %} + {% for invitation in invitations %} + {% has_perm 'core.change_invitation' request.user invitation as can_change %} + {% has_perm 'core.accept_invitation' request.user invitation as can_accept %} + {% if can_change or can_accept %} + <li class="list-group-item"> + {% blocktrans with requester=invitation.requester.name requested=invitation.requested.name type=invitation.get_type_display %}Invitation__description {{ requester }} {{ requested }} {{ type }}{% endblocktrans %} + <span class="float-end"> + <form method='post' + action="{% url "backoffice:invitation-decision" pk=invitation.pk %}"> + {% csrf_token %} + <input type="hidden" name="next" value="{{ request.path }}"> + + {% if can_change and requester_view %} + <a class="btn btn-primary btn-sm" + href="{% url "backoffice:invitation-edit" pk=invitation.pk %}">{% trans "Invitation__action__edit" %}</a> + <button type="submit" + name="action" + value="withdraw" + class="btn btn-outline-secondary btn-sm">{% trans "Invitation__action__withdraw" %}</button> + {% else %} + <a type="submit" + class="btn btn-primary btn-sm" + href="{% url "backoffice:invitation-detail" pk=invitation.pk %}">{% trans "Invitation__action__detail" %}</a> + {% endif %} + {% if can_accept and requested_view %} + <button type="submit" + name="action" + value="accept" + class="btn btn-success btn-sm">{% trans "Invitation__action__accept" %}</button> + <button type="submit" + name="action" + value="reject" + class="btn btn-danger btn-sm">{% trans "Invitation__action__reject" %}</button> + {% endif %} + </form> + </span> + </li> + {% endif %} + {% endfor %} + {% endwith %} + </ul> +</div> diff --git a/src/backoffice/templates/backoffice/invitations/components/create_edit_form.html b/src/backoffice/templates/backoffice/invitations/components/create_edit_form.html deleted file mode 100644 index ba7ab041955203c8506e2d7d2df0092ecefb0d22..0000000000000000000000000000000000000000 --- a/src/backoffice/templates/backoffice/invitations/components/create_edit_form.html +++ /dev/null @@ -1,160 +0,0 @@ - -{% load i18n %} -{% load django_bootstrap5 %} -{% load verbose_name %} -<form method="post"> - {% csrf_token %} - <div class="card"> - <div class="card-header"> - {{ title_text }} - {% if form.instance.state == "requested" %} - <span class="badge float-end bg-secondary">requested</span> - {% elif form.instance.state == "accepted" %} - <span class="badge float-end bg-success">accepted</span> - {% elif form.instance.state == "rejected" %} - <span class="badge float-end bg-danger">rejected</span> - {% elif form.instance.state == "withdrawn" %} - <span class="badge float-end bg-warning">withdrawn</span> - {% endif %} - </div> - <div class="card-body"> - <div class="row"> - <div class="col-md-12"> - <p>{{ introduction_text }}</p> - </div> - </div> - {% if form.non_field_errors %} - <div class="row"> - <div class="col-md-12"> - <div class="alert border-danger"> - <h4 class="alert-heading">Errors</h4> - <ul class="list-group"> - {% for error in form.non_field_errors %} - <li class="list-group-item list-group-item-danger">{{ error }}</li> - {% endfor %} - </ul> - </div> - </div> - </div> - {% 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> - <div class="row"> - <div class="col-md-12">{% bootstrap_field form.comment %}</div> - </div> - {% else %} - <div class="row"> - <label for="requester" class="col-md-1 col-form-label">{{ form.requester_label }}</label> - <div class="col-md-5"> - {% if form.permissions.requester %} - <a id="requester" - class="form-control-plaintext" - href="{% hub_absolute "backoffice:assembly" pk=form.instance.requester.pk %}">{{ form.instance.requester.name }}</a> - {% else %} - <span id="requester" class="form-control-plaintext">{{ form.instance.requester.name }}</span> - {% endif %} - <input type="hidden" - name="requester_id" - value="{{ form.instance.requester.pk }}"> - </div> - <label for="requester" class="col-md-1 col-form-label">{{ form.requested_label }}</label> - <div class="col-md-5"> - {% if form.permissions.requested %} - <a id="requested" - class="form-control-plaintext" - href="{% hub_absolute "backoffice:assembly" pk=form.instance.requested.pk %}">{{ form.instance.requested.name }}</a> - {% else %} - <span id="requested" class="form-control-plaintext">{{ form.instance.requested.name }}</span> - {% endif %} - <input type="hidden" - name="requested_id" - value="{{ form.instance.requested.pk }}"> - </div> - </div> - <div class="row"> - <label for="requester" class="col-md-1 col-form-label">{{ form.instance|verbose_name:"requested_at" }}</label> - <div class="col-md-5"> - <input type="text" - name="requester" - id="requester" - class="form-control-plaintext" - value="{{ form.instance.requested_at }}"> - </div> - <label for="requested" class="col-md-1 col-form-label">{{ form.instance|verbose_name:"updated_at" }}</label> - <div class="col-md-5"> - <input type="text" - name="requested" - id="requested" - class="form-control-plaintext" - value="{{ form.instance.updated_at }}"> - </div> - </div> - <div class="row"> - <label for="sender" class="col-md-2 col-form-label">{{ invitation|verbose_name:"sender" }}</label> - <div class="col-md-4"> - <input type="text" - id="sender" - class="form-control-plaintext" - value="{{ invitation.sender }}"> - </div> - {% if invitation.decision_by %} - <label for="decision_by" class="col-md-2 col-form-label">{{ invitation|verbose_name:"decision_by" }}</label> - <div class="col-md-4"> - <input type="text" - id="decision_by" - class="form-control-plaintext" - value="{{ invitation.decision_by }}"> - </div> - {% endif %} - </div> - {% if form.comment %} - <div class="row"> - <div class="col-md-12">{% bootstrap_field form.comment %}</div> - </div> - {% else %} - <div class="row"> - {% if invitation.comment %} - <div class="col-md-12"> - <label for="comment" class="form-label">{{ invitation|verbose_name:"comment" }}</label> - <textarea name="comment" id="comment" class="form-control-plaintext" rows="3">{{ invitation.comment }}</textarea> - </div> - {% else %} - <label for="requested" class="col-md-2 col-form-label">{{ invitation|verbose_name:"comment" }}</label> - <div class="col-md-10"> - <input type="text" - name="requested" - id="requested" - class="form-control-plaintext" - value="No comment"> - </div> - {% endif %} - </div> - {% endif %} - {% endif %} - </div> - {% if form.create or form.instance.state == 'requested' %} - <div class="card-footer"> - <div class="float-end"> - {% if form.create %} - <button type="submit" class="btn btn-primary">{{ submit_text }}</button> - {% else %} - {% if form.permissions.requester %} - <button type="submit" name="action" value="update" class="btn btn-primary">{{ update_text }}</button> - <button type="submit" - name="action" - value="withdraw" - class="btn btn-outline-secondary">{{ withdraw_text }}</button> - {% endif %} - {% if form.permissions.requested %} - <button type="submit" name="action" value="accept" class="btn btn-primary">{{ accept_text }}</button> - <button type="submit" name="action" value="reject" class="btn btn-danger">{{ reject_text }}</button> - {% endif %} - </div> - {% endif %} - </div> - {% endif %} - </div> -</form> diff --git a/src/backoffice/templates/backoffice/invitations/create_edit.html b/src/backoffice/templates/backoffice/invitations/create_edit.html new file mode 100644 index 0000000000000000000000000000000000000000..0e8342b0bdb62704e5337260d1b9a93fe5daca11 --- /dev/null +++ b/src/backoffice/templates/backoffice/invitations/create_edit.html @@ -0,0 +1,182 @@ +{% extends "backoffice/base.html" %} +{% load django_bootstrap5 %} +{% load i18n %} +{% load verbose_name %} + +{% block title %} + {{ invitation__type }} | {{ conference.name }} +{% endblock title %} + +{% block content %} + {% include "backoffice/assembly_edit_header.html" %} + + <form method="post"> + {% csrf_token %} + <div class="card"> + <div class="card-header"> + {% if form.create %} + {% trans "Create" %} {{ invitation__type }} + {% else %} + {% trans "Edit" %} {{ invitation__type }} + {% endif %} + {% if form.instance.state == "requested" %} + <span class="badge float-end bg-secondary">requested</span> + {% elif form.instance.state == "accepted" %} + <span class="badge float-end bg-success">accepted</span> + {% elif form.instance.state == "rejected" %} + <span class="badge float-end bg-danger">rejected</span> + {% elif form.instance.state == "withdrawn" %} + <span class="badge float-end bg-warning">withdrawn</span> + {% endif %} + </div> + <div class="card-body"> + <div class="row"> + <div class="col-md-12"> + <p>{{ introduction_text }}</p> + </div> + </div> + {% if form.non_field_errors %} + <div class="row"> + <div class="col-md-12"> + <div class="alert border-danger"> + <h4 class="alert-heading">Errors</h4> + <ul class="list-group"> + {% for error in form.non_field_errors %} + <li class="list-group-item list-group-item-danger">{{ error }}</li> + {% endfor %} + </ul> + </div> + </div> + </div> + {% 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> + <div class="row"> + <div class="col-md-12">{% bootstrap_field form.comment %}</div> + </div> + {% else %} + <div class="row"> + <label for="requester" class="col-md-2 col-form-label">{{ form.fields.requester_id.label }}</label> + <div class="col-md-4"> + {% if form.requester_link %} + <a id="requester" + class="form-control-plaintext" + href="{{ form.requester_link }}" + target="_blank">{{ form.instance.requester.name }}</a> + {% else %} + <span id="requester" class="form-control-plaintext">{{ form.instance.requester.name }}</span> + {% endif %} + <input type="hidden" + name="requester_id" + value="{{ form.instance.requester.pk }}"> + </div> + <label for="requester" class="col-md-2 col-form-label">{{ form.fields.requested_id.label }}</label> + <div class="col-md-4"> + {% if form.requested_link %} + <a id="requested" + class="form-control-plaintext" + href="{{ form.requested_link }}" + target="_blank">{{ form.instance.requested.name }}</a> + {% else %} + <span id="requested" class="form-control-plaintext">{{ form.instance.requested.name }}</span> + {% endif %} + <input type="hidden" + name="requested_id" + value="{{ form.instance.requested.pk }}"> + </div> + </div> + <div class="row"> + <label for="requester" class="col-md-1 col-form-label">{{ form.instance|verbose_name:"requested_at" }}</label> + <div class="col-md-5"> + <input type="text" + name="requester" + id="requester" + class="form-control-plaintext" + value="{{ form.instance.requested_at }}"> + </div> + <label for="requested" class="col-md-1 col-form-label">{{ form.instance|verbose_name:"updated_at" }}</label> + <div class="col-md-5"> + <input type="text" + name="requested" + id="requested" + class="form-control-plaintext" + value="{{ form.instance.updated_at }}"> + </div> + </div> + <div class="row"> + <label for="sender" class="col-md-2 col-form-label">{{ invitation|verbose_name:"sender" }}</label> + <div class="col-md-4"> + <input type="text" + id="sender" + class="form-control-plaintext" + value="{{ invitation.sender }}"> + </div> + {% if invitation.decision_by %} + <label for="decision_by" class="col-md-2 col-form-label">{{ invitation|verbose_name:"decision_by" }}</label> + <div class="col-md-4"> + <input type="text" + id="decision_by" + class="form-control-plaintext" + value="{{ invitation.decision_by }}"> + </div> + {% endif %} + </div> + {% if form.comment %} + <div class="row"> + <div class="col-md-12">{% bootstrap_field form.comment %}</div> + </div> + {% else %} + <div class="row"> + {% if invitation.comment %} + <div class="col-md-12"> + <label for="comment" class="form-label">{{ invitation|verbose_name:"comment" }}</label> + <textarea name="comment" id="comment" class="form-control-plaintext" rows="3">{{ invitation.comment }}</textarea> + </div> + {% else %} + <label for="requested" class="col-md-2 col-form-label">{{ invitation|verbose_name:"comment" }}</label> + <div class="col-md-10"> + <input type="text" + name="requested" + id="requested" + class="form-control-plaintext" + value="No comment"> + </div> + {% endif %} + </div> + {% endif %} + {% endif %} + </div> + {% if form.create or form.instance.state == 'requested' %} + <div class="card-footer"> + <div class="float-end"> + {% if form.create %} + <button type="submit" class="btn btn-primary">{% trans "Invitation__action__submit" %}</button> + {% else %} + {% if form.permissions.requester %} + <button type="submit" name="action" value="update" class="btn btn-primary"> + {% trans "Invitation__action__update" %} + </button> + <button type="submit" + name="action" + value="withdraw" + class="btn btn-outline-secondary">{% trans "Invitation__action__withdraw" %}</button> + {% endif %} + {% if form.permissions.requested %} + <button type="submit" name="action" value="accept" class="btn btn-primary"> + {% trans "Invitation__action__accept" %} + </button> + <button type="submit" name="action" value="reject" class="btn btn-danger"> + {% trans "Invitation__action__reject" %} + </button> + {% endif %} + </div> + {% endif %} + </div> + {% endif %} + </div> + </form> + +{% endblock content %} diff --git a/src/backoffice/templates/backoffice/invitations/create_edit_habitat.html b/src/backoffice/templates/backoffice/invitations/create_edit_habitat.html deleted file mode 100644 index f7081ce6350a0c3056a6af9ffa6cb6ee8f80d1cb..0000000000000000000000000000000000000000 --- a/src/backoffice/templates/backoffice/invitations/create_edit_habitat.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends "backoffice/base.html" %} -{% load i18n %} -{% load django_bootstrap5 %} - -{% block title %} - {% trans "Invitation__Habitat__type" %} | {{ conference.name }} -{% endblock title %} - -{% block content %} - {% include "backoffice/assembly_edit_header.html" %} - - {% trans "Invitation__Habitat__title" as title_text %} - {% trans "Invitation__Habitat__introduction" as introduction_text %} - {% trans "Invitation__Habitat__action__submit" as submit_text %} - {% trans "Invitation__Habitat__action__update" as update_text %} - {% trans "Invitation__Habitat__action__withdraw" as withdraw_text %} - {% trans "Invitation__Habitat__action__accept" as accept_text %} - {% trans "Invitation__Habitat__action__reject" as reject_text %} - {% include "backoffice/invitations/components/create_edit_form.html" with title_text=title_text introduction_text=introduction_text edit_text=edit_text withdraw_text=withdraw_text accept_text=accept_text reject_text=reject_text %} - -{% endblock content %} diff --git a/src/backoffice/templates/backoffice/invitations/detail.html b/src/backoffice/templates/backoffice/invitations/detail.html new file mode 100644 index 0000000000000000000000000000000000000000..c1949ec423bcce6634100c3d754240a6d555afb1 --- /dev/null +++ b/src/backoffice/templates/backoffice/invitations/detail.html @@ -0,0 +1,157 @@ +{% extends "backoffice/base.html" %} +{% load i18n %} +{% load django_bootstrap5 %} +{% load rules %} +{% load verbose_name %} + +{% block title %} + {{ title_text }} | {{ invitation.conference.name }} +{% endblock title %} + +{% block content %} + {% include "backoffice/assembly_edit_header.html" %} + {% has_perm "core.accept_invitation" request.user invitation as can_accept %} + {% has_perm "core.change_invitation" request.user invitation as can_modify %} + + <form method="post" + action="{% url "backoffice:invitation-decision" pk=invitation.pk %}"> + {% csrf_token %} + <div class="card"> + <div class="card-header"> + {{ title_text }} + {% if invitation.state == "requested" %} + <span class="badge float-end bg-secondary">requested</span> + {% elif invitation.state == "accepted" %} + <span class="badge float-end bg-success">accepted</span> + {% elif invitation.state == "rejected" %} + <span class="badge float-end bg-danger">rejected</span> + {% elif invitation.state == "withdrawn" %} + <span class="badge float-end bg-warning">withdrawn</span> + {% endif %} + </div> + <div class="card-body"> + {% if invitation.state == "requested" %} + <div class="row"> + <div class="col-md-12"> + <p>{{ introduction_text }}</p> + </div> + </div> + {% endif %} + + <div class="row"> + <label for="requester" class="col-md-2 col-form-label">{{ requester_label }}</label> + <div class="col-md-4"> + {% if requester_link %} + <a id="requester" + class="form-control-plaintext" + href="{{ requester_link }}" + target="_blank">{{ invitation.requester.name }}</a> + {% else %} + <span id="requester" class="form-control-plaintext">{{ invitation.requester.name }}</span> + {% endif %} + <input type="hidden" + name="requester_id" + value="{{ invitation.requester.pk }}"> + </div> + <label for="requester" class="col-md-2 col-form-label">{{ requested_label }}</label> + <div class="col-md-4"> + {% if requested_link %} + <a id="requested" + class="form-control-plaintext" + href="{{ requested_link }}" + target="_blank">{{ invitation.requested.name }}</a> + {% else %} + <span id="requested" class="form-control-plaintext">{{ invitation.requested.name }}</span> + {% endif %} + <input type="hidden" + name="requested_id" + value="{{ invitation.requested.pk }}"> + </div> + </div> + <div class="row"> + <label for="requester" class="col-md-2 col-form-label">{{ invitation|verbose_name:"requested_at" }}</label> + <div class="col-md-4"> + <input type="text" + name="requester" + id="requester" + class="form-control-plaintext" + value="{{ invitation.requested_at }}"> + </div> + <label for="requested" class="col-md-2 col-form-label">{{ invitation|verbose_name:"updated_at" }}</label> + <div class="col-md-4"> + <input type="text" + name="requested" + id="requested" + class="form-control-plaintext" + value="{{ invitation.updated_at }}"> + </div> + </div> + <div class="row"> + <label for="sender" class="col-md-2 col-form-label">{{ invitation|verbose_name:"sender" }}</label> + <div class="col-md-4"> + <input type="text" + id="sender" + class="form-control-plaintext" + value="{{ invitation.sender }}"> + </div> + {% if invitation.decision_by %} + <label for="decision_by" class="col-md-2 col-form-label">{{ invitation|verbose_name:"decision_by" }}</label> + <div class="col-md-4"> + <input type="text" + id="decision_by" + class="form-control-plaintext" + value="{{ invitation.decision_by }}"> + </div> + {% endif %} + </div> + {% if form.comment %} + <div class="row"> + <div class="col-md-12">{% bootstrap_field form.comment %}</div> + </div> + {% else %} + <div class="row"> + {% if invitation.comment %} + <div class="col-md-12"> + <label for="comment" class="form-label">{{ invitation|verbose_name:"comment" }}</label> + <textarea name="comment" id="comment" class="form-control-plaintext" rows="3">{{ invitation.comment }}</textarea> + </div> + {% else %} + <label for="requested" class="col-md-2 col-form-label">{{ invitation|verbose_name:"comment" }}</label> + <div class="col-md-10"> + <input type="text" + name="requested" + id="requested" + class="form-control-plaintext" + value="{% trans "No comment" %}"> + </div> + {% endif %} + </div> + {% endif %} + + </div> + {% if invitation.state == "requested" %} + <div class="card-footer"> + <div class="float-end"> + {% if can_modify %} + <a class="btn btn-primary" + href="{% url "backoffice:invitation-edit" pk=invitation.pk %}">{% trans "Invitation__action__edit" %}</a> + <button type="submit" + name="action" + value="withdraw" + class="btn btn-outline-secondary">{% trans "Invitation__action__withdraw" %}</button> + {% endif %} + {% if can_accept %} + <button type="submit" name="action" value="accept" class="btn btn-primary"> + {% trans "Invitation__action__accept" %} + </button> + <button type="submit" name="action" value="reject" class="btn btn-danger"> + {% trans "Invitation__action__reject" %} + </button> + {% endif %} + </div> + </div> + {% endif %} + </div> + </form> + +{% endblock content %} diff --git a/src/backoffice/templates/backoffice/invitations/list.html b/src/backoffice/templates/backoffice/invitations/list.html new file mode 100644 index 0000000000000000000000000000000000000000..cdc6a601d421f76145bed09b5f65de6b77821367 --- /dev/null +++ b/src/backoffice/templates/backoffice/invitations/list.html @@ -0,0 +1,16 @@ +{% extends "backoffice/base.html" %} +{% load i18n %} +{% load django_bootstrap5 %} +{% load rules %} + +{% block title %} + {{ title_text }} | {{ invitation.conference.name }} +{% endblock title %} + +{% block content %} + {% include "backoffice/assembly_edit_header.html" %} + + {% trans "Invitations__list__title" as list_title %} + {% include "backoffice/components/invitations_list.html" with invitations=invitations list_title=list_title requester_view=True requested_view=True %} + +{% endblock content %} diff --git a/src/backoffice/tests/invitations/__init__.py b/src/backoffice/tests/invitations/__init__.py index da1a5a862123ed42f03f9264930204291de78b19..0909f1390b0d12de005841cf638bc3a40ca587ff 100644 --- a/src/backoffice/tests/invitations/__init__.py +++ b/src/backoffice/tests/invitations/__init__.py @@ -1,18 +1,111 @@ +from uuid import uuid4 + from django.urls import reverse +from core.models import ( + Assembly, + AssemblyMember, + ConferenceMember, + Invitation, + PlatformUser, + Team, + TeamMember, +) + from backoffice.tests.base import BackOfficeTestCase -from backoffice.tests.invitations.habitat import InvitationHabitatSendViewTestCase +from backoffice.tests.invitations.habitat import HabitatInvitationViewsTestCase class InvitationTestCase(BackOfficeTestCase): def test_invalid_invitation_type(self): self.client.force_login(self.staff) response = self.client.post( - reverse('backoffice:invitation-send', kwargs={'type': 'invalid'}), + reverse( + 'backoffice:invitation-send', + kwargs={'type': 'invalid', 'requester_id': uuid4()}, + ), + ) + self.assertContains(response, 'Bad Request', status_code=400) + + +class InvitationListViewTestCase(BackOfficeTestCase): + def setUp(self): + super().setUp() + + self.assembly = Assembly.objects.create(name='Assembly 1', slug='assembly_1', conference=self.conf) + self.assembly_manager_am = AssemblyMember.objects.create( + assembly=self.assembly, + member=self.user, + is_representative=True, + can_manage_assembly=True, + ) + self.habitat = Assembly.objects.create(name='Habitat 1', slug='habitat_1', conference=self.conf, hierarchy=Assembly.Hierarchy.CLUSTER) + self.habitat_manager = PlatformUser.objects.create(username='habitat_manager') + self.habitat_manager_cm = ConferenceMember.objects.create(conference=self.conf, user=self.habitat_manager) + self.habitat_manager_am = AssemblyMember.objects.create( + assembly=self.habitat, + member=self.habitat_manager, + is_representative=True, + can_manage_assembly=True, + ) + + 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.assertContains(response, 'Invalid invitation type', status_code=400) + + Invitation.objects.create( + requester=self.user, + requested=self.team, + conference=self.conf, + state=Invitation.RequestsState.REQUESTED, + type=Invitation.InvitationType.MEMBER_TO_TEAM, + ) + + Invitation.objects.create( + requester=self.user, + requested=self.team, + conference=self.conf, + state=Invitation.RequestsState.WITHDRAWN, + type=Invitation.InvitationType.MEMBER_TO_TEAM, + ) + Invitation.objects.create( + requester=self.user, + requested=self.team, + conference=self.conf, + state=Invitation.RequestsState.REJECTED, + type=Invitation.InvitationType.MEMBER_TO_TEAM, + ) + Invitation.objects.create( + requester=self.habitat, + requested=self.assembly, + conference=self.conf, + state=Invitation.RequestsState.REQUESTED, + type=Invitation.InvitationType.HABITAT, + ) + Invitation.objects.create( + requester=self.assembly, + requested=self.habitat, + conference=self.conf, + state=Invitation.RequestsState.REQUESTED, + type=Invitation.InvitationType.HABITAT, + ) + + def test_invitation_list(self): + self.client.force_login(self.user) + response = self.client.get(reverse('backoffice:invitations')) + self.assertContains(response, 'team 1') + self.assertContains(response, 'Habitat 1', count=2) + self.assertContains(response, 'Assembly 1', count=2) + self.assertEqual(len(response.context['invitations']), 3) __all__ = [ - 'InvitationHabitatSendViewTestCase', + 'HabitatInvitationViewsTestCase', + 'InvitationListViewTestCase', + 'InvitationTestCase', ] diff --git a/src/backoffice/tests/invitations/habitat.py b/src/backoffice/tests/invitations/habitat.py index f911540364a14ca2cfae3545e8b43afca671fcc6..fa07b3cd04a74e69f2ecf561246c9e849a7e7594 100644 --- a/src/backoffice/tests/invitations/habitat.py +++ b/src/backoffice/tests/invitations/habitat.py @@ -14,10 +14,10 @@ from core.models import ( ) from core.tests.mock import mocktrans -from backoffice.tests.base import BackOfficeTestCase +from backoffice.tests.invitations.mixin import InvitationTestCase -class InvitationHabitatTestCase(BackOfficeTestCase): +class InvitationHabitatTestCase(InvitationTestCase): def setUp(self): super().setUp() @@ -41,207 +41,103 @@ class InvitationHabitatTestCase(BackOfficeTestCase): ) -class InvitationHabitatSendViewTestCase(InvitationHabitatTestCase): - def test_missing_requester_id(self): - self.client.force_login(self.habitat_manager) - response = self.client.post( - reverse('backoffice:invitation-send', kwargs={'type': 'habitat'}), - ) - self.assertContains(response, 'Requester ID is required', status_code=400) - +class HabitatInvitationViewsTestCase(InvitationHabitatTestCase): def test_send_habitat_assembly_invitation_accept(self): - self.client.force_login(self.habitat_manager) - # Send invitation from habitat to assembly - response = self.client.post( - reverse( - 'backoffice:invitation-send-with-pk', - kwargs={ - 'type': 'habitat', - 'requester_id': self.habitat.id, - }, - ), - data={ - 'requester_id': self.habitat.id, - 'requested_id': self.assembly.id, - }, + _response, invitation = self.send_successful_invitation( + user=self.habitat_manager, + requester_id=self.habitat.id, + requested_id=self.assembly.id, + invitation_type=Invitation.InvitationType.HABITAT, ) - self.assertEqual(Invitation.objects.count(), 1) - invitation = Invitation.objects.first() - self.assertEqual(invitation.sender, self.habitat_manager) - self.assertEqual(invitation.type, Invitation.InvitationType.HABITAT) - self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation.id})) # Accept invitation - self.client.force_login(self.assembly_manager) self.assertIsNone(self.assembly.parent) activity_log_count = ActivityLogEntry.objects.count() - response = self.client.post( - reverse('backoffice:invitation', kwargs={'pk': invitation.id}), - data={ - 'action': 'accept', - }, + self.send_action( + user=self.assembly_manager, + invitation=invitation, + action='accept', + expected_state=Invitation.RequestsState.ACCEPTED, ) - self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation.id})) - self.assembly.refresh_from_db() - invitation.refresh_from_db() - - self.assertEqual(invitation.decision_by, self.assembly_manager) - self.assertEqual(invitation.state, Invitation.RequestsState.ACCEPTED) + self.assembly.refresh_from_db() self.assertEqual(self.assembly.parent, self.habitat) self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 2) def test_send_assembly_habitat_invitation_accept(self): - self.client.force_login(self.assembly_manager) - # Send invitation from habitat to assembly - response = self.client.post( - reverse( - 'backoffice:invitation-send-with-pk', - kwargs={ - 'type': 'habitat', - 'requester_id': self.assembly.id, - }, - ), - data={ - 'requester_id': self.assembly.id, - 'requested_id': self.habitat.id, - }, + _response, invitation = self.send_successful_invitation( + user=self.assembly_manager, + requester_id=self.assembly.id, + requested_id=self.habitat.id, + invitation_type=Invitation.InvitationType.HABITAT, ) - self.assertEqual(Invitation.objects.count(), 1) - invitation = Invitation.objects.first() - self.assertEqual(invitation.sender, self.assembly_manager) - self.assertEqual(invitation.type, Invitation.InvitationType.HABITAT) - self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation.id})) # Accept invitation - self.client.force_login(self.habitat_manager) self.assertIsNone(self.assembly.parent) activity_log_count = ActivityLogEntry.objects.count() - response = self.client.post( - reverse('backoffice:invitation', kwargs={'pk': invitation.id}), - data={ - 'action': 'accept', - }, + self.send_action( + user=self.habitat_manager, + invitation=invitation, + action='accept', + expected_state=Invitation.RequestsState.ACCEPTED, ) - self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation.id})) - self.assembly.refresh_from_db() - invitation.refresh_from_db() - - self.assertEqual(invitation.decision_by, self.habitat_manager) - self.assertEqual(invitation.state, Invitation.RequestsState.ACCEPTED) + self.assembly.refresh_from_db() self.assertEqual(self.assembly.parent, self.habitat) self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 2) def test_send_habitat_invitation_withdraw(self): - self.client.force_login(self.habitat_manager) - # Send invitation from habitat to assembly - response = self.client.post( - reverse( - 'backoffice:invitation-send-with-pk', - kwargs={ - 'type': 'habitat', - 'requester_id': self.habitat.id, - }, - ), - data={ - 'requester_id': self.habitat.id, - 'requested_id': self.assembly.id, - }, + _response, invitation = self.send_successful_invitation( + user=self.habitat_manager, + requester_id=self.habitat.id, + requested_id=self.assembly.id, + invitation_type=Invitation.InvitationType.HABITAT, ) - self.assertEqual(Invitation.objects.count(), 1) - invitation = Invitation.objects.first() - self.assertEqual(invitation.sender, self.habitat_manager) - self.assertEqual(invitation.type, Invitation.InvitationType.HABITAT) - self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation.id})) - response = self.client.post( - reverse('backoffice:invitation', kwargs={'pk': invitation.id}), - data={ - 'action': 'withdraw', - }, + self.send_action( + user=self.habitat_manager, + invitation=invitation, + action='withdraw', + expected_state=Invitation.RequestsState.WITHDRAWN, ) - self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation.id})) - self.assertEqual(Invitation.objects.count(), 1) - invitation.refresh_from_db() - self.assertEqual(invitation.decision_by, self.habitat_manager) - self.assertEqual(invitation.state, Invitation.RequestsState.WITHDRAWN) # Test if we can send the invitation again - response = self.client.post( - reverse( - 'backoffice:invitation-send-with-pk', - kwargs={ - 'type': 'habitat', - 'requester_id': self.habitat.id, - }, - ), - data={ - 'requester_id': self.habitat.id, - 'requested_id': self.assembly.id, - }, + _response, invitation = self.send_successful_invitation( + user=self.habitat_manager, + requester_id=self.habitat.id, + requested_id=self.assembly.id, + invitation_type=Invitation.InvitationType.HABITAT, + exprected_count=2, ) - self.assertEqual(Invitation.objects.count(), 2) - invitation = Invitation.objects.filter(state=Invitation.RequestsState.REQUESTED).first() - self.assertEqual(invitation.sender, self.habitat_manager) - self.assertEqual(invitation.type, Invitation.InvitationType.HABITAT) - self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation.id})) def test_send_habitat_invitation_reject(self): with patch.object(timezone, 'now', return_value=datetime(2042, 12, 27, 12, 34, 0, 0, tzinfo=UTC)): - self.client.force_login(self.habitat_manager) - # Send invitation from habitat to assembly - response = self.client.post( - reverse( - 'backoffice:invitation-send-with-pk', - kwargs={ - 'type': 'habitat', - 'requester_id': self.habitat.id, - }, - ), - data={ - 'requester_id': self.habitat.id, - 'requested_id': self.assembly.id, - }, + _response, invitation = self.send_successful_invitation( + user=self.habitat_manager, + requester_id=self.habitat.id, + requested_id=self.assembly.id, + invitation_type=Invitation.InvitationType.HABITAT, ) - self.assertEqual(Invitation.objects.count(), 1) - invitation = Invitation.objects.first() - self.assertEqual(invitation.sender, self.habitat_manager) - self.assertEqual(invitation.type, Invitation.InvitationType.HABITAT) - self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation.id})) - - self.client.force_login(self.assembly_manager) - response = self.client.post( - reverse('backoffice:invitation', kwargs={'pk': invitation.id}), - data={ - 'action': 'reject', - }, + + self.send_action( + user=self.assembly_manager, + invitation=invitation, + action='reject', + expected_state=Invitation.RequestsState.REJECTED, ) - self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation.id})) - self.assertEqual(Invitation.objects.count(), 1) - invitation.refresh_from_db() - self.assertEqual(invitation.decision_by, self.assembly_manager) - self.assertEqual(invitation.state, Invitation.RequestsState.REJECTED) self.client.force_login(self.habitat_manager) # Try to re-Send invitation from habitat to assembly with patch('core.models.invitation._', mocktrans): - response = self.client.post( - reverse( - 'backoffice:invitation-send-with-pk', - kwargs={ - 'type': 'habitat', - 'requester_id': self.habitat.id, - }, - ), - data={ - 'requester_id': self.habitat.id, - 'requested_id': self.assembly.id, - }, + response = self.send_invitation( + user=self.habitat_manager, + requester_id=self.habitat.id, + requested_id=self.assembly.id, + invitation_type=Invitation.InvitationType.HABITAT, + expected_code=200, ) - self.assertTemplateUsed(response, 'backoffice/invitations/create_edit_habitat.html') - self.assertContains(response, 'Invitation__error__rejected_timeout_translated', status_code=200) - self.assertEqual(Invitation.objects.count(), 1) + + self.assertTemplateUsed(response, 'backoffice/invitations/create_edit.html') # After the rejection has timed out, can send invitation again with ( @@ -249,241 +145,163 @@ class InvitationHabitatSendViewTestCase(InvitationHabitatTestCase): 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 = self.client.post( - reverse( - 'backoffice:invitation-send-with-pk', - kwargs={ - 'type': 'habitat', - 'requester_id': self.habitat.id, - }, - ), - data={ - 'requester_id': self.habitat.id, - 'requested_id': self.assembly.id, - }, + _response, invitation_2 = self.send_successful_invitation( + user=self.habitat_manager, + requester_id=self.habitat.id, + requested_id=self.assembly.id, + invitation_type=Invitation.InvitationType.HABITAT, + exprected_count=2, ) - self.assertEqual(Invitation.objects.count(), 2) - invitation_2 = Invitation.objects.exclude(id=invitation.id).first() - self.assertEqual(invitation_2.sender, self.habitat_manager) - self.assertEqual(invitation_2.type, Invitation.InvitationType.HABITAT) - self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation_2.id})) # Accept invitation - self.client.force_login(self.assembly_manager) self.assertIsNone(self.assembly.parent) activity_log_count = ActivityLogEntry.objects.count() - response = self.client.post( - reverse('backoffice:invitation', kwargs={'pk': invitation_2.id}), - data={ - 'action': 'accept', - }, - ) - self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation_2.id})) - self.assembly.refresh_from_db() - invitation_2.refresh_from_db() - self.assertEqual(invitation_2.decision_by, self.assembly_manager) - self.assertEqual(invitation_2.state, Invitation.RequestsState.ACCEPTED) + self.send_action( + user=self.assembly_manager, + invitation=invitation_2, + action='accept', + expected_state=Invitation.RequestsState.ACCEPTED, + ) + self.assembly.refresh_from_db() self.assertEqual(self.assembly.parent, self.habitat) self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 2) def test_send_habitat_invitation_update(self): - self.client.force_login(self.habitat_manager) - # Send invitation from habitat to assembly - response = self.client.post( - reverse( - 'backoffice:invitation-send-with-pk', - kwargs={ - 'type': 'habitat', - 'requester_id': self.habitat.id, - }, - ), - data={ - 'requester_id': self.habitat.id, - 'requested_id': self.assembly.id, - }, + _response, invitation = self.send_successful_invitation( + user=self.habitat_manager, + requester_id=self.habitat.id, + requested_id=self.assembly.id, + invitation_type=Invitation.InvitationType.HABITAT, ) - self.assertEqual(Invitation.objects.count(), 1) - invitation = Invitation.objects.first() - assert invitation - self.assertEqual(invitation.sender, self.habitat_manager) - self.assertEqual(invitation.type, Invitation.InvitationType.HABITAT) - self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation.id})) - - response = self.client.post( - reverse('backoffice:invitation', kwargs={'pk': invitation.id}), + + 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__habitat_translated') + self.assertContains(response, 'Invitation__type__habitat_translated') + + response = self.send_action( + user=self.habitat_manager, + invitation=invitation, + action='update', + expected_state=Invitation.RequestsState.REQUESTED, data={ - 'action': 'update', 'requester_id': invitation.requester.id, 'requested_id': invitation.requested.id, 'comment': 'Updated comment', }, ) - self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation.id})) - self.assertEqual(Invitation.objects.count(), 1) invitation.refresh_from_db() - self.assertEqual(invitation.decision_by, None) - self.assertEqual(invitation.state, Invitation.RequestsState.REQUESTED) self.assertEqual(invitation.comment, 'Updated comment') def test_send_habitat_invitation_invalid_action(self): - self.client.force_login(self.habitat_manager) - # Send invitation from habitat to assembly - response = self.client.post( - reverse( - 'backoffice:invitation-send-with-pk', - kwargs={ - 'type': 'habitat', - 'requester_id': self.habitat.id, - }, - ), - data={ - 'requester_id': self.habitat.id, - 'requested_id': self.assembly.id, - }, + _response, invitation = self.send_successful_invitation( + user=self.habitat_manager, + requester_id=self.habitat.id, + requested_id=self.assembly.id, + invitation_type=Invitation.InvitationType.HABITAT, ) - self.assertEqual(Invitation.objects.count(), 1) - invitation = Invitation.objects.first() - self.assertEqual(invitation.sender, self.habitat_manager) - self.assertEqual(invitation.type, Invitation.InvitationType.HABITAT) - self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation.id})) - self.client.force_login(self.assembly_manager) - response = self.client.post( - reverse('backoffice:invitation', kwargs={'pk': invitation.id}), - data={ - 'action': 'invalid', - }, + self.send_action( + user=self.assembly_manager, + invitation=invitation, + action='invalid', + expected_state=Invitation.RequestsState.REQUESTED, + expected_code=400, ) - self.assertContains(response, 'Invalid action', status_code=400) def test_no_permission(self): - self.client.force_login(self.habitat_manager) - # Send invitation from habitat to assembly - response = self.client.post( - reverse( - 'backoffice:invitation-send-with-pk', - kwargs={ - 'type': 'habitat', - 'requester_id': self.habitat.id, - }, - ), - data={ - 'requester_id': self.habitat.id, - 'requested_id': self.assembly.id, - }, + _response, invitation = self.send_successful_invitation( + user=self.habitat_manager, + requester_id=self.habitat.id, + requested_id=self.assembly.id, + invitation_type=Invitation.InvitationType.HABITAT, ) - self.assertEqual(Invitation.objects.count(), 1) - invitation = Invitation.objects.first() - self.assertEqual(invitation.sender, self.habitat_manager) - self.assertEqual(invitation.type, Invitation.InvitationType.HABITAT) - self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation.id})) # Cannot accept invitation self.assertIsNone(self.assembly.parent) - response = self.client.post( - reverse('backoffice:invitation', kwargs={'pk': invitation.id}), - data={ - 'action': 'accept', - }, + + self.send_action( + user=self.habitat_manager, + invitation=invitation, + action='accept', + expected_state=Invitation.RequestsState.REQUESTED, + expected_code=403, ) - self.assertContains(response, 'Forbidden', status_code=403) self.assembly.refresh_from_db() self.assertIsNone(self.assembly.parent) - invitation.refresh_from_db() - self.assertIsNone(invitation.decision_by) - self.assertEqual(invitation.state, Invitation.RequestsState.REQUESTED) # Cannot reject invitation self.assertIsNone(self.assembly.parent) - response = self.client.post( - reverse('backoffice:invitation', kwargs={'pk': invitation.id}), - data={ - 'action': 'reject', - }, + + self.send_action( + user=self.habitat_manager, + invitation=invitation, + action='reject', + expected_state=Invitation.RequestsState.REQUESTED, + expected_code=403, ) - self.assertContains(response, 'Forbidden', status_code=403) self.assembly.refresh_from_db() self.assertIsNone(self.assembly.parent) - invitation.refresh_from_db() - self.assertIsNone(invitation.decision_by) - self.assertEqual(invitation.state, Invitation.RequestsState.REQUESTED) self.client.force_login(self.assembly_manager) # Cannot withdraw invitation self.assertIsNone(self.assembly.parent) - response = self.client.post( - reverse('backoffice:invitation', kwargs={'pk': invitation.id}), - data={ - 'action': 'withdraw', - }, + self.send_action( + user=self.assembly_manager, + invitation=invitation, + action='withdraw', + expected_state=Invitation.RequestsState.REQUESTED, + expected_code=403, ) - self.assertContains(response, 'Forbidden', status_code=403) self.assembly.refresh_from_db() self.assertIsNone(self.assembly.parent) - invitation.refresh_from_db() - self.assertIsNone(invitation.decision_by) - self.assertEqual(invitation.state, Invitation.RequestsState.REQUESTED) # Cannot update invitation self.assertIsNone(self.assembly.parent) - response = self.client.post( - reverse('backoffice:invitation', kwargs={'pk': invitation.id}), + self.send_action( + user=self.assembly_manager, + invitation=invitation, + action='update', + expected_state=Invitation.RequestsState.REQUESTED, + expected_code=403, data={ - 'action': 'update', 'requester_id': invitation.requester.id, 'requested_id': invitation.requested.id, 'comment': 'Updated comment', }, ) - self.assertContains(response, 'Forbidden', status_code=403) + self.assembly.refresh_from_db() self.assertIsNone(self.assembly.parent) invitation.refresh_from_db() - self.assertIsNone(invitation.decision_by) - self.assertEqual(invitation.state, Invitation.RequestsState.REQUESTED) self.assertEqual(invitation.comment, '') def test_duplicate_invitation(self): - self.client.force_login(self.habitat_manager) - # Send invitation from habitat to assembly - response = self.client.post( - reverse( - 'backoffice:invitation-send-with-pk', - kwargs={ - 'type': 'habitat', - 'requester_id': self.habitat.id, - }, - ), - data={ - 'requester_id': self.habitat.id, - 'requested_id': self.assembly.id, - }, + self.send_successful_invitation( + user=self.habitat_manager, + requester_id=self.habitat.id, + requested_id=self.assembly.id, + invitation_type=Invitation.InvitationType.HABITAT, ) - self.assertEqual(Invitation.objects.count(), 1) - invitation = Invitation.objects.first() - self.assertEqual(invitation.sender, self.habitat_manager) - self.assertEqual(invitation.type, Invitation.InvitationType.HABITAT) - self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation.id})) # Try to send duplicate invitation # Send invitation from habitat to assembly with patch('core.models.invitation._', mocktrans): - response = self.client.post( - reverse( - 'backoffice:invitation-send-with-pk', - kwargs={ - 'type': 'habitat', - 'requester_id': self.habitat.id, - }, - ), - data={ - 'requester_id': self.habitat.id, - 'requested_id': self.assembly.id, - }, + response = self.send_invitation( + user=self.habitat_manager, + requester_id=self.habitat.id, + requested_id=self.assembly.id, + invitation_type=Invitation.InvitationType.HABITAT, + expected_code=200, ) - self.assertTemplateUsed(response, 'backoffice/invitations/create_edit_habitat.html') + self.assertTemplateUsed(response, 'backoffice/invitations/create_edit.html') self.assertContains(response, f'Invitation__error__already_exists {self.habitat.name} {self.assembly.name} {self.habitat_manager.username}_translated') self.assertEqual(Invitation.objects.count(), 1) diff --git a/src/backoffice/tests/invitations/mixin.py b/src/backoffice/tests/invitations/mixin.py new file mode 100644 index 0000000000000000000000000000000000000000..59443a6798f9906bca614a3983f4a04b021c8438 --- /dev/null +++ b/src/backoffice/tests/invitations/mixin.py @@ -0,0 +1,103 @@ +from typing import Any +from uuid import UUID + +from django.http import HttpResponse +from django.urls import reverse + +from core.models import ( + Invitation, + PlatformUser, +) + +from backoffice.tests.base import BackOfficeTestCase + + +class InvitationTestCase(BackOfficeTestCase): + def send_invitation( + self, + *, + user: PlatformUser, + requester_id: str | UUID, + requested_id: str | UUID, + invitation_type: Invitation.InvitationType, + url: str | None = None, + exprected_count: int = 1, + expected_code: int = 302, + ) -> HttpResponse: + self.client.force_login(user) + response = self.client.post( + url + if url + else reverse( + 'backoffice:invitation-send', + kwargs={ + 'type': invitation_type.lower(), + 'requester_id': requester_id, + }, + ), + data={ + 'requester_id': requester_id, + 'requested_id': requested_id, + }, + ) + self.assertEqual(Invitation.objects.count(), exprected_count) + self.assertEqual(response.status_code, expected_code) + return response + + def send_successful_invitation( + self, + *, + user: PlatformUser, + invitation_type: Invitation.InvitationType, + expected_code: int = 302, + **kwargs: Any, + ) -> tuple[HttpResponse, Invitation]: + response = self.send_invitation( + user=user, + invitation_type=invitation_type, + expected_code=expected_code, + **kwargs, + ) + invitation = Invitation.objects.filter(state=Invitation.RequestsState.REQUESTED).first() + assert invitation + self.assertEqual(invitation.sender, user) + self.assertEqual(invitation.type, invitation_type) + if expected_code == 302: + self.assertRedirects(response, reverse('backoffice:invitation-detail', kwargs={'pk': invitation.id})) + return (response, invitation) + + def send_action( + self, + *, + user: PlatformUser, + invitation: Invitation, + action: str, + expected_code: int = 302, + expected_state: Invitation.RequestsState | None = None, + expected_count: int = 1, + data: dict[str, Any] | None = None, + ) -> HttpResponse: + data = data or {} + if action == 'update': + url = reverse('backoffice:invitation-edit', kwargs={'pk': invitation.id}) + else: + data['action'] = action + url = reverse('backoffice:invitation-decision', kwargs={'pk': invitation.id}) + + self.client.force_login(user) + response = self.client.post( + url, + data=data, + ) + self.assertEqual(response.status_code, expected_code) + self.assertEqual(Invitation.objects.filter(state=expected_state).count(), expected_count) + if expected_code == 302: + self.assertRedirects(response, reverse('backoffice:invitation-detail', kwargs={'pk': invitation.id})) + if expected_state: + invitation.refresh_from_db() + self.assertEqual(invitation.state, expected_state) + if expected_state != Invitation.RequestsState.REQUESTED: + self.assertEqual(invitation.decision_by, user) + else: + self.assertIsNone(invitation.decision_by) + return response diff --git a/src/backoffice/urls.py b/src/backoffice/urls.py index e118e148b8673f9655ac3f7c7156d433bf751b03..accf9e23b62054ab5940daa6dd03a69365148c0e 100644 --- a/src/backoffice/urls.py +++ b/src/backoffice/urls.py @@ -107,9 +107,10 @@ urlpatterns = [ path('assembly/<uuid:assembly>/r/<uuid:room>/remove_link', assemblies.RoomLinkDeleteView.as_view(), name='roomlink-remove'), path('assembly/<uuid:assembly>/r/<uuid:room>/remove', assemblies.AssemblyRoomDeleteView.as_view(), name='assembly-remove-room'), path('invitations', invitations.InvitationListView.as_view(), name='invitations'), - path('invitation/<uuid:pk>', invitations.InvitationUpdateView.as_view(), name='invitation'), - path('invite/<str:type>/', invitations.InvitationSendView.as_view(), name='invitation-send'), - path('invite/<str:type>/<uuid:requester_id>', invitations.InvitationSendView.as_view(), name='invitation-send-with-pk'), + path('invitation/<uuid:pk>', invitations.InvitationDetailView.as_view(), name='invitation-detail'), + 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('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 696ed9a8bc5185dbced1abe5769830dc7ae22f99..379de5b38f70e751adc80ae96a81a59b49e3600f 100644 --- a/src/backoffice/views/invitations/__init__.py +++ b/src/backoffice/views/invitations/__init__.py @@ -1,50 +1,65 @@ from typing import Any -from django.http import Http404, HttpRequest -from django.http.response import HttpResponse -from django.views.generic import CreateView, ListView, UpdateView +from django.db.models.query import QuerySet +from django.urls import reverse_lazy +from django.utils.translation import gettext_lazy as _ +from django.views.generic import DetailView, ListView +from rules.contrib.views import AutoPermissionRequiredMixin -from core.models import Invitation +from core.models import Assembly, Invitation -from backoffice.views.invitations.habitat import InvitationHabitatSendView, InvitationHabitatUpdateView +from backoffice.views.invitations.send import InvitationCreateView +from backoffice.views.invitations.update import InvitationDecisionView, InvitationUpdateView +from backoffice.views.mixins import ConferenceLoginRequiredMixin, ConferenceRuleRequiredMixin -class InvitationListView(ListView): - def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: - raise Http404('View not yet implemented') +class InvitationListView(ConferenceLoginRequiredMixin, ListView): + model = Invitation + template_name = 'backoffice/invitations/list.html' + context_object_name = 'invitations' + + def get_queryset(self) -> QuerySet[Invitation]: + return super().get_queryset().filter(state=Invitation.RequestsState.REQUESTED) + + def get_context_data(self, **kwargs): + return { + **super().get_context_data(**kwargs), + 'active_page': 'invitations', + } -class InvitationUpdateView(UpdateView): +class InvitationDetailView(ConferenceRuleRequiredMixin, AutoPermissionRequiredMixin, DetailView): model = Invitation + template_name = 'backoffice/invitations/detail.html' + context_object_name = 'invitation' - def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: - invitation = self.get_object() - if not Invitation.type_is(invitation): # pragma: no cover - raise ValueError('Invalid invitation type') + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + if not Invitation.type_is(invitation := self.object): # pragma: no cover + raise ValueError('Invalid object type:') + context = super().get_context_data(**kwargs) + context['active_page'] = 'invitations' match invitation.type: case Invitation.InvitationType.HABITAT: - return InvitationHabitatUpdateView.as_view()(request, *args, **kwargs) + context.update( + { + 'title_text': _('Invitation__title__habitat'), + 'introduction_text': _('Invitation__introduction__habitat'), + 'requester_label': _('Assembly') if invitation.requester != Assembly.Hierarchy.REGULAR else _('Assembly__parent'), + 'requester_link': reverse_lazy('backoffice:assembly', kwargs={'pk': invitation.requester.pk}), + 'requested_label': _('Assembly') if invitation.requester == Assembly.Hierarchy.REGULAR else _('Assembly__parent'), + 'requested_link': reverse_lazy('backoffice:assembly', kwargs={'pk': invitation.requested.pk}), + } + ) case _: # pragma: no cover - raise Http404('View not yet implemented') - - -class InvitationSendView(CreateView): - model = Invitation + raise NotImplementedError - def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: - match kwargs.get('type'): - case 'habitat': - return InvitationHabitatSendView.as_view()(request, *args, **kwargs) - case 'assembly_member': - raise Http404('View not yet implemented') - case 'team_member': - raise Http404('View not yet implemented') - case _: - return HttpResponse('Invalid invitation type', status=400) + return context __all__ = [ + 'InvitationCreateView', + 'InvitationDecisionView', + 'InvitationDetailView', 'InvitationListView', - 'InvitationSendView', 'InvitationUpdateView', ] diff --git a/src/backoffice/views/invitations/habitat.py b/src/backoffice/views/invitations/habitat.py deleted file mode 100644 index 234d4a1a517aefcbbf7fabb65af66c885f2a8ef0..0000000000000000000000000000000000000000 --- a/src/backoffice/views/invitations/habitat.py +++ /dev/null @@ -1,69 +0,0 @@ -from typing import Any - -from django.contrib.auth.views import RedirectURLMixin -from django.contrib.messages import SUCCESS, add_message -from django.http import HttpRequest -from django.http.response import HttpResponse -from django.urls import reverse -from django.views.generic import CreateView, UpdateView, View -from django.views.generic.edit import ModelFormMixin - -from core.forms import InvitationHabitatForm -from core.models import Invitation - -from backoffice.views.invitations.mixins import HandleInvitationActionMixin -from backoffice.views.mixins import ConferenceLoginRequiredMixin - - -class InvitationHabitatBaseView(ConferenceLoginRequiredMixin, RedirectURLMixin, ModelFormMixin, View): - template_name = 'backoffice/invitations/create_edit_habitat.html' - form_class = InvitationHabitatForm - model = Invitation - - def get_form_kwargs(self) -> dict[str, Any]: - return { - **super().get_form_kwargs(), - 'conference': self.conference, - 'user': self.request.user, - 'requester_id': self.kwargs.get('requester_id'), - } - - def get_default_redirect_url(self) -> str: - if not hasattr(self, 'object'): # pragma: no cover - return reverse('backoffice:invitations') - return reverse('backoffice:invitation', kwargs={'pk': self.object.pk}) - - -class InvitationHabitatSendView(InvitationHabitatBaseView, CreateView): - def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: - if kwargs.get('requester_id') is None: - return HttpResponse('Requester ID is required', status=400) - return super().dispatch(request, *args, **kwargs) - - def form_valid(self, form): - response = super().form_valid(form) - add_message(self.request, SUCCESS, f'Invitation to {self.object.requested.name} sent') - return response - - -class InvitationHabitatUpdateView(HandleInvitationActionMixin, InvitationHabitatBaseView, UpdateView): - object: Invitation - - def has_permission(self): - invitation = self.get_object() - if not Invitation.type_is(invitation): # pragma: no cover - raise ValueError('Invalid invitation type') - return self.request.user.has_perm('core.change_assembly', invitation.requested) or self.request.user.has_perm( - 'core.change_assembly', invitation.requester - ) - - def post(self, request: HttpRequest, *args: str, **kwargs: Any) -> HttpResponse: - self.object = self.get_object() # pylint: disable=attribute-defined-outside-init - if not Invitation.type_is(self.object): # pragma: no cover - raise ValueError('Invalid invitation type') - action = request.POST.get('action', '') - if action in ['update', 'withdraw'] and not request.user.has_perm('core.change_assembly', self.object.requester): - return self.handle_no_permission() - if action in ['accept', 'reject'] and not request.user.has_perm('core.change_assembly', self.object.requested): - return self.handle_no_permission() - return super().post(request, *args, **kwargs) diff --git a/src/backoffice/views/invitations/mixins.py b/src/backoffice/views/invitations/mixins.py deleted file mode 100644 index 3d158f4c17971742b6efb63c96ebd8588c8fcbd0..0000000000000000000000000000000000000000 --- a/src/backoffice/views/invitations/mixins.py +++ /dev/null @@ -1,69 +0,0 @@ -from typing import Any - -from django.contrib.messages import ERROR, SUCCESS, WARNING, add_message -from django.http import HttpRequest, HttpResponseRedirect -from django.http.response import HttpResponse -from django.utils.translation import gettext_lazy as _ -from django.views.generic import UpdateView - -from core.models import Invitation - - -class HandleInvitationActionMixin(UpdateView): - object: Invitation - - def post(self, request: HttpRequest, *args: str, **kwargs: Any) -> HttpResponse: - action = request.POST.get('action', '') - if not hasattr(self, 'object') and not Invitation.type_is(self.object): # pragma: no cover - raise ValueError('Invalid invitation type') - match action: - case 'update': - response = super().post(request, *args, **kwargs) - add_message( - request, - SUCCESS, - _('Invitation__updated__message %(requester)s %(requested)s') - % { - 'requester': self.object.requester.name, - 'requested': self.object.requested.name, - }, - ) - return response - case 'withdraw': - self.object.withdraw(request.user) - add_message( - request, - WARNING, - _('Invitation__withdraw__message %(requester)s %(requested)s') - % { - 'requester': self.object.requester.name, - 'requested': self.object.requested.name, - }, - ) - return HttpResponseRedirect(self.get_success_url()) - case 'accept': - self.object.accept(request.user) - add_message( - request, - SUCCESS, - _('Invitation__accept__message %(requester)s %(requested)s') - % { - 'requester': self.object.requester.name, - 'requested': self.object.requested.name, - }, - ) - return HttpResponseRedirect(self.get_success_url()) - case 'reject': - self.object.reject(request.user) - add_message( - request, - ERROR, - _('Invitation__rejected__message %(requester)s %(requested)s') - % { - 'requester': self.object.requester.name, - 'requested': self.object.requested.name, - }, - ) - return HttpResponseRedirect(self.get_success_url()) - case _: - return HttpResponse('Invalid action', status=400) diff --git a/src/backoffice/views/invitations/send.py b/src/backoffice/views/invitations/send.py new file mode 100644 index 0000000000000000000000000000000000000000..65b30cb29a8a11aa082f7d48ea56c2df611afac9 --- /dev/null +++ b/src/backoffice/views/invitations/send.py @@ -0,0 +1,71 @@ +from typing import Any + +from django.contrib.auth.views import RedirectURLMixin +from django.contrib.messages import SUCCESS, add_message +from django.core.exceptions import BadRequest +from django.forms.models import BaseModelForm +from django.shortcuts import get_object_or_404 +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 backoffice.views.mixins import ConferenceLoginRequiredMixin + + +class InvitationCreateView(ConferenceLoginRequiredMixin, RedirectURLMixin, CreateView): + template_name = 'backoffice/invitations/create_edit.html' + model = Invitation + + @property + def invitation_type(self): + if (invitation_type := str(self.kwargs.get('type', '')).upper()) not in Invitation.InvitationType.values: + raise BadRequest('Invalid invitation type') + return invitation_type + + def has_permission(self): + match self.invitation_type: + 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 _: # pragma: no cover + raise NotImplementedError('Invitation type not implemented') + + def get_form_class(self) -> type[BaseModelForm]: + match self.invitation_type: + case Invitation.InvitationType.HABITAT: + return InvitationHabitatForm + case _: # pragma: no cover + raise NotImplementedError('Invalid invitation type') + + def get_form_kwargs(self) -> dict[str, Any]: + return { + **super().get_form_kwargs(), + 'conference': self.conference, + 'user': self.request.user, + 'requester_id': self.kwargs.get('requester_id'), + } + + def form_valid(self, form): + response = super().form_valid(form) + add_message(self.request, SUCCESS, f'Invitation to {self.object.requested.name} sent') + return response + + def get_default_redirect_url(self) -> str: + return reverse('backoffice:invitation-detail', kwargs={'pk': self.object.pk}) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + match self.invitation_type: + case Invitation.InvitationType.HABITAT: + context.update( + { + 'introduction_text': _('Invitation__introduction__habitat'), + 'invitation__type': _('Invitation__type__habitat'), + } + ) + 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 new file mode 100644 index 0000000000000000000000000000000000000000..8cf975a2184a2a01e1f20a99bb7095772621d9d9 --- /dev/null +++ b/src/backoffice/views/invitations/update.py @@ -0,0 +1,140 @@ +from typing import Any + +from django.contrib.auth.views import RedirectURLMixin +from django.contrib.messages import ERROR, SUCCESS, WARNING, add_message +from django.core.exceptions import BadRequest +from django.forms.models import BaseModelForm +from django.http import HttpRequest, HttpResponseRedirect +from django.http.response import HttpResponse +from django.urls import reverse +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.models import Invitation + +from backoffice.views.mixins import ConferenceLoginRequiredMixin, ConferenceRuleLoginRequiredMixin + + +class InvitationUpdateView(ConferenceRuleLoginRequiredMixin, UpdateView): + model = Invitation + template_name = 'backoffice/invitations/create_edit.html' + permission_required = 'core.change_invitation' + + def get_invitation(self) -> Invitation: + invitation = self.get_object() + if not Invitation.type_is(invitation): # pragma: no cover + raise ValueError('Invalid object type') + return invitation + + def get_form_class(self) -> type[BaseModelForm]: + invitation = self.get_invitation() + match invitation.type: + case Invitation.InvitationType.HABITAT: + return InvitationHabitatForm + case _: # pragma: no cover + raise NotImplementedError('Invalid invitation type') + + def get_context_data(self, **kwargs): + invitation = self.get_invitation() + context = super().get_context_data(**kwargs) + context['active_page'] = 'invitations' + match invitation.type: + case Invitation.InvitationType.HABITAT: + context.update( + { + 'introduction_text': _('Invitation__introduction__habitat'), + 'invitation__type': _('Invitation__type__habitat'), + } + ) + case _: # pragma: no cover + raise NotImplementedError('Invalid invitation type') + return context + + def get_form_kwargs(self) -> dict[str, Any]: + return { + **super().get_form_kwargs(), + 'user': self.request.user, + 'conference': self.conference, + } + + def post(self, request: HttpRequest, *args: str, **kwargs: Any) -> HttpResponse: + invitation = self.get_invitation() + response = super().post(request, *args, **kwargs) + add_message( + request, + SUCCESS, + _('Invitation__updated__message %(requester)s %(requested)s') + % { + 'requester': invitation.requester.name, + 'requested': invitation.requested.name, + }, + ) + return response + + def get_success_url(self) -> str: + return reverse('backoffice:invitation-detail', kwargs={'pk': self.object.pk}) + + +class InvitationDecisionView(ConferenceLoginRequiredMixin, RedirectURLMixin, SingleObjectMixin, View): + http_method_names = ['post'] + model = Invitation + + def post(self, request: HttpRequest, *args: str, **kwargs: Any) -> HttpResponse: + self.object = self.get_object() # pylint: disable=attribute-defined-outside-init + if not Invitation.type_is(invitation := self.object): # pragma: no cover + raise ValueError('Invalid object type') + action = request.POST.get('action', '') + # Check permissions + match action: + case 'withdraw': + if not request.user.has_perm('core.change_invitation', invitation): + return self.handle_no_permission() + case 'accept' | 'reject': + if not request.user.has_perm('core.accept_invitation', invitation): + return self.handle_no_permission() + case _: + raise BadRequest('Invalid action') + match action: + case 'withdraw': + invitation.withdraw(request.user) + add_message( + request, + WARNING, + _('Invitation__withdraw__message %(requester)s %(requested)s') + % { + 'requester': invitation.requester.name, + 'requested': invitation.requested.name, + }, + ) + return HttpResponseRedirect(self.get_success_url()) + case 'accept': + invitation.accept(request.user) + add_message( + request, + SUCCESS, + _('Invitation__accept__message %(requester)s %(requested)s') + % { + 'requester': invitation.requester.name, + 'requested': invitation.requested.name, + }, + ) + return HttpResponseRedirect(self.get_success_url()) + case 'reject': + invitation.reject(request.user) + add_message( + request, + ERROR, + _('Invitation__rejected__message %(requester)s %(requested)s') + % { + 'requester': invitation.requester.name, + 'requested': invitation.requested.name, + }, + ) + return HttpResponseRedirect(self.get_success_url()) + case _: # pragma: no cover + raise BadRequest('Invalid action') + + def get_default_redirect_url(self) -> str: + return reverse('backoffice:invitation-detail', kwargs={'pk': self.object.pk}) diff --git a/src/core/admin.py b/src/core/admin.py index d9ee6d63023dc4ceef23e7dcd652ed3192127f44..ca17a5ae5ded567cd7ddc7515dd428db6915ee88 100644 --- a/src/core/admin.py +++ b/src/core/admin.py @@ -467,7 +467,7 @@ class InvitationAdmin(admin.ModelAdmin): def get_fields(self, request: HttpRequest, obj: Any | None = None) -> list[str]: if obj is None: return ['requester_type', 'requester_id', 'requested_type', 'requested_id', 'comment'] - return ['id', 'requester', 'requester_type', 'requested', 'requested_type', 'comment', 'requested_at', 'updated_at', 'type', 'sender'] + return ['id', 'requester', 'requester_type', 'requested', 'requested_type', 'comment', 'requested_at', 'updated_at', 'type', 'state', 'sender'] def get_readonly_fields(self, request: HttpRequest, obj: Any | None = None) -> list[str]: if obj is None: diff --git a/src/core/forms/invitations.py b/src/core/forms/invitations.py index 8a8560a7518f3a584dc10d2b4eea6148bba7c481..df5d51ac242b6aaa04b61c71357c3d9b8cb3e65f 100644 --- a/src/core/forms/invitations.py +++ b/src/core/forms/invitations.py @@ -1,6 +1,9 @@ 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.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 @@ -16,34 +19,65 @@ class InvitationBaseForm(ModelForm): ) requester_id: str | None = None + requester_link: str | None = None + requested_link: str | None = None + + def __init__( + self, + *args, + conference: Conference, + user: PlatformUser, + instance: Invitation | None, + requester_id: str | None = None, + **kwargs, + ): + kwargs['initial'] = kwargs.get('initial', {}) - def __init__(self, *args, conference: Conference, user: PlatformUser, instance: Invitation | None, requester_id: str | None = None, **kwargs): - self.create = instance is None self.conference = conference self.user = user - if requester_id is not None: + + self.create = instance is None + if instance is None: + if not requester_id: # pragma: no cover + raise ValueError('Requester ID is required') self.requester_id = str(requester_id) - kwargs['initial'] = { - 'requester_id': requester_id, - } - if 'data' in kwargs: - kwargs['data'] = kwargs['data'].copy() - kwargs['data']['requester_id'] = requester_id + else: + self.requester_id = str(instance.requester_id) + + kwargs['initial']['requester_id'] = self.requester_id + if 'data' in kwargs: + kwargs['data'] = kwargs['data'].copy() + kwargs['data']['requester_id'] = self.requester_id + super().__init__(*args, instance=instance, **kwargs) - if instance and instance.state != Invitation.RequestsState.REQUESTED: - del self.fields['comment'] - if requester_id is not None: - self.fields['requester_id'].widget.attrs['disabled'] = True + self.fields['requester_id'].widget.attrs['disabled'] = True + if instance: + self.permissions = { + 'requester': self.user.has_perm('core.change_invitation', self.instance), + 'requested': self.user.has_perm('core.accept_invitation', self.instance), + } def save(self, commit: bool = True) -> Invitation: invitation = super().save(commit=False) if self.create: invitation.conference = self.conference invitation.sender = self.user - if commit: + if commit: # pragma: no cover invitation.save() return invitation + def clean_requester_id(self): + data = self.cleaned_data['requester_id'] + if not self.create and self.instance and str(self.instance.requester_id) != str(data): + raise ValidationError(_('Invitation__error__requester_change')) + return data + + def clean_requested_id(self): + data = self.cleaned_data['requested_id'] + if not self.create and self.instance and str(self.instance.requested_id) != str(data): + raise ValidationError(_('Invitation__error__requested_change')) + return data + class InvitationHabitatForm(InvitationBaseForm): class Meta(InvitationBaseForm.Meta): @@ -66,27 +100,16 @@ class InvitationHabitatForm(InvitationBaseForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if self.create: - if not self.requester_id: - raise ValueError('Requester ID is required') - try: - assembly = self.get_queryset().get(id=self.requester_id) - except Assembly.DoesNotExist as exc: - raise ValueError('Invalid requester ID') from exc + assembly = get_object_or_404(Assembly, pk=self.requester_id) habitat = assembly.hierarchy != Assembly.Hierarchy.REGULAR - self.fields['requester_id'].label = _('Assembly__parent') if habitat else _('Assembly') - self.fields['requested_id'].label = _('Assembly') if habitat else _('Assembly__parent') self.fields['requester_id'].widget.choices = self.get_choices(habitat=habitat) self.fields['requested_id'].widget.choices = self.get_choices(habitat=(not habitat)) else: - self.permissions = { - 'requester': self.user.has_perm('core.change_assembly', self.instance.requester), - 'requested': self.user.has_perm('core.change_assembly', self.instance.requested), - } - if not self.permissions['requester'] and 'comment' in self.fields: - del self.fields['comment'] habitat = self.instance.requester.hierarchy != Assembly.Hierarchy.REGULAR - self.requester_label = _('Assembly__parent') if habitat else _('Assembly') - self.requested_label = _('Assembly__parent') if not habitat else _('Assembly') + self.requester_link = reverse_lazy('backoffice:assembly', kwargs={'pk': self.instance.requester.pk}) + self.requested_link = reverse_lazy('backoffice:assembly', kwargs={'pk': self.instance.requester.pk}) + self.fields['requester_id'].label = _('Assembly__parent') if habitat else _('Assembly') + self.fields['requested_id'].label = _('Assembly') if habitat else _('Assembly__parent') def save(self, commit: bool = True) -> Invitation: invitation = super().save(commit=False) @@ -94,6 +117,6 @@ class InvitationHabitatForm(InvitationBaseForm): invitation.type = Invitation.InvitationType.HABITAT invitation.requester_type = assembly_type invitation.requested_type = assembly_type - if commit: + 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 5a321506f61299e167a4741fdac614935b3dcab1..511972e98c88d8be5bc60d296080f0b4878a5418 100644 --- a/src/core/locale/de/LC_MESSAGES/django.po +++ b/src/core/locale/de/LC_MESSAGES/django.po @@ -137,6 +137,12 @@ msgstr "Der Benutzername darf nicht mit einem Unterstrich beginnen." msgid "Conference__is_public__unchangeable" msgstr "Der Konferenz Veröffentlichungsstatus kann nur mittels des Veröffentlichen Buttons durchgeführt werden." +msgid "Invitation__error__requester_change" +msgstr "Eine Einladung kann nicht nachträglich geändert werden" + +msgid "Invitation__error__requested_change" +msgstr "Eine Einladung kann nicht nachträglich geändert werden" + msgid "Assembly__parent" msgstr "Habitat" @@ -1161,13 +1167,19 @@ msgstr "angenommen" msgid "Invitation__requests_state-rejected" msgstr "abgelehnt" -msgid "Assembly Member" -msgstr "Assembly Mitglied" +msgid "Invitation__type__assembly_to_member" +msgstr "Einladung zum Assembly Beitritt" + +msgid "Invitation__type__member_to_assembly" +msgstr "Anfrage zum Assembly Beitritt" + +msgid "Invitation__type__team_to_member" +msgstr "Einladung zum Team Beitritt" -msgid "Team Member" -msgstr "Team Mitglied" +msgid "Invitation__type__member_to_team" +msgstr "Anfrage zum Team Beitritt" -msgid "Habitat" +msgid "Invitation__type__habitat" msgstr "Habitatszuordnung" msgid "Invitation__name" diff --git a/src/core/locale/en/LC_MESSAGES/django.po b/src/core/locale/en/LC_MESSAGES/django.po index 9d671ff7feb9acda6418267b0a0a41ccb74c8373..5029d87d03aa6c56f0ae627a436d83cec6ea297b 100644 --- a/src/core/locale/en/LC_MESSAGES/django.po +++ b/src/core/locale/en/LC_MESSAGES/django.po @@ -137,6 +137,12 @@ msgstr "The username must not begin with an underscore." msgid "Conference__is_public__unchangeable" msgstr "The conference publication status can only be changed using the publish button." +msgid "Invitation__error__requester_change" +msgstr "An invitation cannot be changed retrospectively" + +msgid "Invitation__error__requested_change" +msgstr "An invitation cannot be changed retrospectively" + msgid "Assembly__parent" msgstr "parent" @@ -1161,13 +1167,19 @@ msgstr "accepted" msgid "Invitation__requests_state-rejected" msgstr "rejected" -msgid "Assembly Member" -msgstr "Assembly member" +msgid "Invitation__type__assembly_to_member" +msgstr "Invitation to join Assembly" + +msgid "Invitation__type__member_to_assembly" +msgstr "Request to join Assembly" + +msgid "Invitation__type__team_to_member" +msgstr "Invitation to join team" -msgid "Team Member" -msgstr "Team member" +msgid "Invitation__type__member_to_team" +msgstr "Request to join team" -msgid "Habitat" +msgid "Invitation__type__habitat" msgstr "Habitat assignment" msgid "Invitation__name" diff --git a/src/core/migrations/0168_alter_invitation_type.py b/src/core/migrations/0168_alter_invitation_type.py new file mode 100644 index 0000000000000000000000000000000000000000..ed5658286251d18139990896552354eb5ba45254 --- /dev/null +++ b/src/core/migrations/0168_alter_invitation_type.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.3 on 2024-12-19 01:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0167_roomlink_add_type_nav_remove_internal_link"), + ] + + operations = [ + migrations.AlterField( + model_name="invitation", + name="type", + field=models.CharField( + choices=[ + ("ASSEMBLY_TO_MEMBER", "Invitation__type__assembly_to_member"), + ("MEMBER_TO_ASSEMBLY", "Invitation__type__member_to_assembly"), + ("TEAM_TO_MEMBER", "Invitation__type__team_to_member"), + ("MEMBER_TO_TEAM", "Invitation__type__member_to_team"), + ("HABITAT", "Invitation__type__habitat"), + ], + max_length=25, + verbose_name="Invitation__type", + ), + ), + ] diff --git a/src/core/models/invitation.py b/src/core/models/invitation.py index 955e1823f1e62da2a702b52dd7da3ae9099134e8..0f57555e0d088c657ae9cdf019a1677e94e6a6c9 100644 --- a/src/core/models/invitation.py +++ b/src/core/models/invitation.py @@ -9,14 +9,46 @@ from django.core.exceptions import ValidationError from django.db import models, transaction from django.db.models import Max from django.utils.translation import gettext_lazy as _ +from rules.contrib.models import RulesModel +from rules.predicates import predicate from core.fields import ConferenceReference from core.models.activitylog import ActivityLogChange +from core.predicates import has_perms if TYPE_CHECKING: # pragma: no cover from core.models.users import PlatformUser +@predicate +def is_requester(user: 'PlatformUser', invitation: 'Invitation | None' = None) -> bool: + if invitation is None: # pragma: no cover + return False + match invitation.type: + case Invitation.InvitationType.HABITAT: + return user.has_perm('core.change_assembly', invitation.requester) + case _: # pragma: no cover + raise NotImplementedError + + +@predicate +def is_requested(user: 'PlatformUser', invitation: 'Invitation | None' = None) -> bool: + if invitation is None: # pragma: no cover + return False + match invitation.type: + case Invitation.InvitationType.HABITAT: + return user.has_perm('core.change_assembly', invitation.requested) + case _: # pragma: no cover + raise NotImplementedError + + +@predicate +def state_is_requested(user: 'PlatformUser', invitation: 'Invitation | None' = None) -> bool: + if invitation is None: # pragma: no cover + return False + return invitation.state == Invitation.RequestsState.REQUESTED + + class InvitationAlreadyExists(ValidationError): """ This exception is raised when an invitation already exists between a requester and a requested entity. @@ -26,7 +58,7 @@ class InvitationAlreadyExists(ValidationError): self.invitation = invitation super().__init__( _('Invitation__error__already_exists %(requester)s %(requested)s %(sender)s'), - params={'requester': invitation.requester, 'requested': invitation.requested, 'sender': invitation.sender}, + params={'requester': invitation.requester.name, 'requested': invitation.requested.name, 'sender': invitation.sender.name}, code='already_exists', ) @@ -70,12 +102,14 @@ class InvitationType(models.TextChoices): HABITAT: Invitation of ah Assembly for a habitat. """ - ASSEMBLY_MEMBER = 'ASSEMBLY_MEMBER', _('Assembly Member') - TEAM_MEMBER = 'TEAM_MEMBER', _('Team Member') - HABITAT = 'HABITAT', _('Habitat') + ASSEMBLY_MEMBER = 'ASSEMBLY_TO_MEMBER', _('Invitation__type__assembly_to_member') + MEMBER_TO_ASSEMBLY = 'MEMBER_TO_ASSEMBLY', _('Invitation__type__member_to_assembly') + TEAM_TO_MEMBER = 'TEAM_TO_MEMBER', _('Invitation__type__team_to_member') + MEMBER_TO_TEAM = 'MEMBER_TO_TEAM', _('Invitation__type__member_to_team') + HABITAT = 'HABITAT', _('Invitation__type__habitat') -class Invitation(models.Model): +class Invitation(RulesModel): """ Model representing an invitation. @@ -95,6 +129,12 @@ class Invitation(models.Model): models.Index(fields=['requester_type', 'requester_id'], name='requester_idx'), models.Index(fields=['requested_type', 'requested_id'], name='requested_idx'), ] + rules_permissions = { + 'view': has_perms('core.view_invitation', require_staff=True) | is_requester | is_requested, + 'change': (has_perms('core.change_invitation', require_staff=True) | is_requester) & state_is_requested, + 'accept': has_perms('core.change_invitation', require_staff=True) | is_requested, + 'delete': has_perms('core.delete_invitation', require_staff=True), + } verbose_name = _('Invitation__name') verbose_name_plural = _('Invitation__name__plural') @@ -131,7 +171,7 @@ class Invitation(models.Model): ) type = models.CharField( - max_length=20, + max_length=25, choices=InvitationType.choices, verbose_name=_('Invitation__type'), ) @@ -187,12 +227,15 @@ class Invitation(models.Model): Raises: NotImplementedError: Raied when the invitation type is not yet implemented. """ - self.decision_by = user - match self.type: - case InvitationType.HABITAT: - self.accept_habitat() - case _: - raise NotImplementedError + with transaction.atomic(): + self.decision_by = user + self.state = RequestsState.ACCEPTED + self.save() + match self.type: + case InvitationType.HABITAT: + self.accept_habitat() + case _: # pragma: no cover + raise NotImplementedError def accept_habitat(self): """Accept the invitation for a joining a habitat. @@ -214,21 +257,17 @@ class Invitation(models.Model): assembly = self.requested parent_change = ActivityLogChange(old=assembly.parent.name, new=habitat.name) if assembly.parent else ActivityLogChange(new=habitat.name) old_children = ', '.join(habitat.children.all().values_list('name', flat=True)) - with transaction.atomic(): - assembly.parent = habitat - assembly.save() - - self.state = RequestsState.ACCEPTED - self.save() + assembly.parent = habitat + assembly.save() - assembly.log_activity( - self.decision_by, - parent=parent_change, - ) - habitat.log_activity( - self.decision_by, - children=ActivityLogChange(old=old_children, new=', '.join(habitat.children.all().values_list('name', flat=True))), - ) + assembly.log_activity( + self.decision_by, + parent=parent_change, + ) + habitat.log_activity( + self.decision_by, + children=ActivityLogChange(old=old_children, new=', '.join(habitat.children.all().values_list('name', flat=True))), + ) def reject(self, user: 'PlatformUser'): """Reject the invitation.