Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,7 @@

/.nx/cache

/packages/backend/templates
/packages/backend/templates

# GraphQL generated files
**/__generated__/
51 changes: 50 additions & 1 deletion packages/backend/apps/multitenancy/admin.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from django.contrib import admin
from django.utils import timezone
from django.utils.html import format_html

from . import models

Expand All @@ -10,4 +12,51 @@ class TenantAdmin(admin.ModelAdmin):

@admin.register(models.TenantMembership)
class TenantMembershipAdmin(admin.ModelAdmin):
list_display = ("id", "role", "user", "invitee_email_address", "tenant", "is_accepted")
list_display = ("id", "role", "user_display", "invitee_email_display", "tenant", "status_display", "created_at")
list_filter = ("is_accepted", "role", "tenant__name", "created_at")
search_fields = ("user__email", "invitee_email_address", "tenant__name")
readonly_fields = ("created_at", "updated_at", "invitation_accepted_at")
actions = ["accept_invitations"]

def get_queryset(self, request):
"""
Override the manager's get_queryset to show all memberships including pending invitations.
This bypasses the TenantMembershipManager's filtering that only shows accepted memberships.
"""
return models.TenantMembership.objects.get_all()

def user_display(self, obj):
"""Display user information or indicate pending invitation"""
if obj.user:
return obj.user.email
return "—"

user_display.short_description = "User"

def invitee_email_display(self, obj):
"""Display invitee email for invitations (pending or accepted)"""
if obj.invitee_email_address:
if not obj.is_accepted:
return obj.invitee_email_address
else:
# Show historical data for accepted invitations
return format_html('<span style="color: gray;">{} (accepted)</span>', obj.invitee_email_address)
return "—"

invitee_email_display.short_description = "Invitee Email"

def status_display(self, obj):
"""Display invitation status with color coding"""
if obj.is_accepted:
return format_html('<span style="color: green;">✓ Accepted</span>')
else:
return format_html('<span style="color: orange;">⏳ Pending</span>')

status_display.short_description = "Status"

@admin.action(description="Accept selected invitations")
def accept_invitations(self, request, queryset):
"""Accept pending invitations"""
pending = queryset.filter(is_accepted=False)
count = pending.update(is_accepted=True, invitation_accepted_at=timezone.now())
self.message_user(request, f"Successfully accepted {count} invitation(s).")
28 changes: 23 additions & 5 deletions packages/backend/apps/multitenancy/models.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import hashid_field

from django.db import models, IntegrityError, transaction
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import IntegrityError, models, transaction
from django.db.models import Q, UniqueConstraint
from django.utils import timezone
from django.utils.text import slugify
from django.db.models import UniqueConstraint, Q

from common.models import TimestampedMixin

from . import constants
from .managers import TenantManager, TenantMembershipManager
from common.models import TimestampedMixin


class Tenant(TimestampedMixin, models.Model):
Expand Down Expand Up @@ -158,4 +160,20 @@ class Meta:
]

def __str__(self):
return f"{self.user.email} {self.tenant.name} {self.role}"
if self.user:
return f"{self.user.email} - {self.tenant.name} - {self.role}"
else:
return f"{self.invitee_email_address} (pending) - {self.tenant.name} - {self.role}"

def save(self, *args, **kwargs):
if self.is_accepted and not self.invitation_accepted_at:
self.invitation_accepted_at = timezone.now()

elif not self.is_accepted:
self.invitation_accepted_at = None

super().save(*args, **kwargs)

def clean(self):
if not self.is_accepted and self.invitation_accepted_at:
raise ValidationError("Pending invitations cannot have an accepted date")
26 changes: 16 additions & 10 deletions packages/backend/apps/multitenancy/schema.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,30 @@
import graphene
from django.shortcuts import get_object_or_404
from graphene import relay
from graphql_relay import to_global_id, from_global_id
from graphene_django import DjangoObjectType
from django.shortcuts import get_object_or_404
from graphql_relay import from_global_id, to_global_id
from rest_framework.exceptions import PermissionDenied

