Repository: edubadges/edubadges-server
Pull request #182: WIP for F0X2cE1G-direct-awards-reminders
Author: login: oharsta
Created: 2025-04-23T11:20:47Z | Merged: 2025-04-24T05:44:46Z

https://trello.com/c/F0X2cE1G/1006-verzamel-issue-nieuwe-direct-award-flow-zonder-claimen-bij-bekende-gebruikers


Diff:
diff --git a/apps/directaward/api.py b/apps/directaward/api.py
index f40fef54f..b149dc8b3 100644
--- a/apps/directaward/api.py
+++ b/apps/directaward/api.py
@@ -728,7 +728,7 @@ def put(self, request, **kwargs):
         revocation_reason = request.data.get('revocation_reason', None)
         if not direct_awards:
             raise BadgrValidationFieldError('direct_awards', 'This field is required', 999)
-        delete_at = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=settings.DIRECT_AWARDS_DELETION_THRESHOLD_DAYS)
+        delete_at = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=settings.DIRECT_AWARDS_DELETION_THRESHOLD_DAYS)
         un_successful_direct_awards = []
         successful_direct_awards = []
         for direct_award in direct_awards:
diff --git a/apps/directaward/migrations/0019_remove_directaward_warning_email_send_and_more.py b/apps/directaward/migrations/0019_remove_directaward_warning_email_send_and_more.py
new file mode 100644
index 000000000..834e2dbc6
--- /dev/null
+++ b/apps/directaward/migrations/0019_remove_directaward_warning_email_send_and_more.py
@@ -0,0 +1,22 @@
+# Generated by Django 4.2.17 on 2025-04-22 13:49
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('directaward', '0018_auto_20241104_1117'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='directaward',
+            name='warning_email_send',
+        ),
+        migrations.AddField(
+            model_name='directaward',
+            name='reminders',
+            field=models.IntegerField(default=0),
+        ),
+    ]
diff --git a/apps/directaward/migrations/0020_directaward_expiration_date.py b/apps/directaward/migrations/0020_directaward_expiration_date.py
new file mode 100644
index 000000000..2c6acf583
--- /dev/null
+++ b/apps/directaward/migrations/0020_directaward_expiration_date.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.17 on 2025-04-23 09:07
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('directaward', '0019_remove_directaward_warning_email_send_and_more'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='directaward',
+            name='expiration_date',
+            field=models.DateTimeField(blank=True, default=None, null=True),
+        ),
+    ]
diff --git a/apps/directaward/models.py b/apps/directaward/models.py
index 582e2213d..4cad8a495 100644
--- a/apps/directaward/models.py
+++ b/apps/directaward/models.py
@@ -28,7 +28,7 @@ class DirectAward(BaseAuditedModel, BaseVersionedEntity, CacheModel):
     narrative = models.TextField(blank=True, null=True, default=None)
     name = models.CharField(max_length=255, blank=True, null=True, default=None)
     description = models.TextField(blank=True, null=True, default=None)
-    warning_email_send = models.BooleanField(default=False)
+    reminders = models.IntegerField(default=0, blank=False, null=False)
     grade_achieved = models.CharField(
         max_length=254, blank=True, null=True, default=None
     )
@@ -53,6 +53,7 @@ class DirectAward(BaseAuditedModel, BaseVersionedEntity, CacheModel):
     )
     resend_at = models.DateTimeField(blank=True, null=True, default=None)
     delete_at = models.DateTimeField(blank=True, null=True, default=None)
