diff --git a/CHANGELOG.md b/CHANGELOG.md index eee6de14ea..c3a179bbbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,7 @@ and this project adheres to - ✨(helm) redirecting system #1697 - 📱(frontend) add comments for smaller device #1737 - ✨(project) add custom js support via config #1759 +- ✨(backend) manage reconciliation requests for user accounts #1708 ### Changed diff --git a/docs/env.md b/docs/env.md index 186cfb3051..bd72e41848 100644 --- a/docs/env.md +++ b/docs/env.md @@ -118,6 +118,7 @@ These are the environment variables you can set for the `impress-backend` contai | THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json | | TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 | | USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] | +| USER_RECONCILIATION_FORM_URL | URL of a third-party form for user reconciliation requests | | | Y_PROVIDER_API_BASE_URL | Y Provider url | | | Y_PROVIDER_API_KEY | Y provider API key | | diff --git a/docs/user_account_reconciliation.md b/docs/user_account_reconciliation.md new file mode 100644 index 0000000000..dce16c0ae3 --- /dev/null +++ b/docs/user_account_reconciliation.md @@ -0,0 +1,19 @@ +# User account reconciliation + +It is possible to merge user accounts based on their email addresses. + +Docs does not have an internal process to requests, but it allows the import of a CSV from an external form +(e.g. made with Grist) in the Django admin panel (in "Core" > "User reconciliation CSV imports" > "Add user reconciliation") + +The CSV must contain the following mandatory columns: + +- `active_email`: the email of the user that will remain active after the process. +- `inactive_email`: the email of the user(s) that will be merged into the active user. It is possible to indicate several emails, so the user only has to make one request even if they have more than two accounts. +- `id`: a unique row id, so that entries already processed in a previous import are ignored. + +The following columns are optional: `active_email_checked` and `inactive_email_checked` (both must contain `0` (False) or `1` (True), and both default to False.) +If present, it allows to indicate that the source form has a way to validate that the user making the request actually controls the email addresses, skipping the need to send confirmation emails (cf. below) + +Once the CSV file is processed, this will create entries in "Core" > "User reconciliations" and send verification emails to validate that the user making the request actually controls the email addresses (unless `active_email_checked` and `inactive_email_checked` were set to `1` in the CSV) + +In "Core" > "User reconciliations", an admin can then select all rows they wish to process and check the action "Process selected user reconciliations". Only rows that have the status `ready` and for which both emails have been validated will be processed. diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index c6de806710..3d2b0ef786 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -1,12 +1,14 @@ """Admin classes and registrations for core app.""" -from django.contrib import admin +from django.contrib import admin, messages from django.contrib.auth import admin as auth_admin +from django.shortcuts import redirect from django.utils.translation import gettext_lazy as _ from treebeard.admin import TreeAdmin -from . import models +from core import models +from core.tasks.user_reconciliation import user_reconciliation_csv_import_job @admin.register(models.User) @@ -95,6 +97,44 @@ class UserAdmin(auth_admin.UserAdmin): search_fields = ("id", "sub", "admin_email", "email", "full_name") +@admin.register(models.UserReconciliationCsvImport) +class UserReconciliationCsvImportAdmin(admin.ModelAdmin): + """Admin class for UserReconciliationCsvImport model.""" + + list_display = ("id", "__str__", "created_at", "status") + + def save_model(self, request, obj, form, change): + """Override save_model to trigger the import task on creation.""" + super().save_model(request, obj, form, change) + + if not change: + user_reconciliation_csv_import_job.delay(obj.pk) + messages.success(request, _("Import job created and queued.")) + return redirect("..") + + +@admin.action(description=_("Process selected user reconciliations")) +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 + ) + + for entry in processable_entries: + entry.process_reconciliation_request() + + +@admin.register(models.UserReconciliation) +class UserReconciliationAdmin(admin.ModelAdmin): + """Admin class for UserReconciliation model.""" + + list_display = ["id", "__str__", "created_at", "status"] + actions = [process_reconciliation] + + class DocumentAccessInline(admin.TabularInline): """Inline admin class for document accesses.""" diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 7432004d9e..d0fbc2c85d 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -40,6 +40,8 @@ from rest_framework import filters, status, viewsets from rest_framework import response as drf_response from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView from core import authentication, choices, enums, models from core.api.filters import remove_accents @@ -249,6 +251,59 @@ def get_me(self, request): ) +class ReconciliationConfirmView(APIView): + """API endpoint to confirm user reconciliation emails. + + GET /user-reconciliations/{user_type}/{confirmation_id}/ + Marks `active_email_checked` or `inactive_email_checked` to True. + """ + + permission_classes = [AllowAny] + + def get(self, request, user_type, confirmation_id): + """ + Check the confirmation ID and mark the corresponding email as checked. + """ + try: + # validate UUID + uuid_obj = uuid.UUID(str(confirmation_id)) + except ValueError: + return Response( + {"detail": "Badly formatted confirmation id"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if user_type not in ("active", "inactive"): + return Response( + {"detail": "Invalid user_type"}, status=status.HTTP_400_BAD_REQUEST + ) + + lookup = ( + {"active_email_confirmation_id": uuid_obj} + if user_type == "active" + else {"inactive_email_confirmation_id": uuid_obj} + ) + + try: + rec = models.UserReconciliation.objects.get(**lookup) + except models.UserReconciliation.DoesNotExist: + return Response( + {"detail": "Reconciliation entry not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + field_name = ( + "active_email_checked" + if user_type == "active" + else "inactive_email_checked" + ) + if not getattr(rec, field_name): + setattr(rec, field_name, True) + rec.save() + + return Response({"detail": "Confirmation received"}) + + class ResourceAccessViewsetMixin: """Mixin with methods common to all access viewsets.""" diff --git a/src/backend/core/migrations/0029_userreconciliationcsvimport_userreconciliation.py b/src/backend/core/migrations/0029_userreconciliationcsvimport_userreconciliation.py new file mode 100644 index 0000000000..f9aefee063 --- /dev/null +++ b/src/backend/core/migrations/0029_userreconciliationcsvimport_userreconciliation.py @@ -0,0 +1,178 @@ +# Generated by Django 5.2.10 on 2026-02-02 16:58 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0028_remove_templateaccess_template_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="UserReconciliationCsvImport", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="primary key for the record as UUID", + primary_key=True, + serialize=False, + verbose_name="id", + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, + help_text="date and time at which a record was created", + verbose_name="created on", + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, + help_text="date and time at which a record was last updated", + verbose_name="updated on", + ), + ), + ( + "file", + models.FileField(upload_to="imports/", verbose_name="CSV file"), + ), + ( + "status", + models.CharField( + choices=[ + ("pending", "Pending"), + ("running", "Running"), + ("done", "Done"), + ("error", "Error"), + ], + default="pending", + max_length=20, + ), + ), + ("logs", models.TextField(blank=True)), + ], + options={ + "verbose_name": "user reconciliation CSV import", + "verbose_name_plural": "user reconciliation CSV imports", + "db_table": "impress_user_reconciliation_csv_import", + }, + ), + migrations.CreateModel( + name="UserReconciliation", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="primary key for the record as UUID", + primary_key=True, + serialize=False, + verbose_name="id", + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, + help_text="date and time at which a record was created", + verbose_name="created on", + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, + help_text="date and time at which a record was last updated", + verbose_name="updated on", + ), + ), + ( + "active_email", + models.EmailField( + max_length=254, verbose_name="Active email address" + ), + ), + ( + "inactive_email", + models.EmailField( + max_length=254, verbose_name="Email address to deactivate" + ), + ), + ("active_email_checked", models.BooleanField(default=False)), + ("inactive_email_checked", models.BooleanField(default=False)), + ( + "active_email_confirmation_id", + models.UUIDField( + default=uuid.uuid4, editable=False, null=True, unique=True + ), + ), + ( + "inactive_email_confirmation_id", + models.UUIDField( + default=uuid.uuid4, editable=False, null=True, unique=True + ), + ), + ( + "source_unique_id", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="Unique ID in the source file", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("pending", "Pending"), + ("ready", "Ready"), + ("done", "Done"), + ("error", "Error"), + ], + default="pending", + max_length=20, + ), + ), + ("logs", models.TextField(blank=True)), + ( + "active_user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="active_user", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "inactive_user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="inactive_user", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "user reconciliation", + "verbose_name_plural": "user reconciliations", + "db_table": "impress_user_reconciliation", + "ordering": ["-created_at"], + }, + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 71abd253a3..78bbf3e7c4 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -33,14 +33,14 @@ from timezone_field import TimeZoneField from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet -from .choices import ( +from core.choices import ( PRIVILEGED_ROLES, LinkReachChoices, LinkRoleChoices, RoleChoices, get_equivalent_link_definition, ) -from .validators import sub_validator +from core.validators import sub_validator logger = getLogger(__name__) @@ -251,6 +251,37 @@ def _convert_valid_invitations(self): valid_invitations.delete() + def send_email(self, subject, context=None, language=None): + """Generate and send email to the user from a template.""" + emails = [self.email] + context = context or {} + domain = Site.objects.get_current().domain + language = language or get_language() + context.update( + { + "brandname": settings.EMAIL_BRAND_NAME, + "domain": domain, + "logo_img": settings.EMAIL_LOGO_IMG, + } + ) + + with override(language): + msg_html = render_to_string("mail/html/template.html", context) + msg_plain = render_to_string("mail/text/template.txt", context) + subject = str(subject) # Force translation + + try: + send_mail( + subject.capitalize(), + msg_plain, + settings.EMAIL_FROM, + emails, + html_message=msg_html, + fail_silently=False, + ) + except smtplib.SMTPException as exception: + logger.error("invitation to %s was not sent: %s", emails, exception) + def email_user(self, subject, message, from_email=None, **kwargs): """Email this user.""" if not self.email: @@ -266,6 +297,417 @@ def teams(self): return [] +class UserReconciliation(BaseModel): + """Model to run batch jobs to replace an active user by another one""" + + active_email = models.EmailField(_("Active email address")) + inactive_email = models.EmailField(_("Email address to deactivate")) + active_email_checked = models.BooleanField(default=False) + inactive_email_checked = models.BooleanField(default=False) + active_user = models.ForeignKey( + User, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="active_user", + ) + inactive_user = models.ForeignKey( + User, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="inactive_user", + ) + active_email_confirmation_id = models.UUIDField( + default=uuid.uuid4, unique=True, editable=False, null=True + ) + inactive_email_confirmation_id = models.UUIDField( + default=uuid.uuid4, unique=True, editable=False, null=True + ) + source_unique_id = models.CharField( + max_length=100, + blank=True, + null=True, + verbose_name=_("Unique ID in the source file"), + ) + + status = models.CharField( + max_length=20, + choices=[ + ("pending", _("Pending")), + ("ready", _("Ready")), + ("done", _("Done")), + ("error", _("Error")), + ], + default="pending", + ) + logs = models.TextField(blank=True) + + class Meta: + db_table = "impress_user_reconciliation" + verbose_name = _("user reconciliation") + verbose_name_plural = _("user reconciliations") + ordering = ["-created_at"] + + def __str__(self): + return f"Reconciliation from {self.inactive_email} to {self.active_email}" + + def save(self, *args, **kwargs): + """ + For pending queries, identify the actual users and send validation emails + """ + if self.status == "pending": + self.active_user = User.objects.filter(email=self.active_email).first() + self.inactive_user = User.objects.filter(email=self.inactive_email).first() + + if self.active_user and self.inactive_user: + if not self.active_email_checked: + self.send_reconciliation_confirm_email( + self.active_user, "active", self.active_email_confirmation_id + ) + if not self.inactive_email_checked: + self.send_reconciliation_confirm_email( + self.inactive_user, + "inactive", + self.inactive_email_confirmation_id, + ) + self.status = "ready" + else: + self.status = "error" + self.logs = "Error: Both active and inactive users need to exist." + + super().save(*args, **kwargs) + + @transaction.atomic + def process_reconciliation_request(self): + """ + Process the reconciliation request as a transaction. + + - 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. + """ + + # Prepare the data to perform the reconciliation on + updated_accesses, removed_accesses = ( + self.prepare_documentaccess_reconciliation() + ) + updated_linktraces, removed_linktraces = self.prepare_linktrace_reconciliation() + 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 + + # Actually perform the bulk operations + DocumentAccess.objects.bulk_update(updated_accesses, ["user", "role"]) + + if removed_accesses: + ids_to_delete = [entry.id for entry in removed_accesses] + DocumentAccess.objects.filter(id__in=ids_to_delete).delete() + + DocumentFavorite.objects.bulk_update(update_favorites, ["user"]) + if removed_favorites: + ids_to_delete = [entry.id for entry in removed_favorites] + DocumentFavorite.objects.filter(id__in=ids_to_delete).delete() + + LinkTrace.objects.bulk_update(updated_linktraces, ["user"]) + if removed_linktraces: + 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 + self.logs += f"""Requested update for {len(updated_accesses)} DocumentAccess items + and deletion for {len(removed_accesses)} DocumentAccess items.\n""" + self.status = "done" + self.save() + + self.send_reconciliation_done_email() + + def prepare_documentaccess_reconciliation(self): + """ + Prepare the reconciliation by transferring document accesses from the inactive user + to the active user. + """ + updated_accesses = [] + removed_accesses = [] + inactive_accesses = DocumentAccess.objects.filter(user=self.inactive_user) + + # Check documents where the active user already has access + inactive_accesses_documents = inactive_accesses.values_list( + "document", flat=True + ) + existing_accesses = DocumentAccess.objects.filter(user=self.active_user).filter( + document__in=inactive_accesses_documents + ) + existing_roles_per_doc = dict(existing_accesses.values_list("document", "role")) + + for entry in inactive_accesses: + if entry.document_id in existing_roles_per_doc: + # Update role if needed + existing_role = existing_roles_per_doc[entry.document_id] + max_role = RoleChoices.max(entry.role, existing_role) + if existing_role != max_role: + existing_access = existing_accesses.get(document=entry.document) + existing_access.role = max_role + updated_accesses.append(existing_access) + removed_accesses.append(entry) + else: + entry.user = self.active_user + updated_accesses.append(entry) + + return updated_accesses, removed_accesses + + def prepare_document_favorite_reconciliation(self): + """ + Prepare the reconciliation by transferring document favorites from the inactive user + to the active user. + """ + updated_favorites = [] + removed_favorites = [] + + existing_favorites = DocumentFavorite.objects.filter(user=self.active_user) + existing_favorite_doc_ids = set( + existing_favorites.values_list("document_id", flat=True) + ) + + inactive_favorites = DocumentFavorite.objects.filter(user=self.inactive_user) + + for entry in inactive_favorites: + if entry.document_id in existing_favorite_doc_ids: + removed_favorites.append(entry) + else: + entry.user = self.active_user + updated_favorites.append(entry) + + return updated_favorites, removed_favorites + + def prepare_linktrace_reconciliation(self): + """ + Prepare the reconciliation by transferring link traces from the inactive user + to the active user. + """ + updated_linktraces = [] + removed_linktraces = [] + + existing_linktraces = LinkTrace.objects.filter(user=self.active_user) + inactive_linktraces = LinkTrace.objects.filter(user=self.inactive_user) + + for entry in inactive_linktraces: + if existing_linktraces.filter(document=entry.document).exists(): + removed_linktraces.append(entry) + else: + entry.user = self.active_user + updated_linktraces.append(entry) + + 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 + ): + """Method allowing to send confirmation email for reconciliation requests.""" + language = language or get_language() + domain = Site.objects.get_current().domain + + message = _( + """You have requested a reconciliation of your user accounts on Docs. + To confirm that you are the one who initiated the request + and that this email belongs to you:""" + ) + + with override(language): + subject = _("Confirm by clicking the link to start the reconciliation") + context = { + "title": subject, + "message": message, + "link": f"{domain}/user-reconciliations/{user_type}/{confirmation_id}/", + "link_label": str(_("Click here")), + "button_label": str(_("Confirm")), + } + + user.send_email(subject, context, language) + + def send_reconciliation_done_email(self, language=None): + """Method allowing to send done email for reconciliation requests.""" + language = language or get_language() + domain = Site.objects.get_current().domain + + message = _( + """Your reconciliation request has been processed. + New documents are likely associated with your account:""" + ) + + with override(language): + subject = _("Your accounts have been merged") + context = { + "title": subject, + "message": message, + "link": f"{domain}/", + "link_label": str(_("Click here to see")), + "button_label": str(_("See my documents")), + } + + self.active_user.send_email(subject, context, language) + + +class UserReconciliationCsvImport(BaseModel): + """Model to import reconciliations requests from an external source + (eg, )""" + + file = models.FileField(upload_to="imports/", verbose_name=_("CSV file")) + status = models.CharField( + max_length=20, + choices=[ + ("pending", _("Pending")), + ("running", _("Running")), + ("done", _("Done")), + ("error", _("Error")), + ], + default="pending", + ) + logs = models.TextField(blank=True) + + class Meta: + db_table = "impress_user_reconciliation_csv_import" + verbose_name = _("user reconciliation CSV import") + verbose_name_plural = _("user reconciliation CSV imports") + + def __str__(self): + return f"User reconciliation CSV import {self.id}" + + def send_email(self, subject, emails, context=None, language=None): + """Generate and send email to the user from a template.""" + context = context or {} + domain = Site.objects.get_current().domain + language = language or get_language() + context.update( + { + "brandname": settings.EMAIL_BRAND_NAME, + "domain": domain, + "logo_img": settings.EMAIL_LOGO_IMG, + } + ) + + with override(language): + msg_html = render_to_string("mail/html/template.html", context) + msg_plain = render_to_string("mail/text/template.txt", context) + subject = str(subject) # Force translation + + try: + send_mail( + subject.capitalize(), + msg_plain, + settings.EMAIL_FROM, + emails, + html_message=msg_html, + fail_silently=False, + ) + except smtplib.SMTPException as exception: + logger.error("invitation to %s was not sent: %s", emails, exception) + + def send_reconciliation_error_email( + self, recipient_email, other_email, language=None + ): + """Method allowing to send email for reconciliation requests with errors.""" + language = language or get_language() + + emails = [recipient_email] + + message = _( + """Your request for reconciliation was unsuccessful. + Reconciliation failed for the following email addresses: + {recipient_email}, {other_email}. + Please check for typos. + You can submit another request with the valid email addresses.""" + ).format(recipient_email=recipient_email, other_email=other_email) + + with override(language): + subject = _("Reconciliation of your Docs accounts not completed") + context = { + "title": subject, + "message": message, + "link": settings.USER_RECONCILIATION_FORM_URL, + "link_label": str(_("Click here")), + "button_label": str(_("Make a new request")), + } + + self.send_email(subject, emails, context, language) + + class BaseAccess(BaseModel): """Base model for accesses to handle resources.""" diff --git a/src/backend/core/tasks/user_reconciliation.py b/src/backend/core/tasks/user_reconciliation.py new file mode 100644 index 0000000000..364a628d60 --- /dev/null +++ b/src/backend/core/tasks/user_reconciliation.py @@ -0,0 +1,135 @@ +"""Processing tasks for user reconciliation CSV imports.""" + +import csv +import traceback +import uuid + +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from django.db import IntegrityError + +from botocore.exceptions import ClientError + +from core.models import UserReconciliation, UserReconciliationCsvImport + +from impress.celery_app import app + + +def _process_row(row, job, counters): + """Process a single row from the CSV file.""" + + source_unique_id = row["id"].strip() + + # Skip entries if they already exist with this source_unique_id + if UserReconciliation.objects.filter(source_unique_id=source_unique_id).exists(): + counters["already_processed_source_ids"] += 1 + return counters + + active_email_checked = row.get("active_email_checked", "0") == "1" + inactive_email_checked = row.get("inactive_email_checked", "0") == "1" + + active_email = row["active_email"] + inactive_emails = row["inactive_email"].split("|") + try: + validate_email(active_email) + except ValidationError: + job.send_reconciliation_error_email( + recipient_email=inactive_emails[0], other_email=active_email + ) + job.logs += f"Invalid active email address on row {source_unique_id}." + counters["rows_with_errors"] += 1 + return counters + + for inactive_email in inactive_emails: + try: + validate_email(inactive_email) + except (ValidationError, ValueError): + job.send_reconciliation_error_email( + recipient_email=active_email, other_email=inactive_email + ) + job.logs += f"Invalid inactive email address on row {source_unique_id}.\n" + counters["rows_with_errors"] += 1 + continue + + if inactive_email == active_email: + job.send_reconciliation_error_email( + recipient_email=active_email, other_email=inactive_email + ) + job.logs += ( + f"Error on row {source_unique_id}: " + f"{active_email} set as both active and inactive email.\n" + ) + counters["rows_with_errors"] += 1 + continue + + _rec_entry = UserReconciliation.objects.create( + active_email=active_email, + inactive_email=inactive_email, + active_email_checked=active_email_checked, + inactive_email_checked=inactive_email_checked, + active_email_confirmation_id=uuid.uuid4(), + inactive_email_confirmation_id=uuid.uuid4(), + source_unique_id=source_unique_id, + status="pending", + ) + counters["rec_entries_created"] += 1 + + return counters + + +@app.task +def user_reconciliation_csv_import_job(job_id): + """Process a UserReconciliationCsvImport job. + Creates UserReconciliation entries from the CSV file. + + Does some sanity checks on the data: + - active_email and inactive_email must be valid email addresses + - active_email and inactive_email cannot be the same + + Rows with errors are logged in the job logs and skipped, but do not cause + the entire job to fail or prevent the next rows from being processed. + """ + # Imports the CSV file, breaks it into UserReconciliation items + job = UserReconciliationCsvImport.objects.get(id=job_id) + job.status = "running" + job.save() + + counters = { + "rec_entries_created": 0, + "rows_with_errors": 0, + "already_processed_source_ids": 0, + } + + try: + with job.file.open(mode="r") as f: + reader = csv.DictReader(f) + + if not {"active_email", "inactive_email", "id"}.issubset(reader.fieldnames): + raise KeyError( + "CSV is missing mandatory columns: active_email, inactive_email, id" + ) + + for row in reader: + counters = _process_row(row, job, counters) + + job.status = "done" + job.logs += ( + f"Import completed successfully. {reader.line_num} rows processed." + f" {counters['rec_entries_created']} reconciliation entries created." + f" {counters['already_processed_source_ids']} rows were already processed." + f" {counters['rows_with_errors']} rows had errors." + ) + except ( + csv.Error, + KeyError, + ValidationError, + ValueError, + IntegrityError, + OSError, + ClientError, + ) as e: + # Catch expected I/O/CSV/model errors and record traceback in logs for debugging + job.status = "error" + job.logs += f"{e!s}\n{traceback.format_exc()}" + finally: + job.save() diff --git a/src/backend/core/tests/data/example_reconciliation_basic.csv b/src/backend/core/tests/data/example_reconciliation_basic.csv new file mode 100644 index 0000000000..7e9f7fd727 --- /dev/null +++ b/src/backend/core/tests/data/example_reconciliation_basic.csv @@ -0,0 +1,6 @@ +active_email,inactive_email,active_email_checked,inactive_email_checked,status,id +"user.test40@example.com","user.test41@example.com",0,0,pending,1 +"user.test42@example.com","user.test43@example.com",0,1,pending,2 +"user.test44@example.com","user.test45@example.com",1,0,pending,3 +"user.test46@example.com","user.test47@example.com",1,1,pending,4 +"user.test48@example.com","user.test49@example.com",1,1,pending,5 \ No newline at end of file diff --git a/src/backend/core/tests/data/example_reconciliation_error.csv b/src/backend/core/tests/data/example_reconciliation_error.csv new file mode 100644 index 0000000000..2ac7939616 --- /dev/null +++ b/src/backend/core/tests/data/example_reconciliation_error.csv @@ -0,0 +1,2 @@ +active_email,inactive_email,active_email_checked,inactive_email_checked,status,id +"user.test40@example.com",,0,0,pending,40 diff --git a/src/backend/core/tests/data/example_reconciliation_grist_form.csv b/src/backend/core/tests/data/example_reconciliation_grist_form.csv new file mode 100644 index 0000000000..b92fe03679 --- /dev/null +++ b/src/backend/core/tests/data/example_reconciliation_grist_form.csv @@ -0,0 +1,5 @@ +merge_accept,active_email,inactive_email,status,id +true,user.test10@example.com,user.test11@example.com|user.test12@example.com,pending,10 +true,user.test30@example.com,user.test31@example.com|user.test32@example.com|user.test33@example.com|user.test34@example.com|user.test35@example.com,pending,11 +true,user.test20@example.com,user.test21@example.com,pending,12 +true,user.test22@example.com,user.test23@example.com,pending,13 \ No newline at end of file diff --git a/src/backend/core/tests/data/example_reconciliation_grist_form_error.csv b/src/backend/core/tests/data/example_reconciliation_grist_form_error.csv new file mode 100644 index 0000000000..86d92ca31b --- /dev/null +++ b/src/backend/core/tests/data/example_reconciliation_grist_form_error.csv @@ -0,0 +1,2 @@ +merge_accept,active_email,inactive_email,status,id +true,user.test20@example.com,user.test20@example.com,pending,20 diff --git a/src/backend/core/tests/data/example_reconciliation_missing_column.csv b/src/backend/core/tests/data/example_reconciliation_missing_column.csv new file mode 100644 index 0000000000..2f3450d712 --- /dev/null +++ b/src/backend/core/tests/data/example_reconciliation_missing_column.csv @@ -0,0 +1,6 @@ +active_email,inactive_email,active_email_checked,inactive_email_checked,status +"user.test40@example.com","user.test41@example.com",0,0,pending +"user.test42@example.com","user.test43@example.com",0,1,pending +"user.test44@example.com","user.test45@example.com",1,0,pending +"user.test46@example.com","user.test47@example.com",1,1,pending +"user.test48@example.com","user.test49@example.com",1,1,pending \ No newline at end of file diff --git a/src/backend/core/tests/test_api_user_reconciliation.py b/src/backend/core/tests/test_api_user_reconciliation.py new file mode 100644 index 0000000000..8f2bd1b8b4 --- /dev/null +++ b/src/backend/core/tests/test_api_user_reconciliation.py @@ -0,0 +1,85 @@ +""" +Unit tests for the ReconciliationConfirmView API view. +""" + +import uuid + +from django.conf import settings + +import pytest +from rest_framework.test import APIClient + +from core import factories, models + +pytestmark = pytest.mark.django_db + + +def test_reconciliation_confirm_view_sets_active_checked(): + """GETting the active confirmation endpoint should set active_email_checked.""" + user = factories.UserFactory(email="user.confirm1@example.com") + other = factories.UserFactory(email="user.confirm2@example.com") + rec = models.UserReconciliation.objects.create( + active_email=user.email, + inactive_email=other.email, + active_user=user, + inactive_user=other, + active_email_checked=False, + inactive_email_checked=False, + status="ready", + ) + + client = APIClient() + conf_id = rec.active_email_confirmation_id + url = f"/api/{settings.API_VERSION}/user-reconciliations/active/{conf_id}/" + resp = client.get(url) + assert resp.status_code == 200 + assert resp.json() == {"detail": "Confirmation received"} + + rec.refresh_from_db() + assert rec.active_email_checked is True + + +def test_reconciliation_confirm_view_sets_inactive_checked(): + """GETting the inactive confirmation endpoint should set inactive_email_checked.""" + user = factories.UserFactory(email="user.confirm3@example.com") + other = factories.UserFactory(email="user.confirm4@example.com") + rec = models.UserReconciliation.objects.create( + active_email=user.email, + inactive_email=other.email, + active_user=user, + inactive_user=other, + active_email_checked=False, + inactive_email_checked=False, + status="ready", + ) + + client = APIClient() + conf_id = rec.inactive_email_confirmation_id + url = f"/api/{settings.API_VERSION}/user-reconciliations/inactive/{conf_id}/" + resp = client.get(url) + assert resp.status_code == 200 + assert resp.json() == {"detail": "Confirmation received"} + + rec.refresh_from_db() + assert rec.inactive_email_checked is True + + +def test_reconciliation_confirm_view_invalid_user_type_returns_400(): + """GETting with an invalid user_type should return 400.""" + client = APIClient() + # Use a valid uuid format but invalid user_type + + url = f"/api/{settings.API_VERSION}/user-reconciliations/other/{uuid.uuid4()}/" + resp = client.get(url) + assert resp.status_code == 400 + assert resp.json() == {"detail": "Invalid user_type"} + + +def test_reconciliation_confirm_view_not_found_returns_404(): + """GETting with a non-existing confirmation_id should return 404.""" + client = APIClient() + + url = f"/api/{settings.API_VERSION}/user-reconciliations/active/{uuid.uuid4()}/" + resp = client.get(url) + assert resp.status_code == 404 + assert resp.json() == {"detail": "Reconciliation entry not found"} diff --git a/src/backend/core/tests/test_models_user_reconciliation.py b/src/backend/core/tests/test_models_user_reconciliation.py new file mode 100644 index 0000000000..d7a063d1a4 --- /dev/null +++ b/src/backend/core/tests/test_models_user_reconciliation.py @@ -0,0 +1,669 @@ +""" +Unit tests for the UserReconciliationCsvImport model +""" + +import uuid +from pathlib import Path + +from django.core import mail +from django.core.files.base import ContentFile + +import pytest + +from core import factories, models +from core.admin import process_reconciliation +from core.tasks.user_reconciliation import user_reconciliation_csv_import_job + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(name="import_example_csv_basic") +def fixture_import_example_csv_basic(): + """ + Import an example CSV file for user reconciliation + and return the created import object. + """ + # Create users referenced in the CSV + for i in range(40, 50): + factories.UserFactory(email=f"user.test{i}@example.com") + + example_csv_path = Path(__file__).parent / "data/example_reconciliation_basic.csv" + with open(example_csv_path, "rb") as f: + csv_file = ContentFile(f.read(), name="example_reconciliation_basic.csv") + csv_import = models.UserReconciliationCsvImport(file=csv_file) + csv_import.save() + + return csv_import + + +@pytest.fixture(name="import_example_csv_grist_form") +def fixture_import_example_csv_grist_form(): + """ + Import an example CSV file for user reconciliation + and return the created import object. + """ + # Create users referenced in the CSV + for i in range(10, 40): + factories.UserFactory(email=f"user.test{i}@example.com") + + example_csv_path = ( + Path(__file__).parent / "data/example_reconciliation_grist_form.csv" + ) + with open(example_csv_path, "rb") as f: + csv_file = ContentFile(f.read(), name="example_reconciliation_grist_form.csv") + csv_import = models.UserReconciliationCsvImport(file=csv_file) + csv_import.save() + + return csv_import + + +def test_user_reconciliation_csv_import_entry_is_created(import_example_csv_basic): + """Test that a UserReconciliationCsvImport entry is created correctly.""" + assert import_example_csv_basic.status == "pending" + assert import_example_csv_basic.file.name.endswith( + "example_reconciliation_basic.csv" + ) + + +def test_user_reconciliation_csv_import_entry_is_created_grist_form( + import_example_csv_grist_form, +): + """Test that a UserReconciliationCsvImport entry is created correctly.""" + assert import_example_csv_grist_form.status == "pending" + assert import_example_csv_grist_form.file.name.endswith( + "example_reconciliation_grist_form.csv" + ) + + +def test_incorrect_csv_format_handling(): + """Test that an incorrectly formatted CSV file is handled gracefully.""" + example_csv_path = ( + Path(__file__).parent / "data/example_reconciliation_missing_column.csv" + ) + with open(example_csv_path, "rb") as f: + csv_file = ContentFile( + f.read(), name="example_reconciliation_missing_column.csv" + ) + csv_import = models.UserReconciliationCsvImport(file=csv_file) + csv_import.save() + + assert csv_import.status == "pending" + + user_reconciliation_csv_import_job(csv_import.id) + csv_import.refresh_from_db() + + assert ( + "CSV is missing mandatory columns: active_email, inactive_email, id" + in csv_import.logs + ) + assert csv_import.status == "error" + + +def test_incorrect_email_format_handling(): + """Test that an incorrectly formatted CSV file is handled gracefully.""" + example_csv_path = Path(__file__).parent / "data/example_reconciliation_error.csv" + with open(example_csv_path, "rb") as f: + csv_file = ContentFile(f.read(), name="example_reconciliation_error.csv") + csv_import = models.UserReconciliationCsvImport(file=csv_file) + csv_import.save() + + assert csv_import.status == "pending" + + user_reconciliation_csv_import_job(csv_import.id) + csv_import.refresh_from_db() + + assert "Invalid inactive email address on row 40" in csv_import.logs + assert csv_import.status == "done" + + # pylint: disable-next=no-member + assert len(mail.outbox) == 1 + + # pylint: disable-next=no-member + email = mail.outbox[0] + + assert email.to == ["user.test40@example.com"] + email_content = " ".join(email.body.split()) + + assert "Reconciliation of your Docs accounts not completed" in email_content + + +def test_incorrect_csv_data_handling_grist_form(): + """Test that a CSV file with incorrect data is handled gracefully.""" + example_csv_path = ( + Path(__file__).parent / "data/example_reconciliation_grist_form_error.csv" + ) + with open(example_csv_path, "rb") as f: + csv_file = ContentFile( + f.read(), name="example_reconciliation_grist_form_error.csv" + ) + csv_import = models.UserReconciliationCsvImport(file=csv_file) + csv_import.save() + + assert csv_import.status == "pending" + + user_reconciliation_csv_import_job(csv_import.id) + csv_import.refresh_from_db() + + assert ( + "user.test20@example.com set as both active and inactive email" + in csv_import.logs + ) + assert csv_import.status == "done" + + +def test_job_creates_reconciliation_entries(import_example_csv_basic): + """Test that the CSV import job creates UserReconciliation entries.""" + assert import_example_csv_basic.status == "pending" + user_reconciliation_csv_import_job(import_example_csv_basic.id) + + # Verify the job status changed + import_example_csv_basic.refresh_from_db() + assert import_example_csv_basic.status == "done" + assert "Import completed successfully." in import_example_csv_basic.logs + assert "6 rows processed." in import_example_csv_basic.logs + assert "5 reconciliation entries created." in import_example_csv_basic.logs + + # Verify reconciliation entries were created + reconciliations = models.UserReconciliation.objects.all() + assert reconciliations.count() == 5 + + +def test_job_does_not_create_duplicated_reconciliation_entries( + import_example_csv_basic, +): + """Test that the CSV import job doesn't create UserReconciliation entries + for source unique IDs that have already been processed.""" + + _already_created_entry = models.UserReconciliation.objects.create( + active_email="user.test40@example.com", + inactive_email="user.test41@example.com", + active_email_checked=0, + inactive_email_checked=0, + status="pending", + source_unique_id=1, + ) + + assert import_example_csv_basic.status == "pending" + user_reconciliation_csv_import_job(import_example_csv_basic.id) + + # Verify the job status changed + import_example_csv_basic.refresh_from_db() + assert import_example_csv_basic.status == "done" + assert "Import completed successfully." in import_example_csv_basic.logs + assert "6 rows processed." in import_example_csv_basic.logs + assert "4 reconciliation entries created." in import_example_csv_basic.logs + assert "1 rows were already processed." in import_example_csv_basic.logs + + # Verify the correct number of reconciliation entries were created + reconciliations = models.UserReconciliation.objects.all() + assert reconciliations.count() == 5 + + +def test_job_creates_reconciliation_entries_grist_form(import_example_csv_grist_form): + """Test that the CSV import job creates UserReconciliation entries.""" + assert import_example_csv_grist_form.status == "pending" + user_reconciliation_csv_import_job(import_example_csv_grist_form.id) + + # Verify the job status changed + import_example_csv_grist_form.refresh_from_db() + assert "Import completed successfully" in import_example_csv_grist_form.logs + assert import_example_csv_grist_form.status == "done" + + # Verify reconciliation entries were created + reconciliations = models.UserReconciliation.objects.all() + assert reconciliations.count() == 9 + + +def test_csv_import_reconciliation_data_is_correct(import_example_csv_basic): + """Test that the data in created UserReconciliation entries matches the CSV.""" + user_reconciliation_csv_import_job(import_example_csv_basic.id) + + reconciliations = models.UserReconciliation.objects.order_by("created_at") + first_entry = reconciliations.first() + + assert first_entry.active_email == "user.test40@example.com" + assert first_entry.inactive_email == "user.test41@example.com" + assert first_entry.active_email_checked is False + assert first_entry.inactive_email_checked is False + + for rec in reconciliations: + assert rec.status == "ready" + + +@pytest.fixture(name="user_reconciliation_users_and_docs") +def fixture_user_reconciliation_users_and_docs(): + """Fixture to create two users with overlapping document accesses + for reconciliation tests.""" + user_1 = factories.UserFactory(email="user.test1@example.com") + user_2 = factories.UserFactory(email="user.test2@example.com") + + # Create 10 distinct document accesses for each user + userdocs_u1 = [ + factories.UserDocumentAccessFactory(user=user_1, role="editor") + for _ in range(10) + ] + userdocs_u2 = [ + factories.UserDocumentAccessFactory(user=user_2, role="editor") + for _ in range(10) + ] + + # Make the first 3 documents of each list shared with the other user + # with a lower role + for ud in userdocs_u1[0:3]: + factories.UserDocumentAccessFactory( + user=user_2, document=ud.document, role="reader" + ) + + for ud in userdocs_u2[0:3]: + factories.UserDocumentAccessFactory( + user=user_1, document=ud.document, role="reader" + ) + + # Make the next 3 documents of each list shared with the other user + # with a higher role + for ud in userdocs_u1[3:6]: + factories.UserDocumentAccessFactory( + user=user_2, document=ud.document, role="owner" + ) + + for ud in userdocs_u2[3:6]: + factories.UserDocumentAccessFactory( + user=user_1, document=ud.document, role="owner" + ) + + return (user_1, user_2, userdocs_u1, userdocs_u2) + + +def test_user_reconciliation_is_created(user_reconciliation_users_and_docs): + """Test that a UserReconciliation entry can be created and saved.""" + user_1, user_2, _userdocs_u1, _userdocs_u2 = user_reconciliation_users_and_docs + rec = models.UserReconciliation.objects.create( + active_email=user_1.email, + inactive_email=user_2.email, + active_email_checked=False, + inactive_email_checked=True, + active_email_confirmation_id=uuid.uuid4(), + inactive_email_confirmation_id=uuid.uuid4(), + status="pending", + ) + + rec.save() + assert rec.status == "ready" + + +def test_user_reconciliation_verification_emails_are_sent( + user_reconciliation_users_and_docs, +): + """Test that both UserReconciliation verification emails are sent.""" + user_1, user_2, _userdocs_u1, _userdocs_u2 = user_reconciliation_users_and_docs + rec = models.UserReconciliation.objects.create( + active_email=user_1.email, + inactive_email=user_2.email, + active_email_checked=False, + inactive_email_checked=False, + active_email_confirmation_id=uuid.uuid4(), + inactive_email_confirmation_id=uuid.uuid4(), + status="pending", + ) + + rec.save() + + # pylint: disable-next=no-member + assert len(mail.outbox) == 2 + + # pylint: disable-next=no-member + email_1 = mail.outbox[0] + + assert email_1.to == [user_1.email] + email_1_content = " ".join(email_1.body.split()) + + assert ( + "You have requested a reconciliation of your user accounts on Docs." + in email_1_content + ) + active_email_confirmation_id = rec.active_email_confirmation_id + inactive_email_confirmation_id = rec.inactive_email_confirmation_id + assert ( + f"user-reconciliations/active/{active_email_confirmation_id}/" + in email_1_content + ) + + # pylint: disable-next=no-member + email_2 = mail.outbox[1] + + assert email_2.to == [user_2.email] + email_2_content = " ".join(email_2.body.split()) + + assert ( + "You have requested a reconciliation of your user accounts on Docs." + in email_2_content + ) + + assert ( + f"user-reconciliations/inactive/{inactive_email_confirmation_id}/" + in email_2_content + ) + + +def test_user_reconciliation_only_starts_if_checks_are_made( + user_reconciliation_users_and_docs, +): + """Test that the admin action does not process entries + unless both email checks are confirmed. + """ + user_1, user_2, _userdocs_u1, _userdocs_u2 = user_reconciliation_users_and_docs + + # Create a reconciliation entry where only one email has been checked + rec = models.UserReconciliation.objects.create( + active_email=user_1.email, + inactive_email=user_2.email, + active_email_checked=True, + inactive_email_checked=False, + status="pending", + ) + rec.save() + + # Capture counts before running admin action + accesses_before_active = models.DocumentAccess.objects.filter(user=user_1).count() + accesses_before_inactive = models.DocumentAccess.objects.filter(user=user_2).count() + users_active_before = (user_1.is_active, user_2.is_active) + + # Call the admin action with the queryset containing our single rec + qs = models.UserReconciliation.objects.filter(id=rec.id) + process_reconciliation(None, None, qs) + + # Reload from DB and assert nothing was processed (checks prevent processing) + rec.refresh_from_db() + user_1.refresh_from_db() + user_2.refresh_from_db() + + assert rec.status == "ready" + assert ( + models.DocumentAccess.objects.filter(user=user_1).count() + == accesses_before_active + ) + assert ( + models.DocumentAccess.objects.filter(user=user_2).count() + == accesses_before_inactive + ) + assert (user_1.is_active, user_2.is_active) == users_active_before + + +def test_process_reconciliation_updates_accesses( + user_reconciliation_users_and_docs, +): + """Test that accesses are consolidated on the active user.""" + user_1, user_2, userdocs_u1, userdocs_u2 = user_reconciliation_users_and_docs + + u1_2 = userdocs_u1[2] + u1_5 = userdocs_u1[5] + u2doc1 = userdocs_u2[1].document + u2doc5 = userdocs_u2[5].document + + 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) + + rec.refresh_from_db() + user_1.refresh_from_db() + user_2.refresh_from_db() + u1_2.refresh_from_db( + from_queryset=models.DocumentAccess.objects.select_for_update() + ) + u1_5.refresh_from_db( + from_queryset=models.DocumentAccess.objects.select_for_update() + ) + + # After processing, inactive user should have no accesses + # and active user should have one access per union document + # with the highest role + assert rec.status == "done" + assert "Requested update for 10 DocumentAccess items" in rec.logs + assert "and deletion for 12 DocumentAccess items" in rec.logs + assert models.DocumentAccess.objects.filter(user=user_2).count() == 0 + assert models.DocumentAccess.objects.filter(user=user_1).count() == 20 + assert u1_2.role == "editor" + assert u1_5.role == "owner" + + assert ( + models.DocumentAccess.objects.filter(user=user_1, document=u2doc1).first().role + == "editor" + ) + assert ( + models.DocumentAccess.objects.filter(user=user_1, document=u2doc5).first().role + == "owner" + ) + + assert user_1.is_active is True + assert user_2.is_active is False + + # pylint: disable-next=no-member + assert len(mail.outbox) == 1 + + # pylint: disable-next=no-member + email = mail.outbox[0] + + assert email.to == [user_1.email] + email_content = " ".join(email.body.split()) + + assert "Your accounts have been merged" in email_content + + +def test_process_reconciliation_updates_linktraces( + user_reconciliation_users_and_docs, +): + """Test that linktraces are consolidated on the active user.""" + user_1, user_2, userdocs_u1, userdocs_u2 = user_reconciliation_users_and_docs + + u1_2 = userdocs_u1[2] + u1_5 = userdocs_u1[5] + + doc_both = u1_2.document + models.LinkTrace.objects.create(document=doc_both, user=user_1) + models.LinkTrace.objects.create(document=doc_both, user=user_2) + + doc_inactive_only = userdocs_u2[4].document + models.LinkTrace.objects.create( + document=doc_inactive_only, user=user_2, is_masked=True + ) + + doc_active_only = userdocs_u1[4].document + models.LinkTrace.objects.create(document=doc_active_only, user=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) + + rec.refresh_from_db() + user_1.refresh_from_db() + user_2.refresh_from_db() + u1_2.refresh_from_db( + from_queryset=models.DocumentAccess.objects.select_for_update() + ) + u1_5.refresh_from_db( + from_queryset=models.DocumentAccess.objects.select_for_update() + ) + + # Inactive user should have no linktraces + assert models.LinkTrace.objects.filter(user=user_2).count() == 0 + + # doc_both should have a single LinkTrace owned by the active user + assert ( + models.LinkTrace.objects.filter(user=user_1, document=doc_both).exists() is True + ) + assert models.LinkTrace.objects.filter(user=user_1, document=doc_both).count() == 1 + assert ( + models.LinkTrace.objects.filter(user=user_2, document=doc_both).exists() + is False + ) + + # doc_inactive_only should now be linked to active user and preserve is_masked + lt = models.LinkTrace.objects.filter( + user=user_1, document=doc_inactive_only + ).first() + assert lt is not None + assert lt.is_masked is True + + # doc_active_only should still belong to active user + assert models.LinkTrace.objects.filter( + user=user_1, document=doc_active_only + ).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, +): + """Test that favorites are consolidated on the active user.""" + user_1, user_2, userdocs_u1, userdocs_u2 = user_reconciliation_users_and_docs + + u1_2 = userdocs_u1[2] + u1_5 = userdocs_u1[5] + + doc_both = u1_2.document + models.DocumentFavorite.objects.create(document=doc_both, user=user_1) + models.DocumentFavorite.objects.create(document=doc_both, user=user_2) + + doc_inactive_only = userdocs_u2[4].document + models.DocumentFavorite.objects.create(document=doc_inactive_only, user=user_2) + + doc_active_only = userdocs_u1[4].document + models.DocumentFavorite.objects.create(document=doc_active_only, user=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) + + rec.refresh_from_db() + user_1.refresh_from_db() + user_2.refresh_from_db() + u1_2.refresh_from_db( + from_queryset=models.DocumentAccess.objects.select_for_update() + ) + u1_5.refresh_from_db( + from_queryset=models.DocumentAccess.objects.select_for_update() + ) + + # Inactive user should have no document favorites + assert models.DocumentFavorite.objects.filter(user=user_2).count() == 0 + + # doc_both should have a single DocumentFavorite owned by the active user + assert ( + models.DocumentFavorite.objects.filter(user=user_1, document=doc_both).exists() + is True + ) + assert ( + models.DocumentFavorite.objects.filter(user=user_1, document=doc_both).count() + == 1 + ) + assert ( + models.DocumentFavorite.objects.filter(user=user_2, document=doc_both).exists() + is False + ) + + # doc_inactive_only should now be linked to active user + 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 + ).exists() + + # doc_active_only should still belong to active user + assert models.DocumentFavorite.objects.filter( + user=user_1, document=doc_active_only + ).exists() diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 03593f264d..97ffb24ea1 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -7,6 +7,7 @@ from rest_framework.routers import DefaultRouter from core.api import viewsets +from core.api.viewsets import ReconciliationConfirmView # - Main endpoints router = DefaultRouter() @@ -59,6 +60,10 @@ r"^documents/(?P[0-9a-z-]*)/threads/(?P[0-9a-z-]*)/", include(thread_related_router.urls), ), + path( + "user-reconciliations///", + ReconciliationConfirmView.as_view(), + ), ] ), ), diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 7510160316..aaf982cb1d 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -869,6 +869,11 @@ class Base(Configuration): ), ) + # User accounts management + USER_RECONCILIATION_FORM_URL = values.Value( + None, environ_name="USER_RECONCILIATION_FORM_URL", environ_prefix=None + ) + LASUITE_MARKETING = { "BACKEND": values.Value( "lasuite.marketing.backends.dummy.DummyBackend", diff --git a/src/frontend/apps/impress/src/features/auth/__tests__/UserReconciliation.test.tsx b/src/frontend/apps/impress/src/features/auth/__tests__/UserReconciliation.test.tsx new file mode 100644 index 0000000000..17ae5b9117 --- /dev/null +++ b/src/frontend/apps/impress/src/features/auth/__tests__/UserReconciliation.test.tsx @@ -0,0 +1,62 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import fetchMock from 'fetch-mock'; +import React from 'react'; +import { describe, expect, test } from 'vitest'; + +import { AppWrapper } from '@/tests/utils'; + +import { UserReconciliation } from '../components/UserReconciliation'; + +describe('UserReconciliation', () => { + beforeEach(() => { + fetchMock.reset(); + }); + + ['active', 'inactive'].forEach((type) => { + test(`renders when reconciliation is a ${type} success`, async () => { + fetchMock.get( + `http://test.jest/api/v1.0/user-reconciliations/${type}/123456/`, + { details: 'Success' }, + ); + + render( + , + { + wrapper: AppWrapper, + }, + ); + + await waitFor(() => { + expect(fetchMock.calls().length).toBe(1); + }); + + expect( + await screen.findByText(/Email validated successfully !/i), + ).toBeInTheDocument(); + }); + }); + + test('renders when reconciliation fails', async () => { + fetchMock.get( + `http://test.jest/api/v1.0/user-reconciliations/active/invalid-id/`, + { + throws: new Error('invalid id'), + }, + ); + + render(, { + wrapper: AppWrapper, + }); + + await waitFor(() => { + expect(fetchMock.calls().length).toBe(1); + }); + + expect( + await screen.findByText(/An error occurred during email validation./i), + ).toBeInTheDocument(); + }); +}); diff --git a/src/frontend/apps/impress/src/features/auth/api/index.ts b/src/frontend/apps/impress/src/features/auth/api/index.ts index ce8db5d434..a07760a0d0 100644 --- a/src/frontend/apps/impress/src/features/auth/api/index.ts +++ b/src/frontend/apps/impress/src/features/auth/api/index.ts @@ -1,2 +1,3 @@ export * from './useAuthQuery'; +export * from './useUserReconciliations'; export * from './types'; diff --git a/src/frontend/apps/impress/src/features/auth/api/useUserReconciliations.tsx b/src/frontend/apps/impress/src/features/auth/api/useUserReconciliations.tsx new file mode 100644 index 0000000000..aa7a88ab46 --- /dev/null +++ b/src/frontend/apps/impress/src/features/auth/api/useUserReconciliations.tsx @@ -0,0 +1,51 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +type UserReconciliationResponse = { + details: string; +}; + +interface UserReconciliationProps { + type: 'active' | 'inactive'; + reconciliationId?: string; +} + +export const userReconciliations = async ({ + type, + reconciliationId, +}: UserReconciliationProps): Promise => { + const response = await fetchAPI( + `user-reconciliations/${type}/${reconciliationId}/`, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to do the user reconciliation', + await errorCauses(response), + ); + } + + return response.json() as Promise; +}; + +export const KEY_USER_RECONCILIATIONS = 'user_reconciliations'; + +export function useUserReconciliationsQuery( + param: UserReconciliationProps, + queryConfig?: UseQueryOptions< + UserReconciliationResponse, + APIError, + UserReconciliationResponse + >, +) { + return useQuery< + UserReconciliationResponse, + APIError, + UserReconciliationResponse + >({ + queryKey: [KEY_USER_RECONCILIATIONS, param], + queryFn: () => userReconciliations(param), + ...queryConfig, + }); +} diff --git a/src/frontend/apps/impress/src/features/auth/assets/rocket.gif b/src/frontend/apps/impress/src/features/auth/assets/rocket.gif new file mode 100644 index 0000000000..d0f183267f Binary files /dev/null and b/src/frontend/apps/impress/src/features/auth/assets/rocket.gif differ diff --git a/src/frontend/apps/impress/src/features/auth/components/UserReconciliation.tsx b/src/frontend/apps/impress/src/features/auth/components/UserReconciliation.tsx new file mode 100644 index 0000000000..40a1e5e146 --- /dev/null +++ b/src/frontend/apps/impress/src/features/auth/components/UserReconciliation.tsx @@ -0,0 +1,104 @@ +import { Button } from '@gouvfr-lasuite/cunningham-react'; +import Image from 'next/image'; +import { useTranslation } from 'react-i18next'; +import styled from 'styled-components'; + +import error_img from '@/assets/icons/error-planetes.png'; +import { Box, Icon, Loading, StyledLink, Text } from '@/components'; + +import { useUserReconciliationsQuery } from '../api'; +import success_gif from '../assets/rocket.gif'; + +const StyledButton = styled(Button)` + width: fit-content; +`; + +interface UserReconciliationProps { + type: 'active' | 'inactive'; + reconciliationId: string; +} + +export const UserReconciliation = ({ + type, + reconciliationId, +}: UserReconciliationProps) => { + const { t } = useTranslation(); + const { data: userReconciliations, isError } = useUserReconciliationsQuery({ + type, + reconciliationId, + }); + + if (!userReconciliations && !isError) { + return ( + + ); + } + + let render = ( + <> + + + {t('Email validated successfully !')} + + + ); + + if (isError) { + render = ( + <> + + + {t('An error occurred during email validation.')} + + + ); + } + + return ( + + {render} + + + + + } + > + {t('Home')} + + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/auth/components/index.ts b/src/frontend/apps/impress/src/features/auth/components/index.ts index 26ebaf2e8b..0a91bf374a 100644 --- a/src/frontend/apps/impress/src/features/auth/components/index.ts +++ b/src/frontend/apps/impress/src/features/auth/components/index.ts @@ -1,3 +1,4 @@ export * from './Auth'; export * from './ButtonLogin'; export * from './UserAvatar'; +export * from './UserReconciliation'; diff --git a/src/frontend/apps/impress/src/pages/user-reconciliations/active/[id]/index.tsx b/src/frontend/apps/impress/src/pages/user-reconciliations/active/[id]/index.tsx new file mode 100644 index 0000000000..7fa8c27a21 --- /dev/null +++ b/src/frontend/apps/impress/src/pages/user-reconciliations/active/[id]/index.tsx @@ -0,0 +1,40 @@ +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { UserReconciliation } from '@/features/auth/components/UserReconciliation'; +import { PageLayout } from '@/layouts'; +import { NextPageWithLayout } from '@/types/next'; + +const Page: NextPageWithLayout = () => { + const { t } = useTranslation(); + const { + query: { id }, + } = useRouter(); + + if (typeof id !== 'string') { + return null; + } + + return ( + <> + + + {`${t('User reconciliation')} - ${t('Docs')}`} + + + + + ); +}; + +Page.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default Page; diff --git a/src/frontend/apps/impress/src/pages/user-reconciliations/inactive/[id]/index.tsx b/src/frontend/apps/impress/src/pages/user-reconciliations/inactive/[id]/index.tsx new file mode 100644 index 0000000000..c10cbb38e7 --- /dev/null +++ b/src/frontend/apps/impress/src/pages/user-reconciliations/inactive/[id]/index.tsx @@ -0,0 +1,40 @@ +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { UserReconciliation } from '@/features/auth/components/UserReconciliation'; +import { PageLayout } from '@/layouts'; +import { NextPageWithLayout } from '@/types/next'; + +const Page: NextPageWithLayout = () => { + const { t } = useTranslation(); + const { + query: { id }, + } = useRouter(); + + if (typeof id !== 'string') { + return null; + } + + return ( + <> + + + {`${t('User reconciliation')} - ${t('Docs')}`} + + + + + ); +}; + +Page.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default Page; diff --git a/src/frontend/apps/impress/vitest.setup.ts b/src/frontend/apps/impress/vitest.setup.ts index de0945cfe7..6e449e8b75 100644 --- a/src/frontend/apps/impress/vitest.setup.ts +++ b/src/frontend/apps/impress/vitest.setup.ts @@ -1,4 +1,23 @@ import '@testing-library/jest-dom/vitest'; import * as dotenv from 'dotenv'; +import React from 'react'; +import { vi } from 'vitest'; dotenv.config({ path: './.env.test', quiet: true }); + +vi.mock('next/image', () => ({ + __esModule: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + default: (props: any) => { + const { + src, + alt = '', + unoptimized: _unoptimized, + priority: _priority, + ...rest + } = props; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const resolved = typeof src === 'string' ? src : src?.src; + return React.createElement('img', { src: resolved, alt, ...rest }); + }, +}));