✨(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:
@@ -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."""
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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."""
|
||||
|
||||
|
||||
135
src/backend/core/tasks/user_reconciliation.py
Normal file
135
src/backend/core/tasks/user_reconciliation.py
Normal file
@@ -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()
|
||||
@@ -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
|
||||
|
@@ -0,0 +1,2 @@
|
||||
active_email,inactive_email,active_email_checked,inactive_email_checked,status,id
|
||||
"user.test40@example.com",,0,0,pending,40
|
||||
|
@@ -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
|
||||
|
@@ -0,0 +1,2 @@
|
||||
merge_accept,active_email,inactive_email,status,id
|
||||
true,user.test20@example.com,user.test20@example.com,pending,20
|
||||
|
@@ -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
|
||||
|
85
src/backend/core/tests/test_api_user_reconciliation.py
Normal file
85
src/backend/core/tests/test_api_user_reconciliation.py
Normal file
@@ -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"}
|
||||
669
src/backend/core/tests/test_models_user_reconciliation.py
Normal file
669
src/backend/core/tests/test_models_user_reconciliation.py
Normal file
@@ -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()
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
@@ -59,6 +59,10 @@ urlpatterns = [
|
||||
r"^documents/(?P<resource_id>[0-9a-z-]*)/threads/(?P<thread_id>[0-9a-z-]*)/",
|
||||
include(thread_related_router.urls),
|
||||
),
|
||||
path(
|
||||
"user-reconciliations/<str:user_type>/<uuid:confirmation_id>/",
|
||||
viewsets.ReconciliationConfirmView.as_view(),
|
||||
),
|
||||
]
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user