diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index 8f6ac75..a8dc61f 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -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() diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 13ba2a0..ba96328 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -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"] diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index d9384a8..c8cd0e2 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -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) diff --git a/src/backend/core/migrations/0001_initial.py b/src/backend/core/migrations/0001_initial.py index 936f727..3966fa1 100644 --- a/src/backend/core/migrations/0001_initial.py +++ b/src/backend/core/migrations/0001_initial.py @@ -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=[ diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 368edb5..a6c267e 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -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. diff --git a/src/backend/core/tests/test_models_invitations.py b/src/backend/core/tests/test_models_invitations.py new file mode 100644 index 0000000..959dec9 --- /dev/null +++ b/src/backend/core/tests/test_models_invitations.py @@ -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") diff --git a/src/backend/people/settings.py b/src/backend/people/settings.py index e1017e3..3b1155d 100755 --- a/src/backend/people/settings.py +++ b/src/backend/people/settings.py @@ -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