(backend) reconciliation requests: use the standard email template

This removes the new email template in favor of using the already
existing one, by removing the extra formating.
This commit is contained in:
Sylvain Boissel
2026-02-02 18:12:50 +01:00
parent 97f02ffc71
commit c65ab34ffb
12 changed files with 118 additions and 167 deletions
+1
View File
@@ -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 | |
-7
View File
@@ -162,13 +162,6 @@ class UserReconciliationAdmin(admin.ModelAdmin):
actions = [process_reconciliation]
@admin.register(models.Template)
class TemplateAdmin(admin.ModelAdmin):
"""Template admin interface declaration."""
inlines = (TemplateAccessInline,)
class DocumentAccessInline(admin.TabularInline):
"""Inline admin class for document accesses."""
+1 -1
View File
@@ -254,7 +254,7 @@ class UserViewSet(
class ReconciliationConfirmView(APIView):
"""API endpoint to confirm user reconciliation emails.
GET /user_reconciliations/{user_type}/{confirmation_id}/
GET /user-reconciliations/{user_type}/{confirmation_id}/
Marks `active_email_checked` or `inactive_email_checked` to True.
"""
@@ -1,4 +1,4 @@
# Generated by Django 5.2.10 on 2026-01-27 15:50
# Generated by Django 5.2.10 on 2026-02-02 16:58
import uuid
@@ -9,7 +9,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0027_auto_20251120_0956"),
("core", "0028_remove_templateaccess_template_and_more"),
]
operations = [
@@ -65,6 +65,7 @@ class Migration(migrations.Migration):
options={
"verbose_name": "user reconciliation CSV import",
"verbose_name_plural": "user reconciliation CSV imports",
"db_table": "impress_user_reconciliation_csv_import",
},
),
migrations.CreateModel(
@@ -170,6 +171,7 @@ class Migration(migrations.Migration):
options={
"verbose_name": "user reconciliation",
"verbose_name_plural": "user reconciliations",
"db_table": "impress_user_reconciliation",
"ordering": ["-created_at"],
},
),
+30 -28
View File
@@ -266,8 +266,8 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
)
with override(language):
msg_html = render_to_string("mail/html/user_template.html", context)
msg_plain = render_to_string("mail/text/user_template.txt", context)
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:
@@ -344,6 +344,7 @@ class UserReconciliation(BaseModel):
logs = models.TextField(blank=True)
class Meta:
db_table = "impress_user_reconciliation"
verbose_name = _("user reconciliation")
verbose_name_plural = _("user reconciliations")
ordering = ["-created_at"]
@@ -423,18 +424,18 @@ class UserReconciliation(BaseModel):
language = language or get_language()
domain = 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_bold": _(
"You have requested a reconciliation of your user accounts on Docs."
),
"message": _(
"""To confirm that you are the one who initiated the request
and that this email belongs to you:"""
),
"link": f"{domain}/user_reconciliations/{user_type}/{confirmation_id}/",
"message": message,
"link": f"{domain}/user-reconciliations/{user_type}/{confirmation_id}/",
"link_label": str(_("Click here")),
"button_label": str(_("Confirm")),
}
@@ -446,12 +447,16 @@ class UserReconciliation(BaseModel):
language = language or get_language()
domain = 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_bold": _("Your reconciliation request has been processed."),
"message": _("New documents are likely associated with your account:"),
"message": message,
"link": f"{domain}/",
"link_label": str(_("Click here to see")),
"button_label": str(_("See my documents")),
@@ -478,6 +483,7 @@ class UserReconciliationCsvImport(BaseModel):
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")
@@ -498,8 +504,8 @@ class UserReconciliationCsvImport(BaseModel):
)
with override(language):
msg_html = render_to_string("mail/html/user_template.html", context)
msg_plain = render_to_string("mail/text/user_template.txt", context)
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:
@@ -519,27 +525,23 @@ class UserReconciliationCsvImport(BaseModel):
):
"""Method allowing to send email for reconciliation requests with errors."""
language = language or get_language()
domain = Site.objects.get_current().domain
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_bold": _("Your request for reconciliation was unsuccessful."),
"message": _(
"""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),
"link": f"{domain}/",
"message": message,
"link": settings.USER_RECONCILIATION_FORM_URL,
"link_label": str(_("Click here")),
"button_label": str(_("Make a new request")),
}
+71 -61
View File
@@ -15,6 +15,68 @@ 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.
@@ -32,9 +94,11 @@ def user_reconciliation_csv_import_job(job_id):
job.status = "running"
job.save()
rec_entries_created = 0
rows_with_errors = 0
already_processed_source_ids = 0
counters = {
"rec_entries_created": 0,
"rows_with_errors": 0,
"already_processed_source_ids": 0,
}
try:
with job.file.open(mode="r") as f:
@@ -46,68 +110,14 @@ def user_reconciliation_csv_import_job(job_id):
)
for row in reader:
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():
already_processed_source_ids += 1
continue
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}."
)
rows_with_errors += 1
continue
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"
rows_with_errors += 1
continue
if inactive_email == active_email:
job.logs += (
f"Error on row {source_unique_id}: "
f"{active_email} set as both active and inactive email.\n"
)
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",
)
rec_entries_created += 1
counters = _process_row(row, job, counters)
job.status = "done"
job.logs += (
f"Import completed successfully. {reader.line_num} rows processed."
f"{rec_entries_created} reconciliation entries created."
f" {already_processed_source_ids} rows were already processed."
f"{rows_with_errors} rows had errors."
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,
@@ -324,7 +324,7 @@ def test_user_reconciliation_verification_emails_are_sent(
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}/"
f"user-reconciliations/active/{active_email_confirmation_id}/"
in email_1_content
)
@@ -340,7 +340,7 @@ def test_user_reconciliation_verification_emails_are_sent(
)
assert (
f"user_reconciliations/inactive/{inactive_email_confirmation_id}/"
f"user-reconciliations/inactive/{inactive_email_confirmation_id}/"
in email_2_content
)
+1 -1
View File
@@ -61,7 +61,7 @@ urlpatterns = [
include(thread_related_router.urls),
),
path(
"user_reconciliations/<str:user_type>/<uuid:confirmation_id>/",
"user-reconciliations/<str:user_type>/<uuid:confirmation_id>/",
ReconciliationConfirmView.as_view(),
),
]
+5
View File
@@ -869,6 +869,11 @@ class Base(Configuration):
),
)
# User accounts management
USER_RECONCILIATION_FORM_URL = values.Value(
None, environ_name="USER_RECONCILIATION_FORM_URL", environ_prefix=None
)
LASUITE_MARKETING = {
"BACKEND": values.Value(
"lasuite.marketing.backends.dummy.DummyBackend",
@@ -15,7 +15,7 @@ describe('UserReconciliation', () => {
['active', 'inactive'].forEach((type) => {
test(`renders when reconciliation is a ${type} success`, async () => {
fetchMock.get(
`http://test.jest/api/v1.0/user_reconciliations/${type}/123456/`,
`http://test.jest/api/v1.0/user-reconciliations/${type}/123456/`,
{ details: 'Success' },
);
@@ -41,7 +41,7 @@ describe('UserReconciliation', () => {
test('renders when reconciliation fails', async () => {
fetchMock.get(
`http://test.jest/api/v1.0/user_reconciliations/active/invalid-id/`,
`http://test.jest/api/v1.0/user-reconciliations/active/invalid-id/`,
{
throws: new Error('invalid id'),
},
@@ -16,7 +16,7 @@ export const userReconciliations = async ({
reconciliationId,
}: UserReconciliationProps): Promise<UserReconciliationResponse> => {
const response = await fetchAPI(
`user_reconciliations/${type}/${reconciliationId}/`,
`user-reconciliations/${type}/${reconciliationId}/`,
);
if (!response.ok) {
-62
View File
@@ -1,62 +0,0 @@
<mjml>
<mj-include path="./partial/header.mjml" />
<mj-body mj-class="bg--blue-100">
<mj-wrapper css-class="wrapper" padding="5px 25px 0px 25px">
<mj-section css-class="wrapper-logo">
<mj-column>
<mj-image
align="center"
src="{{logo_img}}"
width="320px"
align="left"
alt="{%trans 'Logo email' %}"
/>
</mj-column>
</mj-section>
<mj-section mj-class="bg--white-100" padding="0px 20px 60px 20px">
<mj-column>
<mj-text align="center">
<h1>{{title|capfirst}}</h1>
</mj-text>
<!-- Main Message -->
<mj-text font-weight="bold">
{{message_bold|capfirst}}
</mj-text>
<mj-text>
{{message|capfirst}}
<a href="{{link}}">{{link_label}}</a>
</mj-text>
<mj-button
href="{{link}}"
background-color="#000091"
color="white"
padding-bottom="30px"
>
{{button_label}}
</mj-button>
<mj-divider
border-width="1px"
border-style="solid"
border-color="#DDDDDD"
width="30%"
align="center"
/>
<mj-text>
{% blocktrans %}
Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team.
{% endblocktrans %}
</mj-text>
<!-- Signature -->
<mj-text>
<p>
{% blocktrans %}
Brought to you by {{brandname}}
{% endblocktrans %}
</p>
</mj-text>
</mj-column>
</mj-section>
</mj-wrapper>
</mj-body>
</mjml>