diff --git a/.gitignore b/.gitignore index 13b12d84d..2d2453c3d 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ src/Dockerfile # Coverage .coverage coverage.xml + +# Uploads from some paper tests +uploads/ diff --git a/src/feed/migrations/0002_feedentry_feed_parent_lookup_idx_and_more.py b/src/feed/migrations/0002_feedentry_feed_parent_lookup_idx_and_more.py new file mode 100644 index 000000000..690f66c4c --- /dev/null +++ b/src/feed/migrations/0002_feedentry_feed_parent_lookup_idx_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 5.1.5 on 2025-01-28 23:20 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("feed", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddIndex( + model_name="feedentry", + index=models.Index( + fields=["parent_content_type", "parent_object_id"], + name="feed_parent_lookup_idx", + ), + ), + migrations.AddConstraint( + model_name="feedentry", + constraint=models.UniqueConstraint( + fields=( + "content_type", + "object_id", + "parent_content_type", + "parent_object_id", + ), + name="unique_parent_child_combination", + ), + ), + ] diff --git a/src/feed/migrations/0003_remove_feedentry_unique_parent_child_combination_and_more.py b/src/feed/migrations/0003_remove_feedentry_unique_parent_child_combination_and_more.py new file mode 100644 index 000000000..0cb08ea07 --- /dev/null +++ b/src/feed/migrations/0003_remove_feedentry_unique_parent_child_combination_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 5.1.5 on 2025-01-29 13:42 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("feed", "0002_feedentry_feed_parent_lookup_idx_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="feedentry", + name="unique_parent_child_combination", + ), + migrations.AddConstraint( + model_name="feedentry", + constraint=models.UniqueConstraint( + fields=( + "content_type", + "object_id", + "parent_content_type", + "parent_object_id", + "action", + "user", + ), + name="unique_feed_entry", + ), + ), + ] diff --git a/src/feed/models.py b/src/feed/models.py index 13a95a39d..5394ea97e 100644 --- a/src/feed/models.py +++ b/src/feed/models.py @@ -2,6 +2,7 @@ from django.contrib.contenttypes.models import ContentType from django.db import models +from researchhub.celery import app from user.models import User from utils.models import DefaultModel @@ -30,3 +31,75 @@ class FeedEntry(DefaultModel): action = models.TextField(choices=action_choices) user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True) + + class Meta: + indexes = [ + models.Index( + fields=["parent_content_type", "parent_object_id"], + name="feed_parent_lookup_idx", + ) + ] + constraints = [ + models.UniqueConstraint( + fields=[ + "content_type", + "object_id", + "parent_content_type", + "parent_object_id", + "action", + "user", + ], + name="unique_feed_entry", + ) + ] + + +@app.task +def create_feed_entry( + item_id, + item_content_type_id, + action, + parent_item_id, + parent_content_type_id, + user_id=None, +): + # Get the ContentType objects + item_content_type = ContentType.objects.get(id=item_content_type_id) + parent_content_type = ContentType.objects.get(id=parent_content_type_id) + + # Get the actual model instances + item = item_content_type.get_object_for_this_type(id=item_id) + parent_item = parent_content_type.get_object_for_this_type(id=parent_item_id) + if user_id: + user = User.objects.get(id=user_id) + else: + user = None + # Create the feed entry + FeedEntry.objects.create( + user=user, + item=item, + content_type=item_content_type, + object_id=item_id, + action=action, + parent_item=parent_item, + parent_content_type=parent_content_type, + parent_object_id=parent_item_id, + ) + + +@app.task +def delete_feed_entry( + item_id, + item_content_type_id, + parent_item_id, + parent_item_content_type_id, +): + item_content_type = ContentType.objects.get(id=item_content_type_id) + parent_item_content_type = ContentType.objects.get(id=parent_item_content_type_id) + feed_entry = FeedEntry.objects.get( + object_id=item_id, + content_type=item_content_type, + parent_object_id=parent_item_id, + parent_content_type=parent_item_content_type, + ) + feed_entry.delete() diff --git a/src/feed/tests/test_views.py b/src/feed/tests/test_views.py index 9d3d1677d..fba58b738 100644 --- a/src/feed/tests/test_views.py +++ b/src/feed/tests/test_views.py @@ -64,12 +64,15 @@ def test_feed_returns_followed_items(self): def test_feed_pagination(self): """Test feed pagination""" - for _ in range(25): + for i in range(25): + paper = Paper.objects.create( + title=f"Test Paper {i}", + ) FeedEntry.objects.create( user=self.user, action="PUBLISH", content_type=self.paper_content_type, - object_id=self.paper.id, + object_id=paper.id, parent_content_type=self.hub_content_type, parent_object_id=self.hub.id, ) diff --git a/src/paper/signals.py b/src/paper/signals.py index 8b4594057..ed47d8023 100644 --- a/src/paper/signals.py +++ b/src/paper/signals.py @@ -1,10 +1,11 @@ -from django.db.models.signals import post_delete, post_save +from django.contrib.contenttypes.models import ContentType +from django.db.models.signals import m2m_changed, post_save from django.dispatch import receiver from django.utils.crypto import get_random_string from django.utils.text import slugify -from paper.related_models.authorship_model import Authorship -from paper.related_models.citation_model import Citation +from feed.models import create_feed_entry, delete_feed_entry +from hub.models import Hub from researchhub_document.models import ResearchhubUnifiedDocument from researchhub_document.related_models.constants.document_type import ( PAPER as PAPER_DOC_TYPE, @@ -49,6 +50,47 @@ def add_unified_doc(created, instance, **kwargs): log_error("EXCPETION (add_unified_doc): ", e) +@receiver(m2m_changed, sender=Paper.hubs.through, dispatch_uid="paper_hubs_changed") +def handle_paper_hubs_changed(sender, instance, action, pk_set, **kwargs): + if action == "post_add": + for hub_id in pk_set: + if isinstance(instance, Paper): + hub = instance.hubs.get(id=hub_id) + paper = instance + else: # instance is Hub + hub = instance + paper = hub.papers.get(id=hub_id) + + create_feed_entry.apply_async( + args=( + paper.id, + ContentType.objects.get_for_model(paper).id, + "PUBLISH", + hub.id, + ContentType.objects.get_for_model(hub).id, + ), + priority=1, + ) + elif action == "post_remove": + for hub_id in pk_set: + if isinstance(instance, Paper): + hub = Hub.objects.get(id=hub_id) + paper = instance + else: # instance is Hub + hub = instance + paper = Paper.objects.get(id=hub_id) + + delete_feed_entry.apply_async( + args=( + paper.id, + ContentType.objects.get_for_model(paper).id, + hub.id, + ContentType.objects.get_for_model(hub).id, + ), + priority=1, + ) + + def check_file_updated(update_fields, file): if update_fields is not None and file: return "file" in update_fields diff --git a/src/paper/tests/test_process_openalex_works.py b/src/paper/tests/test_process_openalex_works.py index 957a307e8..69343bb1d 100644 --- a/src/paper/tests/test_process_openalex_works.py +++ b/src/paper/tests/test_process_openalex_works.py @@ -1,8 +1,11 @@ import json from unittest.mock import patch +from django.contrib.contenttypes.models import ContentType +from django.test import override_settings from rest_framework.test import APITestCase +from feed.models import FeedEntry from hub.models import Hub from paper.models import Paper from paper.openalex_util import ( @@ -394,3 +397,34 @@ def test_get_or_create_journal_hub_witn_managed_journal_hub(self): # Assert self.assertEqual(journal_hub.name, managed_journal_hub.name) + + @override_settings(CELERY_TASK_ALWAYS_EAGER=True, CELERY_TASK_EAGER_PROPAGATES=True) + @patch.object(OpenAlex, "get_authors") + def test_add_paper_to_feed(self, mock_get_authors): + with open("./paper/tests/openalex_authors.json", "r") as file: + mock_data = json.load(file) + mock_get_authors.return_value = (mock_data["results"], None) + + process_openalex_works(self.works) + + dois = [work.get("doi") for work in self.works] + dois = [doi.replace("https://doi.org/", "") for doi in dois] + + created_papers = Paper.objects.filter(doi__in=dois).order_by("doi") + self.assertEqual(len(created_papers), 2) + + for paper in created_papers: + content_type = ContentType.objects.get_for_model(Paper) + feed_entries = FeedEntry.objects.filter( + content_type=content_type, object_id=paper.id + ) + self.assertEqual(len(feed_entries), paper.hubs.count()) + self.assertEqual(feed_entries.first().action, "PUBLISH") + self.assertEqual(feed_entries.first().item, paper) + + first_hub = paper.hubs.first() + paper.hubs.remove(first_hub) + feed_entries = FeedEntry.objects.filter( + content_type=content_type, object_id=paper.id + ) + self.assertEqual(len(feed_entries), paper.hubs.count())