October feature list #226
4 changed files with 201 additions and 0 deletions
|
|
@ -9,6 +9,7 @@ from servala.core.models import (
|
||||||
ControlPlane,
|
ControlPlane,
|
||||||
ControlPlaneCRD,
|
ControlPlaneCRD,
|
||||||
Organization,
|
Organization,
|
||||||
|
OrganizationInvitation,
|
||||||
OrganizationMembership,
|
OrganizationMembership,
|
||||||
OrganizationOrigin,
|
OrganizationOrigin,
|
||||||
Service,
|
Service,
|
||||||
|
|
@ -105,6 +106,28 @@ class OrganizationMembershipAdmin(admin.ModelAdmin):
|
||||||
date_hierarchy = "date_joined"
|
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)
|
@admin.register(ServiceCategory)
|
||||||
class ServiceCategoryAdmin(admin.ModelAdmin):
|
class ServiceCategoryAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "parent")
|
list_display = ("name", "parent")
|
||||||
|
|
|
||||||
107
src/servala/core/migrations/0011_organizationinvitation.py
Normal file
107
src/servala/core/migrations/0011_organizationinvitation.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from .organization import (
|
from .organization import (
|
||||||
BillingEntity,
|
BillingEntity,
|
||||||
Organization,
|
Organization,
|
||||||
|
OrganizationInvitation,
|
||||||
OrganizationMembership,
|
OrganizationMembership,
|
||||||
OrganizationOrigin,
|
OrganizationOrigin,
|
||||||
OrganizationRole,
|
OrganizationRole,
|
||||||
|
|
@ -23,6 +24,7 @@ __all__ = [
|
||||||
"ControlPlane",
|
"ControlPlane",
|
||||||
"ControlPlaneCRD",
|
"ControlPlaneCRD",
|
||||||
"Organization",
|
"Organization",
|
||||||
|
"OrganizationInvitation",
|
||||||
"OrganizationMembership",
|
"OrganizationMembership",
|
||||||
"OrganizationOrigin",
|
"OrganizationOrigin",
|
||||||
"OrganizationRole",
|
"OrganizationRole",
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import secrets
|
||||||
|
|
||||||
import rules
|
import rules
|
||||||
import urlman
|
import urlman
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
@ -417,3 +419,70 @@ class OrganizationMembership(ServalaModelMixin, models.Model):
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user} in {self.organization} as {self.role}"
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue