October feature list #226

Merged
tobru merged 36 commits from october into main 2025-10-22 13:43:34 +00:00
6 changed files with 159 additions and 54 deletions
Showing only changes of commit 714cd9be54 - Show all commits

View file

@ -468,6 +468,7 @@ class OrganizationInvitation(ServalaModelMixin, models.Model):
class urls(urlman.Urls): class urls(urlman.Urls):
accept = "/invitations/{self.secret}/accept/" accept = "/invitations/{self.secret}/accept/"
delete = "{self.organization.urls.details}invitations/{self.pk}/delete/"
class Meta: class Meta:
verbose_name = _("Organization invitation") verbose_name = _("Organization invitation")

View file

@ -14,20 +14,26 @@ def has_organization_role(user, org, roles):
@rules.predicate @rules.predicate
def is_organization_owner(user, obj): def is_organization_owner(user, obj):
from servala.core.models.organization import OrganizationRole
if hasattr(obj, "organization"): if hasattr(obj, "organization"):
org = obj.organization org = obj.organization
else: else:
org = obj org = obj
return has_organization_role(user, org, ["owner"]) return has_organization_role(user, org, [OrganizationRole.OWNER])
@rules.predicate @rules.predicate
def is_organization_admin(user, obj): def is_organization_admin(user, obj):
from servala.core.models.organization import OrganizationRole
if hasattr(obj, "organization"): if hasattr(obj, "organization"):
org = obj.organization org = obj.organization
else: else:
org = obj org = obj
return has_organization_role(user, org, ["owner", "admin"]) return has_organization_role(
user, org, [OrganizationRole.OWNER, OrganizationRole.ADMIN]
)
@rules.predicate @rules.predicate

View file

