Skip to content

Commit 411c038

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 411c038

File tree

5 files changed

+349
-2
lines changed

5 files changed

+349
-2
lines changed

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: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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+
('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')),
20+
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
21+
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
22+
('file', models.FileField(upload_to='imports/')),
23+
('status', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('done', 'Done'), ('error', 'Error')], default='pending', max_length=20)),
24+
('logs', models.TextField(blank=True)),
25+
],
26+
options={
27+
'abstract': False,
28+
},
29+
),
30+
migrations.CreateModel(
31+
name='UserReconciliation',
32+
fields=[
33+
('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')),
34+
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
35+
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
36+
('active_email', models.EmailField(max_length=254, verbose_name='Active email address')),
37+
('inactive_email', models.EmailField(max_length=254, verbose_name='Email address to deactivate')),
38+
('active_email_checked', models.BooleanField(default=False)),
39+
('inactive_email_checked', models.BooleanField(default=False)),
40+
('status', models.CharField(choices=[('pending', 'Pending'), ('ready', 'Ready'), ('done', 'Done'), ('error', 'Error')], default='pending', max_length=20)),
41+
('logs', models.TextField(blank=True)),
42+
('active_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='active_user', to=settings.AUTH_USER_MODEL)),
43+
('inactive_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inactive_user', to=settings.AUTH_USER_MODEL)),
44+
],
45+
options={
46+
'abstract': False,
47+
},
48+
),
49+
]

