diff --git a/squarelet/conftest.py b/squarelet/conftest.py index 33368255..71c5903b 100644 --- a/squarelet/conftest.py +++ b/squarelet/conftest.py @@ -14,6 +14,7 @@ InvoiceFactory, MembershipFactory, OrganizationFactory, + OrganizationInvitationFactory, OrganizationPlanFactory, PlanFactory, ProfessionalPlanFactory, @@ -28,6 +29,7 @@ register(InvoiceFactory) register(MembershipFactory) register(OrganizationFactory) +register(OrganizationInvitationFactory) register(OrganizationPlanFactory) register(ProfessionalPlanFactory) register(SubscriptionFactory) diff --git a/squarelet/organizations/admin.py b/squarelet/organizations/admin.py index 2425be16..fc9443dc 100644 --- a/squarelet/organizations/admin.py +++ b/squarelet/organizations/admin.py @@ -30,6 +30,7 @@ Organization, OrganizationChangeLog, OrganizationEmailDomain, + OrganizationInvitation, OrganizationSubtype, OrganizationType, OrganizationUrl, @@ -208,6 +209,38 @@ def queryset(self, request, queryset): return queryset +class OutgoingOrganizationInvitationInline(admin.TabularInline): + model = OrganizationInvitation + fk_name = "from_organization" + fields = ( + "to_organization", + "relationship_type", + "request", + "accepted_at", + "rejected_at", + ) + readonly_fields = ("to_organization", "accepted_at", "rejected_at") + extra = 0 + verbose_name = "Outgoing Organization Invitation" + verbose_name_plural = "Outgoing Organization Invitations" + + +class IncomingOrganizationInvitationInline(admin.TabularInline): + model = OrganizationInvitation + fk_name = "to_organization" + fields = ( + "from_organization", + "relationship_type", + "request", + "accepted_at", + "rejected_at", + ) + readonly_fields = ("from_organization", "accepted_at", "rejected_at") + extra = 0 + verbose_name = "Incoming Organization Invitation" + verbose_name_plural = "Incoming Organization Invitations" + + @admin.register(Organization) class OrganizationAdmin(VersionAdmin): def export_organizations_as_csv(self, request, queryset): @@ -285,6 +318,8 @@ def export_organizations_as_csv(self, request, queryset): "city", "state", "country", + "collective_enabled", + "share_resources", "parent", "members", "merged", @@ -308,6 +343,8 @@ def export_organizations_as_csv(self, request, queryset): inlines = ( ChildrenInline, MembershipsInline, + OutgoingOrganizationInvitationInline, + IncomingOrganizationInvitationInline, OrganizationUrlInline, OrganizationEmailDomainInline, SubscriptionInline, @@ -712,3 +749,28 @@ def changelist_view(self, request, extra_context=None): ) return super().changelist_view(request, extra_context) + + +@admin.register(OrganizationInvitation) +class OrganizationInvitationAdmin(VersionAdmin): + list_display = ( + "from_organization", + "to_organization", + "from_user", + "closed_by_user", + "relationship_type", + "request", + "created_at", + "accepted_at", + "rejected_at", + ) + list_filter = ("relationship_type", "request") + search_fields = ("from_organization__name", "to_organization__name") + readonly_fields = ("uuid", "created_at", "accepted_at", "rejected_at") + autocomplete_fields = ( + "from_organization", + "to_organization", + "from_user", + "closed_by_user", + ) + date_hierarchy = "created_at" diff --git a/squarelet/organizations/choices.py b/squarelet/organizations/choices.py index 5660cd0f..bfc812f1 100644 --- a/squarelet/organizations/choices.py +++ b/squarelet/organizations/choices.py @@ -12,6 +12,11 @@ class ChangeLogReason(DjangoChoices): credit_card = ChoiceItem(3, _("Credit Card")) +class RelationshipType(DjangoChoices): + member = ChoiceItem(0, _("Member")) + child = ChoiceItem(1, _("Child")) + + COUNTRY_CHOICES = ( ("US", _("United States of America")), ("CA", _("Canada")), diff --git a/squarelet/organizations/migrations/0047_organization_collective_enabled_and_more.py b/squarelet/organizations/migrations/0047_organization_collective_enabled_and_more.py new file mode 100644 index 00000000..31d78af2 --- /dev/null +++ b/squarelet/organizations/migrations/0047_organization_collective_enabled_and_more.py @@ -0,0 +1,157 @@ +# Generated by Django 4.2.18 on 2026-01-07 18:56 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import squarelet.core.fields +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("organizations", "0046_plan_benefits"), + ] + + operations = [ + migrations.AddField( + model_name="organization", + name="collective_enabled", + field=models.BooleanField( + default=False, + help_text="Enable this organization to participate in the collective feature as a parent or membership group. Only staff can enable this via admin.", + verbose_name="collective enabled", + ), + ), + migrations.AddField( + model_name="organization", + name="share_resources", + field=models.BooleanField( + default=True, + help_text="Share resources (subscriptions, credits) with all children and member organizations. Global toggle that applies to all relationships.", + verbose_name="share resources", + ), + ), + migrations.CreateModel( + name="OrganizationInvitation", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="UUID serves as secret token for this invitation in URLs", + unique=True, + verbose_name="UUID", + ), + ), + ( + "relationship_type", + models.PositiveSmallIntegerField( + choices=[(0, "Member"), (1, "Child")], + help_text="Type of relationship: member or child", + verbose_name="relationship type", + ), + ), + ( + "request", + models.BooleanField( + default=False, + help_text="True if this is a request TO JOIN from to_organization. False if this is an invitation FROM from_organization.", + verbose_name="request", + ), + ), + ( + "created_at", + squarelet.core.fields.AutoCreatedField( + default=django.utils.timezone.now, + editable=False, + help_text="When this invitation was created", + verbose_name="created at", + ), + ), + ( + "accepted_at", + models.DateTimeField( + blank=True, + help_text="When accepted (NULL if pending)", + null=True, + verbose_name="accepted at", + ), + ), + ( + "rejected_at", + models.DateTimeField( + blank=True, + help_text="When rejected (NULL if pending)", + null=True, + verbose_name="rejected at", + ), + ), + ( + "message", + models.TextField( + blank=True, + help_text="Optional message from the inviter", + verbose_name="message", + ), + ), + ( + "closed_by_user", + models.ForeignKey( + blank=True, + help_text="The user who accepted or rejected this invitation", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to=settings.AUTH_USER_MODEL, + verbose_name="user", + ), + ), + ( + "from_organization", + models.ForeignKey( + help_text="The organization extending the invitation or receiving the request. This is always the organization that is the parent or group.", + on_delete=django.db.models.deletion.CASCADE, + related_name="outgoing_org_invitations", + to="organizations.organization", + verbose_name="from organization", + ), + ), + ( + "from_user", + models.ForeignKey( + help_text="The user who initiated this invitation", + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to=settings.AUTH_USER_MODEL, + verbose_name="user", + ), + ), + ( + "to_organization", + models.ForeignKey( + help_text="The organization being invited. This is always the child or member", + on_delete=django.db.models.deletion.CASCADE, + related_name="incoming_org_invitations", + to="organizations.organization", + verbose_name="to organization", + ), + ), + ], + options={ + "ordering": ("-created_at",), + }, + ), + ] diff --git a/squarelet/organizations/migrations/0049_merge_20260108_1319.py b/squarelet/organizations/migrations/0049_merge_20260108_1319.py new file mode 100644 index 00000000..b37ed9d7 --- /dev/null +++ b/squarelet/organizations/migrations/0049_merge_20260108_1319.py @@ -0,0 +1,13 @@ +# Generated by Django 4.2.18 on 2026-01-08 18:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("organizations", "0047_organization_collective_enabled_and_more"), + ("organizations", "0048_add_last_overdue_email_sent_to_invoice"), + ] + + operations = [] diff --git a/squarelet/organizations/migrations/0050_merge_20260122_1344.py b/squarelet/organizations/migrations/0050_merge_20260122_1344.py new file mode 100644 index 00000000..aab773aa --- /dev/null +++ b/squarelet/organizations/migrations/0050_merge_20260122_1344.py @@ -0,0 +1,13 @@ +# Generated by Django 4.2.18 on 2026-01-22 18:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("organizations", "0049_merge_20260108_1319"), + ("organizations", "0049_plan_short_description"), + ] + + operations = [] diff --git a/squarelet/organizations/models/organization.py b/squarelet/organizations/models/organization.py index c3ede481..e26727a3 100644 --- a/squarelet/organizations/models/organization.py +++ b/squarelet/organizations/models/organization.py @@ -1,4 +1,5 @@ # Django +from django.core.exceptions import ValidationError from django.db import models, transaction from django.templatetags.static import static from django.urls import reverse @@ -26,11 +27,13 @@ COUNTRY_CHOICES, STATE_CHOICES, ChangeLogReason, + RelationshipType, ) from squarelet.organizations.models.payment import Charge from squarelet.organizations.querysets import ( InvitationQuerySet, MembershipQuerySet, + OrganizationInvitationQuerySet, OrganizationQuerySet, ) @@ -290,6 +293,24 @@ class Organization(AvatarMixin, models.Model): ), ) + collective_enabled = models.BooleanField( + _("collective enabled"), + default=False, + help_text=_( + "Enable this organization to participate in the collective feature as a " + "parent or membership group. Only staff can enable this via admin." + ), + ) + + share_resources = models.BooleanField( + _("share resources"), + default=True, + help_text=_( + "Share resources (subscriptions, credits) with all children and member " + "organizations. Global toggle that applies to all relationships." + ), + ) + class Meta: ordering = ("slug",) permissions = (("merge_organization", "Can merge organizations"),) @@ -563,6 +584,39 @@ def is_hub_eligible(self): or (self.parent and self.parent.is_hub_eligible) ) + # Organization Collective Methods + + def get_effective_verification(self): + """ + Check if this org is verified, either directly or through inheritance. + + Returns True if: + - Org has verified_journalist=True, OR + - Any group the org belongs to is verified (automatic inheritance), OR + - Org's parent is verified (recursive) + """ + # Direct verification + if self.verified_journalist: + return True + + # Check membership groups (automatic inheritance) + if self.groups.filter(verified_journalist=True).exists(): + return True + + # Check parent (recursive) + if self.parent: + return self.parent.get_effective_verification() + + return False + + def can_invite_org_members(self, user): + """ + Check if the given user can invite orgs to be members of this org. + User must be an admin, org must have collective enabled, and org cannot be + individual. + """ + return not self.individual and self.collective_enabled and self.has_admin(user) + def has_member_by_email(self, email): """Check if a user with an email is already a member of the organization.""" return Membership.objects.filter( @@ -612,6 +666,9 @@ def merge(self, org, user): ) org.invitations.all().delete() + org.outgoing_org_invitations.update(from_organization=self) + org.incoming_org_invitations.update(to_organization=self) + org.receipt_emails.exclude( email__in=self.receipt_emails.values("email") ).update(organization=self) @@ -839,6 +896,195 @@ def get_name(self): return self.email +class OrganizationInvitation(models.Model): + """ + Invitation for an organization to join another organization + as a member (in a membership group) or child (in a parent-child hierarchy). + + Can be either: + - An invitation: group/parent invites an org to join + - A request: org requests to join a group + """ + + objects = OrganizationInvitationQuerySet.as_manager() + + uuid = models.UUIDField( + _("UUID"), + default=uuid.uuid4, + editable=False, + unique=True, + help_text=_("UUID serves as secret token for this invitation in URLs"), + ) + + from_organization = models.ForeignKey( + verbose_name=_("from organization"), + to="organizations.Organization", + on_delete=models.CASCADE, + related_name="outgoing_org_invitations", + help_text=_( + "The organization extending the invitation or receiving the request. " + "This is always the organization that is the parent or group." + ), + ) + + to_organization = models.ForeignKey( + verbose_name=_("to organization"), + to="organizations.Organization", + on_delete=models.CASCADE, + related_name="incoming_org_invitations", + help_text=_( + "The organization being invited. This is always the child or member" + ), + ) + + from_user = models.ForeignKey( + verbose_name=_("user"), + to="users.User", + related_name="+", + on_delete=models.PROTECT, + help_text=_("The user who initiated this invitation"), + ) + closed_by_user = models.ForeignKey( + verbose_name=_("user"), + to="users.User", + related_name="+", + on_delete=models.PROTECT, + blank=True, + null=True, + help_text=_("The user who accepted or rejected this invitation"), + ) + + relationship_type = models.PositiveSmallIntegerField( + _("relationship type"), + choices=RelationshipType.choices, + help_text=_("Type of relationship: member or child"), + ) + + request = models.BooleanField( + _("request"), + default=False, + help_text=_( + "True if this is a request TO JOIN from to_organization. " + "False if this is an invitation FROM from_organization." + ), + ) + + created_at = AutoCreatedField( + _("created at"), help_text=_("When this invitation was created") + ) + + accepted_at = models.DateTimeField( + _("accepted at"), + blank=True, + null=True, + help_text=_("When accepted (NULL if pending)"), + ) + + rejected_at = models.DateTimeField( + _("rejected at"), + blank=True, + null=True, + help_text=_("When rejected (NULL if pending)"), + ) + + message = models.TextField( + _("message"), + blank=True, + help_text=_("Optional message from the inviter"), + ) + + class Meta: + ordering = ("-created_at",) + + def __str__(self): + direction = "Request from" if self.request else "Invitation to" + return f"{direction} {self.to_organization} by {self.from_organization}" + + def clean(self): + """Validate invitation requirements""" + + if self.accepted_at and self.rejected_at: + raise ValidationError("Cannot be both accepted and rejected") + + # Verify from org has collective enabled + if self.from_organization and not self.from_organization.collective_enabled: + raise ValidationError( + f"{self.from_organization.name} does " + "not have collective feature enabled" + ) + + @property + def is_pending(self): + """Is this invitation still pending (not accepted or rejected)?""" + return self.accepted_at is None and self.rejected_at is None + + @property + def is_accepted(self): + """Has this invitation been accepted?""" + return self.accepted_at is not None + + @property + def is_rejected(self): + """Has this invitation been rejected?""" + return self.rejected_at is not None + + def send(self): + """Send email notification for this invitation/request""" + if self.request: + # This is a request TO JOIN - notify group admins + send_mail( + subject=_( + f"{self.to_organization} has requested to join " + f"{self.from_organization}" + ), + template="organizations/email/org_join_request.html", + organization=self.from_organization, + organization_to=ORG_TO_ADMINS, + extra_context={ + "invitation": self, + "requesting_org": self.to_organization, + }, + ) + else: + # This is an invitation TO the org - notify target org admins + send_mail( + subject=_(f"Invitation to join {self.from_organization.name}"), + template="organizations/email/org_invitation.html", + organization=self.to_organization, + organization_to=ORG_TO_ADMINS, + extra_context={"invitation": self}, + ) + + @transaction.atomic + def accept(self): + """Accept this invitation/request""" + + if not self.is_pending: + raise ValueError("This invitation has already been processed") + + self.accepted_at = timezone.now() + self.save() + + # Create the relationship based on type + if self.relationship_type == RelationshipType.member: + # Add to membership group (simple M2M) + self.from_organization.members.add(self.to_organization) + elif self.relationship_type == RelationshipType.child: + # Set parent relationship + self.to_organization.parent = self.from_organization + self.to_organization.save() + + @transaction.atomic + def reject(self): + """Reject this invitation/request""" + + if not self.is_pending: + raise ValueError("This invitation has already been processed") + + self.rejected_at = timezone.now() + self.save() + + class ReceiptEmail(models.Model): """An email address to send receipts to""" diff --git a/squarelet/organizations/querysets.py b/squarelet/organizations/querysets.py index dbd33cfa..073c3e22 100644 --- a/squarelet/organizations/querysets.py +++ b/squarelet/organizations/querysets.py @@ -160,6 +160,34 @@ def get_rejected(self): return self.exclude(rejected_at=None) +class OrganizationInvitationQuerySet(models.QuerySet): + def pending(self): + """Return pending invitations (neither accepted nor rejected)""" + return self.filter(accepted_at=None, rejected_at=None) + + def accepted(self): + """Return accepted invitations""" + return self.exclude(accepted_at=None) + + def rejected(self): + """Return rejected invitations""" + return self.exclude(rejected_at=None) + + def invitations(self): + """Return pending invitations (not requests)""" + return self.pending().filter(request=False) + + def requests(self): + """Return pending requests (not invitations)""" + return self.pending().filter(request=True) + + def for_organization(self, organization): + """Return invitations to or from the given organization""" + return self.filter( + Q(from_organization=organization) | Q(to_organization=organization) + ) + + class ChargeQuerySet(models.QuerySet): def make_charge( self, diff --git a/squarelet/organizations/serializers.py b/squarelet/organizations/serializers.py index b7b42718..fdaff81e 100644 --- a/squarelet/organizations/serializers.py +++ b/squarelet/organizations/serializers.py @@ -17,6 +17,11 @@ class OrganizationSerializer(serializers.ModelSerializer): subtypes = serializers.StringRelatedField(many=True) admins = serializers.SerializerMethodField() + parent = serializers.SerializerMethodField() + groups = serializers.SerializerMethodField() + + share_resources = serializers.BooleanField(read_only=True) + class Meta: model = Organization fields = ( @@ -33,6 +38,9 @@ class Meta: "merged", "subtypes", "admins", + "parent", + "groups", + "share_resources", ) def get_admins(self, obj): @@ -48,6 +56,17 @@ def get_admins(self, obj): ) ] + def get_parent(self, obj): + if obj.parent is not None: + return OrganizationDetailSerializer(obj.parent, context=self.context).data + else: + return None + + def get_groups(self, obj): + return OrganizationDetailSerializer( + obj.groups.all(), many=True, context=self.context + ).data + class OrganizationDetailSerializer(OrganizationSerializer): update_on = serializers.SerializerMethodField() diff --git a/squarelet/organizations/tests/factories.py b/squarelet/organizations/tests/factories.py index 3cdbd2ce..2c1e0549 100644 --- a/squarelet/organizations/tests/factories.py +++ b/squarelet/organizations/tests/factories.py @@ -9,6 +9,9 @@ import factory from autoslug.utils import slugify +# Squarelet +from squarelet.organizations.choices import RelationshipType + class OrganizationFactory(factory.django.DjangoModelFactory): name = factory.Sequence(lambda n: f"org-{n}") @@ -150,6 +153,22 @@ class Meta: model = "organizations.Invitation" +class OrganizationInvitationFactory(factory.django.DjangoModelFactory): + from_organization = factory.SubFactory( + "squarelet.organizations.tests.factories.OrganizationFactory" + ) + to_organization = factory.SubFactory( + "squarelet.organizations.tests.factories.OrganizationFactory" + ) + from_user = factory.SubFactory("squarelet.users.tests.factories.UserFactory") + closed_by_user = factory.SubFactory("squarelet.users.tests.factories.UserFactory") + relationship_type = RelationshipType.member + request = False + + class Meta: + model = "organizations.OrganizationInvitation" + + class ChargeFactory(factory.django.DjangoModelFactory): organization = factory.SubFactory( "squarelet.organizations.tests.factories.OrganizationFactory" diff --git a/squarelet/organizations/tests/test_models.py b/squarelet/organizations/tests/test_models.py index 0242f859..cd9e7e15 100644 --- a/squarelet/organizations/tests/test_models.py +++ b/squarelet/organizations/tests/test_models.py @@ -1,4 +1,5 @@ # Django +from django.core.exceptions import ValidationError from django.test import override_settings from django.utils import timezone @@ -11,7 +12,7 @@ import stripe # Squarelet -from squarelet.organizations.choices import ChangeLogReason +from squarelet.organizations.choices import ChangeLogReason, RelationshipType from squarelet.organizations.models import Invoice, Organization, ReceiptEmail from squarelet.organizations.tests.factories import EntitlementFactory, PlanFactory @@ -628,7 +629,7 @@ def test_merge_fks(self): if f.is_relation and f.auto_created ] ) - == 14 + == 16 ) # Many to many relations defined on the Organization model assert ( @@ -642,6 +643,85 @@ def test_merge_fks(self): == 4 ) + @pytest.mark.django_db() + def test_get_effective_verification_own(self, organization_factory): + """Test verification when org is directly verified""" + org = organization_factory(verified_journalist=True) + assert org.get_effective_verification() + + @pytest.mark.django_db() + def test_get_effective_verification_not_verified(self, organization_factory): + """Test verification when org is not verified""" + org = organization_factory(verified_journalist=False) + assert not org.get_effective_verification() + + @pytest.mark.django_db() + def test_get_effective_verification_from_group(self, organization_factory): + """Test verification inherited from membership group""" + org = organization_factory(verified_journalist=False) + group = organization_factory(verified_journalist=True, collective_enabled=True) + group.members.add(org) + assert org.get_effective_verification() + + @pytest.mark.django_db() + def test_get_effective_verification_from_parent(self, organization_factory): + """Test verification inherited from parent""" + parent = organization_factory(verified_journalist=True, collective_enabled=True) + child = organization_factory(verified_journalist=False, parent=parent) + assert child.get_effective_verification() + + @pytest.mark.django_db() + def test_get_effective_verification_recursive(self, organization_factory): + """Test verification inherited recursively through parent chain""" + grandparent = organization_factory( + verified_journalist=True, collective_enabled=True + ) + parent = organization_factory( + verified_journalist=False, parent=grandparent, collective_enabled=True + ) + child = organization_factory(verified_journalist=False, parent=parent) + assert child.get_effective_verification() + + @pytest.mark.django_db() + def test_can_invite_org_members_admin(self, organization_factory, user_factory): + """Test that admin of collective-enabled org can invite members""" + user = user_factory() + org = organization_factory( + individual=False, collective_enabled=True, admins=[user] + ) + assert org.can_invite_org_members(user) + + @pytest.mark.django_db() + def test_can_invite_org_members_not_admin(self, organization_factory, user_factory): + """Test that non-admin cannot invite members""" + user = user_factory() + org = organization_factory( + individual=False, collective_enabled=True, users=[user] + ) + assert not org.can_invite_org_members(user) + + @pytest.mark.django_db() + def test_can_invite_org_members_not_collective( + self, organization_factory, user_factory + ): + """Test that admin of non-collective org cannot invite members""" + user = user_factory() + org = organization_factory( + individual=False, collective_enabled=False, admins=[user] + ) + assert not org.can_invite_org_members(user) + + @pytest.mark.django_db() + def test_can_invite_org_members_individual( + self, organization_factory, user_factory + ): + """Test that individual orgs cannot invite members""" + user = user_factory() + org = organization_factory( + individual=True, collective_enabled=True, admins=[user] + ) + assert not org.can_invite_org_members(user) + class TestCustomer: """Unit tests for Customer model""" @@ -1683,3 +1763,188 @@ def test_mark_uncollectible_in_stripe_stripe_error(self, invoice_factory, mocker # Should raise the Stripe error with pytest.raises(stripe.error.InvalidRequestError): invoice.mark_uncollectible_in_stripe() + + +class TestOrganizationInvitation: + """Unit tests for OrganizationInvitation model""" + + def test_str(self, organization_invitation_factory): + invitation = organization_invitation_factory.build() + assert str(invitation) == ( + f"Invitation to {invitation.to_organization} by " + f"{invitation.from_organization}" + ) + + @pytest.mark.django_db() + def test_clean_valid_invitation(self, organization_invitation_factory): + """Test that clean passes for valid invitation""" + from_org = organization_invitation_factory.create( + from_organization__collective_enabled=True + ) + invitation = organization_invitation_factory.build( + from_organization=from_org.from_organization, + relationship_type=RelationshipType.member, + ) + # Should not raise + invitation.clean() + + @pytest.mark.django_db() + def test_clean_from_org_not_collective(self, organization_invitation_factory): + """Test that clean fails if from_organization doesn't have collective_enabled""" + + invitation = organization_invitation_factory.build( + from_organization__collective_enabled=False, + relationship_type=RelationshipType.member, + ) + with pytest.raises(ValidationError): + invitation.clean() + + @pytest.mark.django_db() + def test_clean_child_relationship_parent_check( + self, organization_invitation_factory + ): + """Test that clean validates from_org collective_enabled for child + relationships""" + + invitation = organization_invitation_factory.build( + from_organization__collective_enabled=False, + relationship_type=RelationshipType.child, + ) + with pytest.raises(ValidationError): + invitation.clean() + + @pytest.mark.django_db() + def test_is_pending(self, organization_invitation_factory): + """Test is_pending property""" + invitation = organization_invitation_factory() + assert invitation.is_pending + + invitation.accepted_at = timezone.now() + assert not invitation.is_pending + + invitation.accepted_at = None + invitation.rejected_at = timezone.now() + assert not invitation.is_pending + + @pytest.mark.django_db() + def test_is_accepted(self, organization_invitation_factory): + """Test is_accepted property""" + invitation = organization_invitation_factory() + assert not invitation.is_accepted + + invitation.accepted_at = timezone.now() + invitation.save() + assert invitation.is_accepted + + @pytest.mark.django_db() + def test_is_rejected(self, organization_invitation_factory): + """Test is_rejected property""" + invitation = organization_invitation_factory() + assert not invitation.is_rejected + + invitation.rejected_at = timezone.now() + invitation.save() + assert invitation.is_rejected + + @pytest.mark.django_db() + def test_send(self, user_factory, organization_invitation_factory, mailoutbox): + """Test sending invitation emails""" + user = user_factory() + invitation = organization_invitation_factory( + request=False, to_organization__admins=[user] + ) + invitation.send() + assert len(mailoutbox) == 1 + mail = mailoutbox[0] + assert invitation.from_organization.name in mail.subject + + @pytest.mark.freeze_time + @pytest.mark.django_db() + def test_accept_member_invitation(self, organization_invitation_factory): + """Test accepting a member invitation""" + invitation = organization_invitation_factory( + from_organization__collective_enabled=True, + relationship_type=RelationshipType.member, + ) + from_org = invitation.from_organization + to_org = invitation.to_organization + + assert not from_org.members.filter(pk=to_org.pk).exists() + + invitation.accept() + + assert from_org.members.filter(pk=to_org.pk).exists() + assert invitation.accepted_at == timezone.now() + + @pytest.mark.freeze_time + @pytest.mark.django_db() + def test_accept_child_invitation(self, organization_invitation_factory): + """Test accepting a child invitation""" + invitation = organization_invitation_factory( + from_organization__collective_enabled=True, + relationship_type=RelationshipType.child, + ) + from_org = invitation.from_organization + to_org = invitation.to_organization + + assert to_org.parent is None + + invitation.accept() + + to_org.refresh_from_db() + assert to_org.parent == from_org + assert invitation.accepted_at == timezone.now() + + @pytest.mark.django_db() + def test_accept_closed_invitation(self, organization_invitation_factory): + """Test that accepting an already accepted invitation raises error""" + invitation = organization_invitation_factory( + from_organization__collective_enabled=True, accepted_at=timezone.now() + ) + with pytest.raises( + ValueError, match="This invitation has already been processed" + ): + invitation.accept() + + @pytest.mark.django_db() + def test_accept_rejected_invitation(self, organization_invitation_factory): + """Test that accepting a rejected invitation raises error""" + invitation = organization_invitation_factory( + from_organization__collective_enabled=True, rejected_at=timezone.now() + ) + with pytest.raises( + ValueError, match="This invitation has already been processed" + ): + invitation.accept() + + @pytest.mark.freeze_time + @pytest.mark.django_db() + def test_reject(self, organization_invitation_factory): + """Test rejecting an invitation""" + invitation = organization_invitation_factory( + from_organization__collective_enabled=True + ) + invitation.reject() + assert invitation.rejected_at == timezone.now() + + @pytest.mark.django_db() + def test_reject_closed_invitation(self, organization_invitation_factory): + """Test that rejecting an already rejected invitation raises error""" + invitation = organization_invitation_factory( + from_organization__collective_enabled=True, rejected_at=timezone.now() + ) + with pytest.raises( + ValueError, match="This invitation has already been processed" + ): + invitation.reject() + + @pytest.mark.django_db() + def test_reject_accepted_invitation(self, organization_invitation_factory): + """Test that rejecting an accepted invitation raises error""" + invitation = organization_invitation_factory( + from_organization__collective_enabled=True, accepted_at=timezone.now() + ) + with pytest.raises( + ValueError, match="This invitation has already been processed" + ): + invitation.reject() diff --git a/squarelet/organizations/viewsets.py b/squarelet/organizations/viewsets.py index d890dc46..cd57bc71 100644 --- a/squarelet/organizations/viewsets.py +++ b/squarelet/organizations/viewsets.py @@ -18,7 +18,7 @@ class OrganizationViewSet(viewsets.ModelViewSet): queryset = Organization.objects.prefetch_related( - "subtypes__type", "users__memberships" + "subtypes__type", "users__memberships", "groups" ) permission_classes = (ScopePermission | IsAdminUser,) read_scopes = ("read_organization",) diff --git a/squarelet/templates/organizations/email/org_invitation.html b/squarelet/templates/organizations/email/org_invitation.html new file mode 100644 index 00000000..04e00518 --- /dev/null +++ b/squarelet/templates/organizations/email/org_invitation.html @@ -0,0 +1,7 @@ +{% extends "core/email/base.html" %} +{% load i18n %} +{% load autologin %} + +{% block body %} +
TK
+{% endblock %} diff --git a/squarelet/templates/organizations/email/org_join_request.html b/squarelet/templates/organizations/email/org_join_request.html new file mode 100644 index 00000000..04e00518 --- /dev/null +++ b/squarelet/templates/organizations/email/org_join_request.html @@ -0,0 +1,7 @@ +{% extends "core/email/base.html" %} +{% load i18n %} +{% load autologin %} + +{% block body %} +TK
+{% endblock %}