|
| 1 | +import re |
| 2 | +import json |
| 3 | +from collections import defaultdict |
1 | 4 | from django.urls import reverse_lazy |
2 | 5 | from django.db.models import Q |
3 | | -from osf.models import NotificationSubscription, NotificationType, Notification, EmailTask |
4 | | -from django.views.generic import ListView, DetailView, UpdateView |
| 6 | +from django.db import models |
| 7 | +from django.shortcuts import get_object_or_404, redirect |
| 8 | +from django.views.generic import ListView, DetailView, UpdateView, CreateView, View |
| 9 | +from django.contrib import messages |
5 | 10 | from django.contrib.auth.mixins import PermissionRequiredMixin |
| 11 | +from osf.models import NotificationSubscription, NotificationType, Notification, EmailTask, NotificationCampaign, OSFUser |
| 12 | +from osf.models.notification_campaign import NotificationCampaignStatus |
6 | 13 | from django.forms.models import model_to_dict |
7 | | -from .forms import NotificationTypeForm |
8 | | -from osf.email import _render_email_html |
9 | | -import json |
10 | | -from collections import defaultdict |
| 14 | +from .forms import NotificationTypeForm, NotificationCampaignCreateForm |
11 | 15 | from mako.lexer import Lexer |
12 | 16 | from mako.parsetree import ControlLine |
13 | | -import re |
14 | 17 | from string import Formatter |
| 18 | +from osf.email import _render_email_html |
| 19 | +from osf.email.notification_campaign import FILTER_PRESETS, filter_users |
| 20 | + |
15 | 21 |
|
16 | 22 | def delete_selected_notifications(selected_ids): |
17 | 23 | NotificationSubscription.objects.filter(id__in=selected_ids).delete() |
@@ -332,3 +338,268 @@ class NotificationTypeChangeForm(PermissionRequiredMixin, UpdateView): |
332 | 338 |
|
333 | 339 | def get_success_url(self, *args, **kwargs): |
334 | 340 | return reverse_lazy('notifications:type_display', kwargs={'pk': self.kwargs.get('pk')}) |
| 341 | + |
| 342 | + |
| 343 | +class NotificationCampaignsList(PermissionRequiredMixin, ListView): |
| 344 | + paginate_by = 25 |
| 345 | + template_name = 'notifications/notification_campaigns_list.html' |
| 346 | + ordering = 'name' |
| 347 | + permission_required = 'osf.view_notificationcampaign' |
| 348 | + raise_exception = True |
| 349 | + model = NotificationCampaign |
| 350 | + |
| 351 | + def get_queryset(self): |
| 352 | + qs = NotificationCampaign.objects.all().order_by(self.ordering) |
| 353 | + q = self.request.GET.get('q') |
| 354 | + if q: |
| 355 | + qs = qs.filter( |
| 356 | + Q(name__icontains=q) | |
| 357 | + Q(status__icontains=q) | |
| 358 | + Q(notification_type__name__icontains=q) |
| 359 | + ) |
| 360 | + return qs |
| 361 | + |
| 362 | + def get_context_data(self, **kwargs): |
| 363 | + context = super().get_context_data(**kwargs) |
| 364 | + q = self.request.GET.get('q', '') |
| 365 | + context['q'] = q |
| 366 | + # append search param to pagination links |
| 367 | + if q: |
| 368 | + context['extra_query_params'] = f"&q={q}" |
| 369 | + else: |
| 370 | + context['extra_query_params'] = '' |
| 371 | + |
| 372 | + context['notification_campaigns'] = context['object_list'] |
| 373 | + context['page'] = context['page_obj'] |
| 374 | + return context |
| 375 | + |
| 376 | + |
| 377 | +class NotificationCampaignDetail(PermissionRequiredMixin, DetailView): |
| 378 | + model = NotificationCampaign |
| 379 | + template_name = 'notifications/notification_campaigns_detail.html' |
| 380 | + permission_required = 'osf.change_notificationcampaign' |
| 381 | + raise_exception = True |
| 382 | + |
| 383 | + def get_object(self, queryset=None): |
| 384 | + return NotificationCampaign.objects.get(id=self.kwargs.get('pk')) |
| 385 | + |
| 386 | + def get_context_data(self, *args, **kwargs): |
| 387 | + notification_campaign = self.get_object() |
| 388 | + metadata = notification_campaign.metadata or {} |
| 389 | + |
| 390 | + context = { |
| 391 | + 'notification_campaign': notification_campaign, |
| 392 | + 'display_fields': [ |
| 393 | + ('Name', notification_campaign.name), |
| 394 | + ('Notification Type', notification_campaign.notification_type), |
| 395 | + ('Created By', notification_campaign.created_by), |
| 396 | + ('Status', notification_campaign.get_status_display()), |
| 397 | + ('Recipients', notification_campaign.recipient_count), |
| 398 | + ('Sent', notification_campaign.sent_count), |
| 399 | + ('Failed', notification_campaign.failed_count), |
| 400 | + ('Retries', notification_campaign.retries), |
| 401 | + ('Created', notification_campaign.created_at), |
| 402 | + ('Started', notification_campaign.started_at), |
| 403 | + ('Completed', notification_campaign.completed_at), |
| 404 | + ], |
| 405 | + 'template': notification_campaign.notification_type.template, |
| 406 | + 'metadata': metadata, |
| 407 | + 'filters_json': json.dumps(notification_campaign.metadata['filters']), |
| 408 | + 'sent_filters_json': json.dumps({ |
| 409 | + 'manual': [ |
| 410 | + {'field': 'notificationcampaignrecipient', 'value': notification_campaign.id, 'lookup': 'campaign'}, |
| 411 | + {'field': 'notificationcampaignrecipient', 'value': 'sent', 'lookup': 'status'} |
| 412 | + ] |
| 413 | + }), |
| 414 | + 'failed_filters_json': json.dumps({ |
| 415 | + 'manual': [ |
| 416 | + {'field': 'notificationcampaignrecipient', 'value': notification_campaign.id, 'lookup': 'campaign'}, |
| 417 | + {'field': 'notificationcampaignrecipient', 'value': 'failed', 'lookup': 'status'} |
| 418 | + ] |
| 419 | + }), |
| 420 | + 'other_metadata': { |
| 421 | + k: v |
| 422 | + for k, v in metadata.items() |
| 423 | + if k not in {'filters', 'context', 'execution', 'template'} |
| 424 | + }, |
| 425 | + } |
| 426 | + |
| 427 | + if notification_campaign.status == NotificationCampaignStatus.RUNNING: |
| 428 | + context.update({ |
| 429 | + 'sent_percent': notification_campaign.sent_count * 100 / notification_campaign.recipient_count if notification_campaign.recipient_count else 0, |
| 430 | + 'failed_percent': notification_campaign.failed_count * 100 / notification_campaign.recipient_count if notification_campaign.recipient_count else 0, |
| 431 | + }) |
| 432 | + |
| 433 | + return context |
| 434 | + |
| 435 | + |
| 436 | +LOOKUPS = { |
| 437 | + models.CharField: { |
| 438 | + 'exact': 'Equals', |
| 439 | + 'iexact': 'Equals (case insensitive)', |
| 440 | + 'contains': 'Contains', |
| 441 | + 'icontains': 'Contains (case insensitive)', |
| 442 | + 'startswith': 'Starts with', |
| 443 | + 'istartswith': 'Starts with (case insensitive)', |
| 444 | + 'endswith': 'Ends with', |
| 445 | + 'iendswith': 'Ends with (case insensitive)', |
| 446 | + 'in': 'In', |
| 447 | + 'isnull': 'Is empty', |
| 448 | + }, |
| 449 | + models.TextField: { |
| 450 | + 'exact': 'Equals', |
| 451 | + 'iexact': 'Equals (case insensitive)', |
| 452 | + 'contains': 'Contains', |
| 453 | + 'icontains': 'Contains (case insensitive)', |
| 454 | + 'startswith': 'Starts with', |
| 455 | + 'istartswith': 'Starts with (case insensitive)', |
| 456 | + 'endswith': 'Ends with', |
| 457 | + 'iendswith': 'Ends with (case insensitive)', |
| 458 | + 'isnull': 'Is empty', |
| 459 | + }, |
| 460 | + models.IntegerField: { |
| 461 | + 'exact': 'Equals', |
| 462 | + 'gt': 'Greater than', |
| 463 | + 'gte': 'Greater than or equal to', |
| 464 | + 'lt': 'Less than', |
| 465 | + 'lte': 'Less than or equal to', |
| 466 | + 'in': 'In', |
| 467 | + 'isnull': 'Is empty', |
| 468 | + }, |
| 469 | + models.DateField: { |
| 470 | + 'exact': 'On', |
| 471 | + 'gt': 'After', |
| 472 | + 'gte': 'On or after', |
| 473 | + 'lt': 'Before', |
| 474 | + 'lte': 'On or before', |
| 475 | + 'isnull': 'Is empty', |
| 476 | + }, |
| 477 | + models.DateTimeField: { |
| 478 | + 'exact': 'On', |
| 479 | + 'gt': 'After', |
| 480 | + 'gte': 'On or after', |
| 481 | + 'lt': 'Before', |
| 482 | + 'lte': 'On or before', |
| 483 | + 'isnull': 'Is empty', |
| 484 | + }, |
| 485 | + models.BooleanField: { |
| 486 | + 'exact': 'Is', |
| 487 | + }, |
| 488 | +} |
| 489 | + |
| 490 | + |
| 491 | +class NotificationCampaignCreateView(CreateView): |
| 492 | + model = NotificationCampaign |
| 493 | + form_class = NotificationCampaignCreateForm |
| 494 | + template_name = 'notifications/notification_campaing_create.html' |
| 495 | + allowed_filters = [ |
| 496 | + 'is_active', |
| 497 | + 'is_staff', |
| 498 | + 'username', |
| 499 | + 'last_login', |
| 500 | + ] |
| 501 | + |
| 502 | + def form_valid(self, form): |
| 503 | + form.instance.created_by = self.request.user |
| 504 | + |
| 505 | + form.instance.metadata = { |
| 506 | + 'filters': form.cleaned_data['filters'], |
| 507 | + 'context': form.cleaned_data['context'], |
| 508 | + 'execution': { |
| 509 | + 'batch_size': form.cleaned_data['batch_size'], |
| 510 | + 'max_retries': form.cleaned_data['max_retries'], |
| 511 | + }, |
| 512 | + } |
| 513 | + |
| 514 | + response = super().form_valid(form) |
| 515 | + |
| 516 | + messages.success( |
| 517 | + self.request, |
| 518 | + 'Notification campaign created successfully.', |
| 519 | + ) |
| 520 | + |
| 521 | + return response |
| 522 | + |
| 523 | + def get_success_url(self): |
| 524 | + return reverse_lazy( |
| 525 | + 'notifications:notification_campaigns_detail', |
| 526 | + kwargs={'pk': self.object.pk}, |
| 527 | + ) |
| 528 | + |
| 529 | + def get_context_data(self, **kwargs): |
| 530 | + context = super().get_context_data(**kwargs) |
| 531 | + context['notification_types'] = NotificationType.objects.order_by('name') |
| 532 | + |
| 533 | + filter_fields = {} |
| 534 | + for field in [f for f in OSFUser._meta.get_fields() if f.name in self.allowed_filters]: |
| 535 | + if not field.concrete: |
| 536 | + continue |
| 537 | + if type(field) not in LOOKUPS.keys(): |
| 538 | + continue |
| 539 | + filter_fields[field.name] = { |
| 540 | + 'label': field.verbose_name, |
| 541 | + 'type': field.get_internal_type().lower(), |
| 542 | + 'lookups': LOOKUPS.get(type(field), {}) |
| 543 | + } |
| 544 | + context['filter_fields'] = filter_fields |
| 545 | + context['filters'] = [] |
| 546 | + context['predefined_filters'] = FILTER_PRESETS.keys() |
| 547 | + return context |
| 548 | + |
| 549 | + |
| 550 | +class NotificationCampaignsRecipientsPreview(PermissionRequiredMixin, ListView): |
| 551 | + template_name = 'users/list.html' |
| 552 | + permission_required = 'osf.view_osfuser' |
| 553 | + raise_exception = True |
| 554 | + paginate_by = 25 |
| 555 | + |
| 556 | + def get_queryset(self): |
| 557 | + filters = {} |
| 558 | + raw_filters = self.request.GET.get('filters', None) |
| 559 | + if raw_filters: |
| 560 | + json_filters = json.loads(raw_filters) |
| 561 | + if predefined := json_filters.get('predefined'): |
| 562 | + filters = FILTER_PRESETS.get(predefined, {}) |
| 563 | + else: |
| 564 | + filters = { |
| 565 | + f'{item["field"]}__{item["lookup"]}': item['value'] |
| 566 | + for item in json_filters.get('manual', []) |
| 567 | + } |
| 568 | + |
| 569 | + return filter_users(filters) |
| 570 | + |
| 571 | + def get_context_data(self, **kwargs): |
| 572 | + users = self.get_queryset() |
| 573 | + |
| 574 | + page_size = self.get_paginate_by(users) |
| 575 | + paginator, page, query_set, is_paginated = self.paginate_queryset( |
| 576 | + users, |
| 577 | + page_size, |
| 578 | + ) |
| 579 | + # append search param to pagination links |
| 580 | + kwargs.update({'extra_query_params': f'&filters={self.request.GET.get("filters")}'}) |
| 581 | + return super().get_context_data( |
| 582 | + **kwargs, |
| 583 | + page=page, |
| 584 | + users=query_set, |
| 585 | + paginator=paginator, |
| 586 | + is_paginated=is_paginated, |
| 587 | + ) |
| 588 | + |
| 589 | +class StartNotificationCampaign(PermissionRequiredMixin, View): |
| 590 | + permission_required = 'osf.change_notificationtype' |
| 591 | + |
| 592 | + def post(self, request, *args, **kwargs): |
| 593 | + notification_campaign = get_object_or_404( |
| 594 | + NotificationCampaign, |
| 595 | + pk=kwargs['pk'], |
| 596 | + ) |
| 597 | + |
| 598 | + restart_failed = request.GET.get('restart_failed') == 'true' |
| 599 | + |
| 600 | + notification_campaign.start(restart_failed=restart_failed) |
| 601 | + |
| 602 | + return redirect( |
| 603 | + 'notifications:notification_campaigns_detail', |
| 604 | + pk=notification_campaign.pk, |
| 605 | + ) |
0 commit comments