Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft: signal handlers sync project views and tasks #1218

Draft
wants to merge 3 commits into
base: 2.3.0
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion rdmo/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,8 @@

PROJECT_SEND_INVITE = True

PROJECT_REMOVE_VIEWS = True
PROJECT_VIEWS_SYNC = True
PROJECT_TASKS_SYNC = True

PROJECT_CREATE_RESTRICTED = False
PROJECT_CREATE_GROUPS = []
Expand Down
6 changes: 4 additions & 2 deletions rdmo/projects/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@ class ProjectsConfig(AppConfig):
def ready(self):
from . import rules # noqa: F401

if settings.PROJECT_REMOVE_VIEWS:
from . import handlers # noqa: F401
if settings.PROJECT_VIEWS_SYNC:
from .handlers import project_views # noqa: F401
if settings.PROJECT_TASKS_SYNC:
from .handlers import project_tasks # noqa: F401
69 changes: 17 additions & 52 deletions rdmo/projects/handlers.py
Original file line number Diff line number Diff line change
@@ -1,60 +1,25 @@
import logging

from django.contrib.auth.models import User
from django.contrib.sites.models import Site
from django.db.models.signals import m2m_changed
from django.db.models.signals import post_save
from django.dispatch import receiver

from rdmo.projects.models import Membership, Project
from rdmo.questions.models import Catalog
from rdmo.projects.models import Project
from rdmo.views.models import View

logger = logging.getLogger(__name__)


@receiver(m2m_changed, sender=View.catalogs.through)
def m2m_changed_view_catalog_signal(sender, instance, **kwargs):
catalogs = instance.catalogs.all()

if catalogs:
catalog_candidates = Catalog.objects.exclude(id__in=[catalog.id for catalog in catalogs])

# Remove catalog candidates for all sites
projects = Project.objects.filter(catalog__in=catalog_candidates, views=instance)
for proj in projects:
proj.views.remove(instance)


@receiver(m2m_changed, sender=View.sites.through)
def m2m_changed_view_sites_signal(sender, instance, **kwargs):
sites = instance.sites.all()
catalogs = instance.catalogs.all()

if sites:
site_candidates = Site.objects.exclude(id__in=[site.id for site in sites])
if not catalogs:
# if no catalogs are selected, update all
catalogs = Catalog.objects.all()

# Restrict chosen catalogs for chosen sites
projects = Project.objects.filter(site__in=site_candidates, catalog__in=catalogs, views=instance)
for project in projects:
project.views.remove(instance)

@receiver(post_save, sender=Project)
def update_views_on_catalog_change(sender, instance, **kwargs):
# remove views that are no longer available
view_candidates = instance.views.exclude(catalogs__in=[instance.catalog]) \
.exclude(catalogs=None)

@receiver(m2m_changed, sender=View.groups.through)
def m2m_changed_view_groups_signal(sender, instance, **kwargs):
groups = instance.groups.all()
catalogs = instance.catalogs.all()
for view in view_candidates:
instance.views.remove(view)

if groups:
users = User.objects.exclude(groups__in=groups)
memberships = [membership.id for membership in Membership.objects.filter(role='owner', user__in=users)]
if not catalogs:
# if no catalogs are selected, update all
catalogs = Catalog.objects.all()
# add views that are now available
view_candidates = View.objects.exclude(id__in=[v.id for v in instance.views.all()]) \
.filter_current_site() \
.filter_catalog(instance.catalog)
# .filter_group(self.request.user) \
# .filter_availability(self.request.user).exists()

# Restrict chosen catalogs for chosen groups
projects = Project.objects.filter(memberships__in=list(memberships), catalog__in=catalogs, views=instance)
for project in projects:
project.views.remove(instance)
for view in view_candidates:
instance.views.add(view)
Empty file.
110 changes: 110 additions & 0 deletions rdmo/projects/handlers/generic_handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
from django.contrib.auth.models import User
jochenklar marked this conversation as resolved.
Show resolved Hide resolved

from rdmo.projects.models import Membership, Project

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):
"""
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)
projects_to_change = Project.objects.filter_catalogs(catalogs=related_instances).filter(
**{project_field: instance}
)
remove_instance_from_projects(projects_to_change, project_field, instance)

elif action == 'post_clear':
projects_to_change = Project.objects.filter(**{project_field: instance})
remove_instance_from_projects(projects_to_change, project_field, instance)

elif action == 'post_add' and pk_set:
related_instances = related_model.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):
"""
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)
catalogs = instance.catalogs.all()

projects_to_change = Project.objects.filter_catalogs(catalogs=catalogs).filter(
site__in=related_sites,
**{project_field: instance}
)
remove_instance_from_projects(projects_to_change, project_field, instance)

elif action == 'post_clear':
projects_to_change = Project.objects.filter_catalogs().filter(**{project_field: instance})
remove_instance_from_projects(projects_to_change, project_field, instance)

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

projects_to_change = Project.objects.filter_catalogs(catalogs=catalogs).filter(
site__in=related_sites
).exclude(**{project_field: instance})
add_instance_to_projects(projects_to_change, project_field, instance)


def m2m_groups_changed_projects_sync_signal_handler(action, model, 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)
users = User.objects.filter(groups__in=related_groups)
memberships = Membership.objects.filter(role='owner', user__in=users).values_list('id', flat=True)
catalogs = instance.catalogs.all()

