Skip to content

Commit

Permalink
feat(project, handlers): refactor and add handler for project save, c…
Browse files Browse the repository at this point in the history
…hange in catalog

Signed-off-by: David Wallace <[email protected]>
  • Loading branch information
MyPyDavid committed Jan 22, 2025
1 parent 88e4583 commit edb842e
Show file tree
Hide file tree
Showing 14 changed files with 210 additions and 29 deletions.
8 changes: 6 additions & 2 deletions rdmo/core/managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,12 @@ def filter_current_site(self):

class GroupsQuerySetMixin:

def filter_group(self, user):
groups = user.groups.all()
def filter_group(self, users):

if not isinstance(users, (list, tuple, models.QuerySet)):
users = [users]

groups = {group for user in users for group in user.groups.all()}
return self.filter(models.Q(groups=None) | models.Q(groups__in=groups))


Expand Down
4 changes: 2 additions & 2 deletions rdmo/projects/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@ def ready(self):
from . import rules # noqa: F401

if settings.PROJECT_VIEWS_SYNC:
from .handlers import project_views # noqa: F401
from .handlers import m2m_changed_views, project_save_views # noqa: F401
if settings.PROJECT_TASKS_SYNC:
from .handlers import project_tasks # noqa: F401
from .handlers import m2m_changed_tasks, project_save_tasks # noqa: F401
42 changes: 42 additions & 0 deletions rdmo/projects/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,16 @@ class Meta:
'catalog': forms.RadioSelect()
}

def save(self, commit=True, *args, **kwargs):
if 'cancel' in self.data:
return self.instance

# if the catalog is the same, do nothing
if self.instance.catalog.id == self.cleaned_data.get('catalog'):
return self.instance

return super().save(commit=commit)


class ProjectUpdateTasksForm(forms.ModelForm):

Expand All @@ -180,6 +190,22 @@ class Meta:
'tasks': forms.CheckboxSelectMultiple()
}

def save(self, commit=True, *args, **kwargs):
if 'cancel' in self.data:
return self.instance

# If the tasks are the same, do nothing
current_tasks = set(self.instance.tasks.values_list('id', flat=True))
new_tasks = set(self.cleaned_data.get('tasks').values_list('id', flat=True)) if self.cleaned_data.get(
'tasks') else set()

if current_tasks == new_tasks:
return self.instance

# Save the updated tasks
self.instance.tasks.set(self.cleaned_data.get('tasks'))
return super().save(commit=commit)


class ProjectUpdateViewsForm(forms.ModelForm):

Expand All @@ -200,6 +226,22 @@ class Meta:
'views': forms.CheckboxSelectMultiple()
}

def save(self, commit=True, *args, **kwargs):
if 'cancel' in self.data:
return self.instance

# If the views are the same, do nothing
current_views = set(self.instance.views.values_list('id', flat=True))
new_views = ( set(self.cleaned_data.get('views').values_list('id', flat=True))
if self.cleaned_data.get('views') else set()
)

if current_views == new_views:
return self.instance

# Save the updated views
self.instance.views.set(self.cleaned_data.get('views'))
return super().save(commit=commit)

class ProjectUpdateParentForm(forms.ModelForm):

Expand Down
25 changes: 12 additions & 13 deletions rdmo/projects/handlers/generic_handlers.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
from django.contrib.auth.models import User
from django.contrib.auth.models import Group, User
from django.contrib.sites.models import Site

from rdmo.projects.models import Membership, Project

from ...questions.models import Catalog
from .utils import add_instance_to_projects, remove_instance_from_projects


