Skip to content

Commit 1ae6644

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 1ae6644

File tree

6 files changed

+450
-2
lines changed

6 files changed

+450
-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: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
"""Admin classes and registrations for core app."""
22

3-
from django.contrib import admin
3+
from os import remove
4+
from django.contrib import admin, messages
45
from django.contrib.auth import admin as auth_admin
6+
from django.forms import ModelForm
7+
from django.shortcuts import render, redirect
8+
from django.urls import path
59
from django.utils.translation import gettext_lazy as _
610

711
from treebeard.admin import TreeAdmin
812

9-
from . import models
13+
from core import models
14+
from core.tasks.user_reconciliation import user_reconciliation_csv_import_job
1015

1116

1217
class TemplateAccessInline(admin.TabularInline):
@@ -104,6 +109,87 @@ class UserAdmin(auth_admin.UserAdmin):
104109
search_fields = ("id", "sub", "admin_email", "email", "full_name")
105110

106111

112+
class UserReconciliationCsvImportForm(ModelForm):
113+
class Meta:
114+
model = models.UserReconciliationCsvImport
115+
fields = ("file",)
116+
117+
118+
@admin.register(models.UserReconciliationCsvImport)
119+
class UserReconciliationCsvImportAdmin(admin.ModelAdmin):
120+
list_display = ("id", "created_at", "status")
121+
122+
def save_model(self, request, obj, form, change):
123+
super().save_model(request, obj, form, change)
124+
125+
if not change:
126+
user_reconciliation_csv_import_job.delay(obj.pk)
127+
messages.success(request, "Import job created and queued.")
128+
return redirect("..")
129+
130+
131+
@admin.action(description="Process selected user reconciliations")
132+
def process_reconciliation(modeladmin, request, queryset):
133+
"""
134+
Admin action to process selected user reconciliations.
135+
The action will process only entries that are ready and have both emails checked.
136+
137+
Its action is threefold:
138+
- Transfer document accesses from inactive to active user, updating roles as needed.
139+
- Transfer invitations from inactive to active user, updating roles as needed.
140+
- Activate the active user and deactivate the inactive user.
141+
"""
142+
processable_entries = queryset.filter(
143+
status="ready", active_email_checked=True, inactive_email_checked=True
144+
)
145+
146+
# Prepare the bulk operations
147+
updated_documentaccess = []
148+
removed_documentaccess = []
149+
updated_invitations = []
150+
removed_invitations = []
151+
update_users_active_status = []
152+
153+
for entry in processable_entries:
154+
new_updated_documentaccess, new_removed_documentaccess = (
155+
entry.process_documentaccess_reconciliation()
156+
)
157+
updated_documentaccess += new_updated_documentaccess
158+
removed_documentaccess += new_removed_documentaccess
159+
160+
new_updated_invitations, new_removed_invitations = (
161+
entry.process_invitation_reconciliation()
162+
)
163+
updated_invitations += new_updated_invitations
164+
removed_invitations += new_removed_invitations
165+
166+
entry.active_user.is_active = True
167+
entry.inactive_user.is_active = False
168+
update_users_active_status.append(entry.active_user)
169+
update_users_active_status.append(entry.inactive_user)
170+
171+
# Actually perform the bulk operations
172+
models.DocumentAccess.objects.bulk_update(updated_documentaccess, ["user", "role"])
173+
174+
if len(removed_documentaccess):
175+
ids_to_delete = [rd.id for rd in removed_documentaccess]
176+
models.DocumentAccess.objects.filter(id__in=ids_to_delete).delete()
177+
178+
models.Invitation.objects.bulk_update(updated_invitations, ["email", "role"])
179+
180+
if len(removed_invitations):
181+
ids_to_delete = [ri.id for ri in removed_invitations]
182+
models.Invitation.objects.filter(id__in=ids_to_delete).delete()
183+
184+
models.User.objects.bulk_update(update_users_active_status, ["is_active"])
185+
186+
187+
@admin.register(models.UserReconciliation)
188+
class UserReconciliationAdmin(admin.ModelAdmin):
189+
list_display = ["id", "created_at", "status"]
190+
actions = [process_reconciliation]
191+
192+
107193
@admin.register(models.Template)
108194
class TemplateAdmin(admin.ModelAdmin):
109195
"""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)