From 359bc587496d915a7aa0bf8fdf294ebf50b48cfc Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 20 Oct 2025 11:56:35 +0200 Subject: [PATCH 1/7] Fix advanced fields not working with array fields ref #204 --- .../frontend/templates/frontend/forms/dynamic_array.html | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/servala/frontend/templates/frontend/forms/dynamic_array.html b/src/servala/frontend/templates/frontend/forms/dynamic_array.html index 4b7e68c..9d61825 100644 --- a/src/servala/frontend/templates/frontend/forms/dynamic_array.html +++ b/src/servala/frontend/templates/frontend/forms/dynamic_array.html @@ -1,6 +1,9 @@
+ data-name="{{ widget.name }}" + {% for name, value in widget.attrs.items %}{% if value is not False and name != "id" and name != "class" %} {{ name }}{% if value is not True %}="{{ value|stringformat:'s' }}"{% endif %} + {% endif %} + {% endfor %}>
{% for item in value_list %}
From 864c0ffc06c7c597a55d6048cea775736df14a61 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 20 Oct 2025 13:54:33 +0200 Subject: [PATCH 2/7] Fix advanced fields not working with categories ref #204 --- src/servala/core/crd.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index 276e9c2..35d9240 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -330,7 +330,9 @@ class CrdModelFormMixin: # Mark advanced fields with a CSS class and data attribute advanced_fields = getattr(self, "ADVANCED_FIELDS", []) for name, field in self.fields.items(): - if name in advanced_fields: + if name in advanced_fields or any( + name.startswith(f"{af}.") for af in advanced_fields + ): field.widget.attrs.update( { "class": ( From 850a79185117bccf0c762b9c51f2385be5169658 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 20 Oct 2025 15:02:41 +0200 Subject: [PATCH 3/7] Handle whole form sections being advanced ref #204 --- src/servala/core/crd.py | 23 +++++++++++++++---- .../includes/tabbed_fieldset_form.html | 13 +++++++---- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index 35d9240..fe8edbb 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -328,11 +328,8 @@ class CrdModelFormMixin: field.required = False # Mark advanced fields with a CSS class and data attribute - advanced_fields = getattr(self, "ADVANCED_FIELDS", []) for name, field in self.fields.items(): - if name in advanced_fields or any( - name.startswith(f"{af}.") for af in advanced_fields - ): + if self.is_field_advanced(name): field.widget.attrs.update( { "class": ( @@ -358,6 +355,17 @@ class CrdModelFormMixin: return True return False + def is_field_advanced(self, field_name): + advanced_fields = getattr(self, "ADVANCED_FIELDS", []) + return field_name in advanced_fields or any( + field_name.startswith(f"{af}.") for af in advanced_fields + ) + + def are_all_fields_advanced(self, field_list): + if not field_list: + return False + return all(self.is_field_advanced(field_name) for field_name in field_list) + def get_fieldsets(self): fieldsets = [] @@ -373,6 +381,7 @@ class CrdModelFormMixin: "fields": general_fields, "fieldsets": [], "has_mandatory": self.has_mandatory_fields(general_fields), + "is_advanced": self.are_all_fields_advanced(general_fields), } if all( [ @@ -439,6 +448,9 @@ class CrdModelFormMixin: title = f"{fieldset['title']}: {sub_fieldset['title']}: " for field in sub_fieldset["fields"]: self.strip_title(field, title) + sub_fieldset["is_advanced"] = self.are_all_fields_advanced( + sub_fieldset["fields"] + ) nested_fieldsets_list.append(sub_fieldset) fieldset["fieldsets"] = nested_fieldsets_list @@ -455,6 +467,8 @@ class CrdModelFormMixin: all_fields.extend(sub_fieldset["fields"]) fieldset["has_mandatory"] = self.has_mandatory_fields(all_fields) + fieldset["is_advanced"] = self.are_all_fields_advanced(all_fields) + fieldsets.append(fieldset) # Add 'others' tab if there are any fields @@ -465,6 +479,7 @@ class CrdModelFormMixin: "fields": others, "fieldsets": [], "has_mandatory": self.has_mandatory_fields(others), + "is_advanced": self.are_all_fields_advanced(others), } ) diff --git a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html index 5857bdf..74fa22a 100644 --- a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html +++ b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html @@ -21,7 +21,8 @@
From 45b2b93aba8dc6f743dac21354768e30193b6160 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 21 Oct 2025 10:03:34 +0200 Subject: [PATCH 4/7] Add Invites to auditlog ref #19 --- src/servala/core/models/organization.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index 1669f39..26435f3 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -2,6 +2,7 @@ import secrets import rules import urlman +from auditlog.registry import auditlog from django.conf import settings from django.contrib.sites.shortcuts import get_current_site from django.core.mail import send_mail @@ -536,3 +537,7 @@ The Servala Team""" recipient_list=[self.email], fail_silently=False, ) + + +auditlog.register(OrganizationInvitation, serialize_data=True) +auditlog.register(OrganizationMembership, serialize_data=True) From 7c6464330ddeea5daea2039f0d09f35009baec24 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 21 Oct 2025 13:29:07 +0200 Subject: [PATCH 5/7] Remove star in front of error message ref #19 --- src/servala/frontend/views/organization.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/servala/frontend/views/organization.py b/src/servala/frontend/views/organization.py index 7013a6c..63c84bb 100644 --- a/src/servala/frontend/views/organization.py +++ b/src/servala/frontend/views/organization.py @@ -199,7 +199,8 @@ class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView): ) else: for error in form.errors.values(): - messages.error(request, error.as_text()) + for error_msg in error: + messages.error(request, error_msg) return redirect(self.get_success_url()) From 892a19bbcc2a2d4f228a812f4850d6aa1447a5e8 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 21 Oct 2025 14:54:12 +0200 Subject: [PATCH 6/7] Explain user roles ref #19 --- .../frontend/organizations/update.html | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/servala/frontend/templates/frontend/organizations/update.html b/src/servala/frontend/templates/frontend/organizations/update.html index d55dc56..2785e9a 100644 --- a/src/servala/frontend/templates/frontend/organizations/update.html +++ b/src/servala/frontend/templates/frontend/organizations/update.html @@ -222,12 +222,34 @@
-
+
+
+ {% translate "Role Permissions" %} +
+
    +
  • + {% translate "Owner" %}: {% translate "Can manage all organization settings, members, services, and can appoint administrators." %} +
  • +
  • + {% translate "Administrator" %}: {% translate "Can manage members, invite users, and manage all services and instances." %} +
  • +
  • + {% translate "Member" %}: {% translate "Can view organization details, create and manage their own service instances." %} +
  • +
+
+ {% csrf_token %} + +
{{ invitation_form }}
-
From 714cd9be5452adf635b4e602c19c83b07e63074b Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 21 Oct 2025 15:10:57 +0200 Subject: [PATCH 7/7] 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())