From 9cdc2a225984a8d47a4d10d9f416bba43977422b Mon Sep 17 00:00:00 2001 From: David Wallace Date: Thu, 20 Nov 2025 21:41:42 +0100 Subject: [PATCH 01/18] Do not filter no sites for multisite #1481 Signed-off-by: David Wallace --- rdmo/core/managers.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rdmo/core/managers.py b/rdmo/core/managers.py index e1f68ebf70..916788986e 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: From c17a1e18186dc34c26f078af4ad8593307508da9 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Thu, 20 Nov 2025 21:43:02 +0100 Subject: [PATCH 02/18] Simplifiy availability filter Signed-off-by: David Wallace --- rdmo/core/managers.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/rdmo/core/managers.py b/rdmo/core/managers.py index 916788986e..c660592231 100644 --- a/rdmo/core/managers.py +++ b/rdmo/core/managers.py @@ -23,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) From 885875639c17b0854236a16ced4ec6d1d5312174 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Thu, 20 Nov 2025 21:48:16 +0100 Subject: [PATCH 03/18] Add catalog filter for user and refactor Signed-off-by: David Wallace --- rdmo/projects/imports.py | 5 +---- rdmo/projects/serializers/v1/__init__.py | 5 +---- rdmo/projects/views/project.py | 4 +--- rdmo/projects/views/project_copy.py | 5 +---- rdmo/projects/views/project_create.py | 5 +---- rdmo/projects/views/project_update.py | 10 ++-------- rdmo/projects/viewsets.py | 4 +--- rdmo/questions/managers.py | 12 ++++++++++++ 8 files changed, 20 insertions(+), 30 deletions(-) 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/serializers/v1/__init__.py b/rdmo/projects/serializers/v1/__init__.py index 4b48ba0736..242c37f30d 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.request.user) class ParentField(serializers.PrimaryKeyRelatedField): diff --git a/rdmo/projects/views/project.py b/rdmo/projects/views/project.py index 4b48e5ecac..ea21680050 100644 --- a/rdmo/projects/views/project.py +++ b/rdmo/projects/views/project.py @@ -60,9 +60,7 @@ 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 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..9e44d02a27 100644 --- a/rdmo/projects/views/project_create.py +++ b/rdmo/projects/views/project_create.py @@ -25,10 +25,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() diff --git a/rdmo/projects/views/project_update.py b/rdmo/projects/views/project_update.py index a0bd369e39..ec99afab00 100644 --- a/rdmo/projects/views/project_update.py +++ b/rdmo/projects/views/project_update.py @@ -33,10 +33,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 +103,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({ diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index 482d10006e..1f81757970 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -920,6 +920,4 @@ 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') + return Catalog.objects.filter_for_user(self.request.user) diff --git a/rdmo/questions/managers.py b/rdmo/questions/managers.py index ccb6592cbb..82216d3933 100644 --- a/rdmo/questions/managers.py +++ b/rdmo/questions/managers.py @@ -18,6 +18,15 @@ 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 +39,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): From 6fbe9de6a8d0fdc5bf2e82e907e8a42c130277b8 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Thu, 20 Nov 2025 22:02:38 +0100 Subject: [PATCH 04/18] Add data migration for catalog.sites in multisite Signed-off-by: David Wallace --- .../0098_data_migration_for_multisite.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 rdmo/questions/migrations/0098_data_migration_for_multisite.py 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..d614079489 --- /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 = list(Site.objects.all()) + if not all_sites: + 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, + ), + ] From 8427adca2cc89a71c7d5e7b41ab25c6fc47a487e Mon Sep 17 00:00:00 2001 From: David Wallace Date: Fri, 21 Nov 2025 14:53:34 +0100 Subject: [PATCH 05/18] Add a CatalogQueryset method for projects Signed-off-by: David Wallace --- rdmo/projects/tests/test_viewset_catalog.py | 12 +++++------ rdmo/projects/viewsets.py | 2 +- rdmo/questions/managers.py | 22 +++++++++++++++++++++ 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/rdmo/projects/tests/test_viewset_catalog.py b/rdmo/projects/tests/test_viewset_catalog.py index fc355e0ed2..b553fa6b50 100644 --- a/rdmo/projects/tests/test_viewset_catalog.py +++ b/rdmo/projects/tests/test_viewset_catalog.py @@ -1,6 +1,6 @@ import pytest -from django.contrib.sites.models import Site +from django.contrib.auth import get_user_model from django.urls import reverse from rdmo.questions.models import Catalog @@ -18,7 +18,7 @@ urlnames = { 'list': 'v1-projects:catalog-list', - 'user': 'v1-projects:catalog-user' + 'user': 'v1-projects:catalog-user' # does not exist } catalog_id = 1 @@ -36,10 +36,10 @@ def test_list(db, client, username, password): assert isinstance(response.json(), list) data = response.json() - site = Site.objects.get_current() - catalogs = Catalog.objects.filter(sites=site) + user = get_user_model().objects.get(username=username) + catalogs = Catalog.objects.for_projects_view(user) + assert {c['id'] for c in data} == set(catalogs.values_list('id', flat=True)) + assert {c['available'] for c in data} == set(catalogs.values_list('available', flat=True)) - 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} else: assert response.status_code == 401 diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index 1f81757970..18b12c4803 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -920,4 +920,4 @@ class CatalogViewSet(ListModelMixin, GenericViewSet): serializer_class = CatalogSerializer def get_queryset(self): - return Catalog.objects.filter_for_user(self.request.user) + return Catalog.objects.for_projects_view(self.request.user) diff --git a/rdmo/questions/managers.py b/rdmo/questions/managers.py index 82216d3933..0df0ff6d4d 100644 --- a/rdmo/questions/managers.py +++ b/rdmo/questions/managers.py @@ -27,6 +27,25 @@ def filter_for_user(self, user): .order_by('-available', 'order') ) + def for_projects_view(self, user): + qs = self.filter_current_site().filter_group(user) + + available_ids = ( + qs + .filter_availability(user) + .values('pk') + ) + + return ( + qs + .filter( + models.Q(pk__in=available_ids) | + models.Q(projects__user=user) + ) + .order_by('-available', 'order') + .distinct() + ) + class CatalogManager(CurrentSiteManagerMixin, GroupsManagerMixin, AvailabilityManagerMixin, models.Manager): @@ -42,6 +61,9 @@ def prefetch_elements(self): def filter_for_user(self, user): return self.get_queryset().filter_for_user(user) + def for_projects_view(self, user): + return self.get_queryset().for_projects_view(user) + class SectionQuerySet(models.QuerySet): From ccc0e0c41755400e2cd81a5782581490614c10bc Mon Sep 17 00:00:00 2001 From: David Wallace Date: Fri, 21 Nov 2025 15:34:18 +0100 Subject: [PATCH 06/18] Revert to correct user object in serializer Signed-off-by: David Wallace --- rdmo/projects/serializers/v1/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rdmo/projects/serializers/v1/__init__.py b/rdmo/projects/serializers/v1/__init__.py index 242c37f30d..790fc18994 100644 --- a/rdmo/projects/serializers/v1/__init__.py +++ b/rdmo/projects/serializers/v1/__init__.py @@ -53,7 +53,7 @@ class ProjectSerializer(serializers.ModelSerializer): class CatalogField(serializers.PrimaryKeyRelatedField): def get_queryset(self): - return Catalog.objects.filter_for_user(self.request.user) + return Catalog.objects.filter_for_user(self.context['request'].user) class ParentField(serializers.PrimaryKeyRelatedField): From a8512048206bacef23e622bab484caa79caf9cf3 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Mon, 24 Nov 2025 10:00:58 +0100 Subject: [PATCH 07/18] projects(tests): move enable multisite fixture Signed-off-by: David Wallace --- rdmo/projects/tests/conftest.py | 4 ++++ rdmo/projects/tests/test_view_membership_multisite.py | 9 ++------- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/rdmo/projects/tests/conftest.py b/rdmo/projects/tests/conftest.py index 1c99a862de..b8d1e49433 100644 --- a/rdmo/projects/tests/conftest.py +++ b/rdmo/projects/tests/conftest.py @@ -3,6 +3,10 @@ from django.apps import apps +@pytest.fixture +def enable_multisite(settings): + settings.MULTISITE = True + @pytest.fixture def enable_project_views_sync(settings): settings.PROJECT_VIEWS_SYNC = True diff --git a/rdmo/projects/tests/test_view_membership_multisite.py b/rdmo/projects/tests/test_view_membership_multisite.py index 585b1096ac..a6e6993052 100644 --- a/rdmo/projects/tests/test_view_membership_multisite.py +++ b/rdmo/projects/tests/test_view_membership_multisite.py @@ -32,16 +32,11 @@ sites_domains = ('example.com', 'foo.com', 'bar.com') -@pytest.fixture -def _multisite(settings): - settings.MULTISITE = True - - @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") +@pytest.mark.usefixtures("enable_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,7 @@ 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") +@pytest.mark.usefixtures("enable_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) From ec5822194cc3379ac8fe1856d90a3a97a7fafc2d Mon Sep 17 00:00:00 2001 From: David Wallace Date: Mon, 24 Nov 2025 10:02:17 +0100 Subject: [PATCH 08/18] projects(tests): update viewset catalog Signed-off-by: David Wallace --- rdmo/projects/tests/test_viewset_catalog.py | 27 +++++++++++---------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/rdmo/projects/tests/test_viewset_catalog.py b/rdmo/projects/tests/test_viewset_catalog.py index b553fa6b50..a2d98dae82 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.auth import get_user_model from django.urls import reverse -from rdmo.questions.models import Catalog - users = ( ('owner', 'owner'), ('manager', 'manager'), @@ -16,13 +13,22 @@ ('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' # does not exist } -catalog_id = 1 - @pytest.mark.parametrize('username,password', users) def test_list(db, client, username, password): @@ -33,13 +39,8 @@ def test_list(db, client, username, password): if password: assert response.status_code == 200 - assert isinstance(response.json(), list) - data = response.json() - user = get_user_model().objects.get(username=username) - catalogs = Catalog.objects.for_projects_view(user) - assert {c['id'] for c in data} == set(catalogs.values_list('id', flat=True)) - assert {c['available'] for c in data} == set(catalogs.values_list('available', flat=True)) - + 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 From de9b360ece279e622d17e50cd07da37ff7a2ae5b Mon Sep 17 00:00:00 2001 From: David Wallace Date: Mon, 24 Nov 2025 10:46:37 +0100 Subject: [PATCH 09/18] Add projects catalog tests for #1481 Signed-off-by: David Wallace --- rdmo/projects/tests/conftest.py | 2 + .../projects/tests/helpers/project_catalog.py | 15 ++++ rdmo/projects/tests/test_viewset_catalog.py | 23 ++++++ .../tests/test_viewset_catalog_multisite.py | 71 +++++++++++++++++++ 4 files changed, 111 insertions(+) create mode 100644 rdmo/projects/tests/helpers/project_catalog.py create mode 100644 rdmo/projects/tests/test_viewset_catalog_multisite.py diff --git a/rdmo/projects/tests/conftest.py b/rdmo/projects/tests/conftest.py index b8d1e49433..400323ba14 100644 --- a/rdmo/projects/tests/conftest.py +++ b/rdmo/projects/tests/conftest.py @@ -2,6 +2,8 @@ from django.apps import apps +from .helpers.project_catalog import clear_sites_from_other_catalogs # noqa: F401 + @pytest.fixture def enable_multisite(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/test_viewset_catalog.py b/rdmo/projects/tests/test_viewset_catalog.py index a2d98dae82..a5824b00af 100644 --- a/rdmo/projects/tests/test_viewset_catalog.py +++ b/rdmo/projects/tests/test_viewset_catalog.py @@ -29,6 +29,7 @@ 'list': 'v1-projects:catalog-list', } +other_sites_catalogs = [(3, True), (4, True)] @pytest.mark.parametrize('username,password', users) def test_list(db, client, username, password): @@ -44,3 +45,25 @@ def test_list(db, client, username, password): 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, 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..c732aa178d --- /dev/null +++ b/rdmo/projects/tests/test_viewset_catalog_multisite.py @@ -0,0 +1,71 @@ +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', +} + + +@pytest.mark.parametrize('username,password', users) +def test_list(db, settings, enable_multisite, 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, enable_multisite, 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 From f79a7f1595a1e480a30d80fc796be08e98b3d792 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Mon, 24 Nov 2025 11:51:13 +0100 Subject: [PATCH 10/18] Also order filtered catalogs by id Signed-off-by: David Wallace --- rdmo/questions/managers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rdmo/questions/managers.py b/rdmo/questions/managers.py index 0df0ff6d4d..1d5a719c11 100644 --- a/rdmo/questions/managers.py +++ b/rdmo/questions/managers.py @@ -42,8 +42,8 @@ def for_projects_view(self, user): models.Q(pk__in=available_ids) | models.Q(projects__user=user) ) - .order_by('-available', 'order') .distinct() + .order_by('-available', 'order', 'id') ) From dd38f6d973e55df9526a254b0e59275613e755f6 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Thu, 27 Nov 2025 11:27:48 +0100 Subject: [PATCH 11/18] Do not use list in Catalogs data migration Signed-off-by: David Wallace --- .../questions/migrations/0098_data_migration_for_multisite.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rdmo/questions/migrations/0098_data_migration_for_multisite.py b/rdmo/questions/migrations/0098_data_migration_for_multisite.py index d614079489..7825b60bd0 100644 --- a/rdmo/questions/migrations/0098_data_migration_for_multisite.py +++ b/rdmo/questions/migrations/0098_data_migration_for_multisite.py @@ -9,8 +9,8 @@ def set_sites_for_catalogs_without_sites(apps, schema_editor): Catalog = apps.get_model('questions', 'Catalog') Site = apps.get_model('sites', 'Site') - all_sites = list(Site.objects.all()) - if not all_sites: + all_sites = Site.objects.all() + if not all_sites.exists(): return catalogs_without_sites = ( From 02755cacec01d9896a284ca14c1924e3689a668f Mon Sep 17 00:00:00 2001 From: David Wallace Date: Thu, 27 Nov 2025 11:41:59 +0100 Subject: [PATCH 12/18] Update Catalog manager methods and use subquery in viewset Signed-off-by: David Wallace --- rdmo/projects/viewsets.py | 11 ++++++++++- rdmo/questions/managers.py | 25 +------------------------ 2 files changed, 11 insertions(+), 25 deletions(-) diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index 18b12c4803..2fb3dfe8f3 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -920,4 +920,13 @@ class CatalogViewSet(ListModelMixin, GenericViewSet): serializer_class = CatalogSerializer def get_queryset(self): - return Catalog.objects.for_projects_view(self.request.user) + 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 1d5a719c11..2f6608da4f 100644 --- a/rdmo/questions/managers.py +++ b/rdmo/questions/managers.py @@ -20,32 +20,12 @@ def prefetch_elements(self): def filter_for_user(self, user): return ( - self - .filter_current_site() + self.filter_current_site() .filter_group(user) .filter_availability(user) .order_by('-available', 'order') ) - def for_projects_view(self, user): - qs = self.filter_current_site().filter_group(user) - - available_ids = ( - qs - .filter_availability(user) - .values('pk') - ) - - return ( - qs - .filter( - models.Q(pk__in=available_ids) | - models.Q(projects__user=user) - ) - .distinct() - .order_by('-available', 'order', 'id') - ) - class CatalogManager(CurrentSiteManagerMixin, GroupsManagerMixin, AvailabilityManagerMixin, models.Manager): @@ -61,9 +41,6 @@ def prefetch_elements(self): def filter_for_user(self, user): return self.get_queryset().filter_for_user(user) - def for_projects_view(self, user): - return self.get_queryset().for_projects_view(user) - class SectionQuerySet(models.QuerySet): From 5b33ed78fab8ce3ed3565e1604bf57e9e1822223 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Thu, 27 Nov 2025 16:29:43 +0100 Subject: [PATCH 13/18] Refactor QuerysetMixins for Task and View Signed-off-by: David Wallace --- rdmo/core/managers.py | 22 ++++++++++++++++++++++ rdmo/tasks/managers.py | 31 ++++++++++--------------------- rdmo/views/managers.py | 31 ++++++++++--------------------- 3 files changed, 42 insertions(+), 42 deletions(-) diff --git a/rdmo/core/managers.py b/rdmo/core/managers.py index c660592231..af7f14abe3 100644 --- a/rdmo/core/managers.py +++ b/rdmo/core/managers.py @@ -1,5 +1,6 @@ from django.conf import settings from django.db import models +from django.db.models import Q from .constants import PERMISSIONS @@ -29,6 +30,27 @@ def filter_availability(self, user): return self.filter(available=True) +class ForGroupsQuerySetMixin: + + def filter_for_groups(self, groups): + return self.filter(Q(groups=None) | Q(groups__in=groups)) + + +class ForSiteQuerySetMixin: + + def filter_for_site(self, site): + if settings.MULTISITE: + return self.filter(sites=site) + else: + return self.filter(Q(sites=None) | Q(sites=site)) + + +class ForCatalogQuerySetMixin: + + def filter_for_catalog(self, catalog): + return self.filter(models.Q(catalogs=None) | models.Q(catalogs=catalog)) + + class CurrentSiteManagerMixin: def filter_current_site(self): diff --git a/rdmo/tasks/managers.py b/rdmo/tasks/managers.py index c60777decb..f30d136b7a 100644 --- a/rdmo/tasks/managers.py +++ b/rdmo/tasks/managers.py @@ -1,33 +1,25 @@ -from django.db.models import Manager, Q, QuerySet +from django.db.models import Manager, QuerySet from rdmo.core.managers import ( AvailabilityManagerMixin, AvailabilityQuerySetMixin, CurrentSiteManagerMixin, - CurrentSiteQuerySetMixin, + ForCatalogQuerySetMixin, + ForGroupsQuerySetMixin, + ForSiteQuerySetMixin, GroupsManagerMixin, - GroupsQuerySetMixin, ) -class TaskQuerySet(CurrentSiteQuerySetMixin, GroupsQuerySetMixin, AvailabilityQuerySetMixin, QuerySet): - - 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)) +class TaskQuerySet(ForSiteQuerySetMixin, ForGroupsQuerySetMixin, ForCatalogQuerySetMixin, + AvailabilityQuerySetMixin, QuerySet): 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) + self.filter(available=True) + .filter_for_site(project.site) + .filter_for_catalog(project.catalog) + .filter_for_groups(project.groups) ) class TaskManager(CurrentSiteManagerMixin, GroupsManagerMixin, AvailabilityManagerMixin, Manager): @@ -35,8 +27,5 @@ class TaskManager(CurrentSiteManagerMixin, GroupsManagerMixin, AvailabilityManag 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) diff --git a/rdmo/views/managers.py b/rdmo/views/managers.py index b2db2cb1dc..9cf68172cf 100644 --- a/rdmo/views/managers.py +++ b/rdmo/views/managers.py @@ -1,33 +1,25 @@ -from django.db.models import Manager, Q, QuerySet +from django.db.models import Manager, QuerySet from rdmo.core.managers import ( AvailabilityManagerMixin, AvailabilityQuerySetMixin, CurrentSiteManagerMixin, - CurrentSiteQuerySetMixin, + ForCatalogQuerySetMixin, + ForGroupsQuerySetMixin, + ForSiteQuerySetMixin, GroupsManagerMixin, - GroupsQuerySetMixin, ) -class ViewQuerySet(CurrentSiteQuerySetMixin, GroupsQuerySetMixin, AvailabilityQuerySetMixin, QuerySet): - - 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)) +class ViewQuerySet(ForSiteQuerySetMixin, ForGroupsQuerySetMixin, ForCatalogQuerySetMixin, + AvailabilityQuerySetMixin, QuerySet): 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) + self.filter(available=True) + .filter_for_site(project.site) + .filter_for_catalog(project.catalog) + .filter_for_groups(project.groups) ) class ViewManager(CurrentSiteManagerMixin, GroupsManagerMixin, AvailabilityManagerMixin, Manager): @@ -35,8 +27,5 @@ class ViewManager(CurrentSiteManagerMixin, GroupsManagerMixin, AvailabilityManag 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) From ff8ff6338ce836115efb3c5763e80ad5f267b442 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Fri, 28 Nov 2025 09:24:59 +0100 Subject: [PATCH 14/18] Refactor View and Task managers into ForProjectManager Signed-off-by: David Wallace --- rdmo/management/managers.py | 32 +++++++++++++++++++++++++++ rdmo/projects/views/project.py | 14 ++---------- rdmo/projects/views/project_create.py | 4 ++-- rdmo/projects/views/project_update.py | 4 ++-- rdmo/projects/viewsets.py | 6 ++--- rdmo/tasks/managers.py | 32 ++++----------------------- rdmo/views/managers.py | 32 ++++----------------------- 7 files changed, 48 insertions(+), 76 deletions(-) create mode 100644 rdmo/management/managers.py diff --git a/rdmo/management/managers.py b/rdmo/management/managers.py new file mode 100644 index 0000000000..72d93f204b --- /dev/null +++ b/rdmo/management/managers.py @@ -0,0 +1,32 @@ + +from django.db.models import QuerySet + +from rdmo.core.managers import ( + AvailabilityQuerySetMixin, + ForCatalogQuerySetMixin, + ForGroupsQuerySetMixin, + ForSiteQuerySetMixin, +) + + +class ForProjectQuerySet(ForSiteQuerySetMixin, ForGroupsQuerySetMixin, ForCatalogQuerySetMixin, + AvailabilityQuerySetMixin, QuerySet): + + def filter_for_project(self, project, user=None): + qs = ( + self.filter_for_site(project.site) + .filter_for_catalog(project.catalog) + .filter_for_groups(project.groups) + ) + if user is not None: + return qs.filter_availability(user) + else: + return qs.filter(available=True) + +class ForProjectManagerMixin: + + def get_queryset(self): + return ForProjectQuerySet(self.model, using=self._db) + + def filter_for_project(self, project, user=None): + return self.get_queryset().filter_for_project(project, user=user) diff --git a/rdmo/projects/views/project.py b/rdmo/projects/views/project.py index ea21680050..fa5629d21c 100644 --- a/rdmo/projects/views/project.py +++ b/rdmo/projects/views/project.py @@ -66,23 +66,13 @@ def get_context_data(self, **kwargs): # 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() - ) + context['tasks_available'] = Task.objects.filter_for_project(project, user=self.request.user).exists() 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_for_project(project) - .filter_availability(self.request.user) - .exists() - ) + context['views_available'] = View.objects.filter_for_project(project, user=self.request.user).exists() ancestors_import = [] for instance in ancestors.exclude(id=project.id): diff --git a/rdmo/projects/views/project_create.py b/rdmo/projects/views/project_create.py index 9e44d02a27..a318369ad2 100644 --- a/rdmo/projects/views/project_create.py +++ b/rdmo/projects/views/project_create.py @@ -48,13 +48,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 = Task.objects.filter_for_project(form.instance, user=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 = View.objects.filter_for_project(form.instance, user=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 ec99afab00..866822e6e0 100644 --- a/rdmo/projects/views/project_update.py +++ b/rdmo/projects/views/project_update.py @@ -124,7 +124,7 @@ 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) + tasks = Task.objects.filter_for_project(self.object, user=self.request.user) form_kwargs = super().get_form_kwargs() form_kwargs.update({ 'tasks': tasks @@ -147,7 +147,7 @@ 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) + views = View.objects.filter_for_project(self.object, user=self.request.user) form_kwargs = super().get_form_kwargs() form_kwargs.update({ 'views': views diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index 2fb3dfe8f3..e370b92ed9 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -408,15 +408,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 Task.objects.filter_for_project(project, user=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 View.objects.filter_for_project(project, user=self.request.user): project.views.add(view) diff --git a/rdmo/tasks/managers.py b/rdmo/tasks/managers.py index f30d136b7a..dea200140d 100644 --- a/rdmo/tasks/managers.py +++ b/rdmo/tasks/managers.py @@ -1,31 +1,7 @@ -from django.db.models import Manager, QuerySet +from django.db.models import Manager -from rdmo.core.managers import ( - AvailabilityManagerMixin, - AvailabilityQuerySetMixin, - CurrentSiteManagerMixin, - ForCatalogQuerySetMixin, - ForGroupsQuerySetMixin, - ForSiteQuerySetMixin, - GroupsManagerMixin, -) +from rdmo.management.managers import ForProjectManagerMixin -class TaskQuerySet(ForSiteQuerySetMixin, ForGroupsQuerySetMixin, ForCatalogQuerySetMixin, - AvailabilityQuerySetMixin, QuerySet): - - def filter_for_project(self, project): - return ( - self.filter(available=True) - .filter_for_site(project.site) - .filter_for_catalog(project.catalog) - .filter_for_groups(project.groups) - ) - -class TaskManager(CurrentSiteManagerMixin, GroupsManagerMixin, AvailabilityManagerMixin, Manager): - - def get_queryset(self) -> TaskQuerySet: - return TaskQuerySet(self.model, using=self._db) - - def filter_for_project(self, project): - return self.get_queryset().filter_for_project(project) +class TaskManager(ForProjectManagerMixin, Manager): + pass diff --git a/rdmo/views/managers.py b/rdmo/views/managers.py index 9cf68172cf..56844071e2 100644 --- a/rdmo/views/managers.py +++ b/rdmo/views/managers.py @@ -1,31 +1,7 @@ -from django.db.models import Manager, QuerySet +from django.db.models import Manager -from rdmo.core.managers import ( - AvailabilityManagerMixin, - AvailabilityQuerySetMixin, - CurrentSiteManagerMixin, - ForCatalogQuerySetMixin, - ForGroupsQuerySetMixin, - ForSiteQuerySetMixin, - GroupsManagerMixin, -) +from rdmo.management.managers import ForProjectManagerMixin -class ViewQuerySet(ForSiteQuerySetMixin, ForGroupsQuerySetMixin, ForCatalogQuerySetMixin, - AvailabilityQuerySetMixin, QuerySet): - - def filter_for_project(self, project): - return ( - self.filter(available=True) - .filter_for_site(project.site) - .filter_for_catalog(project.catalog) - .filter_for_groups(project.groups) - ) - -class ViewManager(CurrentSiteManagerMixin, GroupsManagerMixin, AvailabilityManagerMixin, Manager): - - def get_queryset(self) -> ViewQuerySet: - return ViewQuerySet(self.model, using=self._db) - - def filter_for_project(self, project): - return self.get_queryset().filter_for_project(project) +class ViewManager(ForProjectManagerMixin, Manager): + pass From cd37b539c4a85a4aaf41bb4d86381e80073f7ab3 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Mon, 1 Dec 2025 10:35:28 +0100 Subject: [PATCH 15/18] Add multisite condition for sites to task or view filter for projects Signed-off-by: David Wallace --- rdmo/projects/managers.py | 9 ++++----- .../tests/test_handlers_m2m_tasks_sites_multisite.py | 0 .../tests/test_handlers_m2m_views_sites_multisite.py | 0 3 files changed, 4 insertions(+), 5 deletions(-) create mode 100644 rdmo/projects/tests/test_handlers_m2m_tasks_sites_multisite.py create mode 100644 rdmo/projects/tests/test_handlers_m2m_views_sites_multisite.py diff --git a/rdmo/projects/managers.py b/rdmo/projects/managers.py index 161773357b..9c20ab73cf 100644 --- a/rdmo/projects/managers.py +++ b/rdmo/projects/managers.py @@ -79,13 +79,12 @@ def filter_projects_for_task_or_view(self, instance): # projects that have an unavailable catalog should be disregarded qs = self.filter(catalog__available=True) - # when instance.catalogs is empty it applies to all - if instance.catalogs.exists(): - qs = qs.filter(catalog__in=instance.catalogs.all()) - - # when instance.sites is empty it applies to all + # when instance.sites exists it can be filtered if instance.sites.exists(): qs = qs.filter(site__in=instance.sites.all()) + elif settings.MULTISITE: + # when instance.sites is empty in a multi-site it should not appear at all + return self.none() # when instance.groups is empty it applies to all if instance.groups.exists(): 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..e69de29bb2 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..e69de29bb2 From 98d5c1e032bccc25cc798579124c3d6998e34dba Mon Sep 17 00:00:00 2001 From: David Wallace Date: Mon, 1 Dec 2025 16:17:14 +0100 Subject: [PATCH 16/18] Update and fix project sync tests Signed-off-by: David Wallace --- rdmo/projects/tests/conftest.py | 1 + .../project_sync/arrange_project_tasks.py | 250 +++++++++-------- .../project_sync/arrange_project_views.py | 251 ++++++++++-------- .../assert_project_views_or_tasks.py | 56 ++-- .../tests/test_handlers_m2m_tasks_catalogs.py | 4 +- .../tests/test_handlers_m2m_tasks_groups.py | 2 +- .../tests/test_handlers_m2m_tasks_sites.py | 2 +- ...test_handlers_m2m_tasks_sites_multisite.py | 64 +++++ .../tests/test_handlers_m2m_views_catalogs.py | 4 +- .../tests/test_handlers_m2m_views_groups.py | 4 +- .../tests/test_handlers_m2m_views_sites.py | 4 +- ...test_handlers_m2m_views_sites_multisite.py | 63 +++++ ...test_handlers_view_changes_availability.py | 3 +- .../tests/test_view_membership_multisite.py | 3 +- .../tests/test_viewset_catalog_multisite.py | 5 +- 15 files changed, 449 insertions(+), 267 deletions(-) diff --git a/rdmo/projects/tests/conftest.py b/rdmo/projects/tests/conftest.py index 400323ba14..c392ba554a 100644 --- a/rdmo/projects/tests/conftest.py +++ b/rdmo/projects/tests/conftest.py @@ -7,6 +7,7 @@ @pytest.fixture def enable_multisite(settings): + assert not settings.MULTISITE # assert that the default is False first settings.MULTISITE = True @pytest.fixture 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 index e69de29bb2..e35536e0ea 100644 --- a/rdmo/projects/tests/test_handlers_m2m_tasks_sites_multisite.py +++ 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 index e69de29bb2..2f9c5121c6 100644 --- a/rdmo/projects/tests/test_handlers_m2m_views_sites_multisite.py +++ 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 a6e6993052..04ea1eed77 100644 --- a/rdmo/projects/tests/test_view_membership_multisite.py +++ b/rdmo/projects/tests/test_view_membership_multisite.py @@ -31,12 +31,12 @@ sites_domains = ('example.com', 'foo.com', 'bar.com') +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("enable_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) @@ -65,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("enable_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_multisite.py b/rdmo/projects/tests/test_viewset_catalog_multisite.py index c732aa178d..0092f166d3 100644 --- a/rdmo/projects/tests/test_viewset_catalog_multisite.py +++ b/rdmo/projects/tests/test_viewset_catalog_multisite.py @@ -37,9 +37,10 @@ 'list': 'v1-projects:catalog-list', } +pytestmark = pytest.mark.usefixtures("enable_multisite") @pytest.mark.parametrize('username,password', users) -def test_list(db, settings, enable_multisite, client, username, password): +def test_list(db, settings, client, username, password): client.login(username=username, password=password) url = reverse(urlnames['list']) @@ -55,7 +56,7 @@ def test_list(db, settings, enable_multisite, client, username, password): @pytest.mark.parametrize('username,password', users) -def test_list_with_cleared_sites(db, settings, enable_multisite, clear_sites_from_other_catalogs, +def test_list_with_cleared_sites(db, settings, clear_sites_from_other_catalogs, client, username, password): client.login(username=username, password=password) From f197bfbf2e4e4826f312b77698b3931be4718b1b Mon Sep 17 00:00:00 2001 From: David Wallace Date: Mon, 1 Dec 2025 16:18:37 +0100 Subject: [PATCH 17/18] Add query for catalogs to filter_projects_for_task_or_view Signed-off-by: David Wallace --- rdmo/projects/managers.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/rdmo/projects/managers.py b/rdmo/projects/managers.py index 9c20ab73cf..7925349935 100644 --- a/rdmo/projects/managers.py +++ b/rdmo/projects/managers.py @@ -72,21 +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 + # projects that have an unavailable catalog should be disregarded + qs = self.filter(catalog__available=True) + + # when 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 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 exists it can be filtered + # 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 instance.sites is empty in a multi-site it should not appear at all + # 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()) From d1e4e3c80b0d73eac4dbebd43c8dd6f6b387ee1d Mon Sep 17 00:00:00 2001 From: David Wallace Date: Mon, 1 Dec 2025 21:01:39 +0100 Subject: [PATCH 18/18] Refactor manager methods into projects.util filter_tasks_or_views_for_project function Signed-off-by: David Wallace --- rdmo/core/managers.py | 22 ------------------ rdmo/management/managers.py | 32 --------------------------- rdmo/projects/handlers/sync_utils.py | 7 +++--- rdmo/projects/utils.py | 15 +++++++++++++ rdmo/projects/views/project.py | 14 +++++++----- rdmo/projects/views/project_create.py | 5 +++-- rdmo/projects/views/project_update.py | 7 +++--- rdmo/projects/viewsets.py | 5 +++-- rdmo/tasks/managers.py | 14 +++++++++--- rdmo/views/managers.py | 14 +++++++++--- 10 files changed, 59 insertions(+), 76 deletions(-) diff --git a/rdmo/core/managers.py b/rdmo/core/managers.py index af7f14abe3..c660592231 100644 --- a/rdmo/core/managers.py +++ b/rdmo/core/managers.py @@ -1,6 +1,5 @@ from django.conf import settings from django.db import models -from django.db.models import Q from .constants import PERMISSIONS @@ -30,27 +29,6 @@ def filter_availability(self, user): return self.filter(available=True) -class ForGroupsQuerySetMixin: - - def filter_for_groups(self, groups): - return self.filter(Q(groups=None) | Q(groups__in=groups)) - - -class ForSiteQuerySetMixin: - - def filter_for_site(self, site): - if settings.MULTISITE: - return self.filter(sites=site) - else: - return self.filter(Q(sites=None) | Q(sites=site)) - - -class ForCatalogQuerySetMixin: - - def filter_for_catalog(self, catalog): - return self.filter(models.Q(catalogs=None) | models.Q(catalogs=catalog)) - - class CurrentSiteManagerMixin: def filter_current_site(self): diff --git a/rdmo/management/managers.py b/rdmo/management/managers.py index 72d93f204b..e69de29bb2 100644 --- a/rdmo/management/managers.py +++ b/rdmo/management/managers.py @@ -1,32 +0,0 @@ - -from django.db.models import QuerySet - -from rdmo.core.managers import ( - AvailabilityQuerySetMixin, - ForCatalogQuerySetMixin, - ForGroupsQuerySetMixin, - ForSiteQuerySetMixin, -) - - -class ForProjectQuerySet(ForSiteQuerySetMixin, ForGroupsQuerySetMixin, ForCatalogQuerySetMixin, - AvailabilityQuerySetMixin, QuerySet): - - def filter_for_project(self, project, user=None): - qs = ( - self.filter_for_site(project.site) - .filter_for_catalog(project.catalog) - .filter_for_groups(project.groups) - ) - if user is not None: - return qs.filter_availability(user) - else: - return qs.filter(available=True) - -class ForProjectManagerMixin: - - def get_queryset(self): - return ForProjectQuerySet(self.model, using=self._db) - - def filter_for_project(self, project, user=None): - return self.get_queryset().filter_for_project(project, user=user) 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/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 fa5629d21c..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__) @@ -66,13 +66,17 @@ def get_context_data(self, **kwargs): # 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, user=self.request.user).exists() + context['tasks_available'] = ( + filter_tasks_or_views_for_project(Task, project).filter_availability(self.request.user).exists() + ) 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_for_project(project, user=self.request.user).exists() + context['views_available'] = ( + filter_tasks_or_views_for_project(View, project).filter_availability(self.request.user).exists() + ) ancestors_import = [] for instance in ancestors.exclude(id=project.id): diff --git a/rdmo/projects/views/project_create.py b/rdmo/projects/views/project_create.py index a318369ad2..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__) @@ -48,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, user=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, user=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 866822e6e0..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__) @@ -124,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, user=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 @@ -147,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, user=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 e370b92ed9..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,13 +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: - for task in Task.objects.filter_for_project(project, user=self.request.user): + 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: - for view in View.objects.filter_for_project(project, user=self.request.user): + for view in filter_tasks_or_views_for_project(View, project).filter_availability(self.request.user): project.views.add(view) diff --git a/rdmo/tasks/managers.py b/rdmo/tasks/managers.py index dea200140d..7523b4c2d0 100644 --- a/rdmo/tasks/managers.py +++ b/rdmo/tasks/managers.py @@ -1,7 +1,15 @@ -from django.db.models import Manager +from django.db.models import Manager, QuerySet -from rdmo.management.managers import ForProjectManagerMixin +from rdmo.core.managers import AvailabilityQuerySetMixin -class TaskManager(ForProjectManagerMixin, Manager): +class TaskQuerySet(AvailabilityQuerySetMixin, QuerySet): pass + +class TaskManager(Manager): + + def get_queryset(self) -> TaskQuerySet: + return TaskQuerySet(self.model, using=self._db) + + 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 56844071e2..fee43b4500 100644 --- a/rdmo/views/managers.py +++ b/rdmo/views/managers.py @@ -1,7 +1,15 @@ -from django.db.models import Manager +from django.db.models import Manager, QuerySet -from rdmo.management.managers import ForProjectManagerMixin +from rdmo.core.managers import AvailabilityQuerySetMixin -class ViewManager(ForProjectManagerMixin, Manager): +class ViewQuerySet(AvailabilityQuerySetMixin, QuerySet): pass + +class ViewManager(Manager): + + def get_queryset(self) -> ViewQuerySet: + return ViewQuerySet(self.model, using=self._db) + + def filter_availability(self, user) -> QuerySet: + return self.get_queryset().filter_availability(user)