+    expiration_date = models.DateTimeField(blank=True, null=True, default=None)
 
     def validate_unique(self, exclude=None):
         if (self.__class__.objects.filter(eppn=self.eppn, badgeclass=self.badgeclass, status="Unaccepted",
@@ -131,17 +132,13 @@ def get_permissions(self, user):
 
     def notify_recipient(self):
         html_message = EmailMessageMaker.create_direct_award_student_mail(self)
-        try:
-            EmailBlacklist.objects.get(email=self.recipient_email)
-        except EmailBlacklist.DoesNotExist:
-            # Allow sending, as this email is not blacklisted.
-            plain_text = strip_tags(html_message)
-            send_mail(
-                subject="Je hebt een edubadge ontvangen. You received an edubadge. Claim it now!",
-                message=plain_text,
-                html_message=html_message,
-                recipient_list=[self.recipient_email],
-            )
+        plain_text = strip_tags(html_message)
+        send_mail(
+            subject="Je hebt een edubadge ontvangen. You received an edubadge. Claim it now!",
+            message=plain_text,
+            html_message=html_message,
+            recipient_list=[self.recipient_email],
+        )
 
 
 class DirectAwardBundle(BaseAuditedModel, BaseVersionedEntity, CacheModel):
diff --git a/apps/directaward/schema.py b/apps/directaward/schema.py
index 89f93bed4..0859b5f62 100644
--- a/apps/directaward/schema.py
+++ b/apps/directaward/schema.py
@@ -8,7 +8,7 @@ class DirectAwardType(DjangoObjectType):
     class Meta:
         model = DirectAward
         fields = ('entity_id', 'eppn', 'status', 'recipient_email', 'badgeclass', 'created_at', 'updated_at',
-                  'resend_at', 'delete_at')
+                  'resend_at', 'delete_at', 'expiration_date')
 
 
 class DirectAwardBundleType(DjangoObjectType):
diff --git a/apps/directaward/serializer.py b/apps/directaward/serializer.py
index e2b3cf8a0..c5ee37730 100644
--- a/apps/directaward/serializer.py
+++ b/apps/directaward/serializer.py
@@ -1,6 +1,6 @@
 import re
 import threading
-
+import datetime
 from django.core.exceptions import ValidationError, BadRequest
 from django.db import transaction
 from rest_framework import serializers
@@ -8,6 +8,7 @@
 from directaward.models import DirectAward, DirectAwardBundle
 from directaward.signals import audit_trail_signal
 from issuer.serializers import BadgeClassSlugRelatedField
+from mainsite import settings
 from mainsite.exceptions import BadgrValidationError
 
 
@@ -104,11 +105,15 @@ def create(self, validated_data):
                 direct_award_bundle.badgeclass.remove_cached_data(["cached_direct_award_bundles"])
 
                 eppn_required = validated_data.get("identifier_type", "eppn") == "eppn"
+                now = datetime.datetime.now(datetime.timezone.utc)
+                expiration_date = now + datetime.timedelta(days=settings.EXPIRY_DIRECT_AWARDS_DELETION_THRESHOLD_DAYS)
                 for direct_award in direct_awards:
                     # Not required and already validated
                     direct_award["eppn"] = direct_award["eppn"].lower() if eppn_required else None
                     status = (DirectAward.STATUS_SCHEDULED if scheduled_at else DirectAward.STATUS_UNACCEPTED)
                     direct_award["status"] = status
+
+                    direct_award["expiration_date"] = expiration_date
                     try:
                         da_created = DirectAward.objects.create(
                             bundle=direct_award_bundle,
diff --git a/apps/mainsite/management/commands/delete_direct_awards.py b/apps/mainsite/management/commands/delete_direct_awards.py
index 4f086dedf..ba357a700 100644
--- a/apps/mainsite/management/commands/delete_direct_awards.py
+++ b/apps/mainsite/management/commands/delete_direct_awards.py
@@ -1,10 +1,14 @@
 import logging
-from datetime import datetime
+import datetime
 
 from django.core.management.base import BaseCommand
 from django.db import connections
 from django.utils.timezone import make_aware
 
+def _remove_cached_direct_awards(direct_award):
+    direct_award.badgeclass.remove_cached_data(
+        ['cached_direct_awards', 'cached_pending_direct_awards', 'cached_direct_award_bundles'])
+    direct_award.bundle.remove_cached_data(['cached_direct_awards'])
 
 class Command(BaseCommand):
     """A command to delete direct awards with the status Deleted."""
@@ -18,10 +22,12 @@ def handle(self, *args, **kwargs):
         logger = logging.getLogger('Badgr.Debug')
         logger.info("Running delete_direct_awards")
 
-        direct_awards = DirectAward.objects.filter(delete_at__lt=make_aware(datetime.utcnow()),
+        now = datetime.datetime.now(datetime.timezone.utc)
+        direct_awards = DirectAward.objects.filter(delete_at__lt=make_aware(now),
                                                    status='Deleted').all()
 
         for direct_award in direct_awards:
+            _remove_cached_direct_awards(direct_award)
             direct_award.delete()
 
         logger.info(f"Deleted {len(direct_awards)} direct_awards")
diff --git a/apps/mainsite/management/commands/reminders_direct_awards.py b/apps/mainsite/management/commands/reminders_direct_awards.py
new file mode 100644
index 000000000..e3415ecdc
--- /dev/null
+++ b/apps/mainsite/management/commands/reminders_direct_awards.py
@@ -0,0 +1,72 @@
+import logging
+import datetime
+
+from mainsite import settings
+from django.core.management.base import BaseCommand
+from django.db import connections
+
+
+def _remove_cached_direct_awards(direct_award):
+    direct_award.badgeclass.remove_cached_data(
+        ['cached_direct_awards', 'cached_pending_direct_awards', 'cached_direct_award_bundles'])
+    direct_award.bundle.remove_cached_data(['cached_direct_awards'])
+
+
+class Command(BaseCommand):
+    """A command to send reminders for unclaimed and open direct awards."""
+
+    def handle(self, *args, **kwargs):
+        from directaward.models import DirectAward
+        from mainsite.utils import EmailMessageMaker
+        from mainsite.utils import send_mail
+
+        # Prevent MySQLdb._exceptions.OperationalError: (2006, 'MySQL server has gone away')
+        connections.close_all()
+
+        logger = logging.getLogger('Badgr.Debug')
+        logger.info("Running reminders_direct_awards")
+
+        now = datetime.datetime.now(datetime.timezone.utc)
+
+        # We store the reminder number (first reminders sent = 1) to ensure we don't spam the user
+        threshold_days = settings.EXPIRY_DIRECT_AWARDS_REMINDER_THRESHOLD_DAYS.split(",")
+        threshold_days = [int(days.strip()) for days in threshold_days]
+        # We need to process them in reverse order
+        threshold_days.sort(reverse=True)
+        index = len(threshold_days) - 1
+        unaccepted = 'Unaccepted'
+        for days in threshold_days:
+            reminder_date = now - datetime.timedelta(days=days)
+            direct_awards = DirectAward.objects.filter(created_at__lt=reminder_date,
+                                                       reminders=index,
+                                                       expiration_date__isnull=False,
+                                                       status=unaccepted).all()
+            logger.info(f"Sending {len(direct_awards)} reminder emails for reminder: {index}, threshold: {days}")
+
+            for direct_award in direct_awards:
+                html_message = EmailMessageMaker.direct_award_reminder_student_mail(direct_award)
+                direct_award.reminders = index + 1
+                _remove_cached_direct_awards(direct_award)
+                direct_award.save()
+                send_mail(subject='Reminder: your edubadge will expire',
+                          message=None,
+                          html_message=html_message,
+                          recipient_list=[direct_award.recipient_email])
+            index -= 1
+
+        direct_awards = DirectAward.objects.filter(expiration_date__lt=now,
+                                                   status=unaccepted).all()
+
+        logger.info(f"Deleting {len(direct_awards)} expired direct_awards")
+
+        for direct_award in direct_awards:
+            html_message = EmailMessageMaker.direct_award_expired_student_mail(direct_award)
+            _remove_cached_direct_awards(direct_award)
+            direct_award.delete()
+            send_mail(subject='Your edubadge has been deleted',
+                      message=None,
+                      html_message=html_message,
+                      recipient_list=[direct_award.recipient_email])
+
+        self.stdout.write(f"Direct awards {len(direct_awards)} deleted!\n")
+
diff --git a/apps/mainsite/settings.py b/apps/mainsite/settings.py
index 96ca8ef14..61a25edba 100644
--- a/apps/mainsite/settings.py
+++ b/apps/mainsite/settings.py
@@ -203,7 +203,10 @@ def legacy_boolean_parsing(env_key, default_value):
 EDUID_API_BASE_URL = os.environ.get('EDUID_API_BASE_URL', 'https://login.test.eduid.nl')
 EDUID_IDENTIFIER = os.environ.get('EDUID_IDENTIFIER', 'eduid')
 
+EXPIRY_DIRECT_AWARDS_REMINDER_THRESHOLD_DAYS = str(os.environ.get('EXPIRY_DIRECT_AWARDS_REMINDER_THRESHOLD_DAYS', '42, 62'))
+EXPIRY_DIRECT_AWARDS_DELETION_THRESHOLD_DAYS = int(os.environ.get('EXPIRY_DIRECT_AWARDS_DELETION_THRESHOLD_DAYS', 82))
 DIRECT_AWARDS_DELETION_THRESHOLD_DAYS = int(os.environ.get('DIRECT_AWARDS_DELETION_THRESHOLD_DAYS', 30))
+
 OB3_AGENT_URL_SPHEREON = os.environ.get('OB3_AGENT_URL_SPHEREON', '')
 OB3_AGENT_AUTHZ_TOKEN_SPHEREON = os.environ.get('OB3_AGENT_AUTHZ_TOKEN_SPHEREON', '')
 OB3_AGENT_URL_UNIME = os.environ.get('OB3_AGENT_URL_UNIME', '')
diff --git a/apps/mainsite/templates/email/reminder_direct_award_new.html b/apps/mainsite/templates/email/reminder_direct_award_new.html
index cc0947ae1..bff26947b 100644
--- a/apps/mainsite/templates/email/reminder_direct_award_new.html
+++ b/apps/mainsite/templates/email/reminder_direct_award_new.html
@@ -43,9 +43,8 @@
         font-size: 12px;
         font-weight: bold;
         width: 100%;
-        margin-top: 30px;
         margin: 15px 0;
-        padding: 14px 0px;
+        padding: 14px 0;
     }
 
     .example_container{
@@ -64,7 +63,7 @@
         margin: 15px 0px 0px 10px;
     }
     .badgeclass_image{
-        width: available;
+        width: 100%;
     }
     .issuer_container {
         margin-top: 10px;
@@ -72,7 +71,7 @@
     }
     .issuer_description{
         text-align: left;
-        padding: 10px 0px 10px 10px;
+        padding: 10px 0 10px 10px;
         float: left;
     }
     .issuer_description p {
diff --git a/apps/mainsite/utils.py b/apps/mainsite/utils.py
index 972ba687a..350545408 100644
--- a/apps/mainsite/utils.py
+++ b/apps/mainsite/utils.py
@@ -3,6 +3,7 @@
 """
 
 import base64
+import datetime
 import hashlib
 import io
 import math
@@ -13,7 +14,6 @@
 import urllib.parse
 import uuid
 import webbrowser
-from datetime import datetime
 from io import BytesIO
 from xml.etree import cElementTree as ET
 
@@ -290,7 +290,7 @@ def create_staff_member_addition_email(new_staff_membership):
     @staticmethod
     def create_direct_award_student_mail(direct_award):
         badgeclass = direct_award.badgeclass
-        template = 'email/earned_direct_award.html'
+        template = 'email/earned_direct_award_new.html'
         badgeclass_image = EmailMessageMaker._create_example_image(badgeclass)
         email_vars = {
             'badgeclass_image': badgeclass_image,
@@ -300,6 +300,8 @@ def create_direct_award_student_mail(direct_award):
             'ui_url': urllib.parse.urljoin(settings.UI_URL, 'direct-awards'),
             'badgeclass_description': badgeclass.description,
             'badgeclass_name': badgeclass.name,
+            'institution_name': badgeclass.issuer.faculty.institution.name,
+            'da_enddate': direct_award.expiration_date.strftime('%d %B %Y')
         }
         return render_to_string(template, email_vars)
 
@@ -319,7 +321,7 @@ def create_enrolment_notification_mail(badge_class, student, created_enrollment)
     @staticmethod
     def direct_award_reminder_student_mail(direct_award):
         badgeclass = direct_award.badgeclass
-        template = 'email/reminder_direct_award.html'
+        template = 'email/reminder_direct_award_new.html'
         badgeclass_image = EmailMessageMaker._create_example_image(badgeclass)
         email_vars = {
             'badgeclass_image': badgeclass_image,
@@ -329,13 +331,15 @@ def direct_award_reminder_student_mail(direct_award):
             'ui_url': urllib.parse.urljoin(settings.UI_URL, 'direct-awards'),
             'badgeclass_description': badgeclass.description,
             'badgeclass_name': badgeclass.name,
+            'institution_name': badgeclass.issuer.faculty.institution.name,
+            'da_enddate': direct_award.expiration_date.strftime('%d %B %Y')
         }
         return render_to_string(template, email_vars)
 
     @staticmethod
     def direct_award_expired_student_mail(direct_award):
         badgeclass = direct_award.badgeclass
-        template = 'email/expired_direct_award.html'
+        template = 'email/expired_direct_award_new.html'
         badgeclass_image = EmailMessageMaker._create_example_image(badgeclass)
         email_vars = {
             'badgeclass_image': badgeclass_image,
@@ -345,6 +349,8 @@ def direct_award_expired_student_mail(direct_award):
             'ui_url': urllib.parse.urljoin(settings.UI_URL, 'direct-awards'),
             'badgeclass_description': badgeclass.description,
             'badgeclass_name': badgeclass.name,
+            'institution_name': badgeclass.issuer.faculty.institution.name,
+            'da_enddate': direct_award.expiration_date
         }
         return render_to_string(template, email_vars)
 
@@ -394,7 +400,7 @@ def create_feedback_mail(current_user, message):
         template = 'email/feedback.html'
         email_vars = {
             'current_user': current_user,
-            'date': datetime.now(),
+            'date': datetime.datetime.now(datetime.timezone.utc),
             'environment': settings.DOMAIN,
             'message': message,
         }
diff --git a/apps/queries/api.py b/apps/queries/api.py
index ae8c94616..bf4df2a1a 100644
--- a/apps/queries/api.py
+++ b/apps/queries/api.py
@@ -38,6 +38,7 @@ def get(self, request, **kwargs):
         with connection.cursor() as cursor:
             cursor.execute(f"""
 select da.created_at, da.resend_at, da.delete_at, da.recipient_email as recipientEmail, da.eppn, da.entity_id as entityId,
+        da.expiration_date,
         bc.name, bc.entity_id as bc_entity_id,
         i.name_english as i_name_english, i.name_dutch as i_name_dutch, i.entity_id as i_entity_id, 
         f.name_english as f_name_english, f.name_dutch as f_name_dutch, f.entity_id as f_entity_id


Reviews:
user: login: Iso5786
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T15:00:07Z
user: login: oharsta
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T15:05:31Z
user: login: oharsta
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T04:40:16Z
user: login: Iso5786
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T05:44:41Z

Review comments:
diff_hunk: @@ -0,0 +1,72 @@
+import logging
+import datetime
+
+from mainsite import settings
+from django.core.management.base import BaseCommand
+from django.db import connections
+
+
+def _remove_cached_direct_awards(direct_award):
+    direct_award.badgeclass.remove_cached_data(
+        ['cached_direct_awards', 'cached_pending_direct_awards', 'cached_direct_award_bundles'])
+    direct_award.bundle.remove_cached_data(['cached_direct_awards'])
+
+
+class Command(BaseCommand):
+    """A command to send reminders for unclaimed and open direct awards."""
+
+    def handle(self, *args, **kwargs):
+        from directaward.models import DirectAward
+        from mainsite.utils import EmailMessageMaker
+        from mainsite.utils import send_mail
+
+        # Prevent MySQLdb._exceptions.OperationalError: (2006, 'MySQL server has gone away')
+        connections.close_all()
+
+        logger = logging.getLogger('Badgr.Debug')
+        logger.info("Running reminders_direct_awards")
+
+        now = datetime.datetime.now(datetime.timezone.utc)
+
+        # We store the reminder number (first reminders sent = 1) to ensure we don't spam the user
+        threshold_days = settings.EXPIRY_DIRECT_AWARDS_REMINDER_THRESHOLD_DAYS.split(",")
+        threshold_days = [int(days.strip()) for days in threshold_days]
+        # We need to process them in reverse order
+        threshold_days.sort(reverse=True)
+        index = len(threshold_days) - 1
+        unaccepted = 'Unaccepted'
+        for days in threshold_days:
+            reminder_date = now - datetime.timedelta(days=days)
+            direct_awards = DirectAward.objects.filter(created_at__lt=reminder_date,
+                                                       reminders=index,
+                                                       expiration_date__isnull=False,
+                                                       status=unaccepted).all()
+            logger.info(f"Sending {len(direct_awards)} reminder emails for reminder: {index}, threshold: {days}")
+
+            for direct_award in direct_awards:
+                html_message = EmailMessageMaker.direct_award_reminder_student_mail(direct_award)
+                direct_award.reminders = index + 1
+                _remove_cached_direct_awards(direct_award)
+                direct_award.save()
+                send_mail(subject='Reminder: your edubadge will expire',
+                          message=None,
+                          html_message=html_message,
+                          recipient_list=[direct_award.recipient_email])
+            index -= 1
+
+        direct_awards = DirectAward.objects.filter(expiration_date__lt=now,
path: apps/mainsite/management/commands/reminders_direct_awards.py
user: login: Iso5786
body: So this means that the expiration date is fixed when creating the DA, however, we can still adjust the reminder days after the DA is created. I think good to discuss, otherwise, can lead to confusion in the future?!
created_at: 2025-04-23T14:59:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,72 @@
+import logging
+import datetime
+
+from mainsite import settings
+from django.core.management.base import BaseCommand
+from django.db import connections
+
+
+def _remove_cached_direct_awards(direct_award):
+    direct_award.badgeclass.remove_cached_data(
+        ['cached_direct_awards', 'cached_pending_direct_awards', 'cached_direct_award_bundles'])
+    direct_award.bundle.remove_cached_data(['cached_direct_awards'])
+
+
+class Command(BaseCommand):
+    """A command to send reminders for unclaimed and open direct awards."""
+
+    def handle(self, *args, **kwargs):
+        from directaward.models import DirectAward
+        from mainsite.utils import EmailMessageMaker
+        from mainsite.utils import send_mail
+
+        # Prevent MySQLdb._exceptions.OperationalError: (2006, 'MySQL server has gone away')
+        connections.close_all()
+
+        logger = logging.getLogger('Badgr.Debug')
+        logger.info("Running reminders_direct_awards")
+
+        now = datetime.datetime.now(datetime.timezone.utc)
+
+        # We store the reminder number (first reminders sent = 1) to ensure we don't spam the user
+        threshold_days = settings.EXPIRY_DIRECT_AWARDS_REMINDER_THRESHOLD_DAYS.split(",")
+        threshold_days = [int(days.strip()) for days in threshold_days]
+        # We need to process them in reverse order
+        threshold_days.sort(reverse=True)
+        index = len(threshold_days) - 1
+        unaccepted = 'Unaccepted'
+        for days in threshold_days:
+            reminder_date = now - datetime.timedelta(days=days)
+            direct_awards = DirectAward.objects.filter(created_at__lt=reminder_date,
+                                                       reminders=index,
+                                                       expiration_date__isnull=False,
+                                                       status=unaccepted).all()
+            logger.info(f"Sending {len(direct_awards)} reminder emails for reminder: {index}, threshold: {days}")
+
+            for direct_award in direct_awards:
+                html_message = EmailMessageMaker.direct_award_reminder_student_mail(direct_award)
+                direct_award.reminders = index + 1
+                _remove_cached_direct_awards(direct_award)
+                direct_award.save()
+                send_mail(subject='Reminder: your edubadge will expire',
+                          message=None,
+                          html_message=html_message,
+                          recipient_list=[direct_award.recipient_email])
+            index -= 1
+
+        direct_awards = DirectAward.objects.filter(expiration_date__lt=now,
path: apps/mainsite/management/commands/reminders_direct_awards.py
user: login: oharsta
body: Agree, let's discuss.
created_at: 2025-04-23T15:05:31Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,72 @@
+import logging
+import datetime
+
+from mainsite import settings
+from django.core.management.base import BaseCommand
+from django.db import connections
+
+
+def _remove_cached_direct_awards(direct_award):
+    direct_award.badgeclass.remove_cached_data(
+        ['cached_direct_awards', 'cached_pending_direct_awards', 'cached_direct_award_bundles'])
+    direct_award.bundle.remove_cached_data(['cached_direct_awards'])
+
+
+class Command(BaseCommand):
+    """A command to send reminders for unclaimed and open direct awards."""
+
+    def handle(self, *args, **kwargs):
+        from directaward.models import DirectAward
+        from mainsite.utils import EmailMessageMaker
+        from mainsite.utils import send_mail
+
+        # Prevent MySQLdb._exceptions.OperationalError: (2006, 'MySQL server has gone away')
+        connections.close_all()
+
+        logger = logging.getLogger('Badgr.Debug')
+        logger.info("Running reminders_direct_awards")
+
+        now = datetime.datetime.now(datetime.timezone.utc)
+
+        # We store the reminder number (first reminders sent = 1) to ensure we don't spam the user
+        threshold_days = settings.EXPIRY_DIRECT_AWARDS_REMINDER_THRESHOLD_DAYS.split(",")
+        threshold_days = [int(days.strip()) for days in threshold_days]
+        # We need to process them in reverse order
+        threshold_days.sort(reverse=True)
+        index = len(threshold_days) - 1
+        unaccepted = 'Unaccepted'
+        for days in threshold_days:
+            reminder_date = now - datetime.timedelta(days=days)
+            direct_awards = DirectAward.objects.filter(created_at__lt=reminder_date,
+                                                       reminders=index,
+                                                       expiration_date__isnull=False,
+                                                       status=unaccepted).all()
+            logger.info(f"Sending {len(direct_awards)} reminder emails for reminder: {index}, threshold: {days}")
+
+            for direct_award in direct_awards:
+                html_message = EmailMessageMaker.direct_award_reminder_student_mail(direct_award)
+                direct_award.reminders = index + 1
+                _remove_cached_direct_awards(direct_award)
+                direct_award.save()
+                send_mail(subject='Reminder: your edubadge will expire',
+                          message=None,
+                          html_message=html_message,
+                          recipient_list=[direct_award.recipient_email])
+            index -= 1
+
+        direct_awards = DirectAward.objects.filter(expiration_date__lt=now,
path: apps/mainsite/management/commands/reminders_direct_awards.py
user: login: oharsta
body: @Iso5786 There are two reasons for the fixation of the expiry_date. First one is that we communicate the expiry_date in the mail sent to the users. And secondly we can't determine the expiry_date runtime, as we do for the first and second reminder, because we need to differentiate between existing DA's and new DA's. For the existing DA's we need an extra date column to process them batch-wise. Without an extra fixed expiry_date we can't do that.
created_at: 2025-04-24T04:40:16Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,72 @@
+import logging
+import datetime
+
+from mainsite import settings
+from django.core.management.base import BaseCommand
+from django.db import connections
+
+
+def _remove_cached_direct_awards(direct_award):
+    direct_award.badgeclass.remove_cached_data(
+        ['cached_direct_awards', 'cached_pending_direct_awards', 'cached_direct_award_bundles'])
+    direct_award.bundle.remove_cached_data(['cached_direct_awards'])
+
+
+class Command(BaseCommand):
+    """A command to send reminders for unclaimed and open direct awards."""
+
+    def handle(self, *args, **kwargs):
+        from directaward.models import DirectAward
+        from mainsite.utils import EmailMessageMaker
+        from mainsite.utils import send_mail
+
+        # Prevent MySQLdb._exceptions.OperationalError: (2006, 'MySQL server has gone away')
+        connections.close_all()
+
+        logger = logging.getLogger('Badgr.Debug')
+        logger.info("Running reminders_direct_awards")
+
+        now = datetime.datetime.now(datetime.timezone.utc)
+
+        # We store the reminder number (first reminders sent = 1) to ensure we don't spam the user
+        threshold_days = settings.EXPIRY_DIRECT_AWARDS_REMINDER_THRESHOLD_DAYS.split(",")
+        threshold_days = [int(days.strip()) for days in threshold_days]
+        # We need to process them in reverse order
+        threshold_days.sort(reverse=True)
+        index = len(threshold_days) - 1
+        unaccepted = 'Unaccepted'
+        for days in threshold_days:
+            reminder_date = now - datetime.timedelta(days=days)
+            direct_awards = DirectAward.objects.filter(created_at__lt=reminder_date,
+                                                       reminders=index,
+                                                       expiration_date__isnull=False,
+                                                       status=unaccepted).all()
+            logger.info(f"Sending {len(direct_awards)} reminder emails for reminder: {index}, threshold: {days}")
+
+            for direct_award in direct_awards:
+                html_message = EmailMessageMaker.direct_award_reminder_student_mail(direct_award)
+                direct_award.reminders = index + 1
+                _remove_cached_direct_awards(direct_award)
+                direct_award.save()
+                send_mail(subject='Reminder: your edubadge will expire',
+                          message=None,
+                          html_message=html_message,
+                          recipient_list=[direct_award.recipient_email])
+            index -= 1
+
+        direct_awards = DirectAward.objects.filter(expiration_date__lt=now,
path: apps/mainsite/management/commands/reminders_direct_awards.py
user: login: Iso5786
body: Ok, indeed, I agree, thx for explanation. However, if we not set the reminder dates when creating the DA, it could happen that one reminder is sent, then the env vars are updated to increase the reminder dates, and then no second reminder could be sent out as it is after expiry date. I do not think it really will be an issue but should be documented somewhere.
created_at: 2025-04-24T05:44:41Z
author_association: CONTRIBUTOR

PR comments:
user: login: Iso5786
created_at: 2025-04-23T14:57:42Z
body:  I do not see that the "reminder_direct_award_new.html" mail template is used (see also method direct_award_reminder_student_email?)
author_association: CONTRIBUTOR
user: login: oharsta
created_at: 2025-04-23T15:09:30Z
body: > reminder_direct_award_new.html

https://github.com/edubadges/edubadges-server/commit/461c09432d1c6b75fa795f9513a97c8ad14fa897#diff-ad6852bf2d2fba0a3411adc70747da41ba70cef2888ee2406fcc544573a70c74R324
author_association: MEMBER
user: login: Iso5786
created_at: 2025-04-23T15:20:29Z
body: > > reminder_direct_award_new.html
> 
> [461c094#diff-ad6852bf2d2fba0a3411adc70747da41ba70cef2888ee2406fcc544573a70c74R324](https://github.com/edubadges/edubadges-server/commit/461c09432d1c6b75fa795f9513a97c8ad14fa897#diff-ad6852bf2d2fba0a3411adc70747da41ba70cef2888ee2406fcc544573a70c74R324)

Oh, locally I had develop checked-out, and obviously the "new" template was not known there, my bad!
author_association: CONTRIBUTOR