@ -67,43 +67,66 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{% endpartialdef members-list %}
{% partialdef pending-invitations-card %}
{% if pending_invitations %} {% if pending_invitations %}
<h5 class="mt-4"> <div class="card">
<i class="bi bi-envelope"></i> {% translate "Pending Invitations" %} <div class="card-header">
</h5> <h4 class="card-title">
<div class="table-responsive"> <i class="bi bi-envelope"></i> {% translate "Pending Invitations" %}
<table class="table table-sm"> </h4>
<thead> </div>
<tr> <div class="card-content">
<th>{% translate "Email" %}</th> <div class="card-body">
<th>{% translate "Role" %}</th> <div class="table-responsive">
<th>{% translate "Sent" %}</th> <table class="table table-hover">
<th>{% translate "Link" %}</th> <thead>
</tr> <tr>
</thead> <th>{% translate "Email" %}</th>
<tbody> <th>{% translate "Role" %}</th>
{% for invitation in pending_invitations %} <th>{% translate "Sent" %}</th>
<tr> <th>{% translate "Actions" %}</th>
<td>{{ invitation.email }}</td> </tr>
<td> </thead>
<span class="badge bg-{% if invitation.role == 'owner' %}primary{% elif invitation.role == 'admin' %}info{% else %}secondary{% endif %}"> <tbody>
{{ invitation.get_role_display }} {% for invitation in pending_invitations %}
</span> <tr>
</td> <td>{{ invitation.email }}</td>
<td>{{ invitation.created_at|date:"Y-m-d H:i" }}</td> <td>
<td> <span class="badge bg-{% if invitation.role == 'owner' %}primary{% elif invitation.role == 'admin' %}info{% else %}secondary{% endif %}">
<button class="btn btn-sm btn-outline-secondary" {{ invitation.get_role_display }}
onclick="navigator.clipboard.writeText('{{ request.scheme }}://{{ request.get_host }}{{ invitation.urls.accept }}'); this.textContent='Copied!'"> </span>
<i class="bi bi-clipboard"></i> {% translate "Copy Link" %} </td>
</button> <td>{{ invitation.created_at|date:"Y-m-d H:i" }}</td>
</td> <td>
</tr> <button class="btn btn-sm btn-outline-secondary"
{% endfor %} onclick="navigator.clipboard.writeText('{{ request.scheme }}://{{ request.get_host }}{{ invitation.urls.accept }}'); this.textContent='Copied!'">
</tbody> <i class="bi bi-clipboard"></i> {% translate "Copy Link" %}
</table> </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> </div>
{% endif %} {% endif %}
{% endpartialdef members-list %} {% endpartialdef pending-invitations-card %}
{% block content %} {% block content %}
<section class="section"> <section class="section">
<div class="card"> <div class="card">
@ -214,6 +237,7 @@
<div class="card-body">{% partial members-list %}</div> <div class="card-body">{% partial members-list %}</div>
</div> </div>
</div> </div>
<div id="pending-invitations-card">{% partial pending-invitations-card %}</div>
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h4 class="card-title"> <h4 class="card-title">
@ -238,18 +262,12 @@
</li> </li>
</ul> </ul>
</div> </div>
<form class="form" <form method="post" class="form">
hx-post="{{ request.path }}"
hx-target="#pending-invitations-card"
hx-swap="outerHTML"
hx-on::after-request="if(event.detail.successful) this.reset()">
{% csrf_token %} {% 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">{{ invitation_form }}</div>
<div class="row mt-3"> <div class="row mt-3">
<div class="col-12"> <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" %} <i class="bi bi-send"></i> {% translate "Send Invitation" %}
</button> </button>
</div> </div>

View file

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

View file

@ -9,6 +9,7 @@ from .generic import (
) )
from .organization import ( from .organization import (
InvitationAcceptView, InvitationAcceptView,
InvitationDeleteView,
OrganizationCreateView, OrganizationCreateView,
OrganizationDashboardView, OrganizationDashboardView,
OrganizationUpdateView, OrganizationUpdateView,
@ -27,6 +28,7 @@ from .support import SupportView
__all__ = [ __all__ = [
"IndexView", "IndexView",
"InvitationAcceptView", "InvitationAcceptView",
"InvitationDeleteView",
"LogoutView", "LogoutView",
"OrganizationCreateView", "OrganizationCreateView",
"OrganizationDashboardView", "OrganizationDashboardView",

View file

@ -5,7 +5,7 @@ from django.utils import timezone
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ 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 django_scopes import scopes_disabled
from rules.contrib.views import AutoPermissionRequiredMixin from rules.contrib.views import AutoPermissionRequiredMixin
@ -14,7 +14,6 @@ from servala.core.models import (
Organization, Organization,
OrganizationInvitation, OrganizationInvitation,
OrganizationMembership, OrganizationMembership,
OrganizationRole,
ServiceInstance, ServiceInstance,
) )
from servala.frontend.forms.organization import ( from servala.frontend.forms.organization import (
@ -22,7 +21,11 @@ from servala.frontend.forms.organization import (
OrganizationForm, OrganizationForm,
OrganizationInvitationForm, OrganizationInvitationForm,
) )
from servala.frontend.views.mixins import HtmxUpdateView, OrganizationViewMixin from servala.frontend.views.mixins import (
HtmxUpdateView,
HtmxViewMixin,
OrganizationViewMixin,
)
class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView): class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView):
@ -108,10 +111,8 @@ class OrganizationDashboardView(
return context return context
class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView): class OrganizationMembershipMixin:
template_name = "frontend/organizations/update.html" template_name = "frontend/organizations/update.html"
form_class = OrganizationForm
fragments = ("org-name", "org-name-edit", "members-list")
@cached_property @cached_property
def user_role(self): def user_role(self):
@ -126,10 +127,9 @@ class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView):
@cached_property @cached_property
def can_manage_members(self): def can_manage_members(self):
return self.user_role in [ return self.request.user.has_perm(
OrganizationRole.ADMIN, "core.change_organization", self.request.organization
OrganizationRole.OWNER, )
]
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
@ -159,6 +159,18 @@ class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView):
return context 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): def post(self, request, *args, **kwargs):
if "invite_email" in request.POST: if "invite_email" in request.POST:
return self.handle_invitation(request) return self.handle_invitation(request)
@ -202,6 +214,9 @@ class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView):
for error_msg in error: for error_msg in error:
messages.error(request, error_msg) 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()) return redirect(self.get_success_url())
def get_success_url(self): def get_success_url(self):
@ -260,3 +275,61 @@ class InvitationAcceptView(TemplateView):
request.session.pop("invitation_next", None) request.session.pop("invitation_next", None)
return redirect(invitation.organization.urls.base) 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())