diff --git a/src/servala/frontend/forms/organization.py b/src/servala/frontend/forms/organization.py
index 6fd04b4..27e6a09 100644
--- a/src/servala/frontend/forms/organization.py
+++ b/src/servala/frontend/forms/organization.py
@@ -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(),
+ }
diff --git a/src/servala/frontend/templates/frontend/organizations/update.html b/src/servala/frontend/templates/frontend/organizations/update.html
index 2e1b9b0..d55dc56 100644
--- a/src/servala/frontend/templates/frontend/organizations/update.html
+++ b/src/servala/frontend/templates/frontend/organizations/update.html
@@ -36,6 +36,74 @@
{% endpartialdef org-name-edit %}
+{% partialdef members-list %}
+
+
+
+
+ | {% translate "Name" %} |
+ {% translate "Email" %} |
+ {% translate "Role" %} |
+ {% translate "Joined" %} |
+
+
+
+ {% for membership in memberships %}
+
+ | {{ membership.user }} |
+ {{ membership.user.email }} |
+
+
+ {{ membership.get_role_display }}
+
+ |
+ {{ membership.date_joined|date:"Y-m-d" }} |
+
+ {% empty %}
+
+ | {% translate "No members yet" %} |
+
+ {% endfor %}
+
+
+
+{% if pending_invitations %}
+
+ {% translate "Pending Invitations" %}
+
+
+
+
+
+ | {% translate "Email" %} |
+ {% translate "Role" %} |
+ {% translate "Sent" %} |
+ {% translate "Link" %} |
+
+
+
+ {% for invitation in pending_invitations %}
+
+ | {{ invitation.email }} |
+
+
+ {{ invitation.get_role_display }}
+
+ |
+ {{ invitation.created_at|date:"Y-m-d H:i" }} |
+
+
+ |
+
+ {% endfor %}
+
+
+
+{% endif %}
+{% endpartialdef members-list %}
{% block content %}
@@ -135,5 +203,39 @@
{% endif %}
+ {% if can_manage_members %}
+
+
+
+
{% partial members-list %}
+
+
+
+ {% endif %}
{% endblock content %}
diff --git a/src/servala/frontend/views/organization.py b/src/servala/frontend/views/organization.py
index e56416d..651b18c 100644
--- a/src/servala/frontend/views/organization.py
+++ b/src/servala/frontend/views/organization.py
@@ -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