Add Invitation model

ref #19
This commit is contained in:
Tobias Kunze 2025-10-08 11:00:16 +02:00
parent 842b66a84d
commit 8b1e0f74bb
4 changed files with 201 additions and 0 deletions

View file

@ -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")

View file

@ -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),
),
]

View file

@ -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",

View file

@ -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