🗃️(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:
committed by
Marie
parent
6080af961a
commit
8e537d962c
@@ -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()
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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=[
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
46
src/backend/core/tests/test_models_invitations.py
Normal file
46
src/backend/core/tests/test_models_invitations.py
Normal 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")
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user