diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index 0a4208d..f966f38 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -9,6 +9,7 @@ from servala.core.models import ( ControlPlane, ControlPlaneCRD, Organization, + OrganizationInvitation, OrganizationMembership, OrganizationOrigin, Service, @@ -105,6 +106,28 @@ class OrganizationMembershipAdmin(admin.ModelAdmin): date_hierarchy = "date_joined" +@admin.register(OrganizationInvitation) +class OrganizationInvitationAdmin(admin.ModelAdmin): + list_display = ("email", "organization", "role", "is_accepted", "created_at") + list_filter = ("role", "created_at", "accepted_at", "organization") + search_fields = ("email", "organization__name") + autocomplete_fields = ("organization", "accepted_by") + readonly_fields = ( + "secret", + "accepted_by", + "accepted_at", + "created_at", + "updated_at", + ) + date_hierarchy = "created_at" + + def is_accepted(self, obj): + return obj.is_accepted + + is_accepted.boolean = True + is_accepted.short_description = _("Accepted") + + @admin.register(ServiceCategory) class ServiceCategoryAdmin(admin.ModelAdmin): list_display = ("name", "parent") diff --git a/src/servala/core/migrations/0011_organizationinvitation.py b/src/servala/core/migrations/0011_organizationinvitation.py new file mode 100644 index 0000000..25fb4b1 --- /dev/null +++ b/src/servala/core/migrations/0011_organizationinvitation.py @@ -0,0 +1,107 @@ +# Generated by Django 5.2.7 on 2025-10-17 00:58 + +import django.db.models.deletion +import rules.contrib.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0010_organizationorigin_billing_entity"), + ] + + operations = [ + migrations.CreateModel( + name="OrganizationInvitation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Last updated"), + ), + ( + "email", + models.EmailField(max_length=254, verbose_name="Email address"), + ), + ( + "role", + models.CharField( + choices=[ + ("member", "Member"), + ("admin", "Administrator"), + ("owner", "Owner"), + ], + default="member", + max_length=20, + verbose_name="Role", + ), + ), + ( + "secret", + models.CharField( + editable=False, + max_length=64, + unique=True, + verbose_name="Secret token", + ), + ), + ( + "accepted_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Accepted at" + ), + ), + ( + "accepted_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="accepted_invitations", + to=settings.AUTH_USER_MODEL, + verbose_name="Accepted by", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="created_invitations", + to=settings.AUTH_USER_MODEL, + verbose_name="Created by", + ), + ), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="invitations", + to="core.organization", + verbose_name="Organization", + ), + ), + ], + options={ + "verbose_name": "Organization invitation", + "verbose_name_plural": "Organization invitations", + "unique_together": {("organization", "email")}, + }, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + ] diff --git a/src/servala/core/models/__init__.py b/src/servala/core/models/__init__.py index 22e8e8a..4c23f18 100644 --- a/src/servala/core/models/__init__.py +++ b/src/servala/core/models/__init__.py @@ -1,6 +1,7 @@ from .organization import ( BillingEntity, Organization, + OrganizationInvitation, OrganizationMembership, OrganizationOrigin, OrganizationRole, @@ -23,6 +24,7 @@ __all__ = [ "ControlPlane", "ControlPlaneCRD", "Organization", + "OrganizationInvitation", "OrganizationMembership", "OrganizationOrigin", "OrganizationRole", diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index b0d58d5..b6d7e9f 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -1,3 +1,5 @@ +import secrets + import rules import urlman from django.conf import settings @@ -417,3 +419,70 @@ class OrganizationMembership(ServalaModelMixin, models.Model): def __str__(self): return f"{self.user} in {self.organization} as {self.role}" + + +class OrganizationInvitation(ServalaModelMixin, models.Model): + organization = models.ForeignKey( + to=Organization, + on_delete=models.CASCADE, + related_name="invitations", + verbose_name=_("Organization"), + ) + email = models.EmailField(verbose_name=_("Email address")) + role = models.CharField( + max_length=20, + choices=OrganizationRole.choices, + default=OrganizationRole.MEMBER, + verbose_name=_("Role"), + ) + secret = models.CharField( + max_length=64, + unique=True, + editable=False, + verbose_name=_("Secret token"), + ) + created_by = models.ForeignKey( + to="core.User", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="created_invitations", + verbose_name=_("Created by"), + ) + accepted_by = models.ForeignKey( + to="core.User", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="accepted_invitations", + verbose_name=_("Accepted by"), + ) + accepted_at = models.DateTimeField( + null=True, blank=True, verbose_name=_("Accepted at") + ) + + class urls(urlman.Urls): + accept = "/invitations/{self.secret}/accept/" + + class Meta: + verbose_name = _("Organization invitation") + verbose_name_plural = _("Organization invitations") + unique_together = [["organization", "email"]] + + def __str__(self): + return f"Invitation for {self.email} to {self.organization}" + + def save(self, *args, **kwargs): + if not self.secret: + self.secret = secrets.token_urlsafe(48) + super().save(*args, **kwargs) + + @property + def is_accepted(self): + # We check both accepted_by and accepted_at to avoid a deleted user + # freeing up an invitation + return self.accepted_by or self.accepted_at + + @property + def can_be_accepted(self): + return not self.is_accepted