Skip to content

Commit 4d4bd60

Browse files
committed
✨(backend) manage reconciliation requests for user accounts
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, in a threefold process (update document acess, update invitations, update user status.)
1 parent c13f0e9 commit 4d4bd60

File tree

6 files changed

+448
-2
lines changed

6 files changed

+448
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to
1010

1111
- ✨ Add comments feature to the editor #1330
1212
- ✨(backend) Comments on text editor #1330
13+
- ✨(backend) manage reconciliation requests for user accounts #1708
1314

1415
### Changed
1516

src/backend/core/admin.py

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
"""Admin classes and registrations for core app."""
22

3-
from django.contrib import admin
3+
from django.contrib import admin, messages
44
from django.contrib.auth import admin as auth_admin
5+
from django.forms import ModelForm
6+
from django.shortcuts import redirect
57
from django.utils.translation import gettext_lazy as _
68

79
from treebeard.admin import TreeAdmin
810

9-
from . import models
11+
from core import models
12+
from core.tasks.user_reconciliation import user_reconciliation_csv_import_job
1013

1114

1215
class TemplateAccessInline(admin.TabularInline):
@@ -104,6 +107,87 @@ class UserAdmin(auth_admin.UserAdmin):
104107
search_fields = ("id", "sub", "admin_email", "email", "full_name")
105108

106109

