Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ and this project adheres to
- ✨(helm) redirecting system #1697
- 📱(frontend) add comments for smaller device #1737
- ✨(project) add custom js support via config #1759
- ✨(backend) manage reconciliation requests for user accounts #1708
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be placed under "Unreleased"


### Changed

Expand Down
1 change: 1 addition & 0 deletions docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ These are the environment variables you can set for the `impress-backend` contai
| THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 |
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
| USER_RECONCILIATION_FORM_URL | URL of a third-party form for user reconciliation requests | |
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
| Y_PROVIDER_API_KEY | Y provider API key | |

Expand Down
19 changes: 19 additions & 0 deletions docs/user_account_reconciliation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# User account reconciliation

It is possible to merge user accounts based on their email addresses.

Docs does not have an internal process to requests, but it allows the import of a CSV from an external form
(e.g. made with Grist) in the Django admin panel (in "Core" > "User reconciliation CSV imports" > "Add user reconciliation")

The CSV must contain the following mandatory columns:

- `active_email`: the email of the user that will remain active after the process.
- `inactive_email`: the email of the user(s) that will be merged into the active user. It is possible to indicate several emails, so the user only has to make one request even if they have more than two accounts.
- `id`: a unique row id, so that entries already processed in a previous import are ignored.

The following columns are optional: `active_email_checked` and `inactive_email_checked` (both must contain `0` (False) or `1` (True), and both default to False.)
If present, it allows to indicate that the source form has a way to validate that the user making the request actually controls the email addresses, skipping the need to send confirmation emails (cf. below)

Once the CSV file is processed, this will create entries in "Core" > "User reconciliations" and send verification emails to validate that the user making the request actually controls the email addresses (unless `active_email_checked` and `inactive_email_checked` were set to `1` in the CSV)

In "Core" > "User reconciliations", an admin can then select all rows they wish to process and check the action "Process selected user reconciliations". Only rows that have the status `ready` and for which both emails have been validated will be processed.
44 changes: 42 additions & 2 deletions src/backend/core/admin.py
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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."""

Expand Down
55 changes: 55 additions & 0 deletions src/backend/core/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
from rest_framework import filters, status, viewsets
from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line 41 the drf reponse module is imported, you can probably use it instead of making an other import ?

from rest_framework.views import APIView

from core import authentication, choices, enums, models
from core.api.filters import remove_accents
Expand Down Expand Up @@ -249,6 +251,59 @@ def get_me(self, request):
)


class ReconciliationConfirmView(APIView):
"""API endpoint to confirm user reconciliation emails.

GET /user-reconciliations/{user_type}/{confirmation_id}/
Marks `active_email_checked` or `inactive_email_checked` to True.
"""

permission_classes = [AllowAny]

def get(self, request, user_type, confirmation_id):
"""
Check the confirmation ID and mark the corresponding email as checked.
"""
try:
# validate UUID
uuid_obj = uuid.UUID(str(confirmation_id))
except ValueError:
return Response(
{"detail": "Badly formatted confirmation id"},
status=status.HTTP_400_BAD_REQUEST,
)

if user_type not in ("active", "inactive"):
return Response(
{"detail": "Invalid user_type"}, status=status.HTTP_400_BAD_REQUEST
)

lookup = (
{"active_email_confirmation_id": uuid_obj}
if user_type == "active"
else {"inactive_email_confirmation_id": uuid_obj}
)

try:
rec = models.UserReconciliation.objects.get(**lookup)
except models.UserReconciliation.DoesNotExist:
return Response(
{"detail": "Reconciliation entry not found"},
status=status.HTTP_404_NOT_FOUND,
)

field_name = (
"active_email_checked"
if user_type == "active"
else "inactive_email_checked"
)
if not getattr(rec, field_name):
setattr(rec, field_name, True)
rec.save()

return Response({"detail": "Confirmation received"})


class ResourceAccessViewsetMixin:
"""Mixin with methods common to all access viewsets."""

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# Generated by Django 5.2.10 on 2026-02-02 16:58

import uuid

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("core", "0028_remove_templateaccess_template_and_more"),
]

operations = [
migrations.CreateModel(
name="UserReconciliationCsvImport",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
(
"file",
models.FileField(upload_to="imports/", verbose_name="CSV file"),
),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("running", "Running"),
("done", "Done"),
("error", "Error"),
],
default="pending",
max_length=20,
),
),
("logs", models.TextField(blank=True)),
],
options={
"verbose_name": "user reconciliation CSV import",
"verbose_name_plural": "user reconciliation CSV imports",
"db_table": "impress_user_reconciliation_csv_import",
},
),
migrations.CreateModel(
name="UserReconciliation",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
(
"active_email",
models.EmailField(
max_length=254, verbose_name="Active email address"
),
),
(
"inactive_email",
models.EmailField(
max_length=254, verbose_name="Email address to deactivate"
),
),
("active_email_checked", models.BooleanField(default=False)),
("inactive_email_checked", models.BooleanField(default=False)),
(
"active_email_confirmation_id",
models.UUIDField(
default=uuid.uuid4, editable=False, null=True, unique=True
),
),
(
"inactive_email_confirmation_id",
models.UUIDField(
default=uuid.uuid4, editable=False, null=True, unique=True
),
),
(
"source_unique_id",
models.CharField(
blank=True,
max_length=100,
null=True,
verbose_name="Unique ID in the source file",
),
),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("ready", "Ready"),
("done", "Done"),
("error", "Error"),
],
default="pending",
max_length=20,
),
),
("logs", models.TextField(blank=True)),
(
"active_user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="active_user",
to=settings.AUTH_USER_MODEL,
),
),
(
"inactive_user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="inactive_user",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "user reconciliation",
"verbose_name_plural": "user reconciliations",
"db_table": "impress_user_reconciliation",
"ordering": ["-created_at"],
},
),
]
Loading
Loading