from apps.users.services.users import get_user_from_resolver, get_user_avatar_url
from apps.finances.serializers import CancelTenantActiveSubscriptionSerializer
from apps.finances.services import subscriptions
from apps.users.services.users import get_user_avatar_url, get_user_from_resolver
from common.acl import policies
from common.graphql import mutations, exceptions
from common.graphql import exceptions, mutations
from common.graphql.acl.decorators import permission_classes
from common.graphql.acl.wrappers import PERMISSION_DENIED_MESSAGE
from apps.finances.services import subscriptions
from apps.finances.serializers import CancelTenantActiveSubscriptionSerializer
from . import models
from . import serializers
from .tokens import tenant_invitation_token
from .constants import TenantUserRole, TenantType as ConstantsTenantType

from . import models, serializers
from .constants import TenantType as ConstantsTenantType
from .constants import TenantUserRole
from .tokens import tenant_invitation_token

TenantUserRoleType = graphene.Enum.from_enum(TenantUserRole)


class TenantMembershipType(DjangoObjectType):
id = graphene.ID(required=True)
invitation_accepted = graphene.Boolean()
invitation_accepted_at = graphene.DateTime()
user_id = graphene.ID()
invitee_email_address = graphene.String()
invitation_token = graphene.String()
Expand All @@ -39,6 +40,7 @@ class Meta:
"id",
"role",
"invitation_accepted",
"invitation_accepted_at",
"user_id",
"invitee_email_address",
"invitation_token",
Expand Down Expand Up @@ -79,6 +81,10 @@ def resolve_user_email(parent, info):
def resolve_avatar(parent, info):
return get_user_avatar_url(parent.user) if parent.user else None

@staticmethod
def resolve_invitation_accepted_at(parent, info):
return parent.invitation_accepted_at


class TenantType(DjangoObjectType):
id = graphene.ID(required=True)
Expand Down
7 changes: 3 additions & 4 deletions packages/backend/apps/multitenancy/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from django.contrib.auth.models import BaseUserManager
from django.utils.translation import gettext_lazy as _
from django.db.models import Q
from django.utils import timezone
from graphql_relay import to_global_id

from common.graphql.field_conversions import TextChoicesFieldType
Expand Down Expand Up @@ -66,9 +65,9 @@ def create(self, validated_data):
user = self.context["request"].user
membership = models.TenantMembership.objects.get_not_accepted().filter(pk=membership_id, user=user).first()
if membership:
models.TenantMembership.objects.get_not_accepted().filter(pk=membership_id, user=user).update(
is_accepted=True, invitation_accepted_at=timezone.now()
)
# Use model save() to ensure save() logic runs (auto-sets invitation_accepted_at)
membership.is_accepted = True
membership.save()
notifications.send_accepted_tenant_invitation_notification(
membership, to_global_id("TenantMembershipType", membership_id)
)
Expand Down
8 changes: 8 additions & 0 deletions packages/backend/apps/multitenancy/tests/fixtures.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import pytest
import pytest_factoryboy

from . import factories
from apps.users.tests.factories import UserFactory

pytest_factoryboy.register(factories.TenantFactory)
pytest_factoryboy.register(factories.TenantMembershipFactory)


@pytest.fixture
def admin_user():
"""Create a superuser for admin tests"""
return UserFactory(is_superuser=True, admin=True)
161 changes: 161 additions & 0 deletions packages/backend/apps/multitenancy/tests/test_admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import pytest
from django.contrib.admin.sites import AdminSite
from django.contrib.messages.storage.fallback import FallbackStorage
from django.test import RequestFactory

from ..admin import TenantMembershipAdmin
from ..models import TenantMembership

pytestmark = pytest.mark.django_db


class TestTenantMembershipAdmin:
def test_get_queryset_shows_pending_memberships(self, tenant_membership_factory, admin_user):
"""Test that admin shows both accepted and pending memberships"""
# Create accepted and pending memberships
accepted = tenant_membership_factory(is_accepted=True)
pending = tenant_membership_factory(is_accepted=False, invitee_email_address="pending@example.com")

# Setup admin
site = AdminSite()
admin = TenantMembershipAdmin(TenantMembership, site)
request = RequestFactory().get('/admin/multitenancy/tenantmembership/')
request.user = admin_user

# Get queryset
queryset = admin.get_queryset(request)

# Both should be visible
assert accepted in queryset
assert pending in queryset
assert queryset.count() >= 2