def m2m_catalogs_changed_projects_sync_signal_handler(action, related_model, pk_set, instance, project_field):
def m2m_catalogs_changed_projects_sync_signal_handler(action, pk_set, instance, project_field):
"""
Update project relationships for m2m_changed signals.
Args:
action (str): The m2m_changed action (post_add, post_remove, post_clear).
related_model (Model): The related model (e.g., Catalog).
pk_set (set): The set of primary keys for the related model instances.
instance (Model): The instance being updated (e.g., View or Task).
project_field (str): The field on Project to update (e.g., 'views', 'tasks').
"""
if action == 'post_remove' and pk_set:
related_instances = related_model.objects.filter(pk__in=pk_set)
related_instances = Catalog.objects.filter(pk__in=pk_set)
projects_to_change = Project.objects.filter_catalogs(catalogs=related_instances).filter(
**{project_field: instance}
)
Expand All @@ -28,26 +29,25 @@ def m2m_catalogs_changed_projects_sync_signal_handler(action, related_model, pk_
remove_instance_from_projects(projects_to_change, project_field, instance)

elif action == 'post_add' and pk_set:
related_instances = related_model.objects.filter(pk__in=pk_set)
related_instances = Catalog.objects.filter(pk__in=pk_set)
projects_to_change = Project.objects.filter_catalogs(catalogs=related_instances).exclude(
**{project_field: instance}
)
add_instance_to_projects(projects_to_change, project_field, instance)


def m2m_sites_changed_projects_sync_signal_handler(action, model, pk_set, instance, project_field):
def m2m_sites_changed_projects_sync_signal_handler(action, pk_set, instance, project_field):
"""
Synchronize Project relationships for m2m_changed signals triggered by site updates.
Args:
action (str): The m2m_changed action (post_add, post_remove, post_clear).
model (Model): The related model (e.g., Site).
pk_set (set): The set of primary keys for the related model instances.
instance (Model): The instance being updated (e.g., View or Task).
project_field (str): The field on Project to update (e.g., 'views', 'tasks').
"""
if action == 'post_remove' and pk_set:
related_sites = model.objects.filter(pk__in=pk_set)
related_sites = Site.objects.filter(pk__in=pk_set)
catalogs = instance.catalogs.all()

projects_to_change = Project.objects.filter_catalogs(catalogs=catalogs).filter(
Expand All @@ -61,7 +61,7 @@ def m2m_sites_changed_projects_sync_signal_handler(action, model, pk_set, instan
remove_instance_from_projects(projects_to_change, project_field, instance)

elif action == 'post_add' and pk_set:
related_sites = model.objects.filter(pk__in=pk_set)
related_sites = Site.objects.filter(pk__in=pk_set)
catalogs = instance.catalogs.all()

projects_to_change = Project.objects.filter_catalogs(catalogs=catalogs).filter(
Expand All @@ -70,19 +70,18 @@ def m2m_sites_changed_projects_sync_signal_handler(action, model, pk_set, instan
add_instance_to_projects(projects_to_change, project_field, instance)


def m2m_groups_changed_projects_sync_signal_handler(action, model, pk_set, instance, project_field):
def m2m_groups_changed_projects_sync_signal_handler(action, pk_set, instance, project_field):
"""
Synchronize Project relationships for m2m_changed signals triggered by group updates.
Args:
action (str): The m2m_changed action (post_add, post_remove, post_clear).
model (Model): The related model (e.g., Group).
pk_set (set): The set of primary keys for the related model instances.
instance (Model): The instance being updated (e.g., View or Task).
project_field (str): The field on Project to update (e.g., 'views', 'tasks').
"""
if action == 'post_remove' and pk_set:
related_groups = model.objects.filter(pk__in=pk_set)
related_groups = Group.objects.filter(pk__in=pk_set)
users = User.objects.filter(groups__in=related_groups)
memberships = Membership.objects.filter(role='owner', user__in=users).values_list('id', flat=True)
catalogs = instance.catalogs.all()
Expand All @@ -99,7 +98,7 @@ def m2m_groups_changed_projects_sync_signal_handler(action, model, pk_set, insta
remove_instance_from_projects(projects_to_change, project_field, instance)

elif action == 'post_add' and pk_set:
related_groups = model.objects.filter(pk__in=pk_set)
related_groups = Group.objects.filter(pk__in=pk_set)
users = User.objects.filter(groups__in=related_groups)
memberships = Membership.objects.filter(role='owner', user__in=users).values_list('id', flat=True)
catalogs = instance.catalogs.all()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,32 +12,29 @@


@receiver(m2m_changed, sender=Task.catalogs.through)
def m2m_changed_task_catalog_signal(sender, instance, action, model, pk_set, **kwargs):
def m2m_changed_task_catalog_signal(sender, instance, action, pk_set, **kwargs):
m2m_catalogs_changed_projects_sync_signal_handler(
action=action,
related_model=model,
pk_set=pk_set,
instance=instance,
project_field='tasks',
)


@receiver(m2m_changed, sender=Task.sites.through)
def m2m_changed_task_sites_signal(sender, instance, action, model, pk_set, **kwargs):
def m2m_changed_task_sites_signal(sender, instance, action, pk_set, **kwargs):
m2m_sites_changed_projects_sync_signal_handler(
action=action,
model=model,
pk_set=pk_set,
instance=instance,
project_field='tasks'
)


@receiver(m2m_changed, sender=Task.groups.through)
def m2m_changed_task_groups_signal(sender, instance, action, model, pk_set, **kwargs):
def m2m_changed_task_groups_signal(sender, instance, action, pk_set, **kwargs):
m2m_groups_changed_projects_sync_signal_handler(
action=action,
model=model,
pk_set=pk_set,
instance=instance,
project_field='tasks'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,9 @@


@receiver(m2m_changed, sender=View.catalogs.through)
def m2m_changed_view_catalog_signal(sender, instance, action, model, pk_set, **kwargs):
def m2m_changed_view_catalog_signal(sender, instance, action, pk_set, **kwargs):
m2m_catalogs_changed_projects_sync_signal_handler(
action=action,
related_model=model,
pk_set=pk_set,
instance=instance,
project_field='views',
Expand All @@ -24,21 +23,19 @@ def m2m_changed_view_catalog_signal(sender, instance, action, model, pk_set, **k


@receiver(m2m_changed, sender=View.sites.through)
def m2m_changed_view_sites_signal(sender, instance, action, model, pk_set, **kwargs):
def m2m_changed_view_sites_signal(sender, instance, action, pk_set, **kwargs):
m2m_sites_changed_projects_sync_signal_handler(
action=action,
model=model,
pk_set=pk_set,
instance=instance,
project_field='views'
)


@receiver(m2m_changed, sender=View.groups.through)
def m2m_changed_view_groups_signal(sender, instance, action, model, pk_set, **kwargs):
def m2m_changed_view_groups_signal(sender, instance, action, pk_set, **kwargs):
m2m_groups_changed_projects_sync_signal_handler(
action=action,
model=model,
pk_set=pk_set,
instance=instance,
project_field='views'
Expand Down
28 changes: 28 additions & 0 deletions rdmo/projects/handlers/project_save_tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver

from rdmo.projects.models import Project
from rdmo.tasks.models import Task

DEFERRED_SYNC_TASKS_KEY = '_deferred_sync_tasks'

@receiver(pre_save, sender=Project)
def pre_save_project_sync_tasks_from_catalog(sender, instance, raw, update_fields, **kwargs):
if raw or (update_fields and 'catalog' not in update_fields):
return

# Fetch the original catalog from the database
original_instance = sender.objects.get(id=instance.id)
if original_instance.catalog == instance.catalog:
# Do nothing if the catalog has not changed
return

# Defer synchronization of views
setattr(instance, DEFERRED_SYNC_TASKS_KEY, True)

@receiver(post_save, sender=Project)
def post_save_project_sync_tasks_from_catalog(sender, instance, created, raw, **kwargs):
if getattr(instance, DEFERRED_SYNC_TASKS_KEY, None) or (created and not raw):
# For existing projects with catalog changes, use deferred views
instance.views.set(Task.objects.filter_available_tasks_for_project(instance))
delattr(instance, DEFERRED_SYNC_TASKS_KEY)
28 changes: 28 additions & 0 deletions rdmo/projects/handlers/project_save_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver

from rdmo.projects.models import Project
from rdmo.views.models import View

DEFERRED_SYNC_VIEWS_KEY = '_deferred_sync_views'

@receiver(pre_save, sender=Project)
def pre_save_project_sync_views_from_catalog(sender, instance, raw, update_fields, **kwargs):
if raw or (update_fields and 'catalog' not in update_fields):
return

# Fetch the original catalog from the database
original_instance = sender.objects.get(id=instance.id)
if original_instance.catalog == instance.catalog:
# Do nothing if the catalog has not changed
return

# Defer synchronization of views
setattr(instance, DEFERRED_SYNC_VIEWS_KEY, True)

@receiver(post_save, sender=Project)
def post_save_project_sync_views_from_catalog(sender, instance, created, raw, update_fields, **kwargs):
if getattr(instance, DEFERRED_SYNC_VIEWS_KEY, None) or (created and not raw):
# For existing projects with catalog changes, use deferred views
instance.views.set(View.objects.filter_available_views_for_project(instance))
delattr(instance, DEFERRED_SYNC_VIEWS_KEY)
31 changes: 31 additions & 0 deletions rdmo/projects/tests/helpers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@
from collections import defaultdict

from rdmo.questions.models import Catalog
from rdmo.views.models import View


def assert_other_projects_unchanged(other_projects, initial_tasks_state):
for other_project in other_projects:
assert set(other_project.tasks.values_list('id', flat=True)) == set(initial_tasks_state[other_project.id])



def get_catalog_view_mapping():
"""
Generate a mapping of catalogs to their associated views.
Includes all catalogs, even those with no views, and adds `sites` and `groups` for each view.
"""
# Initialize an empty dictionary for the catalog-to-views mapping
catalog_views_mapping = defaultdict(list)

# Populate the mapping for all catalogs
for catalog in Catalog.objects.all():
catalog_views_mapping[catalog.id] = []

# Iterate through all views and enrich the mapping
for view in View.objects.prefetch_related('sites', 'groups'):
if view.catalogs.exists(): # Only include views with valid catalogs
for catalog in view.catalogs.all():
catalog_views_mapping[catalog.id].append({
'id': view.id,
'sites': list(view.sites.values_list('id', flat=True)),
'groups': list(view.groups.values_list('id', flat=True))
})

# Convert defaultdict to a regular dictionary
return dict(catalog_views_mapping)
File renamed without changes.
File renamed without changes.
30 changes: 30 additions & 0 deletions rdmo/projects/tests/test_handlers_project_save.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from rdmo.projects.models import Project
from rdmo.questions.models import Catalog
from rdmo.views.models import View

from .helpers import get_catalog_view_mapping

project_id = 10


def test_project_views_sync_when_changing_the_catalog_on_a_project(db, settings):
assert settings.PROJECT_VIEWS_SYNC

# Setup: Create a catalog, a view, and a project using the catalog
project = Project.objects.get(id=project_id)
initial_project_views = set(project.views.values_list('id', flat=True))
assert initial_project_views == {1,2,3} # from the fixture

catalog_view_mapping = get_catalog_view_mapping()
for catalog_id, view_ids in catalog_view_mapping.items():
if project.catalog_id == catalog_id:
continue # catalog will not change
project.catalog = Catalog.objects.get(id=catalog_id)
project.save()

# TODO this filter_available_views_for_project method needs to tested explicitly
available_views = set(View.objects
.filter_available_views_for_project(project)
.values_list('id', flat=True)
)
assert set(project.views.values_list('id', flat=True)) == available_views
Loading

0 comments on commit edb842e

Please sign in to comment.