diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c66018f..79bc5a87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,23 +6,23 @@ and this project adheres to ## [Unreleased] -### Added +### Added -✨(frontend) Can print a doc #1832 +- ✨(frontend) Can print a doc #1832 +- ✨(backend) manage reconciliation requests for user accounts #1878 ### Changed - ♿️(frontend) Focus main container after navigation #1854 - 🚸(backend) sort user search results by proximity with the active user #1802 - ### Fixed -🐛(frontend) fix broadcast store sync #1846 +- 🐛(frontend) fix broadcast store sync #1846 ## [v4.5.0] - 2026-01-28 -### Added +### Added - ✨(frontend) integrate configurable Waffle #1795 - ✨ Import of documents #1609 diff --git a/docs/env.md b/docs/env.md index bcb3868a..2f153cee 100644 --- a/docs/env.md +++ b/docs/env.md @@ -25,7 +25,7 @@ These are the environment variables you can set for the `impress-backend` contai | AWS_S3_SIGNATURE_VERSION | S3 signature version (`s3v4` or `s3`) | s3v4 | | AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage | | CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 | -| CACHES_DEFAULT_KEY_PREFIX | The prefix used to every cache keys. | docs | +| CACHES_DEFAULT_KEY_PREFIX | The prefix used to every cache keys. | docs | | COLLABORATION_API_URL | Collaboration api host | | | COLLABORATION_SERVER_SECRET | Collaboration api secret | | | COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false | @@ -120,6 +120,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/examples/helm/impress.values.yaml b/docs/examples/helm/impress.values.yaml index 57fab24c..9f07fbe5 100644 --- a/docs/examples/helm/impress.values.yaml +++ b/docs/examples/helm/impress.values.yaml @@ -67,6 +67,7 @@ backend: AWS_S3_SECRET_ACCESS_KEY: password AWS_STORAGE_BUCKET_NAME: docs-media-storage STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage + USER_RECONCILIATION_FORM_URL: https://docs.127.0.0.1.nip.io Y_PROVIDER_API_BASE_URL: http://impress-y-provider:443/api/ Y_PROVIDER_API_KEY: my-secret CACHES_KEY_PREFIX: "{{ now | unixEpoch }}" diff --git a/docs/user_account_reconciliation.md b/docs/user_account_reconciliation.md new file mode 100644 index 00000000..4bd08b9b --- /dev/null +++ b/docs/user_account_reconciliation.md @@ -0,0 +1,30 @@ +# 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") + +## CSV file format + +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. + +## Settings + +If there is a problem with the reconciliation attempt (e.g., one of the addresses given by the user does not match an existing account), the email signaling the error can give back the link to the reconciliation form. This is configured through the following environment variable: + +```env +USER_RECONCILIATION_FORM_URL= +# e.g. "https://yourgristinstance.tld/xxxx/UserReconciliationForm" +``` diff --git a/env.d/development/common b/env.d/development/common index fad2bac5..f43d5779 100644 --- a/env.d/development/common +++ b/env.d/development/common @@ -59,6 +59,9 @@ OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"} # To create one, use the bin/fernetkey command. # OIDC_STORE_REFRESH_TOKEN_KEY="your-32-byte-encryption-key==" +# User reconciliation +USER_RECONCILIATION_FORM_URL=http://localhost:3000 + # AI AI_FEATURE_ENABLED=true AI_BASE_URL=https://openaiendpoint.com diff --git a/env.d/production.dist/backend b/env.d/production.dist/backend index 1016bd3f..106b64c0 100644 --- a/env.d/production.dist/backend +++ b/env.d/production.dist/backend @@ -53,6 +53,9 @@ LOGOUT_REDIRECT_URL=https://${DOCS_HOST} OIDC_REDIRECT_ALLOWED_HOSTS=["https://${DOCS_HOST}"] +# User reconciliation +#USER_RECONCILIATION_FORM_URL=https://${DOCS_HOST} + # AI #AI_FEATURE_ENABLED=true # is false by default #AI_BASE_URL=https://openaiendpoint.com diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index c6de8067..3d2b0ef7 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 4ceef047..c74bb94c 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -41,6 +41,7 @@ from lasuite.tools.email import get_domain_from_email from rest_framework import filters, status, viewsets from rest_framework import response as drf_response from rest_framework.permissions import AllowAny +from rest_framework.views import APIView from core import authentication, choices, enums, models from core.api.filters import remove_accents @@ -316,6 +317,59 @@ class UserViewSet( ) +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 drf_response.Response( + {"detail": "Badly formatted confirmation id"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if user_type not in ("active", "inactive"): + return drf_response.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 drf_response.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 drf_response.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 00000000..643f50fe --- /dev/null +++ b/src/backend/core/migrations/0029_userreconciliationcsvimport_userreconciliation.py @@ -0,0 +1,178 @@ +# Generated by Django 5.2.11 on 2026-02-10 15:47 + +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 71abd253..69fc24f2 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -15,7 +15,6 @@ from django.contrib.auth import models as auth_models from django.contrib.auth.base_user import AbstractBaseUser from django.contrib.postgres.fields import ArrayField from django.contrib.sites.models import Site -from django.core import mail from django.core.cache import cache from django.core.files.base import ContentFile from django.core.files.storage import default_storage @@ -33,14 +32,14 @@ from rest_framework.exceptions import ValidationError 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,11 +250,37 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin): valid_invitations.delete() - def email_user(self, subject, message, from_email=None, **kwargs): - """Email this user.""" - if not self.email: - raise ValueError("User has no email address.") - mail.send_mail(subject, message, from_email, [self.email], **kwargs) + 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 = settings.EMAIL_URL_APP or 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) @cached_property def teams(self): @@ -266,6 +291,417 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin): 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 = settings.EMAIL_URL_APP or 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 = settings.EMAIL_URL_APP or 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 = settings.EMAIL_URL_APP or 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 00000000..364a628d --- /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 00000000..7e9f7fd7 --- /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 00000000..87ddbf4c --- /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 \ No newline at end of file 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 00000000..b92fe036 --- /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 00000000..13d1a0e4 --- /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 \ No newline at end of file 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 00000000..2f3450d7 --- /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 00000000..8f2bd1b8 --- /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 00000000..d7a063d1 --- /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/tests/test_models_users.py b/src/backend/core/tests/test_models_users.py index ff2bbea6..8c0b0c74 100644 --- a/src/backend/core/tests/test_models_users.py +++ b/src/backend/core/tests/test_models_users.py @@ -2,8 +2,6 @@ Unit tests for the User model """ -from unittest import mock - from django.core.exceptions import ValidationError import pytest @@ -26,26 +24,6 @@ def test_models_users_id_unique(): factories.UserFactory(id=user.id) -def test_models_users_send_mail_main_existing(): - """The "email_user' method should send mail to the user's email address.""" - user = factories.UserFactory() - - with mock.patch("django.core.mail.send_mail") as mock_send: - user.email_user("my subject", "my message") - - mock_send.assert_called_once_with("my subject", "my message", None, [user.email]) - - -def test_models_users_send_mail_main_missing(): - """The "email_user' method should fail if the user has no email address.""" - user = factories.UserFactory(email=None) - - with pytest.raises(ValueError) as excinfo: - user.email_user("my subject", "my message") - - assert str(excinfo.value) == "User has no email address." - - @pytest.mark.parametrize( "sub,is_valid", [ diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 03593f26..d88582d2 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -59,6 +59,10 @@ urlpatterns = [ r"^documents/(?P[0-9a-z-]*)/threads/(?P[0-9a-z-]*)/", include(thread_related_router.urls), ), + path( + "user-reconciliations///", + viewsets.ReconciliationConfirmView.as_view(), + ), ] ), ), diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index a2cbd7d3..5142ab0c 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -880,6 +880,11 @@ class Base(Configuration): ), } + # User accounts management + USER_RECONCILIATION_FORM_URL = values.Value( + None, environ_name="USER_RECONCILIATION_FORM_URL", environ_prefix=None + ) + # Marketing and communication settings SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue( False, diff --git a/src/frontend/apps/impress/src/assets/icons/error-coffee.png b/src/frontend/apps/impress/src/assets/icons/error-coffee.png new file mode 100644 index 00000000..355964d1 Binary files /dev/null and b/src/frontend/apps/impress/src/assets/icons/error-coffee.png differ 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 00000000..43b18bd9 --- /dev/null +++ b/src/frontend/apps/impress/src/features/auth/__tests__/UserReconciliation.test.tsx @@ -0,0 +1,68 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import fetchMock from 'fetch-mock'; +import React from 'react'; +import { describe, expect, test, vi } from 'vitest'; + +import { AppWrapper } from '@/tests/utils'; + +import { UserReconciliation } from '../components/UserReconciliation'; + +vi.mock('../assets/mail-check-filled.svg', () => ({ + default: () =>
SuccessSvg
, +})); + +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( + /To complete the unification of your user accounts/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 ce8db5d4..7bc0da73 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 './types'; +export * from './useAuthQuery'; +export * from './useUserReconciliations'; 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 00000000..aa7a88ab --- /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/mail-check-filled.svg b/src/frontend/apps/impress/src/features/auth/assets/mail-check-filled.svg new file mode 100644 index 00000000..113813e4 --- /dev/null +++ b/src/frontend/apps/impress/src/features/auth/assets/mail-check-filled.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + 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 00000000..5147b00a --- /dev/null +++ b/src/frontend/apps/impress/src/features/auth/components/UserReconciliation.tsx @@ -0,0 +1,97 @@ +import Image from 'next/image'; +import { useTranslation } from 'react-i18next'; + +import error_img from '@/assets/icons/error-coffee.png'; +import { Box, Loading, Text } from '@/components'; + +import { useUserReconciliationsQuery } from '../api'; +import SuccessSvg from '../assets/mail-check-filled.svg'; + +interface UserReconciliationProps { + reconciliationId: string; + type: 'active' | 'inactive'; +} + +export const UserReconciliation = ({ + reconciliationId, + type, +}: UserReconciliationProps) => { + const { t } = useTranslation(); + const { data: userReconciliations, isError } = useUserReconciliationsQuery({ + type, + reconciliationId, + }); + + if (!userReconciliations && !isError) { + return ( + + ); + } + + let render = ( + + + + {t('Email Address Confirmed')} + + + {t( + 'To complete the unification of your user accounts, please click the confirmation links sent to all the email addresses you provided.', + )} + + + ); + + if (isError) { + render = ( + + + + {t('An error occurred during email validation.')} + + + ); + } + + return ( + + {render} + + ); +}; 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 26ebaf2e..0a91bf37 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 00000000..7fa8c27a --- /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 00000000..c10cbb38 --- /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 de0945cf..6e449e8b 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 }); + }, +})); diff --git a/src/helm/env.d/dev/values.impress.yaml.gotmpl b/src/helm/env.d/dev/values.impress.yaml.gotmpl index 7a78894f..84761a6a 100644 --- a/src/helm/env.d/dev/values.impress.yaml.gotmpl +++ b/src/helm/env.d/dev/values.impress.yaml.gotmpl @@ -70,6 +70,7 @@ backend: AWS_STORAGE_BUCKET_NAME: docs-media-storage STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage DOCSPEC_API_URL: http://impress-docs-docspec:4000/conversion + USER_RECONCILIATION_FORM_URL: https://docs.127.0.0.1.nip.io Y_PROVIDER_API_BASE_URL: http://impress-docs-y-provider:443/api/ Y_PROVIDER_API_KEY: my-secret CACHES_KEY_PREFIX: "{{ now | unixEpoch }}" diff --git a/src/helm/env.d/feature/values.impress.yaml.gotmpl b/src/helm/env.d/feature/values.impress.yaml.gotmpl index 15926792..6922261f 100644 --- a/src/helm/env.d/feature/values.impress.yaml.gotmpl +++ b/src/helm/env.d/feature/values.impress.yaml.gotmpl @@ -71,6 +71,7 @@ backend: AWS_STORAGE_BUCKET_NAME: docs-media-storage STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage DOCSPEC_API_URL: http://impress-docs-docspec:4000/conversion + USER_RECONCILIATION_FORM_URL: https://{{ .Values.feature }}-docs.{{ .Values.domain }} Y_PROVIDER_API_BASE_URL: http://impress-docs-y-provider:443/api/ Y_PROVIDER_API_KEY: my-secret CACHES_KEY_PREFIX: "{{ now | unixEpoch }}"