Compare commits

...

7 commits

Author SHA1 Message Date
714cd9be54 Implement organization delete, fix style, use rules
All checks were successful
Tests / test (push) Successful in 27s
ref #19
2025-10-21 15:10:57 +02:00
892a19bbcc Explain user roles
ref #19
2025-10-21 14:54:12 +02:00
7c6464330d Remove star in front of error message
ref #19
2025-10-21 13:29:07 +02:00
45b2b93aba Add Invites to auditlog
ref #19
2025-10-21 10:03:34 +02:00
850a791851 Handle whole form sections being advanced
ref #204
2025-10-20 15:02:41 +02:00
864c0ffc06 Fix advanced fields not working with categories
ref #204
2025-10-20 13:54:33 +02:00
359bc58749 Fix advanced fields not working with array fields
ref #204
2025-10-20 11:56:35 +02:00
9 changed files with 211 additions and 55 deletions

View file

@ -328,9 +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:
if self.is_field_advanced(name):
field.widget.attrs.update(
{
"class": (
@ -356,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 = []
@ -371,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(
[
@ -437,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
@ -453,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
@ -463,6 +479,7 @@ class CrdModelFormMixin:
"fields": others,
"fieldsets": [],
"has_mandatory": self.has_mandatory_fields(others),
"is_advanced": self.are_all_fields_advanced(others),
}
)

View file

@ -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
@ -467,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")
@ -536,3 +538,7 @@ The Servala Team"""
recipient_list=[self.email],
fail_silently=False,
)
auditlog.register(OrganizationInvitation, serialize_data=True)
auditlog.register(OrganizationMembership, serialize_data=True)

View file

@ -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

View file

@ -1,6 +1,9 @@
<div class="dynamic-array-widget"
id="{{ widget.attrs.id|default:'id_'|add:widget.name }}_container"
data-name="{{ widget.name }}">
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 %}>
<div class="array-items">
{% for item in value_list %}
<div class="array-item d-flex mb-2">

View file

@ -67,43 +67,66 @@
</tbody>
</table>
</div>
{% endpartialdef members-list %}
{% partialdef pending-invitations-card %}
{% if pending_invitations %}
<h5 class="mt-4">
<i class="bi bi-envelope"></i> {% translate "Pending Invitations" %}
</h5>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>{% translate "Email" %}</th>
<th>{% translate "Role" %}</th>
<th>{% translate "Sent" %}</th>
<th>{% translate "Link" %}</th>
</tr>
</thead>
<tbody>
{% for invitation in pending_invitations %}
<tr>
<td>{{ invitation.email }}</td>
<td>
<span class="badge bg-{% if invitation.role == 'owner' %}primary{% elif invitation.role == 'admin' %}info{% else %}secondary{% endif %}">
{{ invitation.get_role_display }}
</span>
</td>
<td>{{ invitation.created_at|date:"Y-m-d H:i" }}</td>
<td>
<button class="btn btn-sm btn-outline-secondary"
onclick="navigator.clipboard.writeText('{{ request.scheme }}://{{ request.get_host }}{{ invitation.urls.accept }}'); this.textContent='Copied!'">
<i class="bi bi-clipboard"></i> {% translate "Copy Link" %}
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="card">
<div class="card-header">
<h4 class="card-title">
<i class="bi bi-envelope"></i> {% translate "Pending Invitations" %}
</h4>
</div>
<div class="card-content">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>{% translate "Email" %}</th>
<th>{% translate "Role" %}</th>
<th>{% translate "Sent" %}</th>
<th>{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for invitation in pending_invitations %}
<tr>
<td>{{ invitation.email }}</td>
<td>
<span class="badge bg-{% if invitation.role == 'owner' %}primary{% elif invitation.role == 'admin' %}info{% else %}secondary{% endif %}">
{{ invitation.get_role_display }}
</span>
</td>
<td>{{ invitation.created_at|date:"Y-m-d H:i" }}</td>
<td>
<button class="btn btn-sm btn-outline-secondary"
onclick="navigator.clipboard.writeText('{{ request.scheme }}://{{ request.get_host }}{{ invitation.urls.accept }}'); this.textContent='Copied!'">
<i class="bi bi-clipboard"></i> {% translate "Copy Link" %}
</button>
<form method="post"
action="{{ invitation.urls.delete }}"
style="display: inline"
hx-post="{{ invitation.urls.delete }}"
hx-target="#pending-invitations-card"
hx-swap="outerHTML"
hx-confirm="{% translate 'Are you sure you want to delete this invitation?' %}">
{% csrf_token %}
<input type="hidden" name="fragment" value="pending-invitations-card">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i> {% translate "Delete" %}
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endif %}
{% endpartialdef members-list %}
{% endpartialdef pending-invitations-card %}
{% block content %}
<section class="section">
<div class="card">
@ -214,6 +237,7 @@
<div class="card-body">{% partial members-list %}</div>
</div>
</div>
<div id="pending-invitations-card">{% partial pending-invitations-card %}</div>
<div class="card">
<div class="card-header">
<h4 class="card-title">
@ -222,6 +246,22 @@
</div>
<div class="card-content">
<div class="card-body">
<div class="alert alert-light mb-3">
<h6>
<i class="bi bi-info-circle"></i> {% translate "Role Permissions" %}
</h6>
<ul class="mb-0">
<li>
<strong>{% translate "Owner" %}:</strong> {% translate "Can manage all organization settings, members, services, and can appoint administrators." %}
</li>
<li>
<strong>{% translate "Administrator" %}:</strong> {% translate "Can manage members, invite users, and manage all services and instances." %}
</li>
<li>
<strong>{% translate "Member" %}:</strong> {% translate "Can view organization details, create and manage their own service instances." %}
</li>
</ul>
</div>
<form method="post" class="form">
{% csrf_token %}
<div class="row">{{ invitation_form }}</div>

View file

@ -21,7 +21,8 @@
<ul class="nav nav-tabs" id="myTab" role="tablist">
{% for fieldset in form.get_fieldsets %}
{% if not fieldset.hidden %}
<li class="nav-item" role="presentation">
<li class="nav-item{% if fieldset.is_advanced %} advanced-field-group collapse{% endif %}"
role="presentation">
<button class="nav-link {% if forloop.first %}active{% endif %}{% if fieldset.has_mandatory %} has-mandatory{% endif %}"
id="{{ fieldset.title|slugify }}-tab"
data-bs-toggle="tab"
@ -48,10 +49,12 @@
{% endfor %}
{% for subfieldset in fieldset.fieldsets %}
{% if subfieldset.fields %}
<h4 class="mt-3">{{ subfieldset.title }}</h4>
{% for field in subfieldset.fields %}
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
{% endfor %}
<div {% if subfieldset.is_advanced %}class="advanced-field-group collapse"{% endif %}>
<h4 class="mt-3">{{ subfieldset.title }}</h4>
{% for field in subfieldset.fields %}
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
{% endfor %}
</div>
{% endif %}
{% endfor %}
</div>

View file

@ -30,6 +30,11 @@ urlpatterns = [
views.OrganizationUpdateView.as_view(),
name="organization.details",
),
path(
"details/invitations/<int:pk>/delete/",
views.InvitationDeleteView.as_view(),
name="invitation.delete",
),
path(
"services/",
views.ServiceListView.as_view(),

View file

@ -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",

View file

@ -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)
@ -199,7 +211,11 @@ 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)
if self.is_htmx and self._get_fragment():
return self.get(request, *self.args, **self.kwargs)
return redirect(self.get_success_url())
@ -259,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())