def test_user_display_with_user(self, tenant_membership_factory):
"""Test user_display shows email when user exists"""
membership = tenant_membership_factory(is_accepted=True)
site = AdminSite()
admin = TenantMembershipAdmin(TenantMembership, site)

result = admin.user_display(membership)

assert result == membership.user.email

def test_user_display_without_user(self, tenant_membership_factory):
"""Test user_display shows dash when user is None"""
membership = tenant_membership_factory(user=None, is_accepted=False, invitee_email_address="test@example.com")
site = AdminSite()
admin = TenantMembershipAdmin(TenantMembership, site)

result = admin.user_display(membership)

assert result == "—"

def test_invitee_email_display_pending(self, tenant_membership_factory):
"""Test invitee_email_display shows email for pending invitations"""
membership = tenant_membership_factory(user=None, is_accepted=False, invitee_email_address="invite@example.com")
site = AdminSite()
admin = TenantMembershipAdmin(TenantMembership, site)

result = admin.invitee_email_display(membership)

assert result == "invite@example.com"

def test_invitee_email_display_accepted_without_email(self, tenant_membership_factory):
"""Test invitee_email_display shows dash for accepted memberships without invitee email"""
membership = tenant_membership_factory(is_accepted=True, invitee_email_address="")
site = AdminSite()
admin = TenantMembershipAdmin(TenantMembership, site)

result = admin.invitee_email_display(membership)

assert result == "—"

def test_invitee_email_display_accepted_with_email(self, tenant_membership_factory):
"""Test invitee_email_display shows historical email for accepted memberships"""
membership = tenant_membership_factory(is_accepted=True, invitee_email_address="historical@example.com")
site = AdminSite()
admin = TenantMembershipAdmin(TenantMembership, site)

result = admin.invitee_email_display(membership)

assert "historical@example.com" in result
assert "(accepted)" in result

def test_status_display_accepted(self, tenant_membership_factory):
"""Test status_display for accepted membership"""
membership = tenant_membership_factory(is_accepted=True)
site = AdminSite()
admin = TenantMembershipAdmin(TenantMembership, site)

result = admin.status_display(membership)

assert "Accepted" in result
assert "green" in result

def test_status_display_pending(self, tenant_membership_factory):
"""Test status_display for pending membership"""
membership = tenant_membership_factory(is_accepted=False)
site = AdminSite()
admin = TenantMembershipAdmin(TenantMembership, site)

result = admin.status_display(membership)

assert "Pending" in result
assert "orange" in result

def test_accept_invitations_action(self, tenant_membership_factory, admin_user):
"""Test bulk accept invitations action"""
# Create pending memberships
pending1 = tenant_membership_factory(is_accepted=False)
pending2 = tenant_membership_factory(is_accepted=False)

site = AdminSite()
admin = TenantMembershipAdmin(TenantMembership, site)
request = RequestFactory().post('/admin/multitenancy/tenantmembership/')
request.user = admin_user

# Mock messages framework
setattr(request, 'session', 'session')
messages = FallbackStorage(request)
setattr(request, '_messages', messages)

queryset = TenantMembership.objects.get_all().filter(id__in=[pending1.id, pending2.id])

# Execute action
admin.accept_invitations(request, queryset)

# Verify
pending1.refresh_from_db()
pending2.refresh_from_db()

assert pending1.is_accepted is True
assert pending2.is_accepted is True
assert pending1.invitation_accepted_at is not None
assert pending2.invitation_accepted_at is not None

def test_accept_invitations_action_ignores_already_accepted(self, tenant_membership_factory, admin_user):
"""Test bulk accept action only affects pending invitations"""
# Create one accepted and one pending
accepted = tenant_membership_factory(is_accepted=True)
pending = tenant_membership_factory(is_accepted=False)

site = AdminSite()
admin = TenantMembershipAdmin(TenantMembership, site)
request = RequestFactory().post('/admin/multitenancy/tenantmembership/')
request.user = admin_user

# Mock messages framework
setattr(request, 'session', 'session')
messages = FallbackStorage(request)
setattr(request, '_messages', messages)

queryset = TenantMembership.objects.get_all().filter(id__in=[accepted.id, pending.id])

# Execute action
admin.accept_invitations(request, queryset)

# Verify only pending was affected
pending.refresh_from_db()

assert pending.is_accepted is True
assert pending.invitation_accepted_at is not None
Loading
Loading