From 714cd9be5452adf635b4e602c19c83b07e63074b Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 21 Oct 2025 15:10:57 +0200 Subject: [PATCH] Implement organization delete, fix style, use rules ref #19 --- src/servala/core/models/organization.py | 1 + src/servala/core/rules.py | 10 +- .../frontend/organizations/update.html | 102 ++++++++++-------- src/servala/frontend/urls.py | 5 + src/servala/frontend/views/__init__.py | 2 + src/servala/frontend/views/organization.py | 93 ++++++++++++++-- 6 files changed, 159 insertions(+), 54 deletions(-) diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index 26435f3..bbcc16f 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -468,6 +468,7 @@ class OrganizationInvitation(ServalaModelMixin, models.Model): class urls(urlman.Urls): accept = "/invitations/{self.secret}/accept/" + delete = "{self.organization.urls.details}invitations/{self.pk}/delete/" class Meta: verbose_name = _("Organization invitation") diff --git a/src/servala/core/rules.py b/src/servala/core/rules.py index cf4dc1c..e1a0992 100644 --- a/src/servala/core/rules.py +++ b/src/servala/core/rules.py @@ -14,20 +14,26 @@ def has_organization_role(user, org, roles): @rules.predicate def is_organization_owner(user, obj): + from servala.core.models.organization import OrganizationRole + if hasattr(obj, "organization"): org = obj.organization else: org = obj - return has_organization_role(user, org, ["owner"]) + return has_organization_role(user, org, [OrganizationRole.OWNER]) @rules.predicate def is_organization_admin(user, obj): + from servala.core.models.organization import OrganizationRole + if hasattr(obj, "organization"): org = obj.organization else: org = obj - return has_organization_role(user, org, ["owner", "admin"]) + return has_organization_role( + user, org, [OrganizationRole.OWNER, OrganizationRole.ADMIN] + ) @rules.predicate diff --git a/src/servala/frontend/templates/frontend/organizations/update.html b/src/servala/frontend/templates/frontend/organizations/update.html index 2785e9a..73c2c69 100644 --- a/src/servala/frontend/templates/frontend/organizations/update.html +++ b/src/servala/frontend/templates/frontend/organizations/update.html @@ -67,43 +67,66 @@ +{% endpartialdef members-list %} +{% partialdef pending-invitations-card %} {% if pending_invitations %} -
- {% translate "Pending Invitations" %} -
-
- - - - - - - - - - - {% for invitation in pending_invitations %} - - - - - - - {% endfor %} - -
{% translate "Email" %}{% translate "Role" %}{% translate "Sent" %}{% translate "Link" %}
{{ invitation.email }} - - {{ invitation.get_role_display }} - - {{ invitation.created_at|date:"Y-m-d H:i" }} - -
+
+
+

+ {% translate "Pending Invitations" %} +

+
+
+
+
+ + + + + + + + + + + {% for invitation in pending_invitations %} + + + + + + + {% endfor %} + +
{% translate "Email" %}{% translate "Role" %}{% translate "Sent" %}{% translate "Actions" %}
{{ invitation.email }} + + {{ invitation.get_role_display }} + + {{ invitation.created_at|date:"Y-m-d H:i" }} + +
+ {% csrf_token %} + + +
+
+
+
+
{% endif %} -{% endpartialdef members-list %} +{% endpartialdef pending-invitations-card %} {% block content %}
@@ -214,6 +237,7 @@
{% partial members-list %}
+
{% partial pending-invitations-card %}

@@ -238,18 +262,12 @@

