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 = "
"
+ for entry in obj.changelog:
+ date = timezone.datetime.fromisoformat(entry["date"]).strftime(
+ "%Y-%m-%d %H:%M:%S"
+ )
+
+ html += (
+ f"- {entry['plan']}
{date} ({entry['status']}) "
+ )
+ 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}")