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

signal handlers for syncing of project views and tasks #1218

Merged
merged 23 commits into from
Feb 20, 2025
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3036a33
feat(project,handlers): add sync for views and tasks, #966 #1198
MyPyDavid Jan 8, 2025
1a03525
fix: remove views if catalog is changed
m6121 Nov 22, 2024
8924baf
feat(project, handlers): refactor and add handler for project save, c…
MyPyDavid Jan 22, 2025
55d24f7
fix(project,handlers): ignore signals at project create
MyPyDavid Jan 22, 2025
bdb6f7e
refactor(project,handlers): simplify pre and post save
MyPyDavid Jan 23, 2025
02137dd
fix(managers,tasks,views): use project.owners for filter
MyPyDavid Jan 24, 2025
b09af76
feat(project, update): prevent views or tasks update when sync is ena…
MyPyDavid Jan 24, 2025
ecefbaf
refactor(project,handlers): move add and remove inline to the generic…
MyPyDavid Feb 13, 2025
42f54e0
refactor(project,handlers): not use setattr and add created check
MyPyDavid Feb 14, 2025
e0799c3
refactor(tasks,views,manager): update query filter for project
MyPyDavid Feb 14, 2025
2371728
feat(projects,sync): use tasks and views for project filter, add grou…
MyPyDavid Feb 14, 2025
20ca32d
feat(managers,task,views): include isnull in filter for project
MyPyDavid Feb 14, 2025
9b2dedc
refactor(project,handlers): remove handlers.py
MyPyDavid Feb 14, 2025
d3f94a4
style(managers,tasks,views): fix newlines
MyPyDavid Feb 14, 2025
4f32f00
style(projects,handlers): fix args and remove docstrings
MyPyDavid Feb 14, 2025
9cb4429
feat(projects,sync): prevent update tasks or views when sync is enabled
MyPyDavid Feb 14, 2025
50f25dd
revert(core,managers): keep filter group the same
MyPyDavid Feb 14, 2025
90c32a3
feat(projects,sync): disable sync by default
MyPyDavid Feb 17, 2025
3c2ea28
refactor(managers,sync): add filter_for_project method
MyPyDavid Feb 17, 2025
fa69180
tests(project, handlers): add fixtures to enable sync
MyPyDavid Feb 18, 2025
1b42dd1
feat(project,tasks,views): add user to availability filter
MyPyDavid Feb 18, 2025
3da6110
tests(project, handlers): fix import of enable_project_tasks_sync
MyPyDavid Feb 18, 2025
7fc4740
style(project,tasks,views): update sync queries
MyPyDavid Feb 18, 2025
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
7 changes: 5 additions & 2 deletions rdmo/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,9 @@
'PROJECT_IMPORTS',
'PROJECT_IMPORTS_LIST',
'PROJECT_SEND_ISSUE',
'NESTED_PROJECTS'
'NESTED_PROJECTS',
'PROJECT_VIEWS_SYNC',
'PROJECT_TASKS_SYNC'
]

SETTINGS_API = [
Expand Down Expand Up @@ -331,7 +333,8 @@

PROJECT_SEND_INVITE = True

PROJECT_REMOVE_VIEWS = True
PROJECT_VIEWS_SYNC = True
PROJECT_TASKS_SYNC = True
Copy link
Member Author

Choose a reason for hiding this comment

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

should these actually be enabled by default?

Copy link
Member

Choose a reason for hiding this comment

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

I think not.


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 m2m_changed_views, project_save_views # noqa: F401
if settings.PROJECT_TASKS_SYNC:
from .handlers import m2m_changed_tasks, project_save_tasks # noqa: F401
22 changes: 22 additions & 0 deletions rdmo/projects/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,12 +160,21 @@ class Meta:
'catalog': forms.RadioSelect()
}

def save(self, *args, **kwargs):
# if the catalog is the same, do nothing
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 tried to prevent calling the save method if nothing was changed

Copy link
Member

Choose a reason for hiding this comment

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

I am not sure, but shouldn't this be handled by the UpdateView... maybe because we use only parts of the model here.

if self.instance.catalog.id == self.cleaned_data.get('catalog'):
return self.instance
return super().save(*args, **kwargs)


class ProjectUpdateTasksForm(forms.ModelForm):

use_required_attribute = False

def __init__(self, *args, **kwargs):
if settings.PROJECT_TASKS_SYNC:
raise ValidationError(_("Editing tasks is disabled."))

tasks = kwargs.pop('tasks')
super().__init__(*args, **kwargs)
self.fields['tasks'].queryset = tasks
Expand All @@ -180,12 +189,20 @@ class Meta:
'tasks': forms.CheckboxSelectMultiple()
}

def save(self, *args, **kwargs):
if settings.PROJECT_TASKS_SYNC:
raise ValidationError(_("Editing tasks is disabled."))
super().save(*args, **kwargs)


class ProjectUpdateViewsForm(forms.ModelForm):

use_required_attribute = False

def __init__(self, *args, **kwargs):
if settings.PROJECT_VIEWS_SYNC:
raise ValidationError(_("Editing views is disabled."))

views = kwargs.pop('views')
super().__init__(*args, **kwargs)
self.fields['views'].queryset = views
Expand All @@ -200,6 +217,11 @@ class Meta:
'views': forms.CheckboxSelectMultiple()
}