projects_to_change = Project.objects.filter_catalogs(catalogs=catalogs).filter(
memberships__in=memberships,
**{project_field: instance}
)
remove_instance_from_projects(projects_to_change, project_field, instance)

elif action == 'post_clear':
# Remove all linked projects regardless of catalogs
projects_to_change = Project.objects.filter_catalogs().filter(**{project_field: instance})
remove_instance_from_projects(projects_to_change, project_field, instance)

elif action == 'post_add' and pk_set:
related_groups = model.objects.filter(pk__in=pk_set)
users = User.objects.filter(groups__in=related_groups)
memberships = Membership.objects.filter(role='owner', user__in=users).values_list('id', flat=True)
catalogs = instance.catalogs.all()

projects_to_change = Project.objects.filter_catalogs(catalogs=catalogs).filter(
memberships__in=memberships
).exclude(**{project_field: instance})
add_instance_to_projects(projects_to_change, project_field, instance)
44 changes: 44 additions & 0 deletions rdmo/projects/handlers/project_tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@

from django.db.models.signals import m2m_changed
from django.dispatch import receiver

from rdmo.tasks.models import Task

from .generic_handlers import (
m2m_catalogs_changed_projects_sync_signal_handler,
m2m_groups_changed_projects_sync_signal_handler,
m2m_sites_changed_projects_sync_signal_handler,
)


@receiver(m2m_changed, sender=Task.catalogs.through)
def m2m_changed_task_catalog_signal(sender, instance, action, model, 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):
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):
m2m_groups_changed_projects_sync_signal_handler(
action=action,
model=model,
pk_set=pk_set,
instance=instance,
project_field='tasks'
)
45 changes: 45 additions & 0 deletions rdmo/projects/handlers/project_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@

from django.db.models.signals import m2m_changed
from django.dispatch import receiver

from rdmo.views.models import View

from .generic_handlers import (
m2m_catalogs_changed_projects_sync_signal_handler,
m2m_groups_changed_projects_sync_signal_handler,
m2m_sites_changed_projects_sync_signal_handler,
)


@receiver(m2m_changed, sender=View.catalogs.through)
def m2m_changed_view_catalog_signal(sender, instance, action, model, pk_set, **kwargs):
m2m_catalogs_changed_projects_sync_signal_handler(
action=action,
related_model=model,
pk_set=pk_set,
instance=instance,
project_field='views',
)



@receiver(m2m_changed, sender=View.sites.through)
def m2m_changed_view_sites_signal(sender, instance, action, model, 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):
m2m_groups_changed_projects_sync_signal_handler(
action=action,
model=model,
pk_set=pk_set,
instance=instance,
project_field='views'
)
10 changes: 10 additions & 0 deletions rdmo/projects/handlers/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think those methods should exist, this should be inline in the handlers (and without the getattr).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could make it more explicit but it would require a lot more boilerplate code.
The signal handlers for View and Task are basically duplicates, so there would be 18 branches instead of the 9 if action == ... in total in the different functions for .catalogs, .sites and .groups .
But we already agreed about this in the meeting right?

Or would there be an extra use case that would require an update on the Issues as well, when project.tasks are handled?

Example for an explicit function

    # Identify the catalogs affected by this change
    catalogs = model.objects.filter(pk__in=pk_set)

    # Determine the projects to be modified
    if action == 'post_add':
        projects = Project.objects.filter_catalogs(catalogs).exclude(views=instance)
        for project in projects:
            project.views.add(instance)

    elif action == 'post_remove':
        projects = Project.objects.filter_catalogs(catalogs).filter(views=instance)
        for project in projects:
            project.views.remove(instance)

    elif action == 'post_clear':
        projects = Project.objects.filter(views=instance)
        for project in projects:
            project.views.remove(instance)


def remove_instance_from_projects(projects, project_field, instance):
for project in projects:
getattr(project, project_field).remove(instance)


def add_instance_to_projects(projects, project_field, instance):
for project in projects:
getattr(project, project_field).add(instance)
14 changes: 14 additions & 0 deletions rdmo/projects/managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ def filter_visibility(self, user):
visibility_filter = Q(visibility__isnull=False) & sites_filter & groups_filter
return self.filter(Q(user=user) | visibility_filter)

def filter_catalogs(self, catalogs=None, exclude_catalogs=None, exclude_null=True):
catalogs_filter = Q()
if exclude_null:
catalogs_filter &= Q(catalog__isnull=False)
if catalogs:
catalogs_filter &= Q(catalog__in=catalogs)
if exclude_catalogs:
catalogs_filter &= ~Q(catalog__in=exclude_catalogs)
return self.filter(catalogs_filter)


class MembershipQuerySet(models.QuerySet):

Expand Down Expand Up @@ -167,6 +177,10 @@ def filter_user(self, user):
def filter_visibility(self, user):
return self.get_queryset().filter_visibility(user)

def filter_catalogs(self, catalogs=None, exclude_catalogs=None, exclude_null=True):
return self.get_queryset().filter_catalogs(catalogs=catalogs, exclude_catalogs=exclude_catalogs,
exclude_null=exclude_null)


class MembershipManager(CurrentSiteManagerMixin, models.Manager):

Expand Down
5 changes: 5 additions & 0 deletions rdmo/projects/tests/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@


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])
Loading
Loading