110+
class UserReconciliationCsvImportForm(ModelForm):
111+
class Meta:
112+
model = models.UserReconciliationCsvImport
113+
fields = ("file",)
114+
115+
116+
@admin.register(models.UserReconciliationCsvImport)
117+
class UserReconciliationCsvImportAdmin(admin.ModelAdmin):
118+
list_display = ("id", "created_at", "status")
119+
120+
def save_model(self, request, obj, form, change):
121+
super().save_model(request, obj, form, change)
122+
123+
if not change:
124+
user_reconciliation_csv_import_job.delay(obj.pk)
125+
messages.success(request, _("Import job created and queued."))
126+
return redirect("..")
127+
128+
129+
@admin.action(description=_("Process selected user reconciliations"))
130+
def process_reconciliation(modeladmin, request, queryset):
131+
"""
132+
Admin action to process selected user reconciliations.
133+
The action will process only entries that are ready and have both emails checked.
134+
135+
Its action is threefold:
136+
- Transfer document accesses from inactive to active user, updating roles as needed.
137+
- Transfer invitations from inactive to active user, updating roles as needed.
138+
- Activate the active user and deactivate the inactive user.
139+
"""
140+
processable_entries = queryset.filter(
141+
status="ready", active_email_checked=True, inactive_email_checked=True
142+
)
143+
144+
# Prepare the bulk operations
145+
updated_documentaccess = []
146+
removed_documentaccess = []
147+
updated_invitations = []
148+
removed_invitations = []
149+
update_users_active_status = []
150+
151+
for entry in processable_entries:
152+
new_updated_documentaccess, new_removed_documentaccess = (
153+
entry.process_documentaccess_reconciliation()
154+
)
155+
updated_documentaccess += new_updated_documentaccess
156+
removed_documentaccess += new_removed_documentaccess
157+
158+
new_updated_invitations, new_removed_invitations = (
159+
entry.process_invitation_reconciliation()
160+
)
161+
updated_invitations += new_updated_invitations
162+
removed_invitations += new_removed_invitations
163+
164+
entry.active_user.is_active = True
165+
entry.inactive_user.is_active = False
166+
update_users_active_status.append(entry.active_user)
167+
update_users_active_status.append(entry.inactive_user)
168+
169+
# Actually perform the bulk operations
170+
models.DocumentAccess.objects.bulk_update(updated_documentaccess, ["user", "role"])
171+
172+
if len(removed_documentaccess):
173+
ids_to_delete = [rd.id for rd in removed_documentaccess]
174+
models.DocumentAccess.objects.filter(id__in=ids_to_delete).delete()
175+
176+
models.Invitation.objects.bulk_update(updated_invitations, ["email", "role"])
177+
178+
if len(removed_invitations):
179+
ids_to_delete = [ri.id for ri in removed_invitations]
180+
models.Invitation.objects.filter(id__in=ids_to_delete).delete()
181+
182+
models.User.objects.bulk_update(update_users_active_status, ["is_active"])
183+
184+
185+
@admin.register(models.UserReconciliation)
186+
class UserReconciliationAdmin(admin.ModelAdmin):
187+
list_display = ["id", "created_at", "status"]
188+
actions = [process_reconciliation]
189+
190+
107191
@admin.register(models.Template)
108192
class TemplateAdmin(admin.ModelAdmin):
109193
"""Template admin interface declaration."""
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# Generated by Django 5.2.8 on 2025-12-01 15:01
2+
3+
import django.db.models.deletion
4+
import uuid
5+
from django.conf import settings
6+
from django.db import migrations, models
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
("core", "0026_comments"),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name="UserReconciliationCsvImport",
18+
fields=[
19+
(
20+
"id",
21+
models.UUIDField(
22+
default=uuid.uuid4,
23+
editable=False,
24+
help_text="primary key for the record as UUID",
25+
primary_key=True,
26+
serialize=False,
27+
verbose_name="id",
28+
),
29+
),
30+
(
31+
"created_at",
32+
models.DateTimeField(
33+
auto_now_add=True,
34+
help_text="date and time at which a record was created",
35+
verbose_name="created on",
36+
),
37+
),
38+
(
39+
"updated_at",
40+
models.DateTimeField(
41+
auto_now=True,
42+
help_text="date and time at which a record was last updated",
43+
verbose_name="updated on",
44+
),
45+
),
46+
("file", models.FileField(upload_to="imports/")),
47+
(
48+
"status",
49+
models.CharField(
50+
choices=[
51+
("pending", "Pending"),
52+
("running", "Running"),
53+
("done", "Done"),
54+
("error", "Error"),
55+
],
56+
default="pending",
57+
max_length=20,
58+
),
59+
),
60+
("logs", models.TextField(blank=True)),
61+
],
62+
options={
63+
"abstract": False,
64+
},
65+
),
66+
migrations.CreateModel(
67+
name="UserReconciliation",
68+
fields=[
69+
(
70+
"id",
71+
models.UUIDField(
72+
default=uuid.uuid4,
73+
editable=False,
74+
help_text="primary key for the record as UUID",
75+
primary_key=True,
76+
serialize=False,
77+
verbose_name="id",
78+
),
79+
),
80+
(
81+
"created_at",
82+
models.DateTimeField(
83+
auto_now_add=True,
84+
help_text="date and time at which a record was created",
85+
verbose_name="created on",
86+
),
87+
),
88+
(
89+
"updated_at",
90+
models.DateTimeField(
91+
auto_now=True,
92+
help_text="date and time at which a record was last updated",
93+
verbose_name="updated on",
94+
),
95+
),
96+
(
97+
"active_email",
98+
models.EmailField(
99+
max_length=254, verbose_name="Active email address"
100+
),
101+
),
102+
(
103+
"inactive_email",
104+
models.EmailField(
105+
max_length=254, verbose_name="Email address to deactivate"
106+
),
107+
),
108+
("active_email_checked", models.BooleanField(default=False)),
109+
("inactive_email_checked", models.BooleanField(default=False)),
110+
(
111+
"status",
112+
models.CharField(
113+
choices=[
114+
("pending", "Pending"),
115+
("ready", "Ready"),
116+
("done", "Done"),
117+
("error", "Error"),
118+
],
119+
default="pending",
120+
max_length=20,
121+
),
122+
),
123+
("logs", models.TextField(blank=True)),
124+
(
125+
"active_user",
126+
models.ForeignKey(
127+
blank=True,
128+
null=True,
129+
on_delete=django.db.models.deletion.CASCADE,
130+
related_name="active_user",
131+
to=settings.AUTH_USER_MODEL,
132+
),
133+
),
134+
(
135+
"inactive_user",
136+
models.ForeignKey(
137+
blank=True,
138+
null=True,
139+
on_delete=django.db.models.deletion.CASCADE,
140+
related_name="inactive_user",
141+
to=settings.AUTH_USER_MODEL,
142+
),
143+
),
144+
],
145+
options={
146+
"abstract": False,
147+
},
148+
),
149+
]

0 commit comments

Comments
 (0)