October feature list #226
3 changed files with 256 additions and 3 deletions
|
|
@ -1,8 +1,9 @@
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.forms import ModelForm
|
from django.forms import ModelForm
|
||||||
from django.utils.translation import gettext_lazy as _
|
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.core.odoo import get_invoice_addresses, get_odoo_countries
|
||||||
from servala.frontend.forms.mixins import HtmxMixin
|
from servala.frontend.forms.mixins import HtmxMixin
|
||||||
|
|
||||||
|
|
@ -111,3 +112,68 @@ class OrganizationCreateForm(OrganizationForm):
|
||||||
"existing_odoo_address_id", _("Please select an invoice address.")
|
"existing_odoo_address_id", _("Please select an invoice address.")
|
||||||
)
|
)
|
||||||
return cleaned_data
|
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>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
{% endpartialdef org-name-edit %}
|
{% 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 %}
|
{% block content %}
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
@ -135,5 +203,39 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
</section>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
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.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, DetailView, TemplateView
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
|
|
@ -13,9 +14,14 @@ from servala.core.models import (
|
||||||
Organization,
|
Organization,
|
||||||
OrganizationInvitation,
|
OrganizationInvitation,
|
||||||
OrganizationMembership,
|
OrganizationMembership,
|
||||||
|
OrganizationRole,
|
||||||
ServiceInstance,
|
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
|
from servala.frontend.views.mixins import HtmxUpdateView, OrganizationViewMixin
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -105,7 +111,86 @@ class OrganizationDashboardView(
|
||||||
class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView):
|
class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView):
|
||||||
template_name = "frontend/organizations/update.html"
|
template_name = "frontend/organizations/update.html"
|
||||||
form_class = OrganizationForm
|
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):
|
def get_success_url(self):
|
||||||
return self.request.path
|
return self.request.path
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue