🗃️(database) create invitation model

Create invitation model, factory and related tests to prepare back-end
for invitation endpoints. We chose to use a separate dedicated model
for separation of concerns, see
https://github.com/numerique-gouv/people/issues/25
This commit is contained in:
Marie PUPO JEAMMET
2024-02-07 18:03:12 +01:00
committed by Marie
parent 6080af961a
commit 8e537d962c
7 changed files with 141 additions and 0 deletions

View File

@@ -119,3 +119,31 @@ class TeamAdmin(admin.ModelAdmin):
"updated_at", "updated_at",
) )
search_fields = ("name",) search_fields = ("name",)
@admin.register(models.Invitation)
class InvitationAdmin(admin.ModelAdmin):
"""Admin interface to handle invitations."""
fields = (
"email",
"team",
"role",
"created_at",
"issuer",
)
readonly_fields = (
"created_at",
"is_expired",
"issuer",
)
list_display = (
"email",
"team",
"created_at",
"is_expired",
)
def save_model(self, request, obj, form, change):
obj.issuer = request.user
obj.save()

View File

@@ -148,3 +148,12 @@ class TeamSerializer(serializers.ModelSerializer):
def get_slug(self, instance): def get_slug(self, instance):
"""Return slug from the team's name.""" """Return slug from the team's name."""
return instance.get_slug() return instance.get_slug()
class InvitationSerializer(serializers.ModelSerializer):
"""Serialize invitations."""
class Meta:
model = models.Invitation
fields = ["email", "team", "role", "issuer"]
read_only_fields = ["team", "issuer"]

View File

@@ -170,3 +170,15 @@ class TeamAccessFactory(factory.django.DjangoModelFactory):
team = factory.SubFactory(TeamFactory) team = factory.SubFactory(TeamFactory)
user = factory.SubFactory(UserFactory) user = factory.SubFactory(UserFactory)
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices]) role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
class InvitationFactory(factory.django.DjangoModelFactory):
"""A factory to create invitations for a user"""
class Meta:
model = models.Invitation
email = factory.Faker("email")
team = factory.SubFactory(TeamFactory)
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
issuer = factory.SubFactory(UserFactory)

View File

@@ -105,6 +105,21 @@ class Migration(migrations.Migration):
'ordering': ('-is_main', 'email'), 'ordering': ('-is_main', 'email'),
}, },
), ),
migrations.CreateModel(
name='Invitation',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
('email', models.EmailField(max_length=254, verbose_name='email address')),
('role', models.CharField(choices=[('member', 'Member'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='member', max_length=20)),
('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='core.team')),
],
options={
'abstract': False,
},
),
migrations.CreateModel( migrations.CreateModel(
name='TeamAccess', name='TeamAccess',
fields=[ fields=[

View File

@@ -4,12 +4,14 @@ Declare and configure the models for the People core application
import json import json
import os import os
import uuid import uuid
from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth import models as auth_models from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser from django.contrib.auth.base_user import AbstractBaseUser
from django.core import exceptions, mail, validators from django.core import exceptions, mail, validators
from django.db import models from django.db import models
from django.utils import timezone
from django.utils.functional import lazy from django.utils.functional import lazy
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -453,6 +455,34 @@ class TeamAccess(BaseModel):
} }
class Invitation(BaseModel):
"""User invitation to teams."""
email = models.EmailField(_("email address"), null=False, blank=False)
team = models.ForeignKey(
Team,
on_delete=models.CASCADE,
related_name="invitations",
)
role = models.CharField(
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
)
issuer = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="invitations",
)
def __str__(self):
return f"{self.email} invited to {self.team}"
@property
def is_expired(self):
"""Calculate if invitation is still valid or has expired."""
validity_duration = timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
return timezone.now() > (self.created_at + validity_duration)
def oidc_user_getter(validated_token): def oidc_user_getter(validated_token):
""" """
Given a valid OIDC token , retrieve, create or update corresponding user/contact/email from db. Given a valid OIDC token , retrieve, create or update corresponding user/contact/email from db.

View File

@@ -0,0 +1,46 @@
"""
Unit tests for the Invitation model
"""
from django.core.exceptions import ValidationError
import pytest
from core import factories
pytestmark = pytest.mark.django_db
def test_models_invitations_email_no_empty_mail():
"""The "email" field should not be empty."""
with pytest.raises(ValidationError, match="This field cannot be blank"):
factories.InvitationFactory(email="")
def test_models_invitations_email_no_null_mail():
"""The "email" field is required."""
with pytest.raises(ValidationError, match="This field cannot be null"):
factories.InvitationFactory(email=None)
def test_models_invitations_team_required():
"""The "team" field is required."""
with pytest.raises(ValidationError, match="This field cannot be null"):
factories.InvitationFactory(team=None)
def test_models_invitations_team_should_be_team_instance():
"""The "team" field should be a team instance."""
with pytest.raises(ValueError, match='Invitation.team" must be a "Team" instance'):
factories.InvitationFactory(team="ee")
def test_models_invitations_role_required():
"""The "role" field is required."""
with pytest.raises(ValidationError, match="This field cannot be blank"):
factories.InvitationFactory(role="")
def test_models_invitations_role_among_choices():
"""The "role" field should be a valid choice."""
with pytest.raises(ValidationError, match="Value 'boss' is not a valid choice"):
factories.InvitationFactory(role="boss")

View File

@@ -280,6 +280,7 @@ class Base(Configuration):
EMAIL_FROM = values.Value("from@example.com") EMAIL_FROM = values.Value("from@example.com")
AUTH_USER_MODEL = "core.User" AUTH_USER_MODEL = "core.User"
INVITATION_VALIDITY_DURATION = 604800 # 7 days, in seconds
# CORS # CORS
CORS_ALLOW_CREDENTIALS = True CORS_ALLOW_CREDENTIALS = True