🗃️(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",
|
||||
)
|
||||
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):
|
||||
"""Return slug from the team's name."""
|
||||
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)
|
||||
user = factory.SubFactory(UserFactory)
|
||||
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'),
|
||||
},
|
||||
),
|
||||
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(
|
||||
name='TeamAccess',
|
||||
fields=[
|
||||
|
||||
@@ -4,12 +4,14 @@ Declare and configure the models for the People core application
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import models as auth_models
|
||||
from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.core import exceptions, mail, validators
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import lazy
|
||||
from django.utils.text import slugify
|
||||
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):
|
||||
"""
|
||||
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")
|
||||
|
||||
AUTH_USER_MODEL = "core.User"
|
||||
INVITATION_VALIDITY_DURATION = 604800 # 7 days, in seconds
|
||||
|
||||
# CORS
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
|
||||
Reference in New Issue
Block a user