(project) add Room, Ressource, Access models from Magnify

I picked few models from Magnify to build our MVP:

- Resource:
   A generic model representing any type of resource. Though currently used only by Room,
   it encapsulates a meaningful business logic as an abstract model.
- Room:
   The primary object we manipulate, representing a meeting room with access
   and permission controls.
- ResourceAccess
   Ensures relevant users have the appropriate permissions for a given room.

** What’s different from Magnify ? **

Removed group logic; it will be added later. For now, we rely on the user model's
property to get its groups via desk.

Removed any logic or method related to Jitsi or LiveKit. These servers will be integrated
in the upcomming commits.

Focus on Room-related models to maintain a minimal and functional product (KISS principle)
until we achieve product-market fit (PMF).

Creating simple public and private, permanent and temporary rooms
is sufficient for building our MVP.

The Meeting model in Magnify, which supports recurrence, should be handled by
the collaborative calendar instead.

Adapted the unit test to use Pytest, and linted all the sources using Ruff linter.

(Migrations will be squashed before releasing the MVP)
This commit is contained in:
lebaudantoine
2024-06-24 19:14:27 +02:00
parent fbe79b7b2b
commit 2e6feede31
6 changed files with 565 additions and 0 deletions

View File

@@ -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))

View File

@@ -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.'),
),
]

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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

View File

@@ -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):