diff --git a/rdmo/core/managers.py b/rdmo/core/managers.py index e1f68ebf70..c660592231 100644 --- a/rdmo/core/managers.py +++ b/rdmo/core/managers.py @@ -7,7 +7,10 @@ class CurrentSiteQuerySetMixin: def filter_current_site(self): - return self.filter(models.Q(sites=None) | models.Q(sites=settings.SITE_ID)) + if settings.MULTISITE: + return self.filter(sites=settings.SITE_ID) + else: + return self.filter(models.Q(sites=None) | models.Q(sites=settings.SITE_ID)) class GroupsQuerySetMixin: @@ -20,10 +23,7 @@ def filter_group(self, user): class AvailabilityQuerySetMixin: def filter_availability(self, user): - model = str(self.model._meta) - permissions = PERMISSIONS[model] - - if user.has_perms(permissions): + if user.has_perms(PERMISSIONS[self.model._meta.label_lower]): return self else: return self.filter(available=True) diff --git a/rdmo/management/managers.py b/rdmo/management/managers.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/rdmo/projects/handlers/sync_utils.py b/rdmo/projects/handlers/sync_utils.py index 4cb261921a..8eace26145 100644 --- a/rdmo/projects/handlers/sync_utils.py +++ b/rdmo/projects/handlers/sync_utils.py @@ -4,6 +4,7 @@ from django.db.models import ForeignKey, ManyToManyField, Model from rdmo.projects.models import Project +from rdmo.projects.utils import filter_tasks_or_views_for_project from rdmo.tasks.models import Task from rdmo.views.models import View @@ -46,11 +47,11 @@ def sync_task_or_view_to_projects(instance): instance.projects.add(*to_add) -def sync_tasks_or_views_on_a_project(project, model): +def sync_tasks_or_views_on_a_project(project, task_or_view): """Ensure the project is linked to exactly the correct instances of a model (View/Task).""" - project_m2m_field = get_related_field_name_on_model_for_instance(Project, model) + project_m2m_field = get_related_field_name_on_model_for_instance(Project, task_or_view) - desired_instances = model.objects.filter_for_project(project) + desired_instances = filter_tasks_or_views_for_project(task_or_view, project) current_instances = getattr(project, project_m2m_field).all() to_remove = current_instances.exclude(pk__in=desired_instances) diff --git a/rdmo/projects/imports.py b/rdmo/projects/imports.py index 7e29c4ba8f..bfb40937cc 100644 --- a/rdmo/projects/imports.py +++ b/rdmo/projects/imports.py @@ -106,10 +106,7 @@ def process(self): if self.current_project is None: catalog_uri = get_uri(self.root.find('catalog'), self.ns_map) - available_catalogs = Catalog.objects.filter_current_site() \ - .filter_group(self.request.user) \ - .filter_availability(self.request.user) \ - .order_by('order') + available_catalogs = Catalog.objects.filter_for_user(self.request.user) try: self.catalog = available_catalogs.get(uri=catalog_uri) diff --git a/rdmo/projects/managers.py b/rdmo/projects/managers.py index 161773357b..7925349935 100644 --- a/rdmo/projects/managers.py +++ b/rdmo/projects/managers.py @@ -72,22 +72,25 @@ def filter_groups(self, groups): return self.filter(memberships__in=memberships).distinct() def filter_projects_for_task_or_view(self, instance): - # if View/Task is not available it should not show for any project - if not instance.available: - return self.none() - # projects that have an unavailable catalog should be disregarded qs = self.filter(catalog__available=True) - # when instance.catalogs is empty it applies to all + # when View/Task is not available it should not show for any project + if not instance.available: + return self.none() + + # when View/Task has any catalogs it can be filtered for those if instance.catalogs.exists(): qs = qs.filter(catalog__in=instance.catalogs.all()) - # when instance.sites is empty it applies to all + # when View/Task has any sites it can be filtered for those if instance.sites.exists(): qs = qs.filter(site__in=instance.sites.all()) + elif settings.MULTISITE: + # when View/Task has no sites in a multi-site, it should not appear at all + return self.none() - # when instance.groups is empty it applies to all + # when has any groups it can be filtered for those if instance.groups.exists(): qs = qs.filter_groups(instance.groups.all()) diff --git a/rdmo/projects/serializers/v1/__init__.py b/rdmo/projects/serializers/v1/__init__.py index 4b48ba0736..790fc18994 100644 --- a/rdmo/projects/serializers/v1/__init__.py +++ b/rdmo/projects/serializers/v1/__init__.py @@ -53,10 +53,7 @@ class ProjectSerializer(serializers.ModelSerializer): class CatalogField(serializers.PrimaryKeyRelatedField): def get_queryset(self): - return Catalog.objects.filter_current_site() \ - .filter_group(self.context['request'].user) \ - .filter_availability(self.context['request'].user) \ - .order_by('-available', 'order') + return Catalog.objects.filter_for_user(self.context['request'].user) class ParentField(serializers.PrimaryKeyRelatedField): diff --git a/rdmo/projects/tests/conftest.py b/rdmo/projects/tests/conftest.py index 1c99a862de..c392ba554a 100644 --- a/rdmo/projects/tests/conftest.py +++ b/rdmo/projects/tests/conftest.py @@ -2,6 +2,13 @@ from django.apps import apps +from .helpers.project_catalog import clear_sites_from_other_catalogs # noqa: F401 + + +@pytest.fixture +def enable_multisite(settings): + assert not settings.MULTISITE # assert that the default is False first + settings.MULTISITE = True @pytest.fixture def enable_project_views_sync(settings): diff --git a/rdmo/projects/tests/helpers/project_catalog.py b/rdmo/projects/tests/helpers/project_catalog.py new file mode 100644 index 0000000000..41da594aa1 --- /dev/null +++ b/rdmo/projects/tests/helpers/project_catalog.py @@ -0,0 +1,15 @@ +import pytest + +from rdmo.questions.models import Catalog + + +@pytest.fixture +def clear_sites_from_other_catalogs(settings): + # arrange, remove sites from the other catalogs + # for 'list': 'v1-projects:catalog-list' + # in non-multisite, they should appear + # however, in a multisite they should not appear + other_catalogs = Catalog.objects.exclude(sites=settings.SITE_ID) + assert set(other_catalogs.values_list('id',flat=True)) == {3,4} + for catalog in other_catalogs: + catalog.sites.clear() diff --git a/rdmo/projects/tests/helpers/project_sync/arrange_project_tasks.py b/rdmo/projects/tests/helpers/project_sync/arrange_project_tasks.py index 90d5a4cc55..7f8bf1eb01 100644 --- a/rdmo/projects/tests/helpers/project_sync/arrange_project_tasks.py +++ b/rdmo/projects/tests/helpers/project_sync/arrange_project_tasks.py @@ -1,3 +1,6 @@ +from contextlib import contextmanager +from unittest.mock import patch + from django.contrib.auth.models import Group, User from django.contrib.sites.models import Site @@ -9,127 +12,148 @@ T_uri = "http://example.com/test/tasks/sync{}" +@contextmanager +def _suppress_project_task_sync(): + """Silence automatic project/task sync during test setup.""" + with ( + patch('rdmo.projects.handlers.sync_utils.sync_task_or_view_to_projects'), + patch('rdmo.projects.handlers.task_changed.sync_task_or_view_to_projects') + ): + yield + def arrange_projects_catalogs_and_tasks(): - # Arrange: the project, catalog and task objects - # all catalogs and tasks should have available=True - C = {n: Catalog.objects.get(id=n) for n in one_two_three} # C1, C2, C3 - T = {n: Task.objects.get(id=n) for n in one_two_three} # T1, T2, T3 - P = { # P1, P2, P3 - n: Project.objects.create( - title=P_TITLE.format(n), - catalog=C[n], - site=Site.objects.get(id=1) - ) - for n in one_two_three - } - - # Arrange the catalogs - for catalog in C.values(): - catalog.available = True - catalog.save() - catalog.sites.clear() - catalog.groups.clear() - - # Set a certain initial state for the project.tasks - # this is a sort of random state - P[1].tasks.set([T[1], T[2], T[3]]) - P[2].tasks.clear() - P[3].tasks.set([T[3]]) - - # Clear everything to reset state for the tasks.catalogs - # which will also affect the project.tasks - for n, task in T.items(): - task.available = True - task.uri = T_uri.format(n) - task.save() - task.sites.clear() - task.groups.clear() - task.catalogs.set([C[n]]) + with _suppress_project_task_sync(): + # Arrange: the project, catalog and task objects + # all catalogs and tasks should have available=True + C = {n: Catalog.objects.get(id=n) for n in one_two_three} # C1, C2, C3 + T = {n: Task.objects.get(id=n) for n in one_two_three} # T1, T2, T3 + P = { # P1, P2, P3 + n: Project.objects.create( + title=P_TITLE.format(n), + catalog=C[n], + site=Site.objects.get(id=1) + ) + for n in one_two_three + } + + # Arrange the catalogs + for catalog in C.values(): + catalog.available = True + catalog.save() + catalog.sites.clear() + catalog.groups.clear() + + # Set a certain initial state for the project.tasks + # this is a sort of random state + P[1].tasks.set([T[1], T[2], T[3]]) + P[2].tasks.clear() + P[3].tasks.set([T[3]]) + + # Clear everything to reset state for the tasks.catalogs + # which will also affect the project.tasks + for n, task in T.items(): + task.available = True + task.uri = T_uri.format(n) + task.save() + task.sites.clear() + task.groups.clear() + task.catalogs.set([C[n]]) + + for n in one_two_three: + P[n].tasks.set([T[n]]) return P, C, T def arrange_projects_sites_and_tasks(): - # Arrange: the project, catalog and task objects - # all catalogs and tasks should have available=True - # P1, P2, P3 - S = {n: Site.objects.get(id=n) for n in one_two_three} # S1, S2, S3 - C = {n: Catalog.objects.get(id=n) for n in one_two_three} # C1, C2, C3 - T = {n: Task.objects.get(id=n) for n in one_two_three} # T1, T2, T3 - P = { - n: Project.objects.create( - title=P_TITLE.format(n), - catalog=C[n], - site=S[n], - ) - for n in one_two_three - } - - # Arrange the catalogs - for catalog in C.values(): - catalog.available = True - catalog.save() - catalog.sites.clear() - catalog.groups.clear() - - # Set a certain initial state for the project.tasks - # this is a sort of random state - P[1].tasks.set([T[1], T[2], T[3]]) - P[2].tasks.clear() - P[3].tasks.set([T[3]]) - - # Clear everything to reset state for the tasks.catalogs - # which will also affect the project.tasks - for n, task in T.items(): - task.available = True - task.save() - task.catalogs.clear() - task.groups.clear() - task.sites.set([S[n]]) + with _suppress_project_task_sync(): + # Arrange: the project, catalog and task objects + # all catalogs and tasks should have available=True + # P1, P2, P3 + S = {n: Site.objects.get(id=n) for n in one_two_three} # S1, S2, S3 + C = {n: Catalog.objects.get(id=n) for n in one_two_three} # C1, C2, C3 + T = {n: Task.objects.get(id=n) for n in one_two_three} # T1, T2, T3 + P = { + n: Project.objects.create( + title=P_TITLE.format(n), + catalog=C[n], + site=S[n], + ) + for n in one_two_three + } + + # Arrange the catalogs + for catalog in C.values(): + catalog.available = True + catalog.save() + catalog.sites.clear() + catalog.groups.clear() + + # Set a certain initial state for the project.tasks + # this is a sort of random state + P[1].tasks.set([T[1], T[2], T[3]]) + P[2].tasks.clear() + P[3].tasks.set([T[3]]) + + # Clear everything to reset state for the tasks.catalogs + # which will also affect the project.tasks + for n, task in T.items(): + task.available = True + task.save() + task.catalogs.clear() + task.groups.clear() + task.sites.set([S[n]]) + + for n in one_two_three: + P[n].tasks.set([T[n]]) return P, T, S def arrange_projects_groups_and_tasks(): - # Arrange: the project, catalog and task objects - # all catalogs and tasks should have available=True - S = {n: Site.objects.get(id=1) for n in one_two_three} # S1, S2, S3 - C = {n: Catalog.objects.get(id=1) for n in one_two_three} # C1, C2, C3 - T = {n: Task.objects.get(id=n) for n in one_two_three} # T1, T2, T3 - P = { # P1, P2, P3 - n: Project.objects.create( - title=P_TITLE.format(n), - catalog=C[n], - site=S[n], - ) - for n in one_two_three - } - # Create groups, users and project memberships - G = {n: Group.objects.create(name=f"Sync G{n}") for n in one_two_three} - for n in one_two_three: - _user = User.objects.create(username=f"Sync U{n}") - _user.groups.set([G[n]]) - # this sets P[1].groups -> U[n].groups - Membership.objects.create(user_id=_user.id, project_id=P[n].id, role='owner') - - # Arrange the catalogs - for catalog in C.values(): - catalog.available = True - catalog.save() - catalog.sites.clear() - catalog.groups.clear() - - # Set a certain initial state for the project.tasks - # this is a sort of random state - P[1].tasks.set([T[1], T[2], T[3]]) - P[2].tasks.clear() - P[3].tasks.set([T[3]]) - - # Clear everything to reset state for the tasks.groups - # -> this will also affect the project.tasks - for n, task in T.items(): - task.available = True - task.save() - task.catalogs.clear() - task.sites.clear() - task.groups.set([G[n]]) # set groups as last so that will be state + with _suppress_project_task_sync(): + # Arrange: the project, catalog and task objects + # all catalogs and tasks should have available=True + S = {n: Site.objects.get(id=1) for n in one_two_three} # S1, S2, S3 + C = {n: Catalog.objects.get(id=1) for n in one_two_three} # C1, C2, C3 + T = {n: Task.objects.get(id=n) for n in one_two_three} # T1, T2, T3 + P = { # P1, P2, P3 + n: Project.objects.create( + title=P_TITLE.format(n), + catalog=C[n], + site=S[n], + ) + for n in one_two_three + } + # Create groups, users and project memberships + G = {n: Group.objects.create(name=f"Sync G{n}") for n in one_two_three} + for n in one_two_three: + _user = User.objects.create(username=f"Sync U{n}") + _user.groups.set([G[n]]) + # this sets P[1].groups -> U[n].groups + Membership.objects.create(user_id=_user.id, project_id=P[n].id, role='owner') + + # Arrange the catalogs + for catalog in C.values(): + catalog.available = True + catalog.save() + catalog.sites.clear() + catalog.groups.clear() + + # Set a certain initial state for the project.tasks + # this is a sort of random state + P[1].tasks.set([T[1], T[2], T[3]]) + P[2].tasks.clear() + P[3].tasks.set([T[3]]) + + # Clear everything to reset state for the tasks.groups + # -> this will also affect the project.tasks + for n, task in T.items(): + task.available = True + task.save() + task.catalogs.clear() + task.sites.clear() + task.groups.set([G[n]]) # set groups as last so that will be state + + for n in one_two_three: + P[n].tasks.set([T[n]]) return P, T , G diff --git a/rdmo/projects/tests/helpers/project_sync/arrange_project_views.py b/rdmo/projects/tests/helpers/project_sync/arrange_project_views.py index 290e4503cc..43ecc6c2b4 100644 --- a/rdmo/projects/tests/helpers/project_sync/arrange_project_views.py +++ b/rdmo/projects/tests/helpers/project_sync/arrange_project_views.py @@ -1,3 +1,6 @@ +from contextlib import contextmanager +from unittest.mock import patch + from django.contrib.auth.models import Group, User from django.contrib.sites.models import Site @@ -7,129 +10,149 @@ from rdmo.views.models import View -def arrange_projects_catalogs_and_views(): - - # Arrange: the project, catalog and view objects - # all catalogs and views should have available=True - C = {n: Catalog.objects.get(id=n) for n in one_two_three} # C1, C2, C3 - V = {n: View.objects.get(id=n) for n in one_two_three} # V1, V2, V3 - P = { # P1, P2, P3 - n: Project.objects.create( - title=P_TITLE.format(n), - catalog=C[n], - site=Site.objects.get(id=1) - ) - for n in one_two_three - } - - # Arrange the catalogs - for catalog in C.values(): - catalog.available = True - catalog.save() - catalog.sites.clear() - catalog.groups.clear() - - # Set a certain initial state for the project.views - # this is a sort of random state - P[1].views.set([V[1],V[2],V[3]]) - P[2].views.clear() - P[3].views.set([V[3]]) - - # Clear everything to reset state for the views.catalogs - # which will also affect the project.views - for n, view in V.items(): - view.available = True - view.save() - view.sites.clear() - view.groups.clear() - view.catalogs.set([C[n]]) +@contextmanager +def _suppress_project_view_sync(): + """Silence automatic project/view sync during test setup.""" + with ( + patch('rdmo.projects.handlers.sync_utils.sync_task_or_view_to_projects'), + patch('rdmo.projects.handlers.view_changed.sync_task_or_view_to_projects') + ): + yield +def arrange_projects_catalogs_and_views(): + with _suppress_project_view_sync(): + # Arrange: the project, catalog and view objects + # all catalogs and views should have available=True + C = {n: Catalog.objects.get(id=n) for n in one_two_three} # C1, C2, C3 + V = {n: View.objects.get(id=n) for n in one_two_three} # V1, V2, V3 + P = { # P1, P2, P3 + n: Project.objects.create( + title=P_TITLE.format(n), + catalog=C[n], + site=Site.objects.get(id=1) + ) + for n in one_two_three + } + + # Arrange the catalogs + for catalog in C.values(): + catalog.available = True + catalog.save() + catalog.sites.clear() + catalog.groups.clear() + + # Set a certain initial state for the project.views + # this is a sort of random state + P[1].views.set([V[1],V[2],V[3]]) + P[2].views.clear() + P[3].views.set([V[3]]) + + # Clear everything to reset state for the views.catalogs + # which will also affect the project.views + for n, view in V.items(): + view.available = True + view.save() + view.sites.clear() + view.groups.clear() + view.catalogs.set([C[n]]) + + # Ensure each project starts with its matching view only + for n in one_two_three: + P[n].views.set([V[n]]) return P, C, V def arrange_projects_sites_and_views(): - # Arrange: the project, catalog and view objects - # all catalogs and views should have available=True - # P1, P2, P3 - S = {n: Site.objects.get(id=n) for n in one_two_three} # S1, S2, S3 - C = {n: Catalog.objects.get(id=n) for n in one_two_three} # C1, C2, C3 - V = {n: View.objects.get(id=n) for n in one_two_three} # V1, V2, V3 - P = { - n: Project.objects.create( - title=P_TITLE.format(n), - catalog=C[n], - site=S[n], - ) - for n in one_two_three - } - - # Arrange the catalogs - for catalog in C.values(): - catalog.available = True - catalog.save() - catalog.sites.clear() - catalog.groups.clear() - - # Set a certain initial state for the project.views - # this is a sort of random state - P[1].views.set([V[1], V[2], V[3]]) - P[2].views.clear() - P[3].views.set([V[3]]) - - # Clear everything to reset state for the views.catalogs - # which will also affect the project.views - for n, view in V.items(): - view.available = True - view.save() - view.catalogs.clear() - view.groups.clear() - view.sites.set([S[n]]) + with _suppress_project_view_sync(): + # Arrange: the project, catalog and view objects + # all catalogs and views should have available=True + # P1, P2, P3 + S = {n: Site.objects.get(id=n) for n in one_two_three} # S1, S2, S3 + C = {n: Catalog.objects.get(id=n) for n in one_two_three} # C1, C2, C3 + V = {n: View.objects.get(id=n) for n in one_two_three} # V1, V2, V3 + P = { + n: Project.objects.create( + title=P_TITLE.format(n), + catalog=C[n], + site=S[n], + ) + for n in one_two_three + } + + # Arrange the catalogs + for catalog in C.values(): + catalog.available = True + catalog.save() + catalog.sites.clear() + catalog.groups.clear() + + # Set a certain initial state for the project.views + # this is a sort of random state + P[1].views.set([V[1], V[2], V[3]]) + P[2].views.clear() + P[3].views.set([V[3]]) + + # Clear everything to reset state for the views.catalogs + # which will also affect the project.views + for n, view in V.items(): + view.available = True + view.save() + view.catalogs.clear() + view.groups.clear() + view.sites.set([S[n]]) + + for n in one_two_three: + P[n].views.set([V[n]]) return P, V, S def arrange_projects_groups_and_views(): - # Arrange: the project, catalog and view objects - # all catalogs and views should have available=True - S = {n: Site.objects.get(id=1) for n in one_two_three} # S1, S2, S3 - C = {n: Catalog.objects.get(id=1) for n in one_two_three} # C1, C2, C3 - V = {n: View.objects.get(id=n) for n in one_two_three} # V1, V2, V3 - P = { # P1, P2, P3 - n: Project.objects.create( - title=P_TITLE.format(n), - catalog=C[n], - site=S[n], - ) - for n in one_two_three - } - # Create groups, users and project memberships - G = {n: Group.objects.create(name=f"Sync G{n}") for n in one_two_three} - for n in one_two_three: - _user = User.objects.create(username=f"Sync U{n}") - _user.groups.set([G[n]]) - # this sets P[1].groups -> U[n].groups - Membership.objects.create(user_id=_user.id, project_id=P[n].id, role='owner') - - # Arrange the catalogs - for catalog in C.values(): - catalog.available = True - catalog.save() - catalog.sites.clear() - catalog.groups.clear() - - # Set a certain initial state for the project.views - # this is a sort of random state - P[1].views.set([V[1], V[2], V[3]]) - P[2].views.clear() - P[3].views.set([V[3]]) - - # Clear everything to reset state for the views.groups - # -> this will also affect the project.views - for n, view in V.items(): - view.available = True - view.save() - view.catalogs.clear() - view.sites.clear() - view.groups.set([G[n]]) # set groups as last so that will be state + with _suppress_project_view_sync(): + # Arrange: the project, catalog and view objects + # all catalogs and views should have available=True + S = {n: Site.objects.get(id=1) for n in one_two_three} # S1, S2, S3 + C = {n: Catalog.objects.get(id=1) for n in one_two_three} # C1, C2, C3 + V = {n: View.objects.get(id=n) for n in one_two_three} # V1, V2, V3 + P = { # P1, P2, P3 + n: Project.objects.create( + title=P_TITLE.format(n), + catalog=C[n], + site=S[n], + ) + for n in one_two_three + } + # Create groups, users and project memberships + G = {n: Group.objects.create(name=f"Sync G{n}") for n in one_two_three} + for n in one_two_three: + _user = User.objects.create(username=f"Sync U{n}") + _user.groups.set([G[n]]) + # this sets P[1].groups -> U[n].groups + Membership.objects.create(user_id=_user.id, project_id=P[n].id, role='owner') + + # Arrange the catalogs + for catalog in C.values(): + catalog.available = True + catalog.save() + catalog.sites.clear() + catalog.groups.clear() + + # Set a certain initial state for the project.views + # this is a sort of random state + P[1].views.set([V[1], V[2], V[3]]) + P[2].views.clear() + P[3].views.set([V[3]]) + + # Clear everything to reset state for the views.groups + # -> this will also affect the project.views + for n, view in V.items(): + view.available = True + view.save() + view.catalogs.clear() + view.sites.clear() + view.groups.set([G[n]]) # set groups as last so that will be state + + for n in one_two_three: + P[n].views.set([V[n]]) return P, V , G diff --git a/rdmo/projects/tests/helpers/project_sync/assert_project_views_or_tasks.py b/rdmo/projects/tests/helpers/project_sync/assert_project_views_or_tasks.py index f65058cca8..ba592196a4 100644 --- a/rdmo/projects/tests/helpers/project_sync/assert_project_views_or_tasks.py +++ b/rdmo/projects/tests/helpers/project_sync/assert_project_views_or_tasks.py @@ -1,5 +1,7 @@ import logging +from django.conf import settings + from rdmo.projects.handlers.sync_utils import get_related_field_name_on_model_for_instance from rdmo.projects.models import Project from rdmo.tasks.models import Task @@ -16,7 +18,6 @@ def assert_other_projects_unchanged(other_projects, initial_tasks_state): def assert_all_projects_are_synced_with_instance_m2m_field(instance: Task | View, field: str) -> None: # View/Task .catalogs, .sites or .groups instance_field = getattr(instance, field) - instance_ids = set(instance_field.values_list('id', flat=True)) # Project .catalog, .site or .groups instance_project_field = get_related_field_name_on_model_for_instance(Project, instance_field.model) # Project tasks or views @@ -25,27 +26,32 @@ def assert_all_projects_are_synced_with_instance_m2m_field(instance: Task | View for project in Project.objects.all(): # Project tasks or views project_instances = getattr(project, m2m_field).all() + project_has_instance = instance in project_instances - if not instance_ids: - if instance not in project_instances: - logger.debug( - '%s missing in %s when no %s are set [%s]', - instance, - instance_project_field, - project, - m2m_field - ) - assert instance in project_instances, f"{instance} missing in {project} with matching {instance_project_field}" # noqa: E501 - return - - if instance_project_field in ['site', 'catalog']: - project_has_instance = getattr(project, instance_project_field).id in instance_ids - elif instance_project_field in ['groups']: - project_groups_ids = {i.id for i in getattr(project, instance_project_field)} - project_has_instance = bool((project_groups_ids <= instance_ids) and project_groups_ids) + # (e.g. Task/View has no catalogs / no sites / no groups) + if not instance_field.exists(): + if instance_project_field == 'site' and settings.MULTISITE: + # MULTISITE=True and no sites on the instance: + project_should_have_instance = False + else: + # For catalogs and groups, and for sites when MULTISITE=False: + # empty field means "applies to all projects" + project_should_have_instance = True + else: + if instance_project_field == 'catalog': + project_should_have_instance = bool(project.catalog in instance_field.all()) + elif instance_project_field == 'site': + project_should_have_instance = bool(project.site in instance_field.all()) + elif instance_project_field == 'groups': + instance_ids = set(instance_field.values_list('id', flat=True)) + project_groups_ids = {group.id for group in getattr(project, instance_project_field)} + # project must have at least one group and all must be within instance_ids + project_should_have_instance = bool(project_groups_ids and project_groups_ids <= instance_ids) + else: + raise ValueError("Project field not recognized, should be 'site', 'catalog' or 'groups'") - if project_has_instance: - if instance not in project_instances: + if project_should_have_instance: + if not project_has_instance: logger.debug( '%s missing in %s with %s match [%s]', instance, @@ -53,9 +59,11 @@ def assert_all_projects_are_synced_with_instance_m2m_field(instance: Task | View project, m2m_field ) - assert instance in project_instances, f"{instance} missing in {project} with matching {instance_project_field}" # noqa: E501 + assert project_has_instance, ( + f"{instance} missing in {project} with matching {instance_project_field}" + ) else: - if instance in project_instances: + if project_has_instance: logger.debug( '%s wrongly assigned to %s with %s mismatch [%s]', instance, @@ -63,4 +71,6 @@ def assert_all_projects_are_synced_with_instance_m2m_field(instance: Task | View project, m2m_field ) - assert instance not in project_instances, f"{instance} wrongly assigned to {project} with mismatched {instance_project_field}" # noqa: E501 + assert not project_has_instance, ( + f"{instance} wrongly assigned to {project} with mismatched {instance_project_field} [{m2m_field}] " + ) diff --git a/rdmo/projects/tests/test_handlers_m2m_tasks_catalogs.py b/rdmo/projects/tests/test_handlers_m2m_tasks_catalogs.py index 238677a4d7..52a7d94c13 100644 --- a/rdmo/projects/tests/test_handlers_m2m_tasks_catalogs.py +++ b/rdmo/projects/tests/test_handlers_m2m_tasks_catalogs.py @@ -28,10 +28,8 @@ def test_project_tasks_sync_when_updating_task_catalogs(settings, enable_project assert Project.objects.filter(tasks=T[1]).count() == Project.objects.all().count() assert_all_projects_are_synced_with_instance_m2m_field(T[1],'catalogs') - # === Update: (from empty) add C1 to T1 → it should appear in P1 only again === T[1].catalogs.add(C[1]) # T1 → [C1] - assert set(P[1].tasks.all()) == {T[1]} assert set(P[2].tasks.all()) == {T[2]} assert set(P[3].tasks.all()) == {T[3]} @@ -49,7 +47,7 @@ def test_project_tasks_sync_when_updating_task_catalogs(settings, enable_project assert set(P[1].tasks.all()) == set() # removed assert set(P[2].tasks.all()) == {T[2], T[1]} # stays assert set(P[3].tasks.all()) == {T[3]} - assert_all_projects_are_synced_with_instance_m2m_field(T[2],'catalogs') + assert_all_projects_are_synced_with_instance_m2m_field(T[1],'catalogs') # === Update: remove C2 and add C3 to T1 → it should appear in P3 === T[1].catalogs.remove(C[2]) # T1 → [] diff --git a/rdmo/projects/tests/test_handlers_m2m_tasks_groups.py b/rdmo/projects/tests/test_handlers_m2m_tasks_groups.py index 38bc323095..ab67c71339 100644 --- a/rdmo/projects/tests/test_handlers_m2m_tasks_groups.py +++ b/rdmo/projects/tests/test_handlers_m2m_tasks_groups.py @@ -48,7 +48,7 @@ def test_project_tasks_sync_when_updating_task_groups(settings, enable_project_t assert set(P[1].tasks.all()) == set() # removed assert set(P[2].tasks.all()) == {T[2], T[1]} # stays assert set(P[3].tasks.all()) == {T[3]} - assert_all_projects_are_synced_with_instance_m2m_field(T[2], 'groups') + assert_all_projects_are_synced_with_instance_m2m_field(T[1], 'groups') # === Update: remove C2 and add C3 to V1 → it should appear in P1 and P3 === T[1].groups.remove(G[2]) # V1 → [] diff --git a/rdmo/projects/tests/test_handlers_m2m_tasks_sites.py b/rdmo/projects/tests/test_handlers_m2m_tasks_sites.py index 4b26331aef..fbeca8a44b 100644 --- a/rdmo/projects/tests/test_handlers_m2m_tasks_sites.py +++ b/rdmo/projects/tests/test_handlers_m2m_tasks_sites.py @@ -47,7 +47,7 @@ def test_project_tasks_sync_when_updating_task_sites(settings, enable_project_ta assert set(P[1].tasks.all()) == set() # removed assert set(P[2].tasks.all()) == {T[2], T[1]} # stays assert set(P[3].tasks.all()) == {T[3]} - assert_all_projects_are_synced_with_instance_m2m_field(T[2], 'sites') + assert_all_projects_are_synced_with_instance_m2m_field(T[1], 'sites') # === Update: remove C2 and add C3 to V1 → it should appear in P1 and P3 === T[1].sites.remove(S[2]) # V1 → [] diff --git a/rdmo/projects/tests/test_handlers_m2m_tasks_sites_multisite.py b/rdmo/projects/tests/test_handlers_m2m_tasks_sites_multisite.py new file mode 100644 index 0000000000..e35536e0ea --- /dev/null +++ b/rdmo/projects/tests/test_handlers_m2m_tasks_sites_multisite.py @@ -0,0 +1,64 @@ +import pytest + +from rdmo.projects.models import Project +from rdmo.projects.tests.helpers.project_sync.arrange_project_tasks import arrange_projects_sites_and_tasks +from rdmo.projects.tests.helpers.project_sync.assert_project_views_or_tasks import ( + assert_all_projects_are_synced_with_instance_m2m_field, +) + +pytestmark = pytest.mark.usefixtures("enable_multisite") + + +@pytest.mark.django_db +def test_project_tasks_sync_when_updating_task_sites_multisite(settings, enable_project_tasks_sync): + assert settings.PROJECT_TASKS_SYNC + assert settings.MULTISITE # just a double-check + + P, T, S = arrange_projects_sites_and_tasks() + # === Initial state === + # P1 (with S1) has T1, etc... + assert set(P[1].tasks.all()) == {T[1]} + assert set(P[2].tasks.all()) == {T[2]} + assert set(P[3].tasks.all()) == {T[3]} + + # === Update: T1 has no sites → with MULTISITE=True it should appear in NO projects === + T[1].sites.clear() + + assert set(P[1].tasks.all()) == set() + assert set(P[2].tasks.all()) == {T[2]} + assert set(P[3].tasks.all()) == {T[3]} + assert Project.objects.filter(tasks=T[1]).count() == 0 + + # === Update: (from empty) add S1 to T1 → it should appear in P1 only again === + T[1].sites.add(S[1]) # T1 → [S1] + + assert set(P[1].tasks.all()) == {T[1]} + assert set(P[2].tasks.all()) == {T[2]} + assert set(P[3].tasks.all()) == {T[3]} + # helper works fine again as soon as instance.sites is non-empty + assert_all_projects_are_synced_with_instance_m2m_field(T[1], 'sites') + + # === Update: set [S1, S2] to T1 → it should appear in P1, P2 === + T[1].sites.set([S[1], S[2]]) # T1 → [S1, S2] + + assert set(P[1].tasks.all()) == {T[1]} # should remain unchanged + assert set(P[2].tasks.all()) == {T[2], T[1]} + assert set(P[3].tasks.all()) == {T[3]} + assert_all_projects_are_synced_with_instance_m2m_field(T[1], 'sites') + + # === Update: set T1 to [S2] → should be removed from P1 === + T[1].sites.set([S[2]]) + + assert set(P[1].tasks.all()) == set() # removed + assert set(P[2].tasks.all()) == {T[2], T[1]} # stays + assert set(P[3].tasks.all()) == {T[3]} + assert_all_projects_are_synced_with_instance_m2m_field(T[1], 'sites') + + # === Update: remove S2 and add S3 to T1 → it should appear in P3 only === + T[1].sites.remove(S[2]) # T1 → [] + T[1].sites.add(S[3]) # T1 → [S3] + + assert set(P[1].tasks.all()) == set() + assert set(P[2].tasks.all()) == {T[2]} + assert set(P[3].tasks.all()) == {T[3], T[1]} # got T1 + assert_all_projects_are_synced_with_instance_m2m_field(T[3], 'sites') diff --git a/rdmo/projects/tests/test_handlers_m2m_views_catalogs.py b/rdmo/projects/tests/test_handlers_m2m_views_catalogs.py index 5af864d52a..029d3b1518 100644 --- a/rdmo/projects/tests/test_handlers_m2m_views_catalogs.py +++ b/rdmo/projects/tests/test_handlers_m2m_views_catalogs.py @@ -47,7 +47,7 @@ def test_project_views_sync_when_updating_catalogs_on_a_view(settings, enable_pr assert set(P[1].views.all()) == set() # removed assert set(P[2].views.all()) == {V[2], V[1]} # stays assert set(P[3].views.all()) == {V[3]} - assert_all_projects_are_synced_with_instance_m2m_field(V[2], 'catalogs') + assert_all_projects_are_synced_with_instance_m2m_field(V[1], 'catalogs') # === Update: remove C2 and add C3 to V1 → it should appear in P1 and P3 === V[1].catalogs.remove(C[2]) # V1 → [] @@ -56,4 +56,4 @@ def test_project_views_sync_when_updating_catalogs_on_a_view(settings, enable_pr assert set(P[1].views.all()) == set() assert set(P[2].views.all()) == {V[2]} assert set(P[3].views.all()) == {V[3], V[1]} # got V1 - assert_all_projects_are_synced_with_instance_m2m_field(V[3], 'catalogs') + assert_all_projects_are_synced_with_instance_m2m_field(V[1], 'catalogs') diff --git a/rdmo/projects/tests/test_handlers_m2m_views_groups.py b/rdmo/projects/tests/test_handlers_m2m_views_groups.py index 7449c6004f..e2f8c65eee 100644 --- a/rdmo/projects/tests/test_handlers_m2m_views_groups.py +++ b/rdmo/projects/tests/test_handlers_m2m_views_groups.py @@ -48,7 +48,7 @@ def test_project_views_sync_when_updating_view_groups(settings, enable_project_v assert set(P[1].views.all()) == set() # removed assert set(P[2].views.all()) == {V[2], V[1]} # stays assert set(P[3].views.all()) == {V[3]} - assert_all_projects_are_synced_with_instance_m2m_field(V[2], 'groups') + assert_all_projects_are_synced_with_instance_m2m_field(V[1], 'groups') # === Update: remove C2 and add C3 to V1 → it should appear in P1 and P3 === V[1].groups.remove(G[2]) # V1 → [] @@ -57,4 +57,4 @@ def test_project_views_sync_when_updating_view_groups(settings, enable_project_v assert set(P[1].views.all()) == set() assert set(P[2].views.all()) == {V[2]} assert set(P[3].views.all()) == {V[3], V[1]} # got V1 - assert_all_projects_are_synced_with_instance_m2m_field(V[3], 'groups') + assert_all_projects_are_synced_with_instance_m2m_field(V[1], 'groups') diff --git a/rdmo/projects/tests/test_handlers_m2m_views_sites.py b/rdmo/projects/tests/test_handlers_m2m_views_sites.py index e1573354e2..7c01b99102 100644 --- a/rdmo/projects/tests/test_handlers_m2m_views_sites.py +++ b/rdmo/projects/tests/test_handlers_m2m_views_sites.py @@ -47,7 +47,7 @@ def test_project_views_sync_when_updating_view_sites(settings, enable_project_vi assert set(P[1].views.all()) == set() # removed assert set(P[2].views.all()) == {V[2], V[1]} # stays assert set(P[3].views.all()) == {V[3]} - assert_all_projects_are_synced_with_instance_m2m_field(V[2], 'sites') + assert_all_projects_are_synced_with_instance_m2m_field(V[1], 'sites') # === Update: remove C2 and add C3 to V1 → it should appear in P1 and P3 === V[1].sites.remove(S[2]) # V1 → [] @@ -56,4 +56,4 @@ def test_project_views_sync_when_updating_view_sites(settings, enable_project_vi assert set(P[1].views.all()) == set() assert set(P[2].views.all()) == {V[2]} assert set(P[3].views.all()) == {V[3], V[1]} # got V1 - assert_all_projects_are_synced_with_instance_m2m_field(V[3], 'sites') + assert_all_projects_are_synced_with_instance_m2m_field(V[1], 'sites') diff --git a/rdmo/projects/tests/test_handlers_m2m_views_sites_multisite.py b/rdmo/projects/tests/test_handlers_m2m_views_sites_multisite.py new file mode 100644 index 0000000000..2f9c5121c6 --- /dev/null +++ b/rdmo/projects/tests/test_handlers_m2m_views_sites_multisite.py @@ -0,0 +1,63 @@ +import pytest + +from rdmo.projects.models import Project +from rdmo.projects.tests.helpers.project_sync.arrange_project_views import arrange_projects_sites_and_views +from rdmo.projects.tests.helpers.project_sync.assert_project_views_or_tasks import ( + assert_all_projects_are_synced_with_instance_m2m_field, +) + +pytestmark = pytest.mark.usefixtures("enable_multisite") + + +@pytest.mark.django_db +def test_project_views_sync_when_updating_view_sites_multisite(settings, enable_project_views_sync): + assert settings.PROJECT_VIEWS_SYNC + assert settings.MULTISITE is True + + P, V, S = arrange_projects_sites_and_views() + # === Initial state === + # P1 (with S1) has V1, etc... + assert set(P[1].views.all()) == {V[1]} + assert set(P[2].views.all()) == {V[2]} + assert set(P[3].views.all()) == {V[3]} + + # === Update: V1 has no sites → with MULTISITE=True it should appear in NO projects === + V[1].sites.clear() + + assert set(P[1].views.all()) == set() + assert set(P[2].views.all()) == {V[2]} + assert set(P[3].views.all()) == {V[3]} + assert Project.objects.filter(views=V[1]).count() == 0 + + # === Update: (from empty) add S1 to V1 → it should appear in P1 only again === + V[1].sites.add(S[1]) # V1 → [S1] + + assert set(P[1].views.all()) == {V[1]} + assert set(P[2].views.all()) == {V[2]} + assert set(P[3].views.all()) == {V[3]} + assert_all_projects_are_synced_with_instance_m2m_field(V[1], 'sites') + + # === Update: set [S1, S2] to V1 → it should appear in P1, P2 === + V[1].sites.set([S[1], S[2]]) # V1 → [S1, S2] + + assert set(P[1].views.all()) == {V[1]} # should remain unchanged + assert set(P[2].views.all()) == {V[2], V[1]} + assert set(P[3].views.all()) == {V[3]} + assert_all_projects_are_synced_with_instance_m2m_field(V[1], 'sites') + + # === Update: set V1 to [S2] → should be removed from P1 === + V[1].sites.set([S[2]]) + + assert set(P[1].views.all()) == set() # removed + assert set(P[2].views.all()) == {V[2], V[1]} # stays + assert set(P[3].views.all()) == {V[3]} + assert_all_projects_are_synced_with_instance_m2m_field(V[1], 'sites') + + # === Update: remove S2 and add S3 to V1 → it should appear in P3 only === + V[1].sites.remove(S[2]) # V1 → [] + V[1].sites.add(S[3]) # V1 → [S3] + + assert set(P[1].views.all()) == set() + assert set(P[2].views.all()) == {V[2]} + assert set(P[3].views.all()) == {V[3], V[1]} # got V1 + assert_all_projects_are_synced_with_instance_m2m_field(V[1], 'sites') diff --git a/rdmo/projects/tests/test_handlers_view_changes_availability.py b/rdmo/projects/tests/test_handlers_view_changes_availability.py index 009c68f042..5fa0a3603b 100644 --- a/rdmo/projects/tests/test_handlers_view_changes_availability.py +++ b/rdmo/projects/tests/test_handlers_view_changes_availability.py @@ -28,11 +28,10 @@ def test_project_views_sync_when_updating_available_on_a_view(settings, enable_p assert set(P[1].views.all()) == set() # should stay empty assert set(P[2].views.all()) == {V[2]} assert set(P[3].views.all()) == {V[3]} - # === Act: make V1 available → it should appear in projects P1,P2 === V[1].available = True V[1].save(update_fields=['available']) assert set(P[1].views.all()) == {V[1]} assert set(P[2].views.all()) == {V[2], V[1]} - assert set(P[3].views.all()) == {V[3]} + assert set(P[3].views.all()) == {V[3]} # in non-multisite it can be added diff --git a/rdmo/projects/tests/test_view_membership_multisite.py b/rdmo/projects/tests/test_view_membership_multisite.py index 585b1096ac..04ea1eed77 100644 --- a/rdmo/projects/tests/test_view_membership_multisite.py +++ b/rdmo/projects/tests/test_view_membership_multisite.py @@ -31,17 +31,12 @@ sites_domains = ('example.com', 'foo.com', 'bar.com') - -@pytest.fixture -def _multisite(settings): - settings.MULTISITE = True - +pytestmark = pytest.mark.usefixtures("enable_multisite") @pytest.mark.parametrize('username,password', users) @pytest.mark.parametrize('project_id', projects) @pytest.mark.parametrize('membership_role', membership_roles) @pytest.mark.parametrize('site_domain', sites_domains) -@pytest.mark.usefixtures("_multisite") def test_get_invite_email_project_path_function(db, client, username, password, project_id, membership_role, site_domain): client.login(username=username, password=password) @@ -70,7 +65,6 @@ def test_get_invite_email_project_path_function(db, client, username, password, @pytest.mark.parametrize('project_id', projects) @pytest.mark.parametrize('membership_role', membership_roles) @pytest.mark.parametrize('site_domain', sites_domains) -@pytest.mark.usefixtures("_multisite") def test_invite_email_project_path_email_body(db, client, username, password, project_id, membership_role, site_domain): client.login(username=username, password=password) diff --git a/rdmo/projects/tests/test_viewset_catalog.py b/rdmo/projects/tests/test_viewset_catalog.py index fc355e0ed2..a5824b00af 100644 --- a/rdmo/projects/tests/test_viewset_catalog.py +++ b/rdmo/projects/tests/test_viewset_catalog.py @@ -1,10 +1,7 @@ import pytest -from django.contrib.sites.models import Site from django.urls import reverse -from rdmo.questions.models import Catalog - users = ( ('owner', 'owner'), ('manager', 'manager'), @@ -16,13 +13,23 @@ ('anonymous', None), ) +view_project_catalog_permission_map = { # id, available + 'owner': [(1, True)], + 'manager': [(1, True)], + 'author': [(1, True)], + 'guest': [(1, True)], + 'user': [(1, True)], + 'editor': [(1, True)], + 'reviewer': [(1, True)], + 'api': [(1, True),(2, False)], + 'site': [(1, True)] +} + urlnames = { 'list': 'v1-projects:catalog-list', - 'user': 'v1-projects:catalog-user' } -catalog_id = 1 - +other_sites_catalogs = [(3, True), (4, True)] @pytest.mark.parametrize('username,password', users) def test_list(db, client, username, password): @@ -33,13 +40,30 @@ def test_list(db, client, username, password): if password: assert response.status_code == 200 - assert isinstance(response.json(), list) - data = response.json() - site = Site.objects.get_current() - catalogs = Catalog.objects.filter(sites=site) + assert isinstance(data, list) + assert view_project_catalog_permission_map[username] == [(i['id'],i['available']) for i in data] + else: + assert response.status_code == 401 - assert {c['id'] for c in data} == {c.id for c in catalogs} - assert {c['available'] for c in data} == {c.available for c in catalogs} + +@pytest.mark.parametrize('username,password', users) +def test_list_with_cleared_sites(db, client, clear_sites_from_other_catalogs, username, password): + client.login(username=username, password=password) + + url = reverse(urlnames['list']) + response = client.get(url) + + if password: + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + catalogs = view_project_catalog_permission_map[username] + other_sites_catalogs + if any(not available for _id,available in catalogs): # api sees an available=False catalog + catalogs = sorted( + catalogs, + key=lambda i: (not i[1], i[0]), + ) + assert catalogs == [(i['id'],i['available']) for i in data] else: assert response.status_code == 401 diff --git a/rdmo/projects/tests/test_viewset_catalog_multisite.py b/rdmo/projects/tests/test_viewset_catalog_multisite.py new file mode 100644 index 0000000000..0092f166d3 --- /dev/null +++ b/rdmo/projects/tests/test_viewset_catalog_multisite.py @@ -0,0 +1,72 @@ +import pytest + +from django.urls import reverse + +users = ( + ('owner', 'owner'), + ('manager', 'manager'), + ('author', 'author'), + ('guest', 'guest'), + ('api', 'api'), + ('user', 'user'), + ('site', 'site'), + ('anonymous', None), + ('foo-user','foo-user'), + ('foo-editor', 'foo-editor'), + ('bar-user','bar-user'), + ('bar-editor', 'bar-editor'), +) + +view_project_catalog_permission_map = { # id, available + 'owner': [(1, True)], + 'manager': [(1, True)], + 'author': [(1, True)], + 'guest': [(1, True)], + 'user': [(1, True)], + 'editor': [(1, True)], + 'reviewer': [(1, True)], + 'api': [(1, True),(2, False)], + 'site': [(1, True)], + 'foo-user': [(1, True)], + 'foo-editor': [(1, True)], + 'bar-user': [(1, True)], + 'bar-editor': [(1, True)], +} + +urlnames = { + 'list': 'v1-projects:catalog-list', +} + +pytestmark = pytest.mark.usefixtures("enable_multisite") + +@pytest.mark.parametrize('username,password', users) +def test_list(db, settings, client, username, password): + client.login(username=username, password=password) + + url = reverse(urlnames['list']) + response = client.get(url) + + if password: + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert view_project_catalog_permission_map[username] == [(i['id'],i['available']) for i in data] + else: + assert response.status_code == 401 + + +@pytest.mark.parametrize('username,password', users) +def test_list_with_cleared_sites(db, settings, clear_sites_from_other_catalogs, + client, username, password): + client.login(username=username, password=password) + + url = reverse(urlnames['list']) + response = client.get(url) + + if password: + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert view_project_catalog_permission_map[username] == [(i['id'],i['available']) for i in data] + else: + assert response.status_code == 401 diff --git a/rdmo/projects/utils.py b/rdmo/projects/utils.py index 884c71d5ca..4bd983f0f1 100644 --- a/rdmo/projects/utils.py +++ b/rdmo/projects/utils.py @@ -6,6 +6,7 @@ from django.conf import settings from django.contrib.sites.models import Site from django.core.exceptions import ObjectDoesNotExist +from django.db.models import Q from django.template.loader import render_to_string from django.urls import reverse from django.utils.timezone import now @@ -13,6 +14,8 @@ from rdmo.core.mail import send_mail from rdmo.core.plugins import get_plugins from rdmo.core.utils import remove_double_newlines +from rdmo.tasks.managers import TaskQuerySet +from rdmo.views.managers import ViewQuerySet logger = logging.getLogger(__name__) @@ -368,3 +371,15 @@ def send_contact_message(request, subject, message): send_mail(subject, message, to=settings.PROJECT_CONTACT_RECIPIENTS, cc=[request.user.email], reply_to=[request.user.email]) + + +def filter_tasks_or_views_for_project(task_or_view, project) -> TaskQuerySet | ViewQuerySet: + queryset = ( task_or_view.objects + .filter(Q(catalogs=None) | Q(catalogs=project.catalog)) + .filter(Q(groups=None) | Q(groups__in=project.groups)) + ) + + if settings.MULTISITE: + return queryset.filter(sites=project.site) + else: + return queryset.filter(Q(sites=None) | Q(sites=project.site)) diff --git a/rdmo/projects/views/project.py b/rdmo/projects/views/project.py index 4b48e5ecac..04600c6c16 100644 --- a/rdmo/projects/views/project.py +++ b/rdmo/projects/views/project.py @@ -16,11 +16,11 @@ from rdmo.core.plugins import get_plugin, get_plugins from rdmo.core.views import CSRFViewMixin, ObjectPermissionMixin, RedirectViewMixin, StoreIdViewMixin from rdmo.questions.models import Catalog -from rdmo.tasks.models import Task -from rdmo.views.models import View +from ...tasks.models import Task +from ...views.models import View from ..models import Integration, Invite, Membership, Project -from ..utils import get_upload_accept +from ..utils import filter_tasks_or_views_for_project, get_upload_accept logger = logging.getLogger(__name__) @@ -60,19 +60,14 @@ def get_context_data(self, **kwargs): memberships = memberships.prefetch_related('user__socialaccount_set') integrations = Integration.objects.filter(project__in=ancestors) - context['catalogs'] = Catalog.objects.filter_current_site() \ - .filter_group(self.request.user) \ - .filter_availability(self.request.user) + context['catalogs'] = Catalog.objects.filter_for_user(self.request.user) 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_for_project(project) - .filter_availability(self.request.user) - .exists() + filter_tasks_or_views_for_project(Task, project).filter_availability(self.request.user).exists() ) if settings.PROJECT_VIEWS_SYNC: @@ -80,10 +75,7 @@ def get_context_data(self, **kwargs): context['views_available'] = project.views.exists() else: context['views_available'] = ( - View.objects - .filter_for_project(project) - .filter_availability(self.request.user) - .exists() + filter_tasks_or_views_for_project(View, project).filter_availability(self.request.user).exists() ) ancestors_import = [] diff --git a/rdmo/projects/views/project_copy.py b/rdmo/projects/views/project_copy.py index 6d9df32c3e..c162a48a9a 100644 --- a/rdmo/projects/views/project_copy.py +++ b/rdmo/projects/views/project_copy.py @@ -21,10 +21,7 @@ class ProjectCopyView(ObjectPermissionMixin, RedirectViewMixin, UpdateView): permission_required = ('projects.add_project', 'projects.view_project_object') def get_form_kwargs(self): - catalogs = Catalog.objects.filter_current_site() \ - .filter_group(self.request.user) \ - .filter_availability(self.request.user) \ - .order_by('-available', 'order') + catalogs = Catalog.objects.filter_for_user(self.request.user) projects = Project.objects.filter_user(self.request.user) form_kwargs = super().get_form_kwargs() diff --git a/rdmo/projects/views/project_create.py b/rdmo/projects/views/project_create.py index fb74dcdd16..2e78b0d608 100644 --- a/rdmo/projects/views/project_create.py +++ b/rdmo/projects/views/project_create.py @@ -14,6 +14,7 @@ from ..forms import ProjectForm from ..mixins import ProjectImportMixin from ..models import Membership, Project +from ..utils import filter_tasks_or_views_for_project logger = logging.getLogger(__name__) @@ -25,10 +26,7 @@ class ProjectCreateView(ObjectPermissionMixin, LoginRequiredMixin, permission_required = 'projects.add_project' def get_form_kwargs(self): - catalogs = Catalog.objects.filter_current_site() \ - .filter_group(self.request.user) \ - .filter_availability(self.request.user) \ - .order_by('-available', 'order') + catalogs = Catalog.objects.filter_for_user(self.request.user) projects = Project.objects.filter_user(self.request.user) form_kwargs = super().get_form_kwargs() @@ -51,13 +49,13 @@ def form_valid(self, form): # add all tasks to project if not settings.PROJECT_TASKS_SYNC: - tasks = Task.objects.filter_for_project(form.instance).filter_availability(self.request.user) + tasks = filter_tasks_or_views_for_project(Task, form.instance).filter_availability(self.request.user) for task in tasks: form.instance.tasks.add(task) # add all views to project if not settings.PROJECT_VIEWS_SYNC: - views = View.objects.filter_for_project(form.instance).filter_availability(self.request.user) + views = filter_tasks_or_views_for_project(View, form.instance).filter_availability(self.request.user) for view in views: form.instance.views.add(view) diff --git a/rdmo/projects/views/project_update.py b/rdmo/projects/views/project_update.py index a0bd369e39..805444e1c3 100644 --- a/rdmo/projects/views/project_update.py +++ b/rdmo/projects/views/project_update.py @@ -22,6 +22,7 @@ ) from ..mixins import ProjectImportMixin from ..models import Project, Visibility +from ..utils import filter_tasks_or_views_for_project logger = logging.getLogger(__name__) @@ -33,10 +34,7 @@ class ProjectUpdateView(ObjectPermissionMixin, RedirectViewMixin, UpdateView): permission_required = 'projects.change_project_object' def get_form_kwargs(self): - catalogs = Catalog.objects.filter_current_site() \ - .filter_group(self.request.user) \ - .filter_availability(self.request.user) \ - .order_by('order') + catalogs = Catalog.objects.filter_for_user(self.request.user) projects = Project.objects.filter_user(self.request.user) form_kwargs = super().get_form_kwargs() @@ -106,10 +104,7 @@ class ProjectUpdateCatalogView(ObjectPermissionMixin, RedirectViewMixin, UpdateV permission_required = 'projects.change_project_object' def get_form_kwargs(self): - catalogs = Catalog.objects.filter_current_site() \ - .filter_group(self.request.user) \ - .filter_availability(self.request.user) \ - .order_by('order') + catalogs = Catalog.objects.filter_for_user(self.request.user) form_kwargs = super().get_form_kwargs() form_kwargs.update({ @@ -130,10 +125,9 @@ def dispatch(self, request, *args, **kwargs): return super().dispatch(request, *args, **kwargs) def get_form_kwargs(self): - tasks = Task.objects.filter_for_project(self.object).filter_availability(self.request.user) form_kwargs = super().get_form_kwargs() form_kwargs.update({ - 'tasks': tasks + 'tasks': filter_tasks_or_views_for_project(Task, self.object).filter_availability(self.request.user) }) return form_kwargs @@ -153,10 +147,9 @@ def dispatch(self, request, *args, **kwargs): return super().dispatch(request, *args, **kwargs) def get_form_kwargs(self): - views = View.objects.filter_for_project(self.object).filter_availability(self.request.user) form_kwargs = super().get_form_kwargs() form_kwargs.update({ - 'views': views + 'views': filter_tasks_or_views_for_project(View, self.object).filter_availability(self.request.user) }) return form_kwargs diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index 482d10006e..d6df96c770 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -80,6 +80,7 @@ check_options, compute_set_prefix_from_set_value, copy_project, + filter_tasks_or_views_for_project, get_contact_message, get_upload_accept, send_contact_message, @@ -408,15 +409,13 @@ def perform_create(self, serializer): # add all tasks to project if self.request.data.get('tasks') is None: if not settings.PROJECT_TASKS_SYNC: - tasks = Task.objects.filter_for_project(project).filter_availability(self.request.user) - for task in tasks: + for task in filter_tasks_or_views_for_project(Task, project).filter_availability(self.request.user): project.tasks.add(task) if self.request.data.get('views') is None: # add all views to project if not settings.PROJECT_VIEWS_SYNC: - views = View.objects.filter_for_project(project).filter_availability(self.request.user) - for view in views: + for view in filter_tasks_or_views_for_project(View, project).filter_availability(self.request.user): project.views.add(view) @@ -920,6 +919,13 @@ class CatalogViewSet(ListModelMixin, GenericViewSet): serializer_class = CatalogSerializer def get_queryset(self): - return Catalog.objects.filter_current_site() \ - .filter_group(self.request.user) \ - .order_by('-available', 'order') + queryset = ( + Catalog.objects.filter_current_site().filter_group(self.request.user) + ) + availability_subquery = Subquery( + queryset.filter_availability(self.request.user).values('pk') + ) + return ( + queryset.filter(Q(pk__in=availability_subquery) | Q(projects__user=self.request.user)) + .order_by('-available', 'order', 'id').distinct() + ) diff --git a/rdmo/questions/managers.py b/rdmo/questions/managers.py index ccb6592cbb..2f6608da4f 100644 --- a/rdmo/questions/managers.py +++ b/rdmo/questions/managers.py @@ -18,6 +18,14 @@ def filter_catalog(self, catalog): def prefetch_elements(self): return self.prefetch_related(*self.model.prefetch_lookups) + def filter_for_user(self, user): + return ( + self.filter_current_site() + .filter_group(user) + .filter_availability(user) + .order_by('-available', 'order') + ) + class CatalogManager(CurrentSiteManagerMixin, GroupsManagerMixin, AvailabilityManagerMixin, models.Manager): @@ -30,6 +38,9 @@ def filter_catalog(self, catalog): def prefetch_elements(self): return self.get_queryset().prefetch_elements() + def filter_for_user(self, user): + return self.get_queryset().filter_for_user(user) + class SectionQuerySet(models.QuerySet): diff --git a/rdmo/questions/migrations/0098_data_migration_for_multisite.py b/rdmo/questions/migrations/0098_data_migration_for_multisite.py new file mode 100644 index 0000000000..7825b60bd0 --- /dev/null +++ b/rdmo/questions/migrations/0098_data_migration_for_multisite.py @@ -0,0 +1,37 @@ +from django.conf import settings +from django.db import migrations + + +def set_sites_for_catalogs_without_sites(apps, schema_editor): + if not settings.MULTISITE: + return + + Catalog = apps.get_model('questions', 'Catalog') + Site = apps.get_model('sites', 'Site') + + all_sites = Site.objects.all() + if not all_sites.exists(): + return + + catalogs_without_sites = ( + Catalog.objects + .filter(sites__isnull=True) + .distinct() + ) + + for catalog in catalogs_without_sites: + catalog.sites.set(all_sites) + + +class Migration(migrations.Migration): + + dependencies = [ + ('questions', '0097_alter_question_widget_type'), + ] + + operations = [ + migrations.RunPython( + set_sites_for_catalogs_without_sites, + migrations.RunPython.noop, + ), + ] diff --git a/rdmo/tasks/managers.py b/rdmo/tasks/managers.py index c60777decb..7523b4c2d0 100644 --- a/rdmo/tasks/managers.py +++ b/rdmo/tasks/managers.py @@ -1,42 +1,15 @@ -from django.db.models import Manager, Q, QuerySet +from django.db.models import Manager, QuerySet -from rdmo.core.managers import ( - AvailabilityManagerMixin, - AvailabilityQuerySetMixin, - CurrentSiteManagerMixin, - CurrentSiteQuerySetMixin, - GroupsManagerMixin, - GroupsQuerySetMixin, -) +from rdmo.core.managers import AvailabilityQuerySetMixin -class TaskQuerySet(CurrentSiteQuerySetMixin, GroupsQuerySetMixin, AvailabilityQuerySetMixin, QuerySet): +class TaskQuerySet(AvailabilityQuerySetMixin, QuerySet): + pass - def filter_catalog(self, catalog): - return self.filter(Q(catalogs=None) | Q(catalogs=catalog)) - - def filter_for_project_site(self, site): - return self.filter(Q(sites=None) | Q(sites=site)) - - def filter_for_project_group(self, groups): - return self.filter(Q(groups=None) | Q(groups__in=groups)) - - def filter_for_project(self, project): - return ( - self - .filter(available=True) - .filter_for_project_site(project.site) - .filter_catalog(project.catalog) - .filter_for_project_group(project.groups) - ) - -class TaskManager(CurrentSiteManagerMixin, GroupsManagerMixin, AvailabilityManagerMixin, Manager): +class TaskManager(Manager): def get_queryset(self) -> TaskQuerySet: return TaskQuerySet(self.model, using=self._db) - def filter_catalog(self, catalog): - return self.get_queryset().filter_catalog(catalog) - - def filter_for_project(self, project): - return self.get_queryset().filter_for_project(project) + def filter_availability(self, user) -> QuerySet: + return self.get_queryset().filter_availability(user) diff --git a/rdmo/views/managers.py b/rdmo/views/managers.py index b2db2cb1dc..fee43b4500 100644 --- a/rdmo/views/managers.py +++ b/rdmo/views/managers.py @@ -1,42 +1,15 @@ -from django.db.models import Manager, Q, QuerySet +from django.db.models import Manager, QuerySet -from rdmo.core.managers import ( - AvailabilityManagerMixin, - AvailabilityQuerySetMixin, - CurrentSiteManagerMixin, - CurrentSiteQuerySetMixin, - GroupsManagerMixin, - GroupsQuerySetMixin, -) +from rdmo.core.managers import AvailabilityQuerySetMixin -class ViewQuerySet(CurrentSiteQuerySetMixin, GroupsQuerySetMixin, AvailabilityQuerySetMixin, QuerySet): +class ViewQuerySet(AvailabilityQuerySetMixin, QuerySet): + pass - def filter_catalog(self, catalog): - return self.filter(Q(catalogs=None) | Q(catalogs=catalog)) - - def filter_for_project_site(self, site): - return self.filter(Q(sites=None) | Q(sites=site)) - - def filter_for_project_group(self, groups): - return self.filter(Q(groups=None) | Q(groups__in=groups)) - - def filter_for_project(self, project): - return ( - self - .filter(available=True) - .filter_for_project_site(project.site) - .filter_catalog(project.catalog) - .filter_for_project_group(project.groups) - ) - -class ViewManager(CurrentSiteManagerMixin, GroupsManagerMixin, AvailabilityManagerMixin, Manager): +class ViewManager(Manager): def get_queryset(self) -> ViewQuerySet: return ViewQuerySet(self.model, using=self._db) - def filter_catalog(self, catalog): - return self.get_queryset().filter_catalog(catalog) - - def filter_for_project(self, project): - return self.get_queryset().filter_for_project(project) + def filter_availability(self, user) -> QuerySet: + return self.get_queryset().filter_availability(user)