From 21c26f9e5d846fe366a844056df2f606178bec78 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 8 Oct 2025 15:04:46 +0200 Subject: [PATCH] Allow users to invite other users ref #19 --- src/servala/frontend/forms/organization.py | 68 +++++++++++- .../frontend/organizations/update.html | 102 ++++++++++++++++++ src/servala/frontend/views/organization.py | 89 ++++++++++++++- 3 files changed, 256 insertions(+), 3 deletions(-) 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 %} +
+ + + + + + + + + + + {% for membership in memberships %} + + + + + + + {% empty %} + + + + {% endfor %} + +
{% translate "Name" %}{% translate "Email" %}{% translate "Role" %}{% translate "Joined" %}
{{ membership.user }}{{ membership.user.email }} + + {{ membership.get_role_display }} + + {{ membership.date_joined|date:"Y-m-d" }}
{% translate "No members yet" %}
+
+{% if pending_invitations %} +
+ {% translate "Pending Invitations" %} +
+
+ + + + + + + + + + + {% for invitation in pending_invitations %} + + + + + + + {% endfor %} + +
{% translate "Email" %}{% translate "Role" %}{% translate "Sent" %}{% translate "Link" %}
{{ invitation.email }} + + {{ invitation.get_role_display }} + + {{ invitation.created_at|date:"Y-m-d H:i" }} + +
+
+{% endif %} +{% endpartialdef members-list %} {% block content %}
@@ -135,5 +203,39 @@
{% endif %} + {% if can_manage_members %} +
+
+

+ {% translate "Members" %} +

+
+
+
{% partial members-list %}
+
+
+
+
+

+ {% translate "Invite New Member" %} +

+
+
+
+
+ {% csrf_token %} +
{{ invitation_form }}
+
+
+ +
+
+
+
+
+
+ {% 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