-
+ {% csrf_token %} - -
{{ invitation_form }}
-
diff --git a/src/servala/frontend/urls.py b/src/servala/frontend/urls.py index 3aa9b08..73d0759 100644 --- a/src/servala/frontend/urls.py +++ b/src/servala/frontend/urls.py @@ -30,6 +30,11 @@ urlpatterns = [ views.OrganizationUpdateView.as_view(), name="organization.details", ), + path( + "details/invitations//delete/", + views.InvitationDeleteView.as_view(), + name="invitation.delete", + ), path( "services/", views.ServiceListView.as_view(), diff --git a/src/servala/frontend/views/__init__.py b/src/servala/frontend/views/__init__.py index 6167221..33b0560 100644 --- a/src/servala/frontend/views/__init__.py +++ b/src/servala/frontend/views/__init__.py @@ -9,6 +9,7 @@ from .generic import ( ) from .organization import ( InvitationAcceptView, + InvitationDeleteView, OrganizationCreateView, OrganizationDashboardView, OrganizationUpdateView, @@ -27,6 +28,7 @@ from .support import SupportView __all__ = [ "IndexView", "InvitationAcceptView", + "InvitationDeleteView", "LogoutView", "OrganizationCreateView", "OrganizationDashboardView", diff --git a/src/servala/frontend/views/organization.py b/src/servala/frontend/views/organization.py index 63c84bb..c4c1336 100644 --- a/src/servala/frontend/views/organization.py +++ b/src/servala/frontend/views/organization.py @@ -5,7 +5,7 @@ from django.utils import timezone from django.utils.decorators import method_decorator from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ -from django.views.generic import CreateView, DetailView, TemplateView +from django.views.generic import CreateView, DeleteView, DetailView, TemplateView from django_scopes import scopes_disabled from rules.contrib.views import AutoPermissionRequiredMixin @@ -14,7 +14,6 @@ from servala.core.models import ( Organization, OrganizationInvitation, OrganizationMembership, - OrganizationRole, ServiceInstance, ) from servala.frontend.forms.organization import ( @@ -22,7 +21,11 @@ from servala.frontend.forms.organization import ( OrganizationForm, OrganizationInvitationForm, ) -from servala.frontend.views.mixins import HtmxUpdateView, OrganizationViewMixin +from servala.frontend.views.mixins import ( + HtmxUpdateView, + HtmxViewMixin, + OrganizationViewMixin, +) class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView): @@ -108,10 +111,8 @@ class OrganizationDashboardView( return context -class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView): +class OrganizationMembershipMixin: template_name = "frontend/organizations/update.html" - form_class = OrganizationForm - fragments = ("org-name", "org-name-edit", "members-list") @cached_property def user_role(self): @@ -126,10 +127,9 @@ class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView): @cached_property def can_manage_members(self): - return self.user_role in [ - OrganizationRole.ADMIN, - OrganizationRole.OWNER, - ] + return self.request.user.has_perm( + "core.change_organization", self.request.organization + ) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -159,6 +159,18 @@ class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView): return context + +class OrganizationUpdateView( + OrganizationViewMixin, OrganizationMembershipMixin, HtmxUpdateView +): + form_class = OrganizationForm + fragments = ( + "org-name", + "org-name-edit", + "members-list", + "pending-invitations-card", + ) + def post(self, request, *args, **kwargs): if "invite_email" in request.POST: return self.handle_invitation(request) @@ -202,6 +214,9 @@ class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView): for error_msg in error: messages.error(request, error_msg) + if self.is_htmx and self._get_fragment(): + return self.get(request, *self.args, **self.kwargs) + return redirect(self.get_success_url()) def get_success_url(self): @@ -260,3 +275,61 @@ class InvitationAcceptView(TemplateView): request.session.pop("invitation_next", None) return redirect(invitation.organization.urls.base) + + +class InvitationDeleteView(HtmxViewMixin, OrganizationMembershipMixin, DeleteView): + model = OrganizationInvitation + http_method_names = ["get", "post"] + fragments = ("pending-invitations-card",) + + def get_queryset(self): + return OrganizationInvitation.objects.filter(accepted_by__isnull=True) + + def get_success_url(self): + return self.object.organization.urls.details + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + organization = self.request.organization + context["pending_invitations"] = OrganizationInvitation.objects.filter( + organization=organization, accepted_by__isnull=True + ).order_by("-created_at") + return context + + def _check_permission(self): + return self.request.user.has_perm( + "core.change_organization", self.request.organization + ) + + def get_object(self): + if self.request.method == "POST" and self.is_htmx: + try: + return super().get_object() + except Exception: + return + return super().get_object() + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + organization = self.object.organization + + if not self._check_permission(): + if not self.is_htmx: + messages.error( + request, + _("You do not have permission to delete this invitation."), + ) + return redirect(organization.urls.details) + + email = self.object.email + self.object.delete() + if not self.is_htmx: + messages.success( + request, + _("Invitation for {email} has been deleted.").format(email=email), + ) + + if self.is_htmx and self._get_fragment(): + return self.get(request, *args, **kwargs) + + return redirect(self.get_success_url())