diff --git a/docs/env.md b/docs/env.md index 186cfb30..bd72e418 100644 --- a/docs/env.md +++ b/docs/env.md @@ -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 | | diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index 964e57be..d2034c67 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -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.""" diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 1f74d146..d0fbc2c8 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -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. """ diff --git a/src/backend/core/migrations/0028_userreconciliationcsvimport_userreconciliation.py b/src/backend/core/migrations/0029_userreconciliationcsvimport_userreconciliation.py similarity index 96% rename from src/backend/core/migrations/0028_userreconciliationcsvimport_userreconciliation.py rename to src/backend/core/migrations/0029_userreconciliationcsvimport_userreconciliation.py index 21a3a286..f9aefee0 100644 --- a/src/backend/core/migrations/0028_userreconciliationcsvimport_userreconciliation.py +++ b/src/backend/core/migrations/0029_userreconciliationcsvimport_userreconciliation.py @@ -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"], }, ), diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 46aa4b96..31ff11ec 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -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")), } diff --git a/src/backend/core/tasks/user_reconciliation.py b/src/backend/core/tasks/user_reconciliation.py index 7a3b5174..364a628d 100644 --- a/src/backend/core/tasks/user_reconciliation.py +++ b/src/backend/core/tasks/user_reconciliation.py @@ -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, diff --git a/src/backend/core/tests/test_models_user_reconciliation.py b/src/backend/core/tests/test_models_user_reconciliation.py index 1e8f65bc..dba22310 100644 --- a/src/backend/core/tests/test_models_user_reconciliation.py +++ b/src/backend/core/tests/test_models_user_reconciliation.py @@ -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 ) diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 42a34499..97ffb24e 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -61,7 +61,7 @@ urlpatterns = [ include(thread_related_router.urls), ), path( - "user_reconciliations///", + "user-reconciliations///", ReconciliationConfirmView.as_view(), ), ] diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 75101603..aaf982cb 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -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", diff --git a/src/frontend/apps/impress/src/features/auth/__tests__/UserReconciliation.test.tsx b/src/frontend/apps/impress/src/features/auth/__tests__/UserReconciliation.test.tsx index 4f2d3959..17ae5b91 100644 --- a/src/frontend/apps/impress/src/features/auth/__tests__/UserReconciliation.test.tsx +++ b/src/frontend/apps/impress/src/features/auth/__tests__/UserReconciliation.test.tsx @@ -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'), }, diff --git a/src/frontend/apps/impress/src/features/auth/api/useUserReconciliations.tsx b/src/frontend/apps/impress/src/features/auth/api/useUserReconciliations.tsx index 9185563d..aa7a88ab 100644 --- a/src/frontend/apps/impress/src/features/auth/api/useUserReconciliations.tsx +++ b/src/frontend/apps/impress/src/features/auth/api/useUserReconciliations.tsx @@ -16,7 +16,7 @@ export const userReconciliations = async ({ reconciliationId, }: UserReconciliationProps): Promise => { const response = await fetchAPI( - `user_reconciliations/${type}/${reconciliationId}/`, + `user-reconciliations/${type}/${reconciliationId}/`, ); if (!response.ok) { diff --git a/src/mail/mjml/user_template.mjml b/src/mail/mjml/user_template.mjml deleted file mode 100644 index 2dfd46ac..00000000 --- a/src/mail/mjml/user_template.mjml +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - - - - - - - - -

{{title|capfirst}}

-
- - - {{message_bold|capfirst}} - - - {{message|capfirst}} - {{link_label}} - - - {{button_label}} - - - - {% blocktrans %} - Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. - {% endblocktrans %} - - - -

- {% blocktrans %} - Brought to you by {{brandname}} - {% endblocktrans %} -

-
-
-
-
-
-