Compare commits
7 commits
ce34afa10a
...
714cd9be54
| Author | SHA1 | Date | |
|---|---|---|---|
| 714cd9be54 | |||
| 892a19bbcc | |||
| 7c6464330d | |||
| 45b2b93aba | |||
| 850a791851 | |||
| 864c0ffc06 | |||
| 359bc58749 |
9 changed files with 211 additions and 55 deletions
|
|
@ -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),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue