diff --git a/Pipfile b/Pipfile index 4af09c6..3ec2b6c 100644 --- a/Pipfile +++ b/Pipfile @@ -9,7 +9,7 @@ pip = "*" celery = "*" requests = "*" redis = "*" -django = "==5.1.3" +django = "==5.1.4" pymysql = "*" django-registration = "*" gunicorn = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 9d48034..7bb9a57 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "30c87c97a2b6ce88c62c01523ff6727b07391e9dff6ab71ecc1c94ebd126d2c9" + "sha256": "c97b309ad325eeb9daba81e9c09e71e5f91772a64638e75a8523d2f9afe9060a" }, "pipfile-spec": 6, "requires": { @@ -18,11 +18,11 @@ "default": { "amqp": { "hashes": [ - "sha256:827cb12fb0baa892aad844fd95258143bce4027fdac4fccddbc43330fd281637", - "sha256:a1ecff425ad063ad42a486c902807d1482311481c8ad95a72694b2975e75f7fd" + "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", + "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432" ], "markers": "python_version >= '3.6'", - "version": "==5.2.0" + "version": "==5.3.1" }, "asgiref": { "hashes": [ @@ -51,11 +51,11 @@ }, "certifi": { "hashes": [ - "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", - "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9" + "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", + "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db" ], "markers": "python_version >= '3.6'", - "version": "==2024.8.30" + "version": "==2024.12.14" }, "charset-normalizer": { "hashes": [ @@ -208,12 +208,12 @@ }, "django": { "hashes": [ - "sha256:8b38a9a12da3ae00cb0ba72da985ec4b14de6345046b1e174b1fd7254398f818", - "sha256:c0fa0e619c39325a169208caef234f90baa925227032ad3f44842ba14d75234a" + "sha256:236e023f021f5ce7dee5779de7b286565fdea5f4ab86bae5338e3f7b69896cf0", + "sha256:de450c09e91879fa5a307f696e57c851955c910a438a35e6b4c895e86bedc82a" ], "index": "pypi", "markers": "python_version >= '3.10'", - "version": "==5.1.3" + "version": "==5.1.4" }, "django-registration": { "hashes": [ @@ -310,12 +310,12 @@ }, "redis": { "hashes": [ - "sha256:0b1087665a771b1ff2e003aa5bdd354f15a70c9e25d5a7dbf9c722c16528a7b0", - "sha256:ae174f2bb3b1bf2b09d54bf3e51fbc1469cf6c10aa03e21141f51969801a7897" + "sha256:16f2e22dff21d5125e8481515e386711a34cbec50f0e44413dd7d9c060a54e0f", + "sha256:ee7e1056b9aea0f04c6c2ed59452947f34c4940ee025f5dd83e6a6418b6989e4" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==5.2.0" + "version": "==5.2.1" }, "requests": { "hashes": [ @@ -328,19 +328,19 @@ }, "six": { "hashes": [ - "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", - "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", + "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.16.0" + "version": "==1.17.0" }, "sqlparse": { "hashes": [ - "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4", - "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e" + "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", + "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca" ], "markers": "python_version >= '3.8'", - "version": "==0.5.1" + "version": "==0.5.3" }, "tzdata": { "hashes": [ diff --git a/impresso/__init__.py b/impresso/__init__.py index 070e835..0165ba0 100644 --- a/impresso/__init__.py +++ b/impresso/__init__.py @@ -4,4 +4,4 @@ # Django starts so that shared_task will use this app. from .celery import app as celery_app -__all__ = ('celery_app',) +__all__ = ("celery_app",) diff --git a/impresso/admin.py b/impresso/admin.py index 09b1247..1e0de23 100644 --- a/impresso/admin.py +++ b/impresso/admin.py @@ -1,4 +1,3 @@ -from django import forms from django.contrib import admin from django.contrib import messages from django.contrib.auth.admin import UserAdmin as BaseUserAdmin @@ -10,9 +9,11 @@ from .models import Collection, CollectableItem, Tag, TaggableItem from .models import Attachment, UploadedImage from .models import UserBitmap, DatasetBitmapPosition, UserRequest - +from .models import UserChangePlanRequest from impresso.tasks import after_user_activation +from django.utils.html import format_html + @admin.register(UserRequest) class UserRequestAdmin(admin.ModelAdmin): @@ -23,11 +24,77 @@ class UserRequestAdmin(admin.ModelAdmin): "status", "date_created", ) - search_fields = ["subscriber__username", "subscription__name"] + search_fields = ["user__username", "subscription__name"] list_filter = ["status"] autocomplete_fields = ["user", "reviewer", "subscription"] +@admin.register(UserChangePlanRequest) +class UserChangePlanRequestAdmin(admin.ModelAdmin): + search_fields = ["user__username", "user__last_name"] + list_filter = ["status"] + search_help_text = "Search by requester user id (numeric) or username" + list_display = ("user", "plan", "status", "date_created", "changelog_parsed") + autocomplete_fields = ["user", "plan"] + actions = ["approve_requests", "reject_requests"] + + def changelog_parsed(self, obj): + try: + html = "" + return format_html(html) + except AttributeError as e: + return f"Changelog error: {e}" + except (TypeError, ValueError): + return "Invalid JSON" + + changelog_parsed.short_description = "Changes" + + @admin.action(description="APPROVE selected requests") + def approve_requests(self, request, queryset): + updated = queryset.count() + for req in queryset: + req.status = UserChangePlanRequest.STATUS_APPROVED + # post_save method in impresso.signals already include the code to add the user the Plan Group. + req.save() + self.message_user( + request, + ngettext( + "%d request was successfully approved.", + "%d requests were successfully approved.", + updated, + ) + % updated, + messages.SUCCESS, + ) + + @admin.action(description="REJECT selected requests") + def reject_requests(self, request, queryset): + updated = queryset.count() + for req in queryset: + req.status = UserChangePlanRequest.STATUS_REJECTED + # post_save() method in impresso.signals already includes the code to remove the Plan Group. + req.save() + self.message_user( + request, + ngettext( + "%d request was successfully rejected.", + "%d requests were successfully rejected.", + updated, + ) + % updated, + messages.SUCCESS, + ) + + @admin.register(UserBitmap) class UserBitmapAdmin(admin.ModelAdmin): list_display = ( diff --git a/impresso/apps.py b/impresso/apps.py new file mode 100644 index 0000000..7befd67 --- /dev/null +++ b/impresso/apps.py @@ -0,0 +1,23 @@ +from django.apps import AppConfig + + +class ImpressoConfig(AppConfig): + name = "impresso" + verbose_name = "Impresso" + + def ready(self): + # we import the signal handler inside the ready() method to avoid import issues + from django.db.models.signals import post_migrate, post_save, m2m_changed + from impresso.models import UserBitmap + from django.contrib.auth.models import User + from .signals import ( + create_default_groups, + post_save_user_change_plan_request, + ) + + post_migrate.connect(create_default_groups, sender="impresso") + + post_save.connect( + post_save_user_change_plan_request, + sender="impresso.UserChangePlanRequest", + ) diff --git a/impresso/migrations/0051_userrequestingchangeplan_userchangeplanrequest.py b/impresso/migrations/0051_userrequestingchangeplan_userchangeplanrequest.py new file mode 100644 index 0000000..fa0e1e0 --- /dev/null +++ b/impresso/migrations/0051_userrequestingchangeplan_userchangeplanrequest.py @@ -0,0 +1,50 @@ +# Generated by Django 5.1.4 on 2024-12-19 11:12 + +import django.contrib.auth.models +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('impresso', '0050_alter_job_type'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='UserRequestingChangePlan', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('auth.user',), + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name='UserChangePlanRequest', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date_created', models.DateTimeField(auto_now_add=True)), + ('date_last_modified', models.DateTimeField(auto_now=True)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('approved', 'Approved'), ('rejected', 'Rejected')], default='pending', max_length=10)), + ('changelog', models.JSONField(blank=True, default=list, null=True)), + ('notes', models.TextField(blank=True, null=True)), + ('plan', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='auth.group')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='changePlanRequest', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'User Change Plan Request', + 'verbose_name_plural': 'User Change Plan Requests', + 'unique_together': {('user', 'plan')}, + }, + ), + ] diff --git a/impresso/migrations/0052_delete_userrequestingchangeplan.py b/impresso/migrations/0052_delete_userrequestingchangeplan.py new file mode 100644 index 0000000..f522257 --- /dev/null +++ b/impresso/migrations/0052_delete_userrequestingchangeplan.py @@ -0,0 +1,16 @@ +# Generated by Django 5.1.4 on 2024-12-19 12:29 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('impresso', '0051_userrequestingchangeplan_userchangeplanrequest'), + ] + + operations = [ + migrations.DeleteModel( + name='UserRequestingChangePlan', + ), + ] diff --git a/impresso/migrations/0053_alter_userchangeplanrequest_user.py b/impresso/migrations/0053_alter_userchangeplanrequest_user.py new file mode 100644 index 0000000..1b7dea6 --- /dev/null +++ b/impresso/migrations/0053_alter_userchangeplanrequest_user.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1.4 on 2024-12-20 16:02 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('impresso', '0052_delete_userrequestingchangeplan'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='userchangeplanrequest', + name='user', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='changePlanRequest', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/impresso/migrations/0054_alter_userchangeplanrequest_unique_together.py b/impresso/migrations/0054_alter_userchangeplanrequest_unique_together.py new file mode 100644 index 0000000..098a5cb --- /dev/null +++ b/impresso/migrations/0054_alter_userchangeplanrequest_unique_together.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.4 on 2024-12-20 16:04 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('impresso', '0053_alter_userchangeplanrequest_user'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='userchangeplanrequest', + unique_together=set(), + ), + ] diff --git a/impresso/models/__init__.py b/impresso/models/__init__.py index a680cae..7356821 100644 --- a/impresso/models/__init__.py +++ b/impresso/models/__init__.py @@ -18,3 +18,4 @@ from .datasetBitmapPosition import DatasetBitmapPosition from .userBitmap import UserBitmap from .userRequest import UserRequest +from .userChangePlanRequest import UserChangePlanRequest diff --git a/impresso/models/userChangePlanRequest.py b/impresso/models/userChangePlanRequest.py new file mode 100644 index 0000000..70b0754 --- /dev/null +++ b/impresso/models/userChangePlanRequest.py @@ -0,0 +1,79 @@ +from django.db import models +from django.contrib.auth.models import User, Group + + +class UserChangePlanRequest(models.Model): + """ + UserChangePlanRequest model + This model represents a request made by a user to change their subscription plan. + As soon as this model is saved and the status is STATUS_APPROVED or STATUS_REJECTED, + the user groups are updated by the celery task `after_change_plan_request_updated` + + Note: + Each user can have only one change plan request item. This ensures that all changes are tracked within a single item in the database. + + Attributes: + STATUS_PENDING (str): Constant for pending status. + STATUS_APPROVED (str): Constant for approved status. + STATUS_REJECTED (str): Constant for rejected status. + user (OneToOneField): A one-to-one relationship with the User model. + plan (ForeignKey): A foreign key relationship with the Group model, representing the subscription plan. + date_created (DateTimeField): The date and time when the request was created. + date_last_modified (DateTimeField): The date and time when the request was last modified. + status (CharField): The current status of the request, with choices for pending, approved, and rejected. + changelog (JSONField): A JSON field to store the history of changes made to the request. + notes (TextField): Additional notes related to the request. + + Methods: + __str__(): Returns a string representation of the request. + save(*args, **kwargs): Overrides the save method to handle changelog updates and status changes. + + Meta: + verbose_name (str): Human-readable name for the model. + verbose_name_plural (str): Human-readable plural name for the model. + """ + + STATUS_PENDING = "pending" + STATUS_APPROVED = "approved" + STATUS_REJECTED = "rejected" + + user = models.OneToOneField( + User, + on_delete=models.CASCADE, + related_name="changePlanRequest", + ) + plan = models.ForeignKey(Group, on_delete=models.SET_NULL, null=True) + date_created = models.DateTimeField(auto_now_add=True) + date_last_modified = models.DateTimeField(auto_now=True) + status = models.CharField( + max_length=10, + default=STATUS_PENDING, + choices=( + (STATUS_PENDING, "Pending"), + (STATUS_APPROVED, "Approved"), + (STATUS_REJECTED, "Rejected"), + ), + ) + changelog = models.JSONField(null=True, blank=True, default=list) + notes = models.TextField(null=True, blank=True) + + def __str__(self): + return f"{self.user.username} Request for {self.plan.name if self.plan else '[deleted subscription]'}" + + class Meta: + verbose_name = "User Change Plan Request" + verbose_name_plural = "User Change Plan Requests" + + def save(self, *args, **kwargs): + if self.pk: + # Prepare the new changelog entry + changelog_entry = { + "status": self.status, + "plan": self.plan.name if self.plan else None, + "date": self.date_last_modified.isoformat(), + "notes": self.notes if self.notes else "", + } + + # Append the new entry to the changelog list + self.changelog.append(changelog_entry) + super().save(*args, **kwargs) diff --git a/impresso/models/userRequest.py b/impresso/models/userRequest.py index 5139fa9..bf8737a 100644 --- a/impresso/models/userRequest.py +++ b/impresso/models/userRequest.py @@ -4,6 +4,35 @@ class UserRequest(models.Model): + """ + UserRequest model represents a request made by a user for a subscription. + Note: The unique_together constraint ensures that each user can only have one request per subscription, + regardless of the reviewer. This is to prevent duplicate requests for the same subscription by the same user. + + Attributes: + STATUS_PENDING (str): Status indicating the request is pending. + STATUS_APPROVED (str): Status indicating the request is approved. + STATUS_REJECTED (str): Status indicating the request is rejected. + + user (ForeignKey): Foreign key to the User model representing the user making the request. + reviewer (ForeignKey): Foreign key to the User model representing the reviewer of the request. + subscription (ForeignKey): Foreign key to the DatasetBitmapPosition model representing the subscription requested. + date_created (DateTimeField): The date and time when the request was created. + date_last_modified (DateTimeField): The date and time when the request was last modified. + status (CharField): The current status of the request. + changelog (JSONField): A list of changes made to the request. + notes (TextField): Additional notes related to the request. + + + Methods: + __str__(): Returns a string representation of the UserRequest instance. + save(*args, **kwargs): Overrides the save method to append changes to the changelog before saving. + + Meta: + unique_together (tuple): Ensures that each user can only have one request per subscription. + verbose_name (str): Human-readable name for the model. + verbose_name_plural (str): Human-readable plural name for the model. + """ STATUS_PENDING = "pending" STATUS_APPROVED = "approved" STATUS_REJECTED = "rejected" diff --git a/impresso/settings.py b/impresso/settings.py index b435c63..2cf57c9 100644 --- a/impresso/settings.py +++ b/impresso/settings.py @@ -30,7 +30,7 @@ "django.contrib.messages", "django.contrib.staticfiles", "django_registration", - "impresso", + "impresso.apps.ImpressoConfig", ] MIDDLEWARE = [ @@ -272,8 +272,30 @@ IMPRESSO_GIT_BRANCH = get_env_variable("IMPRESSO_GIT_BRANCH", "?") IMPRESSO_GIT_REVISION = get_env_variable("IMPRESSO_GIT_REVISION", "?") +IMPRESSO_GROUP_USER_PLAN_BASIC = "plan-basic" IMPRESSO_GROUP_USER_PLAN_EDUCATIONAL = "plan-educational" IMPRESSO_GROUP_USER_PLAN_RESEARCHER = "plan-researcher" +IMPRESSO_GROUP_USER_PLAN_REQUEST_EDUCATIONAL = "request-plan-educational" +IMPRESSO_GROUP_USER_PLAN_REQUEST_RESEARCHER = "request-plan-researcher" + +IMPRESSO_GROUP_USERS_AVAILABLE_PLANS = [ + IMPRESSO_GROUP_USER_PLAN_BASIC, + IMPRESSO_GROUP_USER_PLAN_EDUCATIONAL, + IMPRESSO_GROUP_USER_PLAN_RESEARCHER, +] + +IMPRESSO_GROUP_USER_PLAN_BASIC_LABEL = "Basic User Plan" +IMPRESSO_GROUP_USER_PLAN_EDUCATIONAL_LABEL = "Student User Plan" +IMPRESSO_GROUP_USER_PLAN_RESEARCHER_LABEL = "Academic User Plan" + +IMPRESSO_DEFAULT_GROUP_USERS = ( + (IMPRESSO_GROUP_USER_PLAN_REQUEST_EDUCATIONAL, "Request Student User Plan"), + (IMPRESSO_GROUP_USER_PLAN_REQUEST_RESEARCHER, "Request Academic User Plan"), + (IMPRESSO_GROUP_USER_PLAN_BASIC, IMPRESSO_GROUP_USER_PLAN_BASIC_LABEL), + (IMPRESSO_GROUP_USER_PLAN_EDUCATIONAL, IMPRESSO_GROUP_USER_PLAN_EDUCATIONAL_LABEL), + (IMPRESSO_GROUP_USER_PLAN_RESEARCHER, IMPRESSO_GROUP_USER_PLAN_RESEARCHER_LABEL), +) + # Logging LOGGING = { diff --git a/impresso/signals.py b/impresso/signals.py new file mode 100644 index 0000000..982275b --- /dev/null +++ b/impresso/signals.py @@ -0,0 +1,54 @@ +import logging +import sys +from django.conf import settings +from django.contrib.auth.models import Group, User +from impresso.tasks import after_change_plan_request_updated + +logger = logging.getLogger(__name__) + + +def create_default_groups(sender, **kwargs): + """ + Creates default user groups based on the settings. + + This function is typically used as a signal handler to create default + user groups when certain events occur (e.g., when a user is created). + + Args: + sender (Any): The sender of the signal. + **kwargs: Additional keyword arguments passed by the signal. + + Settings: + IMPRESSO_DEFAULT_GROUP_USERS (list of tuple): A list of tuples where each + tuple contains the slug and the name of the group to be created. + + Logs: + Logs the creation of default groups using the logger. + + Example: + default_groups = [ + ('admin', 'Administrators'), + ('editor', 'Editors'), + ('viewer', 'Viewers') + ] + settings.IMPRESSO_DEFAULT_GROUP_USERS = default_groups + create_default_groups(sender=None) + """ + default_groups = settings.IMPRESSO_DEFAULT_GROUP_USERS + + logger.info(f"Creating default groups: {[a[0] for a in default_groups]}") + # each item is a tuple, containing the slud and the name of the group + for group_slug, group_name in default_groups: + g, created = Group.objects.get_or_create(name=group_slug) + if created: + logger.info(f"Group created successfully: {group_slug}") + else: + logger.info(f"Group already exists: {g.id} - {group_slug}") + + +def post_save_user_change_plan_request(sender, instance, created, **kwargs): + logger.info( + f"@post_save UserChangePlanRequest for user={instance.user.pk} plan={instance.plan.name} status=instance.status" + ) + if not "test" in sys.argv: + after_change_plan_request_updated.delay(user_id=instance.user.pk) diff --git a/impresso/tasks.py b/impresso/tasks.py index b3671e1..b7b6de8 100644 --- a/impresso/tasks.py +++ b/impresso/tasks.py @@ -2,10 +2,12 @@ import time from celery.utils.log import get_task_logger -from django.contrib.auth.models import User +from django.contrib.auth.models import User, Group from .celery import app +from celery import shared_task, chain from .models import Job, Collection, CollectableItem, SearchQuery, Attachment from .models import UserBitmap +from .models import UserChangePlanRequest from .utils.tasks import ( TASKSTATE_INIT, update_job_progress, @@ -24,6 +26,9 @@ send_emails_after_user_registration, send_emails_after_user_activation, send_email_password_reset, + send_email_plan_change, + send_email_plan_change_accepted, + send_email_plan_change_rejected, ) from .utils.tasks.userBitmap import helper_update_user_bitmap from .utils.tasks.export import helper_export_query_as_csv_progress @@ -894,7 +899,7 @@ def remove_collection_in_tr_progress(self, collection_id, job_id, skip=0, limit= retry_jitter=True, ) def after_user_registered(self, user_id): - logger.info(f"user({user_id}) just registered") + logger.info(f"[user:{user_id}] just registered") # send confirmation email to the registered user # and send email to impresso admins send_emails_after_user_registration(user_id=user_id, logger=logger) @@ -908,7 +913,7 @@ def after_user_registered(self, user_id): retry_jitter=True, ) def after_user_activation(self, user_id): - logger.info(f"user({user_id}) is now active") + logger.info(f"[user:{user_id}] is now active") # send confirmation email to the registered user # and send email to impresso admins send_emails_after_user_activation(user_id=user_id, logger=logger) @@ -927,7 +932,7 @@ def email_password_reset( token="nonce", callback_url="https://impresso-project.ch/app/reset-password", ): - logger.info(f"user({user_id}) requested password reset!") + logger.info(f"[user:{user_id}] requested password reset!") # send confirmation email to the registered user # and send email to impresso admins send_email_password_reset( @@ -935,6 +940,170 @@ def email_password_reset( ), +@app.task( + bind=True, + autoretry_for=(Exception,), + exponential_backoff=2, + retry_kwargs={"max_retries": 5}, + retry_jitter=True, +) +def email_plan_change(self, user_id: int, plan: str = None) -> None: + """ + Sends an email notification for a user's plan change request. + + Args: + self: The task instance. + user_id (int): The ID of the user requesting the plan change. + plan (str, optional): The new plan requested by the user. Defaults to None. + + Returns: + None + """ + logger.info(f"[user:{user_id}] requested plan change to {plan}!") + # send confirmation email to the registered user + # and send email to impresso admins + send_email_plan_change(user_id=user_id, plan=plan, logger=logger) + + +@app.task(bind=True) +def add_user_to_group_task(self, user_id: int, group_name: str) -> int: + """ + Task to add a user to a group. + + Args: + user_id (int): The ID of the user to be added to the group. + group_name (str): The name of the group to which the user will be added. + + Returns: + int: The ID of the user after being added to the group. + """ + logger.info(f"[user:{user_id}] adding user to group {group_name}") + user = User.objects.get(id=user_id) + group = Group.objects.get(name=group_name) + user.groups.add(group) + + +@app.task(bind=True) +def remove_user_from_group_task(self, user_id: int, group_name: str) -> int: + """ + Task to remove a user from a group. + + Args: + user_id (int): The ID of the user to be removed from the group. + group_name (str): The name of the group from which the user will be removed. + + Returns: + int: The ID of the user after being removed from the group. + """ + logger.info(f"[user:{user_id}] removing user from group {group_name}") + user = User.objects.get(id=user_id) + group = Group.objects.get(name=group_name) + user.groups.remove(group) + + +@app.task( + bind=True, + autoretry_for=(Exception,), + exponential_backoff=2, + retry_kwargs={"max_retries": 5}, + retry_jitter=True, +) +def email_change_plan_request_accepted(self, user_id: int, plan: str = None) -> None: + logger.info(f"[user:{user_id}] sending email after plan change ACCEPTED") + send_email_plan_change_accepted(user_id=user_id, plan=plan, logger=logger) + + +@app.task( + bind=True, + autoretry_for=(Exception,), + exponential_backoff=2, + retry_kwargs={"max_retries": 5}, + retry_jitter=True, +) +def email_change_plan_request_rejected(self, user_id: int, plan: str = None) -> None: + logger.info(f"[user:{user_id}] sending email after plan change REJECTED") + send_email_plan_change_rejected(user_id=user_id, plan=plan, logger=logger) + + +@app.task( + bind=True, + autoretry_for=(Exception,), + exponential_backoff=2, + retry_kwargs={"max_retries": 5}, + retry_jitter=True, +) +def after_change_plan_request_updated(self, user_id: int) -> None: + """ + Accepts user request (if it is not rejected!) then + sends an email notification for an accepted plan change request. + + Args: + self: The task instance. + user_id (int): The ID of the user requesting the plan change. + + Returns: + None + """ + # get request + try: + req = UserChangePlanRequest.objects.get(user_id=user_id) + except UserChangePlanRequest.DoesNotExist: + logger.error(f"[user:{user_id}] UserChangePlanRequest.DoesNotExist") + return + logger.info(f"[user:{user_id}] plan change to {req.plan.name} status {req.status}") + + if req.status == UserChangePlanRequest.STATUS_APPROVED: + chain( + add_user_to_group_task.si(user_id, req.plan.name), + email_change_plan_request_accepted.si(user_id, req.plan.name), + )() + else: + chain( + remove_user_from_group_task.si(user_id, req.plan.name), + email_change_plan_request_rejected.si(user_id, req.plan.name), + )() + + +@app.task( + bind=True, + autoretry_for=(Exception,), + exponential_backoff=2, + retry_kwargs={"max_retries": 5}, + retry_jitter=True, +) +def after_plan_change_rejected(self, user_id: int) -> None: + """ + Rejects user request (if it is not already accepted!) then + sends an email notification for a rejected plan change request. + + Args: + self: The task instance. + user_id (int): The ID of the user requesting the plan change. + + Returns: + None + """ + # get request + try: + req = UserChangePlanRequest.objects.get(user_id=user_id) + except UserChangePlanRequest.DoesNotExist: + logger.error(f"UserChangePlanRequest.DoesNotExist for user {user_id}") + return + # if request is not PENDING, send out an error email. + if req.status == UserChangePlanRequest.STATUS_APPROVED: + logger.error( + f"[user:{user_id}] plan change to {req.plan.name} is APPROVED, can't reject. Change the status in the DB before !" + ) + logger.info( + f"[user:{user_id}] request to change plan to {req.plan.name} has been REJECTED!" + ) + # save Rejected status to the plan request + req.status = UserChangePlanRequest.STATUS_REJECTED + req.save() + # send confirmation email to the registered user + send_email_plan_change_rejected(user_id=user_id, plan=req.plan.name, logger=logger) + + @app.task( bind=True, autoretry_for=(Exception,), diff --git a/impresso/templates/emails/account_plan_change_accepted.html b/impresso/templates/emails/account_plan_change_accepted.html new file mode 100644 index 0000000..52f6d55 --- /dev/null +++ b/impresso/templates/emails/account_plan_change_accepted.html @@ -0,0 +1,21 @@ + +Dear {{ user.first_name }}, + +

+ We are pleased to inform you that your subscription plan has been successfully updated. + You are now subscribed to the {{ plan_to_name }} plan. +

+ +

+ Please note that you need to log out and then log in again for the changes to take effect. +

+ +

+ If you have any questions or need further assistance, please do not + hesitate to contact our support team. +

+ +

Thank you for choosing Impresso!

+ +

Best regards,
+The Impresso Team \ No newline at end of file diff --git a/impresso/templates/emails/account_plan_change_accepted.txt b/impresso/templates/emails/account_plan_change_accepted.txt new file mode 100644 index 0000000..748bda2 --- /dev/null +++ b/impresso/templates/emails/account_plan_change_accepted.txt @@ -0,0 +1,11 @@ +Dear {{ user.first_name }}, + +We are pleased to inform you that your subscription plan has been successfully updated. You are now subscribed to the {{ plan_to_name }}. +Please note that you need to log out and then log in again for the changes to take effect. + +If you have any questions or need further assistance, please do not hesitate to contact our support team. + +Thank you for choosing Impresso! + +Best regards, +The Impresso Team \ No newline at end of file diff --git a/impresso/templates/emails/account_plan_change_rejected.html b/impresso/templates/emails/account_plan_change_rejected.html new file mode 100644 index 0000000..ee2083d --- /dev/null +++ b/impresso/templates/emails/account_plan_change_rejected.html @@ -0,0 +1,19 @@ +Dear {{ user.first_name }}, + +

+ We regret to inform you that your request to change your subscription plan to + {{ plan_to_name }} has been rejected. Unfortunately, you will remain + subscribed to your current plan. +

+ +

+ If you believe this is a mistake or have any questions, please reply to this + email or contact our support team for further assistance. +

+ +

Thank you for your understanding.

+ +

+ Best regards,
+ The Impresso Team +

diff --git a/impresso/templates/emails/account_plan_change_rejected.txt b/impresso/templates/emails/account_plan_change_rejected.txt new file mode 100644 index 0000000..98df74a --- /dev/null +++ b/impresso/templates/emails/account_plan_change_rejected.txt @@ -0,0 +1,10 @@ +Dear {{ user.first_name }}, + +We regret to inform you that your request to change your subscription plan to {{ plan_to_name }} has been rejected. Unfortunately, you will remain subscribed to your current plan. + +If you believe this is a mistake or have any questions, please reply to this email or contact our support team for further assistance. + +Thank you for your understanding. + +Best regards, +The Impresso Team diff --git a/impresso/templates/emails/account_plan_change_to_educational.html b/impresso/templates/emails/account_plan_change_to_educational.html new file mode 100644 index 0000000..06e9f5f --- /dev/null +++ b/impresso/templates/emails/account_plan_change_to_educational.html @@ -0,0 +1,28 @@ +Dear {{user.first_name}}, + +

+ Thank you for requesting a change to your Impresso plan. We've received your + request to switch to the + {{ plan_to_name }} plan. +

+

We are now processing your request. This usually takes 1-2 business days.

+

+ Upon successful completion of the plan change, your permissions will be + updated to reflect the {{ plan_to_name }} features. +
+ Have a look at the + Corpus Overview page + to see what you can do with your new plan. +

+

+ We will send you another email once your plan has been successfully updated. +
+ If you have any questions or need to cancel your request, please contact our + support team at {{ from_email }}. +

+

+ With best wishes,
+ The impresso team +

diff --git a/impresso/templates/emails/account_plan_change_to_educational.txt b/impresso/templates/emails/account_plan_change_to_educational.txt new file mode 100644 index 0000000..ab29c8b --- /dev/null +++ b/impresso/templates/emails/account_plan_change_to_educational.txt @@ -0,0 +1,16 @@ +Dear {{user.first_name}}, + +Thank you for requesting a change to your Impresso plan. We've received your request to switch to the *{{ plan_to_name }}* plan. + +We are now processing your request. This usually takes 1-2 business days. + +Upon successful completion of the plan change, your permissions will be updated to reflect the {{ plan_to_name }} features. + +Have a look at the Corpus Overview page: https://impresso-project.ch/datalab/corpus-overview to see what you can do with your new plan. + +We will send you another email once your plan has been successfully updated. + +If you have any questions or need to cancel your request, please contact our support team at {{ from_email }}. + +With best wishes, +The Impresso team diff --git a/impresso/templates/emails/account_plan_change_to_educational_staff.html b/impresso/templates/emails/account_plan_change_to_educational_staff.html new file mode 100644 index 0000000..06e9f5f --- /dev/null +++ b/impresso/templates/emails/account_plan_change_to_educational_staff.html @@ -0,0 +1,28 @@ +Dear {{user.first_name}}, + +

+ Thank you for requesting a change to your Impresso plan. We've received your + request to switch to the + {{ plan_to_name }} plan. +

+

We are now processing your request. This usually takes 1-2 business days.

+

+ Upon successful completion of the plan change, your permissions will be + updated to reflect the {{ plan_to_name }} features. +
+ Have a look at the + Corpus Overview page + to see what you can do with your new plan. +

+

+ We will send you another email once your plan has been successfully updated. +
+ If you have any questions or need to cancel your request, please contact our + support team at {{ from_email }}. +

+

+ With best wishes,
+ The impresso team +

diff --git a/impresso/templates/emails/account_plan_change_to_educational_staff.txt b/impresso/templates/emails/account_plan_change_to_educational_staff.txt new file mode 100644 index 0000000..ab29c8b --- /dev/null +++ b/impresso/templates/emails/account_plan_change_to_educational_staff.txt @@ -0,0 +1,16 @@ +Dear {{user.first_name}}, + +Thank you for requesting a change to your Impresso plan. We've received your request to switch to the *{{ plan_to_name }}* plan. + +We are now processing your request. This usually takes 1-2 business days. + +Upon successful completion of the plan change, your permissions will be updated to reflect the {{ plan_to_name }} features. + +Have a look at the Corpus Overview page: https://impresso-project.ch/datalab/corpus-overview to see what you can do with your new plan. + +We will send you another email once your plan has been successfully updated. + +If you have any questions or need to cancel your request, please contact our support team at {{ from_email }}. + +With best wishes, +The Impresso team diff --git a/impresso/templates/emails/account_plan_change_to_researcher.html b/impresso/templates/emails/account_plan_change_to_researcher.html new file mode 100644 index 0000000..06e9f5f --- /dev/null +++ b/impresso/templates/emails/account_plan_change_to_researcher.html @@ -0,0 +1,28 @@ +Dear {{user.first_name}}, + +

+ Thank you for requesting a change to your Impresso plan. We've received your + request to switch to the + {{ plan_to_name }} plan. +

+

We are now processing your request. This usually takes 1-2 business days.

+

+ Upon successful completion of the plan change, your permissions will be + updated to reflect the {{ plan_to_name }} features. +
+ Have a look at the + Corpus Overview page + to see what you can do with your new plan. +

+

+ We will send you another email once your plan has been successfully updated. +
+ If you have any questions or need to cancel your request, please contact our + support team at {{ from_email }}. +

+

+ With best wishes,
+ The impresso team +

diff --git a/impresso/templates/emails/account_plan_change_to_researcher.txt b/impresso/templates/emails/account_plan_change_to_researcher.txt new file mode 100644 index 0000000..ab29c8b --- /dev/null +++ b/impresso/templates/emails/account_plan_change_to_researcher.txt @@ -0,0 +1,16 @@ +Dear {{user.first_name}}, + +Thank you for requesting a change to your Impresso plan. We've received your request to switch to the *{{ plan_to_name }}* plan. + +We are now processing your request. This usually takes 1-2 business days. + +Upon successful completion of the plan change, your permissions will be updated to reflect the {{ plan_to_name }} features. + +Have a look at the Corpus Overview page: https://impresso-project.ch/datalab/corpus-overview to see what you can do with your new plan. + +We will send you another email once your plan has been successfully updated. + +If you have any questions or need to cancel your request, please contact our support team at {{ from_email }}. + +With best wishes, +The Impresso team diff --git a/impresso/templates/emails/account_plan_change_to_researcher_staff.html b/impresso/templates/emails/account_plan_change_to_researcher_staff.html new file mode 100644 index 0000000..06e9f5f --- /dev/null +++ b/impresso/templates/emails/account_plan_change_to_researcher_staff.html @@ -0,0 +1,28 @@ +Dear {{user.first_name}}, + +

+ Thank you for requesting a change to your Impresso plan. We've received your + request to switch to the + {{ plan_to_name }} plan. +

+

We are now processing your request. This usually takes 1-2 business days.

+

+ Upon successful completion of the plan change, your permissions will be + updated to reflect the {{ plan_to_name }} features. +
+ Have a look at the + Corpus Overview page + to see what you can do with your new plan. +

+

+ We will send you another email once your plan has been successfully updated. +
+ If you have any questions or need to cancel your request, please contact our + support team at {{ from_email }}. +

+

+ With best wishes,
+ The impresso team +

diff --git a/impresso/templates/emails/account_plan_change_to_researcher_staff.txt b/impresso/templates/emails/account_plan_change_to_researcher_staff.txt new file mode 100644 index 0000000..ab29c8b --- /dev/null +++ b/impresso/templates/emails/account_plan_change_to_researcher_staff.txt @@ -0,0 +1,16 @@ +Dear {{user.first_name}}, + +Thank you for requesting a change to your Impresso plan. We've received your request to switch to the *{{ plan_to_name }}* plan. + +We are now processing your request. This usually takes 1-2 business days. + +Upon successful completion of the plan change, your permissions will be updated to reflect the {{ plan_to_name }} features. + +Have a look at the Corpus Overview page: https://impresso-project.ch/datalab/corpus-overview to see what you can do with your new plan. + +We will send you another email once your plan has been successfully updated. + +If you have any questions or need to cancel your request, please contact our support team at {{ from_email }}. + +With best wishes, +The Impresso team diff --git a/impresso/tests/utils/tasks/test_account.py b/impresso/tests/utils/tasks/test_account.py new file mode 100644 index 0000000..ca04d5f --- /dev/null +++ b/impresso/tests/utils/tasks/test_account.py @@ -0,0 +1,134 @@ +import logging +from django.conf import settings +from django.test import TestCase +from django.contrib.auth.models import User +from impresso.models import UserChangePlanRequest +from impresso.models import UserBitmap +from impresso.utils.tasks.account import ( + send_email_password_reset, + send_email_plan_change, + send_email_plan_change_accepted, + send_email_plan_change_rejected, +) +from django.utils import timezone +from django.core import mail + +logger = logging.getLogger("console") + + +class TestAccount(TestCase): + """ + Test the task helper for update_user_bitmap_task + ENV=dev pipenv run ./manage.py test impresso.tests.utils.tasks.test_account + """ + + def setUp(self): + self.user = User.objects.create_user( + username="testuser", + first_name="Jane", + last_name="Doe", + password="12345", + email="test@test.com", + ) + # create default groups + from impresso.signals import create_default_groups + + create_default_groups(sender="impresso") + + def test_send_email_password_reset(self): + send_email_password_reset(self.user.id, token="test", logger=logger) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].subject, "Reset password for impresso") + # clean outbox + mail.outbox = [] + + def test_send_plan_change_exceptions(self): + with self.assertRaises(ValueError, msg="this plan does not exist"): + send_email_plan_change( + user_id=self.user.id, + plan="not_existing_plan", + logger=logger, + ) + with self.assertRaises(User.DoesNotExist, msg="this user does not exist"): + send_email_plan_change( + user_id=200000, + plan="not_existing_plan", + logger=logger, + ) + + def test_send_email_plan_change(self): + send_email_plan_change( + user_id=self.user.id, + plan=settings.IMPRESSO_GROUP_USER_PLAN_EDUCATIONAL, + logger=logger, + ) + self.assertEqual(len(mail.outbox), 2) + # first email contains this subject + self.assertEqual(mail.outbox[0].subject, "Change plan for Impresso") + # first line of the email is: Dear Jane, + self.assertTrue("Dear Jane," in mail.outbox[0].body) + + req = UserChangePlanRequest.objects.get(user=self.user) + + self.assertEqual(req.status, UserChangePlanRequest.STATUS_PENDING) + self.assertEqual(req.plan.name, settings.IMPRESSO_GROUP_USER_PLAN_EDUCATIONAL) + # clean outbox + mail.outbox = [] + # accept the request + req.status = UserChangePlanRequest.STATUS_APPROVED + req.save() + # add user to the group. Done using celery task + self.user.groups.add(req.plan) + # manually send the email + send_email_plan_change_accepted( + user_id=self.user.id, plan=req.plan.name, logger=logger + ) + self.assertEqual(len(mail.outbox), 1) + # Check that the body starts with Dear Jane, and contains the settings.IMPRESSO_GROUP_USER_PLAN_EDUCATIONAL_LABEL + self.assertTrue("Dear Jane," in mail.outbox[0].body) + self.assertTrue( + settings.IMPRESSO_GROUP_USER_PLAN_EDUCATIONAL_LABEL in mail.outbox[0].body, + f"should receive corrrect email:f{mail.outbox[0].body}", + ) + + # get user bitmap + user_bitmap = UserBitmap.objects.get(user=self.user) + + self.assertEqual( + user_bitmap.get_bitmap_as_int(), + UserBitmap.USER_PLAN_GUEST, + "user is Guest auntill they accept the terms", + ) + # accept the terms + user_bitmap.date_accepted_terms = timezone.now() + user_bitmap.save() + user_bitmap.refresh_from_db() + self.assertEqual( + str(user_bitmap), + f"testuser Bitmap {bin(UserBitmap.USER_PLAN_EDUCATIONAL)}", + "User plan USER_PLAN_EDUCATIONAL activated!", + ) + + # now we manually reject the request, as approving was an error + req.status = UserChangePlanRequest.STATUS_REJECTED + req.notes = "Wrong acceptance!" + req.save() + self.user.groups.remove(req.plan) + user_bitmap.refresh_from_db() + self.assertEqual( + str(user_bitmap), + f"testuser Bitmap {bin(UserBitmap.USER_PLAN_AUTH_USER)}", + "User plan back to USER_PLAN_AUTH_USER !", + ) + mail.outbox = [] + send_email_plan_change_rejected( + user_id=self.user.id, plan=req.plan.name, logger=logger + ) + self.assertEqual(len(mail.outbox), 1) + + self.assertTrue("Dear Jane," in mail.outbox[0].body) + self.assertTrue("rejected" in mail.outbox[0].body) + self.assertTrue( + settings.IMPRESSO_GROUP_USER_PLAN_EDUCATIONAL_LABEL in mail.outbox[0].body, + f"should receive corrrect email:f{mail.outbox[0].body}", + ) diff --git a/impresso/utils/tasks/account.py b/impresso/utils/tasks/account.py index 4764351..6ddc70a 100644 --- a/impresso/utils/tasks/account.py +++ b/impresso/utils/tasks/account.py @@ -1,6 +1,9 @@ import logging +import smtplib +from logging import Logger from django.core import mail -from django.contrib.auth.models import User +from django.contrib.auth.models import User, Group +from ...models import UserChangePlanRequest from django_registration.backends.activation.views import RegistrationView from django.core.mail import EmailMultiAlternatives from django.template.loader import render_to_string @@ -9,7 +12,17 @@ default_logger = logging.getLogger(__name__) -def getEmailsContents(prefix, context): +def getEmailsContents(prefix: str, context: dict) -> tuple[str, str]: + """ + Renders email contents in both text and HTML formats. + + Args: + prefix (str): The prefix used to identify the email template files. + context (dict): The context dictionary to be used for rendering the templates. + + Returns: + tuple[str, str]: A tuple containing the rendered text content and HTML content. + """ txt_content = render_to_string(f"emails/{prefix}.txt", context=context) html_content = render_to_string(f"emails/{prefix}.html", context=context) return txt_content, html_content @@ -106,11 +119,24 @@ def send_emails_after_user_activation(user_id, logger=default_logger): def send_email_password_reset( - user_id, - token="token", - callback_url="https://impresso-project.ch/app/reset-password", - logger=default_logger, -): + user_id: int, + token: str = "token", + callback_url: str = "https://impresso-project.ch/app/reset-password", + logger: Logger = default_logger, +) -> None: + """ + Sends a password reset email to the user with the given user_id. + + Args: + user_id (int): The ID of the user to send the password reset email to. + token (str, optional): The token to include in the password reset link. Defaults to "token". + callback_url (str, optional): The URL to use for the password reset link. Defaults to "https://impresso-project.ch/app/reset-password". + logger (Logger, optional): The logger to use for logging information. Defaults to default_logger. + + Raises: + User.DoesNotExist: If no active user with the given user_id is found. + Exception: If there is an error sending the email. + """ try: user = User.objects.get(pk=user_id, is_active=True) except User.DoesNotExist: @@ -130,14 +156,322 @@ def send_email_password_reset( to=[ user.email, ], - cc=[ + cc=[], + reply_to=[ settings.DEFAULT_FROM_EMAIL, ], + ) + emailMessage.attach_alternative(html_content, "text/html") + emailMessage.send(fail_silently=False) + except smtplib.SMTPException as e: + logger.exception(f"SMTPException Error sending email: {e}") + except Exception as e: + logger.exception(f"Error sending email: {e}") + logger.info(f"Password reset email sent to user={user_id}") + + +def send_email_plan_change( + user_id: int, + plan: None, + logger: Logger = default_logger, +) -> None: + """ + Sends the message to change plan to staff and a receipt email back to the sender with the given user_id. + + Args: + user_id (int): The ID of the user that initiated the change plan request.". + logger (Logger, optional): The logger to use for logging information. Defaults to default_logger. + + Raises: + User.DoesNotExist: If no active user with the given user_id is found. + ValueError: If the plan is not in the available plans. + Exception: If there is an error sending the email. + """ + try: + user = User.objects.get(pk=user_id, is_active=True) + except User.DoesNotExist: + logger.error(f"user={user_id} NOT FOUND or is not active!") + raise + if plan not in settings.IMPRESSO_GROUP_USERS_AVAILABLE_PLANS: + logger.error( + f"user={user_id} bad request, plan is not in {settings.IMPRESSO_GROUP_USERS_AVAILABLE_PLANS}" + ) + raise ValueError( + f"plan={plan} is not in {settings.IMPRESSO_GROUP_USERS_AVAILABLE_PLANS}" + ) + return + # this suffix to get the right email template + plan_template_suffix = "_basic" + # label for the plan, plain string + plan_label = settings.IMPRESSO_GROUP_USER_PLAN_BASIC_LABEL + plan_group_name = settings.IMPRESSO_GROUP_USER_PLAN_BASIC + + if plan == settings.IMPRESSO_GROUP_USER_PLAN_RESEARCHER: + plan_template_suffix = "_researcher" + plan_label = settings.IMPRESSO_GROUP_USER_PLAN_RESEARCHER_LABEL + plan_group_name = settings.IMPRESSO_GROUP_USER_PLAN_RESEARCHER + elif plan == settings.IMPRESSO_GROUP_USER_PLAN_EDUCATIONAL: + plan_template_suffix = "_educational" + plan_label = settings.IMPRESSO_GROUP_USER_PLAN_EDUCATIONAL_LABEL + plan_group_name = settings.IMPRESSO_GROUP_USER_PLAN_EDUCATIONAL + + # check if the user already belongs to the group, print out user groups to be 100% sure + user_groups_names = [n for n in user.groups.values_list("name", flat=True)] + + logger.info( + f"user={user_id} Checking if user already associated groups groups={user_groups_names} ..." + ) + + if plan_group_name in user_groups_names: + logger.info( + f"user={user_id} already in the group={plan_group_name}, no need to change" + ) + return + + # add the user to the REQUEST group + + plan_as_group = Group.objects.get(name=plan_group_name) + logger.info( + f"user={user_id} creating or updating related UserChangePlanRequest to {plan_group_name}" + ) + + change_plan_request, created = UserChangePlanRequest.objects.get_or_create( + user=user, + defaults={ + "plan": plan_as_group, + }, + ) + + logger.info( + f"user={user_id} created={created} change_plan_request={change_plan_request}" + ) + + # email for the user as a receipt + prefix = f"account_plan_change_to{plan_template_suffix}" + logger.info( + f"user={user_id} Sending email to staff and user={user_id} with plan={plan} template={prefix}" + ) + + txt_content, html_content = getEmailsContents( + prefix=prefix, + context=( + { + "user": user, + "plan_to_name": plan_label, + "from_email": settings.DEFAULT_FROM_EMAIL, + } + ), + ) + email_being_sent_without_error = False + try: + emailMessage = EmailMultiAlternatives( + subject="Change plan for Impresso", + body=txt_content, + from_email=f"Impresso Team <{settings.DEFAULT_FROM_EMAIL}>", + to=[ + user.email, + ], + cc=[], reply_to=[ settings.DEFAULT_FROM_EMAIL, ], ) emailMessage.attach_alternative(html_content, "text/html") emailMessage.send(fail_silently=False) - except Exception: + email_being_sent_without_error = True + + logger.info(f"Password reset email sent to user={user_id}") + except smtplib.SMTPException as e: + logger.exception(f"SMTPException Error sending email: {e} to user={user_id}") + except Exception as e: + logger.exception(f"Error sending email: {e} to user={user_id}") + + # email for the staff + prefix = f"account_plan_change_to_{plan_template_suffix.split('_')[-1]}_staff" + logger.info( + f"Sending email to staff with plan={plan} for user={user_id} template={prefix}" + ) + + txt_content, html_content = getEmailsContents( + prefix=prefix, + context=( + { + "user": user, + "plan_to_name": plan_label, + "from_email": settings.DEFAULT_FROM_EMAIL, + "email_being_sent_without_error": email_being_sent_without_error, + } + ), + ) + + # send email to the staff + try: + emailMessage = EmailMultiAlternatives( + subject="Change plan for Impresso", + body=txt_content, + from_email=f"Impresso Team <{settings.DEFAULT_FROM_EMAIL}>", + to=[ + settings.DEFAULT_FROM_EMAIL, + ], + cc=[], + reply_to=[ + settings.DEFAULT_FROM_EMAIL, + ], + ) + emailMessage.attach_alternative(html_content, "text/html") + emailMessage.send(fail_silently=False) + logger.info(f"Password reset email sent to staff") + except smtplib.SMTPException as e: + logger.exception(f"SMTPException Error sending email: {e} to staff") + except Exception as e: + logger.exception(f"Error sending email: {e} to staff") + + +def send_email_plan_change_accepted( + user_id: int, + plan: str, + logger: Logger = default_logger, +) -> None: + """ + Sends the message that plan has changed to the requester user as email receipt given user_id. + + Args: + user_id (int): The ID of the user that initiated the change plan request.". + plan (None): The plan that was accepted. + logger (Logger, optional): The logger to use for logging information. Defaults to default_logger. + Raises: + User.DoesNotExist: If no active user with the given user_id is found (or they have been disabled in the meanwhile) + Exception: If there is an error sending the email. + """ + try: + user = User.objects.get(pk=user_id, is_active=True) + except User.DoesNotExist: + logger.error(f"user={user_id} NOT FOUND or is not active!") + raise + + if plan not in settings.IMPRESSO_GROUP_USERS_AVAILABLE_PLANS: + logger.error( + f"user={user_id} bad request, plan is not in {settings.IMPRESSO_GROUP_USERS_AVAILABLE_PLANS}" + ) + raise ValueError( + f"plan={plan} is not in {settings.IMPRESSO_GROUP_USERS_AVAILABLE_PLANS}" + ) + + plan_label = settings.IMPRESSO_GROUP_USER_PLAN_BASIC_LABEL + + if plan == settings.IMPRESSO_GROUP_USER_PLAN_RESEARCHER: + plan_label = settings.IMPRESSO_GROUP_USER_PLAN_RESEARCHER_LABEL + elif plan == settings.IMPRESSO_GROUP_USER_PLAN_EDUCATIONAL: + plan_label = settings.IMPRESSO_GROUP_USER_PLAN_EDUCATIONAL_LABEL + + prefix = f"account_plan_change_accepted" + logger.info( + f"user={user_id} Sending email to user={user_id} with plan={plan} template={prefix}" + ) + + txt_content, html_content = getEmailsContents( + prefix=prefix, + context=( + { + "user": user, + "plan_to_name": plan_label, + "from_email": settings.DEFAULT_FROM_EMAIL, + } + ), + ) + try: + emailMessage = EmailMultiAlternatives( + subject="Your Subscription Plan Change is Confirmed", + body=txt_content, + from_email=f"Impresso Team <{settings.DEFAULT_FROM_EMAIL}>", + to=[ + user.email, + ], + cc=[], + reply_to=[ + settings.DEFAULT_FROM_EMAIL, + ], + ) + emailMessage.attach_alternative(html_content, "text/html") + emailMessage.send(fail_silently=False) + logger.info(f"Plan change acceptance email sent to user={user_id}") + except smtplib.SMTPException as e: + logger.exception(f"SMTPException Error sending email: {e} to user={user_id}") + except Exception as e: + logger.exception(f"Error sending email: {e} to user={user_id}") + + +def send_email_plan_change_rejected( + user_id: int, + plan: str, + logger: Logger = default_logger, +) -> None: + """ + Sends the message that plan change has been rejected to the requester user as email receipt given user_id. + + Args: + user_id (int): The ID of the user that initiated the change plan request. + plan (str): The plan that was rejected. + logger (Logger, optional): The logger to use for logging information. Defaults to default_logger. + + Raises: + User.DoesNotExist: If no active user with the given user_id is found (or they have been disabled in the meanwhile). + ValueError: If the plan is not in the available plans. + Exception: If there is an error sending the email. + """ + try: + user = User.objects.get(pk=user_id, is_active=True) + except User.DoesNotExist: + logger.error(f"user={user_id} NOT FOUND or is not active!") raise + + if plan not in settings.IMPRESSO_GROUP_USERS_AVAILABLE_PLANS: + logger.error( + f"user={user_id} bad request, plan is not in {settings.IMPRESSO_GROUP_USERS_AVAILABLE_PLANS}" + ) + raise ValueError( + f"plan={plan} is not in {settings.IMPRESSO_GROUP_USERS_AVAILABLE_PLANS}" + ) + + plan_label = settings.IMPRESSO_GROUP_USER_PLAN_BASIC_LABEL + + if plan == settings.IMPRESSO_GROUP_USER_PLAN_RESEARCHER: + plan_label = settings.IMPRESSO_GROUP_USER_PLAN_RESEARCHER_LABEL + elif plan == settings.IMPRESSO_GROUP_USER_PLAN_EDUCATIONAL: + plan_label = settings.IMPRESSO_GROUP_USER_PLAN_EDUCATIONAL_LABEL + + prefix = f"account_plan_change_rejected" + logger.info( + f"user={user_id} Sending email to user={user_id} with plan={plan} template={prefix}" + ) + + txt_content, html_content = getEmailsContents( + prefix=prefix, + context=( + { + "user": user, + "plan_to_name": plan_label, + "from_email": settings.DEFAULT_FROM_EMAIL, + } + ), + ) + try: + emailMessage = EmailMultiAlternatives( + subject="Your Subscription Plan Change Request is Rejected", + body=txt_content, + from_email=f"Impresso Team <{settings.DEFAULT_FROM_EMAIL}>", + to=[ + user.email, + ], + cc=[], + reply_to=[ + settings.DEFAULT_FROM_EMAIL, + ], + ) + emailMessage.attach_alternative(html_content, "text/html") + emailMessage.send(fail_silently=False) + logger.info(f"Plan change rejection email sent to user={user_id}") + except smtplib.SMTPException as e: + logger.exception(f"SMTPException Error sending email: {e} to user={user_id}") + except Exception as e: + logger.exception(f"Error sending email: {e} to user={user_id}")