diff --git a/rdmo/core/managers.py b/rdmo/core/managers.py index e1f68ebf70..2243bf3277 100644 --- a/rdmo/core/managers.py +++ b/rdmo/core/managers.py @@ -12,8 +12,12 @@ def filter_current_site(self): class GroupsQuerySetMixin: - def filter_group(self, user): - groups = user.groups.all() + def filter_group(self, users): + + if not isinstance(users, (list, tuple, models.QuerySet)): + users = [users] + + groups = {group for user in users for group in user.groups.all()} return self.filter(models.Q(groups=None) | models.Q(groups__in=groups)) diff --git a/rdmo/core/settings.py b/rdmo/core/settings.py index c6610be4de..bd74dd25a2 100644 --- a/rdmo/core/settings.py +++ b/rdmo/core/settings.py @@ -209,7 +209,9 @@ 'PROJECT_IMPORTS_LIST', 'PROJECT_SEND_ISSUE', 'PROJECT_QUESTIONS_AUTOSAVE', - 'NESTED_PROJECTS' + 'NESTED_PROJECTS', + 'PROJECT_VIEWS_SYNC', + 'PROJECT_TASKS_SYNC' ] SETTINGS_API = [ @@ -332,7 +334,8 @@ PROJECT_SEND_INVITE = True -PROJECT_REMOVE_VIEWS = True +PROJECT_VIEWS_SYNC = True +PROJECT_TASKS_SYNC = True PROJECT_CREATE_RESTRICTED = False PROJECT_CREATE_GROUPS = [] diff --git a/rdmo/projects/apps.py b/rdmo/projects/apps.py index 178bb4ccd3..07dfc6a64d 100644 --- a/rdmo/projects/apps.py +++ b/rdmo/projects/apps.py @@ -10,5 +10,7 @@ class ProjectsConfig(AppConfig): def ready(self): from . import rules # noqa: F401 - if settings.PROJECT_REMOVE_VIEWS: - from . import handlers # noqa: F401 + if settings.PROJECT_VIEWS_SYNC: + from .handlers import m2m_changed_views, project_save_views # noqa: F401 + if settings.PROJECT_TASKS_SYNC: + from .handlers import m2m_changed_tasks, project_save_tasks # noqa: F401 diff --git a/rdmo/projects/forms.py b/rdmo/projects/forms.py index 3d2d1a220a..dd0228240b 100644 --- a/rdmo/projects/forms.py +++ b/rdmo/projects/forms.py @@ -160,6 +160,16 @@ class Meta: 'catalog': forms.RadioSelect() } + def save(self, commit=True, *args, **kwargs): + if 'cancel' in self.data: + return self.instance + + # if the catalog is the same, do nothing + if self.instance.catalog.id == self.cleaned_data.get('catalog'): + return self.instance + + return super().save(commit=commit) + class ProjectUpdateTasksForm(forms.ModelForm): @@ -180,6 +190,22 @@ class Meta: 'tasks': forms.CheckboxSelectMultiple() } + def save(self, commit=True, *args, **kwargs): + if 'cancel' in self.data: + return self.instance + + # If the tasks are the same, do nothing + current_tasks = set(self.instance.tasks.values_list('id', flat=True)) + new_tasks = set(self.cleaned_data.get('tasks').values_list('id', flat=True)) if self.cleaned_data.get( + 'tasks') else set() + + if current_tasks == new_tasks: + return self.instance + + # Save the updated tasks + self.instance.tasks.set(self.cleaned_data.get('tasks')) + return super().save(commit=commit) + class ProjectUpdateViewsForm(forms.ModelForm): @@ -200,6 +226,22 @@ class Meta: 'views': forms.CheckboxSelectMultiple() } + def save(self, commit=True, *args, **kwargs): + if 'cancel' in self.data: + return self.instance + + # If the views are the same, do nothing + current_views = set(self.instance.views.values_list('id', flat=True)) + new_views = ( set(self.cleaned_data.get('views').values_list('id', flat=True)) + if self.cleaned_data.get('views') else set() + ) + + if current_views == new_views: + return self.instance + + # Save the updated views + self.instance.views.set(self.cleaned_data.get('views')) + return super().save(commit=commit) class ProjectUpdateParentForm(forms.ModelForm): diff --git a/rdmo/projects/handlers.py b/rdmo/projects/handlers.py index 6f65709cbe..7014bedfcc 100644 --- a/rdmo/projects/handlers.py +++ b/rdmo/projects/handlers.py @@ -1,60 +1,25 @@ -import logging - -from django.contrib.auth.models import User -from django.contrib.sites.models import Site -from django.db.models.signals import m2m_changed +from django.db.models.signals import post_save from django.dispatch import receiver -from rdmo.projects.models import Membership, Project -from rdmo.questions.models import Catalog +from rdmo.projects.models import Project from rdmo.views.models import View -logger = logging.getLogger(__name__) - - -@receiver(m2m_changed, sender=View.catalogs.through) -def m2m_changed_view_catalog_signal(sender, instance, **kwargs): - catalogs = instance.catalogs.all() - - if catalogs: - catalog_candidates = Catalog.objects.exclude(id__in=[catalog.id for catalog in catalogs]) - - # Remove catalog candidates for all sites - projects = Project.objects.filter(catalog__in=catalog_candidates, views=instance) - for proj in projects: - proj.views.remove(instance) - - -@receiver(m2m_changed, sender=View.sites.through) -def m2m_changed_view_sites_signal(sender, instance, **kwargs): - sites = instance.sites.all() - catalogs = instance.catalogs.all() - - if sites: - site_candidates = Site.objects.exclude(id__in=[site.id for site in sites]) - if not catalogs: - # if no catalogs are selected, update all - catalogs = Catalog.objects.all() - - # Restrict chosen catalogs for chosen sites - projects = Project.objects.filter(site__in=site_candidates, catalog__in=catalogs, views=instance) - for project in projects: - project.views.remove(instance) +@receiver(post_save, sender=Project) +def update_views_on_catalog_change(sender, instance, **kwargs): + # remove views that are no longer available + view_candidates = instance.views.exclude(catalogs__in=[instance.catalog]) \ + .exclude(catalogs=None) -@receiver(m2m_changed, sender=View.groups.through) -def m2m_changed_view_groups_signal(sender, instance, **kwargs): - groups = instance.groups.all() - catalogs = instance.catalogs.all() + for view in view_candidates: + instance.views.remove(view) - if groups: - users = User.objects.exclude(groups__in=groups) - memberships = [membership.id for membership in Membership.objects.filter(role='owner', user__in=users)] - if not catalogs: - # if no catalogs are selected, update all - catalogs = Catalog.objects.all() + # add views that are now available + view_candidates = View.objects.exclude(id__in=[v.id for v in instance.views.all()]) \ + .filter_current_site() \ + .filter_catalog(instance.catalog) + # .filter_group(self.request.user) \ + # .filter_availability(self.request.user).exists() - # Restrict chosen catalogs for chosen groups - projects = Project.objects.filter(memberships__in=list(memberships), catalog__in=catalogs, views=instance) - for project in projects: - project.views.remove(instance) + for view in view_candidates: + instance.views.add(view) diff --git a/rdmo/projects/handlers/__init__.py b/rdmo/projects/handlers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/rdmo/projects/handlers/generic_handlers.py b/rdmo/projects/handlers/generic_handlers.py new file mode 100644 index 0000000000..6aae8561fe --- /dev/null +++ b/rdmo/projects/handlers/generic_handlers.py @@ -0,0 +1,109 @@ +from django.contrib.auth.models import Group, User +from django.contrib.sites.models import Site + +from rdmo.projects.models import Membership, Project + +from ...questions.models import Catalog +from .utils import add_instance_to_projects, remove_instance_from_projects + + +def m2m_catalogs_changed_projects_sync_signal_handler(action, pk_set, instance, project_field): + """ + Update project relationships for m2m_changed signals. + + Args: + action (str): The m2m_changed action (post_add, post_remove, post_clear). + pk_set (set): The set of primary keys for the related model instances. + instance (Model): The instance being updated (e.g., View or Task). + project_field (str): The field on Project to update (e.g., 'views', 'tasks'). + """ + if action == 'post_remove' and pk_set: + related_instances = Catalog.objects.filter(pk__in=pk_set) + projects_to_change = Project.objects.filter_catalogs(catalogs=related_instances).filter( + **{project_field: instance} + ) + remove_instance_from_projects(projects_to_change, project_field, instance) + + elif action == 'post_clear': + projects_to_change = Project.objects.filter(**{project_field: instance}) + remove_instance_from_projects(projects_to_change, project_field, instance) + + elif action == 'post_add' and pk_set: + related_instances = Catalog.objects.filter(pk__in=pk_set) + projects_to_change = Project.objects.filter_catalogs(catalogs=related_instances).exclude( + **{project_field: instance} + ) + add_instance_to_projects(projects_to_change, project_field, instance) + + +def m2m_sites_changed_projects_sync_signal_handler(action, pk_set, instance, project_field): + """ + Synchronize Project relationships for m2m_changed signals triggered by site updates. + + Args: + action (str): The m2m_changed action (post_add, post_remove, post_clear). + pk_set (set): The set of primary keys for the related model instances. + instance (Model): The instance being updated (e.g., View or Task). + project_field (str): The field on Project to update (e.g., 'views', 'tasks'). + """ + if action == 'post_remove' and pk_set: + related_sites = Site.objects.filter(pk__in=pk_set) + catalogs = instance.catalogs.all() + + projects_to_change = Project.objects.filter_catalogs(catalogs=catalogs).filter( + site__in=related_sites, + **{project_field: instance} + ) + remove_instance_from_projects(projects_to_change, project_field, instance) + + elif action == 'post_clear': + projects_to_change = Project.objects.filter_catalogs().filter(**{project_field: instance}) + remove_instance_from_projects(projects_to_change, project_field, instance) + + elif action == 'post_add' and pk_set: + related_sites = Site.objects.filter(pk__in=pk_set) + catalogs = instance.catalogs.all() + + projects_to_change = Project.objects.filter_catalogs(catalogs=catalogs).filter( + site__in=related_sites + ).exclude(**{project_field: instance}) + add_instance_to_projects(projects_to_change, project_field, instance) + + +def m2m_groups_changed_projects_sync_signal_handler(action, pk_set, instance, project_field): + """ + Synchronize Project relationships for m2m_changed signals triggered by group updates. + + Args: + action (str): The m2m_changed action (post_add, post_remove, post_clear). + pk_set (set): The set of primary keys for the related model instances. + instance (Model): The instance being updated (e.g., View or Task). + project_field (str): The field on Project to update (e.g., 'views', 'tasks'). + """ + if action == 'post_remove' and pk_set: + related_groups = Group.objects.filter(pk__in=pk_set) + users = User.objects.filter(groups__in=related_groups) + memberships = Membership.objects.filter(role='owner', user__in=users).values_list('id', flat=True) + catalogs = instance.catalogs.all() + + projects_to_change = Project.objects.filter_catalogs(catalogs=catalogs).filter( + memberships__in=memberships, + **{project_field: instance} + ) + remove_instance_from_projects(projects_to_change, project_field, instance) + + elif action == 'post_clear': + # Remove all linked projects regardless of catalogs + projects_to_change = Project.objects.filter_catalogs().filter(**{project_field: instance}) + remove_instance_from_projects(projects_to_change, project_field, instance) + + elif action == 'post_add' and pk_set: + related_groups = Group.objects.filter(pk__in=pk_set) + users = User.objects.filter(groups__in=related_groups) + memberships = Membership.objects.filter(role='owner', user__in=users).values_list('id', flat=True) + catalogs = instance.catalogs.all() + + projects_to_change = Project.objects.filter_catalogs(catalogs=catalogs).filter( + memberships__in=memberships + ).exclude(**{project_field: instance}) + add_instance_to_projects(projects_to_change, project_field, instance) diff --git a/rdmo/projects/handlers/m2m_changed_tasks.py b/rdmo/projects/handlers/m2m_changed_tasks.py new file mode 100644 index 0000000000..40190cbd3a --- /dev/null +++ b/rdmo/projects/handlers/m2m_changed_tasks.py @@ -0,0 +1,41 @@ + +from django.db.models.signals import m2m_changed +from django.dispatch import receiver + +from rdmo.tasks.models import Task + +from .generic_handlers import ( + m2m_catalogs_changed_projects_sync_signal_handler, + m2m_groups_changed_projects_sync_signal_handler, + m2m_sites_changed_projects_sync_signal_handler, +) + + +@receiver(m2m_changed, sender=Task.catalogs.through) +def m2m_changed_task_catalog_signal(sender, instance, action, pk_set, **kwargs): + m2m_catalogs_changed_projects_sync_signal_handler( + action=action, + pk_set=pk_set, + instance=instance, + project_field='tasks', + ) + + +@receiver(m2m_changed, sender=Task.sites.through) +def m2m_changed_task_sites_signal(sender, instance, action, pk_set, **kwargs): + m2m_sites_changed_projects_sync_signal_handler( + action=action, + pk_set=pk_set, + instance=instance, + project_field='tasks' + ) + + +@receiver(m2m_changed, sender=Task.groups.through) +def m2m_changed_task_groups_signal(sender, instance, action, pk_set, **kwargs): + m2m_groups_changed_projects_sync_signal_handler( + action=action, + pk_set=pk_set, + instance=instance, + project_field='tasks' + ) diff --git a/rdmo/projects/handlers/m2m_changed_views.py b/rdmo/projects/handlers/m2m_changed_views.py new file mode 100644 index 0000000000..c946ab4c5a --- /dev/null +++ b/rdmo/projects/handlers/m2m_changed_views.py @@ -0,0 +1,42 @@ + +from django.db.models.signals import m2m_changed +from django.dispatch import receiver + +from rdmo.views.models import View + +from .generic_handlers import ( + m2m_catalogs_changed_projects_sync_signal_handler, + m2m_groups_changed_projects_sync_signal_handler, + m2m_sites_changed_projects_sync_signal_handler, +) + + +@receiver(m2m_changed, sender=View.catalogs.through) +def m2m_changed_view_catalog_signal(sender, instance, action, pk_set, **kwargs): + m2m_catalogs_changed_projects_sync_signal_handler( + action=action, + pk_set=pk_set, + instance=instance, + project_field='views', + ) + + + +@receiver(m2m_changed, sender=View.sites.through) +def m2m_changed_view_sites_signal(sender, instance, action, pk_set, **kwargs): + m2m_sites_changed_projects_sync_signal_handler( + action=action, + pk_set=pk_set, + instance=instance, + project_field='views' + ) + + +@receiver(m2m_changed, sender=View.groups.through) +def m2m_changed_view_groups_signal(sender, instance, action, pk_set, **kwargs): + m2m_groups_changed_projects_sync_signal_handler( + action=action, + pk_set=pk_set, + instance=instance, + project_field='views' + ) diff --git a/rdmo/projects/handlers/project_save_tasks.py b/rdmo/projects/handlers/project_save_tasks.py new file mode 100644 index 0000000000..40938a1556 --- /dev/null +++ b/rdmo/projects/handlers/project_save_tasks.py @@ -0,0 +1,31 @@ +from django.db.models.signals import post_save, pre_save +from django.dispatch import receiver + +from rdmo.projects.models import Project +from rdmo.tasks.models import Task + +DEFERRED_SYNC_TASKS_KEY = '_deferred_sync_tasks' + +@receiver(pre_save, sender=Project) +def pre_save_project_sync_tasks_from_catalog(sender, instance, raw, update_fields, **kwargs): + if raw or (update_fields and 'catalog' not in update_fields): + return + + if instance.id is not None: + # Fetch the original catalog from the database + if sender.objects.get(id=instance.id).catalog == instance.catalog: + # Do nothing if the catalog has not changed + return + + # Defer synchronization of views + setattr(instance, DEFERRED_SYNC_TASKS_KEY, True) + + +@receiver(post_save, sender=Project) +def post_save_project_sync_tasks_from_catalog(sender, instance, created, raw, update_fields, **kwargs): + if raw or (update_fields and 'catalog' not in update_fields): + return + + if hasattr(instance, DEFERRED_SYNC_TASKS_KEY): + instance.tasks.set(Task.objects.filter_available_tasks_for_project(instance).values_list('id', flat=True)) + delattr(instance, DEFERRED_SYNC_TASKS_KEY) diff --git a/rdmo/projects/handlers/project_save_views.py b/rdmo/projects/handlers/project_save_views.py new file mode 100644 index 0000000000..4e96c9448a --- /dev/null +++ b/rdmo/projects/handlers/project_save_views.py @@ -0,0 +1,30 @@ +from django.db.models.signals import post_save, pre_save +from django.dispatch import receiver + +from rdmo.projects.models import Project +from rdmo.views.models import View + +DEFERRED_SYNC_VIEWS_KEY = '_deferred_sync_views' + +@receiver(pre_save, sender=Project) +def pre_save_project_sync_views_from_catalog(sender, instance, raw, update_fields, **kwargs): + if raw or (update_fields and 'catalog' not in update_fields): + return + + if instance.id is not None: + # Fetch the original catalog from the database + if sender.objects.get(id=instance.id).catalog == instance.catalog: + # Do nothing if the catalog has not changed + return + + # Defer synchronization of views + setattr(instance, DEFERRED_SYNC_VIEWS_KEY, True) + +@receiver(post_save, sender=Project) +def post_save_project_sync_views_from_catalog(sender, instance, created, raw, update_fields, **kwargs): + if raw or (update_fields and 'catalog' not in update_fields): + return + + if hasattr(instance, DEFERRED_SYNC_VIEWS_KEY): + instance.views.set(View.objects.filter_available_views_for_project(instance).values_list('id', flat=True)) + delattr(instance, DEFERRED_SYNC_VIEWS_KEY) diff --git a/rdmo/projects/handlers/utils.py b/rdmo/projects/handlers/utils.py new file mode 100644 index 0000000000..3175010938 --- /dev/null +++ b/rdmo/projects/handlers/utils.py @@ -0,0 +1,10 @@ + + +def remove_instance_from_projects(projects, project_field, instance): + for project in projects: + getattr(project, project_field).remove(instance) + + +def add_instance_to_projects(projects, project_field, instance): + for project in projects: + getattr(project, project_field).add(instance) diff --git a/rdmo/projects/managers.py b/rdmo/projects/managers.py index a6d49e4ff6..369b592f46 100644 --- a/rdmo/projects/managers.py +++ b/rdmo/projects/managers.py @@ -35,6 +35,16 @@ def filter_visibility(self, user): visibility_filter = Q(visibility__isnull=False) & sites_filter & groups_filter return self.filter(Q(user=user) | visibility_filter) + def filter_catalogs(self, catalogs=None, exclude_catalogs=None, exclude_null=True): + catalogs_filter = Q() + if exclude_null: + catalogs_filter &= Q(catalog__isnull=False) + if catalogs: + catalogs_filter &= Q(catalog__in=catalogs) + if exclude_catalogs: + catalogs_filter &= ~Q(catalog__in=exclude_catalogs) + return self.filter(catalogs_filter) + class MembershipQuerySet(models.QuerySet): @@ -167,6 +177,10 @@ def filter_user(self, user): def filter_visibility(self, user): return self.get_queryset().filter_visibility(user) + def filter_catalogs(self, catalogs=None, exclude_catalogs=None, exclude_null=True): + return self.get_queryset().filter_catalogs(catalogs=catalogs, exclude_catalogs=exclude_catalogs, + exclude_null=exclude_null) + class MembershipManager(CurrentSiteManagerMixin, models.Manager): diff --git a/rdmo/projects/serializers/v1/__init__.py b/rdmo/projects/serializers/v1/__init__.py index 8e61748193..3a441b089d 100644 --- a/rdmo/projects/serializers/v1/__init__.py +++ b/rdmo/projects/serializers/v1/__init__.py @@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import serializers +from rest_framework.exceptions import ValidationError from rdmo.questions.models import Catalog from rdmo.services.validators import ProviderValidator @@ -93,6 +94,12 @@ class Meta: ProjectParentValidator() ] + def validate_views(self, value): + """Block updates to views if syncing is enabled.""" + if settings.PROJECT_VIEWS_SYNC and value: + raise ValidationError(_('Updating views is not allowed.')) + return value + class ProjectCopySerializer(ProjectSerializer): diff --git a/rdmo/projects/templates/projects/project_detail_issues.html b/rdmo/projects/templates/projects/project_detail_issues.html index 0e51a4257f..9bf77532e2 100644 --- a/rdmo/projects/templates/projects/project_detail_issues.html +++ b/rdmo/projects/templates/projects/project_detail_issues.html @@ -21,7 +21,7 @@

{% trans 'Tasks' %}

{% trans 'Time frame' %} {% trans 'Status' %} - {% if can_change_project %} + {% if can_change_project and not settings.PROJECT_TASKS_SYNC %} @@ -67,7 +67,7 @@

{% trans 'Tasks' %}

{% else %} - {% if can_change_project %} + {% if can_change_project and not settings.PROJECT_TASKS_SYNC %}

diff --git a/rdmo/projects/templates/projects/project_detail_views.html b/rdmo/projects/templates/projects/project_detail_views.html index ae3ce20fa7..09f9915cf5 100644 --- a/rdmo/projects/templates/projects/project_detail_views.html +++ b/rdmo/projects/templates/projects/project_detail_views.html @@ -19,7 +19,7 @@

{% trans 'Views' %}

{% trans 'View' %} {% trans 'Description' %} - {% if can_change_project %} + {% if can_change_project and not settings.PROJECT_VIEWS_SYNC %} @@ -45,7 +45,7 @@

{% trans 'Views' %}

{% else %} - {% if can_change_project %} + {% if can_change_project and not settings.PROJECT_VIEWS_SYNC %}

diff --git a/rdmo/projects/tests/helpers.py b/rdmo/projects/tests/helpers.py new file mode 100644 index 0000000000..999d8a9a92 --- /dev/null +++ b/rdmo/projects/tests/helpers.py @@ -0,0 +1,36 @@ +from collections import defaultdict + +from rdmo.questions.models import Catalog +from rdmo.views.models import View + + +def assert_other_projects_unchanged(other_projects, initial_tasks_state): + for other_project in other_projects: + assert set(other_project.tasks.values_list('id', flat=True)) == set(initial_tasks_state[other_project.id]) + + + +def get_catalog_view_mapping(): + """ + Generate a mapping of catalogs to their associated views. + Includes all catalogs, even those with no views, and adds `sites` and `groups` for each view. + """ + # Initialize an empty dictionary for the catalog-to-views mapping + catalog_views_mapping = defaultdict(list) + + # Populate the mapping for all catalogs + for catalog in Catalog.objects.all(): + catalog_views_mapping[catalog.id] = [] + + # Iterate through all views and enrich the mapping + for view in View.objects.prefetch_related('sites', 'groups'): + if view.catalogs.exists(): # Only include views with valid catalogs + for catalog in view.catalogs.all(): + catalog_views_mapping[catalog.id].append({ + 'id': view.id, + 'sites': list(view.sites.values_list('id', flat=True)), + 'groups': list(view.groups.values_list('id', flat=True)) + }) + + # Convert defaultdict to a regular dictionary + return dict(catalog_views_mapping) diff --git a/rdmo/projects/tests/test_handlers.py b/rdmo/projects/tests/test_handlers.py deleted file mode 100644 index 83a9c3c3c8..0000000000 --- a/rdmo/projects/tests/test_handlers.py +++ /dev/null @@ -1,61 +0,0 @@ -import itertools - -import pytest - -from django.contrib.auth.models import Group -from django.contrib.sites.models import Site - -from rdmo.projects.models import Project -from rdmo.questions.models import Catalog -from rdmo.views.models import View - -view_update_tests = [ - # tuples of: view_id, sites, catalogs, groups, project_id, project_exists - ('3', [], [], [], '10', True), - ('3', [2], [], [], '10', False), - ('3', [1, 2, 3], [], [], '10', True), - ('3', [], [2], [], '10', False), - ('3', [2], [2], [], '10', False), - ('3', [1, 2, 3], [2], [], '10', False), - ('3', [], [1, 2], [], '10', True), - ('3', [2], [1, 2], [], '10', False), - ('3', [1, 2, 3], [1, 2], [], '10', True), - - ('3', [], [], [1], '10', False), - ('3', [2], [], [1], '10', False), - ('3', [1, 2, 3], [], [1], '10', False), - ('3', [], [2], [1], '10', False), - ('3', [2], [2], [1], '10', False), - ('3', [1, 2, 3], [2], [1], '10', False), - ('3', [], [1, 2], [1], '10', False), - ('3', [2], [1, 2], [1], '10', False), - ('3', [1, 2, 3], [1, 2], [1], '10', False), - - ('3', [], [], [1, 2, 3, 4], '10', False), - ('3', [2], [], [1, 2, 3, 4], '10', False), - ('3', [1, 2, 3], [], [1, 2, 3, 4], '10', False), - ('3', [], [2], [1, 2, 3, 4], '10', False), - ('3', [2], [2], [1, 2, 3, 4], '10', False), - ('3', [1, 2, 3], [2], [1, 2, 3, 4], '10', False), - ('3', [], [1, 2], [1, 2, 3, 4], '10', False), - ('3', [2], [1, 2], [1, 2, 3, 4], '10', False), - ('3', [1, 2, 3], [1, 2], [1, 2, 3, 4], '10', False) -] - -@pytest.mark.parametrize('view_id,sites,catalogs,groups,project_id,project_exists', view_update_tests) -def test_update_projects(db, view_id, sites, catalogs, groups, project_id, project_exists): - view = View.objects.get(pk=view_id) - - view.sites.set(Site.objects.filter(pk__in=sites)) - view.catalogs.set(Catalog.objects.filter(pk__in=catalogs)) - view.groups.set(Group.objects.filter(pk__in=groups)) - - assert sorted(itertools.chain.from_iterable(view.sites.all().values_list('pk'))) == sites - assert sorted(itertools.chain.from_iterable(view.catalogs.all().values_list('pk'))) == catalogs - assert sorted(itertools.chain.from_iterable(view.groups.all().values_list('pk'))) == groups - - if not project_exists: - with pytest.raises(Project.DoesNotExist): - Project.objects.filter(views=view).get(pk=project_id) - else: - assert Project.objects.filter(views=view).get(pk=project_id) diff --git a/rdmo/projects/tests/test_handlers_m2m_tasks.py b/rdmo/projects/tests/test_handlers_m2m_tasks.py new file mode 100644 index 0000000000..cf710fd09a --- /dev/null +++ b/rdmo/projects/tests/test_handlers_m2m_tasks.py @@ -0,0 +1,151 @@ + +from django.contrib.auth.models import Group + +from rdmo.projects.models import Project +from rdmo.tasks.models import Task + +from .helpers import assert_other_projects_unchanged + +project_id = 10 +task_id = 1 +group_name = 'view_test' + +def test_project_tasks_sync_when_adding_or_removing_a_catalog_to_or_from_a_task(db, settings): + assert settings.PROJECT_TASKS_SYNC + + # Setup: Create a catalog, a task, and a project using the catalog + project = Project.objects.get(id=project_id) + catalog = project.catalog + other_projects = Project.objects.exclude(catalog=catalog) # All other projects + task = Task.objects.get(id=task_id) # This task does not have catalogs in the fixture + task.catalogs.clear() + initial_project_tasks = project.tasks.values_list('id', flat=True) + + # Save initial state of tasks for other projects + initial_other_project_tasks = { + i.id: list(i.tasks.values_list('id', flat=True)) + for i in other_projects + } + + # Ensure the project does not have the task initially + assert task not in project.tasks.all() + + ## Tests for .add and .remove + # Add the catalog to the task and assert that the project now includes the task + task.catalogs.add(catalog) + assert task in project.tasks.all() + assert_other_projects_unchanged(other_projects, initial_other_project_tasks) + + # Remove the catalog from the task and assert that the project no longer includes the task + task.catalogs.remove(catalog) + assert task not in project.tasks.all() + assert_other_projects_unchanged(other_projects, initial_other_project_tasks) + + ## Tests for .set and .clear + # Add the catalog to the task and assert that the project now includes the task + task.catalogs.set([catalog]) + assert task in project.tasks.all() + assert_other_projects_unchanged(other_projects, initial_other_project_tasks) + + # Remove all catalogs from the task and assert that the project no longer includes the task + task.catalogs.clear() + assert task not in project.tasks.all() + assert_other_projects_unchanged(other_projects, initial_other_project_tasks) + + # Assert that the initial project tasks are unchanged + assert set(project.tasks.values_list('id', flat=True)) == set(initial_project_tasks) + + +def test_project_tasks_sync_when_adding_or_removing_a_site_to_or_from_a_task(db, settings): + assert settings.PROJECT_TASKS_SYNC + + # Setup: Get an existing project, its associated site, and create a task + project = Project.objects.get(id=project_id) + site = project.site + other_projects = Project.objects.exclude(site=site) # All other projects + task = Task.objects.get(id=task_id) # This task does not have sites in the fixture + task.sites.clear() # Ensure the task starts without any sites + project.tasks.remove(task) + initial_project_tasks = project.tasks.values_list('id', flat=True) + + # Save initial state of tasks for other projects + initial_other_project_tasks = { + i.id: list(i.tasks.values_list('id', flat=True)) + for i in other_projects + } + + # Ensure the project does not have the task initially + assert task not in project.tasks.all() + + ## Tests for .add and .remove + # Add the site to the task and assert that the project now includes the task + task.sites.add(site) + assert task in project.tasks.all() + assert_other_projects_unchanged(other_projects, initial_other_project_tasks) + + # Remove the site from the task and assert that the project no longer includes the task + task.sites.remove(site) + assert task not in project.tasks.all() + assert_other_projects_unchanged(other_projects, initial_other_project_tasks) + + ## Tests for .set and .clear + # Add the site to the task and assert that the project now includes the task + task.sites.set([site]) + assert task in project.tasks.all() + assert_other_projects_unchanged(other_projects, initial_other_project_tasks) + + # Clear all sites from the task and assert that the project no longer includes the task + task.sites.clear() + assert task not in project.tasks.all() + assert_other_projects_unchanged(other_projects, initial_other_project_tasks) + + # Assert that the initial project tasks are unchanged + assert set(project.tasks.values_list('id', flat=True)) == set(initial_project_tasks) + + +def test_project_tasks_sync_when_adding_or_removing_a_group_to_or_from_a_task(db, settings): + assert settings.PROJECT_TASKS_SYNC + + # Setup: Get an existing project, its associated group, and create a task + project = Project.objects.get(id=project_id) + user = project.owners.first() # Get the first user associated with the project + group = Group.objects.filter(name=group_name).first() # Get a test group + user.groups.add(group) + other_projects = Project.objects.exclude(memberships__user=user) # All other projects + task = Task.objects.get(id=task_id) # This task does not have groups in the fixture + task.groups.clear() # Ensure the task starts without any groups + initial_project_tasks = project.tasks.values_list('id', flat=True) + + # Save initial state of tasks for other projects + initial_other_project_tasks = { + i.id: list(i.tasks.values_list('id', flat=True)) + for i in other_projects + } + + # Ensure the project does not have the task initially + assert task not in project.tasks.all() + + ## Tests for .add and .remove + # Add the group to the task and assert that the project now includes the task + task.groups.add(group) + assert task in project.tasks.all() + assert_other_projects_unchanged(other_projects, initial_other_project_tasks) + + # Remove the group from the task and assert that the project no longer includes the task + task.groups.remove(group) + assert task not in project.tasks.all() + assert_other_projects_unchanged(other_projects, initial_other_project_tasks) + + ## Tests for .set and .clear + # Add the group to the task and assert that the project now includes the task + task.groups.set([group]) + assert task in project.tasks.all() + assert_other_projects_unchanged(other_projects, initial_other_project_tasks) + + # Clear all groups from the task and assert that the project no longer includes the task + task.groups.clear() + assert task not in project.tasks.all() + assert_other_projects_unchanged(other_projects, initial_other_project_tasks) + + # Assert that the initial project tasks are unchanged + assert set(project.tasks.values_list('id', flat=True)) == set(initial_project_tasks) diff --git a/rdmo/projects/tests/test_handlers_m2m_views.py b/rdmo/projects/tests/test_handlers_m2m_views.py new file mode 100644 index 0000000000..d88c6ca20c --- /dev/null +++ b/rdmo/projects/tests/test_handlers_m2m_views.py @@ -0,0 +1,116 @@ + +from django.contrib.auth.models import Group + +from rdmo.projects.models import Project +from rdmo.views.models import View + +project_id = 10 +view_id = 3 +group_name = 'view_test' + +def test_project_views_sync_when_adding_or_removing_a_catalog_to_or_from_a_view(db, settings): + assert settings.PROJECT_VIEWS_SYNC + + # Setup: Create a catalog, a view, and a project using the catalog + project = Project.objects.get(id=project_id) + catalog = project.catalog + view = View.objects.get(id=view_id) # this view does not have catalogs in fixture + view.catalogs.clear() + initial_project_views = project.views.values_list('id', flat=True) + + # # Initially, the project should not have the view + assert view not in project.views.all() + + ## Tests for .add and .remove + # Add the catalog to the view and assert that the project now includes the view + view.catalogs.add(catalog) + assert view in project.views.all() + + # Remove the catalog from the view and assert that the project should no longer include the view + view.catalogs.remove(catalog) + assert view not in project.views.all() + + ## Tests for .set and .clear + # Add the catalog to the view and assert that the project now includes the view + view.catalogs.set([catalog]) + assert view in project.views.all() + + # Remove the catalog from the view and assert that the project should no longer include the view + view.catalogs.clear() + assert view not in project.views.all() + + # assert that the initial project views are unchanged + assert set(project.views.values_list('id', flat=True)) == set(initial_project_views) + + +def test_project_views_sync_when_adding_or_removing_a_site_to_or_from_a_view(db, settings): + assert settings.PROJECT_VIEWS_SYNC + + # Setup: Get an existing project and its associated site and create a view + project = Project.objects.get(id=project_id) + site = project.site + view = View.objects.get(id=view_id) # This view does not have sites in the fixture + view.sites.clear() # Ensure the view starts without any sites + initial_project_views = project.views.values_list('id', flat=True) + + # Ensure initial state: The project should not have the view + assert view not in project.views.all() + + ## Tests for .add and .remove + # Add the site to the view and assert that the project now includes the view + view.sites.add(site) + assert view in project.views.all() + + # Remove the site from the view and assert that the project should no longer include the view + view.sites.remove(site) + assert view not in project.views.all() + + ## Tests for .set and .clear + # Add the site to the view and assert that the project now includes the view + view.sites.set([site]) + assert view in project.views.all() + + # Clear all sites from the view and assert that the project should no longer include the view + view.sites.clear() + assert view not in project.views.all() + + # Assert that the initial project views are unchanged + assert set(project.views.values_list('id', flat=True)) == set(initial_project_views) + + +def test_project_views_sync_when_adding_or_removing_a_group_to_or_from_a_view(db, settings): + assert settings.PROJECT_VIEWS_SYNC + + # Setup: Get an existing project, its associated group, and create a view + project = Project.objects.get(id=project_id) + # breakpoint() + user = project.owners.first() # Get the first user associated with the project + group = Group.objects.filter(name=group_name).first() # Get the first group the user belongs to + user.groups.add(group) + view = View.objects.get(id=view_id) # This view does not have groups in the fixture + view.groups.clear() # Ensure the view starts without any groups + initial_project_views = project.views.values_list('id', flat=True) + + # Ensure initial state: The project should not have the view + assert view not in project.views.all() + + ## Tests for .add and .remove + # Add the group to the view and assert that the project now includes the view + view.groups.add(group) + assert view in project.views.all() + + # Remove the group from the view and assert that the project should no longer include the view + view.groups.remove(group) + assert view not in project.views.all() + + ## Tests for .set and .clear + # Add the group to the view and assert that the project now includes the view + view.groups.set([group]) + assert view in project.views.all() + + # Clear all groups from the view and assert that the project should no longer include the view + view.groups.clear() + assert view not in project.views.all() + + # Assert that the initial project views are unchanged + assert set(project.views.values_list('id', flat=True)) == set(initial_project_views) diff --git a/rdmo/projects/tests/test_handlers_project_save.py b/rdmo/projects/tests/test_handlers_project_save.py new file mode 100644 index 0000000000..1a0fb5661c --- /dev/null +++ b/rdmo/projects/tests/test_handlers_project_save.py @@ -0,0 +1,30 @@ +from rdmo.projects.models import Project +from rdmo.questions.models import Catalog +from rdmo.views.models import View + +from .helpers import get_catalog_view_mapping + +project_id = 10 + + +def test_project_views_sync_when_changing_the_catalog_on_a_project(db, settings): + assert settings.PROJECT_VIEWS_SYNC + + # Setup: Create a catalog, a view, and a project using the catalog + project = Project.objects.get(id=project_id) + initial_project_views = set(project.views.values_list('id', flat=True)) + assert initial_project_views == {1,2,3} # from the fixture + + catalog_view_mapping = get_catalog_view_mapping() + for catalog_id, view_ids in catalog_view_mapping.items(): + if project.catalog_id == catalog_id: + continue # catalog will not change + project.catalog = Catalog.objects.get(id=catalog_id) + project.save() + + # TODO this filter_available_views_for_project method needs to tested explicitly + available_views = set(View.objects + .filter_available_views_for_project(project) + .values_list('id', flat=True) + ) + assert set(project.views.values_list('id', flat=True)) == available_views diff --git a/rdmo/projects/tests/test_viewset_project.py b/rdmo/projects/tests/test_viewset_project.py index f45351743b..0b51f5972b 100644 --- a/rdmo/projects/tests/test_viewset_project.py +++ b/rdmo/projects/tests/test_viewset_project.py @@ -429,6 +429,20 @@ def test_update_parent(db, client, username, password, project_id): assert Project.objects.get(pk=project_id).parent == project.parent +def test_update_project_views_not_allowed(db, client, settings): + assert settings.PROJECT_VIEWS_SYNC + client.login(username='owner', password='owner') + + url = reverse(urlnames['detail'], args=[project_id]) + data = { + 'views': [1] + } + response = client.put(url, data, content_type='application/json') + + assert response.status_code == 400 + assert 'Updating views is not allowed' in ' '.join(response.json()['views']) + + @pytest.mark.parametrize('username,password', users) @pytest.mark.parametrize('project_id', projects) def test_delete(db, client, username, password, project_id): diff --git a/rdmo/projects/views/project.py b/rdmo/projects/views/project.py index 9760fbd3b6..6eade947ac 100644 --- a/rdmo/projects/views/project.py +++ b/rdmo/projects/views/project.py @@ -64,11 +64,20 @@ def get_context_data(self, **kwargs): context['catalogs'] = Catalog.objects.filter_current_site() \ .filter_group(self.request.user) \ .filter_availability(self.request.user) - context['tasks_available'] = Task.objects.filter_current_site() \ + + if settings.PROJECT_TASKS_SYNC: + # tasks should be synced, the user can not change them + context['tasks_available'] = project.tasks.exists() + else: + context['tasks_available'] = Task.objects.filter_current_site() \ .filter_catalog(self.object.catalog) \ .filter_group(self.request.user) \ .filter_availability(self.request.user).exists() - context['views_available'] = View.objects.filter_current_site() \ + if settings.PROJECT_VIEWS_SYNC: + # views should be synced, the user can not change them + context['views_available'] = project.views.exists() + else: + context['views_available'] = View.objects.filter_current_site() \ .filter_catalog(self.object.catalog) \ .filter_group(self.request.user) \ .filter_availability(self.request.user).exists() diff --git a/rdmo/tasks/managers.py b/rdmo/tasks/managers.py index 0251eab9f0..a8e3c4f696 100644 --- a/rdmo/tasks/managers.py +++ b/rdmo/tasks/managers.py @@ -15,6 +15,16 @@ class TaskQuestionSet(CurrentSiteQuerySetMixin, GroupsQuerySetMixin, Availabilit def filter_catalog(self, catalog): return self.filter(models.Q(catalogs=None) | models.Q(catalogs=catalog)) + def filter_available_views_for_project(self, project): + return (self + .filter(sites=project.site) + .filter(catalogs=project.catalog) + .filter_group(project.owners) + .filter(available=True) + .exclude(catalogs=None) + ) + + class TaskManager(CurrentSiteManagerMixin, GroupsManagerMixin, AvailabilityManagerMixin, models.Manager): @@ -23,3 +33,6 @@ def get_queryset(self): def filter_catalog(self, catalog): return self.get_queryset().filter_catalog(catalog) + + def filter_available_tasks_for_project(self, project): + return self.get_queryset().filter_available_views_for_project(project) diff --git a/rdmo/views/managers.py b/rdmo/views/managers.py index 4c584e4839..e656ef9bf0 100644 --- a/rdmo/views/managers.py +++ b/rdmo/views/managers.py @@ -15,6 +15,15 @@ class ViewQuestionSet(CurrentSiteQuerySetMixin, GroupsQuerySetMixin, Availabilit def filter_catalog(self, catalog): return self.filter(models.Q(catalogs=None) | models.Q(catalogs=catalog)) + def filter_available_views_for_project(self, project): + return (self + .filter(sites=project.site) + .filter(catalogs=project.catalog) + .filter_group(project.owners) + .filter(available=True) + .exclude(catalogs=None) + ) + class ViewManager(CurrentSiteManagerMixin, GroupsManagerMixin, AvailabilityManagerMixin, models.Manager): @@ -23,3 +32,6 @@ def get_queryset(self): def filter_catalog(self, catalog): return self.get_queryset().filter_catalog(catalog) + + def filter_available_views_for_project(self, project): + return self.get_queryset().filter_available_views_for_project(project) diff --git a/testing/config/settings/base.py b/testing/config/settings/base.py index 3517e314a4..4133ff8ec4 100644 --- a/testing/config/settings/base.py +++ b/testing/config/settings/base.py @@ -69,7 +69,8 @@ PROJECT_SEND_INVITE = True -PROJECT_REMOVE_VIEWS = True +PROJECT_VIEWS_SYNC = True +PROJECT_TASKS_SYNC = True PROJECT_SNAPSHOT_EXPORTS = [ ('xml', _('RDMO XML'), 'rdmo.projects.exports.RDMOXMLExport'),