def save(self, *args, **kwargs):
if settings.PROJECT_VIEWS_SYNC:
raise ValidationError(_("Editing views is disabled."))
super().save(*args, **kwargs)


class ProjectUpdateParentForm(forms.ModelForm):

Expand Down
60 changes: 0 additions & 60 deletions rdmo/projects/handlers.py

This file was deleted.

Empty file.
92 changes: 92 additions & 0 deletions rdmo/projects/handlers/generic_handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
from django.contrib.auth.models import Group, User
from django.contrib.sites.models import Site

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


def m2m_catalogs_changed_projects_sync_signal_handler(instance, action, pk_set, project_field):

if action == 'post_remove' and pk_set:
related_instances = Catalog.objects.filter(pk__in=pk_set)
projects_to_change = Project.objects.filter_catalogs(catalogs=related_instances).filter(
**{project_field: instance}
)
for project in projects_to_change: # remove instance from project
getattr(project, project_field).remove(instance)

elif action == 'post_clear':
projects_to_change = Project.objects.filter(**{project_field: instance})
for project in projects_to_change: # remove instance from project
getattr(project, project_field).remove(instance)

elif action == 'post_add' and pk_set:
related_instances = Catalog.objects.filter(pk__in=pk_set)
projects_to_change = Project.objects.filter_catalogs(catalogs=related_instances).exclude(
**{project_field: instance}
)
for project in projects_to_change: # add instance to project
getattr(project, project_field).add(instance)


def m2m_sites_changed_projects_sync_signal_handler(instance, action, pk_set, project_field):

if action == 'post_remove' and pk_set:
related_sites = Site.objects.filter(pk__in=pk_set)
catalogs = instance.catalogs.all()

projects_to_change = Project.objects.filter_catalogs(catalogs=catalogs).filter(
site__in=related_sites,
**{project_field: instance}
)
for project in projects_to_change: # remove instance from project
getattr(project, project_field).remove(instance)

elif action == 'post_clear':
projects_to_change = Project.objects.filter_catalogs().filter(**{project_field: instance})
for project in projects_to_change: # remove instance from project
getattr(project, project_field).remove(instance)

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

projects_to_change = Project.objects.filter_catalogs(catalogs=catalogs).filter(
site__in=related_sites
).exclude(**{project_field: instance})
for project in projects_to_change: # add instance to project
getattr(project, project_field).add(instance)


def m2m_groups_changed_projects_sync_signal_handler(instance, action, pk_set, project_field):

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

projects_to_change = Project.objects.filter_catalogs(catalogs=catalogs).filter(
memberships__in=memberships,
**{project_field: instance}
)
for project in projects_to_change: # remove instance from project
getattr(project, project_field).remove(instance)

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

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

projects_to_change = Project.objects.filter_catalogs(catalogs=catalogs).filter(
memberships__in=memberships
).exclude(**{project_field: instance})
for project in projects_to_change: # add instance to project
getattr(project, project_field).add(instance)
25 changes: 25 additions & 0 deletions rdmo/projects/handlers/m2m_changed_tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from django.db.models.signals import m2m_changed
from django.dispatch import receiver

from rdmo.tasks.models import Task

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


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


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


@receiver(m2m_changed, sender=Task.groups.through)
def m2m_changed_task_groups_signal(sender, instance, action, pk_set, **kwargs):
m2m_groups_changed_projects_sync_signal_handler(instance, action, pk_set, 'tasks')
25 changes: 25 additions & 0 deletions rdmo/projects/handlers/m2m_changed_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from django.db.models.signals import m2m_changed
from django.dispatch import receiver

from rdmo.views.models import View

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


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


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


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

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


@receiver(pre_save, sender=Project)
def pre_save_project_sync_tasks_from_catalog(sender, instance, raw, update_fields, **kwargs):
instance._catalog_has_changed_sync_tasks = False

if raw or (update_fields and 'catalog' not in update_fields):
return

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

# Defer synchronization of views
instance._catalog_has_changed_sync_tasks = True


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

if instance._catalog_has_changed_sync_tasks or (created and not instance.tasks.exists) :
instance.tasks.set(Task.objects.filter_available_tasks_for_project(instance).values_list('id', flat=True))
31 changes: 31 additions & 0 deletions rdmo/projects/handlers/project_save_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver

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


@receiver(pre_save, sender=Project)
def pre_save_project_sync_views_from_catalog(sender, instance, raw, update_fields, **kwargs):
instance._catalog_has_changed_sync_views = False

if raw or (update_fields and 'catalog' not in update_fields):
return

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

# Defer synchronization of views
instance._catalog_has_changed_sync_views = True


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

if instance._catalog_has_changed_sync_views or (created and not instance.views.exists):
instance.views.set(View.objects.filter_available_views_for_project(instance).values_list('id', flat=True))
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 @@ -193,6 +203,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
8 changes: 8 additions & 0 deletions rdmo/projects/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,14 @@ def authors(self):
def guests(self):
return self.get_members('guest')

@cached_property
def groups_str(self):
return ', '.join(str(i) for i in self.groups if i is not None)

@property
def groups(self):
return {group for user in self.get_members('owner') for group in user.groups.all()}

@property
def file_size(self):
queryset = self.values.filter(snapshot=None).exclude(models.Q(file='') | models.Q(file=None))
Expand Down
Loading