From fc25b62c0553edbf48f7d104a763ca655ac57b49 Mon Sep 17 00:00:00 2001 From: Stefano Pigozzi Date: Fri, 6 Aug 2021 17:14:28 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Implement=20research=20groups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (closes #22) (closes #23) --- backend/sophon/core/admin.py | 23 ++- .../migrations/0005_auto_20210806_1506.py | 41 ++++ backend/sophon/core/models.py | 111 +++++++--- backend/sophon/core/permissions.py | 9 +- backend/sophon/core/serializers.py | 190 +++++++++++------- backend/sophon/core/urls.py | 2 +- backend/sophon/core/views.py | 17 +- 7 files changed, 272 insertions(+), 121 deletions(-) create mode 100644 backend/sophon/core/migrations/0005_auto_20210806_1506.py diff --git a/backend/sophon/core/admin.py b/backend/sophon/core/admin.py index 587920d..2f6da42 100644 --- a/backend/sophon/core/admin.py +++ b/backend/sophon/core/admin.py @@ -9,15 +9,26 @@ class CoreAdmin(admin.ModelAdmin): """ -@admin.register(models.Project) -class ProjectAdmin(CoreAdmin): - """ - :class:`.CoreAdmin` class for :class:`.models.Project` . - """ - +@admin.register(models.ResearchGroup) +class ResearchGroupAdmin(CoreAdmin): list_display = ( "slug", "name", + "access", + ) + + ordering = ( + "slug", + ) + + +@admin.register(models.ResearchProject) +class ResearchProjectAdmin(CoreAdmin): + list_display = ( + "group", + "slug", + "name", + "visibility", ) ordering = ( diff --git a/backend/sophon/core/migrations/0005_auto_20210806_1506.py b/backend/sophon/core/migrations/0005_auto_20210806_1506.py new file mode 100644 index 0000000..b7a1960 --- /dev/null +++ b/backend/sophon/core/migrations/0005_auto_20210806_1506.py @@ -0,0 +1,41 @@ +# Generated by Django 3.2 on 2021-08-06 15:06 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('core', '0004_alter_dataflow_surrogate_id'), + ] + + operations = [ + migrations.CreateModel( + name='ResearchGroup', + fields=[ + ('slug', models.SlugField(help_text='Unique alphanumeric string which identifies the group.', max_length=64, primary_key=True, serialize=False, verbose_name='Slug')), + ('name', models.CharField(help_text='The display name of the group.', max_length=512, verbose_name='Name')), + ('description', models.TextField(blank=True, help_text='A brief description of what the group is about, to be displayed in the overview.', verbose_name='Description')), + ('access', models.CharField(choices=[('MANUAL', '⛔️ Collaborators must be added manually'), ('OPEN', '❇️ Users can join the group freely')], default='MANUAL', help_text='A setting specifying how can users join this group.', max_length=16, verbose_name='Access')), + ('members', models.ManyToManyField(blank=True, help_text='The users who belong to this group, including the owner.', related_name='is_a_member_of', to=settings.AUTH_USER_MODEL)), + ('owner', models.ForeignKey(help_text='The user who created the group, and therefore can add other users to it.', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='ResearchProject', + fields=[ + ('slug', models.SlugField(help_text='Unique alphanumeric string which identifies the project.', max_length=64, primary_key=True, serialize=False, verbose_name='Slug')), + ('name', models.CharField(help_text='The display name of the project.', max_length=512, verbose_name='Name')), + ('description', models.TextField(blank=True, help_text='A brief description of the project, to be displayed in the overview.', verbose_name='Description')), + ('visibility', models.CharField(choices=[('PUBLIC', '🌍 Public'), ('INTERNAL', '🏭 Internal'), ('PRIVATE', '🔒 Private')], default='INTERNAL', help_text='A setting specifying who can view the project contents.', max_length=16, verbose_name='Visibility')), + ('flows', models.ManyToManyField(blank=True, help_text='The DataFlows used in this project.', related_name='used_in', to='core.DataFlow')), + ('group', models.ForeignKey(help_text='The group this project belongs to.', on_delete=django.db.models.deletion.CASCADE, to='core.researchgroup')), + ], + ), + migrations.DeleteModel( + name='Project', + ), + ] diff --git a/backend/sophon/core/models.py b/backend/sophon/core/models.py index 7668c47..94c334d 100644 --- a/backend/sophon/core/models.py +++ b/backend/sophon/core/models.py @@ -332,9 +332,61 @@ class DataFlow(models.Model): return f"[{self.datasource}] {self.sdmx_id}" -class Project(models.Model): +class ResearchGroup(models.Model): """ - A research :class:`.Project` is a work which may use zero or more :class:`.DataSource`\\ s to prove or disprove an + A :class:`.ResearchGroup` is a group of users which collectively own :class:`.ResearchProjects`. + """ + + slug = models.SlugField( + "Slug", + help_text="Unique alphanumeric string which identifies the group.", + max_length=64, + primary_key=True, + ) + + name = models.CharField( + "Name", + help_text="The display name of the group.", + max_length=512, + ) + + description = models.TextField( + "Description", + help_text="A brief description of what the group is about, to be displayed in the overview.", + blank=True, + ) + + owner = models.ForeignKey( + User, + help_text="The user who created the group, and therefore can add other users to it.", + on_delete=models.CASCADE, + ) + + members = models.ManyToManyField( + User, + help_text="The users who belong to this group, including the owner.", + related_name="is_a_member_of", + blank=True, + ) + + access = models.CharField( + "Access", + help_text="A setting specifying how can users join this group.", + choices=[ + ("MANUAL", "⛔️ Collaborators must be added manually"), + ("OPEN", "❇️ Users can join the group freely"), + ], + default="MANUAL", + max_length=16, + ) + + def __str__(self): + return f"{self.slug}" + + +class ResearchProject(models.Model): + """ + A :class:`.ResearchProject` is a work which may use zero or more :class:`.DataSource`\\ s to prove or disprove an hypothesis. """ @@ -353,13 +405,13 @@ class Project(models.Model): description = models.TextField( "Description", - help_text="A brief description of the project, to be displayed inthe overview.", + help_text="A brief description of the project, to be displayed in the overview.", blank=True, ) visibility = models.CharField( "Visibility", - help_text="A setting specifying who can view the project.", + help_text="A setting specifying who can view the project contents.", choices=[ ("PUBLIC", "🌍 Public"), ("INTERNAL", "🏭 Internal"), @@ -369,19 +421,12 @@ class Project(models.Model): max_length=16, ) - owner = models.ForeignKey( - User, - help_text="The user who owns the project, and has full access to it.", + group = models.ForeignKey( + ResearchGroup, + help_text="The group this project belongs to.", on_delete=models.CASCADE, ) - collaborators = models.ManyToManyField( - User, - help_text="The users who can edit the project.", - related_name="collaborates_in", - blank=True, - ) - flows = models.ManyToManyField( DataFlow, help_text="The DataFlows used in this project.", @@ -389,30 +434,24 @@ class Project(models.Model): blank=True, ) - def get_project(self): - return self - - def get_contributors(self): - """ - :return: All the contributors (:attr:`.owner` + :attr:`.collaborators`) of the project. - """ - - return {self.owner, *self.collaborators.values()} - def can_be_viewed_by(self, user) -> bool: """ - Check whether an user should be allowed to **view** the project details. + Check whether an user should be allowed to **view** the project contents. :param user: The user to check permissions for. :return: :data:`True` if the user can view the details, or :data:`False` if they cannot. """ - if self.visibility == "PUBLIC": + if user.is_superuser: + return True + + elif self.visibility == "PUBLIC": return True elif self.visibility == "INTERNAL": return not user.is_anonymous() elif self.visibility == "PRIVATE": - return user in self.get_contributors() + return user in self.group.members + else: raise ValueError(f"Unknown visibility value: {self.visibility}") @@ -424,7 +463,13 @@ class Project(models.Model): :return: :data:`True` if the user can edit the details, or :data:`False` if they cannot. """ - return user in self.get_contributors() + if user.is_superuser: + return True + + elif user in self.group.members: + return True + + return False def can_be_administrated_by(self, user) -> bool: """ @@ -434,7 +479,13 @@ class Project(models.Model): :return: :data:`True` if the user can administrate the project, or :data:`False` if they cannot. """ - return user == self.owner + if user.is_superuser: + return True + + elif user in self.group.members: + return True + + return False def __str__(self): - return self.slug + return f"{self.group.slug}/{self.slug}" diff --git a/backend/sophon/core/permissions.py b/backend/sophon/core/permissions.py index b8a4ef4..63ae0b1 100644 --- a/backend/sophon/core/permissions.py +++ b/backend/sophon/core/permissions.py @@ -7,17 +7,14 @@ log = logging.getLogger(__name__) class CanAdministrateProject(permissions.BasePermission): def has_object_permission(self, request, view, obj): - project = obj.get_project() - return project.can_be_administrated_by(request.user) + return obj.can_be_administrated_by(request.user) class CanEditProject(permissions.BasePermission): def has_object_permission(self, request, view, obj): - project = obj.get_project() - return project.can_be_edited_by(request.user) + return obj.can_be_edited_by(request.user) class CanViewProject(permissions.BasePermission): def has_object_permission(self, request, view, obj): - project = obj.get_project() - return project.can_be_viewed_by(request.user) + return obj.can_be_viewed_by(request.user) diff --git a/backend/sophon/core/serializers.py b/backend/sophon/core/serializers.py index fb479a7..468156d 100644 --- a/backend/sophon/core/serializers.py +++ b/backend/sophon/core/serializers.py @@ -55,83 +55,133 @@ class DataFlowSerializer(serializers.ModelSerializer): ] -class ProjectPrivateSerializer(serializers.ModelSerializer): - class Meta: - model = models.Project - fields = [ - "slug", - "name", - "visibility", - "owner", - ] - read_only_fields = [ - "slug", - "name", - "visibility", - "owner", - ] - - -class ProjectViewableSerializer(serializers.ModelSerializer): - class Meta: - model = models.Project - fields = [ - "slug", - "name", - "description", - "visibility", - "owner", - "collaborators", - "flows", - ] - read_only_fields = [ - "slug", - "name", - "description", - "visibility", - "owner", - "collaborators", - "flows", - ] - - -class ProjectEditableSerializer(serializers.ModelSerializer): - class Meta: - model = models.Project - fields = [ - "slug", - "name", - "description", - "visibility", - "owner", - "collaborators", - "flows", - ] - read_only_fields = [ - "slug", - "visibility", - "owner", - "collaborators", - ] - - -class ProjectAdministrableSerializer(serializers.ModelSerializer): +class ResearchGroupPublicSerializer(serializers.ModelSerializer): """ - Serializer for :class:`.models.Project` when accessed as the project owner. + Serializer for users who are not administrators of a :class:`.models.ResearchGroup`. """ class Meta: - model = models.Project - fields = [ + model = models.ResearchGroup + fields = ( + "slug", + "name", + "description", + "owner", + "members", + "access", + ) + read_only_fields = ( + "slug", + "name", + "description", + "owner", + "members", + "access", + ) + + +class ResearchGroupAdminSerializer(serializers.ModelSerializer): + """ + Serializer for users who are administrators of a :class:`.models.ResearchGroup`. + """ + + class Meta: + model = models.ResearchGroup + fields = ( + "slug", + "name", + "description", + "owner", + "members", + "access", + ) + read_only_fields = ( + "slug", + "owner", + ) + + +class ResearchProjectPublicSerializer(serializers.ModelSerializer): + """ + Serializer for users who are not collaborators of a :class:`~.models.ResearchProject` and do not have permissions to view it. + """ + + class Meta: + model = models.ResearchProject + fields = ( + "slug", + "visibility", + "group", + ) + read_only_fields = ( + "slug", + "visibility", + "group", + ) + + +class ResearchProjectViewerSerializer(serializers.ModelSerializer): + """ + Serializer for users who are not collaborators of a :class:`~.models.ResearchProject`, but have permissions to view it. + """ + + class Meta: + model = models.ResearchProject + fields = ( "slug", "name", "description", "visibility", - "owner", - "collaborators", + "group", "flows", - ] - read_only_fields = [ + ) + read_only_fields = ( "slug", - "owner", - ] + "name", + "description", + "visibility", + "group", + "flows", + ) + + +class ResearchProjectCollaboratorSerializer(serializers.ModelSerializer): + """ + Serializer for users who are collaborators of a :class:`~.models.ResearchProject`, but not administrators. + """ + + class Meta: + model = models.ResearchProject + fields = ( + "slug", + "name", + "description", + "visibility", + "group", + "flows", + ) + read_only_fields = ( + "slug", + "visibility", + "group", + ) + + +class ResearchProjectAdminSerializer(serializers.ModelSerializer): + """ + Serializer for users who are administrators of a :class:`~.models.ResearchProject`. + """ + + class Meta: + model = models.ResearchProject + fields = ( + "slug", + "name", + "description", + "visibility", + "group", + "flows", + ) + read_only_fields = ( + "slug", + ) diff --git a/backend/sophon/core/urls.py b/backend/sophon/core/urls.py index 3e5b42b..c9a3918 100644 --- a/backend/sophon/core/urls.py +++ b/backend/sophon/core/urls.py @@ -6,7 +6,7 @@ from . import views router = DefaultRouter() router.register("datasources", views.DataSourceViewSet) router.register("dataflows", views.DataFlowViewSet) -router.register("projects", views.ProjectViewSet) +router.register("projects", views.ResearchProjectViewSet) urlpatterns = [ path("", include(router.urls)), diff --git a/backend/sophon/core/views.py b/backend/sophon/core/views.py index 23902f5..7fae7da 100644 --- a/backend/sophon/core/views.py +++ b/backend/sophon/core/views.py @@ -7,8 +7,8 @@ from . import models, serializers, permissions as custom_permissions log = getLogger(__name__) -class ProjectViewSet(viewsets.ModelViewSet): - queryset = models.Project.objects.all() +class ResearchProjectViewSet(viewsets.ModelViewSet): + queryset = models.ResearchProject.objects.all() @property def permission_classes(self): @@ -19,25 +19,26 @@ class ProjectViewSet(viewsets.ModelViewSet): "update": [custom_permissions.CanEditProject], "partial_update": [custom_permissions.CanEditProject], "destroy": [custom_permissions.CanAdministrateProject], + "metadata": [], None: [], }[self.action] def get_serializer_class(self): if self.action == "list": - return serializers.ProjectPrivateSerializer + return serializers.ResearchProjectPublicSerializer elif self.action == "create": - return serializers.ProjectAdministrableSerializer + return serializers.ResearchProjectAdminSerializer else: project = self.get_object() user = self.request.user if project.can_be_administrated_by(user): - return serializers.ProjectAdministrableSerializer + return serializers.ResearchProjectAdminSerializer elif project.can_be_edited_by(user): - return serializers.ProjectEditableSerializer + return serializers.ResearchProjectCollaboratorSerializer elif project.can_be_viewed_by(user): - return serializers.ProjectViewableSerializer + return serializers.ResearchProjectViewerSerializer else: - return serializers.ProjectPrivateSerializer + return serializers.ResearchProjectPublicSerializer class DataFlowViewSet(viewsets.ModelViewSet):