October feature list #226
6 changed files with 159 additions and 54 deletions
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue