diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4e6bdb8..225c8ed 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,13 @@ 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 +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..036db15 --- /dev/null +++ b/impresso/management/commands/checkpendingrequests.py @@ -0,0 +1,191 @@ +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( + "--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: + dry_run: bool = options.get("dry_run", False) + + 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") + ) + + # 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 + 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( + self.style.WARNING("No reviewers with pending requests found.\n") + ) + return + + # Process each reviewer + for reviewer in reviewers: + self._process_reviewer(reviewer, dry_run) + + 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 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( + self.style.WARNING(f"Found {request_count} pending request(s)") + ) + + 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" User: {request.user.username} ({request.user.get_full_name() or 'No name'})" + ) + self.stdout.write(f" Email: {request.user.email}") + self.stdout.write( + f" Subscription: {request.subscription.title if request.subscription else '[deleted subscription]'}" + ) + # Determine reviewer assignment type + if request.reviewer == reviewer: + self.stdout.write( + f" Reviewer assignment: \033[1mDirect\033[0m (assigned to request)" + ) + elif request.subscription and request.subscription.reviewer == reviewer: + self.stdout.write( + f" Reviewer assignment: \033[1mDataset-level\033[0m (assigned to subscription dataset)" + ) + else: + self.stdout.write(f" Reviewer assignment: \033[1mOther\033[0m") + + self.stdout.write(f" Status: {request.status}") + + # Send email if requested + if dry_run: + self.stdout.write( + self.style.WARNING(f"\n[DRY RUN] Would send email to {reviewer.email}") + ) + return + 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, + ) + + if success: + self.stdout.write( + self.style.SUCCESS(f"\n✓ 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 new file mode 100644 index 0000000..a82074f --- /dev/null +++ b/impresso/management/commands/remindpendingrequests.py @@ -0,0 +1,236 @@ +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, + "settings": settings, + } + + 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/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 new file mode 100644 index 0000000..8960dc3 --- /dev/null +++ b/impresso/templates/emails/pending_requests_reminder_to_reviewer.html @@ -0,0 +1,67 @@ +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: +

+ +

+ {{ settings.IMPRESSO_INSTITUTIONS_ACCESS_URL }} +

+ +

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..cac49ce --- /dev/null +++ b/impresso/templates/emails/pending_requests_reminder_to_reviewer.txt @@ -0,0 +1,24 @@ +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: + +{{ settings.IMPRESSO_INSTITUTIONS_ACCESS_URL }} + +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..758fd55 --- /dev/null +++ b/impresso/templates/emails/pending_requests_summary_to_reviewer.html @@ -0,0 +1,59 @@ +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: +

+ +

+ {{ settings.IMPRESSO_INSTITUTIONS_ACCESS_URL }} +

+ +

+ 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..94cbe2c --- /dev/null +++ b/impresso/templates/emails/pending_requests_summary_to_reviewer.txt @@ -0,0 +1,22 @@ +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: + +{{ settings.IMPRESSO_INSTITUTIONS_ACCESS_URL }} + +Best regards, +The Impresso Team 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_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) 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)