(backend) manage reconciliation requests for user accounts (#1878)

For now, the reconciliation requests are imported through CSV in the
Django admin, which sends confirmation email to both addresses. When
both are checked, the actual reconciliation is processed, and all
user-related content is updated.

## Purpose

Fix #1616 // Replaces #1708

For now, the reconciliation requests are imported through CSV in the
Django admin, which sends confirmation email to both addresses. When
both are checked, the actual reconciliation is processed, and all
user-related content is updated.


## Proposal
- [x] New `UserReconciliationCsvImport` model to manage the import of
reconciliation requests through a task
(`user_reconciliation_csv_import_job`)
- [x] New `UserReconciliation` model to store the user reconciliation
requests themselves (a row = a `active_user`/`inactive_user` pair)
  - [x] On save, a confirmation email is sent to the users
- [x] A `process_reconciliation` admin action process the action on the
requested entries, if both emails have been checked.
- [x] Bulk update the `DocumentAccess` items, while managing the case
where both users have access to the document (keeping the higher role)
- [x] Bulk update the `LinkTrace` items, while managing the case where
both users have link traces to the document
- [x] Bulk update the `DocumentFavorite` items, while managing the case
where both users have put the document in their favorites
- [x] Bulk update the comment system items (`Thread`, `Comment` and
`Reaction` items)
  - [x] Bulk update the `is_active` status on both users
- [x] New `USER_RECONCILIATION_FORM_URL` env variable for the "make a
new request" URL in an email.
- [x] Write unit tests
- [x] Remove the unused `email_user()` method on `User`, replaced with
`send_email()` similar to the one on the `Document` model


## Demo page reconciliation success

<img width="1149" height="746" alt="image"
src="https://github.com/user-attachments/assets/09ba2b38-7af3-41fa-a64f-ce3c4fd8548d"
/>

---------

Co-authored-by: Anthony LC <anthony.le-courric@mail.numerique.gouv.fr>
This commit is contained in:
Sylvain Boissel
2026-02-11 19:09:20 +01:00
committed by GitHub
parent 685464f2d7
commit 3ab0a47c3a
33 changed files with 2015 additions and 39 deletions

View File

@@ -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."""