src/backend/core/models.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""
22
Declare and configure the models for the impress core application
33
"""
4+
45
# pylint: disable=too-many-lines
56

67
import hashlib
@@ -265,6 +266,174 @@ def teams(self):
265266
return []
266267

267268

269+
class UserReconciliation(BaseModel):
270+
"""Model to run batch jobs to replace an active user by another one"""
271+
272+
active_email = models.EmailField(_("Active email address"))
273+
inactive_email = models.EmailField(_("Email address to deactivate"))
274+
active_email_checked = models.BooleanField(default=False)
275+
inactive_email_checked = models.BooleanField(default=False)
276+
active_user = models.ForeignKey(
277+
User,
278+
on_delete=models.CASCADE,
279+
null=True,
280+
blank=True,
281+
related_name="active_user",
282+
)
283+
inactive_user = models.ForeignKey(
284+
User,
285+
on_delete=models.CASCADE,
286+
null=True,
287+
blank=True,
288+
related_name="inactive_user",
289+
)
290+
291+
status = models.CharField(
292+
max_length=20,
293+
choices=[
294+
("pending", _("Pending")),
295+
("ready", _("Ready")),
296+
("done", _("Done")),
297+
("error", _("Error")),
298+
],
299+
default="pending",
300+
)
301+
logs = models.TextField(blank=True)
302+
303+
class Meta:
304+
verbose_name = _("user reconciliation")
305+
verbose_name_plural = _("user reconciliations")
306+
307+
def process_documentaccess_reconciliation(self):
308+
"""
309+
Process the reconciliation by transferring document accesses from the inactive user
310+
to the active user.
311+
"""
312+
updated_accesses = []
313+
removed_accesses = []
314+
inactive_accesses = DocumentAccess.objects.filter(user=self.inactive_user)
315+
316+
# Check documents where the active user already has access
317+
documents_with_both_users = inactive_accesses.values_list("document", flat=True)
318+
existing_accesses = DocumentAccess.objects.filter(user=self.active_user).filter(
319+
document__in=documents_with_both_users
320+
)
321+
existing_roles_per_doc = {
322+
x: y for (x, y) in existing_accesses.values_list("document", "role")
323+
}
324+
325+
for entry in inactive_accesses:
326+
if entry.document_id in existing_roles_per_doc:
327+
# Update role if needed
328+
existing_role = existing_roles_per_doc[entry.document_id]
329+
max_role = RoleChoices.max(entry.role, existing_role)
330+
if existing_role != max_role:
331+
existing_access = existing_accesses.get(document=entry.document)
332+
existing_access.role = max_role
333+
updated_accesses.append(existing_access)
334+
removed_accesses.append(entry)
335+
else:
336+
entry.user = self.active_user
337+
updated_accesses.append(entry)
338+
339+
self.logs += f"""Requested update for {len(updated_accesses)} DocumentAccess items
340+
and deletion for {len(removed_accesses)} DocumentAccess items.\n"""
341+
self.status = "done"
342+
self.save()
343+
344+
return updated_accesses, removed_accesses
345+
346+
def process_invitation_reconciliation(self):
347+
"""
348+
Process the reconciliation by transferring invitations from the inactive user
349+
to the active user.
350+
"""
351+
352+
updated_invitations = []
353+
removed_invitations = []
354+
inactive_invitations = Invitation.objects.filter(email=self.inactive_email)
355+
356+
# Check documents where the active user already has access
357+
documents_with_both_users = inactive_invitations.values_list(
358+
"document", flat=True
359+
)
360+
existing_accesses = Invitation.objects.filter(email=self.active_email).filter(
361+
document__in=documents_with_both_users
362+
)
363+
existing_roles_per_doc = {
364+
x: y for (x, y) in existing_accesses.values_list("document", "role")
365+
}
366+
367+
for entry in inactive_invitations:
368+
if entry.document_id in existing_roles_per_doc:
369+
# Update role if needed
370+
existing_role = existing_roles_per_doc[entry.document_id]
371+
max_role = RoleChoices.max(entry.role, existing_role)
372+
if existing_role != max_role:
373+
existing_access = existing_accesses.get(document=entry.document)
374+
existing_access.role = max_role
375+
updated_invitations.append(existing_access)
376+
removed_invitations.append(entry)
377+
else:
378+
entry.user = self.active_user
379+
updated_invitations.append(entry)
380+
381+
self.logs += f"""Requested update for {len(updated_invitations)} Invitation items
382+
and deletion for {len(removed_invitations)} Invitation items.\n"""
383+
self.status = "done"
384+
self.save()
385+
386+
return updated_invitations, removed_invitations
387+
388+
def save(self, *args, **kwargs):
389+
"""
390+
For pending queries, identify the actual users and send validation emails
391+
"""
392+
if self.status == "pending":
393+
self.active_user = User.objects.filter(email=self.active_email).first()
394+
self.inactive_user = User.objects.filter(email=self.inactive_email).first()
395+
396+
if self.active_user and self.inactive_user:
397+
email_subject = _("Account reconciliation request")
398+
email_content = _(
399+
"""
400+
Please click here.
401+
"""
402+
)
403+
if not self.active_email_checked:
404+
self.active_user.email_user(email_subject, email_content)
405+
if not self.inactive_email_checked:
406+
self.inactive_user.email_user(email_subject, email_content)
407+
self.status = "ready"
408+
else:
409+
self.status = "error"
410+
self.logs = "Error: Both active and inactive users need to exist."
411+
412+
super().save(*args, **kwargs)
413+
414+
415+
class UserReconciliationCsvImport(BaseModel):
416+
"""Model to import reconciliations requests from an external source
417+
(eg, )"""
418+
419+
file = models.FileField(upload_to="imports/")
420+
status = models.CharField(
421+
max_length=20,
422+
choices=[
423+
("pending", _("Pending")),
424+
("running", _("Running")),
425+
("done", _("Done")),
426+
("error", _("Error")),
427+
],
428+
default="pending",
429+
)
430+
logs = models.TextField(blank=True)
431+
432+
class Meta:
433+
verbose_name = _("user reconciliation CSV import")
434+
verbose_name_plural = _("user reconciliation CSV imports")
435+
436+
268437
class BaseAccess(BaseModel):
269438
"""Base model for accesses to handle resources."""
270439

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from impress.celery_app import app
2+
3+
from core.models import UserReconciliation, UserReconciliationCsvImport
4+
5+
import csv
6+
7+
8+
@app.task
9+
def user_reconciliation_csv_import_job(job_id):
10+
# Imports the CSV file, breaks it into UserReconciliation items
11+
job = UserReconciliationCsvImport.objects.get(id=job_id)
12+
job.status = "running"
13+
job.save()
14+
15+
try:
16+
with job.file.open(mode="r") as f:
17+
reader = csv.DictReader(f)
18+
for row in reader:
19+
active_email_checked = row["active_email_checked"] == "1"
20+
inactive_email_checked = row["inactive_email_checked"] == "1"
21+
22+
rec_entry = UserReconciliation.objects.create(
23+
active_email=row["active_email"],
24+
inactive_email=row["inactive_email"],
25+
active_email_checked=active_email_checked,
26+
inactive_email_checked=inactive_email_checked,
27+
status="pending",
28+
)
29+
rec_entry.save()
30+
31+
job.status = "done"
32+
job.logs = f"Import completed successfully. {reader.line_num} rows processed."
33+
except Exception as e:
34+
job.status = "error"
35+
job.logs = str(e)
36+
finally:
37+
job.save()
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
active_email,inactive_email,active_email_checked,inactive_email_checked,
2+
3+
4+
5+
6+

0 commit comments

Comments
 (0)