Implement organization delete, fix style, use rules
All checks were successful
Tests / test (push) Successful in 27s

ref #19
This commit is contained in:
Tobias Kunze 2025-10-21 15:10:57 +02:00
parent 892a19bbcc
commit 714cd9be54
6 changed files with 159 additions and 54 deletions

View file

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

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

@ -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">
@ -238,18 +262,12 @@
</li>
</ul>
</div>
<form class="form"
hx-post="{{ request.path }}"
hx-target="#pending-invitations-card"
hx-swap="outerHTML"
hx-on::after-request="if(event.detail.successful) this.reset()">
<form method="post" class="form">
{% csrf_token %}
<input type="hidden" name="fragment" value="pending-invitations-card">
<input type="hidden" name="invite_email" value="1">
<div class="row">{{ invitation_form }}</div>
<div class="row mt-3">
<div class="col-12">
<button type="submit" class="btn btn-primary">
<button type="submit" class="btn btn-primary" name="invite_email" value="1">
<i class="bi bi-send"></i> {% translate "Send Invitation" %}
</button>
</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)
@ -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())