From b3e69eaa88b3bf18b14b22229eb1b1ccf85b9abe Mon Sep 17 00:00:00 2001 From: Sylvain Boissel Date: Tue, 3 Feb 2026 19:22:09 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20reconciliation=20requests?= =?UTF-8?q?=20update=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds management of threads, comments and reactions in the reconciliation requests. --- src/backend/core/admin.py | 2 - src/backend/core/models.py | 69 ++++++++++++++++ .../tests/test_models_user_reconciliation.py | 79 +++++++++++++++++-- 3 files changed, 142 insertions(+), 8 deletions(-) diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index b9964de2..3d2b0ef7 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -2,7 +2,6 @@ from django.contrib import admin, messages from django.contrib.auth import admin as auth_admin -from django.db import transaction from django.shortcuts import redirect from django.utils.translation import gettext_lazy as _ @@ -119,7 +118,6 @@ def process_reconciliation(_modeladmin, _request, queryset): """ Admin action to process selected user reconciliations. The action will process only entries that are ready and have both emails checked. - """ processable_entries = queryset.filter( status="ready", active_email_checked=True, inactive_email_checked=True diff --git a/src/backend/core/models.py b/src/backend/core/models.py index c29e4455..78bbf3e7 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -386,6 +386,8 @@ class UserReconciliation(BaseModel): - Transfer document accesses from inactive to active user, updating roles as needed. - Transfer document favorites from inactive to active user. - Transfer link traces from inactive to active user. + - Transfer comment-related content from inactive to active user + (threads, comments and reactions) - Activate the active user and deactivate the inactive user. - Update the reconciliation entry itself. """ @@ -398,6 +400,9 @@ class UserReconciliation(BaseModel): update_favorites, removed_favorites = ( self.prepare_document_favorite_reconciliation() ) + updated_threads = self.prepare_thread_reconciliation() + updated_comments = self.prepare_comment_reconciliation() + updated_reactions, removed_reactions = self.prepare_reaction_reconciliation() self.active_user.is_active = True self.inactive_user.is_active = False @@ -419,6 +424,28 @@ class UserReconciliation(BaseModel): ids_to_delete = [entry.id for entry in removed_linktraces] LinkTrace.objects.filter(id__in=ids_to_delete).delete() + Thread.objects.bulk_update(updated_threads, ["creator"]) + Comment.objects.bulk_update(updated_comments, ["user"]) + + # pylint: disable=C0103 + ReactionThroughModel = Reaction.users.through + reactions_to_create = [] + for updated_reaction in updated_reactions: + reactions_to_create.append( + ReactionThroughModel( + user_id=self.active_user.pk, reaction_id=updated_reaction.pk + ) + ) + + if reactions_to_create: + ReactionThroughModel.objects.bulk_create(reactions_to_create) + + if removed_reactions: + ids_to_delete = [entry.id for entry in removed_reactions] + ReactionThroughModel.objects.filter( + reaction_id__in=ids_to_delete, user_id=self.inactive_user.pk + ).delete() + User.objects.bulk_update([self.active_user, self.inactive_user], ["is_active"]) # Wrap up the reconciliation entry @@ -507,6 +534,48 @@ class UserReconciliation(BaseModel): return updated_linktraces, removed_linktraces + def prepare_thread_reconciliation(self): + """ + Prepare the reconciliation by transferring threads from the inactive user + to the active user. + """ + updated_threads = [] + + inactive_threads = Thread.objects.filter(creator=self.inactive_user) + + for entry in inactive_threads: + entry.creator = self.active_user + updated_threads.append(entry) + + return updated_threads + + def prepare_comment_reconciliation(self): + """ + Prepare the reconciliation by transferring comments from the inactive user + to the active user. + """ + updated_comments = [] + + inactive_comments = Comment.objects.filter(user=self.inactive_user) + + for entry in inactive_comments: + entry.user = self.active_user + updated_comments.append(entry) + + return updated_comments + + def prepare_reaction_reconciliation(self): + """ + Prepare the reconciliation by creating missing reactions for the active user + (ie, the ones that exist for the inactive user but not the active user) + and then deleting all reactions of the inactive user. + """ + + inactive_reactions = Reaction.objects.filter(users=self.inactive_user) + updated_reactions = inactive_reactions.exclude(users=self.active_user) + + return updated_reactions, inactive_reactions + def send_reconciliation_confirm_email( self, user, user_type, confirmation_id, language=None ): diff --git a/src/backend/core/tests/test_models_user_reconciliation.py b/src/backend/core/tests/test_models_user_reconciliation.py index 070f491d..d7a063d1 100644 --- a/src/backend/core/tests/test_models_user_reconciliation.py +++ b/src/backend/core/tests/test_models_user_reconciliation.py @@ -466,8 +466,6 @@ def test_process_reconciliation_updates_linktraces( u1_2 = userdocs_u1[2] u1_5 = userdocs_u1[5] - u2doc1 = userdocs_u2[1].document - u2doc5 = userdocs_u2[5].document doc_both = u1_2.document models.LinkTrace.objects.create(document=doc_both, user=user_1) @@ -530,6 +528,71 @@ def test_process_reconciliation_updates_linktraces( ).exists() +def test_process_reconciliation_updates_threads_comments_reactions( + user_reconciliation_users_and_docs, +): + """Test that threads, comments and reactions are transferred/deduplicated + on reconciliation.""" + user_1, user_2, _userdocs_u1, userdocs_u2 = user_reconciliation_users_and_docs + + # Use a document from the inactive user's set + document = userdocs_u2[0].document + + # Thread and comment created by inactive user -> should be moved to active + thread = factories.ThreadFactory(document=document, creator=user_2) + comment = factories.CommentFactory(thread=thread, user=user_2) + + # Reaction where only inactive user reacted -> should be moved to active user + reaction_inactive_only = factories.ReactionFactory(comment=comment, users=[user_2]) + + # Reaction where both users reacted -> inactive user's participation should be removed + thread2 = factories.ThreadFactory(document=document, creator=user_1) + comment2 = factories.CommentFactory(thread=thread2, user=user_1) + reaction_both = factories.ReactionFactory(comment=comment2, users=[user_1, user_2]) + + # Reaction where only active user reacted -> unchanged + thread3 = factories.ThreadFactory(document=document, creator=user_1) + comment3 = factories.CommentFactory(thread=thread3, user=user_1) + reaction_active_only = factories.ReactionFactory(comment=comment3, users=[user_1]) + + rec = models.UserReconciliation.objects.create( + active_email=user_1.email, + inactive_email=user_2.email, + active_user=user_1, + inactive_user=user_2, + active_email_checked=True, + inactive_email_checked=True, + status="ready", + ) + + qs = models.UserReconciliation.objects.filter(id=rec.id) + process_reconciliation(None, None, qs) + + # Refresh objects + thread.refresh_from_db() + comment.refresh_from_db() + reaction_inactive_only.refresh_from_db() + reaction_both.refresh_from_db() + reaction_active_only.refresh_from_db() + + # Thread and comment creator should now be the active user + assert thread.creator == user_1 + assert comment.user == user_1 + + # reaction_inactive_only: inactive user's participation should be removed and + # active user's participation added + reaction_inactive_only.refresh_from_db() + assert not reaction_inactive_only.users.filter(pk=user_2.pk).exists() + assert reaction_inactive_only.users.filter(pk=user_1.pk).exists() + + # reaction_both: should end up with only active user's participation + assert reaction_both.users.filter(pk=user_2.pk).exists() is False + assert reaction_both.users.filter(pk=user_1.pk).exists() is True + + # reaction_active_only should still have active user's participation + assert reaction_active_only.users.filter(pk=user_1.pk).exists() + + def test_process_reconciliation_updates_favorites( user_reconciliation_users_and_docs, ): @@ -538,8 +601,6 @@ def test_process_reconciliation_updates_favorites( u1_2 = userdocs_u1[2] u1_5 = userdocs_u1[5] - u2doc1 = userdocs_u2[1].document - u2doc5 = userdocs_u2[5].document doc_both = u1_2.document models.DocumentFavorite.objects.create(document=doc_both, user=user_1) @@ -592,9 +653,15 @@ def test_process_reconciliation_updates_favorites( ) # doc_inactive_only should now be linked to active user - df = models.DocumentFavorite.objects.filter( + assert ( + models.DocumentFavorite.objects.filter( + user=user_2, document=doc_inactive_only + ).count() + == 0 + ) + assert models.DocumentFavorite.objects.filter( user=user_1, document=doc_inactive_only - ).first() + ).exists() # doc_active_only should still belong to active user assert models.DocumentFavorite.objects.filter(