parent
09ab83d1e4
commit
21c26f9e5d
3 changed files with 256 additions and 3 deletions
|
|
@ -1,8 +1,9 @@
|
|||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.forms import ModelForm
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from servala.core.models import Organization
|
||||
from servala.core.models import Organization, OrganizationInvitation, OrganizationRole
|
||||
from servala.core.odoo import get_invoice_addresses, get_odoo_countries
|
||||
from servala.frontend.forms.mixins import HtmxMixin
|
||||
|
||||
|
|
@ -111,3 +112,68 @@ class OrganizationCreateForm(OrganizationForm):
|
|||
"existing_odoo_address_id", _("Please select an invoice address.")
|
||||
)
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class OrganizationInvitationForm(forms.ModelForm):
|
||||
|
||||
def __init__(self, *args, organization=None, user_role=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.organization = organization
|
||||
self.user_role = user_role
|
||||
|
||||
if user_role:
|
||||
allowed_roles = self._get_allowed_roles(user_role)
|
||||
self.fields["role"].choices = [
|
||||
(value, label)
|
||||
for value, label in OrganizationRole.choices
|
||||
if value in allowed_roles
|
||||
]
|
||||
|
||||
def _get_allowed_roles(self, user_role):
|
||||
role_hierarchy = {
|
||||
OrganizationRole.OWNER: [
|
||||
OrganizationRole.OWNER,
|
||||
OrganizationRole.ADMIN,
|
||||
OrganizationRole.MEMBER,
|
||||
],
|
||||
OrganizationRole.ADMIN: [
|
||||
OrganizationRole.ADMIN,
|
||||
OrganizationRole.MEMBER,
|
||||
],
|
||||
OrganizationRole.MEMBER: [],
|
||||
}
|
||||
return role_hierarchy.get(user_role, [])
|
||||
|
||||
def clean_email(self):
|
||||
email = self.cleaned_data["email"].lower()
|
||||
|
||||
if self.organization.members.filter(email__iexact=email).exists():
|
||||
raise ValidationError(
|
||||
_("A user with this email is already a member of this organization.")
|
||||
)
|
||||
|
||||
if OrganizationInvitation.objects.filter(
|
||||
organization=self.organization,
|
||||
email__iexact=email,
|
||||
accepted_by__isnull=True,
|
||||
).exists():
|
||||
raise ValidationError(
|
||||
_("An invitation has already been sent to this email address.")
|
||||
)
|
||||
|
||||
return email
|
||||
|
||||
def save(self, commit=True):
|
||||
invitation = super().save(commit=False)
|
||||
invitation.organization = self.organization
|
||||
if commit:
|
||||
invitation.save()
|
||||
return invitation
|
||||
|
||||
class Meta:
|
||||
model = OrganizationInvitation
|
||||
fields = ("email", "role")
|
||||
widgets = {
|
||||
"email": forms.EmailInput(attrs={"placeholder": _("user@example.com")}),
|
||||
"role": forms.RadioSelect(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,74 @@
|
|||
</form>
|
||||
</td>
|
||||
{% endpartialdef org-name-edit %}
|
||||
{% partialdef members-list %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% translate "Name" %}</th>
|
||||
<th>{% translate "Email" %}</th>
|
||||
<th>{% translate "Role" %}</th>
|
||||
<th>{% translate "Joined" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for membership in memberships %}
|
||||
<tr>
|
||||
<td>{{ membership.user }}</td>
|
||||
<td>{{ membership.user.email }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{% if membership.role == 'owner' %}primary{% elif membership.role == 'admin' %}info{% else %}secondary{% endif %}">
|
||||
{{ membership.get_role_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ membership.date_joined|date:"Y-m-d" }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-muted text-center">{% translate "No members yet" %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
{% endpartialdef members-list %}
|
||||
{% block content %}
|
||||
<section class="section">
|
||||
<div class="card">
|
||||
|
|
@ -135,5 +203,39 @@
|
|||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if can_manage_members %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title">
|
||||
<i class="bi bi-people"></i> {% translate "Members" %}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-body">{% partial members-list %}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title">
|
||||
<i class="bi bi-person-plus"></i> {% translate "Invite New Member" %}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-body">
|
||||
<form method="post" class="form">
|
||||
{% csrf_token %}
|
||||
<div class="row">{{ invitation_form }}</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary" name="invite_email" value="1">
|
||||
<i class="bi bi-send"></i> {% translate "Send Invitation" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock content %}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from django.shortcuts import get_object_or_404, redirect
|
|||
from django.urls import reverse
|
||||
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_scopes import scopes_disabled
|
||||
|
|
@ -13,9 +14,14 @@ from servala.core.models import (
|
|||
Organization,
|
||||
OrganizationInvitation,
|
||||
OrganizationMembership,
|
||||
OrganizationRole,
|
||||
ServiceInstance,
|
||||
)
|
||||
from servala.frontend.forms.organization import OrganizationCreateForm, OrganizationForm
|
||||
from servala.frontend.forms.organization import (
|
||||
OrganizationCreateForm,
|
||||
OrganizationForm,
|
||||
OrganizationInvitationForm,
|
||||
)
|
||||
from servala.frontend.views.mixins import HtmxUpdateView, OrganizationViewMixin
|
||||
|
||||
|
||||
|
|
@ -105,7 +111,86 @@ class OrganizationDashboardView(
|
|||
class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView):
|
||||
template_name = "frontend/organizations/update.html"
|
||||
form_class = OrganizationForm
|
||||
fragments = ("org-name", "org-name-edit")
|
||||
fragments = ("org-name", "org-name-edit", "members-list")
|
||||
|
||||
@cached_property
|
||||
def user_role(self):
|
||||
membership = (
|
||||
OrganizationMembership.objects.filter(
|
||||
user=self.request.user, organization=self.get_object()
|
||||
)
|
||||
.order_by("role")
|
||||
.first()
|
||||
)
|
||||
return membership.role if membership else None
|
||||
|
||||
@cached_property
|
||||
def can_manage_members(self):
|
||||
return self.user_role in [
|
||||
OrganizationRole.ADMIN,
|
||||
OrganizationRole.OWNER,
|
||||
]
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
organization = self.get_object()
|
||||
|
||||
if self.can_manage_members:
|
||||
memberships = (
|
||||
OrganizationMembership.objects.filter(organization=organization)
|
||||
.select_related("user")
|
||||
.order_by("role", "user__email")
|
||||
)
|
||||
pending_invitations = OrganizationInvitation.objects.filter(
|
||||
organization=organization, accepted_by__isnull=True
|
||||
).order_by("-created_at")
|
||||
invitation_form = OrganizationInvitationForm(
|
||||
organization=organization, user_role=self.user_role
|
||||
)
|
||||
context.update(
|
||||
{
|
||||
"memberships": memberships,
|
||||
"pending_invitations": pending_invitations,
|
||||
"invitation_form": invitation_form,
|
||||
"can_manage_members": self.can_manage_members,
|
||||
"user_role": self.user_role,
|
||||
}
|
||||
)
|
||||
|
||||
return context
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
if "invite_email" in request.POST:
|
||||
return self.handle_invitation(request)
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
def handle_invitation(self, request):
|
||||
organization = self.get_object()
|
||||
if not self.can_manage_members:
|
||||
messages.error(request, _("You do not have permission to invite members."))
|
||||
return redirect(self.get_success_url())
|
||||
|
||||
form = OrganizationInvitationForm(
|
||||
request.POST, organization=organization, user_role=self.user_role
|
||||
)
|
||||
|
||||
if form.is_valid():
|
||||
invitation = form.save(commit=False)
|
||||
invitation.created_by = request.user
|
||||
invitation.save()
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
_("Invitation sent to {email}. Share this link: {url}").format(
|
||||
email=invitation.email,
|
||||
url=request.build_absolute_uri(invitation.urls.accept),
|
||||
),
|
||||
)
|
||||
else:
|
||||
for error in form.errors.values():
|
||||
messages.error(request, error.as_text())
|
||||
|
||||
return redirect(self.get_success_url())
|
||||
|
||||
def get_success_url(self):
|
||||
return self.request.path
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue