From 6d5830ef74c28f9d8fc24a11b3c77fa774013c22 Mon Sep 17 00:00:00 2001 From: Daniele Guido Date: Thu, 29 Jan 2026 09:24:16 +0100 Subject: [PATCH 1/5] Update copilot-instructions.md --- .github/copilot-instructions.md | 35 ++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4e6bdb8..ec7dd96 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -57,6 +57,7 @@ Common task utilities are provided in `impresso/utils/tasks/__init__.py`: - `is_task_stopped()` - Check if user has stopped a job Task states: + - `TASKSTATE_INIT` - Task initialization - `TASKSTATE_PROGRESS` - Task in progress - `TASKSTATE_SUCCESS` - Task completed successfully @@ -89,6 +90,7 @@ Task states: - Use structured logging with task context (job_id, user_id) Example task pattern: + ```python from celery import shared_task from celery.utils.log import get_task_logger @@ -131,8 +133,6 @@ def my_task(self, user_id: int) -> None: - Handle SMTP exceptions gracefully - Log email sending status - - ### Job Management - Jobs track long-running asynchronous tasks @@ -203,7 +203,10 @@ ENV=dev pipenv run celery -A impresso worker -l info pipenv run mypy --config-file ./.mypy.ini impresso ``` -### Common Commands +### Django custom Commands + +- Custom management commands are located in `impresso/management/commands/` +- Example commands include creating accounts, stopping jobs, and updating user bitmaps. ```bash # Create accounts @@ -216,6 +219,23 @@ ENV=dev pipenv run ./manage.py stopjob ENV=dev pipenv run ./manage.py updateuserbitmap ``` +Example command structure, use help and logging extensively for clarity nd ALWAYS use typings as much as possible: + +```python +from django.core.management.base import BaseCommand + +class Command(BaseCommand): + help = "Check pending special membership requests for a user" + + def add_arguments(self, parser) -> None: + parser.add_argument("username", type=str) + + def handle(self, username:str, *args, **options) -> None: + self.stdout.write(f"Get user with username: {username}") +``` + +- when creating a new command, be + ## Security Considerations - Never commit secrets to source code @@ -250,3 +270,12 @@ When adding new Celery tasks: - Main repository: https://github.com/impresso/impresso-user-admin - Impresso project: https://impresso-project.ch - License: GNU Affero General Public License v3.0 + +## Adding new email templates and sending emails + +When adding new email templates: + +1. Create both `.txt` and `.html` versions in `impresso/templates/emails/` +2. Use consistent naming conventions (e.g., `plan_change_request_email.txt` and `plan_change_request_email.html`) +3. Use context variables for dynamic content +4. use the method `send_templated_email_with_context()` from `impresso/utils/tasks/email.py` to send emails From dc054d09518361baee0097ad458d19170e4faf2d Mon Sep 17 00:00:00 2001 From: Daniele Guido Date: Thu, 29 Jan 2026 09:25:16 +0100 Subject: [PATCH 2/5] add command and test to remind pending requests --- .../commands/remindpendingrequests.py | 235 ++++++++++++++++++ impresso/tests/management/__init__.py | 1 + .../management/test_remindpendingrequests.py | 135 ++++++++++ 3 files changed, 371 insertions(+) create mode 100644 impresso/management/commands/remindpendingrequests.py create mode 100644 impresso/tests/management/__init__.py create mode 100644 impresso/tests/management/test_remindpendingrequests.py diff --git a/impresso/management/commands/remindpendingrequests.py b/impresso/management/commands/remindpendingrequests.py new file mode 100644 index 0000000..f4a5677 --- /dev/null +++ b/impresso/management/commands/remindpendingrequests.py @@ -0,0 +1,235 @@ +from django.core.management.base import BaseCommand +from django.contrib.auth.models import User +from django.db.models import Q, QuerySet +from django.conf import settings +from django.utils import timezone +from datetime import timedelta +from typing import List, Dict, Any, Optional +from impresso.models import UserSpecialMembershipRequest +from impresso.utils.tasks.email import send_templated_email_with_context + + +class Command(BaseCommand): + """ + Send reminder emails to reviewers for pending special membership requests + that have been waiting for more than 7 days. + + The reviewer can be: + - Directly assigned to a UserSpecialMembershipRequest instance + - Assigned to a SpecialMembershipDataset instance (linked via subscription) + + Usage with pipenv: + ENV=dev pipenv run ./manage.py remindpendingrequests + ENV=dev pipenv run ./manage.py remindpendingrequests + ENV=dev pipenv run ./manage.py remindpendingrequests --days 14 + + Usage with docker: + docker-compose exec python manage.py remindpendingrequests + docker-compose exec python manage.py remindpendingrequests + + Example: + ENV=dev pipenv run ./manage.py remindpendingrequests + ENV=dev pipenv run ./manage.py remindpendingrequests john.doe + ENV=dev pipenv run ./manage.py remindpendingrequests --days 14 --dry-run + + testing: + ENV=test pipenv run ./manage.py test impresso.tests.management.test_remindpendingrequests + """ + + help = "Send reminder emails to reviewers for pending requests older than specified days" + + def add_arguments(self, parser) -> None: + parser.add_argument( + "username", + nargs="?", + type=str, + help="Optional username of specific reviewer to remind", + ) + parser.add_argument( + "--days", + type=int, + default=7, + help="Minimum days a request must be pending to trigger reminder (default: 7)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be sent without actually sending emails", + ) + + def handle(self, username: Optional[str] = None, *args, **options) -> None: + days_threshold: int = options.get("days", 7) + dry_run: bool = options.get("dry_run", False) + + self.stdout.write(f"\n{'='*80}") + self.stdout.write( + f"Starting reminder process for requests older than \033[1m{days_threshold} days\033[0m" + ) + if dry_run: + self.stdout.write( + self.style.WARNING("DRY RUN MODE - No emails will be sent") + ) + self.stdout.write(f"{'='*80}\n") + + # Calculate cutoff date + cutoff_date = timezone.now() - timedelta(days=days_threshold) + + # Get reviewers to process + if username: + # Process specific reviewer + try: + reviewers = [User.objects.get(username=username)] + self.stdout.write( + f"Processing specific reviewer: \033[1m{username}\033[0m\n" + ) + except User.DoesNotExist: + self.stdout.write( + self.style.ERROR( + f"✗ User with username '{username}' does not exist.\n" + ) + ) + return + else: + # Get all reviewers with old pending requests + reviewers = self._get_reviewers_with_old_pending_requests(cutoff_date) + self.stdout.write( + f"Found \033[1m{len(reviewers)}\033[0m reviewer(s) with old pending requests\n" + ) + + if not reviewers: + self.stdout.write( + self.style.WARNING("No reviewers with old pending requests found.\n") + ) + return + + # Process each reviewer + total_emails_sent = 0 + for reviewer in reviewers: + result = self._process_reviewer(reviewer, cutoff_date, dry_run) + if result: + total_emails_sent += 1 + + # Summary + self.stdout.write(f"\n{'='*80}") + if dry_run: + self.stdout.write( + self.style.SUCCESS( + f"✓ DRY RUN: Would have sent {total_emails_sent} reminder email(s)\n" + ) + ) + else: + self.stdout.write( + self.style.SUCCESS( + f"✓ Successfully sent {total_emails_sent} reminder email(s)\n" + ) + ) + self.stdout.write(f"{'='*80}\n") + + def _get_reviewers_with_old_pending_requests(self, cutoff_date) -> List[User]: + """Get all reviewers who have pending requests older than cutoff date.""" + # Get distinct reviewers from direct assignments + direct_reviewers = User.objects.filter( + review__status=UserSpecialMembershipRequest.STATUS_PENDING, + review__date_last_modified__lt=cutoff_date, + ).distinct() + + # Get distinct reviewers from dataset assignments + dataset_reviewers = User.objects.filter( + reviewed_datasets__userspecialmembershiprequest__status=UserSpecialMembershipRequest.STATUS_PENDING, + reviewed_datasets__userspecialmembershiprequest__date_last_modified__lt=cutoff_date, + ).distinct() + + # Combine and return unique reviewers + reviewer_ids = set(direct_reviewers.values_list("id", flat=True)) | set( + dataset_reviewers.values_list("id", flat=True) + ) + return list(User.objects.filter(id__in=reviewer_ids)) + + def _process_reviewer( + self, reviewer: User, cutoff_date, dry_run: bool = False + ) -> bool: + """Process a single reviewer and send reminder email if needed.""" + self.stdout.write(f"\nProcessing reviewer: \033[1m{reviewer.username}\033[0m") + + # Query pending requests older than cutoff date + pending_requests: QuerySet[UserSpecialMembershipRequest] = ( + UserSpecialMembershipRequest.objects.filter( + status=UserSpecialMembershipRequest.STATUS_PENDING, + date_last_modified__lt=cutoff_date, + ) + .filter(Q(reviewer=reviewer) | Q(subscription__reviewer=reviewer)) + .select_related("user", "reviewer", "subscription") + .order_by("date_last_modified") # Oldest first + ) + + request_count: int = pending_requests.count() + + if request_count == 0: + self.stdout.write(f" No old pending requests for {reviewer.username}") + return False + + self.stdout.write(f" Found {request_count} old pending request(s)") + + # Check if reviewer has email + if not reviewer.email: + self.stdout.write( + self.style.WARNING( + f" ✗ Skipping: {reviewer.username} has no email address configured" + ) + ) + return False + + # Get the 3 oldest requests + latest_requests = list(pending_requests[:3]) + + # Calculate days waiting for each request + now = timezone.now() + for request in latest_requests: + days_waiting = (now - request.date_last_modified).days + request.days_waiting = days_waiting + + # Prepare email context + context: Dict[str, Any] = { + "reviewer": reviewer, + "latest_requests": latest_requests, + "total_count": request_count, + } + + if dry_run: + self.stdout.write( + self.style.WARNING( + f" [DRY RUN] Would send reminder email to {reviewer.email}" + ) + ) + self.stdout.write( + f" Subject: Impresso: Reminder - {request_count} Pending Request(s) Need Review" + ) + return True + + # Send email + try: + success = send_templated_email_with_context( + template="pending_requests_reminder_to_reviewer", + subject=f"Impresso: Reminder - {request_count} Pending Request{'s' if request_count != 1 else ''} Need{'s' if request_count == 1 else ''} Review", + from_email=settings.DEFAULT_FROM_EMAIL, + to=[reviewer.email], + context=context, + ) + + if success: + self.stdout.write( + self.style.SUCCESS(f" ✓ Reminder email sent to {reviewer.email}") + ) + return True + else: + self.stdout.write( + self.style.ERROR(f" ✗ Failed to send email to {reviewer.email}") + ) + return False + except Exception as e: + self.stdout.write( + self.style.ERROR( + f" ✗ Error sending email to {reviewer.email}: {str(e)}" + ) + ) + return False diff --git a/impresso/tests/management/__init__.py b/impresso/tests/management/__init__.py new file mode 100644 index 0000000..526331c --- /dev/null +++ b/impresso/tests/management/__init__.py @@ -0,0 +1 @@ +# Management command tests diff --git a/impresso/tests/management/test_remindpendingrequests.py b/impresso/tests/management/test_remindpendingrequests.py new file mode 100644 index 0000000..bf772b8 --- /dev/null +++ b/impresso/tests/management/test_remindpendingrequests.py @@ -0,0 +1,135 @@ +import logging +from io import StringIO +from django.core.management import call_command +from django.test import TestCase +from django.contrib.auth.models import User +from django.core import mail +from django.utils import timezone +from datetime import timedelta +from impresso.models import SpecialMembershipDataset, UserSpecialMembershipRequest +from impresso.signals import create_default_groups + + +logger = logging.getLogger("console") + + +class TestRemindPendingRequestsCommand(TestCase): + """ + Test the remindpendingrequests management command. + + Usage: + ENV=dev pipenv run ./manage.py test impresso.tests.management.test_remindpendingrequests + """ + + def setUp(self): + """Set up test fixtures.""" + # Create default groups + create_default_groups(sender="impresso") + + # Clear mail outbox + mail.outbox = [] + + # Create reviewer user + self.reviewer = User.objects.create_user( + username="reviewer_user", + first_name="John", + last_name="Reviewer", + email="reviewer@example.com", + password="testpass123", + ) + + # Create test users requesting membership + self.user1 = User.objects.create_user( + username="user1", + first_name="Alice", + last_name="Smith", + email="alice@example.com", + password="testpass123", + ) + + self.user2 = User.objects.create_user( + username="user2", + first_name="Bob", + last_name="Jones", + email="bob@example.com", + password="testpass123", + ) + + # Create special membership dataset + self.dataset1 = SpecialMembershipDataset.objects.create( + title="Dataset Alpha", + reviewer=self.reviewer, + ) + + def test_command_with_no_old_pending_requests(self): + """Test that no email is sent when there are no old pending requests.""" + UserSpecialMembershipRequest.objects.create( + user=self.user1, + reviewer=self.reviewer, + subscription=self.dataset1, + status=UserSpecialMembershipRequest.STATUS_PENDING, + ) + out = StringIO() + mail.outbox = [] + call_command("remindpendingrequests", stdout=out) + output = out.getvalue() + self.assertIn("No reviewers with old pending requests found", output) + self.assertEqual(len(mail.outbox), 0) + + def test_command_excludes_non_pending_requests(self): + """Test that command only considers pending requests.""" + + # Create old approved request (should be ignored) + request1 = UserSpecialMembershipRequest.objects.create( + user=self.user1, + reviewer=self.reviewer, + subscription=self.dataset1, + status=UserSpecialMembershipRequest.STATUS_APPROVED, + ) + old_date = timezone.now() - timedelta(days=10) + UserSpecialMembershipRequest.objects.filter(pk=request1.pk).update( + date_last_modified=old_date + ) + + # Create old rejected request (should be ignored) + request2 = UserSpecialMembershipRequest.objects.create( + user=self.user2, + reviewer=self.reviewer, + subscription=self.dataset1, + status=UserSpecialMembershipRequest.STATUS_REJECTED, + ) + UserSpecialMembershipRequest.objects.filter(pk=request2.pk).update( + date_last_modified=old_date + ) + mail.outbox = [] + out = StringIO() + call_command("remindpendingrequests", stdout=out) + output = out.getvalue() + + self.assertIn("No reviewers with old pending requests found", output) + self.assertEqual(len(mail.outbox), 0) + + def test_command_calculates_days_waiting_correctly(self): + """Test that days_waiting is calculated correctly in email.""" + # Create request 15 days old + request = UserSpecialMembershipRequest.objects.create( + user=self.user1, + reviewer=self.reviewer, + subscription=self.dataset1, + status=UserSpecialMembershipRequest.STATUS_PENDING, + ) + old_date = timezone.now() - timedelta(days=15) + UserSpecialMembershipRequest.objects.filter(pk=request.pk).update( + date_last_modified=old_date + ) + mail.outbox = [] + call_command("remindpendingrequests", stdout=StringIO()) + + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + + # Check email recipient + self.assertEqual(email.to, ["reviewer@example.com"]) + + # Check that 15 days is mentioned in the email + self.assertIn("15 days ago", email.body) From 3ffdf3dbe11445bb55aa788fcdeef41bd7938cdb Mon Sep 17 00:00:00 2001 From: Daniele Guido Date: Thu, 29 Jan 2026 09:25:28 +0100 Subject: [PATCH 3/5] add templates for reviewers --- ...pending_requests_reminder_to_reviewer.html | 61 +++++++++++++++++++ .../pending_requests_reminder_to_reviewer.txt | 22 +++++++ .../pending_requests_summary_to_reviewer.html | 53 ++++++++++++++++ .../pending_requests_summary_to_reviewer.txt | 20 ++++++ 4 files changed, 156 insertions(+) create mode 100644 impresso/templates/emails/pending_requests_reminder_to_reviewer.html create mode 100644 impresso/templates/emails/pending_requests_reminder_to_reviewer.txt create mode 100644 impresso/templates/emails/pending_requests_summary_to_reviewer.html create mode 100644 impresso/templates/emails/pending_requests_summary_to_reviewer.txt diff --git a/impresso/templates/emails/pending_requests_reminder_to_reviewer.html b/impresso/templates/emails/pending_requests_reminder_to_reviewer.html new file mode 100644 index 0000000..ebf4252 --- /dev/null +++ b/impresso/templates/emails/pending_requests_reminder_to_reviewer.html @@ -0,0 +1,61 @@ +Dear {{ reviewer.first_name }}, + +

+ We hope this message finds you well. This is a friendly reminder that you have + {{ total_count }} pending special membership request{{ + total_count|pluralize }} + that {{ total_count|pluralize:"has,have" }} been awaiting review for over a + week. +

+ +

Here are the requests that need your attention:

+ +
    + {% for request in latest_requests %} +
  • + Request from {{ request.user.get_full_name|default:request.user.username + }} +
      +
    • Email: {{ request.user.email }}
    • +
    • Subscription: {{ request.subscription.title }}
    • +
    • + Submitted: {{ request.date_created|date:"F j, Y" }} + ({{ request.days_waiting }} day{{ request.days_waiting|pluralize }} + ago) +
    • + {% if request.notes %} +
    • Notes: {{ request.notes|truncatewords:20 }}
    • + {% endif %} +
    +
  • + {% endfor %} +
+ +{% if total_count > 3 %} +

+ ...and + {{ total_count|add:"-3" }} more request{{ total_count|add:"-3"|pluralize + }}. +

+{% endif %} + +

+ We understand you have a busy schedule, and we appreciate your efforts in + reviewing these requests. When you have a moment, please take the time to + review them through the admin interface. +

+ +

Thank you for your continued support!

+ +

+ Best regards,
+ The Impresso Team +

diff --git a/impresso/templates/emails/pending_requests_reminder_to_reviewer.txt b/impresso/templates/emails/pending_requests_reminder_to_reviewer.txt new file mode 100644 index 0000000..76eb5fa --- /dev/null +++ b/impresso/templates/emails/pending_requests_reminder_to_reviewer.txt @@ -0,0 +1,22 @@ +Dear {{ reviewer.first_name }}, + +We hope this message finds you well. This is a friendly reminder that you have {{ total_count }} pending special membership request{{ total_count|pluralize }} that {{ total_count|pluralize:"has,have" }} been awaiting review for over a week. + +Here are the requests that need your attention: +{% for request in latest_requests %} +{{ forloop.counter }}. Request from {{ request.user.get_full_name|default:request.user.username }} + - Email: {{ request.user.email }} + - Subscription: {{ request.subscription.title }} + - Submitted: {{ request.date_created|date:"F j, Y" }} ({{ request.days_waiting }} day{{ request.days_waiting|pluralize }} ago) + {% if request.notes %}- Notes: {{ request.notes|truncatewords:20 }}{% endif %} +{% endfor %} +{% if total_count > 3 %} +...and {{ total_count|add:"-3" }} more request{{ total_count|add:"-3"|pluralize }}. +{% endif %} + +We understand you have a busy schedule, and we appreciate your efforts in reviewing these requests. When you have a moment, please take the time to review them through the admin interface. + +Thank you for your continued support! + +Best regards, +The Impresso Team diff --git a/impresso/templates/emails/pending_requests_summary_to_reviewer.html b/impresso/templates/emails/pending_requests_summary_to_reviewer.html new file mode 100644 index 0000000..6d048f1 --- /dev/null +++ b/impresso/templates/emails/pending_requests_summary_to_reviewer.html @@ -0,0 +1,53 @@ +Dear {{ reviewer.first_name }}, + +

+ You currently have + {{ total_count }} pending special membership request{{ + total_count|pluralize }} + awaiting your review. +

+ +

Here are the 3 most recent requests:

+ +
    + {% for request in latest_requests %} +
  • + Request from {{ request.user.get_full_name|default:request.user.username + }} +
      +
    • Email: {{ request.user.email }}
    • +
    • Subscription: {{ request.subscription.title }}
    • +
    • + Submitted: {{ request.date_created|date:"F j, Y" }} +
    • + {% if request.notes %} +
    • Notes: {{ request.notes|truncatewords:20 }}
    • + {% endif %} +
    +
  • + {% endfor %} +
+ +{% if total_count > 3 %} +

+ ...and + {{ total_count|add:"-3" }} more request{{ total_count|add:"-3"|pluralize + }}. +

+{% endif %} + +

+ Please review these requests at your earliest convenience. You can manage all + pending requests through the admin interface. +

+ +

+ Best regards,
+ The Impresso Team +

diff --git a/impresso/templates/emails/pending_requests_summary_to_reviewer.txt b/impresso/templates/emails/pending_requests_summary_to_reviewer.txt new file mode 100644 index 0000000..4f5fda3 --- /dev/null +++ b/impresso/templates/emails/pending_requests_summary_to_reviewer.txt @@ -0,0 +1,20 @@ +Dear {{ reviewer.first_name }}, + +You currently have {{ total_count }} pending special membership request{{ total_count|pluralize }} awaiting your review. + +Here are the 3 most recent requests: +{% for request in latest_requests %} +{{ forloop.counter }}. Request from {{ request.user.get_full_name|default:request.user.username }} + - Email: {{ request.user.email }} + - Subscription: {{ request.subscription.title }} + - Submitted: {{ request.date_created|date:"F j, Y" }} + {% if request.notes %}- Notes: {{ request.notes|truncatewords:20 }}{% endif %} +{% endfor %} +{% if total_count > 3 %} +...and {{ total_count|add:"-3" }} more request{{ total_count|add:"-3"|pluralize }}. +{% endif %} + +Please review these requests at your earliest convenience. You can manage all pending requests through the admin interface. + +Best regards, +The Impresso Team From df35f1495d8c06847670169a32314afb7eca5948 Mon Sep 17 00:00:00 2001 From: Daniele Guido Date: Thu, 29 Jan 2026 09:51:01 +0100 Subject: [PATCH 4/5] Add checkpendingrequests command and update reviewer email templates Introduces a new management command `checkpendingrequests` to list and optionally email reviewers about their pending special membership requests. Updates email templates and context to include a direct link to the institutions access admin interface, and adds the corresponding URL to settings. Also updates documentation to recommend logging SMTP exceptions and email status. --- .github/copilot-instructions.md | 1 + .../commands/checkpendingrequests.py | 327 ++++++++++++++++++ .../commands/remindpendingrequests.py | 1 + impresso/settings.py | 5 +- ...pending_requests_reminder_to_reviewer.html | 6 +- .../pending_requests_reminder_to_reviewer.txt | 4 +- .../pending_requests_summary_to_reviewer.html | 6 +- .../pending_requests_summary_to_reviewer.txt | 4 +- 8 files changed, 349 insertions(+), 5 deletions(-) create mode 100644 impresso/management/commands/checkpendingrequests.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ec7dd96..225c8ed 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -279,3 +279,4 @@ When adding new email templates: 2. Use consistent naming conventions (e.g., `plan_change_request_email.txt` and `plan_change_request_email.html`) 3. Use context variables for dynamic content 4. use the method `send_templated_email_with_context()` from `impresso/utils/tasks/email.py` to send emails +5. Handle SMTP exceptions and log email sending status in STRERR appropriately using logging library diff --git a/impresso/management/commands/checkpendingrequests.py b/impresso/management/commands/checkpendingrequests.py new file mode 100644 index 0000000..ab101c8 --- /dev/null +++ b/impresso/management/commands/checkpendingrequests.py @@ -0,0 +1,327 @@ +from django.core.management.base import BaseCommand +from django.contrib.auth.models import User +from django.db.models import Q, QuerySet +from django.conf import settings +from impresso.models import UserSpecialMembershipRequest +from impresso.utils.tasks.email import send_templated_email_with_context +from typing import Optional + + +class Command(BaseCommand): + """ + Check pending special membership requests for reviewers. + + The reviewer can be: + - Directly assigned to a UserSpecialMembershipRequest instance + - Assigned to a SpecialMembershipDataset instance (linked via subscription) + + Usage with pipenv: + ENV=dev pipenv run ./manage.py checkpendingrequests + ENV=dev pipenv run ./manage.py checkpendingrequests + ENV=dev pipenv run ./manage.py checkpendingrequests --send-email + ENV=dev pipenv run ./manage.py checkpendingrequests --send-email --dry-run + + Usage with docker: + docker-compose exec python manage.py checkpendingrequests + docker-compose exec python manage.py checkpendingrequests + + Example: + ENV=dev pipenv run ./manage.py checkpendingrequests + ENV=dev pipenv run ./manage.py checkpendingrequests john.doe + ENV=dev pipenv run ./manage.py checkpendingrequests john.doe --send-email + ENV=dev pipenv run ./manage.py checkpendingrequests john.doe --send-email --dry-run + + testing: + ENV=test pipenv run ./manage.py test impresso.tests.management.test_checkpendingrequests + """ + + help = "Check pending special membership requests for a specific reviewer" + + def add_arguments(self, parser) -> None: + parser.add_argument( + "username", + nargs="?", + type=str, + help="Optional username of specific reviewer to check pending requests for", + ) + parser.add_argument( + "--send-email", + action="store_true", + help="Send email summary to the reviewer", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be sent without actually sending emails", + ) + + def handle(self, username: Optional[str] = None, *args, **options) -> None: + send_email: bool = options.get("send_email", False) + dry_run: bool = options.get("dry_run", False) + + self.stdout.write(f"\n{'='*80}") + if username: + self.stdout.write( + f"Looking up reviewer with username: \033[1m{username}\033[0m\n" + ) + else: + self.stdout.write("Checking all reviewers for pending requests\n") + if dry_run: + self.stdout.write( + self.style.WARNING("DRY RUN MODE - No emails will be sent") + ) + self.stdout.write(f"{'='*80}\n") + + # Fetch the reviewer user(s) + if username: + try: + reviewers = [User.objects.get(username=username)] + except User.DoesNotExist: + self.stdout.write( + self.style.ERROR( + f"✗ User with username '{username}' does not exist.\n" + ) + ) + return + else: + # Get all users who have pending requests + reviewers = self._get_reviewers_with_pending_requests() + + if not reviewers: + self.stdout.write( + self.style.WARNING("No reviewers with pending requests found.\n") + ) + return + + # Process each reviewer + for reviewer in reviewers: + self._process_reviewer(reviewer, send_email, dry_run) + + self.stdout.write(f"{'='*80}\n") + + # Query pending requests where: + # 1. Reviewer is directly assigned to the request, OR + # 2. Reviewer is assigned to the subscription's dataset + pending_requests: QuerySet[UserSpecialMembershipRequest] = ( + UserSpecialMembershipRequest.objects.filter( + status=UserSpecialMembershipRequest.STATUS_PENDING + ) + .filter(Q(reviewer=reviewer) | Q(subscription__reviewer=reviewer)) + .select_related("user", "reviewer", "subscription") + .order_by("-date_created") + ) + + request_count: int = pending_requests.count() + + if request_count == 0: + self.stdout.write( + self.style.WARNING( + f"No pending requests found for reviewer '{reviewer.username}'.\n" + ) + ) + return + + self.stdout.write( + self.style.SUCCESS( + f"✓ Found {request_count} pending request{'s' if request_count != 1 else ''}:\n" + ) + ) + self.stdout.write("=" * 80 + "\n") + + for idx, request in enumerate(pending_requests, start=1): + self.stdout.write(f"\n{idx}. Request ID: \033[1m{request.pk}\033[0m") + self.stdout.write( + f"\n User: {request.user.username} ({request.user.get_full_name() or 'No name'})" + ) + self.stdout.write(f"\n Email: {request.user.email}") + self.stdout.write( + f"\n Subscription: {request.subscription.title if request.subscription else '[deleted subscription]'}" + ) + + # Determine reviewer assignment type + if request.reviewer == reviewer: + self.stdout.write( + f"\n Reviewer assignment: \033[1mDirect\033[0m (assigned to request)" + ) + elif request.subscription and request.subscription.reviewer == reviewer: + self.stdout.write( + f"\n Reviewer assignment: \033[1mDataset-level\033[0m (assigned to subscription dataset)" + ) + else: + self.stdout.write(f"\n Reviewer assignment: \033[1mOther\033[0m") + + self.stdout.write(f"\n Status: {request.status}") + self.stdout.write( + f"\n Created: {request.date_created.strftime('%Y-%m-%d %H:%M:%S')}" + ) + self.stdout.write( + f"\n Last modified: {request.date_last_modified.strftime('%Y-%m-%d %H:%M:%S')}" + ) + + if request.notes: + # Truncate notes if too long + notes_preview: str = ( + request.notes[:100] + "..." + if len(request.notes) > 100 + else request.notes + ) + self.stdout.write(f"\n Notes: {notes_preview}") + + self.stdout.write("\n" + "-" * 80) + + self.stdout.write( + f"\n\n✓ Total pending requests for '{reviewer.username}': \033[1m{request_count}\033[0m\n" + ) + + # Send email if requested + if send_email: + self.stdout.write("\n" + "=" * 80 + "\n") + + if dry_run: + self.stdout.write( + self.style.WARNING( + f"[DRY RUN] Would send email summary to {reviewer.email}\n" + ) + ) + return + + self.stdout.write("Sending email summary to reviewer...\n") + + if not reviewer.email: + self.stdout.write( + self.style.ERROR( + f"✗ Reviewer '{reviewer.username}' has no email address configured.\n" + ) + ) + return + + # Get the 3 most recent requests + latest_requests = list(pending_requests[:3]) + + # Prepare email context + context = { + "reviewer": reviewer, + "latest_requests": latest_requests, + "total_count": request_count, + "settings": settings, + } + + # Send email + try: + success = send_templated_email_with_context( + template="pending_requests_summary_to_reviewer", + subject=f"Impresso: {request_count} Pending Special Membership Request{'s' if request_count != 1 else ''}", + from_email=settings.DEFAULT_FROM_EMAIL, + to=[reviewer.email], + context=context, + ) + + if success: + self.stdout.write( + self.style.SUCCESS( + f"✓ Email successfully sent to {reviewer.email}\n" + ) + ) + else: + self.stdout.write( + self.style.ERROR( + f"✗ Failed to send email to {reviewer.email}\n" + ) + ) + except Exception as e: + self.stdout.write( + self.style.ERROR(f"✗ Error sending email: {str(e)}\n") + ) + + def _get_reviewers_with_pending_requests(self) -> list[User]: + """Get all reviewers who have pending requests.""" + # Get distinct reviewers from direct assignments + direct_reviewers = User.objects.filter( + review__status=UserSpecialMembershipRequest.STATUS_PENDING, + ).distinct() + + # Get distinct reviewers from dataset assignments + dataset_reviewers = User.objects.filter( + reviewed_datasets__userspecialmembershiprequest__status=UserSpecialMembershipRequest.STATUS_PENDING, + ).distinct() + + # Combine and return unique reviewers + reviewer_ids = set(direct_reviewers.values_list("id", flat=True)) | set( + dataset_reviewers.values_list("id", flat=True) + ) + return list(User.objects.filter(id__in=reviewer_ids)) + + def _process_reviewer( + self, reviewer: User, send_email: bool = False, dry_run: bool = False + ) -> None: + """Process a single reviewer and display their pending requests.""" + self.stdout.write(f"\nReviewer: \033[1m{reviewer.username}\033[0m") + + # Query pending requests for this reviewer + pending_requests: QuerySet[UserSpecialMembershipRequest] = ( + UserSpecialMembershipRequest.objects.filter( + status=UserSpecialMembershipRequest.STATUS_PENDING + ) + .filter(Q(reviewer=reviewer) | Q(subscription__reviewer=reviewer)) + .select_related("user", "reviewer", "subscription") + .order_by("-date_created") + ) + + request_count: int = pending_requests.count() + + if request_count == 0: + self.stdout.write(" No pending requests") + return + + self.stdout.write(f" Found {request_count} pending request(s)") + + for idx, request in enumerate(pending_requests, start=1): + self.stdout.write( + f" {idx}. {request.user.username} - {request.subscription.title if request.subscription else '[deleted]'}" + ) + + # Send email if requested + if send_email: + if not reviewer.email: + self.stdout.write( + self.style.WARNING(f" ✗ No email address configured") + ) + return + + if dry_run: + self.stdout.write( + self.style.WARNING( + f" [DRY RUN] Would send email to {reviewer.email}" + ) + ) + return + + # Get the 3 most recent requests + latest_requests = list(pending_requests[:3]) + + # Prepare email context + context = { + "reviewer": reviewer, + "latest_requests": latest_requests, + "total_count": request_count, + "settings": settings, + } + + # Send email + try: + success = send_templated_email_with_context( + template="pending_requests_summary_to_reviewer", + subject=f"Impresso: {request_count} Pending Special Membership Request{'s' if request_count != 1 else ''}", + from_email=settings.DEFAULT_FROM_EMAIL, + to=[reviewer.email], + context=context, + ) + + if success: + self.stdout.write( + self.style.SUCCESS(f" ✓ Email sent to {reviewer.email}") + ) + else: + self.stdout.write(self.style.ERROR(f" ✗ Failed to send email")) + except Exception as e: + self.stdout.write(self.style.ERROR(f" ✗ Error: {str(e)}")) diff --git a/impresso/management/commands/remindpendingrequests.py b/impresso/management/commands/remindpendingrequests.py index f4a5677..a82074f 100644 --- a/impresso/management/commands/remindpendingrequests.py +++ b/impresso/management/commands/remindpendingrequests.py @@ -193,6 +193,7 @@ def _process_reviewer( "reviewer": reviewer, "latest_requests": latest_requests, "total_count": request_count, + "settings": settings, } if dry_run: diff --git a/impresso/settings.py b/impresso/settings.py index 4c8aee5..3a9121c 100644 --- a/impresso/settings.py +++ b/impresso/settings.py @@ -174,7 +174,10 @@ ) IMPRESSO_BASE_URL = get_env_variable("IMPRESSO_BASE_URL", "https://impresso-project.ch") - +IMPRESSO_INSTITUTIONS_ACCESS_URL = get_env_variable( + "IMPRESSO_INSTITUTIONS_ACCESS_URL", + "https://impresso-project.ch/app/institutions-access", +) # Solr IMPRESSO_SOLR_URL_SELECT = os.path.join(get_env_variable("IMPRESSO_SOLR_URL"), "select") IMPRESSO_SOLR_URL_UPDATE = os.path.join(get_env_variable("IMPRESSO_SOLR_URL"), "update") diff --git a/impresso/templates/emails/pending_requests_reminder_to_reviewer.html b/impresso/templates/emails/pending_requests_reminder_to_reviewer.html index ebf4252..c0c647d 100644 --- a/impresso/templates/emails/pending_requests_reminder_to_reviewer.html +++ b/impresso/templates/emails/pending_requests_reminder_to_reviewer.html @@ -50,7 +50,11 @@

We understand you have a busy schedule, and we appreciate your efforts in reviewing these requests. When you have a moment, please take the time to - review them through the admin interface. + review them through the admin interface: +

+ +

+ {{ settings.IMPRESSO_INSTITUTIONS_ACCESS_URL }}

Thank you for your continued support!

diff --git a/impresso/templates/emails/pending_requests_reminder_to_reviewer.txt b/impresso/templates/emails/pending_requests_reminder_to_reviewer.txt index 76eb5fa..cac49ce 100644 --- a/impresso/templates/emails/pending_requests_reminder_to_reviewer.txt +++ b/impresso/templates/emails/pending_requests_reminder_to_reviewer.txt @@ -14,7 +14,9 @@ Here are the requests that need your attention: ...and {{ total_count|add:"-3" }} more request{{ total_count|add:"-3"|pluralize }}. {% endif %} -We understand you have a busy schedule, and we appreciate your efforts in reviewing these requests. When you have a moment, please take the time to review them through the admin interface. +We understand you have a busy schedule, and we appreciate your efforts in reviewing these requests. When you have a moment, please take the time to review them through the admin interface: + +{{ settings.IMPRESSO_INSTITUTIONS_ACCESS_URL }} Thank you for your continued support! diff --git a/impresso/templates/emails/pending_requests_summary_to_reviewer.html b/impresso/templates/emails/pending_requests_summary_to_reviewer.html index 6d048f1..aacadfc 100644 --- a/impresso/templates/emails/pending_requests_summary_to_reviewer.html +++ b/impresso/templates/emails/pending_requests_summary_to_reviewer.html @@ -44,7 +44,11 @@

Please review these requests at your earliest convenience. You can manage all - pending requests through the admin interface. + pending requests through the admin interface: +

+ +

+ {{ settings.IMPRESSO_INSTITUTIONS_ACCESS_URL }}

diff --git a/impresso/templates/emails/pending_requests_summary_to_reviewer.txt b/impresso/templates/emails/pending_requests_summary_to_reviewer.txt index 4f5fda3..94cbe2c 100644 --- a/impresso/templates/emails/pending_requests_summary_to_reviewer.txt +++ b/impresso/templates/emails/pending_requests_summary_to_reviewer.txt @@ -14,7 +14,9 @@ Here are the 3 most recent requests: ...and {{ total_count|add:"-3" }} more request{{ total_count|add:"-3"|pluralize }}. {% endif %} -Please review these requests at your earliest convenience. You can manage all pending requests through the admin interface. +Please review these requests at your earliest convenience. You can manage all pending requests through the admin interface: + +{{ settings.IMPRESSO_INSTITUTIONS_ACCESS_URL }} Best regards, The Impresso Team From 5a9fb75870777751dca48c53d53e7361a3c99142 Mon Sep 17 00:00:00 2001 From: Daniele Guido Date: Thu, 29 Jan 2026 11:01:12 +0100 Subject: [PATCH 5/5] Refactor checkpendingrequests command and add tests Simplifies reviewer selection logic and refactors email sending in the checkpendingrequests management command. Updates email templates for improved formatting. Adds comprehensive tests for pending request scenarios and email functionality. --- .../commands/checkpendingrequests.py | 264 +++++------------- ...pending_requests_reminder_to_reviewer.html | 4 +- .../pending_requests_summary_to_reviewer.html | 4 +- .../management/test_checkpendingrequests.py | 240 ++++++++++++++++ 4 files changed, 310 insertions(+), 202 deletions(-) create mode 100644 impresso/tests/management/test_checkpendingrequests.py diff --git a/impresso/management/commands/checkpendingrequests.py b/impresso/management/commands/checkpendingrequests.py index ab101c8..036db15 100644 --- a/impresso/management/commands/checkpendingrequests.py +++ b/impresso/management/commands/checkpendingrequests.py @@ -30,7 +30,7 @@ class Command(BaseCommand): ENV=dev pipenv run ./manage.py checkpendingrequests john.doe ENV=dev pipenv run ./manage.py checkpendingrequests john.doe --send-email ENV=dev pipenv run ./manage.py checkpendingrequests john.doe --send-email --dry-run - + testing: ENV=test pipenv run ./manage.py test impresso.tests.management.test_checkpendingrequests """ @@ -44,11 +44,7 @@ def add_arguments(self, parser) -> None: type=str, help="Optional username of specific reviewer to check pending requests for", ) - parser.add_argument( - "--send-email", - action="store_true", - help="Send email summary to the reviewer", - ) + parser.add_argument( "--dry-run", action="store_true", @@ -56,10 +52,8 @@ def add_arguments(self, parser) -> None: ) def handle(self, username: Optional[str] = None, *args, **options) -> None: - send_email: bool = options.get("send_email", False) dry_run: bool = options.get("dry_run", False) - self.stdout.write(f"\n{'='*80}") if username: self.stdout.write( f"Looking up reviewer with username: \033[1m{username}\033[0m\n" @@ -70,7 +64,6 @@ def handle(self, username: Optional[str] = None, *args, **options) -> None: self.stdout.write( self.style.WARNING("DRY RUN MODE - No emails will be sent") ) - self.stdout.write(f"{'='*80}\n") # Fetch the reviewer user(s) if username: @@ -85,7 +78,22 @@ def handle(self, username: Optional[str] = None, *args, **options) -> None: return else: # Get all users who have pending requests - reviewers = self._get_reviewers_with_pending_requests() + all_pending_requests: QuerySet[UserSpecialMembershipRequest] = ( + UserSpecialMembershipRequest.objects.filter( + status=UserSpecialMembershipRequest.STATUS_PENDING + ) + ) + # get all reviewers or subscription__reviewer + reviewers_ids = set( + all_pending_requests.values_list("reviewer_id", flat=True) + ).union( + set( + all_pending_requests.values_list( + "subscription__reviewer_id", flat=True + ) + ) + ) + reviewers = User.objects.filter(id__in=reviewers_ids) if not reviewers: self.stdout.write( @@ -95,13 +103,13 @@ def handle(self, username: Optional[str] = None, *args, **options) -> None: # Process each reviewer for reviewer in reviewers: - self._process_reviewer(reviewer, send_email, dry_run) + self._process_reviewer(reviewer, dry_run) - self.stdout.write(f"{'='*80}\n") + def _process_reviewer(self, reviewer: User, dry_run: bool = False) -> None: + """Process a single reviewer and display their pending requests.""" + self.stdout.write(f"\nReviewer: \033[1m{reviewer.username}\033[0m") - # Query pending requests where: - # 1. Reviewer is directly assigned to the request, OR - # 2. Reviewer is assigned to the subscription's dataset + # Query pending requests for this reviewer pending_requests: QuerySet[UserSpecialMembershipRequest] = ( UserSpecialMembershipRequest.objects.filter( status=UserSpecialMembershipRequest.STATUS_PENDING @@ -114,214 +122,70 @@ def handle(self, username: Optional[str] = None, *args, **options) -> None: request_count: int = pending_requests.count() if request_count == 0: - self.stdout.write( - self.style.WARNING( - f"No pending requests found for reviewer '{reviewer.username}'.\n" - ) - ) + self.stdout.write(" No pending requests") return self.stdout.write( - self.style.SUCCESS( - f"✓ Found {request_count} pending request{'s' if request_count != 1 else ''}:\n" - ) + self.style.WARNING(f"Found {request_count} pending request(s)") ) - self.stdout.write("=" * 80 + "\n") - for idx, request in enumerate(pending_requests, start=1): - self.stdout.write(f"\n{idx}. Request ID: \033[1m{request.pk}\033[0m") + for idx, request in enumerate(pending_requests): + self.stdout.write(f"\n{idx + 1}. Request ID: \033[1m{request.pk}\033[0m") self.stdout.write( - f"\n User: {request.user.username} ({request.user.get_full_name() or 'No name'})" + f" User: {request.user.username} ({request.user.get_full_name() or 'No name'})" ) - self.stdout.write(f"\n Email: {request.user.email}") + self.stdout.write(f" Email: {request.user.email}") self.stdout.write( - f"\n Subscription: {request.subscription.title if request.subscription else '[deleted subscription]'}" + f" Subscription: {request.subscription.title if request.subscription else '[deleted subscription]'}" ) - # Determine reviewer assignment type if request.reviewer == reviewer: self.stdout.write( - f"\n Reviewer assignment: \033[1mDirect\033[0m (assigned to request)" + f" Reviewer assignment: \033[1mDirect\033[0m (assigned to request)" ) elif request.subscription and request.subscription.reviewer == reviewer: self.stdout.write( - f"\n Reviewer assignment: \033[1mDataset-level\033[0m (assigned to subscription dataset)" + f" Reviewer assignment: \033[1mDataset-level\033[0m (assigned to subscription dataset)" ) else: - self.stdout.write(f"\n Reviewer assignment: \033[1mOther\033[0m") + self.stdout.write(f" Reviewer assignment: \033[1mOther\033[0m") - self.stdout.write(f"\n Status: {request.status}") - self.stdout.write( - f"\n Created: {request.date_created.strftime('%Y-%m-%d %H:%M:%S')}" - ) - self.stdout.write( - f"\n Last modified: {request.date_last_modified.strftime('%Y-%m-%d %H:%M:%S')}" - ) - - if request.notes: - # Truncate notes if too long - notes_preview: str = ( - request.notes[:100] + "..." - if len(request.notes) > 100 - else request.notes - ) - self.stdout.write(f"\n Notes: {notes_preview}") - - self.stdout.write("\n" + "-" * 80) - - self.stdout.write( - f"\n\n✓ Total pending requests for '{reviewer.username}': \033[1m{request_count}\033[0m\n" - ) + self.stdout.write(f" Status: {request.status}") # Send email if requested - if send_email: - self.stdout.write("\n" + "=" * 80 + "\n") - - if dry_run: - self.stdout.write( - self.style.WARNING( - f"[DRY RUN] Would send email summary to {reviewer.email}\n" - ) - ) - return - - self.stdout.write("Sending email summary to reviewer...\n") - - if not reviewer.email: - self.stdout.write( - self.style.ERROR( - f"✗ Reviewer '{reviewer.username}' has no email address configured.\n" - ) - ) - return - - # Get the 3 most recent requests - latest_requests = list(pending_requests[:3]) - - # Prepare email context - context = { - "reviewer": reviewer, - "latest_requests": latest_requests, - "total_count": request_count, - "settings": settings, - } - - # Send email - try: - success = send_templated_email_with_context( - template="pending_requests_summary_to_reviewer", - subject=f"Impresso: {request_count} Pending Special Membership Request{'s' if request_count != 1 else ''}", - from_email=settings.DEFAULT_FROM_EMAIL, - to=[reviewer.email], - context=context, - ) - - if success: - self.stdout.write( - self.style.SUCCESS( - f"✓ Email successfully sent to {reviewer.email}\n" - ) - ) - else: - self.stdout.write( - self.style.ERROR( - f"✗ Failed to send email to {reviewer.email}\n" - ) - ) - except Exception as e: - self.stdout.write( - self.style.ERROR(f"✗ Error sending email: {str(e)}\n") - ) - - def _get_reviewers_with_pending_requests(self) -> list[User]: - """Get all reviewers who have pending requests.""" - # Get distinct reviewers from direct assignments - direct_reviewers = User.objects.filter( - review__status=UserSpecialMembershipRequest.STATUS_PENDING, - ).distinct() - - # Get distinct reviewers from dataset assignments - dataset_reviewers = User.objects.filter( - reviewed_datasets__userspecialmembershiprequest__status=UserSpecialMembershipRequest.STATUS_PENDING, - ).distinct() - - # Combine and return unique reviewers - reviewer_ids = set(direct_reviewers.values_list("id", flat=True)) | set( - dataset_reviewers.values_list("id", flat=True) - ) - return list(User.objects.filter(id__in=reviewer_ids)) - - def _process_reviewer( - self, reviewer: User, send_email: bool = False, dry_run: bool = False - ) -> None: - """Process a single reviewer and display their pending requests.""" - self.stdout.write(f"\nReviewer: \033[1m{reviewer.username}\033[0m") - - # Query pending requests for this reviewer - pending_requests: QuerySet[UserSpecialMembershipRequest] = ( - UserSpecialMembershipRequest.objects.filter( - status=UserSpecialMembershipRequest.STATUS_PENDING + if dry_run: + self.stdout.write( + self.style.WARNING(f"\n[DRY RUN] Would send email to {reviewer.email}") ) - .filter(Q(reviewer=reviewer) | Q(subscription__reviewer=reviewer)) - .select_related("user", "reviewer", "subscription") - .order_by("-date_created") - ) - - request_count: int = pending_requests.count() - - if request_count == 0: - self.stdout.write(" No pending requests") return - - self.stdout.write(f" Found {request_count} pending request(s)") - - for idx, request in enumerate(pending_requests, start=1): - self.stdout.write( - f" {idx}. {request.user.username} - {request.subscription.title if request.subscription else '[deleted]'}" + if not reviewer.email: + self.stdout.write(self.style.WARNING(f" ✗ No email address configured")) + return + # Get the 3 most recent requests + latest_requests = list(pending_requests[:3]) + # Prepare email context + context = { + "reviewer": reviewer, + "latest_requests": latest_requests, + "total_count": request_count, + "settings": settings, + } + + # Send email + try: + success = send_templated_email_with_context( + template="pending_requests_summary_to_reviewer", + subject=f"Impresso: {request_count} Pending Special Membership Request{'s' if request_count != 1 else ''}", + from_email=settings.DEFAULT_FROM_EMAIL, + to=[reviewer.email], + context=context, ) - # Send email if requested - if send_email: - if not reviewer.email: + if success: self.stdout.write( - self.style.WARNING(f" ✗ No email address configured") + self.style.SUCCESS(f"\n✓ Email sent to {reviewer.email}") ) - return - - if dry_run: - self.stdout.write( - self.style.WARNING( - f" [DRY RUN] Would send email to {reviewer.email}" - ) - ) - return - - # Get the 3 most recent requests - latest_requests = list(pending_requests[:3]) - - # Prepare email context - context = { - "reviewer": reviewer, - "latest_requests": latest_requests, - "total_count": request_count, - "settings": settings, - } - - # Send email - try: - success = send_templated_email_with_context( - template="pending_requests_summary_to_reviewer", - subject=f"Impresso: {request_count} Pending Special Membership Request{'s' if request_count != 1 else ''}", - from_email=settings.DEFAULT_FROM_EMAIL, - to=[reviewer.email], - context=context, - ) - - if success: - self.stdout.write( - self.style.SUCCESS(f" ✓ Email sent to {reviewer.email}") - ) - else: - self.stdout.write(self.style.ERROR(f" ✗ Failed to send email")) - except Exception as e: - self.stdout.write(self.style.ERROR(f" ✗ Error: {str(e)}")) + else: + self.stdout.write(self.style.ERROR(f" ✗ Failed to send email")) + except Exception as e: + self.stdout.write(self.style.ERROR(f" ✗ Error: {str(e)}")) diff --git a/impresso/templates/emails/pending_requests_reminder_to_reviewer.html b/impresso/templates/emails/pending_requests_reminder_to_reviewer.html index c0c647d..8960dc3 100644 --- a/impresso/templates/emails/pending_requests_reminder_to_reviewer.html +++ b/impresso/templates/emails/pending_requests_reminder_to_reviewer.html @@ -54,7 +54,9 @@

- {{ settings.IMPRESSO_INSTITUTIONS_ACCESS_URL }} + {{ settings.IMPRESSO_INSTITUTIONS_ACCESS_URL }}

Thank you for your continued support!

diff --git a/impresso/templates/emails/pending_requests_summary_to_reviewer.html b/impresso/templates/emails/pending_requests_summary_to_reviewer.html index aacadfc..758fd55 100644 --- a/impresso/templates/emails/pending_requests_summary_to_reviewer.html +++ b/impresso/templates/emails/pending_requests_summary_to_reviewer.html @@ -48,7 +48,9 @@

- {{ settings.IMPRESSO_INSTITUTIONS_ACCESS_URL }} + {{ settings.IMPRESSO_INSTITUTIONS_ACCESS_URL }}

diff --git a/impresso/tests/management/test_checkpendingrequests.py b/impresso/tests/management/test_checkpendingrequests.py new file mode 100644 index 0000000..32dbbba --- /dev/null +++ b/impresso/tests/management/test_checkpendingrequests.py @@ -0,0 +1,240 @@ +import logging +from io import StringIO +from django.core.management import call_command +from django.test import TestCase +from django.contrib.auth.models import User +from django.core import mail +from impresso.models import SpecialMembershipDataset, UserSpecialMembershipRequest +from impresso.signals import create_default_groups + + +logger = logging.getLogger("console") + + +class TestCheckPendingRequestsCommand(TestCase): + """ + Test the checkpendingrequests management command. + + Usage: + ENV=dev pipenv run ./manage.py test impresso.tests.management.test_checkpendingrequests + """ + + def setUp(self): + """Set up test fixtures.""" + # Create default groups + create_default_groups(sender="impresso") + + # Clear mail outbox + mail.outbox = [] + + # Create reviewer user + self.reviewer = User.objects.create_user( + username="reviewer_user", + first_name="John", + last_name="Reviewer", + email="reviewer@example.com", + password="testpass123", + ) + + # Create another reviewer + self.other_reviewer = User.objects.create_user( + username="other_reviewer", + first_name="Jane", + last_name="OtherReviewer", + email="other@example.com", + password="testpass123", + ) + + # Create test users requesting membership + self.user1 = User.objects.create_user( + username="user1", + first_name="Alice", + last_name="Smith", + email="alice@example.com", + password="testpass123", + ) + + self.user2 = User.objects.create_user( + username="user2", + first_name="Bob", + last_name="Jones", + email="bob@example.com", + password="testpass123", + ) + + self.user3 = User.objects.create_user( + username="user3", + first_name="Charlie", + last_name="Brown", + email="charlie@example.com", + password="testpass123", + ) + + self.user4 = User.objects.create_user( + username="user4", + first_name="Diana", + last_name="Prince", + email="diana@example.com", + password="testpass123", + ) + + # Create special membership datasets + self.dataset1 = SpecialMembershipDataset.objects.create( + title="Dataset Alpha", + reviewer=self.reviewer, + ) + + self.dataset2 = SpecialMembershipDataset.objects.create( + title="Dataset Beta", + reviewer=self.other_reviewer, + ) + + def test_command_with_no_pending_requests(self): + """Test command shows no pending requests when none exist.""" + out = StringIO() + call_command("checkpendingrequests", "reviewer_user", stdout=out) + output = out.getvalue() + self.assertIn("No pending requests", output) + + def test_command_with_optional_username(self): + """Test command without username shows all reviewers with pending requests.""" + # Create pending request for first reviewer + UserSpecialMembershipRequest.objects.create( + user=self.user1, + reviewer=self.reviewer, + subscription=self.dataset1, + status=UserSpecialMembershipRequest.STATUS_PENDING, + ) + + # Create pending request for second reviewer + UserSpecialMembershipRequest.objects.create( + user=self.user2, + reviewer=self.other_reviewer, + subscription=self.dataset2, + status=UserSpecialMembershipRequest.STATUS_PENDING, + ) + + out = StringIO() + mail.outbox = [] + call_command("checkpendingrequests", stdout=out) + output = out.getvalue() + + # Should show both reviewers + self.assertIn("reviewer_user", output) + self.assertIn("other_reviewer", output) + self.assertIn("Found 1 pending request", output) + + def test_command_excludes_non_pending_requests(self): + """Test that command only shows pending requests, excluding approved/rejected.""" + # Create pending request + UserSpecialMembershipRequest.objects.create( + user=self.user1, + reviewer=self.reviewer, + subscription=self.dataset1, + status=UserSpecialMembershipRequest.STATUS_PENDING, + ) + + # Create approved request (should be excluded) + UserSpecialMembershipRequest.objects.create( + user=self.user2, + reviewer=self.reviewer, + subscription=self.dataset1, + status=UserSpecialMembershipRequest.STATUS_APPROVED, + ) + + # Create rejected request (should be excluded) + UserSpecialMembershipRequest.objects.create( + user=self.user3, + reviewer=self.reviewer, + subscription=self.dataset1, + status=UserSpecialMembershipRequest.STATUS_REJECTED, + ) + + out = StringIO() + mail.outbox = [] + call_command("checkpendingrequests", "reviewer_user", stdout=out) + output = out.getvalue() + self.assertIn("Found 1 pending request", output) + self.assertIn("Alice Smith", output) + self.assertNotIn("Bob Jones", output) + self.assertNotIn("Charlie Brown", output) + + def test_command_send_email_with_multiple_requests(self): + """Test email sending with multiple pending requests.""" + # Create 4 pending requests + UserSpecialMembershipRequest.objects.create( + user=self.user1, + reviewer=self.reviewer, + subscription=self.dataset1, + status=UserSpecialMembershipRequest.STATUS_PENDING, + ) + + UserSpecialMembershipRequest.objects.create( + user=self.user2, + reviewer=self.reviewer, + subscription=self.dataset1, + status=UserSpecialMembershipRequest.STATUS_PENDING, + ) + + UserSpecialMembershipRequest.objects.create( + user=self.user3, + reviewer=self.reviewer, + subscription=self.dataset1, + status=UserSpecialMembershipRequest.STATUS_PENDING, + ) + + UserSpecialMembershipRequest.objects.create( + user=self.user4, + reviewer=self.reviewer, + subscription=self.dataset1, + status=UserSpecialMembershipRequest.STATUS_PENDING, + ) + mail.outbox = [] + out = StringIO() + + call_command("checkpendingrequests", "reviewer_user", stdout=out) + output = out.getvalue() + + # Check email was sent + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + + # Check subject shows total count + self.assertIn("4 Pending Special Membership Requests", email.subject) + + # Check email shows total count + self.assertIn("4 pending special membership requests", email.body) + + # Check email shows only top 3 most recent + self.assertIn("Diana Prince", email.body) + self.assertIn("Charlie Brown", email.body) + self.assertIn("Bob Jones", email.body) + + # Check indication of more requests + self.assertIn("1 more request", email.body) + + # Check HTML email version + self.assertTrue(hasattr(email, "alternatives")) + self.assertGreater(len(email.alternatives), 0) + + def test_command_send_email_dry_run(self): + """Test email sending in dry-run mode does not send emails.""" + UserSpecialMembershipRequest.objects.create( + user=self.user1, + reviewer=self.reviewer, + subscription=self.dataset1, + status=UserSpecialMembershipRequest.STATUS_PENDING, + ) + mail.outbox = [] + out = StringIO() + call_command( + "checkpendingrequests", + "reviewer_user", + "--dry-run", + stdout=out, + ) + output = out.getvalue() + # Check dry run message + self.assertIn("[DRY RUN] Would send email to", output) + # No email should be sent + self.assertEqual(len(mail.outbox), 0)