diff --git a/src/etools/applications/audit/serializers/engagement.py b/src/etools/applications/audit/serializers/engagement.py index eb7e629183..f1ab8d360b 100644 --- a/src/etools/applications/audit/serializers/engagement.py +++ b/src/etools/applications/audit/serializers/engagement.py @@ -583,6 +583,7 @@ def _validate_financial_findings(self, validated_data): raise serializers.ValidationError({'financial_findings_local': _('Cannot exceed Audited Expenditure Local')}) def validate(self, validated_data): + validated_data = super().validate(validated_data) self._validate_financial_findings(validated_data) return validated_data diff --git a/src/etools/applications/audit/serializers/mixins.py b/src/etools/applications/audit/serializers/mixins.py index bb876c84a5..12c3524f11 100644 --- a/src/etools/applications/audit/serializers/mixins.py +++ b/src/etools/applications/audit/serializers/mixins.py @@ -50,6 +50,33 @@ def _validate_dates(self, validated_data): if value and key in date_fields and timezone.now().date() < value: errors[key] = _('Date should be in past.') + start_date = validated_data.get('start_date', self.instance.start_date if self.instance else None) + end_date = validated_data.get('end_date', self.instance.end_date if self.instance else None) + partner_contacted_at = validated_data.get('partner_contacted_at', self.instance.partner_contacted_at if self.instance else None) + date_of_field_visit = validated_data.get('date_of_field_visit', self.instance.date_of_field_visit if self.instance else None) + date_of_draft_report_to_ip = validated_data.get('date_of_draft_report_to_ip', self.instance.date_of_draft_report_to_ip if self.instance else None) + date_of_comments_by_ip = validated_data.get('date_of_comments_by_ip', self.instance.date_of_comments_by_ip if self.instance else None) + date_of_draft_report_to_unicef = validated_data.get('date_of_draft_report_to_unicef', self.instance.date_of_draft_report_to_unicef if self.instance else None) + date_of_comments_by_unicef = validated_data.get('date_of_comments_by_unicef', self.instance.date_of_comments_by_unicef if self.instance else None) + + if start_date and end_date and end_date < start_date: + errors['end_date'] = _('This date should be after Period Start Date.') + if end_date and partner_contacted_at and partner_contacted_at < end_date: + errors['partner_contacted_at'] = _('This date should be after Period End Date.') + + if partner_contacted_at and date_of_field_visit and date_of_field_visit < partner_contacted_at: + errors['date_of_field_visit'] = _('This date should be after Date IP was contacted.') + + if date_of_field_visit and date_of_draft_report_to_ip and date_of_draft_report_to_ip < date_of_field_visit: + # date of field visit is editable even if date of draft report is readonly, map error to field visit date + errors['date_of_field_visit'] = _('This date should be before Date Draft Report Issued to IP.') + if date_of_draft_report_to_ip and date_of_comments_by_ip and date_of_comments_by_ip < date_of_draft_report_to_ip: + errors['date_of_comments_by_ip'] = _('This date should be after Date Draft Report Issued to UNICEF.') + if date_of_comments_by_ip and date_of_draft_report_to_unicef and date_of_draft_report_to_unicef < date_of_comments_by_ip: + errors['date_of_draft_report_to_unicef'] = _('This date should be after Date Comments Received from IP.') + if date_of_draft_report_to_unicef and date_of_comments_by_unicef and date_of_comments_by_unicef < date_of_draft_report_to_unicef: + errors['date_of_comments_by_unicef'] = _('This date should be after Date Draft Report Issued to UNICEF.') + if errors: raise serializers.ValidationError(errors) return validated_data diff --git a/src/etools/applications/audit/tests/factories.py b/src/etools/applications/audit/tests/factories.py index e8b25dd84b..a71acf8266 100644 --- a/src/etools/applications/audit/tests/factories.py +++ b/src/etools/applications/audit/tests/factories.py @@ -1,6 +1,8 @@ import random +from datetime import timedelta from django.db.models import signals +from django.utils import timezone import factory from factory import fuzzy @@ -133,16 +135,25 @@ class Meta: class AuditFactory(EngagementFactory): + start_date = timezone.now().date() - timedelta(days=30) + end_date = timezone.now().date() - timedelta(days=10) + class Meta: model = Audit class SpecialAuditFactory(EngagementFactory): + start_date = timezone.now().date() - timedelta(days=30) + end_date = timezone.now().date() - timedelta(days=10) + class Meta: model = SpecialAudit class SpotCheckFactory(EngagementFactory): + start_date = timezone.now().date() - timedelta(days=30) + end_date = timezone.now().date() - timedelta(days=10) + class Meta: model = SpotCheck diff --git a/src/etools/applications/audit/tests/test_views.py b/src/etools/applications/audit/tests/test_views.py index 7e6785663a..8b68f8acab 100644 --- a/src/etools/applications/audit/tests/test_views.py +++ b/src/etools/applications/audit/tests/test_views.py @@ -1,6 +1,7 @@ import datetime import json import random +from copy import copy from unittest.mock import Mock, patch from django.contrib.contenttypes.models import ContentType @@ -539,6 +540,16 @@ def setUp(self): def _do_create(self, user, data): data = data or {} + for date_field in [ + 'start_date', 'end_date', 'partner_contacted_at', + 'date_of_field_visit', 'date_of_draft_report_to_ip', + 'date_of_comments_by_ip', 'date_of_draft_report_to_unicef', + 'date_of_comments_by_unicef' + ]: + if data.get(date_field): + if isinstance(data[date_field], datetime.datetime): + data[date_field] = data[date_field].date() + data[date_field] = data[date_field].isoformat() response = self.forced_auth_req( 'post', self.engagements_url(), @@ -602,6 +613,14 @@ class TestMicroAssessmentCreateViewSet(TestEngagementCreateActivePDViewSet, Base BaseTenantTestCase): engagement_factory = MicroAssessmentFactory + def test_partner_contacted_at_validation(self): + # date should be in past + data = copy(self.create_data) + data['partner_contacted_at'] = datetime.datetime.now() + datetime.timedelta(days=1) + response = self._do_create(self.unicef_focal_point, data) + self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('partner_contacted_at', response.data) + class TestAuditCreateViewSet(TestEngagementCreateActivePDViewSet, BaseTestEngagementsCreateViewSet, BaseTenantTestCase): engagement_factory = AuditFactory @@ -610,6 +629,20 @@ def setUp(self): super().setUp() self.create_data['year_of_audit'] = timezone.now().year + def test_end_date_validation(self): + data = copy(self.create_data) + data['end_date'] = data['start_date'] - datetime.timedelta(days=1) + response = self._do_create(self.unicef_focal_point, data) + self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('end_date', response.data) + + def test_partner_contacted_at_validation(self): + data = copy(self.create_data) + data['partner_contacted_at'] = data['end_date'] - datetime.timedelta(days=1) + response = self._do_create(self.unicef_focal_point, data) + self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('partner_contacted_at', response.data) + class TestSpotCheckCreateViewSet(TestEngagementCreateActivePDViewSet, BaseTestEngagementsCreateViewSet, BaseTenantTestCase): @@ -690,6 +723,20 @@ def test_offices(self): sorted([office_1.pk, office_2.pk]), ) + def test_end_date_validation(self): + data = copy(self.create_data) + data['end_date'] = data['start_date'] - datetime.timedelta(days=1) + response = self._do_create(self.unicef_focal_point, data) + self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('end_date', response.data) + + def test_partner_contacted_at_validation(self): + data = copy(self.create_data) + data['partner_contacted_at'] = data['end_date'] - datetime.timedelta(days=1) + response = self._do_create(self.unicef_focal_point, data) + self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('partner_contacted_at', response.data) + class SpecialAuditCreateViewSet(BaseTestEngagementsCreateViewSet, BaseTenantTestCase): engagement_factory = SpecialAuditFactory @@ -715,12 +762,36 @@ def test_engagement_without_active_pd(self): self.assertEquals(response.status_code, status.HTTP_201_CREATED) + def test_end_date_validation(self): + data = copy(self.create_data) + data['end_date'] = data['start_date'] - datetime.timedelta(days=1) + response = self._do_create(self.unicef_focal_point, data) + self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('end_date', response.data) + + def test_partner_contacted_at_validation(self): + data = copy(self.create_data) + data['partner_contacted_at'] = data['end_date'] - datetime.timedelta(days=1) + response = self._do_create(self.unicef_focal_point, data) + self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('partner_contacted_at', response.data) + class TestEngagementsUpdateViewSet(EngagementTransitionsTestCaseMixin, BaseTenantTestCase): engagement_factory = AuditFactory def _do_update(self, user, data): data = data or {} + for date_field in [ + 'start_date', 'end_date', 'partner_contacted_at', + 'date_of_field_visit', 'date_of_draft_report_to_ip', + 'date_of_comments_by_ip', 'date_of_draft_report_to_unicef', + 'date_of_comments_by_unicef' + ]: + if data.get(date_field): + if isinstance(data[date_field], datetime.datetime): + data[date_field] = data[date_field].date() + data[date_field] = data[date_field].isoformat() response = self.forced_auth_req( 'patch', '/api/audit/audits/{}/'.format(self.engagement.id), @@ -753,6 +824,83 @@ def test_percent_of_audited_expenditure_valid(self): }) self.assertEqual(response.status_code, status.HTTP_200_OK) + def test_date_of_field_visit_after_partner_contacted_at_validation(self): + self.engagement.partner_contacted_at = self.engagement.end_date + datetime.timedelta(days=1) + self.engagement.save() + response = self._do_update(self.auditor, { + 'date_of_field_visit': self.engagement.end_date, + 'date_of_draft_report_to_ip': None, + 'date_of_comments_by_ip': None, + 'date_of_draft_report_to_unicef': None, + 'date_of_comments_by_unicef': None, + }) + self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('date_of_field_visit', response.data) + + def test_date_of_draft_report_to_ip_after_date_of_field_visit_validation(self): + self.engagement.partner_contacted_at = self.engagement.end_date + datetime.timedelta(days=1) + self.engagement.save() + response = self._do_update(self.auditor, { + 'date_of_field_visit': self.engagement.end_date + datetime.timedelta(days=2), + 'date_of_draft_report_to_ip': self.engagement.end_date, + 'date_of_comments_by_ip': None, + 'date_of_draft_report_to_unicef': None, + 'date_of_comments_by_unicef': None, + }) + self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('date_of_field_visit', response.data) + + def test_date_of_comments_by_ip_after_date_of_draft_report_to_ip_validation(self): + self.engagement.partner_contacted_at = self.engagement.end_date + datetime.timedelta(days=1) + self.engagement.save() + response = self._do_update(self.auditor, { + 'date_of_field_visit': self.engagement.end_date + datetime.timedelta(days=2), + 'date_of_draft_report_to_ip': self.engagement.end_date + datetime.timedelta(days=3), + 'date_of_comments_by_ip': self.engagement.end_date, + 'date_of_draft_report_to_unicef': None, + 'date_of_comments_by_unicef': None, + }) + self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('date_of_comments_by_ip', response.data) + + def test_date_of_draft_report_to_unicef_after_date_of_comments_by_ip_validation(self): + self.engagement.partner_contacted_at = self.engagement.end_date + datetime.timedelta(days=1) + self.engagement.save() + response = self._do_update(self.auditor, { + 'date_of_field_visit': self.engagement.end_date + datetime.timedelta(days=2), + 'date_of_draft_report_to_ip': self.engagement.end_date + datetime.timedelta(days=3), + 'date_of_comments_by_ip': self.engagement.end_date + datetime.timedelta(days=4), + 'date_of_draft_report_to_unicef': self.engagement.end_date, + 'date_of_comments_by_unicef': None, + }) + self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('date_of_draft_report_to_unicef', response.data) + + def test_date_of_comments_by_unicef_after_date_of_draft_report_to_unicef_validation(self): + self.engagement.partner_contacted_at = self.engagement.end_date + datetime.timedelta(days=1) + self.engagement.save() + response = self._do_update(self.auditor, { + 'date_of_field_visit': self.engagement.end_date + datetime.timedelta(days=2), + 'date_of_draft_report_to_ip': self.engagement.end_date + datetime.timedelta(days=3), + 'date_of_comments_by_ip': self.engagement.end_date + datetime.timedelta(days=4), + 'date_of_draft_report_to_unicef': self.engagement.end_date + datetime.timedelta(days=5), + 'date_of_comments_by_unicef': self.engagement.end_date, + }) + self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('date_of_comments_by_unicef', response.data) + + def test_dates_update_ok(self): + self.engagement.partner_contacted_at = self.engagement.end_date + datetime.timedelta(days=1) + self.engagement.save() + response = self._do_update(self.auditor, { + 'date_of_field_visit': self.engagement.end_date + datetime.timedelta(days=2), + 'date_of_draft_report_to_ip': self.engagement.end_date + datetime.timedelta(days=3), + 'date_of_comments_by_ip': self.engagement.end_date + datetime.timedelta(days=4), + 'date_of_draft_report_to_unicef': self.engagement.end_date + datetime.timedelta(days=5), + 'date_of_comments_by_unicef': self.engagement.end_date + datetime.timedelta(days=6), + }) + self.assertEquals(response.status_code, status.HTTP_200_OK) + class TestEngagementActionPointViewSet(EngagementTransitionsTestCaseMixin, BaseTenantTestCase): engagement_factory = MicroAssessmentFactory diff --git a/src/etools/applications/core/management/commands/init-celery.py b/src/etools/applications/core/management/commands/init-celery.py index 54f539505d..e54129c658 100644 --- a/src/etools/applications/core/management/commands/init-celery.py +++ b/src/etools/applications/core/management/commands/init-celery.py @@ -28,6 +28,11 @@ def handle(self, *args, **options): 'enabled': False, 'interval': every_day}) + PeriodicTask.objects.get_or_create(name='Notification Partner Assessment expires', defaults={ + 'task': 'partners.tasks.notify_partner_assessment_expires', + 'enabled': False, + 'interval': every_day}) + PeriodicTask.objects.get_or_create(name='Intervention Notification Ending', defaults={ 'task': 'partners.tasks.intervention_notification_ending', 'enabled': False, diff --git a/src/etools/applications/last_mile/admin.py b/src/etools/applications/last_mile/admin.py index 871db8ad30..b0c9bdc0d5 100644 --- a/src/etools/applications/last_mile/admin.py +++ b/src/etools/applications/last_mile/admin.py @@ -29,10 +29,10 @@ class WaybillTransferAttachmentInline(AttachmentSingleInline): @admin.register(models.PointOfInterest) class PointOfInterestAdmin(XLSXImportMixin, admin.ModelAdmin): - list_display = ('name', 'parent', 'poi_type') + list_display = ('name', 'parent', 'poi_type', 'p_code') list_select_related = ('parent',) list_filter = ('private', 'is_active') - search_fields = ('name', ) + search_fields = ('name', 'p_code') raw_id_fields = ('partner_organizations',) formfield_overrides = { models.PointField: {'widget': forms.OSMWidget(attrs={'display_raw': True})}, @@ -44,7 +44,8 @@ class PointOfInterestAdmin(XLSXImportMixin, admin.ModelAdmin): 'PRIMARY TYPE *': 'poi_type', 'IS PRIVATE***': 'private', 'LATITUDE': 'latitude', - 'LONGITUDE': 'longitude' + 'LONGITUDE': 'longitude', + 'P CODE': 'p_code' } def has_import_permission(self, request): @@ -60,8 +61,9 @@ def import_data(self, workbook): continue poi_dict[self.import_field_mapping[col[0].value]] = str(col[row].value).strip() - # add a pcode as it doesn't exist: - poi_dict['p_code'] = generate_hash(poi_dict['partner_org_vendor_no'] + poi_dict['name'], 12) + if not poi_dict.get('p_code'): + # add a pcode if it doesn't exist: + poi_dict['p_code'] = generate_hash(poi_dict['partner_org_vendor_no'] + poi_dict['name'] + poi_dict['poi_type'], 12) long = poi_dict.pop('longitude') lat = poi_dict.pop('latitude') try: @@ -88,11 +90,11 @@ def import_data(self, workbook): continue poi_obj, _ = models.PointOfInterest.all_objects.update_or_create( - point=poi_dict['point'], - name=poi_dict['name'], p_code=poi_dict['p_code'], - poi_type=poi_dict.get('poi_type'), - defaults={'private': poi_dict['private']} + defaults={'private': poi_dict['private'], + 'point': poi_dict['point'], + 'name': poi_dict['name'], + 'poi_type': poi_dict.get('poi_type')} ) poi_obj.partner_organizations.add(partner_org_obj) diff --git a/src/etools/applications/last_mile/migrations/0003_alter_pointofinterest_p_code.py b/src/etools/applications/last_mile/migrations/0003_alter_pointofinterest_p_code.py new file mode 100644 index 0000000000..687a515ec1 --- /dev/null +++ b/src/etools/applications/last_mile/migrations/0003_alter_pointofinterest_p_code.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.19 on 2024-05-17 08:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('last_mile', '0002_auto_20240426_1157'), + ] + + operations = [ + migrations.AlterField( + model_name='pointofinterest', + name='p_code', + field=models.CharField(max_length=32, unique=True, verbose_name='P Code'), + ), + ] diff --git a/src/etools/applications/last_mile/models.py b/src/etools/applications/last_mile/models.py index 940a9bc622..8dedf0276b 100644 --- a/src/etools/applications/last_mile/models.py +++ b/src/etools/applications/last_mile/models.py @@ -44,7 +44,7 @@ class PointOfInterest(TimeStampedModel, models.Model): null=True, blank=True ) name = models.CharField(verbose_name=_("Name"), max_length=254) - p_code = models.CharField(verbose_name=_("P Code"), max_length=32, blank=True, default='') + p_code = models.CharField(verbose_name=_("P Code"), max_length=32, unique=True) description = models.CharField(verbose_name=_("Description"), max_length=254) poi_type = models.ForeignKey( PointOfInterestType, diff --git a/src/etools/applications/last_mile/views_ext.py b/src/etools/applications/last_mile/views_ext.py index f80288fb85..25c42e55d8 100644 --- a/src/etools/applications/last_mile/views_ext.py +++ b/src/etools/applications/last_mile/views_ext.py @@ -79,7 +79,11 @@ def get_annotated_qs(qs): if qs.model == models.Transfer: return qs.annotate(vendor_number=F('partner_organization__organization__vendor_number'), checked_out_by_email=F('checked_out_by__email'), + checked_out_by_first_name=F('checked_out_by__first_name'), + checked_out_by_last_name=F('checked_out_by__last_name'), checked_in_by_email=F('checked_in_by__email'), + checked_in_by_last_name=F('checked_in_by__last_name'), + checked_in_by_first_name=F('checked_in_by__first_name'), origin_name=F('origin_point__name'), destination_name=F('destination_point__name'), ).values() diff --git a/src/etools/applications/partners/notifications/expiring_partner.py b/src/etools/applications/partners/notifications/expiring_partner.py new file mode 100644 index 0000000000..dfb6925d19 --- /dev/null +++ b/src/etools/applications/partners/notifications/expiring_partner.py @@ -0,0 +1,16 @@ +name = 'partners/expiring_partner' +defaults = { + 'description': 'Partner Assessment due to expire', + 'subject': 'eTools Partnership {{ partner_name }} Assessment is expiring in {{ days }} days', + 'content': """ + Dear Colleague, + + The assessment for the "HACT" or "Core Value" of Partner {{ partner_name }} + with Vendor number {{ partner_number }} in {{ country }} is due to expire in {{ days }} days. + Kindly complete the new assessment and ensure the vendor record is updated with the + latest risk information promptly to avoid any potential transaction blocks in the system. + + + Please note that this is an automated message and any response to this email cannot be replied to. + """ +} diff --git a/src/etools/applications/partners/tasks.py b/src/etools/applications/partners/tasks.py index 84a61eb931..a6223abb5b 100644 --- a/src/etools/applications/partners/tasks.py +++ b/src/etools/applications/partners/tasks.py @@ -4,7 +4,9 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType +from django.contrib.postgres.aggregates import ArrayAgg from django.db import connection, transaction +from django.db.models import DurationField, ExpressionWrapper, F, OuterRef, Q, Subquery from django.utils import timezone from django.utils.translation import gettext as _ @@ -26,7 +28,7 @@ from etools.applications.partners.validation.agreements import AgreementValid from etools.applications.partners.validation.interventions import InterventionValid from etools.applications.reports.models import CountryProgramme -from etools.applications.users.models import Country +from etools.applications.users.models import Country, User from etools.config.celery import app from etools.libraries.djangolib.utils import get_environment from etools.libraries.tenant_support.utils import run_on_all_tenants @@ -37,6 +39,10 @@ # about each intervention ending {delta} days from now. _INTERVENTION_ENDING_SOON_DELTAS = (15, 30, 60, 90) +# _PARTNER_ASSESSMENT_EXPIRING_SOON_DELTAS is used by notify_partner_expires(). Notifications will be sent to +# UNICEF Focal Points every {delta} days prior expiration (reaching 5 years from the date of the Core Value Assessment). +_PARTNER_ASSESSMENT_EXPIRING_SOON_DELTAS = (30, 60, 90) + def get_intervention_context(intervention): """Return a dict formatting some details about the intervention. @@ -272,6 +278,68 @@ def notify_partner_hidden(partner_pk, tenant_name): ) +@app.task +def notify_partner_assessment_expires(): + """Send notifications to UNICEF Focal Points for partners that will have their + Core Value Assessment or HACT Assessment expire within 30/60/90 days. + Task will run every 24 hours. + """ + today = timezone.now().date() + notify_end_dates = [datetime.timedelta(days=-delta) for delta in _PARTNER_ASSESSMENT_EXPIRING_SOON_DELTAS] + delta = datetime.timedelta(days=PartnerOrganization.EXPIRING_ASSESSMENT_LIMIT_YEAR * 365) + + for country in Country.objects.exclude(name='Global').all(): + connection.set_tenant(country) + logger.info('Starting Partner Assessment Expire for country {}'.format(country.name)) + + core_value_assessment_expiring = PartnerOrganization.objects\ + .filter(pk=OuterRef("pk"))\ + .annotate(core_assessments_to_expire=ExpressionWrapper( + today - (F('core_values_assessment_date') + delta), + output_field=DurationField())).values('core_assessments_to_expire') + last_assessment_expiring = PartnerOrganization.objects\ + .filter(pk=OuterRef("pk"))\ + .annotate(assessments_to_expire=ExpressionWrapper( + today - (F('last_assessment_date') + delta), + output_field=DurationField())).values('assessments_to_expire') + partner_qs = PartnerOrganization.objects\ + .active()\ + .annotate(core_value_assessment_expiring=Subquery(core_value_assessment_expiring)) \ + .annotate(assessments_expiring=Subquery(last_assessment_expiring)) \ + .filter(Q(core_value_assessment_expiring__in=notify_end_dates) | + Q(assessments_expiring__in=notify_end_dates))\ + .distinct() + + for partner in partner_qs: + pds = Intervention.objects\ + .prefetch_related('unicef_focal_points')\ + .filter(agreement__partner=partner)\ + .exclude(Q(status__in=[Intervention.CLOSED, Intervention.ENDED]) | + Q(status=Intervention.DRAFT, modified__gt=timezone.now() - datetime.timedelta(days=365)))\ + .annotate(unicef_focal_point_emails=ArrayAgg('unicef_focal_points__email')) + + focal_points_emails = set() + for pd in pds: + focal_points_emails.update(pd.unicef_focal_point_emails) + filtered_emails = User.objects.base_qs().filter( + email__in=focal_points_emails, profile__country=country).values_list('email', flat=True) + + if filtered_emails: + days = partner.core_value_assessment_expiring.days.__abs__() \ + if partner.core_value_assessment_expiring else partner.assessments_expiring.days.__abs__() + email_context = { + 'country': country.name, + 'partner_name': partner.name, + 'partner_number': partner.vendor_number, + 'days': days, + } + send_notification_with_template( + recipients=list(filtered_emails), + template_name='partners/expiring_partner', + context=email_context + ) + + @app.task def check_pca_required(): run_on_all_tenants(send_pca_required_notifications) diff --git a/src/etools/applications/partners/tests/test_tasks.py b/src/etools/applications/partners/tests/test_tasks.py index 9939b5bb07..bc6e9e65f5 100644 --- a/src/etools/applications/partners/tests/test_tasks.py +++ b/src/etools/applications/partners/tests/test_tasks.py @@ -23,8 +23,9 @@ from etools.applications.attachments.tests.factories import AttachmentFactory, AttachmentFileTypeFactory from etools.applications.core.tests.cases import BaseTenantTestCase from etools.applications.funds.tests.factories import FundsReservationHeaderFactory +from etools.applications.organizations.models import OrganizationType from etools.applications.organizations.tests.factories import OrganizationFactory -from etools.applications.partners.models import Agreement, Intervention +from etools.applications.partners.models import Agreement, Intervention, PartnerOrganization from etools.applications.partners.synchronizers import PDVisionUploader from etools.applications.partners.tasks import transfer_active_pds_to_new_cp from etools.applications.partners.tests.factories import ( @@ -1409,3 +1410,78 @@ def test_realms_sync_unicef(self, sync_mock): RealmFactory(user=user) sync_mock.assert_not_called() self.assertEqual(len(commit_callbacks), 0) + + +class TestPartnerAssessmentExpires(BaseTenantTestCase): + @classmethod + def setUpTestData(cls): + call_command("update_notifications") + cls.today = timezone.now().date() + cls.partner_1 = PartnerFactory( + organization=OrganizationFactory(organization_type=OrganizationType.CIVIL_SOCIETY_ORGANIZATION) + ) + cls.intervention_1 = InterventionFactory( + agreement=AgreementFactory(partner=cls.partner_1), + status=Intervention.ACTIVE, + start=cls.today - datetime.timedelta(days=2), + ) + cls.focal_point = UserFactory() + cls.focal_point.profile.country_override = connection.tenant + cls.focal_point.profile.save() + cls.intervention_1.unicef_focal_points.add(cls.focal_point) + + def test_task_last_assessment_date_expiring(self): + last_assessment_date = self.today - datetime.timedelta(days=PartnerOrganization.EXPIRING_ASSESSMENT_LIMIT_YEAR * 365 - 30) + self.partner_1.last_assessment_date = last_assessment_date + self.partner_1.save() + + send_path = "etools.applications.partners.tasks.send_notification_with_template" + mock_send = mock.Mock() + with mock.patch(send_path, mock_send): + etools.applications.partners.tasks.notify_partner_assessment_expires() + self.assertEqual(mock_send.call_count, 1) + self.assertEqual(mock_send.call_args.kwargs['recipients'], [self.focal_point.email]) + + def test_task_core_assessment_date_expiring(self): + core_values_assessment_date = self.today - datetime.timedelta(days=PartnerOrganization.EXPIRING_ASSESSMENT_LIMIT_YEAR * 365 - 60) + + self.partner_1.core_values_assessment_date = core_values_assessment_date + self.partner_1.save() + + send_path = "etools.applications.partners.tasks.send_notification_with_template" + mock_send = mock.Mock() + with mock.patch(send_path, mock_send): + etools.applications.partners.tasks.notify_partner_assessment_expires() + self.assertEqual(mock_send.call_count, 1) + self.assertEqual(mock_send.call_args.kwargs['recipients'], [self.focal_point.email]) + + def test_task_focal_point_without_country(self): + core_values_assessment_date = self.today - datetime.timedelta(days=PartnerOrganization.EXPIRING_ASSESSMENT_LIMIT_YEAR * 365 - 60) + + self.partner_1.core_values_assessment_date = core_values_assessment_date + self.partner_1.save() + + focal_point = UserFactory(profile__country=None) + self.intervention_1.unicef_focal_points.remove(self.focal_point) + self.intervention_1.unicef_focal_points.add(focal_point) + self.assertIsNone(focal_point.profile.country) + + send_path = "etools.applications.partners.tasks.send_notification_with_template" + mock_send = mock.Mock() + with mock.patch(send_path, mock_send): + etools.applications.partners.tasks.notify_partner_assessment_expires() + self.assertEqual(mock_send.call_count, 0) + + def test_task_excluded_pd_status(self): + core_values_assessment_date = self.today - datetime.timedelta(days=PartnerOrganization.EXPIRING_ASSESSMENT_LIMIT_YEAR * 365 - 60) + + self.partner_1.core_values_assessment_date = core_values_assessment_date + self.partner_1.save() + self.intervention_1.status = Intervention.CLOSED + self.intervention_1.save() + + send_path = "etools.applications.partners.tasks.send_notification_with_template" + mock_send = mock.Mock() + with mock.patch(send_path, mock_send): + etools.applications.partners.tasks.notify_partner_assessment_expires() + self.assertEqual(mock_send.call_count, 0) diff --git a/src/etools/applications/reports/models.py b/src/etools/applications/reports/models.py index 82e286e684..351b8522a7 100644 --- a/src/etools/applications/reports/models.py +++ b/src/etools/applications/reports/models.py @@ -2,6 +2,7 @@ from datetime import date from string import ascii_lowercase +from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator from django.db import models, transaction from django.db.models import Q, Sum @@ -583,6 +584,11 @@ class Disaggregation(TimeStampedModel): def __str__(self): return self.name + def save(self, *args, **kwargs): + if not self.pk and Disaggregation.objects.filter(name__iexact=self.name).first(): + raise ValidationError("A disaggregation with this name already exists.") + super().save(*args, **kwargs) + class DisaggregationValue(TimeStampedModel): """ diff --git a/src/etools/applications/reports/serializers/v2.py b/src/etools/applications/reports/serializers/v2.py index afc77fb10e..034f18ee7d 100644 --- a/src/etools/applications/reports/serializers/v2.py +++ b/src/etools/applications/reports/serializers/v2.py @@ -1,3 +1,4 @@ +from django.core.exceptions import ValidationError as DjangoValidationError from django.db import transaction from django.utils.translation import gettext as _ @@ -108,7 +109,11 @@ def create(self, validated_data): values_data = validated_data.pop('disaggregation_values') if not values_data or len(values_data) == 1: raise ValidationError('At least 2 Disaggregation Groups must be set.') - disaggregation = Disaggregation.objects.create(**validated_data) + try: + disaggregation = Disaggregation.objects.create(**validated_data) + except DjangoValidationError as exc: + raise ValidationError(exc.messages) + for value_data in values_data: if 'id' in value_data: raise ValidationError( diff --git a/src/etools/applications/reports/tests/test_views.py b/src/etools/applications/reports/tests/test_views.py index 5f556615a0..0c6ef9b33c 100644 --- a/src/etools/applications/reports/tests/test_views.py +++ b/src/etools/applications/reports/tests/test_views.py @@ -365,6 +365,41 @@ def test_create_one_group(self): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + def test_create_duplicate_disaggregation(self): + """ + Test creating disaggregations with duplicated name + """ + data = { + "name": "name", + "disaggregation_values": [ + { + "value": "first group", + "active": False + }, + { + "value": "second group", + "active": False + } + ] + } + response = self.forced_auth_req( + 'post', + self.url, + user=self.pme_user, + data=data + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + data['name'] = 'NAME' + response = self.forced_auth_req( + 'post', + self.url, + user=self.pme_user, + data=data + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("A disaggregation with this name already exists.", response.data) + class TestDisaggregationRetrieveUpdateViews(BaseTenantTestCase): """