✨(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:
@@ -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 | |
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
|
||||
+4
-2
@@ -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
@@ -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")),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user