diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index 9b1f3186..38f91b81 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -4,6 +4,7 @@ Core application factories """ from django.conf import settings from django.contrib.auth.hashers import make_password +from django.utils.text import slugify import factory.fuzzy from faker import Faker @@ -23,3 +24,43 @@ class UserFactory(factory.django.DjangoModelFactory): email = factory.Faker("email") language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES]) password = make_password("password") + + +class ResourceFactory(factory.django.DjangoModelFactory): + """Create fake resources for testing.""" + + class Meta: + model = models.Resource + + is_public = factory.Faker("boolean", chance_of_getting_true=50) + + @factory.post_generation + def users(self, create, extracted, **kwargs): + """Add users to resource from a given list of users.""" + if create and extracted: + for item in extracted: + if isinstance(item, models.User): + UserResourceAccessFactory(resource=self, user=item) + else: + UserResourceAccessFactory(resource=self, user=item[0], role=item[1]) + + +class UserResourceAccessFactory(factory.django.DjangoModelFactory): + """Create fake resource user accesses for testing.""" + + class Meta: + model = models.ResourceAccess + + resource = factory.SubFactory(ResourceFactory) + user = factory.SubFactory(UserFactory) + role = factory.fuzzy.FuzzyChoice(models.RoleChoices.values) + + +class RoomFactory(ResourceFactory): + """Create fake rooms for testing.""" + + class Meta: + model = models.Room + + name = factory.Faker("catch_phrase") + slug = factory.LazyAttribute(lambda o: slugify(o.name)) diff --git a/src/backend/core/migrations/0003_resource_remove_documentaccess_document_and_more.py b/src/backend/core/migrations/0003_resource_remove_documentaccess_document_and_more.py new file mode 100644 index 00000000..ea0481f4 --- /dev/null +++ b/src/backend/core/migrations/0003_resource_remove_documentaccess_document_and_more.py @@ -0,0 +1,115 @@ +# Generated by Django 5.0.3 on 2024-06-24 15:37 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0002_create_pg_trgm_extension'), + ] + + operations = [ + migrations.CreateModel( + name='Resource', + 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 on')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')), + ('is_public', models.BooleanField(default=True)), + ], + options={ + 'verbose_name': 'Resource', + 'verbose_name_plural': 'Resources', + 'db_table': 'impress_resource', + }, + ), + migrations.RemoveField( + model_name='documentaccess', + name='document', + ), + migrations.RemoveField( + model_name='invitation', + name='document', + ), + migrations.RemoveField( + model_name='documentaccess', + name='user', + ), + migrations.RemoveField( + model_name='invitation', + name='issuer', + ), + migrations.RemoveField( + model_name='templateaccess', + name='template', + ), + migrations.RemoveField( + model_name='templateaccess', + name='user', + ), + migrations.AlterField( + model_name='user', + name='language', + field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'), + ), + migrations.CreateModel( + name='Room', + fields=[ + ('name', models.CharField(max_length=500)), + ('resource', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='core.resource')), + ('slug', models.SlugField(blank=True, max_length=100, null=True, unique=True)), + ('configuration', models.JSONField(blank=True, default={}, help_text='Values for Magnify parameters to configure the room.', verbose_name='Magnify room configuration')), + ], + options={ + 'verbose_name': 'Room', + 'verbose_name_plural': 'Rooms', + 'db_table': 'impress_room', + 'ordering': ('name',), + }, + bases=('core.resource',), + ), + migrations.CreateModel( + name='ResourceAccess', + 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 on')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')), + ('role', models.CharField(choices=[('member', 'Member'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='member', max_length=20)), + ('resource', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.resource')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Resource access', + 'verbose_name_plural': 'Resource accesses', + 'db_table': 'impress_resource_access', + }, + ), + migrations.AddField( + model_name='resource', + name='users', + field=models.ManyToManyField(related_name='resources', through='core.ResourceAccess', to=settings.AUTH_USER_MODEL), + ), + migrations.DeleteModel( + name='Document', + ), + migrations.DeleteModel( + name='DocumentAccess', + ), + migrations.DeleteModel( + name='Invitation', + ), + migrations.DeleteModel( + name='Template', + ), + migrations.DeleteModel( + name='TemplateAccess', + ), + migrations.AddConstraint( + model_name='resourceaccess', + constraint=models.UniqueConstraint(fields=('user', 'resource'), name='resource_access_unique_user_resource', violation_error_message='Resource access with this user and resource already exists.'), + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 765280ff..71a36517 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -8,8 +8,10 @@ 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 mail, validators +from django.core.exceptions import PermissionDenied, ValidationError from django.db import models from django.utils.functional import lazy +from django.utils.text import capfirst, slugify from django.utils.translation import gettext_lazy as _ from timezone_field import TimeZoneField @@ -17,6 +19,24 @@ from timezone_field import TimeZoneField logger = getLogger(__name__) +class RoleChoices(models.TextChoices): + """Role choices.""" + + MEMBER = "member", _("Member") + ADMIN = "administrator", _("Administrator") + OWNER = "owner", _("Owner") + + @classmethod + def check_administrator_role(cls, role): + """Check if a role is administrator.""" + return role in [cls.ADMIN, cls.OWNER] + + @classmethod + def check_owner_role(cls, role): + """Check if a role is owner.""" + return role == cls.OWNER + + class BaseModel(models.Model): """ Serves as an abstract base model for other models, ensuring that records are validated @@ -141,3 +161,159 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin): Must be cached if retrieved remotely. """ return [] + + +class Resource(BaseModel): + """Model to define access control""" + + is_public = models.BooleanField(default=settings.DEFAULT_ROOM_IS_PUBLIC) + users = models.ManyToManyField( + User, + through="ResourceAccess", + through_fields=("resource", "user"), + related_name="resources", + ) + + class Meta: + db_table = "impress_resource" + verbose_name = _("Resource") + verbose_name_plural = _("Resources") + + def __str__(self): + try: + return self.name + except AttributeError: + return f"Resource {self.id!s}" + + def get_role(self, user): + """ + Determine the role of a given user in this resource. + """ + if not user or not user.is_authenticated: + return None + + role = None + for access in self.accesses.filter(user=user): + if access.role == RoleChoices.OWNER: + return RoleChoices.OWNER + if access.role == RoleChoices.ADMIN: + role = RoleChoices.ADMIN + if access.role == RoleChoices.MEMBER and role != RoleChoices.ADMIN: + role = RoleChoices.MEMBER + return role + + def is_administrator(self, user): + """ + Check if a user is administrator of the resource. + + Users carrying the "owner" role are considered as administrators a fortiori. + """ + return RoleChoices.check_administrator_role(self.get_role(user)) + + def is_owner(self, user): + """Check if a user is owner of the resource.""" + return RoleChoices.check_owner_role(self.get_role(user)) + + +class ResourceAccess(BaseModel): + """Link table between resources and users""" + + resource = models.ForeignKey( + Resource, + on_delete=models.CASCADE, + related_name="accesses", + ) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="accesses") + role = models.CharField( + max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER + ) + + class Meta: + db_table = "impress_resource_access" + verbose_name = _("Resource access") + verbose_name_plural = _("Resource accesses") + constraints = [ + models.UniqueConstraint( + fields=["user", "resource"], + name="resource_access_unique_user_resource", + violation_error_message=_( + "Resource access with this User and Resource already exists." + ), + ), + ] + + def __str__(self): + role = capfirst(self.get_role_display()) + try: + resource = self.resource.name + except AttributeError: + resource = f"resource {self.resource_id!s}" + + return f"{role:s} role for {self.user!s} on {resource:s}" + + def save(self, *args, **kwargs): + """Make sure we keep at least one owner for the resource.""" + if self.pk and self.role != RoleChoices.OWNER: + accesses = self._meta.model.objects.filter( + resource=self.resource, role=RoleChoices.OWNER + ).only("pk") + if len(accesses) == 1 and accesses[0].pk == self.pk: + raise PermissionDenied("A resource should keep at least one owner.") + return super().save(*args, **kwargs) + + def delete(self, *args, **kwargs): + """Disallow deleting the last of the Mohicans.""" + if ( + self.role == RoleChoices.OWNER + and self._meta.model.objects.filter( + resource=self.resource, role=RoleChoices.OWNER + ).count() + == 1 + ): + raise PermissionDenied("A resource should keep at least one owner.") + return super().delete(*args, **kwargs) + + +class Room(Resource): + """Model for one room""" + + name = models.CharField(max_length=500) + resource = models.OneToOneField( + Resource, + on_delete=models.CASCADE, + parent_link=True, + primary_key=True, + ) + slug = models.SlugField(max_length=100, blank=True, null=True, unique=True) + + configuration = models.JSONField( + blank=True, + default={}, + verbose_name=_("Visio room configuration"), + help_text=_("Values for Visio parameters to configure the room."), + ) + + class Meta: + db_table = "impress_room" + ordering = ("name",) + verbose_name = _("Room") + verbose_name_plural = _("Rooms") + + def __str__(self): + return capfirst(self.name) + + def clean_fields(self, exclude=None): + """ + Automatically generate the slug from the name and make sure it does not look like a UUID. + + We don't want any overlapping between the `slug` and the `id` fields because they can + both be used to get a room detail view on the API. + """ + self.slug = slugify(self.name) + try: + uuid.UUID(self.slug) + except ValueError: + pass + else: + raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'}) + super().clean_fields(exclude=exclude) diff --git a/src/backend/core/tests/test_models_resource_accesses.py b/src/backend/core/tests/test_models_resource_accesses.py new file mode 100644 index 00000000..df3e117d --- /dev/null +++ b/src/backend/core/tests/test_models_resource_accesses.py @@ -0,0 +1,63 @@ +""" +Unit tests for the ResourceAccess model with user +""" +from django.core.exceptions import ValidationError + +import pytest + +from core.factories import RoomFactory, UserResourceAccessFactory + +pytestmark = pytest.mark.django_db + + +def test_models_resource_accesses_user_str_member_room(): + """The str representation should consist in the room and usernames.""" + room = RoomFactory(name="my room") + access = UserResourceAccessFactory( + resource=room, user__email="john.doe@impress.com", role="member" + ) + assert str(access) == "Member role for john.doe@impress.com on my room" + + +def test_models_resource_accesses_user_str_member_resource(): + """The str representation should consist in the resource id and username.""" + access = UserResourceAccessFactory( + user__email="john.doe@impress.com", role="member" + ) + assert ( + str(access) + == f"Member role for john.doe@impress.com on resource {access.resource_id!s}" + ) + + +def test_models_resource_accesses_user_str_admin(): + """The str representation for an admin user should include the role.""" + access = UserResourceAccessFactory( + user__email="john.doe@impress.com", role="administrator" + ) + + assert ( + str(access) + == f"Administrator role for john.doe@impress.com on resource {access.resource_id!s}" + ) + + +def test_models_resource_accesses_user_str_owner(): + """The str representation for an admin user should include the role.""" + access = UserResourceAccessFactory(user__email="john.doe@impress.com", role="owner") + assert ( + str(access) + == f"Owner role for john.doe@impress.com on resource {access.resource_id!s}" + ) + + +def test_models_resource_accesses_user_unique(): + """Room user accesses should be unique.""" + access = UserResourceAccessFactory() + + with pytest.raises(ValidationError) as excinfo: + UserResourceAccessFactory(user=access.user, resource=access.resource) + + assert "Resource access with this User and Resource already exists." in str( + excinfo.value + ) diff --git a/src/backend/core/tests/test_models_rooms.py b/src/backend/core/tests/test_models_rooms.py new file mode 100644 index 00000000..8c757a85 --- /dev/null +++ b/src/backend/core/tests/test_models_rooms.py @@ -0,0 +1,165 @@ +""" +Unit tests for the Room model +""" +from django.contrib.auth.models import AnonymousUser +from django.core.exceptions import ValidationError + +import pytest + +from core.factories import RoomFactory, UserFactory +from core.models import Room + +pytestmark = pytest.mark.django_db + + +def test_models_rooms_str(): + """The str representation should be the name.""" + room = RoomFactory() + assert str(room) == room.name + + +def test_models_rooms_ordering(): + """Rooms should be returned ordered by name.""" + RoomFactory.create_batch(3) + rooms = Room.objects.all() + # Remove hyphens because postgresql is ignoring them when they sort + assert rooms[1].name.replace("-", "") >= rooms[0].name.replace("-", "") + assert rooms[2].name.replace("-", "") >= rooms[1].name.replace("-", "") + + +def test_models_rooms_name_maxlength(): + """The name field should be less than 100 characters.""" + RoomFactory(name="a" * 100) + + with pytest.raises(ValidationError) as excinfo: + RoomFactory(name="a" * 101) + + assert "Ensure this value has at most 100 characters (it has 101)." in str( + excinfo.value + ) + + +def test_models_rooms_slug_unique(): + """Room slugs should be unique.""" + RoomFactory(name="a room!") + + with pytest.raises(ValidationError) as excinfo: + RoomFactory(name="A Room!") + + assert "Room with this Slug already exists." in str(excinfo.value) + + +def test_models_rooms_name_slug_like_uuid(): + """ + It should raise an error if the value of the name field leads to a slug looking + like a UUID . We need unicity on the union of the `id` and `slug` fields. + """ + with pytest.raises(ValidationError) as excinfo: + RoomFactory(name="918689fb-038e 4e81-bf09 efd5902c5f0b") + + assert 'Room name "918689fb-038e 4e81-bf09 efd5902c5f0b" is reserved.' in str( + excinfo.value + ) + + +def test_models_rooms_slug_automatic(): + """Room slugs should be automatically populated upon saving.""" + room = Room(name="Eléphant in the room") + room.save() + assert room.slug == "elephant-in-the-room" + + +def test_models_rooms_users(): + """It should be possible to attach users to a room.""" + room = RoomFactory() + user = UserFactory() + room.users.add(user) + room.refresh_from_db() + + assert list(room.users.all()) == [user] + + +def test_models_rooms_is_public_default(): + """A room should be public by default.""" + room = Room.objects.create(name="room") + assert room.is_public is True + + +# Access rights methods + + +def test_models_rooms_access_rights_none(django_assert_num_queries): + """Calling access rights methods with None should return None.""" + room = RoomFactory() + + with django_assert_num_queries(0): + assert room.get_role(None) is None + with django_assert_num_queries(0): + assert room.is_administrator(None) is False + with django_assert_num_queries(0): + assert room.is_owner(None) is False + + +def test_models_rooms_access_rights_anonymous(django_assert_num_queries): + """Check access rights methods on the room object for an anonymous user.""" + user = AnonymousUser() + room = RoomFactory() + + with django_assert_num_queries(0): + assert room.get_role(user) is None + with django_assert_num_queries(0): + assert room.is_administrator(user) is False + with django_assert_num_queries(0): + assert room.is_owner(user) is False + + +def test_models_rooms_access_rights_authenticated(django_assert_num_queries): + """Check access rights methods on the room object for an unrelated user.""" + user = UserFactory() + room = RoomFactory() + + with django_assert_num_queries(1): + assert room.get_role(user) is None + with django_assert_num_queries(1): + assert room.is_administrator(user) is False + with django_assert_num_queries(1): + assert room.is_owner(user) is False + + +def test_models_rooms_access_rights_member_direct(django_assert_num_queries): + """Check access rights methods on the room object for a direct member.""" + user = UserFactory() + room = RoomFactory(users=[(user, "member")]) + + with django_assert_num_queries(1): + assert room.get_role(user) == "member" + with django_assert_num_queries(1): + assert room.is_administrator(user) is False + with django_assert_num_queries(1): + assert room.is_owner(user) is False + + +def test_models_rooms_access_rights_administrator_direct(django_assert_num_queries): + """The is_administrator method should return True for a direct administrator.""" + user = UserFactory() + room = RoomFactory(users=[(user, "administrator")]) + + with django_assert_num_queries(1): + assert room.get_role(user) == "administrator" + with django_assert_num_queries(1): + assert room.is_administrator(user) is True + with django_assert_num_queries(1): + assert room.is_owner(user) is False + + +def test_models_rooms_access_rights_owner_direct(django_assert_num_queries): + """Check access rights methods on the room object for an owner.""" + user = UserFactory() + room = RoomFactory(users=[(user, "owner")]) + + with django_assert_num_queries(1): + assert room.get_role(user) == "owner" + with django_assert_num_queries(1): + assert room.is_administrator(user) is True + with django_assert_num_queries(1): + assert room.is_owner(user) is True diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 787f4cb6..05195a97 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -368,6 +368,11 @@ class Base(Configuration): default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None ) + # Video conference configuration + DEFAULT_ROOM_IS_PUBLIC = values.BooleanValue( + True, environ_name="MAGNIFY_DEFAULT_ROOM_IS_PUBLIC", environ_prefix=None + ) + # pylint: disable=invalid-name @property def ENVIRONMENT(self):