diff --git a/Pipfile b/Pipfile index c1fb50df7..fb5b53232 100644 --- a/Pipfile +++ b/Pipfile @@ -77,7 +77,7 @@ unicef-djangolib = "==0.5.4" unicef-locations = "==4.0.1" unicef-notification = "==1.1" unicef-restlib = "==0.7" -unicef-snapshot = "==1.1" +unicef-snapshot = "==1.3" unicef-rest-export = "==0.5.3" xhtml2pdf = "==0.2.5" unicef-vision = "==0.6" diff --git a/Pipfile.lock b/Pipfile.lock index f13581b0d..8ddd2ed3d 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1306,11 +1306,11 @@ }, "unicef-snapshot": { "hashes": [ - "sha256:1740c75736d3d921d9cea57c127b02be99bfc37efbf69115f1df81d5c371ec3e", - "sha256:5645e3c887b108450b138ca87be3c73c45477611660e5056f0ea4b70202734bc" + "sha256:8426b5c1e126eb56ea1f7ee26a340535661087656f10dcb2f90c839a6e074ffa", + "sha256:d86f3e5274daa6617436517c7c0d709034cdcac78d1b572f5a0127b9044d6763" ], "index": "pypi", - "version": "==1.1" + "version": "==1.3" }, "unicef-vision": { "hashes": [ diff --git a/src/etools/__init__.py b/src/etools/__init__.py index 964d5506d..7f4c1e32e 100644 --- a/src/etools/__init__.py +++ b/src/etools/__init__.py @@ -1,2 +1,2 @@ -VERSION = __version__ = '9.12.1' +VERSION = __version__ = '10.1.1' NAME = 'eTools' diff --git a/src/etools/applications/audit/admin.py b/src/etools/applications/audit/admin.py index cca629048..75c1ab37f 100644 --- a/src/etools/applications/audit/admin.py +++ b/src/etools/applications/audit/admin.py @@ -24,7 +24,7 @@ class EngagementAdmin(admin.ModelAdmin): list_display = [ '__str__', 'status', 'partner', 'date_of_field_visit', - 'engagement_type', 'start_date', 'end_date', + 'engagement_type', 'start_date', 'end_date', 'year_of_audit', ] list_filter = [ 'status', 'start_date', 'end_date', 'status', 'engagement_type', diff --git a/src/etools/applications/audit/conditions.py b/src/etools/applications/audit/conditions.py index a69444e5e..b16d8e5d5 100644 --- a/src/etools/applications/audit/conditions.py +++ b/src/etools/applications/audit/conditions.py @@ -28,11 +28,11 @@ def is_satisfied(self): return self.user in self.engagement.staff_members.all() -class IsStaffMemberCondition(SimpleCondition): - predicate = 'user.is_staff' +class IsUnicefUserCondition(SimpleCondition): + predicate = 'user.is_unicef_user' def __init__(self, user): self.user = user def is_satisfied(self): - return self.user.is_staff + return self.user.is_unicef_user() diff --git a/src/etools/applications/audit/exports.py b/src/etools/applications/audit/exports.py index 8e7faa752..143afd23d 100644 --- a/src/etools/applications/audit/exports.py +++ b/src/etools/applications/audit/exports.py @@ -103,7 +103,16 @@ def labels(self): ), RiskCategory.objects.get(code='ma_questionnaire', parent__isnull=True).children.all() )): - labels['questionnaire.{}'.format(blueprint.id)] = blueprint.header + labels['questionnaire.{}'.format(blueprint.id)] = "Questionnaire v1 - {}".format(blueprint.header) + + for blueprint in itertools.chain(*map( + lambda c: itertools.chain( + itertools.chain(*map(lambda sc: sc.blueprints.all(), c.children.all())), + c.blueprints.all() + ), + RiskCategory.objects.get(code='ma_questionnaire_v2', parent__isnull=True).children.all() + )): + labels['questionnaire_v2.{}'.format(blueprint.id)] = "Questionnaire v2 - {}".format(blueprint.header) return labels diff --git a/src/etools/applications/audit/filters.py b/src/etools/applications/audit/filters.py index 2c942ca02..e7ca4b9c1 100644 --- a/src/etools/applications/audit/filters.py +++ b/src/etools/applications/audit/filters.py @@ -83,6 +83,7 @@ class Meta: 'date_of_draft_report_to_ip': ['lte', 'gte', 'gt', 'lt'], 'offices': ['exact', 'in'], 'sections': ['exact', 'in'], + 'year_of_audit': ['exact', 'in'], } diff --git a/src/etools/applications/audit/management/commands/update_audit_permissions.py b/src/etools/applications/audit/management/commands/update_audit_permissions.py index d504b29dc..c0ebe52ff 100644 --- a/src/etools/applications/audit/management/commands/update_audit_permissions.py +++ b/src/etools/applications/audit/management/commands/update_audit_permissions.py @@ -11,7 +11,7 @@ AuditModuleCondition, AuditStaffMemberCondition, EngagementStaffMemberCondition, - IsStaffMemberCondition, + IsUnicefUserCondition, ) from etools.applications.audit.models import ( Auditor, @@ -77,6 +77,7 @@ class Command(BaseCommand): 'audit.engagement.end_date', 'audit.engagement.total_value', 'audit.engagement.joint_audit', + 'audit.engagement.year_of_audit', 'audit.engagement.shared_ip_with', 'audit.engagement.related_agreement', 'audit.engagement.sections', @@ -289,10 +290,7 @@ def assign_permissions(self): self.report_editable_block ) - self.add_permissions([self.focal_point, self.auditor], 'edit', [ - 'purchase_order.auditorfirm.staff_members', - 'purchase_order.auditorstaffmember.*', - ] + self.engagement_attachments_block) + self.add_permissions([self.focal_point, self.auditor], 'edit', self.engagement_attachments_block) self.add_permissions(self.focal_point, 'edit', [ 'purchase_order.purchaseorder.contract_end_date', @@ -321,11 +319,11 @@ def assign_permissions(self): self.report_readonly_block, condition=partner_contacted_condition ) - is_staff_condition = [IsStaffMemberCondition.predicate] + is_unicef_user = [IsUnicefUserCondition.predicate] self.add_permissions( self.engagement_staff_auditor, 'edit', self.users_notified_block, - condition=partner_contacted_condition + is_staff_condition + condition=partner_contacted_condition + is_unicef_user ) self.add_permissions( self.engagement_staff_auditor, 'edit', diff --git a/src/etools/applications/audit/migrations/0024_audit_year_of_audit.py b/src/etools/applications/audit/migrations/0024_audit_year_of_audit.py new file mode 100644 index 000000000..8a22ed994 --- /dev/null +++ b/src/etools/applications/audit/migrations/0024_audit_year_of_audit.py @@ -0,0 +1,51 @@ +# Generated by Django 3.2.6 on 2023-04-14 07:53 + +from django.db import migrations, models +from django.db.models import F, Subquery, OuterRef +from django.db.models.functions import Extract +from django.utils import timezone + +import etools.applications.audit.models + + +def fill_year_of_audit(apps, schema_editor): + Engagement = apps.get_model('audit', 'Engagement') + Engagement.objects.filter( + engagement_type='audit', + date_of_draft_report_to_ip__isnull=False, + ).update( + year_of_audit=Subquery( + Engagement.objects.filter( + pk=OuterRef('pk') + ).annotate( + end_year=Extract("date_of_draft_report_to_ip", "year") + ).values('end_year')[:1] + ) + ) + Engagement.objects.filter( + engagement_type='audit', + date_of_draft_report_to_ip__isnull=True, + ).update( + year_of_audit=timezone.now().year, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('audit', '0023_auto_20220415_1130'), + ] + + operations = [ + migrations.AddField( + model_name='engagement', + name='year_of_audit', + field=models.PositiveSmallIntegerField(default=1, null=True), + ), + migrations.RunPython(fill_year_of_audit, migrations.RunPython.noop), + migrations.AlterField( + model_name='engagement', + name='year_of_audit', + field=models.PositiveSmallIntegerField(default=etools.applications.audit.models.get_current_year, null=True), + ), + ] diff --git a/src/etools/applications/audit/migrations/0025_alter_engagement_year_of_audit.py b/src/etools/applications/audit/migrations/0025_alter_engagement_year_of_audit.py new file mode 100644 index 000000000..00d4af99b --- /dev/null +++ b/src/etools/applications/audit/migrations/0025_alter_engagement_year_of_audit.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.6 on 2023-04-17 09:36 + +from django.db import migrations, models +import etools.applications.audit.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('audit', '0024_audit_year_of_audit'), + ] + + operations = [ + migrations.AlterField( + model_name='engagement', + name='year_of_audit', + field=models.PositiveSmallIntegerField(db_index=True, default=etools.applications.audit.models.get_current_year, null=True), + ), + ] diff --git a/src/etools/applications/audit/migrations/0024_alter_engagement_staff_members.py b/src/etools/applications/audit/migrations/0026_alter_engagement_staff_members.py similarity index 91% rename from src/etools/applications/audit/migrations/0024_alter_engagement_staff_members.py rename to src/etools/applications/audit/migrations/0026_alter_engagement_staff_members.py index f4a0ed05d..204d7201a 100644 --- a/src/etools/applications/audit/migrations/0024_alter_engagement_staff_members.py +++ b/src/etools/applications/audit/migrations/0026_alter_engagement_staff_members.py @@ -8,7 +8,7 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('audit', '0023_auto_20220415_1130'), + ('audit', '0025_alter_engagement_year_of_audit'), ] operations = [ diff --git a/src/etools/applications/audit/migrations/0025_engagement_staff_members.py b/src/etools/applications/audit/migrations/0027_engagement_staff_members.py similarity index 97% rename from src/etools/applications/audit/migrations/0025_engagement_staff_members.py rename to src/etools/applications/audit/migrations/0027_engagement_staff_members.py index 38222835e..1186a192d 100644 --- a/src/etools/applications/audit/migrations/0025_engagement_staff_members.py +++ b/src/etools/applications/audit/migrations/0027_engagement_staff_members.py @@ -30,7 +30,7 @@ class Migration(migrations.Migration): migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('users', '0021_migrate_to_realms'), ('purchase_order', '0012_alter_auditorstaffmember_auditor_firm'), - ('audit', '0024_alter_engagement_staff_members'), + ('audit', '0026_alter_engagement_staff_members'), ] operations = [ diff --git a/src/etools/applications/audit/migrations/0028_audit_year_of_audit_recalculate.py b/src/etools/applications/audit/migrations/0028_audit_year_of_audit_recalculate.py new file mode 100644 index 000000000..e8d46a8ec --- /dev/null +++ b/src/etools/applications/audit/migrations/0028_audit_year_of_audit_recalculate.py @@ -0,0 +1,41 @@ +# Generated by Django 3.2.6 on 2023-04-14 07:53 + +from django.db import migrations, models +from django.db.models import F, Subquery, OuterRef +from django.db.models.functions import Extract +from django.utils import timezone + +import etools.applications.audit.models + + +def fill_year_of_audit_for_special_audit(apps, schema_editor): + Engagement = apps.get_model('audit', 'Engagement') + Engagement.objects.filter( + engagement_type='sa', + date_of_draft_report_to_ip__isnull=False, + ).update( + year_of_audit=Subquery( + Engagement.objects.filter( + pk=OuterRef('pk') + ).annotate( + end_year=Extract("date_of_draft_report_to_ip", "year") + ).values('end_year')[:1] + ) + ) + Engagement.objects.filter( + engagement_type='sa', + date_of_draft_report_to_ip__isnull=True, + ).update( + year_of_audit=timezone.now().year, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('audit', '0027_engagement_staff_members'), + ] + + operations = [ + migrations.RunPython(fill_year_of_audit_for_special_audit, migrations.RunPython.noop), + ] diff --git a/src/etools/applications/audit/migrations/0028_auto_20230515_0551.py b/src/etools/applications/audit/migrations/0028_auto_20230515_0551.py new file mode 100644 index 000000000..e3d384760 --- /dev/null +++ b/src/etools/applications/audit/migrations/0028_auto_20230515_0551.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.6 on 2023-05-15 05:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('audit', '0027_engagement_staff_members'), + ] + + operations = [ + migrations.AddField( + model_name='microassessment', + name='questionnaire_version', + field=models.PositiveSmallIntegerField(default=1), + ), + migrations.AlterField( + model_name='microassessment', + name='questionnaire_version', + field=models.PositiveSmallIntegerField(default=2), + ), + migrations.AlterField( + model_name='riskcategory', + name='header', + field=models.CharField(max_length=500, verbose_name='Header'), + ), + ] diff --git a/src/etools/applications/audit/migrations/0029_merge_20230523_1049.py b/src/etools/applications/audit/migrations/0029_merge_20230523_1049.py new file mode 100644 index 000000000..f7a9228f4 --- /dev/null +++ b/src/etools/applications/audit/migrations/0029_merge_20230523_1049.py @@ -0,0 +1,14 @@ +# Generated by Django 3.2.6 on 2023-05-23 10:49 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('audit', '0028_audit_year_of_audit_recalculate'), + ('audit', '0028_auto_20230515_0551'), + ] + + operations = [ + ] diff --git a/src/etools/applications/audit/models.py b/src/etools/applications/audit/models.py index 8e973d0e9..83f92f40a 100644 --- a/src/etools/applications/audit/models.py +++ b/src/etools/applications/audit/models.py @@ -40,6 +40,10 @@ from etools.libraries.fsm.views import has_action_permission +def get_current_year(): + return timezone.now().year + + class Engagement(InheritedModelMixin, TimeStampedModel, models.Model): TYPE_AUDIT = 'audit' TYPE_MICRO_ASSESSMENT = 'ma' @@ -160,6 +164,7 @@ class Engagement(InheritedModelMixin, TimeStampedModel, models.Model): ) joint_audit = models.BooleanField(verbose_name=_('Joint Audit'), default=False, blank=True) + year_of_audit = models.PositiveSmallIntegerField(null=True, default=get_current_year, db_index=True) shared_ip_with = ArrayField(models.CharField( max_length=20, choices=PartnerOrganization.AGENCY_CHOICES ), blank=True, default=list, verbose_name=_('Shared Audit with')) @@ -307,7 +312,7 @@ class RiskCategory(OrderedModel, models.Model): ('primary', _('Primary')), ) - header = models.CharField(verbose_name=_('Header'), max_length=255) + header = models.CharField(verbose_name=_('Header'), max_length=500) parent = models.ForeignKey( 'self', verbose_name=_('Parent'), null=True, blank=True, related_name='children', db_index=True, on_delete=models.CASCADE, @@ -549,6 +554,7 @@ class MicroAssessment(Engagement): code='micro_assessment_final_report', blank=True, ) + questionnaire_version = models.PositiveSmallIntegerField(default=2) objects = models.Manager() @@ -561,6 +567,13 @@ def save(self, *args, **kwargs): self.engagement_type = Engagement.TYPES.ma return super().save(*args, **kwargs) + @staticmethod + def get_questionnaire_code(version: int): + return { + 1: 'ma_questionnaire', + 2: 'ma_questionnaire_v2' + }[version] + @transition( 'status', source=Engagement.STATUSES.partner_contacted, target=Engagement.STATUSES.report_submitted, diff --git a/src/etools/applications/audit/serializers/auditor.py b/src/etools/applications/audit/serializers/auditor.py index f8f8013ec..497a10a15 100644 --- a/src/etools/applications/audit/serializers/auditor.py +++ b/src/etools/applications/audit/serializers/auditor.py @@ -80,6 +80,13 @@ class Meta(UserSerializer.Meta): fields = ['id', 'user', 'hidden'] +class AuditorStaffMemberRealmSerializer(AuditorStaffMemberSerializer): + has_active_realm = serializers.BooleanField(read_only=True) + + class Meta(AuditorStaffMemberSerializer.Meta): + fields = AuditorStaffMemberSerializer.Meta.fields + ['has_active_realm'] + + class AuditorFirmLightSerializer(PermissionsBasedSerializerMixin, serializers.ModelSerializer): organization_id = serializers.IntegerField(read_only=True, source='organization.id') diff --git a/src/etools/applications/audit/serializers/engagement.py b/src/etools/applications/audit/serializers/engagement.py index 3e8082d30..d0de15767 100644 --- a/src/etools/applications/audit/serializers/engagement.py +++ b/src/etools/applications/audit/serializers/engagement.py @@ -1,5 +1,7 @@ from copy import copy +from django.db import connection +from django.db.models import Exists, OuterRef from django.utils.translation import gettext as _ from rest_framework import serializers @@ -29,7 +31,7 @@ ) from etools.applications.audit.purchase_order.models import PurchaseOrder from etools.applications.audit.serializers.auditor import ( - AuditorStaffMemberSerializer, + AuditorStaffMemberRealmSerializer, PurchaseOrderItemSerializer, PurchaseOrderSerializer, ) @@ -47,6 +49,8 @@ from etools.applications.permissions2.serializers import PermissionsBasedSerializerMixin from etools.applications.reports.serializers.v1 import SectionSerializer from etools.applications.reports.serializers.v2 import OfficeLightSerializer, OfficeSerializer +from etools.applications.users.mixins import AUDIT_ACTIVE_GROUPS +from etools.applications.users.models import Realm from etools.applications.users.serializers_v3 import MinimalUserSerializer @@ -254,7 +258,8 @@ class EngagementSerializer( EngagementListSerializer ): staff_members = SeparatedReadWriteField( - read_field=AuditorStaffMemberSerializer(many=True, required=False), label=_('Audit Staff Team Members') + read_field=serializers.SerializerMethodField(), + label=_('Audit Staff Team Members') ) active_pd = SeparatedReadWriteField( read_field=BaseInterventionListSerializer(many=True, required=False), @@ -283,7 +288,7 @@ class EngagementSerializer( class Meta(EngagementListSerializer.Meta): fields = EngagementListSerializer.Meta.fields + [ 'total_value', 'staff_members', 'active_pd', 'authorized_officers', 'users_notified', - 'joint_audit', 'shared_ip_with', 'exchange_rate', 'currency_of_report', + 'joint_audit', 'year_of_audit', 'shared_ip_with', 'exchange_rate', 'currency_of_report', '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', 'date_of_report_submit', 'date_of_final_report', 'date_of_cancel', @@ -308,6 +313,17 @@ class Meta(EngagementListSerializer.Meta): } extra_kwargs['engagement_type'] = {'label': _('Engagement Type')} + def get_staff_members(self, obj): + staff_members_qs = obj.all()\ + .annotate(has_active_realm=Exists( + Realm.objects.filter( + user=OuterRef('pk'), + country=connection.tenant, + group__name__in=AUDIT_ACTIVE_GROUPS, + is_active=True)) + ) + return AuditorStaffMemberRealmSerializer(staff_members_qs, many=True).data + def get_sections(self, obj): return [{"id": s.pk, "name": s.name} for s in obj.all()] @@ -454,7 +470,10 @@ class Meta(WritableNestedSerializerMixin.Meta): class MicroAssessmentSerializer(ActivePDValidationMixin, RiskCategoriesUpdateMixin, EngagementSerializer): - questionnaire = AggregatedRiskRootSerializer(code='ma_questionnaire', required=False) + questionnaire = AggregatedRiskRootSerializer( + code=lambda ma: MicroAssessment.get_questionnaire_code(ma.questionnaire_version), + required=False, + ) test_subject_areas = RiskRootSerializer( code='ma_subject_areas', required=False, label=_('Tested Subject Areas') ) @@ -532,6 +551,7 @@ class Meta(EngagementSerializer.Meta): extra_kwargs = EngagementSerializer.Meta.extra_kwargs.copy() extra_kwargs.update({ 'engagement_type': {'read_only': True}, + 'year_of_audit': {'required': True}, }) def get_number_of_financial_findings(self, obj): diff --git a/src/etools/applications/audit/serializers/export.py b/src/etools/applications/audit/serializers/export.py index 7bc0475d7..913e489a8 100644 --- a/src/etools/applications/audit/serializers/export.py +++ b/src/etools/applications/audit/serializers/export.py @@ -80,8 +80,8 @@ class EngagementActionPointPDFSerializer(serializers.ModelSerializer): status = serializers.CharField(source='get_status_display') due_date = serializers.DateField(format='%d %b %Y') assigned_to = serializers.CharField(source='assigned_to.get_full_name') - office = serializers.CharField(source='office.name') - section = serializers.CharField(source='section.name') + office = serializers.CharField(source='office.name', allow_null=True) + section = serializers.CharField(source='section.name', allow_null=True) class Meta: model = EngagementActionPoint @@ -140,7 +140,10 @@ def get_active_pd(self, obj): class MicroAssessmentPDFSerializer(EngagementPDFSerializer): - questionnaire = AggregatedRiskRootSerializer(code='ma_questionnaire', required=False) + questionnaire = AggregatedRiskRootSerializer( + lambda ma: MicroAssessment.get_questionnaire_code(ma.questionnaire_version), + required=False, + ) test_subject_areas = RiskRootSerializer( code='ma_subject_areas', required=False, label=_('Tested Subject Areas') ) @@ -310,6 +313,7 @@ class MicroAssessmentDetailCSVSerializer(EngagementBaseDetailCSVSerializer): overall_risk_assessment = serializers.SerializerMethodField() subject_areas = serializers.SerializerMethodField() questionnaire = serializers.SerializerMethodField() + questionnaire_v2 = serializers.SerializerMethodField() def get_overall_risk_assessment(self, obj): serializer = RiskRootSerializer(code='ma_global_assessment') @@ -341,6 +345,20 @@ def get_questionnaire(self, obj): )) ) + def get_questionnaire_v2(self, obj): + serializer = AggregatedRiskRootSerializer(code='ma_questionnaire_v2') + questionnaire = serializer.to_representation(serializer.get_attribute(instance=obj)) + + return OrderedDict( + (b['id'], b['risk']['value_display'] if b['risk'] else 'N/A') + for b in itertools.chain(*map( + lambda c: itertools.chain(itertools.chain(*map( + lambda sc: sc['blueprints'], c['children'] + )), c['blueprints']), + questionnaire['children'] + )) + ) + class SpecialAuditDetailCSVSerializer(EngagementBaseDetailCSVSerializer): """ diff --git a/src/etools/applications/audit/serializers/risks.py b/src/etools/applications/audit/serializers/risks.py index 0eac9a862..ebb6442c1 100644 --- a/src/etools/applications/audit/serializers/risks.py +++ b/src/etools/applications/audit/serializers/risks.py @@ -179,12 +179,17 @@ def __init__(self, code, *args, **kwargs): if 'risks' in blueprint_fields: blueprint_fields['risks'].child.fields['value'].choices = to_choices_list(self.risk_choices) + def get_code(self, instance): + if callable(self.code): + return self.code(instance) + return self.code + def get_attribute(self, instance): """ Collect categories tree with connected blueprints and risks related to engagement. This allows us to avoid passing instance deeper for filtering risks. """ - categories = self.Meta.model.objects.filter(code=self.code).prefetch_related( + categories = self.Meta.model.objects.filter(code=self.get_code(instance)).prefetch_related( 'blueprints', models.Prefetch('blueprints__risks', Risk.objects.filter(engagement=instance)) ) diff --git a/src/etools/applications/audit/tests/test_transitions.py b/src/etools/applications/audit/tests/test_transitions.py index b6423292d..d11a6180b 100644 --- a/src/etools/applications/audit/tests/test_transitions.py +++ b/src/etools/applications/audit/tests/test_transitions.py @@ -53,6 +53,7 @@ class MATransitionsTestCaseMixin(EngagementTransitionsTestCaseMixin): def _init_filled_engagement(self): super()._init_filled_engagement() self._fill_category('ma_questionnaire') + self._fill_category('ma_questionnaire_v2') self._fill_category('ma_subject_areas', extra={"comments": "some info"}) self._fill_category('ma_global_assessment', extra={"comments": "some info"}) @@ -124,11 +125,12 @@ def test_submit_for_dummy_object(self): def test_filled_questionnaire(self): self._fill_date_fields() self._test_submit(self.auditor, status.HTTP_400_BAD_REQUEST, - errors=['questionnaire', 'test_subject_areas', 'overall_risk_assessment']) + errors=['test_subject_areas', 'overall_risk_assessment', 'questionnaire']) def test_missing_comments_subject_areas(self): self._fill_date_fields() self._fill_category('ma_questionnaire') + self._fill_category('ma_questionnaire_v2') self._fill_category('ma_subject_areas') self._fill_category('ma_global_assessment') self._test_submit(self.auditor, status.HTTP_400_BAD_REQUEST, @@ -137,6 +139,7 @@ def test_missing_comments_subject_areas(self): def test_attachments_required(self): self._fill_date_fields() self._fill_category('ma_questionnaire') + self._fill_category('ma_questionnaire_v2') self._fill_category('ma_subject_areas', extra={"comments": "some info"}) self._fill_category('ma_global_assessment', extra={"comments": "some info"}) self._test_submit(self.auditor, status.HTTP_400_BAD_REQUEST, errors=['report_attachments']) diff --git a/src/etools/applications/audit/tests/test_views.py b/src/etools/applications/audit/tests/test_views.py index a04429c1a..741c06697 100644 --- a/src/etools/applications/audit/tests/test_views.py +++ b/src/etools/applications/audit/tests/test_views.py @@ -8,6 +8,7 @@ from django.core.management import call_command from django.db import connection from django.urls import reverse +from django.utils import timezone from factory import fuzzy from rest_framework import status @@ -19,6 +20,7 @@ from etools.applications.audit.tests.base import AuditTestCaseMixin, EngagementTransitionsTestCaseMixin from etools.applications.audit.tests.factories import ( AuditFactory, + AuditFocalPointUserFactory, AuditorUserFactory, AuditPartnerFactory, EngagementFactory, @@ -37,7 +39,7 @@ from etools.applications.organizations.models import OrganizationType from etools.applications.organizations.tests.factories import OrganizationFactory from etools.applications.reports.tests.factories import SectionFactory -from etools.applications.users.tests.factories import CountryFactory, OfficeFactory, RealmFactory +from etools.applications.users.tests.factories import CountryFactory, GroupFactory, OfficeFactory, RealmFactory class BaseTestCategoryRisksViewSet(EngagementTransitionsTestCaseMixin): @@ -211,7 +213,9 @@ class TestMARisksViewSet(BaseTestCategoryRisksViewSet, BaseTenantTestCase): engagement_factory = MicroAssessmentFactory endpoint = 'micro-assessments' - def test_ma_risks(self): + def test_ma_risks_v1(self): + self.engagement.questionnaire_version = 1 + self.engagement.save() self._test_engagement_categories( category_code='ma_questionnaire', field_name='questionnaire', allowed_user=self.auditor @@ -221,7 +225,19 @@ def test_ma_risks(self): allowed_user=self.auditor ) - def test_update_unexisted_blueprint(self): + def test_ma_risks(self): + self._test_engagement_categories( + category_code='ma_questionnaire_v2', field_name='questionnaire', + allowed_user=self.auditor + ) + self._test_engagement_categories( + category_code='ma_subject_areas', field_name='test_subject_areas', + allowed_user=self.auditor + ) + + def test_update_unexisted_blueprint_v1(self): + self.engagement.questionnaire_version = 1 + self.engagement.save() self._update_unexisted_blueprint( field_name='questionnaire', category_code='ma_questionnaire', allowed_user=self.auditor @@ -231,7 +247,19 @@ def test_update_unexisted_blueprint(self): allowed_user=self.auditor ) - def test_ma_risks_update_without_perms(self): + def test_update_unexisted_blueprint(self): + self._update_unexisted_blueprint( + field_name='questionnaire', category_code='ma_questionnaire_v2', + allowed_user=self.auditor + ) + self._update_unexisted_blueprint( + field_name='test_subject_areas', category_code='ma_subject_areas', + allowed_user=self.auditor + ) + + def test_ma_risks_update_without_perms_v1(self): + self.engagement.questionnaire_version = 1 + self.engagement.save() self._test_category_update_by_user_without_permissions( category_code='ma_questionnaire', field_name='questionnaire', not_allowed=self.unicef_focal_point @@ -241,6 +269,16 @@ def test_ma_risks_update_without_perms(self): not_allowed=self.unicef_focal_point ) + def test_ma_risks_update_without_perms(self): + self._test_category_update_by_user_without_permissions( + category_code='ma_questionnaire_v2', field_name='questionnaire', + not_allowed=self.unicef_focal_point + ) + self._test_category_update_by_user_without_permissions( + category_code='test_subject_areas', field_name='ma_subject_areas', + not_allowed=self.unicef_focal_point + ) + class TestAuditRisksViewSet(BaseTestCategoryRisksViewSet, BaseTenantTestCase): engagement_factory = AuditFactory @@ -298,6 +336,19 @@ def test_focal_point_list(self): def test_engagement_staff_list(self): self._test_list(self.auditor, [self.engagement]) + def test_engagement_staff_list_multiple_auditor_realms(self): + auditor_firm_1 = AuditPartnerFactory() + engagement_1 = self.engagement_factory(agreement__auditor_firm=auditor_firm_1, staff_members=[self.auditor]) + RealmFactory( + user=self.auditor, country=CountryFactory(), + organization=auditor_firm_1.organization, group=GroupFactory(name="Auditor") + ) + self._test_list(self.auditor, [self.engagement]) + + self.auditor.profile.organization = auditor_firm_1.organization + self.auditor.profile.save(update_fields=['organization']) + self._test_list(self.auditor, [engagement_1]) + def test_non_engagement_staff_list(self): self._test_list(self.non_engagement_auditor, []) @@ -556,6 +607,10 @@ class TestMicroAssessmentCreateViewSet(TestEngagementCreateActivePDViewSet, Base class TestAuditCreateViewSet(TestEngagementCreateActivePDViewSet, BaseTestEngagementsCreateViewSet, BaseTenantTestCase): engagement_factory = AuditFactory + def setUp(self): + super().setUp() + self.create_data['year_of_audit'] = timezone.now().year + class TestSpotCheckCreateViewSet(TestEngagementCreateActivePDViewSet, BaseTestEngagementsCreateViewSet, BaseTenantTestCase): @@ -827,6 +882,32 @@ def test_create(self): self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertIsNotNone(response.data['agreement']) + def test_detail_staff_members(self): + active_staff_list = [] + for i in range(3): + active_staff_list.append(AuditFocalPointUserFactory()) + + inactive_unicef_focal_point = AuditFocalPointUserFactory() + inactive_unicef_focal_point.realms.update(is_active=False) + + spot_check = SpotCheckFactory(staff_members=active_staff_list + [inactive_unicef_focal_point]) + + response = self.forced_auth_req( + 'get', + reverse('audit:staff-spot-checks-detail', args=[spot_check.pk]), + user=self.unicef_focal_point, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data['staff_members']), spot_check.staff_members.count()) + self.assertEqual( + [inactive_unicef_focal_point.pk], + [staff['id'] for staff in response.data['staff_members'] if not staff['has_active_realm']] + ) + self.assertEqual( + sorted([staff.pk for staff in active_staff_list]), + sorted([staff['id'] for staff in response.data['staff_members'] if staff['has_active_realm']]) + ) + def test_list(self): SpotCheckFactory() staff_spot_check = StaffSpotCheckFactory() @@ -898,6 +979,19 @@ def _test_risk_choices(self, field, expected_choices): ) +class TestEngagementMetadataViewSet(AuditTestCaseMixin, BaseTenantTestCase): + endpoint = "engagements" + + def test_staff_members(self): + response = self.forced_auth_req( + 'options', + '/api/audit/{}/'.format(self.endpoint), + user=self.unicef_focal_point + ) + self.assertIn('POST', response.data['actions']) + self.assertIn('staff_members', response.data['actions']['POST']) + + class TestMicroAssessmentMetadataDetailViewSet(TestMetadataDetailViewSet, BaseTenantTestCase): engagement_factory = MicroAssessmentFactory endpoint = 'micro-assessments' @@ -916,11 +1010,19 @@ class TestAuditMetadataDetailViewSet(TestMetadataDetailViewSet, BaseTenantTestCa def test_weaknesses_choices(self): self._test_risk_choices('key_internal_weakness', Risk.AUDIT_VALUES) - def test_users_notified_auditor_not_staff(self): - self.assertFalse(self.auditor.is_staff) + +class TestSpotCheckMetadataDetailViewSet(TestMetadataDetailViewSet, BaseTenantTestCase): + engagement_factory = StaffSpotCheckFactory + endpoint = 'spot-checks' + + def test_users_notified_auditor_not_unicef(self): + self.assertFalse(self.auditor.is_unicef_user()) + spot_check = self.engagement_factory( + staff_members=[self.auditor], agreement__auditor_firm=self.auditor_firm + ) response = self.forced_auth_req( 'options', - '/api/audit/{}/{}/'.format(self.endpoint, self.engagement.id), + '/api/audit/{}/{}/'.format(self.endpoint, spot_check.id), user=self.auditor ) self.assertIn('GET', response.data['actions']) @@ -929,14 +1031,15 @@ def test_users_notified_auditor_not_staff(self): put = response.data['actions']['PUT'] self.assertNotIn('users_notified', put) - def test_users_notified_auditor_is_staff(self): - self.auditor.is_staff = True - self.auditor.save() - self.assertTrue(self.auditor.is_staff) + def test_users_notified_auditor_is_unicef(self): + spot_check = self.engagement_factory( + staff_members=[self.unicef_focal_point], agreement__auditor_firm=self.auditor_firm + ) + self.assertTrue(self.unicef_focal_point.is_unicef_user()) response = self.forced_auth_req( 'options', - '/api/audit/{}/{}/'.format(self.endpoint, self.engagement.id), - user=self.auditor + '/api/audit/{}/{}/'.format(self.endpoint, spot_check.id), + user=self.unicef_focal_point ) self.assertIn('GET', response.data['actions']) get = response.data['actions']['GET'] diff --git a/src/etools/applications/audit/transitions/conditions.py b/src/etools/applications/audit/transitions/conditions.py index 21341a4ea..0caf15fcb 100644 --- a/src/etools/applications/audit/transitions/conditions.py +++ b/src/etools/applications/audit/transitions/conditions.py @@ -31,11 +31,16 @@ def condition(*args, **kwargs): class ValidateRiskCategories(BaseTransitionCheck): VALIDATE_CATEGORIES_BEFORE_SUBMIT = {} + def get_categories_to_validate_before_submit(self, instance): + return self.VALIDATE_CATEGORIES_BEFORE_SUBMIT + def get_errors(self, instance, fields_to_check=None, *args, **kwargs): from etools.applications.audit.models import RiskBluePrint + categories_to_validate = self.get_categories_to_validate_before_submit(instance) + if not fields_to_check: - fields_to_check = self.VALIDATE_CATEGORIES_BEFORE_SUBMIT.keys() + fields_to_check = categories_to_validate.keys() errors = super().get_errors(*args, **kwargs) @@ -43,7 +48,7 @@ def get_errors(self, instance, fields_to_check=None, *args, **kwargs): questions_count = RiskBluePrint.objects.filter(category__code=code).count() answers_count = instance.risks.filter(blueprint__category__code=code).count() if questions_count != answers_count: - errors[self.VALIDATE_CATEGORIES_BEFORE_SUBMIT[code]] = _('Please answer all questions') + errors[categories_to_validate[code]] = _('Please answer all questions') return errors @@ -146,11 +151,17 @@ class AuditSubmitReportRequiredFieldsCheck(EngagementSubmitReportRequiredFieldsC class ValidateMARiskCategories(ValidateRiskCategories): VALIDATE_CATEGORIES_BEFORE_SUBMIT = { - 'ma_questionnaire': 'questionnaire', 'ma_subject_areas': 'test_subject_areas', 'ma_global_assessment': 'overall_risk_assessment', } + def get_categories_to_validate_before_submit(self, instance): + from etools.applications.audit.models import MicroAssessment + + categories = self.VALIDATE_CATEGORIES_BEFORE_SUBMIT + categories[MicroAssessment.get_questionnaire_code(instance.questionnaire_version)] = 'questionnaire' + return categories + class ValidateMARiskExtra(ValidateRiskExtra): VALIDATE_CATEGORIES = { diff --git a/src/etools/applications/audit/views.py b/src/etools/applications/audit/views.py index a59a2f3ea..2d320e6eb 100644 --- a/src/etools/applications/audit/views.py +++ b/src/etools/applications/audit/views.py @@ -1,7 +1,7 @@ from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.db import connection -from django.db.models import Prefetch +from django.db.models import Exists, OuterRef, Prefetch from django.http import Http404 from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -29,7 +29,7 @@ AuditModuleCondition, AuditStaffMemberCondition, EngagementStaffMemberCondition, - IsStaffMemberCondition, + IsUnicefUserCondition, ) from etools.applications.audit.exports import ( AuditDetailCSVRenderer, @@ -71,7 +71,7 @@ AuditorFirmExportSerializer, AuditorFirmLightSerializer, AuditorFirmSerializer, - AuditorStaffMemberSerializer, + AuditorStaffMemberRealmSerializer, AuditUserSerializer, PurchaseOrderSerializer, ) @@ -352,7 +352,9 @@ def get_queryset(self): # no need to filter queryset pass elif Auditor.as_group() in user_groups: - queryset = queryset.filter(staff_members=self.request.user) + queryset = queryset.filter( + agreement__auditor_firm__organization=self.request.user.profile.organization, + staff_members=self.request.user) else: queryset = queryset.none() @@ -382,7 +384,7 @@ def get_obj_permission_context(self, obj): ObjectStatusCondition(obj), AuditStaffMemberCondition(obj.agreement.auditor_firm.organization, self.request.user), EngagementStaffMemberCondition(obj, self.request.user), - IsStaffMemberCondition(self.request.user) + IsUnicefUserCondition(self.request.user) ]) return context @@ -518,8 +520,8 @@ class AuditorStaffMembersViewSet( viewsets.GenericViewSet ): metadata_class = PermissionBasedMetadata - queryset = get_user_model().objects.prefetch_related('realms').distinct() - serializer_class = AuditorStaffMemberSerializer + queryset = get_user_model().objects.all() + serializer_class = AuditorStaffMemberRealmSerializer permission_classes = BaseAuditViewSet.permission_classes + [ get_permission_for_targets('purchase_order.auditorfirm.staff_members') ] @@ -537,11 +539,19 @@ def get_parent_filter(self): def get_queryset(self): queryset = super().get_queryset() + + context_realms_qs = Realm.objects.filter( + organization=self.get_parent_object().organization, + country=connection.tenant, + group__name__in=AUDIT_ACTIVE_GROUPS + ) queryset = queryset.filter( - realms__country=connection.tenant, realms__organization=self.get_parent_object().organization, - realms__group__name__in=AUDIT_ACTIVE_GROUPS, - ) + realms__country=connection.tenant, + realms__group__name__in=AUDIT_ACTIVE_GROUPS) \ + .annotate(has_active_realm=Exists(context_realms_qs.filter(user=OuterRef('pk'), is_active=True)))\ + .distinct() + return queryset def get_serializer_context(self): diff --git a/src/etools/applications/core/auth.py b/src/etools/applications/core/auth.py index 9a9d32be7..e6211c524 100644 --- a/src/etools/applications/core/auth.py +++ b/src/etools/applications/core/auth.py @@ -71,7 +71,6 @@ def user_details(strategy, details, backend, user=None, *args, **kwargs): # This is where we update the user # see what the property to map by is here if user: - user_groups = [group.name for group in user.groups] business_area_code = details.get("business_area_code", 'defaultBA1235') try: @@ -79,17 +78,24 @@ def user_details(strategy, details, backend, user=None, *args, **kwargs): except Country.DoesNotExist: country = Country.objects.get(name='UAT') - if details.get("idp") == "UNICEF Azure AD" and "UNICEF User" not in user_groups: + if details.get("idp") == "UNICEF Azure AD": unicef_org = Organization.objects.get(name='UNICEF') - Realm.objects.create( - user=user, - country=country, - organization=unicef_org, - group=Group.objects.get(name='UNICEF User')) - user.is_staff = True - user.save(update_fields=['is_staff']) - user.profile.organization = unicef_org - user.profile.save(update_fields=['organization']) + user_has_unicef_group = user.realms.filter(country=user.profile.country or country, + organization=unicef_org, + is_active=True, + group=Group.objects.get(name='UNICEF User')).exists() + if not user_has_unicef_group: + Realm.objects.update_or_create( + user=user, + country=user.profile.country or country, + organization=unicef_org, + group=Group.objects.get(name='UNICEF User'), + is_active=False, defaults={"is_active": True} + ) + user.is_staff = True + user.save(update_fields=['is_staff']) + user.profile.organization = unicef_org + user.profile.save(update_fields=['organization']) if not user.profile.country: user.profile.country = country diff --git a/src/etools/applications/core/data/action_points_categories.json b/src/etools/applications/core/data/action_points_categories.json index 01dbdcfd8..b69ad99b6 100644 --- a/src/etools/applications/core/data/action_points_categories.json +++ b/src/etools/applications/core/data/action_points_categories.json @@ -142,5 +142,77 @@ "module": "fm", "description": "Other" } + }, + { + "model": "categories.category", + "pk": 17, + "fields": { + "order": 17, + "module": "fm", + "description": "Bottleneck Fix / Remedy" + } + }, + { + "model": "categories.category", + "pk": 18, + "fields": { + "order": 18, + "module": "fm", + "description": "Improving Performance Against Targets" + } + }, + { + "model": "categories.category", + "pk": 19, + "fields": { + "order": 19, + "module": "fm", + "description": "Supplies Issue" + } + }, + { + "model": "categories.category", + "pk": 20, + "fields": { + "order": 20, + "module": "fm", + "description": "Partner Performance Issue" + } + }, + { + "model": "categories.category", + "pk": 21, + "fields": { + "order": 21, + "module": "fm", + "description": "Coordination Issue" + } + }, + { + "model": "categories.category", + "pk": 22, + "fields": { + "order": 22, + "module": "fm", + "description": "Risk to Beneficiaries" + } + }, + { + "model": "categories.category", + "pk": 23, + "fields": { + "order": 23, + "module": "fm", + "description": "Preparedness" + } + }, + { + "model": "categories.category", + "pk": 24, + "fields": { + "order": 24, + "module": "fm", + "description": "Life Saving" + } } ] diff --git a/src/etools/applications/core/data/audit_risks_blueprints.json b/src/etools/applications/core/data/audit_risks_blueprints.json index 530c57c99..66eb2eaa4 100644 --- a/src/etools/applications/core/data/audit_risks_blueprints.json +++ b/src/etools/applications/core/data/audit_risks_blueprints.json @@ -241,6 +241,1595 @@ "code": "audit_key_weakness" } }, + + + + + { + "model": "audit.riskcategory", + "pk": 100, + "fields": { + "order": 0, + "header": "Micro Assesment Questionarie", + "parent": null, + "category_type": "primary", + "code": "ma_questionnaire_v2" + } + }, + + { + "model": "audit.riskcategory", + "pk": 101, + "fields": { + "order": 0, + "header": "A. Organisation", + "parent": 100, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + + { + "model": "audit.riskcategory", + "pk": 102, + "fields": { + "order": 1, + "header": "B. People and behaviours", + "parent": 100, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskcategory", + "pk": 103, + "fields": { + "order": 2, + "header": "C. Activities", + "parent": 100, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskcategory", + "pk": 104, + "fields": { + "order": 3, + "header": "D. Reporting and accountability", + "parent": 100, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskcategory", + "pk": 105, + "fields": { + "order": 4, + "header": "E. Assets and inventory", + "parent": 100, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskcategory", + "pk": 106, + "fields": { + "order": 5, + "header": "F. Procurement", + "parent": 100, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskcategory", + "pk": 107, + "fields": { + "order": 6, + "header": "G. Sub-partners", + "parent": 100, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskcategory", + "pk": 108, + "fields": { + "order": 7, + "header": "H. Systems", + "parent": 100, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskcategory", + "pk": 109, + "fields": { + "order": 0, + "header": "General", + "parent": 101, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskblueprint", + "pk": 200, + "fields": { + "order": 0, + "weight": 2, + "is_key": true, + "header": "1. Is the entity in compliance with national registration requirements?", + "description": "If the organisation is a government entity, answer \"N/A\".\nFor NGO / INGO / Other entity types, please record the legal status and date of registration in country.", + "category": 109 + } + }, + { + "model": "audit.riskblueprint", + "pk": 201, + "fields": { + "order": 1, + "weight": 2, + "is_key": true, + "header": "2. Does an internet search indicate there have been no known cases of fraud, or other allegations of malpractice, concerning the entity or its staff in the last five years?", + "description": "The search should be performed using terms such as \"fraud\", \"allegations\", \"abuse\", and \"criminal\".", + "category": 109 + } + }, + { + "model": "audit.riskblueprint", + "pk": 202, + "fields": { + "order": 2, + "weight": 1, + "is_key": false, + "header": "3. Does management confirm there are no ongoing legal proceedings that are likely materially to impact the organisation or its activities?", + "description": "Obtain and file the statement in writing, indicating the name and position of the senior official making it, and the date it was made.", + "category": 109 + } + }, + { + "model": "audit.riskcategory", + "pk": 110, + "fields": { + "order": 1, + "header": "Organisational structure and governance", + "parent": 101, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + + { + "model": "audit.riskblueprint", + "pk": 203, + "fields": { + "order": 0, + "weight": 1, + "is_key": false, + "header": "4. Does the governing body meet on a regular basis and perform sufficient oversight functions?", + "description": "The \"governing body\" may be a management board, committee or similar, and has responsibility for ensuring that the actions of the organisation and its staff meet the stated objectives. Evidence of their involvement should be obtained.", + "category": 110 + } + }, + { + "model": "audit.riskblueprint", + "pk": 204, + "fields": { + "order": 1, + "weight": 1, + "is_key": false, + "header": "5. Are minutes of oversight meetings maintained, with evidence of action plans and appropriate follow?", + "description": "", + "category": 110 + } + }, + { + "model": "audit.riskblueprint", + "pk": 205, + "fields": { + "order": 2, + "weight": 1, + "is_key": false, + "header": "6. Is the organisation structured in such a way that enables clear reporting lines and designates particular areas of responsibility?", + "description": "Attach the organisation's organogram under Annex III.", + "category": 110 + } + }, + + { + "model": "audit.riskblueprint", + "pk": 206, + "fields": { + "order": 3, + "weight": 1, + "is_key": false, + "header": "7. Are there sufficient procedures in place to ensure that activities performed by sub-offices are carried out in accordance with the overall policies of the organisation?", + "description": "\"Sub-offices refer to other physical offices in the same country that form part of the legal entity under review.\nIf there are no sub-offices, answer \"N/A\".", + "category": 110 + } + }, + { + "model": "audit.riskblueprint", + "pk": 207, + "fields": { + "order": 4, + "weight": 1, + "is_key": false, + "header": "8. Are there sufficient procedures in place to ensure that financial transactions initiated by sub-offices are executed and recorded in accordance with the overall policies of the organisation?", + "description": "If there are no sub-offices, answer \"N/A\".", + "category": 110 + } + }, + { + "model": "audit.riskblueprint", + "pk": 208, + "fields": { + "order": 5, + "weight": 1, + "is_key": false, + "header": "9. Does the organisation review the accuracy and completeness of the supporting documentation for transactions incurred by its sub-offices prior to the amounts being consolidated into the central records?", + "description": "\"If there are no sub-offices, answer \"N/A\".", + "category": 110 + } + }, + + { + "model": "audit.riskcategory", + "pk": 111, + "fields": { + "order": 0, + "header": "General", + "parent": 102, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskblueprint", + "pk": 209, + "fields": { + "order": 0, + "weight": 2, + "is_key": true, + "header": "1. Is there an HR manual that covers key areas such as recruitment, employment and personnel practices, and which is provided to all staff?", + "description": "", + "category": 111 + } + }, + { + "model": "audit.riskcategory", + "pk": 112, + "fields": { + "order": 1, + "header": "Recruitment and retention", + "parent": 102, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskblueprint", + "pk": 210, + "fields": { + "order": 0, + "weight": 1, + "is_key": false, + "header": "2. Are vacant positions widely advertised?", + "description": "", + "category": 112 + } + }, + { + "model": "audit.riskblueprint", + "pk": 211, + "fields": { + "order": 1, + "weight": 1, + "is_key": false, + "header": "3. Are the selected candidates appointed to roles in a competitive and transparent way, that is documented and filed?", + "description": "", + "category": 112 + } + }, + { + "model": "audit.riskblueprint", + "pk": 212, + "fields": { + "order": 2, + "weight": 1, + "is_key": false, + "header": "4. Are background checks performed on potential new recruits, and the results documented and filed?", + "description": "", + "category": 112 + } + }, + { + "model": "audit.riskblueprint", + "pk": 213, + "fields": { + "order": 3, + "weight": 1, + "is_key": false, + "header": "5. Have key positions been filled throughout the last three years, (or with only limited gaps between new appointments), without evidence of regular turnover of the same positions?", + "description": "Key positions include those with management responsibilities, or for important process tasks that cannot be performed by others.", + "category": 112 + } + }, + { + "model": "audit.riskblueprint", + "pk": 214, + "fields": { + "order": 4, + "weight": 1, + "is_key": false, + "header": "6. Are procedures in place to ensure that, when staff leave employment with the organisation, they are removed from the payroll after receipt of the final salary due, are required to return any equipment belonging to the organisation, and have any access rights to in-house systems terminated?", + "description": "", + "category": 112 + } + }, + { + "model": "audit.riskcategory", + "pk": 113, + "fields": { + "order": 2, + "header": "Qualifications and training", + "parent": 102, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskblueprint", + "pk": 215, + "fields": { + "order": 0, + "weight": 2, + "is_key": true, + "header": "7. Does the finance team contain a sufficient number of suitably experienced staff, so that team members are competent to perform the tasks assigned to them, and with sufficient segregation of duties?", + "description": "", + "category": 113 + } + }, + { + "model": "audit.riskblueprint", + "pk": 216, + "fields": { + "order": 1, + "weight": 1, + "is_key": false, + "header": "8. Are there sufficient job descriptions for the various roles within the organisation, and the minimum expected qualifications required for each?", + "description": "", + "category": 113 + } + }, + { + "model": "audit.riskblueprint", + "pk": 217, + "fields": { + "order": 2, + "weight": 1, + "is_key": false, + "header": "9. Does the organisation provide sufficient training opportunities to its staff?", + "description": "", + "category": 113 + } + }, + { + "model": "audit.riskcategory", + "pk": 114, + "fields": { + "order": 3, + "header": "Practices", + "parent": 102, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskblueprint", + "pk": 218, + "fields": { + "order": 0, + "weight": 2, + "is_key": true, + "header": "10. Does the organisation have a clear set of policies concerning the expected conduct of its staff, and procedures to follow up on allegations of misconduct?", + "description": "Such policies should cover areas such as the prevention of violence, intimidation and sexual harassment, and require people to act with honesty, integrity and diligence.", + "category": 114 + } + }, + { + "model": "audit.riskblueprint", + "pk": 219, + "fields": { + "order": 1, + "weight": 2, + "is_key": true, + "header": "11. Does the organisation have an anti-fraud and anti-corruption policy that is readily accessible to all staff?", + "description": "", + "category": 114 + } + }, + { + "model": "audit.riskblueprint", + "pk": 220, + "fields": { + "order": 2, + "weight": 1, + "is_key": false, + "header": "12. Is it clear to whom staff should report concerns about suspected fraud, corruption or other malpractice, and are procedures in place to protect staff from potential retaliation as a result?", + "description": "", + "category": 114 + } + }, + { + "model": "audit.riskblueprint", + "pk": 221, + "fields": { + "order": 3, + "weight": 1, + "is_key": false, + "header": "13. Does the organisation have policies and procedures to help prevent discrimination on the basis of gender?", + "description": "This should cover areas such as recruitment, pay and promotion opportunities.", + "category": 114 + } + }, + { + "model": "audit.riskblueprint", + "pk": 222, + "fields": { + "order": 4, + "weight": 1, + "is_key": false, + "header": "14. Does the organisation integrate social and environmental standards in their activities?", + "description": "Are there practices, guidelines, tools, or a policy that integrate social and environmental standards in the organisation's activities?", + "category": 114 + } + }, + + { + "model": "audit.riskcategory", + "pk": 115, + "fields": { + "order": 0, + "header": "Workplans", + "parent": 103, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskblueprint", + "pk": 223, + "fields": { + "order": 0, + "weight": 2, + "is_key": true, + "header": "1. Does the organisation have and use sufficiently detailed written policies, procedures and other tools to develop and manage programmes and plans?", + "description": "", + "category": 115 + } + }, + { + "model": "audit.riskblueprint", + "pk": 224, + "fields": { + "order": 1, + "weight": 1, + "is_key": false, + "header": "2. Are the workplans sufficiently detailed to allow a clear understanding of the objectives, expected activities, budget, and timeframe?", + "description": "", + "category": 115 + } + }, + { + "model": "audit.riskblueprint", + "pk": 225, + "fields": { + "order": 2, + "weight": 1, + "is_key": false, + "header": "3. Are revisions and amendments to workplans appropriately reviewed, documented and communicated?", + "description": "", + "category": 115 + } + }, + { + "model": "audit.riskcategory", + "pk": 116, + "fields": { + "order": 1, + "header": "Risk management", + "parent": 103, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskblueprint", + "pk": 226, + "fields": { + "order": 0, + "weight": 2, + "is_key": true, + "header": "4. Does the organisation identify the potential risks for achieving its objectives and programme delivery and mechanisms to mitigate them?", + "description": "This could include areas such as a lack of skills and resources, political instability, or environmental factors.", + "category": 116 + } + }, + { + "model": "audit.riskblueprint", + "pk": 227, + "fields": { + "order": 1, + "weight": 1, + "is_key": false, + "header": "5. Is a sufficiently detailed risk register maintained for the organisation?", + "description": "Risks should be identified and documented in a formal risk register, and assessed for their likelihood and impact.", + "category": 116 + } + }, + { + "model": "audit.riskblueprint", + "pk": 228, + "fields": { + "order": 2, + "weight": 1, + "is_key": false, + "header": "6. Are risk management procedures undertaken and / or approved by sufficiently senior members of staff?", + "description": "", + "category": 116 + } + }, + { + "model": "audit.riskcategory", + "pk": 117, + "fields": { + "order": 2, + "header": "Monitoring and evaluation", + "parent": 103, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskblueprint", + "pk": 229, + "fields": { + "order": 0, + "weight": 2, + "is_key": true, + "header": "7. Does the organisation have and use sufficiently detailed policies, procedures, guidelines and other tools for monitoring and evaluation?", + "description": "", + "category": 117 + } + }, + { + "model": "audit.riskblueprint", + "pk": 230, + "fields": { + "order": 1, + "weight": 1, + "is_key": false, + "header": "8. Does the organisation carry out and document regular monitoring activities such as review meetings and on-site project visits, to assess implementation against the stated objectives of the work plan?", + "description": "", + "category": 117 + } + }, + { + "model": "audit.riskblueprint", + "pk": 231, + "fields": { + "order": 2, + "weight": 1, + "is_key": false, + "header": "9. Does the organisation prepare sufficiently detailed narrative reports, based on the evidence it has obtained, that provide donors and other stakeholders with a clear understanding of current progress against the objectives?", + "description": "", + "category": 117 + } + }, + { + "model": "audit.riskblueprint", + "pk": 232, + "fields": { + "order": 3, + "weight": 1, + "is_key": false, + "header": "10. Is data collected during monitoring and evaluation procedures documented and filed in accordance with written policies?", + "description": "", + "category": 117 + } + }, + { + "model": "audit.riskcategory", + "pk": 118, + "fields": { + "order": 0, + "header": "General", + "parent": 104, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskblueprint", + "pk": 233, + "fields": { + "order": 0, + "weight": 2, + "is_key": true, + "header": "1. Is there a finance manual, or similar, that clearly sets out the main policies and procedures to be followed?", + "description": "", + "category": 118 + } + }, + { + "model": "audit.riskcategory", + "pk": 119, + "fields": { + "order": 1, + "header": "Audit environment", + "parent": 104, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskblueprint", + "pk": 234, + "fields": { + "order": 0, + "weight": 2, + "is_key": true, + "header": "2. Has the organisation complied with its statutory reporting requirements for the last three years?", + "description": "If no such reporting requirements, state \"N/A\".", + "category": 119 + } + }, + { + "model": "audit.riskblueprint", + "pk": 235, + "fields": { + "order": 1, + "weight": 1, + "is_key": false, + "header": "3. Did the statutory audits from the last three years result in unmodified (clean) audit opinions and without other significant issues being raised?", + "description": "If no such reports issued, state \"N/A\".", + "category": 119 + } + }, + { + "model": "audit.riskblueprint", + "pk": 236, + "fields": { + "order": 2, + "weight": 2, + "is_key": true, + "header": "4. Has the organisation received UN audit reports, or other assurance activities commissioned by UN organisations, which report a good control environment, and without significant amounts of unsupported expenditure being identified?", + "description": "If no such activities have taken place, answer \"no\" and record \"significant risk\". If such activities have reported significant issues, answer \"no\" and record \"high risk\".", + "category": 119 + } + }, + { + "model": "audit.riskblueprint", + "pk": 237, + "fields": { + "order": 3, + "weight": 1, + "is_key": false, + "header": "5. Does the organisation have an internal audit function?", + "description": "This may be either an internal or outsourced internal audit function.", + "category": 119 + } + }, + { + "model": "audit.riskblueprint", + "pk": 238, + "fields": { + "order": 4, + "weight": 1, + "is_key": false, + "header": "6. Is the internal audit department sufficiently independent so that it is able to make recommendations?", + "description": "", + "category": 119 + } + }, + { + "model": "audit.riskblueprint", + "pk": 239, + "fields": { + "order": 5, + "weight": 1, + "is_key": false, + "header": "7. Does the internal audit function include donor-funded activities within its remit?", + "description": "", + "category": 119 + } + }, + { + "model": "audit.riskblueprint", + "pk": 240, + "fields": { + "order": 6, + "weight": 1, + "is_key": false, + "header": "8. Are recommendations made by internal and external reviewers logged centrally, indicating the follow-up status, who is responsible for implementation, and the timeframe?", + "description": "", + "category": 119 + } + }, + + { + "model": "audit.riskcategory", + "pk": 120, + "fields": { + "order": 2, + "header": "Financial reporting tools", + "parent": 104, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskblueprint", + "pk": 241, + "fields": { + "order": 0, + "weight": 1, + "is_key": false, + "header": "9. Can the figures for donor financial reporting, by total and by budget and / or activity line, be generated automatically from the accounting system?", + "description": "", + "category": 120 + } + }, + { + "model": "audit.riskblueprint", + "pk": 242, + "fields": { + "order": 1, + "weight": 1, + "is_key": false, + "header": "10. Are donor reports reviewed and approved by a suitable member of staff other than the preparer?", + "description": "", + "category": 120 + } + }, + { + "model": "audit.riskcategory", + "pk": 121, + "fields": { + "order": 3, + "header": "Budget preparation and monitoring", + "parent": 104, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskblueprint", + "pk": 243, + "fields": { + "order": 0, + "weight": 2, + "is_key": true, + "header": "11. Are budgets prepared in sufficient detail so that they can be used as a meaningful monitoring and control tool?", + "description": "", + "category": 121 + } + }, + { + "model": "audit.riskblueprint", + "pk": 244, + "fields": { + "order": 1, + "weight": 1, + "is_key": false, + "header": "12. Are budgets authorised by a suitably senior member of staff?", + "description": "", + "category": 121 + } + }, + { + "model": "audit.riskblueprint", + "pk": 245, + "fields": { + "order": 2, + "weight": 1, + "is_key": false, + "header": "13. Are budgets compared against actual expenditure on a sufficiently regular basis, with unexpected variances investigated?", + "description": "", + "category": 121 + } + }, + { + "model": "audit.riskblueprint", + "pk": 246, + "fields": { + "order": 3, + "weight": 1, + "is_key": false, + "header": "14. Is there a policy stating how budget amendments are to be considered, and who is responsible for authorising these?", + "description": "", + "category": 121 + } + }, + { + "model": "audit.riskcategory", + "pk": 122, + "fields": { + "order": 0, + "header": "Fixed asset register", + "parent": 105, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskblueprint", + "pk": 247, + "fields": { + "order": 0, + "weight": 2, + "is_key": true, + "header": "1. Does the organisation maintain a comprehensive and up to date fixed asset register, that records all relevant details (such as purchase date, cost, condition, location, tag number, serial number, and owner) for each asset held?", + "description": "", + "category": 122 + } + }, + { + "model": "audit.riskblueprint", + "pk": 248, + "fields": { + "order": 1, + "weight": 1, + "is_key": false, + "header": "2. Are there sufficient measures and procedures in place to protect assets from theft, damage or misuse?", + "description": "", + "category": 122 + } + }, + + { + "model": "audit.riskcategory", + "pk": 123, + "fields": { + "order": 1, + "header": "Insurance", + "parent": 105, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskblueprint", + "pk": 249, + "fields": { + "order": 0, + "weight": 1, + "is_key": false, + "header": "3. Are significant assets either insured, or can otherwise be readily replaced, in the event of theft or damage?", + "description": "", + "category": 123 + } + }, + { + "model": "audit.riskcategory", + "pk": 124, + "fields": { + "order": 2, + "header": "Verifications", + "parent": 105, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskblueprint", + "pk": 250, + "fields": { + "order": 0, + "weight": 1, + "is_key": false, + "header": "4. Are assets subject to at least annual physical verifications?", + "description": "", + "category": 124 + } + }, + { + "model": "audit.riskblueprint", + "pk": 251, + "fields": { + "order": 1, + "weight": 1, + "is_key": false, + "header": "5. Are the physical verifications performed by more than one person, and are the results, and any necessary adjustments, appropriately documented and approved?", + "description": "", + "category": 124 + } + }, + { + "model": "audit.riskcategory", + "pk": 125, + "fields": { + "order": 3, + "header": "Inventory", + "parent": 105, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskblueprint", + "pk": 252, + "fields": { + "order": 0, + "weight": 1, + "is_key": false, + "header": "6. Is inventory managed through a computerised system that provides an up to date picture of items held?", + "description": "If a computerised system is not used, provide details of the procedures in place and assess risk accordingly, considering the levels of inventory held.", + "category": 125 + } + }, + { + "model": "audit.riskblueprint", + "pk": 253, + "fields": { + "order": 0, + "weight": 1, + "is_key": false, + "header": "7. Are physical verifications of inventory items reconciled to the records held on a sufficiently frequent basis, and the results, and any necessary adjustments, documented and approved?", + "description": "", + "category": 125 + } + }, + { + "model": "audit.riskblueprint", + "pk": 254, + "fields": { + "order": 0, + "weight": 1, + "is_key": false, + "header": "8. Are items with a limited shelf-life sufficiently monitored to ensure they do not expire prior to issue?", + "description": "", + "category": 125 + } + }, + { + "model": "audit.riskcategory", + "pk": 126, + "fields": { + "order": 4, + "header": "Warehouse
It may not be possible to inspect warehouses as part of the assessment, and it is not a requirement to do so. The response should state which warehouses, if any, have been physically verified. In cases where they have not been physically reviewed, the scores should be at least moderate risk.", + "parent": 105, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskblueprint", + "pk": 255, + "fields": { + "order": 0, + "weight": 1, + "is_key": false, + "header": "9. Are the warehouse facilities sufficiently secure, providing suitable conditions for the items held, with adequate protection against environmental factors?", + "description": "Provide details of the evidence available to support the assessment (e.g. physical inspection, information provided by the partner, internal reviews, or external assessments).", + "category": 126 + } + }, + { + "model": "audit.riskblueprint", + "pk": 256, + "fields": { + "order": 1, + "weight": 1, + "is_key": false, + "header": "10. Are warehouse items maintained in a way that allows authorised persons safe and ready access to them?", + "description": "Provide details of the evidence available to support the assessment (e.g. physical inspection, information provided by the partner, internal reviews, or external assessments).", + "category": 126 + } + }, + { + "model": "audit.riskcategory", + "pk": 127, + "fields": { + "order": 5, + "header": "Cash", + "parent": 105, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskblueprint", + "pk": 257, + "fields": { + "order": 0, + "weight": 2, + "is_key": true, + "header": "11. Is cash held in a secure place that can be accessed only by certain designated individuals?", + "description": "If no cash is held, state \"N/A\".", + "category": 127 + } + }, + { + "model": "audit.riskblueprint", + "pk": 258, + "fields": { + "order": 1, + "weight": 2, + "is_key": true, + "header": "12. Are cash reconciliations performed on a frequent basis, by more than one individual at the same time, and the results documented and approved?", + "description": "", + "category": 127 + } + }, + { + "model": "audit.riskblueprint", + "pk": 259, + "fields": { + "order": 0, + "weight": 2, + "is_key": true, + "header": "1. Does the organisation have written procurement policies and procedures, which facilitate competition, transparency and obtaining value for money?", + "description": "", + "category": 106 + } + }, + { + "model": "audit.riskblueprint", + "pk": 260, + "fields": { + "order": 1, + "weight": 1, + "is_key": false, + "header": "2. Do the procurement policies specify appropriate thresholds at which points different procurement procedures apply?", + "description": "", + "category": 106 + } + }, + { + "model": "audit.riskblueprint", + "pk": 261, + "fields": { + "order": 2, + "weight": 1, + "is_key": false, + "header": "3. Are all procurements authorised through documented approval from an appropriate member of staff?", + "description": "", + "category": 106 + } + }, + { + "model": "audit.riskblueprint", + "pk": 262, + "fields": { + "order": 3, + "weight": 2, + "is_key": true, + "header": "4. Is there adequate segregation of duties in the procurement process?", + "description": "", + "category": 106 + } + }, + { + "model": "audit.riskblueprint", + "pk": 263, + "fields": { + "order": 4, + "weight": 1, + "is_key": false, + "header": "5. Is there a stated basis for the assessment of bids, and is this followed in practice and documented?", + "description": "", + "category": 106 + } + }, + { + "model": "audit.riskblueprint", + "pk": 264, + "fields": { + "order": 5, + "weight": 1, + "is_key": false, + "header": "6. Does the organisation have a policy that sets out how any exceptions to the stated procurement procedures are to be implemented and managed, along with appropriate approval requirements?", + "description": "Such exceptions may, for example, be where the usual requirement for three quotations has been overridden due to the specific circumstances of that purchase.", + "category": 106 + } + }, + { + "model": "audit.riskblueprint", + "pk": 265, + "fields": { + "order": 6, + "weight": 1, + "is_key": false, + "header": "7. Does the organisation have adequate policies to ensure staff consider and document whether they have any conflicts of interest with potential suppliers?", + "description": "", + "category": 106 + } + }, + { + "model": "audit.riskblueprint", + "pk": 266, + "fields": { + "order": 7, + "weight": 1, + "is_key": false, + "header": "8. If a conflict is identified, is it evident that the staff member concerned is required to recuse themselves from any procurement process in which that entity is involved?", + "description": "", + "category": 106 + } + }, + { + "model": "audit.riskblueprint", + "pk": 267, + "fields": { + "order": 8, + "weight": 1, + "is_key": false, + "header": "9. Are background checks performed on suppliers to ensure there are no publicly known cases of fraud or other malpractice?", + "description": "", + "category": 106 + } + }, + { + "model": "audit.riskblueprint", + "pk": 268, + "fields": { + "order": 9, + "weight": 1, + "is_key": false, + "header": "10. Does the organisation have policies in relation to contract management?", + "description": "\"This will cover areas such as monitoring contract expiration, performance securities, and contract risk management procedures.\nIf no contracts are managed, state \"N/A\".", + "category": 106 + } + }, + { + "model": "audit.riskblueprint", + "pk": 269, + "fields": { + "order": 10, + "weight": 1, + "is_key": false, + "header": "11. Does the organisation require its suppliers to uphold high ethical standards at all times?", + "description": "For commercial suppliers / businesses this could include principles and exclusionary factors in line with the Ten Principles | UN Global Compact.", + "category": 106 + } + }, + { + "model": "audit.riskcategory", + "pk": 140, + "fields": { + "order": 0, + "header": "Sub-partners are external parties (separate legal entities) with whom the organisation engages to perform project activities. The sub-partners are required to account for the funds disbursed to them, and to show that the amounts have been incurred in line with the project's objectives and agreed activities, and in accordance with the budgets they have been issued.", + "parent": 107, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskblueprint", + "pk": 270, + "fields": { + "order": 0, + "weight": 1, + "is_key": false, + "header": "1. Are sub-partners selected on the basis of standard procedures, such as pre-award assessments, to ensure they are appropriately registered, suitably qualified to perform the role to be assigned, have adequate internal control systems, and that there are not significant ethical or reputational concerns?", + "description": "If sub-partners are not used, state \"N/A\" to each of these questions.", + "category": 140 + } + }, + { + "model": "audit.riskblueprint", + "pk": 271, + "fields": { + "order": 1, + "weight": 1, + "is_key": false, + "header": "2. If sub-partners are engaged, are formal agreements signed between the parties, clearly stating the work to be performed, the reporting and documentation obligations, and any other conditions of funding, in line with the agreement between the UN agency (or other donor) and the organisation, prior to activities being undertaken or funds advanced?", + "description": "", + "category": 140 + } + }, + { + "model": "audit.riskblueprint", + "pk": 272, + "fields": { + "order": 2, + "weight": 1, + "is_key": false, + "header": "3. Does the organisation implement procedures to verify the financial reports and corresponding documentation submitted by the sub-partner prior to onward reporting to the donor?", + "description": "", + "category": 140 + } + }, + { + "model": "audit.riskblueprint", + "pk": 273, + "fields": { + "order": 3, + "weight": 1, + "is_key": false, + "header": "4. Does the organisation implement procedures to monitor the implementation of project activities by the sub-partners?", + "description": "", + "category": 140 + } + }, + { + "model": "audit.riskblueprint", + "pk": 274, + "fields": { + "order": 4, + "weight": 1, + "is_key": false, + "header": "5. Does the organisation have suitable procedures for dealing with instances of poor performance, mismanagement and misconduct by sub-partners, or non-compliance with the terms of engagement?", + "description": "", + "category": 140 + } + }, + { + "model": "audit.riskblueprint", + "pk": 275, + "fields": { + "order": 5, + "weight": 1, + "is_key": false, + "header": "6. Does the organisation require its sub-partners to uphold high ethical standards, such as evidenced by a code of conduct?", + "description": "", + "category": 140 + } + }, + { + "model": "audit.riskcategory", + "pk": 128, + "fields": { + "order": 0, + "header": "Accounting system", + "parent": 108, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskblueprint", + "pk": 276, + "fields": { + "order": 0, + "weight": 1, + "is_key": false, + "header": "1. Does the organisation have and make use of a computerised accounting system that records sufficient details of each transaction to allow it to be linked to the corresponding documentation and allocated to the relevant funding source?", + "description": "", + "category": 128 + } + }, + { + "model": "audit.riskblueprint", + "pk": 277, + "fields": { + "order": 1, + "weight": 2, + "is_key": true, + "header": "2. Is access to the accounting system protected through the use of usernames and passwords?", + "description": "", + "category": 128 + } + }, + { + "model": "audit.riskblueprint", + "pk": 278, + "fields": { + "order": 2, + "weight": 1, + "is_key": false, + "header": "3. Do different users have different access rights so that they are only able to review or make changes to information that is relevant to their function?", + "description": "", + "category": 128 + } + }, + { + "model": "audit.riskblueprint", + "pk": 279, + "fields": { + "order": 3, + "weight": 1, + "is_key": false, + "header": "4. Is the accounting system backed up to a secure offsite location on a sufficiently regular basis?", + "description": "", + "category": 128 + } + }, + { + "model": "audit.riskblueprint", + "pk": 280, + "fields": { + "order": 4, + "weight": 1, + "is_key": false, + "header": "5. Can the system be accessed from premises other than the organisation's offices (for example if staff are working remotely) so that recording or reviewing financial transactions is not adversely impacted in the event that staff are not physically present?", + "description": "", + "category": 128 + } + }, + { + "model": "audit.riskblueprint", + "pk": 281, + "fields": { + "order": 5, + "weight": 2, + "is_key": true, + "header": "6. Do appropriate procedures and controls exist to ensure that the same or similar level of oversight is maintained even if staff are not physically present in the office?", + "description": "", + "category": 128 + } + }, + { + "model": "audit.riskblueprint", + "pk": 282, + "fields": { + "order": 6, + "weight": 1, + "is_key": false, + "header": "7. Are all staff issued with computers that are maintained by and accessible to the organisation's IT department, and that have adequate anti-malware installed?", + "description": "", + "category": 128 + } + }, + { + "model": "audit.riskcategory", + "pk": 129, + "fields": { + "order": 1, + "header": "Banking", + "parent": 108, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskblueprint", + "pk": 283, + "fields": { + "order": 0, + "weight": 2, + "is_key": true, + "header": "8. Does the organisation perform bank reconciliations on at least a monthly basis?", + "description": "If the organisation's bank account is pooled with other accounts, and therefore cannot perform a reconciliation, state \"N/A\" and provide comments explaining the cirucmstances.", + "category": 129 + } + }, + { + "model": "audit.riskblueprint", + "pk": 284, + "fields": { + "order": 1, + "weight": 1, + "is_key": false, + "header": "9. Are bank reconciliations performed by someone other than those responsible for making or approving payments?", + "description": "", + "category": 129 + } + }, + { + "model": "audit.riskblueprint", + "pk": 285, + "fields": { + "order": 2, + "weight": 1, + "is_key": false, + "header": "10. Are reconciling items identified and investigated in a timely manner?", + "description": "", + "category": 129 + } + }, + { + "model": "audit.riskcategory", + "pk": 130, + "fields": { + "order": 2, + "header": "Payments", + "parent": 108, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskblueprint", + "pk": 286, + "fields": { + "order": 0, + "weight": 2, + "is_key": true, + "header": "11. Are payments subject to a clear approval process with adequate segregation of duties?", + "description": "", + "category": 130 + } + }, + { + "model": "audit.riskblueprint", + "pk": 287, + "fields": { + "order": 1, + "weight": 1, + "is_key": false, + "header": "12. Are appropriate authorisation thresholds in place for approval of payments?", + "description": "", + "category": 130 + } + }, + { + "model": "audit.riskblueprint", + "pk": 288, + "fields": { + "order": 2, + "weight": 1, + "is_key": false, + "header": "13. Are all, or substantially all, payments made in a traceable form, such as bank transfer, cheques made out to the specific payee, or mobile money transfer?", + "description": "", + "category": 130 + } + }, + { + "model": "audit.riskblueprint", + "pk": 289, + "fields": { + "order": 3, + "weight": 1, + "is_key": false, + "header": "14. If online payments are used, do these require dual signatories?", + "description": "", + "category": 130 + } + }, + { + "model": "audit.riskblueprint", + "pk": 290, + "fields": { + "order": 4, + "weight": 2, + "is_key": true, + "header": "15. Is there a stated and reasonable limit for the amount that can be paid in cash?", + "description": "", + "category": 130 + } + }, + { + "model": "audit.riskblueprint", + "pk": 291, + "fields": { + "order": 5, + "weight": 1, + "is_key": false, + "header": "16. If staff have to transport significant amounts of cash (for example when withdrawn from the bank, or carried to project sites), are sufficient security arrangements in place?", + "description": "Although definitions of \"significant\" may vary, a starting point can be considered the equivalent of approximately US$ 1,000.", + "category": 130 + } + }, + { + "model": "audit.riskblueprint", + "pk": 292, + "fields": { + "order": 6, + "weight": 1, + "is_key": false, + "header": "17. Does the organisation perform a three-way match between the invoice received from the supplier, the purchase order raised, and the goods received, with evidence of these checks maintained and signed?", + "description": "", + "category": 130 + } + }, + { + "model": "audit.riskblueprint", + "pk": 293, + "fields": { + "order": 7, + "weight": 1, + "is_key": false, + "header": "18. For payments that are not made on the basis of an invoice, such as for daily allowances, are appropriate procedures in place to ensure the amounts are in line with a stated policy, there is adequate review and approval, and that relevant supporting documents are maintained?", + "description": "", + "category": 130 + } + }, + { + "model": "audit.riskblueprint", + "pk": 294, + "fields": { + "order": 8, + "weight": 2, + "is_key": true, + "header": "19. Are supporting documents stamped as \"Paid\" and marked with the donor or project name after payment has been made, or does the accounting system otherwise have inbuilt controls to ensure payments cannot be made more than once or claimed against more than one funding source?", + "description": "", + "category": 130 + } + }, + { + "model": "audit.riskcategory", + "pk": 131, + "fields": { + "order": 3, + "header": "Cost allocations", + "parent": 108, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskblueprint", + "pk": 295, + "fields": { + "order": 0, + "weight": 1, + "is_key": false, + "header": "20. Does the organisation have a clear policy for allocating shared costs across different projects or donors?", + "description": "", + "category": 131 + } + }, + { + "model": "audit.riskblueprint", + "pk": 296, + "fields": { + "order": 1, + "weight": 1, + "is_key": false, + "header": "21. Are salary costs charged to the project on the basis of an identifiable proportion of the actual costs incurred?", + "description": "", + "category": 131 + } + }, + { + "model": "audit.riskcategory", + "pk": 132, + "fields": { + "order": 4, + "header": "Document management / record keeping", + "parent": 108, + "category_type": "default", + "code": "ma_questionnaire_v2" + } + }, + { + "model": "audit.riskblueprint", + "pk": 297, + "fields": { + "order": 0, + "weight": 2, + "is_key": true, + "header": "22. Does the organisation maintain all its records in an orderly and consistent way, that enables the ready identification of relevant documentation?", + "description": "", + "category": 132 + } + }, + { + "model": "audit.riskblueprint", + "pk": 298, + "fields": { + "order": 1, + "weight": 1, + "is_key": false, + "header": "23. Does the organisation have a stated document management policy that ensures documents are maintained for at least the period required by donors?", + "description": "", + "category": 132 + } + }, + { + "model": "audit.riskblueprint", + "pk": 299, + "fields": { + "order": 2, + "weight": 1, + "is_key": false, + "header": "24. Does the organisation have a data protection policy?", + "description": "", + "category": 132 + } + }, + + + { "model": "audit.riskblueprint", "pk": 1, diff --git a/src/etools/applications/core/jwt_api.py b/src/etools/applications/core/jwt_api.py new file mode 100644 index 000000000..848ce7f46 --- /dev/null +++ b/src/etools/applications/core/jwt_api.py @@ -0,0 +1,69 @@ +import json + +from django.conf import settings +from django.db import connection + +import jwt +import requests +from rest_framework_simplejwt.tokens import RefreshToken + + +class BaseJWTAPI: + def __init__(self, user, url): + self.url_prototype = url + self.user = user + self.enabled = bool(self.url_prototype) + + def generate_jwt(self): + # copy from IssueJWTRedirectView + refresh = RefreshToken.for_user(self.user) + access = str(refresh.access_token) + + decoded_token = jwt.decode(access, + settings.SIMPLE_JWT['VERIFYING_KEY'], + [settings.SIMPLE_JWT['ALGORITHM']], + audience=settings.SIMPLE_JWT['AUDIENCE'], + leeway=settings.SIMPLE_JWT['LEEWAY'], + ) + + groups = list(self.user.groups.values_list('name', flat=True)) if not connection.schema_name == "public" else [] + decoded_token.update({ + 'groups': groups, + 'username': self.user.username, + 'email': self.user.email, + }) + + encoded = jwt.encode( + decoded_token, + settings.SIMPLE_JWT['SIGNING_KEY'], + algorithm=settings.SIMPLE_JWT['ALGORITHM'] + ) + # endcopy + return encoded + + def _get_headers(self, data=None): + headers = {'Content-Type': 'application/json', 'Keep-Alive': '1800'} + if data: + headers['Content-Length'] = str(len(data)) + + jwt_token = self.generate_jwt() + headers['Authorization'] = 'JWT ' + jwt_token + return headers + + def _push_request(self, data=None, timeout=None): + if not self.enabled: + return + + headers = self._get_headers(data) + + if data: + r = requests.post(url=self.url, headers=headers, json=data, verify=True, timeout=timeout) + else: + r = requests.get(url=self.url, headers=headers, verify=True, timeout=timeout) + + # Any status code answer below 400 is OK + if r.status_code >= 400: + r.raise_for_status() + + data = json.loads(r.text) + return data diff --git a/src/etools/applications/core/middleware.py b/src/etools/applications/core/middleware.py index 23e1fa5db..ad26e20e9 100644 --- a/src/etools/applications/core/middleware.py +++ b/src/etools/applications/core/middleware.py @@ -113,6 +113,12 @@ class EToolsLocaleMiddleware(MiddlewareMixin): def process_request(self, request): if request.user.is_anonymous: return + + header_language_code = request.META.get('HTTP_LANGUAGE', '') + if header_language_code and header_language_code in get_languages(): + translation.activate(header_language_code) + return + preferences = request.user.preferences if preferences and 'language' in preferences: language_code = preferences['language'] diff --git a/src/etools/applications/core/templatetags/etools.py b/src/etools/applications/core/templatetags/etools.py index db2303fce..e7d0693e7 100644 --- a/src/etools/applications/core/templatetags/etools.py +++ b/src/etools/applications/core/templatetags/etools.py @@ -1,3 +1,5 @@ +from textwrap import wrap + from django import template from django.conf import settings from django.utils.safestring import mark_safe @@ -62,3 +64,14 @@ def call_method(obj, method_name, *args): @register.filter(is_safe=True) def currency(value): return currency_format(value) + + +@register.filter +def text_wrap(text, width=70): + """ + The used PDF libs don't allow CSS word-wrap, so to split long words (e.g. urls) + we wrap the text by lines and join them with spaces to have multiple lines. See: + https://github.com/nigma/django-easy-pdf/issues/65 + https://github.com/xhtml2pdf/xhtml2pdf/issues/379 + """ + return ' '.join(wrap(text, width)) diff --git a/src/etools/applications/ecn/api.py b/src/etools/applications/ecn/api.py index 7982e3efb..fd58c80ce 100644 --- a/src/etools/applications/ecn/api.py +++ b/src/etools/applications/ecn/api.py @@ -2,70 +2,14 @@ from django.conf import settings -import jwt -import requests from requests import HTTPError -from rest_framework_simplejwt.tokens import RefreshToken +from etools.applications.core.jwt_api import BaseJWTAPI -class ECNAPI(object): - def __init__(self, user): - self.url_prototype = settings.ECN_API_ENDPOINT - self.enabled = bool(self.url_prototype) - self.user = user - - def generate_jwt(self): - # copy from IssueJWTRedirectView - refresh = RefreshToken.for_user(self.user) - access = str(refresh.access_token) - - decoded_token = jwt.decode(access, - settings.SIMPLE_JWT['VERIFYING_KEY'], - [settings.SIMPLE_JWT['ALGORITHM']], - audience=settings.SIMPLE_JWT['AUDIENCE'], - leeway=settings.SIMPLE_JWT['LEEWAY'], - ) - - decoded_token.update({ - 'groups': list(self.user.groups.values_list('name', flat=True)), - 'username': self.user.username, - 'email': self.user.email, - }) - - encoded = jwt.encode( - decoded_token, - settings.SIMPLE_JWT['SIGNING_KEY'], - algorithm=settings.SIMPLE_JWT['ALGORITHM'] - ) - # endcopy - return encoded - - def _get_headers(self, data=None): - headers = {'Content-Type': 'application/json', 'Keep-Alive': '1800'} - if data: - headers['Content-Length'] = str(len(data)) - - jwt_token = self.generate_jwt() - headers['Authorization'] = 'JWT ' + jwt_token - return headers - def _push_request(self, data=None, timeout=None): - if not self.enabled: - return - - headers = self._get_headers(data) - - if data: - r = requests.post(url=self.url, headers=headers, json=data, verify=True, timeout=timeout) - else: - r = requests.get(url=self.url, headers=headers, verify=True, timeout=timeout) - - # Any status code answer below 400 is OK - if r.status_code >= 400: - r.raise_for_status() - - data = json.loads(r.text) - return data +class ECNAPI(BaseJWTAPI): + def __init__(self, user): + super().__init__(user, url=settings.ECN_API_ENDPOINT) def get_intervention(self, number: str) -> json: if not self.enabled: diff --git a/src/etools/applications/field_monitoring/data_collection/tests/test_offline.py b/src/etools/applications/field_monitoring/data_collection/tests/test_offline.py index f687f54b8..439d8b079 100644 --- a/src/etools/applications/field_monitoring/data_collection/tests/test_offline.py +++ b/src/etools/applications/field_monitoring/data_collection/tests/test_offline.py @@ -197,7 +197,8 @@ def test_blueprints_sent_on_tpm_data_collection(self, add_mock): ) add_mock.assert_called() - @override_settings(ETOOLS_OFFLINE_API='http://example.com/b/api/remote/blueprint/') + @override_settings(ETOOLS_OFFLINE_API='http://example.com/b/api/remote/blueprint/', + UNICEF_USER_EMAIL="@example.com") @patch('etools.applications.field_monitoring.data_collection.offline.synchronizer.OfflineCollect.add') def test_blueprints_sent_on_staff_assignment(self, add_mock): activity = MonitoringActivityFactory(status='pre_assigned', partners=[PartnerFactory()]) @@ -207,7 +208,8 @@ def test_blueprints_sent_on_staff_assignment(self, add_mock): self._test_update(self.fm_user, activity, {'status': 'assigned'}) add_mock.assert_called() - @override_settings(ETOOLS_OFFLINE_API='http://example.com/b/api/remote/blueprint/') + @override_settings(ETOOLS_OFFLINE_API='http://example.com/b/api/remote/blueprint/', + UNICEF_USER_EMAIL="@example.com") @patch('etools.applications.field_monitoring.data_collection.offline.synchronizer.OfflineCollect.add') def test_tenant_switch_missing(self, add_mock): activity = MonitoringActivityFactory(status='pre_assigned', partners=[PartnerFactory()]) @@ -217,7 +219,8 @@ def test_tenant_switch_missing(self, add_mock): self._test_update(self.fm_user, activity, {'status': 'assigned'}) add_mock.assert_called() - @override_settings(ETOOLS_OFFLINE_API='http://example.com/b/api/remote/blueprint/') + @override_settings(ETOOLS_OFFLINE_API='http://example.com/b/api/remote/blueprint/', + UNICEF_USER_EMAIL="@example.com") @patch('etools.applications.field_monitoring.data_collection.offline.synchronizer.OfflineCollect.add') def test_tenant_switch_enabled(self, add_mock): TenantSwitchFactory(name="fm_offline_sync_disabled", countries=[connection.tenant], active=True) @@ -228,7 +231,8 @@ def test_tenant_switch_enabled(self, add_mock): self._test_update(self.fm_user, activity, {'status': 'assigned'}) add_mock.assert_not_called() - @override_settings(ETOOLS_OFFLINE_API='http://example.com/b/api/remote/blueprint/') + @override_settings(ETOOLS_OFFLINE_API='http://example.com/b/api/remote/blueprint/', + UNICEF_USER_EMAIL="@example.com") @patch('etools.applications.field_monitoring.data_collection.offline.synchronizer.OfflineCollect.add') def test_tenant_switch_disabled(self, add_mock): TenantSwitchFactory(name="fm_offline_sync_disabled", countries=[connection.tenant], active=False) @@ -270,7 +274,8 @@ def test_blueprint_updated_on_team_member_remove(self, update_mock): activity.team_members.remove(activity.team_members.first()) update_mock.assert_called() - @override_settings(ETOOLS_OFFLINE_API='http://example.com/b/api/remote/blueprint/') + @override_settings(ETOOLS_OFFLINE_API='http://example.com/b/api/remote/blueprint/', + UNICEF_USER_EMAIL="@example.com") @patch('etools.applications.field_monitoring.data_collection.offline.synchronizer.OfflineCollect.delete') def test_blueprints_deleted_on_activity_cancel(self, delete_mock): activity = MonitoringActivityFactory(status='data_collection', partners=[PartnerFactory()]) @@ -280,7 +285,8 @@ def test_blueprints_deleted_on_activity_cancel(self, delete_mock): self._test_update(self.fm_user, activity, {'status': 'cancelled', 'cancel_reason': 'For testing purposes'}) delete_mock.assert_called() - @override_settings(ETOOLS_OFFLINE_API='http://example.com/b/api/remote/blueprint/') + @override_settings(ETOOLS_OFFLINE_API='http://example.com/b/api/remote/blueprint/', + UNICEF_USER_EMAIL="@example.com") @patch('etools.applications.field_monitoring.data_collection.offline.synchronizer.OfflineCollect.delete') def test_blueprints_deleted_on_activity_report_finalization(self, delete_mock): activity = MonitoringActivityFactory(status='data_collection', partners=[PartnerFactory()]) @@ -292,7 +298,7 @@ def test_blueprints_deleted_on_activity_report_finalization(self, delete_mock): self._test_update(activity.visit_lead, activity, {'status': 'report_finalization'}) delete_mock.assert_called() - @override_settings(ETOOLS_OFFLINE_API='') + @override_settings(ETOOLS_OFFLINE_API='', UNICEF_USER_EMAIL="@example.com") @patch('etools.applications.field_monitoring.data_collection.offline.synchronizer.OfflineCollect.add') def test_tenant_switch_missing_but_api_not_configured(self, add_mock): activity = MonitoringActivityFactory(status='pre_assigned', partners=[PartnerFactory()]) @@ -302,7 +308,9 @@ def test_tenant_switch_missing_but_api_not_configured(self, add_mock): self._test_update(self.fm_user, activity, {'status': 'assigned'}) add_mock.assert_not_called() - @override_settings(ETOOLS_OFFLINE_API='http://example.com/b/api/remote/blueprint/', SENTRY_DSN='https://test.dns') + @override_settings(ETOOLS_OFFLINE_API='http://example.com/b/api/remote/blueprint/', + SENTRY_DSN='https://test.dns', + UNICEF_USER_EMAIL="@example.com") @patch('sentry_sdk.api.Hub.current.capture_exception') @patch('etools.applications.field_monitoring.data_collection.offline.synchronizer.OfflineCollect.add') def test_add_offline_backend_unavailable(self, add_mock, capture_event_mock): @@ -320,7 +328,9 @@ def communication_failure(*args, **kwargs): self._test_update(self.fm_user, activity, {'status': 'assigned'}) capture_event_mock.assert_called() - @override_settings(ETOOLS_OFFLINE_API='http://example.com/b/api/remote/blueprint/', SENTRY_DSN='https://test.dns') + @override_settings(ETOOLS_OFFLINE_API='http://example.com/b/api/remote/blueprint/', + SENTRY_DSN='https://test.dns', + UNICEF_USER_EMAIL="@example.com") @patch('sentry_sdk.api.Hub.current.capture_exception') @patch('etools.applications.field_monitoring.data_collection.offline.synchronizer.OfflineCollect.update') def test_update_offline_backend_unavailable(self, update_mock, capture_event_mock): @@ -338,7 +348,9 @@ def communication_failure(*args, **kwargs): activity.team_members.remove(activity.team_members.first()) capture_event_mock.assert_called() - @override_settings(ETOOLS_OFFLINE_API='http://example.com/b/api/remote/blueprint/', SENTRY_DSN='https://test.dns') + @override_settings(ETOOLS_OFFLINE_API='http://example.com/b/api/remote/blueprint/', + SENTRY_DSN='https://test.dns', + UNICEF_USER_EMAIL="@example.com") @patch('sentry_sdk.api.Hub.current.capture_exception') @patch('etools.applications.field_monitoring.data_collection.offline.synchronizer.OfflineCollect.delete') def test_delete_offline_backend_unavailable(self, delete_mock, capture_event_mock): diff --git a/src/etools/applications/field_monitoring/planning/activity_validation/permissions_matrix.csv b/src/etools/applications/field_monitoring/planning/activity_validation/permissions_matrix.csv index 3c78760dd..01df52831 100644 --- a/src/etools/applications/field_monitoring/planning/activity_validation/permissions_matrix.csv +++ b/src/etools/applications/field_monitoring/planning/activity_validation/permissions_matrix.csv @@ -40,9 +40,9 @@ Field no,Field Name,Group,Condition,Status,Action,Allowed ,report_attachments,All Users,is_ma_related_user,data_collection,edit,TRUE ,report_attachments,All Users,is_ma_related_user,report_finalization,edit,TRUE ,action_points,Field Monitor,,submitted,edit,TRUE -,action_points,All Users,is_visit_lead,submitted,edit,TRUE +,action_points,UNICEF User,is_visit_lead,submitted,edit,TRUE ,action_points,Field Monitor,,completed,edit,TRUE -,action_points,All Users,is_visit_lead,completed,edit,TRUE +,action_points,UNICEF User,is_visit_lead,completed,edit,TRUE ,cancel_reason,Field Monitor,,*,edit,TRUE ,report_reject_reason,Field Monitor,,*,edit,TRUE ,reject_reason,All Users,is_ma_related_user,*,edit,TRUE @@ -85,9 +85,9 @@ Field no,Field Name,Group,Condition,Status,Action,Allowed ,report_reject_reason,All Users,,submitted,view,TRUE ,report_reject_reason,All Users,,report_finalization,view,TRUE ,action_points,Field Monitor,,submitted,view,TRUE -,action_points,All Users,is_visit_lead,submitted,view,TRUE +,action_points,UNICEF User,is_visit_lead,submitted,view,TRUE ,action_points,Field Monitor,,completed,view,TRUE -,action_points,All Users,is_visit_lead,completed,view,TRUE +,action_points,UNICEF User,is_visit_lead,completed,view,TRUE ,activity_question_set,All Users,,checklist,view,TRUE ,activity_question_set_review,All Users,,review,view,TRUE ,activity_question_set_review,All Users,,assigned,view,TRUE diff --git a/src/etools/applications/field_monitoring/planning/activity_validation/validations/basic.py b/src/etools/applications/field_monitoring/planning/activity_validation/validations/basic.py index 76be90ed7..053ebcd7c 100644 --- a/src/etools/applications/field_monitoring/planning/activity_validation/validations/basic.py +++ b/src/etools/applications/field_monitoring/planning/activity_validation/validations/basic.py @@ -17,9 +17,15 @@ def tpm_staff_members_belongs_to_the_partner(i): if not i.tpm_partner: return True - team_members = set(i.team_members.values_list('id', flat=True)) + team_members = set([tm.id for tm in i.team_members.all()]) + if i.old_instance: + old_team_members = set([tm.id for tm in i.old_instance.team_members.all()]) + members_to_validate = team_members - old_team_members + else: + members_to_validate = team_members + partner_staff_members = set(i.tpm_partner.staff_members.all().values_list('id', flat=True)) - if team_members - partner_staff_members: + if members_to_validate - partner_staff_members: raise BasicValidationError(_('Staff members do not belong to the selected partner')) return True diff --git a/src/etools/applications/field_monitoring/planning/filters.py b/src/etools/applications/field_monitoring/planning/filters.py index 76b84e865..73592954f 100644 --- a/src/etools/applications/field_monitoring/planning/filters.py +++ b/src/etools/applications/field_monitoring/planning/filters.py @@ -57,11 +57,7 @@ def filter_queryset(self, request, queryset, view): return queryset if value == 'tpm': - return queryset.filter( - realms__country=connection.tenant, - realms__organization__tpmpartner__isnull=False, - realms__group__name__in=TPM_ACTIVE_GROUPS, - ) + return queryset.filter(tpm_partner__isnull=False).distinct() else: return queryset.filter(is_staff=True) @@ -76,7 +72,7 @@ def filter_queryset(self, request, queryset, view): realms__country=connection.tenant, realms__organization__tpmpartner=value, realms__group__name__in=TPM_ACTIVE_GROUPS, - ) + ).distinct() class CPOutputsFilterSet(filters.FilterSet): diff --git a/src/etools/applications/field_monitoring/planning/locale/ar/LC_MESSAGES/django.mo b/src/etools/applications/field_monitoring/planning/locale/ar/LC_MESSAGES/django.mo index e2aa353f8..964c8ee70 100644 Binary files a/src/etools/applications/field_monitoring/planning/locale/ar/LC_MESSAGES/django.mo and b/src/etools/applications/field_monitoring/planning/locale/ar/LC_MESSAGES/django.mo differ diff --git a/src/etools/applications/field_monitoring/planning/locale/ar/LC_MESSAGES/django.po b/src/etools/applications/field_monitoring/planning/locale/ar/LC_MESSAGES/django.po index a727bf823..18e484cae 100644 --- a/src/etools/applications/field_monitoring/planning/locale/ar/LC_MESSAGES/django.po +++ b/src/etools/applications/field_monitoring/planning/locale/ar/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-03-24 08:39+0000\n" +"POT-Creation-Date: 2023-05-26 10:45+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -226,9 +226,11 @@ msgstr "التاريخ" msgid "Document Type" msgstr "نوع الوثيقة" -#, python-format -msgid "[Inactive] %s" -msgstr "[غير نشط] %s" +msgid "No Access" +msgstr "ممنوع الدخول" + +msgid "Inactive" +msgstr "غير نشط" msgid "Reference No." msgstr "رقم المرجع." @@ -265,3 +267,6 @@ msgstr "القوالب" msgid "Attachments" msgstr "المرفقات" + +#~ msgid "Invited" +#~ msgstr "مدعو" diff --git a/src/etools/applications/field_monitoring/planning/locale/es/LC_MESSAGES/django.mo b/src/etools/applications/field_monitoring/planning/locale/es/LC_MESSAGES/django.mo index 70be431df..5ef7b9e22 100644 Binary files a/src/etools/applications/field_monitoring/planning/locale/es/LC_MESSAGES/django.mo and b/src/etools/applications/field_monitoring/planning/locale/es/LC_MESSAGES/django.mo differ diff --git a/src/etools/applications/field_monitoring/planning/locale/es/LC_MESSAGES/django.po b/src/etools/applications/field_monitoring/planning/locale/es/LC_MESSAGES/django.po index 9ab42930e..8f7fec319 100644 --- a/src/etools/applications/field_monitoring/planning/locale/es/LC_MESSAGES/django.po +++ b/src/etools/applications/field_monitoring/planning/locale/es/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-03-24 08:39+0000\n" +"POT-Creation-Date: 2023-05-26 10:45+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -226,9 +226,11 @@ msgstr "Historia" msgid "Document Type" msgstr "Tipo de documento" -#, python-format -msgid "[Inactive] %s" -msgstr "[Inactivo] %s" +msgid "No Access" +msgstr "Sin acceso" + +msgid "Inactive" +msgstr "Inactivo" msgid "Reference No." msgstr "Número de referencia" @@ -265,3 +267,6 @@ msgstr "Plantillas" msgid "Attachments" msgstr "Archivos adjuntos" + +#~ msgid "Invited" +#~ msgstr "Invitado" diff --git a/src/etools/applications/field_monitoring/planning/locale/fr/LC_MESSAGES/django.mo b/src/etools/applications/field_monitoring/planning/locale/fr/LC_MESSAGES/django.mo index eb4760e21..9649cc9e8 100644 Binary files a/src/etools/applications/field_monitoring/planning/locale/fr/LC_MESSAGES/django.mo and b/src/etools/applications/field_monitoring/planning/locale/fr/LC_MESSAGES/django.mo differ diff --git a/src/etools/applications/field_monitoring/planning/locale/fr/LC_MESSAGES/django.po b/src/etools/applications/field_monitoring/planning/locale/fr/LC_MESSAGES/django.po index 0388463fa..90cf8ca9c 100644 --- a/src/etools/applications/field_monitoring/planning/locale/fr/LC_MESSAGES/django.po +++ b/src/etools/applications/field_monitoring/planning/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-03-24 08:39+0000\n" +"POT-Creation-Date: 2023-05-26 10:45+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -228,9 +228,11 @@ msgstr "Historique" msgid "Document Type" msgstr "Type de document" -#, python-format -msgid "[Inactive] %s" -msgstr "[Inactif] %s" +msgid "No Access" +msgstr "Pas d'accès" + +msgid "Inactive" +msgstr "Inactif" msgid "Reference No." msgstr "Numéro de référence." @@ -267,3 +269,6 @@ msgstr "Modèles" msgid "Attachments" msgstr "Pièces jointes" + +#~ msgid "Invited" +#~ msgstr "Invité" diff --git a/src/etools/applications/field_monitoring/planning/locale/pt/LC_MESSAGES/django.mo b/src/etools/applications/field_monitoring/planning/locale/pt/LC_MESSAGES/django.mo index 16809813c..388ba11a1 100644 Binary files a/src/etools/applications/field_monitoring/planning/locale/pt/LC_MESSAGES/django.mo and b/src/etools/applications/field_monitoring/planning/locale/pt/LC_MESSAGES/django.mo differ diff --git a/src/etools/applications/field_monitoring/planning/locale/pt/LC_MESSAGES/django.po b/src/etools/applications/field_monitoring/planning/locale/pt/LC_MESSAGES/django.po index 90060ec0f..3791ec094 100644 --- a/src/etools/applications/field_monitoring/planning/locale/pt/LC_MESSAGES/django.po +++ b/src/etools/applications/field_monitoring/planning/locale/pt/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-03-24 08:39+0000\n" +"POT-Creation-Date: 2023-05-26 10:45+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -225,9 +225,11 @@ msgstr "História" msgid "Document Type" msgstr "Tipo de Documento" -#, python-format -msgid "[Inactive] %s" -msgstr "[Inativo] %s" +msgid "No Access" +msgstr "Sem acesso" + +msgid "Inactive" +msgstr "Inativo" msgid "Reference No." msgstr "Nº de referência" @@ -264,3 +266,6 @@ msgstr "Modelos" msgid "Attachments" msgstr "Anexos" + +#~ msgid "Invited" +#~ msgstr "Convidado" diff --git a/src/etools/applications/field_monitoring/planning/locale/ru/LC_MESSAGES/django.mo b/src/etools/applications/field_monitoring/planning/locale/ru/LC_MESSAGES/django.mo index 50beaf87d..1b882de65 100644 Binary files a/src/etools/applications/field_monitoring/planning/locale/ru/LC_MESSAGES/django.mo and b/src/etools/applications/field_monitoring/planning/locale/ru/LC_MESSAGES/django.mo differ diff --git a/src/etools/applications/field_monitoring/planning/locale/ru/LC_MESSAGES/django.po b/src/etools/applications/field_monitoring/planning/locale/ru/LC_MESSAGES/django.po index dc14d455e..1a061e068 100644 --- a/src/etools/applications/field_monitoring/planning/locale/ru/LC_MESSAGES/django.po +++ b/src/etools/applications/field_monitoring/planning/locale/ru/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-03-24 08:39+0000\n" +"POT-Creation-Date: 2023-05-26 10:45+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -228,9 +228,11 @@ msgstr "История" msgid "Document Type" msgstr "Тип документа" -#, python-format -msgid "[Inactive] %s" -msgstr "[Неактивно] %s" +msgid "No Access" +msgstr "Нет доступа" + +msgid "Inactive" +msgstr "Неактивный" msgid "Reference No." msgstr "Номер ссылки." @@ -267,3 +269,6 @@ msgstr "Шаблоны" msgid "Attachments" msgstr "Вложения" + +#~ msgid "Invited" +#~ msgstr "Приглашен" diff --git a/src/etools/applications/field_monitoring/planning/mixins.py b/src/etools/applications/field_monitoring/planning/mixins.py index 58daae67d..d269bc0f7 100644 --- a/src/etools/applications/field_monitoring/planning/mixins.py +++ b/src/etools/applications/field_monitoring/planning/mixins.py @@ -37,3 +37,13 @@ def access_denied_permission(instance, user): )(new_transition) return super().__new__(cls, name, bases, new_attrs, **kwargs) + + +class EmptyQuerysetForExternal: + def get_queryset(self): + queryset = super().get_queryset() + + if not self.request.user.is_unicef_user(): + return queryset.none() + + return queryset diff --git a/src/etools/applications/field_monitoring/planning/serializers.py b/src/etools/applications/field_monitoring/planning/serializers.py index 74f530292..34dc9059b 100644 --- a/src/etools/applications/field_monitoring/planning/serializers.py +++ b/src/etools/applications/field_monitoring/planning/serializers.py @@ -173,7 +173,7 @@ def create(self, validated_data): class FMUserSerializer(MinimalUserSerializer): name = serializers.SerializerMethodField() user_type = serializers.SerializerMethodField() - tpm_partner = serializers.ReadOnlyField(source='profile.organization.tpmpartner.id', allow_null=True) + tpm_partner = serializers.ReadOnlyField(allow_null=True) class Meta(MinimalUserSerializer.Meta): fields = MinimalUserSerializer.Meta.fields + ( @@ -181,14 +181,18 @@ class Meta(MinimalUserSerializer.Meta): ) def get_user_type(self, obj): - if hasattr(obj.profile.organization, 'tpmpartner'): + if obj.tpm_partner: return 'tpm' return 'staff' def get_name(self, obj): if obj.is_active: - return obj.get_full_name() - return _('[Inactive] %s') % obj.get_full_name() + if hasattr(obj, 'has_active_realm') and obj.has_active_realm: + return obj.get_full_name() + status = _('No Access') + else: + status = _('Inactive') + return f"[{status}] {obj.get_full_name()}" class CPOutputListSerializer(MinimalOutputListSerializer): diff --git a/src/etools/applications/field_monitoring/planning/tests/test_views.py b/src/etools/applications/field_monitoring/planning/tests/test_views.py index abc676b05..ce6fb18ca 100644 --- a/src/etools/applications/field_monitoring/planning/tests/test_views.py +++ b/src/etools/applications/field_monitoring/planning/tests/test_views.py @@ -4,6 +4,7 @@ from django.core import mail from django.core.management import call_command from django.db import connection +from django.test.utils import override_settings from django.urls import reverse from rest_framework import status @@ -95,13 +96,16 @@ def setUp(self): super().setUp() call_command("update_notifications") + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_create_empty_visit(self): response = self._test_create(self.fm_user, {}, expected_status=status.HTTP_400_BAD_REQUEST) self.assertIn('location', response.data[0]) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_create_minimum_visit(self): self._test_create(self.fm_user, {'location': LocationFactory().id}) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_list(self): activities = [ MonitoringActivityFactory(monitor_type='tpm', tpm_partner=None), @@ -109,15 +113,32 @@ def test_list(self): MonitoringActivityFactory(monitor_type='staff'), ] - with self.assertNumQueries(10): + with self.assertNumQueries(9): self._test_list(self.unicef_user, activities, data={'page': 1, 'page_size': 10}) + def test_list_as_tpm_user(self): + tpm_partner = TPMPartnerFactory() + tpm_staff = TPMUserFactory(tpm_partner=tpm_partner, profile__organization=tpm_partner.organization) + + activities = [ + MonitoringActivityFactory( + monitor_type='tpm', tpm_partner=tpm_partner, status='assigned', team_members=[tpm_staff]), + MonitoringActivityFactory( + monitor_type='tpm', tpm_partner=TPMPartnerFactory(), status='assigned'), + MonitoringActivityFactory( + monitor_type='staff', status='assigned') + ] + with self.assertNumQueries(7): + self._test_list(tpm_staff, [activities[0]], data={'page': 1, 'page_size': 10}) + + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_search_by_ref_number(self): activity = MonitoringActivityFactory(monitor_type='staff') MonitoringActivityFactory(monitor_type='staff') self._test_list(self.unicef_user, [activity], data={'search': activity.reference_number}) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_filter_by_visit_lead(self): activity1 = MonitoringActivityFactory(monitor_type='staff', visit_lead=UserFactory()) activity2 = MonitoringActivityFactory(monitor_type='staff', visit_lead=UserFactory()) @@ -128,6 +149,7 @@ def test_filter_by_visit_lead(self): data={'visit_lead__in': f'{activity1.visit_lead.pk},{activity2.visit_lead.pk}'} ) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_filter_by_partner_hact(self): partner = PartnerFactory() activity1 = MonitoringActivityFactory(partners=[partner], status='completed') @@ -164,6 +186,7 @@ def test_filter_by_partner_hact(self): self._test_list(self.unicef_user, [activity1], data={'hact_for_partner': partner.id}) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_details(self): activity = MonitoringActivityFactory(monitor_type='staff', team_members=[UserFactory(unicef_user=True)]) @@ -179,12 +202,14 @@ def test_details(self): ['cancel', 'mark_details_configured'] ) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_unlinked_intervention(self): intervention = InterventionFactory() activity = MonitoringActivityFactory(monitor_type='staff', interventions=[intervention], partners=[intervention.agreement.partner]) self._test_update(self.fm_user, activity, data={'partners': []}, expected_status=status.HTTP_400_BAD_REQUEST) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_add_linked_intervention(self): intervention = InterventionFactory() link = InterventionResultLinkFactory(intervention=intervention) @@ -197,6 +222,7 @@ def test_add_linked_intervention(self): response = self._test_update(self.fm_user, activity, data=data, expected_status=status.HTTP_200_OK) self.assertNotEqual(response.data['cp_outputs'], []) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_update_draft_success(self): activity = MonitoringActivityFactory(monitor_type='tpm', tpm_partner=None) @@ -205,6 +231,7 @@ def test_update_draft_success(self): self.assertIsNotNone(response.data['tpm_partner']) self.assertNotEqual(response.data['tpm_partner'], {}) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_update_tpm_partner_staff_activity(self): activity = MonitoringActivityFactory(monitor_type='staff') @@ -215,6 +242,29 @@ def test_update_tpm_partner_staff_activity(self): basic_errors=['TPM Partner selected for staff activity'], ) + @override_settings(UNICEF_USER_EMAIL="@example.com") + def test_update_with_inactive_tpm_team_members(self): + tpm_partner = TPMPartnerFactory() + tpm_staff_1 = TPMUserFactory( + tpm_partner=tpm_partner, profile__organization=tpm_partner.organization + ) + tpm_staff_2 = TPMUserFactory( + tpm_partner=tpm_partner, profile__organization=tpm_partner.organization + ) + tpm_staff_3 = TPMUserFactory( + tpm_partner=tpm_partner, profile__organization=tpm_partner.organization + ) + activity = MonitoringActivityFactory( + monitor_type='tpm', tpm_partner=tpm_partner, status='assigned', team_members=[tpm_staff_1, tpm_staff_2] + ) + tpm_staff_1.realms.update(is_active=False) + self._test_update( + self.fm_user, activity, + data={'team_members': [tpm_staff_1.pk, tpm_staff_2.pk, tpm_staff_3.pk]}, + expected_status=status.HTTP_200_OK + ) + + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_auto_accept_activity(self): activity = MonitoringActivityFactory(monitor_type='staff', status='pre_' + MonitoringActivity.STATUSES.assigned) @@ -228,6 +278,7 @@ def test_auto_accept_activity(self): len(activity.team_members.all()) + 1, ) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_dont_auto_accept_activity_if_tpm(self): tpm_partner = SimpleTPMPartnerFactory() team_members = [ @@ -248,6 +299,7 @@ def test_dont_auto_accept_activity_if_tpm(self): self.assertEqual(response.data['status'], 'assigned') self.assertEqual(len(mail.outbox), len(team_members) + 1) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_cancel_activity(self): activity = MonitoringActivityFactory(status=MonitoringActivity.STATUSES.review) @@ -256,6 +308,7 @@ def test_cancel_activity(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['status'], 'cancelled') + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_cancel_submitted_activity_fail(self): activity = MonitoringActivityFactory(status=MonitoringActivity.STATUSES.submitted) @@ -264,6 +317,7 @@ def test_cancel_submitted_activity_fail(self): expected_status=status.HTTP_400_BAD_REQUEST, basic_errors=['generic_transition_fail'] ) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_flow(self): activity = MonitoringActivityFactory( monitor_type='staff', status='draft', @@ -309,12 +363,14 @@ def goto(next_status, user, extra_data=None, mail_count=None): mail_count=len(PME.as_group().user_set.filter(profile__country=connection.tenant))) goto('completed', self.pme) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_sections_are_displayed_correctly(self): activity = MonitoringActivityFactory(status=MonitoringActivity.STATUSES.draft, sections=[SectionFactory()]) response = self._test_retrieve(self.unicef_user, activity) self.assertIsNotNone(response.data['sections'][0]['name']) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_reject_reason_required(self): visit_lead = UserFactory(unicef_user=True) activity = MonitoringActivityFactory(monitor_type='staff', status='assigned', @@ -324,6 +380,7 @@ def test_reject_reason_required(self): expected_status=status.HTTP_400_BAD_REQUEST) self._test_update(visit_lead, activity, {'status': 'draft', 'reject_reason': 'just because'}) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_cancel_reason_required(self): activity = MonitoringActivityFactory(monitor_type='staff', status='assigned', visit_lead=self.unicef_user) @@ -355,6 +412,7 @@ def test_reject_as_tpm(self): self._test_update(visit_lead, activity, {'status': 'draft', 'reject_reason': 'just because'}) self.assertEqual(len(mail.outbox), 1) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_draft_status_permissions(self): activity = MonitoringActivityFactory(monitor_type='staff', status='draft') @@ -368,6 +426,7 @@ def test_draft_status_permissions(self): self.assertFalse(permissions['edit']['activity_question_set']) self.assertFalse(permissions['view']['additional_info']) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_checklist_status_permissions(self): activity = MonitoringActivityFactory(monitor_type='staff', status='checklist') @@ -381,6 +440,7 @@ def test_checklist_status_permissions(self): self.assertFalse(permissions['view']['activity_question_set_review']) self.assertTrue(permissions['view']['additional_info']) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_review_status_permissions(self): activity = MonitoringActivityFactory(monitor_type='staff', status='review') @@ -391,6 +451,7 @@ def test_review_status_permissions(self): self.assertTrue(permissions['view']['activity_question_set_review']) self.assertTrue(permissions['view']['additional_info']) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_offices_update(self): activity = MonitoringActivityFactory(monitor_type='staff', status='draft') activity.offices.set([]) @@ -404,6 +465,7 @@ def test_offices_update(self): self.assertTrue(permissions['view']['offices']) self.assertTrue(permissions['edit']['offices']) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_offices_not_editable_in_checklist(self): activity = MonitoringActivityFactory(monitor_type='staff', status='checklist') response = self._test_retrieve(self.fm_user, activity) @@ -411,6 +473,7 @@ def test_offices_not_editable_in_checklist(self): self.assertTrue(permissions['view']['offices']) self.assertFalse(permissions['edit']['offices']) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_filter_by_offices(self): MonitoringActivityFactory(monitor_type='staff', status='draft') o1 = OfficeFactory() @@ -422,6 +485,7 @@ def test_filter_by_offices(self): data={'offices__in': f'{activity1.offices.first().id},{activity2.offices.first().id}'}, ) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_filter_by_section(self): MonitoringActivityFactory(monitor_type='staff', status='draft') section = SectionFactory() @@ -440,6 +504,7 @@ def test_filter_by_section(self): data={'sections__in': f'{section.id}'}, ) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_visit_pdf_export(self): partner = PartnerFactory() activity = MonitoringActivityFactory(partners=[partner]) @@ -473,6 +538,7 @@ def get_list_args(self): def set_attachments(self, user, data): return self.make_request_to_viewset(user, action='bulk_update', method='put', data=data) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_bulk_add(self): self.assertEqual(self.activity.attachments.count(), 0) @@ -488,6 +554,7 @@ def test_bulk_add(self): self.assertEqual(self.activity.attachments.count(), 2) self.assertEqual(AttachmentLink.objects.filter(object_id=self.activity.id).count(), 2) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_list(self): attachments = AttachmentFactory.create_batch(size=2, content_object=self.activity, code='attachments') for attachment in attachments: @@ -497,6 +564,7 @@ def test_list(self): self._test_list(self.unicef_user, attachments) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_bulk_change_file_type(self): attachment = AttachmentFactory(content_object=self.activity, file_type__code='fm_common', file_type__name='before', code='attachments') @@ -512,6 +580,7 @@ def test_bulk_change_file_type(self): self.assertEqual(self.activity.attachments.count(), 1) self.assertEqual(Attachment.objects.get(pk=attachment.pk, object_id=self.activity.id).file_type.name, 'after') + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_bulk_remove(self): attachment = AttachmentFactory(content_object=self.activity, file_type__code='fm_common', file_type__name='before', code='attachments') @@ -523,6 +592,7 @@ def test_bulk_remove(self): self.assertEqual(self.activity.attachments.count(), 0) self.assertEqual(AttachmentLink.objects.filter(object_id=self.activity.id).count(), 0) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_add(self): self.assertFalse(self.activity.attachments.exists()) @@ -535,6 +605,7 @@ def test_add(self): ) self.assertTrue(self.activity.attachments.exists()) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_update(self): attachment = AttachmentFactory(code='attachments', content_object=self.activity) @@ -544,6 +615,7 @@ def test_update(self): ) self.assertNotEqual(Attachment.objects.get(pk=attachment.pk).file_type_id, attachment.file_type_id) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_destroy(self): attachment = AttachmentFactory(code='attachments', content_object=self.activity) self.assertTrue(Attachment.objects.filter(pk=attachment.pk).exists()) @@ -551,11 +623,13 @@ def test_destroy(self): self._test_destroy(self.fm_user, attachment) self.assertFalse(Attachment.objects.filter(pk=attachment.pk).exists()) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_add_unicef(self): response = self.set_attachments(self.unicef_user, []) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_file_types(self): wrong_file_type = AttachmentFileTypeFactory() file_type = AttachmentFileTypeFactory(code='fm_common') @@ -695,22 +769,33 @@ def setUpTestData(cls): cls.tpm_user = TPMUserFactory(tpm_partner=SimpleTPMPartnerFactory()) cls.another_tpm_user = TPMUserFactory(tpm_partner=SimpleTPMPartnerFactory()) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_filter_unicef(self): response = self._test_list(self.unicef_user, [self.unicef_user, self.fm_user, self.pme], data={'user_type': 'unicef'}) self.assertEqual(response.data['results'][0]['user_type'], 'staff') + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_filter_default(self): - with self.assertNumQueries(2): + with self.assertNumQueries(3): self._test_list(self.unicef_user, [ - self.unicef_user, self.fm_user, self.pme, self.usual_user, self.tpm_user, self.another_tpm_user + self.unicef_user, self.fm_user, self.pme, self.tpm_user, self.another_tpm_user ]) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_filter_tpm(self): - response = self._test_list(self.unicef_user, [self.tpm_user, self.another_tpm_user], data={'user_type': 'tpm'}) + new_tpm_user = TPMUserFactory(tpm_partner=SimpleTPMPartnerFactory()) + new_tpm_user.profile.organization = None + new_tpm_user.profile.save(update_fields=['organization']) + + response = self._test_list( + self.unicef_user, [self.tpm_user, self.another_tpm_user, new_tpm_user], data={'user_type': 'tpm'}) + self.assertEqual(response.data['results'][0]['user_type'], 'tpm') self.assertEqual(response.data['results'][1]['user_type'], 'tpm') + self.assertEqual(response.data['results'][2]['user_type'], 'tpm') + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_filter_tpm_partner(self): tpm_partner = self.tpm_user.profile.organization.tpmpartner.id @@ -724,6 +809,7 @@ def test_filter_tpm_partner(self): class CPOutputsViewTestCase(FMBaseTestCaseMixin, APIViewSetTestCase, BaseTenantTestCase): base_view = 'field_monitoring_planning:cp_outputs' + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_filter_by_partners(self): ResultFactory(result_type__name=ResultType.OUTPUT) result_link = InterventionResultLinkFactory(cp_output__result_type__name=ResultType.OUTPUT) @@ -735,20 +821,29 @@ def test_filter_by_partners(self): } ) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_output_name_contains_wbs_code(self): result = ResultFactory(result_type__name=ResultType.OUTPUT, wbs='wbs-code') response = self._test_list(self.unicef_user, [result]) self.assertIn('wbs-code', response.data['results'][0]['name']) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_queries_number(self): results = [ResultFactory(result_type__name=ResultType.OUTPUT) for _i in range(9)] with self.assertNumQueries(2): self._test_list(self.unicef_user, results) + def test_empty_list_for_tpm_staff(self): + ResultFactory(result_type__name=ResultType.OUTPUT) + tpm_staff = TPMUserFactory(tpm_partner=TPMPartnerFactory()) + + self._test_list(tpm_staff, []) + class InterventionsViewTestCase(FMBaseTestCaseMixin, APIViewSetTestCase, BaseTenantTestCase): base_view = 'field_monitoring_planning:interventions' + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_list(self): InterventionFactory(status=Intervention.DRAFT) valid_interventions = [ @@ -764,6 +859,7 @@ def test_list(self): with self.assertNumQueries(10): # 3 basic + 7 prefetches from InterventionManager self._test_list(self.unicef_user, valid_interventions) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_filter_by_outputs(self): InterventionFactory(status=Intervention.SIGNED) result_link = InterventionResultLinkFactory( @@ -776,6 +872,7 @@ def test_filter_by_outputs(self): data={'cp_outputs__in': str(result_link.cp_output.id)}, ) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_filter_by_partners(self): InterventionFactory(status=Intervention.SIGNED) result_link = InterventionResultLinkFactory(intervention__status=Intervention.SIGNED) @@ -785,6 +882,7 @@ def test_filter_by_partners(self): data={'partners__in': str(result_link.intervention.agreement.partner.id)} ) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_linked_data(self): result_link = InterventionResultLinkFactory(intervention__status=Intervention.SIGNED) @@ -793,6 +891,12 @@ def test_linked_data(self): self.assertEqual(response.data['results'][0]['partner'], result_link.intervention.agreement.partner_id) self.assertListEqual(response.data['results'][0]['cp_outputs'], [result_link.cp_output_id]) + def test_empty_list_for_tpm_staff(self): + InterventionResultLinkFactory(intervention__status=Intervention.SIGNED) + tpm_staff = TPMUserFactory(tpm_partner=TPMPartnerFactory()) + + self._test_list(tpm_staff, []) + class MonitoringActivityActionPointsViewTestCase(FMBaseTestCaseMixin, APIViewSetTestCase, BaseTenantTestCase): base_view = 'field_monitoring_planning:activity_action_points' @@ -817,6 +921,7 @@ def setUpTestData(cls): def get_list_args(self): return [self.activity.pk] + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_list(self): action_points = MonitoringActivityActionPointFactory.create_batch(size=10, monitoring_activity=self.activity) MonitoringActivityActionPointFactory() @@ -824,6 +929,7 @@ def test_list(self): with self.assertNumQueries(14): # prefetched 13 queries self._test_list(self.unicef_user, action_points) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_create(self): response = self._test_create( self.fm_user, @@ -831,12 +937,15 @@ def test_create(self): ) self.assertEqual(len(response.data['history']), 1) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_create_visit_lead(self): self._test_create(self.activity.visit_lead, data=self.create_data) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_create_unicef_user(self): self._test_create(self.unicef_user, data={}, expected_status=status.HTTP_403_FORBIDDEN) + @override_settings(UNICEF_USER_EMAIL="@example.com") def test_create_wrong_activity_status(self): self.activity = MonitoringActivityFactory(status='draft') self._test_create(self.fm_user, data={}, expected_status=status.HTTP_403_FORBIDDEN) @@ -845,11 +954,22 @@ def test_create_wrong_activity_status(self): class PartnersViewTestCase(FMBaseTestCaseMixin, APIViewSetTestCase, BaseTenantTestCase): base_view = 'field_monitoring_planning:partners' - def test_list(self): + @classmethod + def setUpTestData(cls): + super().setUpTestData() PartnerFactory(deleted_flag=True) PartnerFactory(organization=OrganizationFactory(name='')) + + @override_settings(UNICEF_USER_EMAIL="@example.com") + def test_list(self): + valid_partners = [PartnerFactory(organization=OrganizationFactory(name='b')), PartnerFactory(organization=OrganizationFactory(name='a'))] valid_partners.reverse() self._test_list(self.unicef_user, valid_partners) + + def test_empty_list_for_tpm_staff(self): + tpm_staff = TPMUserFactory(tpm_partner=TPMPartnerFactory()) + + self._test_list(tpm_staff, []) diff --git a/src/etools/applications/field_monitoring/planning/views.py b/src/etools/applications/field_monitoring/planning/views.py index 37aaf7c63..38fc2a2e8 100644 --- a/src/etools/applications/field_monitoring/planning/views.py +++ b/src/etools/applications/field_monitoring/planning/views.py @@ -3,7 +3,7 @@ from django.contrib.auth import get_user_model from django.db import connection, transaction -from django.db.models import Count, Prefetch, Q +from django.db.models import Count, F, Prefetch, Q from django.http import Http404 from django.utils.translation import gettext as _ @@ -40,6 +40,7 @@ UserTPMPartnerFilter, UserTypeFilter, ) +from etools.applications.field_monitoring.planning.mixins import EmptyQuerysetForExternal from etools.applications.field_monitoring.planning.models import ( MonitoringActivity, MonitoringActivityActionPoint, @@ -59,6 +60,8 @@ from etools.applications.partners.models import Intervention, PartnerOrganization from etools.applications.partners.serializers.partner_organization_v2 import MinimalPartnerOrganizationListSerializer from etools.applications.reports.models import Result, ResultType +from etools.applications.tpm.models import ThirdPartyMonitor +from etools.applications.users.models import Realm class YearPlanViewSet( @@ -170,13 +173,13 @@ class MonitoringActivitiesViewSet( def get_queryset(self): queryset = super().get_queryset() - # todo: change to the user.is_unicef - if UNICEFUser.name not in [g.name for g in self.request.user.groups.all()]: + if not self.request.user.is_unicef_user(): # we should hide activities before assignment # if reject reason available activity should be visible (draft + reject_reason = rejected) queryset = queryset.filter( Q(visit_lead=self.request.user) | Q(team_members=self.request.user), Q(status__in=MonitoringActivity.TPM_AVAILABLE_STATUSES) | ~Q(reject_reason=''), + tpm_partner__organization=self.request.user.profile.organization, ) return queryset @@ -270,21 +273,33 @@ class FMUsersViewSet( filter_backends = (SearchFilter, UserTypeFilter, UserTPMPartnerFilter) search_fields = ('email',) - queryset = get_user_model().objects.select_related('profile__organization__tpmpartner') - queryset = queryset.order_by('first_name', 'middle_name', 'last_name') + queryset = get_user_model().objects.all().order_by('first_name', 'middle_name', 'last_name') serializer_class = FMUserSerializer def get_queryset(self): - user = self.request.user - qs = super().get_queryset().filter( - Q(profile__country=user.profile.country) | Q(monitoring_activities__isnull=False) - ).order_by('first_name').distinct() + user_groups = [UNICEFUser.name, ThirdPartyMonitor.name] + qs_context = { + "country": connection.tenant, + "group__name__in": user_groups + } + if not self.request.user.is_unicef_user(): + qs_context.update({"organization": self.request.user.profile.organization}) + + context_realms_qs = Realm.objects.filter(**qs_context).select_related('organization__tpmpartner') + + qs = super().get_queryset()\ + .filter(realms__in=context_realms_qs) \ + .prefetch_related(Prefetch('realms', queryset=context_realms_qs)) \ + .annotate(tpm_partner=F('realms__organization__tpmpartner'), + has_active_realm=F('realms__is_active')) \ + .distinct() return qs class CPOutputsViewSet( FMBaseViewSet, + EmptyQuerysetForExternal, mixins.ListModelMixin, viewsets.GenericViewSet, ): @@ -296,6 +311,7 @@ class CPOutputsViewSet( class InterventionsViewSet( FMBaseViewSet, + EmptyQuerysetForExternal, mixins.ListModelMixin, viewsets.GenericViewSet, ): @@ -312,6 +328,7 @@ class InterventionsViewSet( class PartnersViewSet( FMBaseViewSet, + EmptyQuerysetForExternal, mixins.ListModelMixin, viewsets.GenericViewSet, ): diff --git a/src/etools/applications/hact/tasks.py b/src/etools/applications/hact/tasks.py index bb2c86757..9b6acc4d0 100644 --- a/src/etools/applications/hact/tasks.py +++ b/src/etools/applications/hact/tasks.py @@ -101,10 +101,10 @@ def notify_hact_update(partner_list, country_id): 'environment': get_environment(), } recipients = get_user_model().objects.filter( - groups=UNICEFAuditFocalPoint.as_group(), + realms__group=UNICEFAuditFocalPoint.as_group(), realms__country__id=country_id, is_superuser=False, - ).values_list('email', flat=True) + ).values_list('email', flat=True).distinct() send_notification_with_template( recipients=list(recipients), template_name='partners/hact_updated', diff --git a/src/etools/applications/hact/tests/test_tasks.py b/src/etools/applications/hact/tests/test_tasks.py index 3d46f2d00..9dee4ffbb 100644 --- a/src/etools/applications/hact/tests/test_tasks.py +++ b/src/etools/applications/hact/tests/test_tasks.py @@ -1,11 +1,14 @@ from unittest.mock import Mock, patch +from etools.applications.audit.models import UNICEFAuditFocalPoint from etools.applications.core.tests.cases import BaseTenantTestCase from etools.applications.hact.models import AggregateHact from etools.applications.hact.tasks import update_aggregate_hact_values, update_hact_for_country, update_hact_values from etools.applications.hact.tests.factories import AggregateHactFactory from etools.applications.organizations.tests.factories import OrganizationFactory +from etools.applications.partners.permissions import UNICEF_USER from etools.applications.partners.tests.factories import PartnerFactory +from etools.applications.users.tests.factories import UserFactory from etools.applications.vision.models import VisionSyncLog @@ -40,6 +43,71 @@ def test_task_create(self): self.assertEqual(log.total_processed, 1) self.assertTrue(log.successful) + def test_task_update(self): + logs = VisionSyncLog.objects.all() + self.assertEqual(logs.count(), 0) + partner = PartnerFactory(organization=OrganizationFactory(name="Partner XYZ"), reported_cy=20000) + unicef_focal_user = UserFactory( + is_staff=True, realms__data=[UNICEF_USER, UNICEFAuditFocalPoint.name] + ) + update_hact_for_country(self.tenant.business_area_code) + self.assertEqual(logs.count(), 1) + + partner.hact_values = { + "outstanding_findings": 0, + "audits": { + "completed": 0, + "minimum_requirements": 1 + }, + "programmatic_visits": { + "completed": { + "q1": 0, + "total": 0, + "q3": 0, + "q2": 0, + "q4": 0 + }, + "planned": { + "q1": 0, + "total": 0, + "q3": 0, + "q2": 0, + "q4": 0 + }, + "minimum_requirements": 2 + }, + "spot_checks": { + "completed": { + "q1": 0, + "total": 0, + "q3": 0, + "q2": 0, + "q4": 0 + }, + "planned": { + "q1": 0, + "total": 0, + "q3": 0, + "q2": 0, + "q4": 0 + }, + "follow_up_required": 0, + "minimum_requirements": 3 + } + } + partner.save(update_fields=['hact_values']) + + mock_send = Mock() + with patch("etools.applications.hact.tasks.send_notification_with_template", mock_send): + update_hact_for_country(self.tenant.business_area_code) + self.assertEqual(mock_send.call_count, 1) + self.assertEqual(mock_send.call_args.kwargs['recipients'], [unicef_focal_user.email]) + + log = logs.first() + self.assertEqual(log.total_records, 1) + self.assertEqual(log.total_processed, 1) + self.assertTrue(log.successful) + class TestUpdateHactValues(BaseTenantTestCase): diff --git a/src/etools/applications/locations/admin.py b/src/etools/applications/locations/admin.py index 39f0e7c26..f6ab544db 100644 --- a/src/etools/applications/locations/admin.py +++ b/src/etools/applications/locations/admin.py @@ -3,7 +3,7 @@ from admin_extra_urls.decorators import button from celery import chain -from unicef_locations.admin import CartoDBTableAdmin, LocationAdmin +from unicef_locations.admin import ActiveLocationsFilter, CartoDBTableAdmin, LocationAdmin from unicef_locations.models import CartoDBTable from etools.applications.locations.models import Location @@ -22,6 +22,16 @@ def import_sites(self, request, pk): messages.info(request, 'Import Scheduled') +class eToolsLocationAdmin(LocationAdmin): + list_filter = ( + ActiveLocationsFilter, + "admin_level", + ) + + def get_queryset(self, request): + return super().get_queryset(request).defer("geom", "point") + + admin.site.unregister(CartoDBTable) admin.site.register(CartoDBTable, EtoolsCartoDBTableAdmin) -admin.site.register(Location, LocationAdmin) +admin.site.register(Location, eToolsLocationAdmin) diff --git a/src/etools/applications/partners/exports_v2.py b/src/etools/applications/partners/exports_v2.py index 324a85f67..73fbc2959 100644 --- a/src/etools/applications/partners/exports_v2.py +++ b/src/etools/applications/partners/exports_v2.py @@ -979,9 +979,9 @@ def render_detailed_workplan_budget(self, worksheet): worksheet.append([ _('EEPM'), _('Effective and efficient programme management'), '', '', '', - currency_format(self.intervention.management_budgets.total), currency_format(self.intervention.management_budgets.partner_total), currency_format(self.intervention.management_budgets.unicef_total), + currency_format(self.intervention.management_budgets.total), ]) self.apply_styles_to_cells( worksheet, worksheet.max_row, 1, worksheet.max_row, total_columns, [self.fill_blue_pale_light] @@ -1009,7 +1009,9 @@ def render_detailed_workplan_budget(self, worksheet): worksheet.append([ _('EEPM.1.%d') % idx, item.name, - '', '', '', + item.unit, + item.no_units, + item.unit_price, currency_format(item.cso_cash), currency_format(item.unicef_cash), currency_format(item.unicef_cash + item.cso_cash), @@ -1034,7 +1036,9 @@ def render_detailed_workplan_budget(self, worksheet): worksheet.append([ _('EEPM.2.%d') % idx, item.name, - '', '', '', + item.unit, + item.no_units, + item.unit_price, currency_format(item.cso_cash), currency_format(item.unicef_cash), currency_format(item.unicef_cash + item.cso_cash), @@ -1059,7 +1063,9 @@ def render_detailed_workplan_budget(self, worksheet): worksheet.append([ _('EEPM.3.%d') % idx, item.name, - '', '', '', + item.unit, + item.no_units, + item.unit_price, currency_format(item.cso_cash), currency_format(item.unicef_cash), currency_format(item.unicef_cash + item.cso_cash), @@ -1071,10 +1077,10 @@ def render_detailed_workplan_budget(self, worksheet): worksheet.append([ _('Total Cost for all outputs'), '', '', '', '', - currency_format(self.intervention.planned_budget.partner_contribution_local + - self.intervention.planned_budget.total_unicef_cash_local_wo_hq), currency_format(self.intervention.planned_budget.partner_contribution_local), currency_format(self.intervention.planned_budget.total_unicef_cash_local_wo_hq), + currency_format(self.intervention.planned_budget.partner_contribution_local + + self.intervention.planned_budget.total_unicef_cash_local_wo_hq), ]) self.apply_styles_to_cells( worksheet, diff --git a/src/etools/applications/partners/filters.py b/src/etools/applications/partners/filters.py index 35a920946..65fba7e3a 100644 --- a/src/etools/applications/partners/filters.py +++ b/src/etools/applications/partners/filters.py @@ -15,7 +15,7 @@ def filter_queryset(self, request, queryset, view): realms__country=connection.tenant, realms__organization=partner.organization, realms__group__name__in=PARTNER_ACTIVE_GROUPS, - ) + ).distinct() return queryset diff --git a/src/etools/applications/partners/locale/ar/LC_MESSAGES/django.mo b/src/etools/applications/partners/locale/ar/LC_MESSAGES/django.mo index 6f3c814d1..38dc6f378 100644 Binary files a/src/etools/applications/partners/locale/ar/LC_MESSAGES/django.mo and b/src/etools/applications/partners/locale/ar/LC_MESSAGES/django.mo differ diff --git a/src/etools/applications/partners/locale/ar/LC_MESSAGES/django.po b/src/etools/applications/partners/locale/ar/LC_MESSAGES/django.po index 47ed71d84..a038efe83 100644 --- a/src/etools/applications/partners/locale/ar/LC_MESSAGES/django.po +++ b/src/etools/applications/partners/locale/ar/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-04-11 13:53+0000\n" +"POT-Creation-Date: 2023-05-18 09:35+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -1152,6 +1152,9 @@ msgstr "التدخل المعدل" msgid "Intervention amendments" msgstr "تعديلات التدخل" +msgid "Q1" +msgstr "" + msgid "Intervention" msgstr "تدخل" @@ -1322,7 +1325,7 @@ msgid "Sent Back by Secretary Comment" msgstr "تم الإرسال مرة أخرى بسبب تعليق السكرتير" msgid "FACE" -msgstr "إستمارة الموافقه على التمويل وشهادة الصرف" +msgstr "" msgid "Progress Report" msgstr "تقرير التطور" @@ -1604,6 +1607,9 @@ msgstr "لا يمكن إضافة تعديل جديد أثناء حظر الشر msgid "Other description required, if type 'Other' selected." msgstr "مطلوب وصف آخر ، إذا تم تحديد نوع \"أخرى\"." +msgid "Inactive" +msgstr "غير نشط" + msgid "Planned Visit cannot be set for Terminated interventions" msgstr "لا يمكن تعيين الزيارة المخططة للتدخلات المنتهية" @@ -2252,19 +2258,6 @@ msgstr "لا يمكن حذف تقييم مكتمل" msgid "No vendor number provided for Partner Organization" msgstr "لم يتم توفير رقم البائع للمؤسسة الشريكة" -msgid "" -"There was a PCA/SSFA signed with this partner or a transaction was performed " -"against this partner. The Partner record cannot be deleted" -msgstr "" -"كان هناك PCA / SSFA موقعة مع هذا الشريك أو تم إجراء معاملة ضد هذا الشريك. لا " -"يمكن حذف سجل الشريك" - -msgid "This partner has trips associated to it" -msgstr "هذا الشريك لديه رحلات مرتبطة به" - -msgid "This partner has cash transactions associated to it" -msgstr "هذا الشريك لديه معاملات نقدية مرتبطة به" - msgid "Terms to be acknowledged" msgstr "يجب الاعتراف بالشروط" @@ -2288,6 +2281,19 @@ msgstr "لا تحتوي الاستجابة التي أرجعها الخادم ع msgid "Partnership Manager role required for pca export." msgstr "دور مدير الشراكة المطلوب لتصدير pca." +#~ msgid "" +#~ "There was a PCA/SSFA signed with this partner or a transaction was " +#~ "performed against this partner. The Partner record cannot be deleted" +#~ msgstr "" +#~ "كان هناك PCA / SSFA موقعة مع هذا الشريك أو تم إجراء معاملة ضد هذا الشريك. " +#~ "لا يمكن حذف سجل الشريك" + +#~ msgid "This partner has trips associated to it" +#~ msgstr "هذا الشريك لديه رحلات مرتبطة به" + +#~ msgid "This partner has cash transactions associated to it" +#~ msgstr "هذا الشريك لديه معاملات نقدية مرتبطة به" + #~ msgid "You must select a type for this CSO" #~ msgstr "يجب عليك تحديد نوع CSO هذا" diff --git a/src/etools/applications/partners/locale/es/LC_MESSAGES/django.mo b/src/etools/applications/partners/locale/es/LC_MESSAGES/django.mo index b7d34448c..e0bd00d60 100644 Binary files a/src/etools/applications/partners/locale/es/LC_MESSAGES/django.mo and b/src/etools/applications/partners/locale/es/LC_MESSAGES/django.mo differ diff --git a/src/etools/applications/partners/locale/es/LC_MESSAGES/django.po b/src/etools/applications/partners/locale/es/LC_MESSAGES/django.po index e6fa1ef95..d1b4856d5 100644 --- a/src/etools/applications/partners/locale/es/LC_MESSAGES/django.po +++ b/src/etools/applications/partners/locale/es/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: et-partners2-bk\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-04-11 13:53+0000\n" +"POT-Creation-Date: 2023-05-18 09:35+0000\n" "Language: es\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -1149,6 +1149,9 @@ msgstr "Intervención modificada" msgid "Intervention amendments" msgstr "Enmiendas de intervención" +msgid "Q1" +msgstr "" + msgid "Intervention" msgstr "Intervención" @@ -1321,7 +1324,7 @@ msgid "Sent Back by Secretary Comment" msgstr "Enviado por el Secretario Comentario" msgid "FACE" -msgstr "Cara" +msgstr "" msgid "Progress Report" msgstr "Informe de situación" @@ -1613,6 +1616,9 @@ msgstr "" msgid "Other description required, if type 'Other' selected." msgstr "Se requiere otra descripción, si se selecciona el tipo 'Otro'." +msgid "Inactive" +msgstr "Inactivo" + msgid "Planned Visit cannot be set for Terminated interventions" msgstr "" "La visita planificada no se puede configurar para las intervenciones " @@ -2313,19 +2319,6 @@ msgstr "No se puede eliminar una evaluación completa" msgid "No vendor number provided for Partner Organization" msgstr "No se proporcionó un número de proveedor para la organización asociada" -msgid "" -"There was a PCA/SSFA signed with this partner or a transaction was performed " -"against this partner. The Partner record cannot be deleted" -msgstr "" -"Se firmó un PCA/SSFA con este socio o se realizó una transacción contra este " -"socio. El registro de socio no se puede eliminar" - -msgid "This partner has trips associated to it" -msgstr "Este socio tiene viajes asociados" - -msgid "This partner has cash transactions associated to it" -msgstr "Este socio tiene transacciones en efectivo asociadas" - msgid "Terms to be acknowledged" msgstr "Términos a reconocer" @@ -2355,6 +2348,19 @@ msgid "Partnership Manager role required for pca export." msgstr "" "Se requiere el rol de Gerente de asociación para la exportación de PCA." +#~ msgid "" +#~ "There was a PCA/SSFA signed with this partner or a transaction was " +#~ "performed against this partner. The Partner record cannot be deleted" +#~ msgstr "" +#~ "Se firmó un PCA/SSFA con este socio o se realizó una transacción contra " +#~ "este socio. El registro de socio no se puede eliminar" + +#~ msgid "This partner has trips associated to it" +#~ msgstr "Este socio tiene viajes asociados" + +#~ msgid "This partner has cash transactions associated to it" +#~ msgstr "Este socio tiene transacciones en efectivo asociadas" + #~ msgid "You must select a type for this CSO" #~ msgstr "Debe seleccionar un tipo para este CSO" diff --git a/src/etools/applications/partners/locale/fr/LC_MESSAGES/django.mo b/src/etools/applications/partners/locale/fr/LC_MESSAGES/django.mo index dbff996c0..bef2a9ce3 100644 Binary files a/src/etools/applications/partners/locale/fr/LC_MESSAGES/django.mo and b/src/etools/applications/partners/locale/fr/LC_MESSAGES/django.mo differ diff --git a/src/etools/applications/partners/locale/fr/LC_MESSAGES/django.po b/src/etools/applications/partners/locale/fr/LC_MESSAGES/django.po index 676f4a725..38db2e053 100644 --- a/src/etools/applications/partners/locale/fr/LC_MESSAGES/django.po +++ b/src/etools/applications/partners/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-04-11 13:53+0000\n" +"POT-Creation-Date: 2023-05-18 09:35+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -911,7 +911,7 @@ msgid "Memorandum of Understanding" msgstr "Protocole d'accord" msgid "Draft" -msgstr "Draft" +msgstr "Développement" msgid "Signed" msgstr "Signé" @@ -1159,6 +1159,9 @@ msgstr "Intervention modifiée" msgid "Intervention amendments" msgstr "Modifications de l'intervention" +msgid "Q1" +msgstr "" + msgid "Intervention" msgstr "Intervention" @@ -1332,7 +1335,7 @@ msgid "Sent Back by Secretary Comment" msgstr "Renvoyé par le secrétaire Commentaire" msgid "FACE" -msgstr "Visage" +msgstr "" msgid "Progress Report" msgstr "Rapport d'activité" @@ -1624,6 +1627,9 @@ msgstr "" msgid "Other description required, if type 'Other' selected." msgstr "Autre description requise, si le type \"Autre\" est sélectionné." +msgid "Inactive" +msgstr "Inactif" + msgid "Planned Visit cannot be set for Terminated interventions" msgstr "" "La visite planifiée ne peut pas être définie pour les interventions terminées" @@ -1722,7 +1728,7 @@ msgid "" "A record that starts in the passed cannot be modified on a non-draft PD/SPD" msgstr "" "Un enregistrement commençant par le passé ne peut pas être modifié sur un PD/" -"SPD non brouillon" +"SPD non-développement" msgid "" "Invalid budget data. Total cash should be equal to items number * price per " @@ -1932,7 +1938,7 @@ msgid "" "the record is in \"Draft\" status please edit that record." msgstr "" "Un PCA avec ce partenaire existe déjà pour ce cycle de programme de pays. Si " -"l'enregistrement est à l'état \"Brouillon\", veuillez le modifier." +"l'enregistrement est à l'état \"Développement\", veuillez le modifier." msgid "" "Agreement start date needs to be earlier than or the same as the end date" @@ -2016,9 +2022,9 @@ msgid "" "Programme Document will not change status until the related PCA is in Signed " "status" msgstr "" -"L'APC lié à ce document est en projet, suspendu ou terminé. Ce document de " -"programme ne changera pas de statut tant que l'APC correspondant n'aura pas " -"le statut Signé." +"L'APC lié à ce document est en Développement, Suspendu ou Terminé. Ce " +"document de programme ne changera pas de statut tant que l'APC correspondant " +"n'aura pas le statut Signé." msgid "PD cannot transition to signed if the Partner is Blocked in Vision" msgstr "" @@ -2151,7 +2157,7 @@ msgid "" "Cannot delete an agreement that is not Draft or has PDs/SSFAs associated " "with it" msgstr "" -"Impossible de supprimer un accord qui n'est pas un brouillon ou qui est " +"Impossible de supprimer un accord qui n'est pas un Développement ou qui est " "associé à des PD/SSFA" msgid "Workspace is required as a queryparam" @@ -2167,7 +2173,8 @@ msgstr "Les valeurs d'ID doivent être des nombres entiers" msgid "Deleting an attachment can only be done in Draft status" msgstr "" -"La suppression d'une pièce jointe ne peut être effectuée qu'en état Brouillon" +"La suppression d'une pièce jointe ne peut être effectuée qu'en état " +"Développement" msgid "" "This PD Output has indicators related, please remove the indicators first" @@ -2184,10 +2191,11 @@ msgstr "" "pour continuer" msgid "Deleting an indicator is only possible in status Draft." -msgstr "La suppression d'un indicateur n'est possible qu'en état Brouillon." +msgstr "" +"La suppression d'un indicateur n'est possible qu'en état Développement." msgid "Cannot delete a PD or SSFA that is not Draft" -msgstr "Impossible de supprimer un PD ou SSFA qui n'est pas un brouillon" +msgstr "Impossible de supprimer un PD ou SSFA qui n'est pas un Développement" msgid "Cannot delete a PD or SSFA that has Planned Trips" msgstr "Impossible de supprimer un PD ou SSFA qui a des trajets planifiés" @@ -2195,7 +2203,7 @@ msgstr "Impossible de supprimer un PD ou SSFA qui a des trajets planifiés" msgid "Cannot delete a PD or SSFA that was manually moved back to Draft" msgstr "" "Impossible de supprimer un PD ou une SSFA qui a été déplacé manuellement " -"vers le brouillon" +"vers le Développement" msgid "PD has already been sent to Partner." msgstr "PD a déjà été envoyé au partenaire." @@ -2327,20 +2335,6 @@ msgstr "Impossible de supprimer une évaluation terminée" msgid "No vendor number provided for Partner Organization" msgstr "Aucun numéro de fournisseur fourni pour l'organisation partenaire" -msgid "" -"There was a PCA/SSFA signed with this partner or a transaction was performed " -"against this partner. The Partner record cannot be deleted" -msgstr "" -"Un PCA/SSFA a été signé avec ce partenaire ou une transaction a été " -"effectuée contre ce partenaire. L'enregistrement de partenaire ne peut pas " -"être supprimé" - -msgid "This partner has trips associated to it" -msgstr "Ce partenaire est associé à des voyages" - -msgid "This partner has cash transactions associated to it" -msgstr "Ce partenaire est associé à des transactions en espèces" - msgid "Terms to be acknowledged" msgstr "Termes à reconnaître" @@ -2409,3 +2403,17 @@ msgstr "Rôle de gestionnaire de partenariat requis pour l'exportation pca." #~ msgstr "" #~ "Le \"Type d'OSC\" ne s'applique pas aux organisations non OSC, veuillez " #~ "supprimer le type." + +#~ msgid "" +#~ "There was a PCA/SSFA signed with this partner or a transaction was " +#~ "performed against this partner. The Partner record cannot be deleted" +#~ msgstr "" +#~ "Un PCA/SSFA a été signé avec ce partenaire ou une transaction a été " +#~ "effectuée contre ce partenaire. L'enregistrement de partenaire ne peut " +#~ "pas être supprimé" + +#~ msgid "This partner has trips associated to it" +#~ msgstr "Ce partenaire est associé à des voyages" + +#~ msgid "This partner has cash transactions associated to it" +#~ msgstr "Ce partenaire est associé à des transactions en espèces" diff --git a/src/etools/applications/partners/locale/pt/LC_MESSAGES/django.mo b/src/etools/applications/partners/locale/pt/LC_MESSAGES/django.mo index ec6719d09..195fb2f00 100644 Binary files a/src/etools/applications/partners/locale/pt/LC_MESSAGES/django.mo and b/src/etools/applications/partners/locale/pt/LC_MESSAGES/django.mo differ diff --git a/src/etools/applications/partners/locale/pt/LC_MESSAGES/django.po b/src/etools/applications/partners/locale/pt/LC_MESSAGES/django.po index 05a36076f..9f27cf63a 100644 --- a/src/etools/applications/partners/locale/pt/LC_MESSAGES/django.po +++ b/src/etools/applications/partners/locale/pt/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-04-11 13:53+0000\n" +"POT-Creation-Date: 2023-05-18 09:35+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -1169,6 +1169,9 @@ msgstr "Intervenção alterada" msgid "Intervention amendments" msgstr "Aditivos da implementação do programa" +msgid "Q1" +msgstr "" + msgid "Intervention" msgstr "Intervenção" @@ -1344,7 +1347,7 @@ msgid "Sent Back by Secretary Comment" msgstr "Enviado para comentário do(a) secretário(a)" msgid "FACE" -msgstr "FACE" +msgstr "" msgid "Progress Report" msgstr "Relatório de progresso" @@ -1638,6 +1641,9 @@ msgstr "" msgid "Other description required, if type 'Other' selected." msgstr "Outra descrição necessária, se o tipo 'Outro' for selecionado." +msgid "Inactive" +msgstr "Inativo" + msgid "Planned Visit cannot be set for Terminated interventions" msgstr "Visita planejada não pode ser definida para intervenções encerradas" @@ -2336,19 +2342,6 @@ msgstr "Não é possível excluir uma avaliação concluída" msgid "No vendor number provided for Partner Organization" msgstr "Nenhum número de fornecedor fornecido para organização parceira" -msgid "" -"There was a PCA/SSFA signed with this partner or a transaction was performed " -"against this partner. The Partner record cannot be deleted" -msgstr "" -"Houve um PCA/SSFA assinado com este parceiro ou uma transação foi realizada " -"contra este parceiro. O registro do parceiro não pode ser excluído" - -msgid "This partner has trips associated to it" -msgstr "Este parceiro tem viagens associadas" - -msgid "This partner has cash transactions associated to it" -msgstr "Este parceiro tem transações em dinheiro associadas a ele" - msgid "Terms to be acknowledged" msgstr "Termos a serem reconhecidos" @@ -2429,3 +2422,17 @@ msgstr "A função de gerente de parceria é necessária para a exportação de #~ msgid "Red Cross/Red Crescent National Societies" #~ msgstr "Sociedades Nacionais da Cruz Vermelha/Crescente Vermelho" + +#~ msgid "" +#~ "There was a PCA/SSFA signed with this partner or a transaction was " +#~ "performed against this partner. The Partner record cannot be deleted" +#~ msgstr "" +#~ "Houve um PCA/SSFA assinado com este parceiro ou uma transação foi " +#~ "realizada contra este parceiro. O registro do parceiro não pode ser " +#~ "excluído" + +#~ msgid "This partner has trips associated to it" +#~ msgstr "Este parceiro tem viagens associadas" + +#~ msgid "This partner has cash transactions associated to it" +#~ msgstr "Este parceiro tem transações em dinheiro associadas a ele" diff --git a/src/etools/applications/partners/locale/ru/LC_MESSAGES/django.mo b/src/etools/applications/partners/locale/ru/LC_MESSAGES/django.mo index c73e8e574..87a383707 100644 Binary files a/src/etools/applications/partners/locale/ru/LC_MESSAGES/django.mo and b/src/etools/applications/partners/locale/ru/LC_MESSAGES/django.mo differ diff --git a/src/etools/applications/partners/locale/ru/LC_MESSAGES/django.po b/src/etools/applications/partners/locale/ru/LC_MESSAGES/django.po index 84424ddd6..147808492 100644 --- a/src/etools/applications/partners/locale/ru/LC_MESSAGES/django.po +++ b/src/etools/applications/partners/locale/ru/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-04-11 13:53+0000\n" +"POT-Creation-Date: 2023-05-18 09:35+0000\n" "Language: ru\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -1151,6 +1151,9 @@ msgstr "Программа с поправками" msgid "Intervention amendments" msgstr "Поправки к программе" +msgid "Q1" +msgstr "" + msgid "Intervention" msgstr "Программа" @@ -1323,7 +1326,7 @@ msgid "Sent Back by Secretary Comment" msgstr "Возвращено с комментарием секретаря" msgid "FACE" -msgstr "УФСР" +msgstr "" msgid "Progress Report" msgstr "Отчет о проделанной работе" @@ -1613,6 +1616,9 @@ msgstr "" msgid "Other description required, if type 'Other' selected." msgstr "Требуется другое описание, если выбран тип «Другое»." +msgid "Inactive" +msgstr "Неактивный" + msgid "Planned Visit cannot be set for Terminated interventions" msgstr "" "Запланированный визит не может быть установлен для прекращенных вмешательств" @@ -2299,19 +2305,6 @@ msgstr "Невозможно удалить завершенную оценку" msgid "No vendor number provided for Partner Organization" msgstr "Не указан номер поставщика для партнерской организации" -msgid "" -"There was a PCA/SSFA signed with this partner or a transaction was performed " -"against this partner. The Partner record cannot be deleted" -msgstr "" -"С этим партнером было подписано соглашение PCA/SSFA или с ним была проведена " -"транзакция. Запись партнера не может быть удалена" - -msgid "This partner has trips associated to it" -msgstr "С этим партнером связаны поездки" - -msgid "This partner has cash transactions associated to it" -msgstr "С этим партнером связаны операции с наличными" - msgid "Terms to be acknowledged" msgstr "Условия, которые необходимо принять" @@ -2379,3 +2372,16 @@ msgstr "Для экспорта PCA требуется роль менеджер #~ msgstr "" #~ "Партнерский сотрудник, которого вы пытаетесь активировать, связан с " #~ "другой партнерской организацией." + +#~ msgid "" +#~ "There was a PCA/SSFA signed with this partner or a transaction was " +#~ "performed against this partner. The Partner record cannot be deleted" +#~ msgstr "" +#~ "С этим партнером было подписано соглашение PCA/SSFA или с ним была " +#~ "проведена транзакция. Запись партнера не может быть удалена" + +#~ msgid "This partner has trips associated to it" +#~ msgstr "С этим партнером связаны поездки" + +#~ msgid "This partner has cash transactions associated to it" +#~ msgstr "С этим партнером связаны операции с наличными" diff --git a/src/etools/applications/partners/migrations/0111_auto_20230315_0932.py b/src/etools/applications/partners/migrations/0111_auto_20230315_0932.py new file mode 100644 index 000000000..b687bef24 --- /dev/null +++ b/src/etools/applications/partners/migrations/0111_auto_20230315_0932.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.6 on 2023-03-15 09:32 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('field_monitoring_settings', '0009_merge_0008_alter_option_label_0008_auto_20210108_1634'), + ('partners', '0110_intervention_other_details'), + ] + + operations = [ + migrations.CreateModel( + name='InterventionPlannedVisitSite', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quarter', models.PositiveSmallIntegerField(choices=[(1, 'Q1'), (2, 'Q2'), (3, 'Q3'), (4, 'Q4')])), + ('planned_visits', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='partners.interventionplannedvisits')), + ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='field_monitoring_settings.locationsite')), + ], + ), + migrations.AddField( + model_name='interventionplannedvisits', + name='sites', + field=models.ManyToManyField(blank=True, through='partners.InterventionPlannedVisitSite', to='field_monitoring_settings.LocationSite', verbose_name='Sites'), + ), + migrations.AlterUniqueTogether( + name='interventionplannedvisitsite', + unique_together={('planned_visits', 'site', 'quarter')}, + ), + ] diff --git a/src/etools/applications/partners/migrations/0112_auto_20230418_1346.py b/src/etools/applications/partners/migrations/0112_auto_20230418_1346.py new file mode 100644 index 000000000..1e7fc5ce4 --- /dev/null +++ b/src/etools/applications/partners/migrations/0112_auto_20230418_1346.py @@ -0,0 +1,47 @@ +# Generated by Django 3.2.6 on 2023-04-18 13:46 +import json + +from django.db import migrations +from django.utils import timezone + + +def update_audits_completed(apps, schema_editor): + PartnerOrganization = apps.get_model('partners', 'PartnerOrganization') + Audit = apps.get_model('audit', 'Audit') + SpecialAudit = apps.get_model('audit', 'SpecialAudit') + + for partner in PartnerOrganization.objects.all(): + if isinstance(partner.hact_values, str): + partner.hact_values = json.loads(partner.hact_values) + + if 'audits' not in partner.hact_values: + continue + + if 'completed' not in partner.hact_values['audits']: + continue + + audits = Audit.objects.filter( + partner=partner, + year_of_audit=timezone.now().year + ).exclude(status='cancelled') + + s_audits = SpecialAudit.objects.filter( + partner=partner, + year_of_audit=timezone.now().year + ).exclude(status='cancelled') + + completed_audit = audits.count() + s_audits.count() + partner.hact_values['audits']['completed'] = completed_audit + partner.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('partners', '0111_auto_20230315_0932'), + ('audit', '0024_audit_year_of_audit'), + ] + + operations = [ + migrations.RunPython(update_audits_completed, migrations.RunPython.noop), + ] diff --git a/src/etools/applications/partners/migrations/0113_alter_intervention_title.py b/src/etools/applications/partners/migrations/0113_alter_intervention_title.py new file mode 100644 index 000000000..db842dcb7 --- /dev/null +++ b/src/etools/applications/partners/migrations/0113_alter_intervention_title.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.6 on 2023-05-02 14:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('partners', '0112_auto_20230418_1346'), + ] + + operations = [ + migrations.AlterField( + model_name='intervention', + name='title', + field=models.CharField(max_length=306, verbose_name='Document Title'), + ), + ] diff --git a/src/etools/applications/partners/migrations/0111_partnerorganization_organization.py b/src/etools/applications/partners/migrations/0113_partnerorganization_organization.py similarity index 97% rename from src/etools/applications/partners/migrations/0111_partnerorganization_organization.py rename to src/etools/applications/partners/migrations/0113_partnerorganization_organization.py index 92b09a278..87eca3da5 100644 --- a/src/etools/applications/partners/migrations/0111_partnerorganization_organization.py +++ b/src/etools/applications/partners/migrations/0113_partnerorganization_organization.py @@ -37,7 +37,7 @@ class Migration(migrations.Migration): dependencies = [ ('organizations', '0001_initial'), - ('partners', '0110_intervention_other_details'), + ('partners', '0112_auto_20230418_1346'), ] operations = [ diff --git a/src/etools/applications/partners/migrations/0112_remove_partner_org_fields.py b/src/etools/applications/partners/migrations/0114_remove_partner_org_fields.py similarity index 92% rename from src/etools/applications/partners/migrations/0112_remove_partner_org_fields.py rename to src/etools/applications/partners/migrations/0114_remove_partner_org_fields.py index b6cf5eb55..8d803d45a 100644 --- a/src/etools/applications/partners/migrations/0112_remove_partner_org_fields.py +++ b/src/etools/applications/partners/migrations/0114_remove_partner_org_fields.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ('partners', '0111_partnerorganization_organization'), + ('partners', '0113_partnerorganization_organization'), ] operations = [ diff --git a/src/etools/applications/partners/migrations/0113_alter_partnerorganization_organization.py b/src/etools/applications/partners/migrations/0115_alter_partnerorganization_organization.py similarity index 91% rename from src/etools/applications/partners/migrations/0113_alter_partnerorganization_organization.py rename to src/etools/applications/partners/migrations/0115_alter_partnerorganization_organization.py index 382d36714..bef3f742d 100644 --- a/src/etools/applications/partners/migrations/0113_alter_partnerorganization_organization.py +++ b/src/etools/applications/partners/migrations/0115_alter_partnerorganization_organization.py @@ -8,7 +8,7 @@ class Migration(migrations.Migration): dependencies = [ ('organizations', '0001_initial'), - ('partners', '0112_remove_partner_org_fields'), + ('partners', '0114_remove_partner_org_fields'), ] operations = [ diff --git a/src/etools/applications/partners/migrations/0114_realms_partnerstaffmembers.py b/src/etools/applications/partners/migrations/0116_realms_partnerstaffmembers.py similarity index 97% rename from src/etools/applications/partners/migrations/0114_realms_partnerstaffmembers.py rename to src/etools/applications/partners/migrations/0116_realms_partnerstaffmembers.py index 424ce7331..49172bae4 100644 --- a/src/etools/applications/partners/migrations/0114_realms_partnerstaffmembers.py +++ b/src/etools/applications/partners/migrations/0116_realms_partnerstaffmembers.py @@ -75,7 +75,7 @@ def migrate_partnerstaffmembers(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('partners', '0113_alter_partnerorganization_organization'), + ('partners', '0115_alter_partnerorganization_organization'), ('users', '0021_migrate_to_realms'), ('organizations', '0001_initial'), ] diff --git a/src/etools/applications/partners/migrations/0115_auto_20221102_1045.py b/src/etools/applications/partners/migrations/0117_auto_20221102_1045.py similarity index 99% rename from src/etools/applications/partners/migrations/0115_auto_20221102_1045.py rename to src/etools/applications/partners/migrations/0117_auto_20221102_1045.py index c710c60c9..15043d63b 100644 --- a/src/etools/applications/partners/migrations/0115_auto_20221102_1045.py +++ b/src/etools/applications/partners/migrations/0117_auto_20221102_1045.py @@ -10,7 +10,7 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('partners', '0114_realms_partnerstaffmembers'), + ('partners', '0116_realms_partnerstaffmembers'), ] operations = [ diff --git a/src/etools/applications/partners/migrations/0116_staff_members_to_users.py b/src/etools/applications/partners/migrations/0118_staff_members_to_users.py similarity index 97% rename from src/etools/applications/partners/migrations/0116_staff_members_to_users.py rename to src/etools/applications/partners/migrations/0118_staff_members_to_users.py index c9b108dcc..dc04a934f 100644 --- a/src/etools/applications/partners/migrations/0116_staff_members_to_users.py +++ b/src/etools/applications/partners/migrations/0118_staff_members_to_users.py @@ -46,7 +46,7 @@ def migrate_staff_members_to_users(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('partners', '0115_auto_20221102_1045'), + ('partners', '0117_auto_20221102_1045'), ] operations = [ diff --git a/src/etools/applications/partners/migrations/0117_auto_20221107_1015.py b/src/etools/applications/partners/migrations/0119_auto_20221107_1015.py similarity index 94% rename from src/etools/applications/partners/migrations/0117_auto_20221107_1015.py rename to src/etools/applications/partners/migrations/0119_auto_20221107_1015.py index 7f2a900e3..dbeb19010 100644 --- a/src/etools/applications/partners/migrations/0117_auto_20221107_1015.py +++ b/src/etools/applications/partners/migrations/0119_auto_20221107_1015.py @@ -8,7 +8,7 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('partners', '0116_staff_members_to_users'), + ('partners', '0118_staff_members_to_users'), ] operations = [ diff --git a/src/etools/applications/partners/migrations/0120_merge_20230502_1523.py b/src/etools/applications/partners/migrations/0120_merge_20230502_1523.py new file mode 100644 index 000000000..12e4fe31f --- /dev/null +++ b/src/etools/applications/partners/migrations/0120_merge_20230502_1523.py @@ -0,0 +1,14 @@ +# Generated by Django 3.2.6 on 2023-05-02 15:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('partners', '0113_alter_intervention_title'), + ('partners', '0119_auto_20221107_1015'), + ] + + operations = [ + ] diff --git a/src/etools/applications/partners/migrations/0121_auto_20230607_0736.py b/src/etools/applications/partners/migrations/0121_auto_20230607_0736.py new file mode 100644 index 000000000..4ed75ea53 --- /dev/null +++ b/src/etools/applications/partners/migrations/0121_auto_20230607_0736.py @@ -0,0 +1,43 @@ +# Generated by Django 3.2.6 on 2023-06-07 07:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('partners', '0120_merge_20230502_1523'), + ] + + operations = [ + migrations.AddField( + model_name='interventionbudget', + name='has_unfunded_cash', + field=models.BooleanField(default=False, verbose_name='Unfunded Cash'), + ), + migrations.AddField( + model_name='interventionbudget', + name='unfunded_cash_local', + field=models.DecimalField(decimal_places=2, default=0, max_digits=20, verbose_name='Unfunded HQ Cash Local'), + ), + migrations.AddField( + model_name='interventionmanagementbudget', + name='act1_unfunded', + field=models.DecimalField(decimal_places=2, default=0, max_digits=20, verbose_name='Unfunded amount for In-country management and support staff prorated to their contribution to the programme (representation, planning, coordination, logistics, administration, finance)'), + ), + migrations.AddField( + model_name='interventionmanagementbudget', + name='act2_unfunded', + field=models.DecimalField(decimal_places=2, default=0, max_digits=20, verbose_name='Unfunded amount for Operational costs prorated to their contribution to the programme (office space, equipment, office supplies, maintenance)'), + ), + migrations.AddField( + model_name='interventionmanagementbudget', + name='act3_unfunded', + field=models.DecimalField(decimal_places=2, default=0, max_digits=20, verbose_name='Unfunded amount for Planning, monitoring, evaluation and communication, prorated to their contribution to the programme (venue, travels, etc.)'), + ), + migrations.AddField( + model_name='interventionmanagementbudgetitem', + name='unfunded_cash', + field=models.DecimalField(decimal_places=2, default=0, max_digits=20, verbose_name='Unfunded Cash Local'), + ), + ] diff --git a/src/etools/applications/partners/models.py b/src/etools/applications/partners/models.py index c536b311a..886acd252 100644 --- a/src/etools/applications/partners/models.py +++ b/src/etools/applications/partners/models.py @@ -7,7 +7,7 @@ from django.contrib.postgres.fields import ArrayField from django.core.validators import MinValueValidator from django.db import connection, models, transaction -from django.db.models import Case, CharField, Count, F, Max, Min, OuterRef, Prefetch, Q, Subquery, Sum, When +from django.db.models import Case, CharField, Count, Exists, F, Max, Min, OuterRef, Prefetch, Q, Subquery, Sum, When from django.urls import reverse from django.utils import timezone from django.utils.functional import cached_property @@ -554,19 +554,26 @@ def cso_type(self): return self.organization.cso_type @cached_property - def all_staff_members(self): - return User.objects.filter( - realms__organization=self.organization, - realms__country=connection.tenant, - realms__group__name__in=PARTNER_ACTIVE_GROUPS, + def context_realms(self): + return Realm.objects.filter( + organization=self.organization, + country=connection.tenant, + group__name__in=PARTNER_ACTIVE_GROUPS, ) + @cached_property + def all_staff_members(self): + user_qs = User.objects.filter(realms__in=self.context_realms) + + return user_qs\ + .annotate(has_active_realm=Exists(self.context_realms.filter(user=OuterRef('pk'), is_active=True)))\ + .distinct() + @cached_property def active_staff_members(self): - return self.all_staff_members.filter( - is_active=True, - realms__is_active=True - ) + return self.all_staff_members\ + .filter(is_active=True, has_active_realm=True)\ + .distinct() def get_object_url(self): return reverse("partners_api:partner-detail", args=[self.pk]) @@ -875,12 +882,12 @@ def audits_completed(self): from etools.applications.audit.models import Audit, Engagement, SpecialAudit audits = Audit.objects.filter( partner=self, - date_of_draft_report_to_ip__year=datetime.datetime.now().year + year_of_audit=datetime.datetime.now().year ).exclude(status=Engagement.CANCELLED) s_audits = SpecialAudit.objects.filter( partner=self, - date_of_draft_report_to_ip__year=datetime.datetime.now().year + year_of_audit=datetime.datetime.now().year ).exclude(status=Engagement.CANCELLED) return audits, s_audits @@ -1675,6 +1682,7 @@ def detail_qs(self): 'management_budgets__items', 'flat_locations', 'sites', + 'planned_visits__sites', Prefetch('supply_items', queryset=InterventionSupplyItem.objects.order_by('-id')), ) @@ -1891,7 +1899,7 @@ class Intervention(TimeStampedModel): null=True, unique=True, ) - title = models.CharField(verbose_name=_("Document Title"), max_length=256) + title = models.CharField(verbose_name=_("Document Title"), max_length=306) status = FSMField( verbose_name=_("Status"), max_length=32, @@ -2904,6 +2912,11 @@ def merge_amendment(self): self.is_active = False self.save() + # TODO: Technical debt - remove after tempoorary exception for ended amendments is removed. + if self.intervention.status == self.intervention.ENDED: + if self.intervention.end >= datetime.date.today() >= self.intervention.start: + self.intervention.status = self.intervention.ACTIVE + self.intervention.save(amendment_number=self.intervention.amendments.filter(is_active=False).count()) amended_intervention.delete() @@ -2919,6 +2932,27 @@ def get_difference(self): ) +class InterventionPlannedVisitSite(models.Model): + Q1 = 1 + Q2 = 2 + Q3 = 3 + Q4 = 4 + + QUARTER_CHOICES = ( + (Q1, _('Q1')), + (Q2, _('Q2')), + (Q3, _('Q3')), + (Q4, _('Q4')), + ) + + planned_visits = models.ForeignKey('partners.InterventionPlannedVisits', on_delete=models.CASCADE) + site = models.ForeignKey('field_monitoring_settings.LocationSite', on_delete=models.CASCADE) + quarter = models.PositiveSmallIntegerField(choices=QUARTER_CHOICES) + + class Meta: + unique_together = ('planned_visits', 'site', 'quarter') + + class InterventionPlannedVisits(TimeStampedModel): """Represents planned visits for the intervention""" @@ -2931,6 +2965,12 @@ class InterventionPlannedVisits(TimeStampedModel): programmatic_q2 = models.IntegerField(default=0, verbose_name=_('Programmatic Q2')) programmatic_q3 = models.IntegerField(default=0, verbose_name=_('Programmatic Q3')) programmatic_q4 = models.IntegerField(default=0, verbose_name=_('Programmatic Q4')) + sites = models.ManyToManyField( + 'field_monitoring_settings.LocationSite', + through=InterventionPlannedVisitSite, + verbose_name=_('Sites'), + blank=True, + ) tracker = FieldTracker() @@ -2941,6 +2981,32 @@ class Meta: def __str__(self): return '{} {}'.format(self.intervention, self.year) + def programmatic_sites(self, quarter): + from etools.applications.field_monitoring.fm_settings.models import LocationSite + return LocationSite.objects.filter( + pk__in=InterventionPlannedVisitSite.objects.filter( + site__in=self.sites.all(), + planned_visits=self, + quarter=quarter + ).values_list('site', flat=True) + ) + + @property + def programmatic_q1_sites(self): + return self.programmatic_sites(InterventionPlannedVisitSite.Q1) + + @property + def programmatic_q2_sites(self): + return self.programmatic_sites(InterventionPlannedVisitSite.Q2) + + @property + def programmatic_q3_sites(self): + return self.programmatic_sites(InterventionPlannedVisitSite.Q3) + + @property + def programmatic_q4_sites(self): + return self.programmatic_sites(InterventionPlannedVisitSite.Q4) + class InterventionResultLink(TimeStampedModel): code = models.CharField(verbose_name=_("Code"), max_length=50, blank=True, null=True) @@ -3049,6 +3115,7 @@ class InterventionBudget(TimeStampedModel): verbose_name=_('UNICEF Supplies Local') ) has_unfunded_cash = models.BooleanField(verbose_name=_("Unfunded Cash"), default=False) + currency = CurrencyField(verbose_name=_('Currency'), null=False, default='') total_local = models.DecimalField(max_digits=20, decimal_places=2, verbose_name=_('Total Local')) programme_effectiveness = models.DecimalField( @@ -3316,7 +3383,7 @@ def send_notification(self): 'intervention_number': self.review.intervention.reference_number, 'meeting_date': self.review.meeting_date.strftime('%d-%m-%Y'), 'user_name': self.user.get_full_name(), - 'url': '{}{}'.format(settings.HOST, self.review.intervention.get_frontend_object_url(suffix='review')) + 'url': self.review.intervention.get_frontend_object_url(suffix='review') } send_notification_with_template( @@ -3588,6 +3655,12 @@ class InterventionManagementBudget(TimeStampedModel): max_digits=20, default=0, ) + act1_unfunded = models.DecimalField( + verbose_name=_("Unfunded amount for In-country management and support staff prorated to their contribution to the programme (representation, planning, coordination, logistics, administration, finance)"), + decimal_places=2, + max_digits=20, + default=0, + ) act2_unicef = models.DecimalField( verbose_name=_("UNICEF contribution for Operational costs prorated to their contribution to the programme (office space, equipment, office supplies, maintenance)"), decimal_places=2, @@ -3786,6 +3859,7 @@ class InterventionManagementBudgetItem(models.Model): max_digits=20, default=0, ) + unfunded_cash = models.DecimalField( verbose_name=_("Unfunded Cash Local"), decimal_places=2, diff --git a/src/etools/applications/partners/permission_matrix/intervention_permissions.csv b/src/etools/applications/partners/permission_matrix/intervention_permissions.csv index ad4435e55..e6d622ebf 100644 --- a/src/etools/applications/partners/permission_matrix/intervention_permissions.csv +++ b/src/etools/applications/partners/permission_matrix/intervention_permissions.csv @@ -11,6 +11,7 @@ Field no,Field Name,Group,Condition,Status,Action,Allowed 3.2.7,amendments,*,,Draft,View,FALSE 3.2.8,amendments,Unicef Focal Point,not_in_amendment_mode,Signed,Edit,TRUE 3.2.9,amendments,Unicef Focal Point,not_in_amendment_mode,Active,Edit,TRUE +3.2.9,amendments,Unicef Focal Point,post_epd_temp_conditions,Ended,Edit,TRUE 3.3.7,attachments,*,not_in_amendment_mode,*,View,TRUE 3.3.7,attachments,Unicef Focal Point,not_in_amendment_mode,Draft,Edit,TRUE 3.3.7,attachments,Partner User,not_in_amendment_mode,Draft,Edit,TRUE @@ -44,6 +45,8 @@ Field no,Field Name,Group,Condition,Status,Action,Allowed 3.3.3,cash_transfer_modalities,Unicef Focal Point,unlocked,Draft,Edit,TRUE 3.3.5,cfei_number,*,,*,View,TRUE 3.3.5,cfei_number,Unicef Focal Point,,Draft,Edit,TRUE +3.3.5,cfei_number,Unicef Focal Point,cfei_absent,*,Edit,TRUE +3.3.5,cfei_number,Country Office Administrator,,*,Edit,TRUE 3.3.5,context,Unicef Focal Point,unicef_court,Draft,Edit,TRUE 3.3.5,context,Partner User,partner_court,Draft,Edit,TRUE ,has_data_processing_agreement,Unicef Focal Point,,Draft,Edit,TRUE diff --git a/src/etools/applications/partners/permissions.py b/src/etools/applications/partners/permissions.py index e3278186e..35662afc8 100644 --- a/src/etools/applications/partners/permissions.py +++ b/src/etools/applications/partners/permissions.py @@ -119,6 +119,19 @@ def __init__(self, **kwargs): def user_added_amendment(instance): return instance.in_amendment is True + # TODO: remove this as sooon as it expires on July first. Technical Debt - hard coded exception + def post_epd_temp_conditions(i): + # quick fix for offices that have not added their amendments in the system before the release date. + today = datetime.date.today() + available_til = datetime.date(2023, 7, 1) + begin_date = datetime.date(2022, 12, 1) + release_date = datetime.date(2023, 4, 30) + if i.end and begin_date <= i.end < release_date \ + and today < available_til \ + and i.document_type != "SSFA": + return True + return False + def prp_mode_off(): return tenant_switch_is_active("prp_mode_off") @@ -213,6 +226,8 @@ def not_ssfa(instance): 'unlocked_or_spd': not not_spd(self.instance) or unlocked(self.instance), 'unicef_not_accepted_spd_non_hum': unicef_not_accepted_spd_non_hum(self.instance), 'not_ssfa+unicef_not_accepted': not_ssfa(self.instance) and unicef_not_accepted(self.instance), + 'post_epd_temp_conditions': post_epd_temp_conditions(self.instance), + 'cfei_absent': not self.instance.cfei_number } # override get_permissions to enable us to prevent old interventions from being blocked on transitions @@ -494,6 +509,9 @@ class UserGroupPermission(BasePermission): def has_permission(self, request, view): return request.user.is_authenticated and is_user_in_groups(request.user, groups) + def has_object_permission(self, request, view, obj): + return self.has_permission(request, view) + return UserGroupPermission @@ -628,6 +646,7 @@ def has_object_permission(self, request, view, obj): class UserIsUnicefFocalPoint(BasePermission): - def has_permission(self, request, view): - intervention = view.get_root_object() - return request.user in intervention.unicef_focal_points.all() + def has_object_permission(self, request, view, obj): + if hasattr(view, 'get_root_object'): + obj = view.get_root_object() + return request.user in obj.unicef_focal_points.all() diff --git a/src/etools/applications/partners/prp_api.py b/src/etools/applications/partners/prp_api.py index 6c12bbcd5..120bf1657 100644 --- a/src/etools/applications/partners/prp_api.py +++ b/src/etools/applications/partners/prp_api.py @@ -1,12 +1,15 @@ -import base64 import json from typing import Dict, Iterable, NamedTuple from django.conf import settings +from django.contrib.auth import get_user_model import requests +from etools.applications.core.jwt_api import BaseJWTAPI + +# TODO cleanup class PRPPartnerResponse(NamedTuple): id: int external_id: str @@ -14,6 +17,7 @@ class PRPPartnerResponse(NamedTuple): name: str +# TODO cleanup class PRPPartnerUserResponse(NamedTuple): email: str title: str @@ -23,40 +27,11 @@ class PRPPartnerUserResponse(NamedTuple): is_active: bool -class PRPAPI(object): - def __init__(self): - self.url_prototype = settings.PRP_API_ENDPOINT - self.username = settings.PRP_API_USER - self.password = settings.PRP_API_PASSWORD - self.enabled = bool(self.url_prototype) - - def _get_headers(self, data=None): - headers = {'Content-Type': 'application/json', 'Keep-Alive': '1800'} - if data: - headers['Content-Length'] = str(len(data)) - - auth_pair_str = '%s:%s' % (self.username, self.password) - headers['Authorization'] = 'Basic ' + \ - base64.b64encode(auth_pair_str.encode()).decode() - return headers - - def _push_request(self, data=None, timeout=None): - if not self.enabled: - return - - headers = self._get_headers(data) - - if data: - r = requests.post(url=self.url, headers=headers, json=data, verify=True, timeout=timeout) - else: - r = requests.get(url=self.url, headers=headers, verify=True, timeout=timeout) - - # Any status code answer below 400 is OK - if r.status_code >= 400: - r.raise_for_status() - - data = json.loads(r.text) - return data +class PRPAPI(BaseJWTAPI): + def __init__(self, user=None): + if not user: + user = get_user_model().objects.get(email=settings.PRP_API_USER) + super().__init__(user, url=settings.PRP_API_ENDPOINT) def _simple_get_request(self, timeout=None): if not self.enabled: @@ -68,6 +43,7 @@ def _simple_get_request(self, timeout=None): return json.loads(r.text) + # TODO clean up: endpoint removed in prp def send_partner_data(self, business_area_code: str, partner_data: Dict): if not self.enabled: return @@ -77,6 +53,7 @@ def send_partner_data(self, business_area_code: str, partner_data: Dict): response_data = self._push_request(data=partner_data, timeout=3000) return response_data + # TODO clean up: endpoint removed in prp def get_partners_list(self) -> Iterable[PRPPartnerResponse]: if not self.enabled: return [] @@ -94,6 +71,7 @@ def get_partners_list(self) -> Iterable[PRPPartnerResponse]: page += 1 + # TODO clean up: endpoint removed in prp def get_partner_staff_members(self, partner_id: int) -> Iterable[PRPPartnerUserResponse]: if not self.enabled: return [] @@ -110,3 +88,11 @@ def get_partner_staff_members(self, partner_id: int) -> Iterable[PRPPartnerUserR break page += 1 + + def send_user_realms(self, data: dict): + if not self.enabled: + return + + self.url = self.url_prototype + '/unicef/users/realms/import/' + response_data = self._push_request(data=data) + return response_data diff --git a/src/etools/applications/partners/serializers/interventions_v2.py b/src/etools/applications/partners/serializers/interventions_v2.py index 594738f91..dc9eb3ed2 100644 --- a/src/etools/applications/partners/serializers/interventions_v2.py +++ b/src/etools/applications/partners/serializers/interventions_v2.py @@ -2,20 +2,24 @@ from operator import itemgetter from django.contrib.auth import get_user_model +from django.core.exceptions import ImproperlyConfigured from django.db import transaction from django.db.models import Q, QuerySet from django.utils.functional import cached_property from django.utils.translation import gettext as _, gettext_lazy -from rest_framework import serializers +from rest_framework import fields, serializers +from rest_framework.relations import RelatedField from rest_framework.serializers import ValidationError from rest_framework.validators import UniqueTogetherValidator +from rest_framework_gis.fields import GeometryField from unicef_attachments.fields import AttachmentSingleFileField from unicef_attachments.models import Attachment from unicef_attachments.serializers import AttachmentSerializerMixin from unicef_locations.serializers import LocationSerializer from unicef_snapshot.serializers import SnapshotModelSerializer +from etools.applications.field_monitoring.fm_settings.models import LocationSite from etools.applications.funds.models import FundsCommitmentItem, FundsReservationHeader from etools.applications.funds.serializers import FRHeaderSerializer, FRsSerializer from etools.applications.organizations.models import OrganizationType @@ -26,6 +30,7 @@ InterventionAttachment, InterventionBudget, InterventionPlannedVisits, + InterventionPlannedVisitSite, InterventionReportingPeriod, InterventionResultLink, ) @@ -58,6 +63,7 @@ class InterventionBudgetCUSerializer( total_cash_local = serializers.DecimalField(max_digits=20, decimal_places=2) total_local = serializers.DecimalField(max_digits=20, decimal_places=2) total_supply = serializers.DecimalField(max_digits=20, decimal_places=2) + unfunded_cash_local = serializers.DecimalField(max_digits=20, decimal_places=2) class Meta: model = InterventionBudget @@ -77,7 +83,9 @@ class Meta: "total_cash_local", "total_unicef_cash_local_wo_hq", "total_hq_cash_local", - "total_supply" + "total_supply", + "unfunded_cash_local", + "has_unfunded_cash" ) read_only_fields = ( "total_local", @@ -89,6 +97,11 @@ class Meta: "total_supply" ) + def validate_unfunded_cash_local(self, value): + if value and not self.instance.has_unfunded_cash: + raise serializers.ValidationError(_('This programme document does not include unfunded amounts')) + return value + def get_intervention(self): return self.validated_data['intervention'] @@ -175,11 +188,72 @@ def validate(self, data): return data +class LocationSiteSerializer(serializers.ModelSerializer): + is_active = serializers.ChoiceField(choices=( + (True, _('Active')), + (False, _('Inactive')), + ), label=_('Status'), required=False) + point = GeometryField(precision=5) + + class Meta: + model = LocationSite + fields = ['id', 'name', 'point', 'is_active'] + + +class PlannedVisitSitesQuarterSerializer(fields.ListField): + child = RelatedField(queryset=LocationSite.objects.all()) + child_serializer = LocationSiteSerializer + + def __init__(self, *args, **kwargs): + self.quarter = kwargs.pop('quarter', None) + if self.quarter is None: + raise ImproperlyConfigured('`quarter` is required') + super().__init__(*args, **kwargs) + + def to_internal_value(self, data: list[int]): + return LocationSite.objects.filter(pk__in=data) + + def to_representation(self, data: list[LocationSite]): + return self.child_serializer(data, many=True).data + + def save(self, planned_visits: InterventionPlannedVisits, sites: list[LocationSite]): + sites = set(sites) + planned_sites = set(InterventionPlannedVisitSite.objects.filter( + planned_visits=planned_visits, + quarter=self.quarter, + )) + sites_to_create = sites - planned_sites + sites_to_delete = planned_sites - sites + InterventionPlannedVisitSite.objects.bulk_create(( + InterventionPlannedVisitSite(planned_visits=planned_visits, quarter=self.quarter, site=site) + for site in sites_to_create + )) + InterventionPlannedVisitSite.objects.filter( + planned_visits=planned_visits, quarter=self.quarter, site__in=sites_to_delete, + ).delete() + + class PlannedVisitsCUSerializer(serializers.ModelSerializer): + programmatic_q1_sites = PlannedVisitSitesQuarterSerializer(quarter=InterventionPlannedVisitSite.Q1, required=False) + programmatic_q2_sites = PlannedVisitSitesQuarterSerializer(quarter=InterventionPlannedVisitSite.Q2, required=False) + programmatic_q3_sites = PlannedVisitSitesQuarterSerializer(quarter=InterventionPlannedVisitSite.Q3, required=False) + programmatic_q4_sites = PlannedVisitSitesQuarterSerializer(quarter=InterventionPlannedVisitSite.Q4, required=False) class Meta: model = InterventionPlannedVisits - fields = "__all__" + fields = ( + 'id', + 'intervention', + 'year', + 'programmatic_q1', + 'programmatic_q1_sites', + 'programmatic_q2', + 'programmatic_q2_sites', + 'programmatic_q3', + 'programmatic_q3_sites', + 'programmatic_q4', + 'programmatic_q4_sites', + ) def is_valid(self, raise_exception=True): try: @@ -196,18 +270,58 @@ def is_valid(self, raise_exception=True): self.instance = None return super().is_valid(raise_exception=True) + def create(self, validated_data): + sites_q1 = validated_data.pop('programmatic_q1_sites', None) + sites_q2 = validated_data.pop('programmatic_q2_sites', None) + sites_q3 = validated_data.pop('programmatic_q3_sites', None) + sites_q4 = validated_data.pop('programmatic_q4_sites', None) + instance = super().create(validated_data) + if sites_q1 is not None: + self.fields['programmatic_q1_sites'].save(instance, sites_q1) + if sites_q2 is not None: + self.fields['programmatic_q2_sites'].save(instance, sites_q2) + if sites_q3 is not None: + self.fields['programmatic_q3_sites'].save(instance, sites_q3) + if sites_q4 is not None: + self.fields['programmatic_q4_sites'].save(instance, sites_q4) + return instance + + def update(self, instance, validated_data): + sites_q1 = validated_data.pop('programmatic_q1_sites', None) + sites_q2 = validated_data.pop('programmatic_q2_sites', None) + sites_q3 = validated_data.pop('programmatic_q3_sites', None) + sites_q4 = validated_data.pop('programmatic_q4_sites', None) + instance = super().update(instance, validated_data) + if sites_q1 is not None: + self.fields['programmatic_q1_sites'].save(instance, sites_q1) + if sites_q2 is not None: + self.fields['programmatic_q2_sites'].save(instance, sites_q2) + if sites_q3 is not None: + self.fields['programmatic_q3_sites'].save(instance, sites_q3) + if sites_q4 is not None: + self.fields['programmatic_q4_sites'].save(instance, sites_q4) + return instance + class PlannedVisitsNestedSerializer(serializers.ModelSerializer): + programmatic_q1_sites = PlannedVisitSitesQuarterSerializer(quarter=InterventionPlannedVisitSite.Q1, required=False) + programmatic_q2_sites = PlannedVisitSitesQuarterSerializer(quarter=InterventionPlannedVisitSite.Q2, required=False) + programmatic_q3_sites = PlannedVisitSitesQuarterSerializer(quarter=InterventionPlannedVisitSite.Q3, required=False) + programmatic_q4_sites = PlannedVisitSitesQuarterSerializer(quarter=InterventionPlannedVisitSite.Q4, required=False) class Meta: model = InterventionPlannedVisits fields = ( "id", "year", - "programmatic_q1", - "programmatic_q2", - "programmatic_q3", - "programmatic_q4", + 'programmatic_q1', + 'programmatic_q1_sites', + 'programmatic_q2', + 'programmatic_q2_sites', + 'programmatic_q3', + 'programmatic_q3_sites', + 'programmatic_q4', + 'programmatic_q4_sites', ) diff --git a/src/etools/applications/partners/serializers/interventions_v3.py b/src/etools/applications/partners/serializers/interventions_v3.py index 2a619f909..e3ecf955f 100644 --- a/src/etools/applications/partners/serializers/interventions_v3.py +++ b/src/etools/applications/partners/serializers/interventions_v3.py @@ -461,7 +461,7 @@ def get_available_actions(self, obj): ).exists(): available_actions.append("individual_review") - if obj.in_amendment and obj.status == obj.SIGNED and obj.budget_owner == user: + if obj.in_amendment and obj.status == obj.SIGNED and budget_owner_or_focal_point: available_actions.append("amendment_merge") # if NOT in Development status then we're done diff --git a/src/etools/applications/partners/serializers/partner_organization_v2.py b/src/etools/applications/partners/serializers/partner_organization_v2.py index 1b0e81f04..2e1bb7646 100644 --- a/src/etools/applications/partners/serializers/partner_organization_v2.py +++ b/src/etools/applications/partners/serializers/partner_organization_v2.py @@ -212,12 +212,18 @@ class Meta: model = get_user_model() fields = ( 'id', 'email', 'first_name', 'last_name', 'created', 'modified', - 'active', 'phone', 'title', - # TODO REALMS check with frontend if partner id is used - # 'partner' + 'active', 'phone', 'title' ) +class PartnerStaffMemberRealmSerializer(PartnerStaffMemberDetailSerializer): + has_active_realm = serializers.BooleanField() + + class Meta(PartnerStaffMemberDetailSerializer.Meta): + model = get_user_model() + fields = PartnerStaffMemberDetailSerializer.Meta.fields + ('has_active_realm',) + + class PartnerStaffMemberUserSerializer(PartnerStaffMemberDetailSerializer): pass @@ -443,7 +449,7 @@ class PartnerOrganizationDetailSerializer(serializers.ModelSerializer): short_name = serializers.CharField(source='organization.short_name', read_only=True) partner_type = serializers.CharField(source='organization.organization_type', read_only=True) cso_type = serializers.CharField(source='organization.cso_type', read_only=True) - staff_members = PartnerStaffMemberDetailSerializer(source='all_staff_members', many=True, read_only=True) + staff_members = PartnerStaffMemberRealmSerializer(source='all_staff_members', many=True, read_only=True) assessments = AssessmentDetailSerializer(many=True, read_only=True) planned_engagement = PlannedEngagementSerializer(read_only=True) interventions = serializers.SerializerMethodField(read_only=True) diff --git a/src/etools/applications/partners/serializers/prp_v1.py b/src/etools/applications/partners/serializers/prp_v1.py index 7afe5f864..73bfceb7c 100644 --- a/src/etools/applications/partners/serializers/prp_v1.py +++ b/src/etools/applications/partners/serializers/prp_v1.py @@ -17,6 +17,7 @@ SpecialReportingRequirement, ) from etools.applications.reports.serializers.v1 import SectionSerializer +from etools.applications.users.models import Realm class InterventionPDFileSerializer(serializers.ModelSerializer): @@ -335,3 +336,31 @@ class Meta: 'disbursement', 'disbursement_percent' ) + + +class PRPSyncRealmSerializer(serializers.ModelSerializer): + country = serializers.CharField(source='country.id') + organization = serializers.CharField(source='organization.vendor_number') + group = serializers.CharField(source='group.name') + + class Meta: + model = Realm + fields = ( + 'country', + 'organization', + 'group', + ) + + +class PRPSyncUserSerializer(serializers.ModelSerializer): + realms = PRPSyncRealmSerializer(many=True) + + class Meta: + model = get_user_model() + fields = ( + 'email', + 'first_name', + 'middle_name', + 'last_name', + 'realms', + ) diff --git a/src/etools/applications/partners/signals.py b/src/etools/applications/partners/signals.py index 936ecd396..3691b16b1 100644 --- a/src/etools/applications/partners/signals.py +++ b/src/etools/applications/partners/signals.py @@ -1,4 +1,3 @@ -from django.db import connection from django.db.models.signals import m2m_changed, post_delete, post_save from django.dispatch import receiver @@ -9,13 +8,12 @@ InterventionSupplyItem, PRCOfficerInterventionReview, ) -from etools.applications.partners.tasks import sync_partner_to_prp - -@receiver(post_save, sender=Intervention) -def sync_pd_partner_to_prp(instance: Intervention, created: bool, **kwargs): - if instance.date_sent_to_partner and instance.tracker.has_changed('date_sent_to_partner'): - sync_partner_to_prp.delay(connection.tenant.name, instance.agreement.partner_id) +# TODO clean up: endpoint removed in prp +# @receiver(post_save, sender=Intervention) +# def sync_pd_partner_to_prp(instance: Intervention, created: bool, **kwargs): +# if instance.date_sent_to_partner and instance.tracker.has_changed('date_sent_to_partner'): +# sync_partner_to_prp.delay(connection.tenant.name, instance.agreement.partner_id) @receiver(post_delete, sender=InterventionSupplyItem) diff --git a/src/etools/applications/partners/synchronizers.py b/src/etools/applications/partners/synchronizers.py index eeafc4706..20c5b77c6 100644 --- a/src/etools/applications/partners/synchronizers.py +++ b/src/etools/applications/partners/synchronizers.py @@ -320,7 +320,7 @@ def deactivate_staff_members(partner_org): staff_members_ids = list(partner_org.active_staff_members.values_list('id', flat=True)) partner_org.active_staff_members.update(is_active=False) try: - country = Country.objects.get(schema_name=partner_org.country) + country = Country.objects.get(schema_name=connection.tenant.schema_name) Realm.objects.filter( user_id__in=staff_members_ids, country=country, diff --git a/src/etools/applications/partners/tasks.py b/src/etools/applications/partners/tasks.py index 71f339a61..8c48f873e 100644 --- a/src/etools/applications/partners/tasks.py +++ b/src/etools/applications/partners/tasks.py @@ -5,7 +5,6 @@ from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.db import connection, transaction -from django.db.models import Prefetch from django.utils import timezone from django.utils.translation import gettext as _ @@ -17,28 +16,25 @@ from etools.applications.environment.notifications import send_notification_with_template from etools.applications.partners.models import Agreement, Intervention, PartnerOrganization -from etools.applications.partners.prp_api import PRPAPI -from etools.applications.partners.serializers.prp_v1 import PRPPartnerOrganizationWithStaffMembersSerializer from etools.applications.partners.utils import ( copy_all_attachments, send_intervention_draft_notification, send_intervention_past_start_notification, send_pca_missing_notifications, send_pca_required_notifications, - sync_partner_staff_member, ) 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, User +from etools.applications.users.models import Country from etools.config.celery import app from etools.libraries.djangolib.utils import get_environment -from etools.libraries.tenant_support.utils import every_country, run_on_all_tenants +from etools.libraries.tenant_support.utils import run_on_all_tenants logger = get_task_logger(__name__) # _INTERVENTION_ENDING_SOON_DELTAS is used by intervention_notification_ending(). Notifications will be sent -# about each interventions ending {delta} days from now. +# about each intervention ending {delta} days from now. _INTERVENTION_ENDING_SOON_DELTAS = (15, 30, 60, 90) @@ -258,7 +254,7 @@ def notify_partner_hidden(partner_pk, tenant_name): with schema_context(tenant_name): partner = PartnerOrganization.objects.get(pk=partner_pk) pds = Intervention.objects.filter( - agreement__partner__name=partner.name, + agreement__partner=partner, status__in=[Intervention.SIGNED, Intervention.ACTIVE, Intervention.ENDED] ) if pds: @@ -297,44 +293,43 @@ def check_intervention_past_start(): run_on_all_tenants(send_intervention_past_start_notification) -@app.task -def sync_partner_to_prp(tenant: str, partner_id: int): - tenant = get_tenant_model().objects.get(name=tenant) - connection.set_tenant(tenant) - - partner = PartnerOrganization.objects.filter(id=partner_id).prefetch_related( - Prefetch('organization__realms__users', User.objects.filter(is_active=True)) - ).get() - partner_data = PRPPartnerOrganizationWithStaffMembersSerializer(instance=partner).data - PRPAPI().send_partner_data(tenant.business_area_code, partner_data) - - -@app.task -def sync_partners_staff_members_from_prp(): - api = PRPAPI() - - # remember where every particular partner is located for easier search - partners_tenants_mapping = {} - with every_country() as c: - for country in c: - connection.set_tenant(country) - for partner in PartnerOrganization.objects.all(): - partners_tenants_mapping[(str(partner.id), partner.vendor_number)] = country - - for partner_data in PRPAPI().get_partners_list(): - key = (partner_data.external_id, partner_data.unicef_vendor_number) - partner_tenant = partners_tenants_mapping.get(key) - if not partner_tenant: - continue - - connection.set_tenant(partner_tenant) - partner = PartnerOrganization.objects.get( - id=partner_data.external_id, - vendor_number=partner_data.unicef_vendor_number - ) - - for staff_member_data in api.get_partner_staff_members(partner_data.id): - sync_partner_staff_member(partner, staff_member_data) +# TODO clean up: endpoint removed in prp +# @app.task +# def sync_partner_to_prp(tenant: str, partner_id: int): +# tenant = get_tenant_model().objects.get(name=tenant) +# connection.set_tenant(tenant) +# +# partner = PartnerOrganization.objects.get(id=partner_id) +# partner_data = PRPPartnerOrganizationWithStaffMembersSerializer(instance=partner).data +# PRPAPI().send_partner_data(tenant.business_area_code, partner_data) + +# TODO clean up: endpoint removed in prp +# @app.task +# def sync_partners_staff_members_from_prp(): +# api = PRPAPI() +# +# # remember where every particular partner is located for easier search +# partners_tenants_mapping = {} +# with every_country() as c: +# for country in c: +# connection.set_tenant(country) +# for partner in PartnerOrganization.objects.all(): +# partners_tenants_mapping[(str(partner.id), partner.vendor_number)] = country +# +# for partner_data in PRPAPI().get_partners_list(): +# key = (partner_data.external_id, partner_data.unicef_vendor_number) +# partner_tenant = partners_tenants_mapping.get(key) +# if not partner_tenant: +# continue +# +# connection.set_tenant(partner_tenant) +# partner = PartnerOrganization.objects.get( +# id=partner_data.external_id, +# vendor_number=partner_data.unicef_vendor_number +# ) +# +# for staff_member_data in api.get_partner_staff_members(partner_data.id): +# sync_partner_staff_member(partner, staff_member_data) @app.task diff --git a/src/etools/applications/partners/templates/pca/english_pdf.html b/src/etools/applications/partners/templates/pca/english_pdf.html index f00997508..b80e8800a 100644 --- a/src/etools/applications/partners/templates/pca/english_pdf.html +++ b/src/etools/applications/partners/templates/pca/english_pdf.html @@ -1,5 +1,6 @@ {% extends "easy_pdf/base.html" %} {% load humanize %} +{% load etools %} {% block style_base %} {% comment %} @@ -105,6 +106,11 @@ padding-left: 25px; } + .indent2 { + padding-left: 50px; + } + + .page-break { page-break-after: always; } @@ -158,7 +164,7 @@ {{ officer.first_name }} {{ officer.last_name }} {{ officer.title }} - {{ officer.email }} + {{ officer.email|text_wrap:30 }}   {% endfor %} @@ -234,11 +240,11 @@

1.10 "Support costs for organizational capacity strengthening" means those costs incurred by the Partner for organizational capacity - strengthening which cannot be unequivocally attributed to a specific - activity implemented by the Partner in accordance with this Agreement, - including any Programme Document. + >Support costs for organizational capacity" means those costs incurred by the Partner for organizational capacity + strengthening and/or capacity maintenance, including HQ support costs, which + cannot be unequivocally attributed to a specific activity implemented by the + Partner in accordance with this Agreement, including any Programme Document.

1.11 "United Nations Children`s Fund" or @@ -288,11 +294,11 @@ Documents.

- 4.3 The Parties shall keep each other informed of all relevant activities - pertaining to the implementation of the Programme Documents, and shall - hold consultations when either Party considers it appropriate, including - any circumstance that may affect the achievement of the results of the - Programme and the Programme Documents. + 4.3 The Parties will keep each other informed of all relevant activities + pertaining to the implementation of the Programme Documents, and will hold + consultations when either Party considers it appropriate, including any + circumstance that may affect the achievement of the results of the Programme + and the Programme Documents.

4.4 The Parties will fulfill their commitments with the fullest regard for @@ -413,13 +419,13 @@

- 6.2 Support costs for organizational capacity strengthening with respect - to any single Programme Document and associated budget will be - reimbursable by UNICEF to the Partner at a rate of seven percent (7%) of - actual expenditures in connection with that Programme Document and - associated budget. The Partner will record the support costs for - organizational capacity strengthening in the FACE forms to be submitted to - UNICEF in accordance with the terms of this Agreement. + 6.2 At the Partner’s request, support costs for organizational capacity + with respect to any single Programme Document and associated budget will + be reimbursable by UNICEF to the Partner up to a rate of seven percent (7%) + of actual expenditures in connection with that Programme Document and associated + budget. For it to be reimbursable, the Partner must record the support costs for + organizational capacity in the FACE forms to be submitted to UNICEF in accordance + with the terms of this Agreement.

7. Programme Documents

@@ -484,10 +490,10 @@

- 8.5 UNICEF will make each Cash Transfer Installment to (or, where the - Direct Payment modality is used, on behalf of the Partner) in response to - a written request from the Partner, in accordance with the following - procedures: + 8.5 UNICEF will make each Cash Transfer Installment to the Partner + (or, where the Direct Payment modality is used, on behalf of the Partner) + in response to a written request from the Partner, in accordance with the + following procedures:

Procedures for requests for Cash Transfer Installments under all three @@ -504,23 +510,23 @@ Estimate. The request must be signed by an Authorized Officer.

- (b) The first written request, using the FACE form, may be made as soon - as this Agreement and the relevant Programme Document have been signed - by both Parties. If such written request is in proper form and complete, - UNICEF will determine the amount to be transferred and will transfer - that amount to (or, where the modality of Direct Payment is used, on - behalf of the Partner) within a reasonable time. + (b) The first written request, using the FACE form, may be made as soon as this + Agreement and the relevant Programme Document have been signed by + both Parties. If such written request is in proper form and complete, UNICEF + will determine the amount to be transferred and will transfer that amount + to the Partner (or, where the modality of Direct Payment is used, on behalf + of the Partner) within a reasonable time.

(c) Unless otherwise decided by UNICEF, the second and each subsequent - written request, using the FACE form, requires that reporting on the use - of the first Cash Transfer Installment has been received by UNICEF - before a Cash Transfer Installment for a subsequent three month period - will be released. If such second or subsequent request is received in a - timely fashion and is in proper form and complete, UNICEF will determine - the amount to be transferred and will transfer that amount to (or, where - the modality of Direct Payment is used, on behalf of the Partner) within - a reasonable time. + written request, using the FACE form, requires that reporting on the use of + the first Cash Transfer Installment has been received by UNICEF before a + Cash Transfer Installment for a subsequent three-month period will be + released. If such second or subsequent request is received in a timely + fashion and is in proper form and complete, UNICEF will determine the + amount to be transferred and will transfer that amount to the Partner (or, + where the modality of Direct Payment is used, on behalf of the Partner) + within a reasonable time.

Additional procedures applicable only to Direct Payment modality: @@ -627,36 +633,36 @@ Bank Name - {{ bank_detail.bank_name }} + {{ bank_detail.bank_name }} Bank Address - {{ bank_detail.bank_address }} + {{ bank_detail.bank_address }} Account Title - {{ bank_detail.account_title }} + {{ bank_detail.account_title }} Account No. - {{ bank_detail.account_number }} + {{ bank_detail.account_number }} Account Currency - {{ bank_detail.account_currency }} + {{ bank_detail.account_currency }} Routing Details
SWIFT/IBAN - {{ bank_detail.routing_details }} + {{ bank_detail.routing_details }} Bank Contact Person - {% firstof bank_detail.bank_contact_person ' ' %} + {% firstof bank_detail.bank_contact_person ' ' %} {% if bank_detail.tax_number_5 %} Hawala Banking Details - {{ bank_detail.tax_number_5 }} + {{ bank_detail.tax_number_5 }} {% endif %} @@ -834,14 +840,13 @@ in-kind contribution from another donor or entity;

- (vi) Support costs for organizational capacity strengthening exceeding - the rate referred to in Article 6.2 of this Agreement; + (vi) Support costs for organizational capacity exceeding the rate + referred to in Article 6.2 of this Agreement;

- (vii) Expenditures that are not verifiable by records as provided in - Article 9 of this Agreement (other than support costs for - organizational capacity strengthening referred to in Article 6.2 of - this Agreement); + (vii) Expenditures that are not verifiable by records as provided + in Article 9 of this Agreement (other than support costs for organizational + capacity referred to in Article 6.2 of this Agreement);

(viii) Salaries for the Partner`s employees exceeding the rates @@ -980,27 +985,25 @@ writing.

- 12.3 Any dispute, controversy, or claim between the Parties arising out of - this Agreement which is not solved within ninety (90) days after one Party - receives a request from the other Party for amicable settlement, can be - referred by either Party to arbitration. The arbitration will take place - in accordance with the UNCITRAL Arbitration Rules then in force. The venue - of the arbitration will be New York, NY, USA. The decisions of the - arbitral tribunal will be based on general principles of international - commercial law. The arbitral tribunal will have no authority to award + 12.3 Any dispute, controversy, or claim between the Parties arising out of this Agreement + which is not solved within ninety (90) days after one Party receives a request from + the other Party for amicable settlement, can be referred by either Party to + arbitration. The arbitration will take place in accordance with the UNCITRAL + Arbitration Rules then in force. The venue of the arbitration will be New York, NY, + USA. The decisions of the arbitral tribunal will be based on general principles of + international commercial law. The arbitral tribunal will have no authority to award punitive damages. In addition, unless otherwise expressly provided in the - Agreement, the arbitral tribunal will have no authority to award interest - in excess of the London Inter-Bank Offered Rate ("LIBOR") then prevailing, - and any such interest will be simple interest only. Should LIBOR no longer - be available, the United States Federal Reserve Bank of New York`s Secured - Overnight Financing Rate (SOFR) then prevailing shall be used, and any - such interest shall be simple interest only. In light of the privileges - and immunities of UNICEF, references in the UNCITRAL Arbitration Rules to - the place of arbitration shall connote only the actual location for the - arbitral proceedings but shall not mean the "seat" or "juridical seat" or - "juridical place" for such proceedings. The Parties will be bound by any - arbitration award rendered as a result of such arbitration as the final - adjudication of any such dispute, controversy, or claim. + Agreement, the arbitral tribunal will have no authority to award interest in excess of + the London Inter-Bank Offered Rate (“LIBOR”) then prevailing, and any such interest + will be simple interest only. Should LIBOR no longer be available, the United States + Federal Reserve Bank of New York’s Secured Overnight Financing Rate (SOFR) then + prevailing will be used, and any such interest will be simple interest only. In light of + the privileges and immunities of UNICEF, references in the UNCITRAL Arbitration + Rules to the place of arbitration will connote only the actual location for the arbitral + proceedings but will not mean the “seat” or “juridical seat” or “juridical place” for + such proceedings. The Parties will be bound by any arbitration award rendered as a + result of such arbitration as the final adjudication of any such dispute, controversy, + or claim.

13. Final Provisions

@@ -1103,26 +1106,23 @@ >Partner`s Responsibility for Employees, Personnel and Subcontractors: - The Partner will be responsible for the professional and technical - competence of its employees, personnel and subcontractors and will select, - for work under this Agreement, reliable persons and entities who will - perform effectively in the implementation of this Agreement, respect the - local laws and customs, and conform to a high standard of moral and - ethical conduct, which includes safeguarding. Before selection, employees - and other personnel and subcontractors` personnel will be asked if they - have been dismissed from a previous employer for misconduct, or left while - being investigated for misconduct and were never cleared. Appropriate - reference or background checks will also be conducted. The Partner will - disclose whether it has any dismissed any individuals for safeguarding - violations or sexual exploitation and abuse in the last 5 years, and - whether any are still working at the Partner and proposed to work under - the Agreement. At any time during the term of the Agreement, UNICEF can - make a written request that the Partner replace one or more of the - assigned employees or other personnel or subcontractors` personnel. UNICEF - will not be required to give an explanation or justification for this - request. Within seven (7) working days of receiving UNICEF`s request, the - Partner must replace the individual in question with an individual - acceptable to UNICEF. + The Partner will be responsible for the professional and technical competence of its employees, + personnel and subcontractors and will select, for work under this Agreement, + reliable persons and entities who will perform effectively in the implementation of + this Agreement, respect the local laws and customs, and conform to a high standard + of moral and ethical conduct, which includes safeguarding. Before appointment, + employees and other personnel and subcontractors’ personnel will be asked, to the + extent legally permissible, if they have been dismissed from a previous employer for + misconduct, or left while being investigated for misconduct and were never cleared. + To the extent legally permissible, appropriate reference or background checks will + also be conducted. At UNICEF’s request, the Partner will disclose if (i) it has + dismissed any employees or personnel for breach of a Partner’s safeguarding policy, + including sexual exploitation and abuse in the last 5 years, and (ii) any employees or + personnel working under this Agreement for the Partner have been investigated (and the + findings of the investigation have been substantiated) and/or are under + investigation for safeguarding violations, including sexual exploitation and abuse + allegations; provided that the Partner will not be required to disclose any Personal + Data.

4.3 @@ -1271,182 +1271,197 @@ Abuse:

-
    -
  1. - In this Agreement the following definitions are used: -
    -

    - (i) "sexual exploitation" means any actual or attempted abuse of a - position of vulnerability, differential power, or trust, for sexual - purposes, including, but not limited to, profiting monetarily, - socially or politically from the sexual exploitation of another; -

    -

    - (ii) "sexual abuse" means the actual or threatened physical - intrusion of a sexual nature, whether by force or under unequal or - coercive conditions. Sexual exploitation and abuse are strictly - prohibited; -

    -

    - (iii) "child" means any person less than eighteen (18) years of age, - regardless of any laws relating to consent or when one legally - becomes an adult. -

    -

    - (iv) "safeguarding" is the reduction of risks of harm arising from a - party`s work, employees or other personnel, or subcontractors. -

    -

    - (v) "safeguarding violation" is a conduct by a party`s employees, - personnel or subcontractors that actually or likely causes - significant harm to a person, including any kind of physical, - emotional or sexual abuse, neglect or exploitation. -

    -
    -
  2. -
  3. - In addition, in this Agreement: -
    -

    - (i) Any sexual acts involving a person who cannot consent or is not - consenting at the time, will be sexual abuse. -

    -

    - (ii) Sexual activity with a child will always be sexual abuse, even - if there is a mistake about the age of the child, or the person is - married to the child. -

    -

    - (iii) Demanding, giving, offering or receiving anything (money, - employment, goods, services, or other things of value) for sexual - touching or sexual activities with anyone will be sexual - exploitation. This includes paying or offering money for sex with a - prostitute. -

    -

    - (iv) Anyone who might affect who gets goods or services must not - have sex with anyone who may get that help. It will be sexual - exploitation. -

    -

    - (v) Exploitative labor is a safeguarding violation. This means - workers must be allowed to freely associate and collectively - bargain. Workers must not be forced or compelled to work. Workers - must not be subject to discrimination. Workers must be given a safe - and healthy working environment. -

    -

    - (vi) Child labor is a safeguarding violation. This means no child - under 14 will work under the Agreement. Further, any child working - must be above the age the law requires for mandatory school and the - minimum age for work. -

    -
    -
  4. +

    + a. In this Agreement the following definitions are used: +

    +

    + (i) "sexual exploitation" means any actual or attempted abuse of a + position of vulnerability, differential power, or trust, for sexual + purposes, including, but not limited to, profiting monetarily, + socially or politically from the sexual exploitation of another; +

    +

    + (ii) "sexual abuse" means the actual or threatened physical + intrusion of a sexual nature, whether by force or under unequal or + coercive conditions. Sexual exploitation and abuse are strictly + prohibited; +

    +

    + (iii) "child" means any person less than eighteen (18) years of age, + regardless of any laws relating to consent or when one legally + becomes an adult. +

    +

    + (iv) "safeguarding" is the reduction of risks of harm arising from a + party`s work, employees or other personnel, or subcontractors. +

    +

    + (v) "" is conduct by a party’s employees, personnel or + subcontractors that actually or likely causes harm to a person, including + any kind of physical, emotional or sexual abuse, neglect or exploitation. +

    +
    +

    -
  5. - Policies. The Partner has read (a) ST/SGB/2003/13 entitled "Special - Measures for Protection from Sexual Exploitation and Sexual Abuse", - which is available at - https://undocs.org/ST/SGB/2003/13; and (b) UNICEF`s policies related to safeguarding, available at - https://www.unicef.org/supply/documents/safeguarding-policy - (or another URL given to the Partner). The Partner will either adopt its - own policy/policies that are at least as stringent as these policies, or - apply these policies. The Partner will require subcontractors to do the - same. The Partner will also have a policy that makes it a misconduct to - retaliate against anyone for reporting or cooperating in an - investigation of safeguarding violations, including sexual exploitation - and abuse. The Partner shall require its employees and other personnel - and persons working for subcontractors to agree to a code of conduct - that covers behavior in these policies. -
  6. +

    + b. In addition, in this Agreement: +

    +

    + (i) Any sexual acts involving a person who cannot consent or is not + consenting at the time, will be sexual abuse. +

    +

    + (ii) Sexual activity with a child will always be sexual abuse, even + if there is a mistake about the age of the child, or the person is + married to the child. +

    +

    + (iii) Demanding, giving, offering or receiving anything (money, + employment, goods, services, or other things of value) for sexual + touching or sexual activities with anyone will be sexual + exploitation. This includes paying or offering money for sex with a + prostitute. +

    +

    + (iv) Anyone who might affect who gets goods or services must not + have sex with anyone who may get that help. It will be sexual + exploitation. +

    +

    + (v) Exploitative labor is a safeguarding concern. This means workers must + be allowed to freely associate and collectively bargain. Workers must + not be forced or compelled to work. Workers must not be subject to + discrimination. Workers must be given a safe and healthy working + environment. +

    +

    + (vi) Child labor is a safeguarding violation. This means no child + under 14 will work under the Agreement. Further, any child working + must be above the age the law requires for mandatory school and the + minimum age for work. +

    +
    +

    -
  7. - Prevention. The Partner will take all appropriate measures to have its - employees and other personnel and subcontractors demonstrate behaviour - that proactively safeguards, including protecting from sexual - exploitation and abuse. The Partner must ensure employees and other - personnel and subcontractors have an understanding of their obligations - and: (i) what is safeguarding in programming, the importance of risk - assessments of planned activities, events, and interventions to plan - mitigation measures, and reflect those in relevant Programme Documents; - (ii) of the kinds of safeguarding concerns, and definitions of - safeguarding violations and sexual exploitation and sexual abuse; (iii) - why any unsafe or prohibited behavior as outlined in UNICEF Safeguarding - Policy, including any form of sexual exploitation and abuse, is - unacceptable, as it harms people and destroys trust in the work; (iv) - the need to properly supervise children and workers; (v) the - requirements in this Agreement to promptly report to UNICEF safeguarding - violations, sexual exploitation and sexual abuse; (vi) how other - safeguarding concerns should be raised by employees or other personnel - or subcontractors at the Partner, and addressed in consultation with - UNICEF; and (vii) the requirements in this Agreement to facilitate - victim assistance. To gain this understanding, the Partner will, among - other things, ensure that its employees, personnel or subcontractors - successfully complete and renew appropriate training. -
  8. -
  9. - Reporting of allegations to UNICEF. The Partner will promptly and - confidentially, in a manner that assures the safety of all involved, - report allegations of safeguarding violations, including but not limited - to sexual exploitation and abuse, arising from this Agreement, of which - the Partner has been informed or has otherwise become aware, to (i) the - UNICEF head of office, or (ii) the UNICEF Director Office of Internal - Audit and Investigation (integrity1@unicef.org), or (iii) other reporting channels established locally by UNICEF - Country Offices and communicated to the Partner. This obligation - survives the expiry or termination of the Agreement, with respect to - incidents occurring during the term of this Agreement. -
  10. +

    + c. Policies. The Partner has read (a) ST/SGB/2003/13 entitled “Special Measures + for Protection from Sexual Exploitation and Sexual Abuse”, which is available at + https://undocs.org/ST/SGB/2003/13; and (b) UNICEF`s policies related to safeguarding, available at + https://www.unicef.org/supply/documents/safeguarding-policy + (or another URL given to the Partner). The Partner will either adopt its own policy/policies that + are at least as stringent as these policies or apply these policies. The Partner will + require subcontractors to do the same. The Partner will also have a policy that + makes it a misconduct to retaliate against anyone for reporting or cooperating in + an investigation of safeguarding violations, including sexual exploitation and + abuse. The Partner will require its employees and other personnel and persons + working for subcontractors to agree to a code of conduct that covers behavior in + these policies. +

    -
  11. - Assistance. Alleged victims of safeguarding concerns, including sexual - exploitation and abuse, will be promptly informed of and referred to - available professional assistance, according to their consent and in - coordination with UNICEF. This obligation survives the expiry or - termination of the Agreement, with respect to incidents occurring during - the term of this Agreement. -
  12. +

    + d. Prevention. The Partner will take all appropriate measures to have its employees + and other personnel and subcontractors demonstrate behaviour that proactively + safeguards, including protecting from sexual exploitation and abuse. The Partner + must ensure employees and other personnel and subcontractors have an + understanding of their obligations and: +

    +

    + (i) what is safeguarding in programming, the importance of risk assessments of + planned activities, events, and interventions to plan mitigation measures, and + reflect those in relevant Programme Documents; +

    +

    + (ii) of the kinds of safeguarding concerns, including sexual exploitation and + sexual abuse; +

    +

    + (iii) why any unsafe or prohibited behavior as outlined in UNICEF Safeguarding + Policy, including any form of sexual exploitation and abuse, is unacceptable, as it + harms people and destroys trust in the work; +

    +

    + (iv) the need to properly supervise children and workers; +

    +

    + (v) the requirements in this Agreement to promptly report to UNICEF + safeguarding violations, including sexual exploitation and sexual abuse in + accordance with Article 4.5(e) below; +

    +

    + (vi) how other safeguarding concerns should be raised by employees or other + personnel or subcontractors at the Partner, and addressed in consultation with + UNICEF; and +

    +

    + (vii) the requirements in this Agreement to facilitate victim assistance. + To gain this understanding, the Partner will, among other things, ensure that its + employees, personnel or subcontractors successfully complete and renew + appropriate training. +

    +
    +

    +

    + e. Reporting of allegations to UNICEF. The Partner will promptly and confidentially, + in a manner that assures the safety of all involved, report any allegations of + sexual exploitation and abuse and safeguarding violations causing or likely to + cause significant harm to a child, arising from this Agreement or which the + Partner has been informed or has otherwise become aware and which, in the + Partner’s view, could have a significant impact to UNICEF, to (i) the UNICEF head + of office, or (ii) the UNICEF Director Office of Internal Audit and Investigation + integrity1@unicef.org), or (iii) other + reporting channels established locally by UNICEF Country Offices and communicated + to the Partner. This obligation survives the expiry or termination of the Agreement, + with respect to incidents occurring during the term of this Agreement. +

    -
  13. - Investigation. The Partner will properly and without delay investigate - allegations of safeguarding violations, including sexual exploitation - and abuse, by the Partner`s employees, personnel, or subcontractors. The - Partner will keep UNICEF informed during the conduct of the - investigation, without prejudice to the due process rights of any - persons concerned. UNICEF may require the Partner to suspend any - individual from work under this Agreement while under investigation. - Following the conclusion of the investigation by the Partner, the - Partner will promptly provide reports on the outcome of the - investigation, including any relevant details relating to the alleged - offender, to the extent legally possible. Upon request, the Partner will - provide relevant evidence to UNICEF for examination and further use by - UNICEF as deemed necessary solely by UNICEF. UNICEF may decide that the - obligation on the part of the Partner under the first sentence of this - Article 4.5(g) to conduct an investigation will not apply if an - investigation is being or has been conducted by competent national - authorities. In the event that competent national authorities are - conducting or have conducted the investigation, the Partner will assist - UNICEF and take all necessary steps, to the extent legally possible, for - UNICEF to obtain information on the status and outcome of the - investigation. These obligations will survive the expiry or termination - of this Agreement, with respect to incidents occurring during the term - of this Agreement. The Partner will provide information to UNICEF about - any action it has taken in response to the incident to meet safeguarding - standards and reduce the chance of similar concerns in the future. It is - understood that UNICEF expects that safeguarding concerns and violations - will always be addressed. It is further understood that any - investigation conducted by the Partner under this clause will be without - prejudice to the right of UNICEF under Article 15.3 to conduct - investigations. -
  14. -
+

+ f. Assistance. Alleged survivors of safeguarding violations, including sexual + exploitation and abuse, will be promptly informed of and referred to available + professional assistance by the Partner with the consent of the alleged survivors. + Where reasonably practicable and ensuring that the safety of alleged survivors is + not compromised, the Partner will inform UNICEF if such a referral has been + made or not, including the types of assistance provided, and where not + provided, the general reason for that. This obligation survives the expiry or + termination of the Agreement, with respect to incidents occurring during the + term of this Agreement. +

+ +

+ g. Investigation. The Partner will properly and without delay investigate allegations + of safeguarding violations, including sexual exploitation and abuse, by the + Partner’s employees, personnel, or subcontractors (where the subcontractors + do not follow their own processes). The Partner will keep UNICEF informed + during the conduct of the investigation, without prejudice to the due process + rights of any persons concerned. UNICEF may, with a clear rationale and + justification, require the Partner to suspend any individual from work under this + Agreement while under investigation, provided that the safety of any such + individual or other individuals involved in the investigation is not compromised, + and subject to applicable law. Following the conclusion of the investigation by + the Partner, the Partner will promptly provide reports on the outcome of the + investigation, including any relevant details relating to the alleged offender, to + the extent legally possible. Upon request, the Partner will provide relevant + evidence to UNICEF for examination and further use by UNICEF as deemed + necessary solely by UNICEF. UNICEF may decide that the obligation on the part + of the Partner under the first sentence of this Article 4.5(g) to conduct an + investigation will not apply if an investigation is being or has been conducted by + competent national authorities. In the event that competent national authorities + are conducting or have conducted the investigation, the Partner will assist + UNICEF and take all necessary steps, to the extent legally possible, for UNICEF to + obtain information on the status and outcome of the investigation. These + obligations will survive the expiry or termination of this Agreement, with respect + to incidents occurring during the term of this Agreement.  The Partner will + provide information to UNICEF about any action it has taken in response to the + incident to meet safeguarding standards and reduce the chance of similar + concerns in the future. It is understood that UNICEF expects that safeguarding + concerns and violations will always be addressed. It is further understood that + any investigation conducted by the Partner under this clause will be without + prejudice to the right of UNICEF under Article 15.3 to conduct investigations. +

4.6 Ethical Standards in Evidence Generation: For @@ -1636,24 +1651,19 @@ appropriate alternative arrangements.

- 11. COMMITMENT TO TRANSPARENCY. UNICEF and - the Partner acknowledge their shared commitment to transparency. In this - regard, each Party may include references to this Agreement and to their - respective contributions to the implementation of this Agreement in their - public reports and in other materials in accordance with their respective - regulations, rules, policies and practices. UNICEF may disclose - information about the Partner pursuant to its policies, regulations, rules - and procedures and resolutions or regulations of UNICEF`s governing - bodies; this includes public disclosure by UNICEF of the Partner`s name, - the cash transfer amount from UNICEF to the Partner, location, purpose and - title of the Programme interventions. UNICEF may disclose to other UN - entities (i) the identities of individuals the Partner has dismissed or - removed for safeguarding violations, including sexual exploitation and - abuse; and (ii) the name of the Partner if this Agreement is suspended or - terminated, for failure to adequately safeguard or protect against sexual - exploitation and abuse. UNICEF may also disclose publicly non-personal - data about allegations of safeguarding violations, including sexual - exploitation and abuse. + 11. COMMITMENT TO TRANSPARENCY. UNICEF and the Partner acknowledge their shared + commitment to transparency. In this regard, each Party may include references to this + Agreement and to their respective contributions to the implementation of this + Agreement in their public reports and in other materials in accordance with their + respective regulations, rules, policies and practices. UNICEF may disclose information + about the Partner pursuant to its policies, regulations, rules and procedures and + resolutions or regulations of UNICEF’s governing bodies; this includes public disclosure + by UNICEF of the Partner’s name, the cash transfer amount from UNICEF to the Partner, + location, purpose and title of the Programme interventions. UNICEF may disclose to + other UN entities the name of the Partner if this Agreement is suspended or terminated + for cause, including for failure to adequately safeguard or protect against sexual + exploitation and abuse. UNICEF may also disclose publicly aggregated non-personal data + about allegations of safeguarding violations, including sexual exploitation and abuse.

12. @@ -1779,13 +1789,25 @@ or any other liability of any kind.

- 13.4 Immediately upon sending or receiving a notice of termination UNICEF + 13.4 As an alternative to suspension or termination, in case of any safeguarding concerns, + UNICEF can, with a clear rationale and justification, make a written request that the + Partner replaces one or more of the employees or other personnel or + subcontractors’ personnel assigned for a particular Programme under this + Agreement. UNICEF will work in consultation with the Partner to support the + replacement of the employee(s) in question with suitable qualified staff. Where the + Partner is not willing to accept a request to replace one or more of the employees or + other personnel or subcontractor’s personnel assigned for a particular Programme + under this Agreement, UNICEF may suspend or terminate this Agreement with + immediate effect upon written notice to the Partner. +

+

+ 13.5 Immediately upon sending or receiving a notice of termination UNICEF will cease disbursement of any funds under this Agreement and the Partner will not make any forward commitments, financial or otherwise, in connection with this Agreement.

- 13.5 On termination of this Agreement pursuant to this Article 13, the + 13.6 On termination of this Agreement pursuant to this Article 13, the Partner will (a) transfer either to UNICEF or in accordance with UNICEF`s instructions the unexpended balance of the Cash Transfer held by the Partner and the unused supplies and equipment provided by UNICEF under @@ -1797,7 +1819,7 @@ return to UNICEF all of UNICEF`s confidential information.

- 13.6 If UNICEF exercises its right to terminate this Agreement, UNICEF + 13.7 If UNICEF exercises its right to terminate this Agreement, UNICEF will have the right to require the Partner to repay to UNICEF such amount of money, up to the total amount paid to the Partner by UNICEF prior to the date of the notice of termination, as UNICEF will determine. It is @@ -1807,15 +1829,15 @@ promptly upon receipt of UNICEF`s notice to pay.

- 13.7 If UNICEF exercises its right to terminate this Agreement and decides + 13.8 If UNICEF exercises its right to terminate this Agreement and decides that the Programme is to be implemented by another organization or programming modality, the Partner will promptly provide full cooperation to UNICEF in the orderly transfer of all unused supplies and equipment - provided to the Partner by UNICEF and the provisions of Article 13.5 above + provided to the Partner by UNICEF and the provisions of Article 13.6 above will apply.

- 13.8 The suspension or termination of a Programme Document in accordance + 13.9 The suspension or termination of a Programme Document in accordance with this Section 13 will be without prejudice to the Programme Cooperation Agreement and other Programme Documents under this Agreement which will continue in effect, unless the Programme Cooperation Agreement diff --git a/src/etools/applications/partners/tests/factories.py b/src/etools/applications/partners/tests/factories.py index e1a4f6b96..7114b5503 100644 --- a/src/etools/applications/partners/tests/factories.py +++ b/src/etools/applications/partners/tests/factories.py @@ -4,6 +4,7 @@ from factory import fuzzy from etools.applications.attachments.tests.factories import AttachmentFactory +from etools.applications.field_monitoring.fm_settings.tests.factories import LocationSiteFactory from etools.applications.organizations.tests.factories import OrganizationFactory from etools.applications.partners import models from etools.applications.partners.models import InterventionManagementBudgetItem @@ -201,6 +202,15 @@ class Meta: year = datetime.datetime.today().year +class InterventionPlannedVisitSiteFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.InterventionPlannedVisitSite + + planned_visit = factory.SubFactory(InterventionPlannedVisitsFactory) + site = factory.SubFactory(LocationSiteFactory) + quarter = 1 + + class AgreementAmendmentFactory(factory.django.DjangoModelFactory): class Meta: diff --git a/src/etools/applications/partners/tests/test_api_amendments.py b/src/etools/applications/partners/tests/test_api_amendments.py index 60919c2bf..c309244f9 100644 --- a/src/etools/applications/partners/tests/test_api_amendments.py +++ b/src/etools/applications/partners/tests/test_api_amendments.py @@ -1,7 +1,9 @@ import datetime from unittest import mock, skip +from unittest.mock import patch from django.core.management import call_command +from django.db import connection from django.test import override_settings from django.urls import reverse from django.utils import timezone, translation @@ -13,6 +15,7 @@ from etools.applications.attachments.models import AttachmentFlat from etools.applications.attachments.tests.factories import AttachmentFactory from etools.applications.core.tests.cases import BaseTenantTestCase +from etools.applications.environment.tests.factories import TenantSwitchFactory from etools.applications.field_monitoring.fm_settings.tests.factories import LocationSiteFactory from etools.applications.partners.models import Intervention, InterventionAmendment from etools.applications.partners.permissions import PARTNERSHIP_MANAGER_GROUP, UNICEF_USER @@ -33,7 +36,7 @@ from etools.applications.users.tests.factories import UserFactory -class TestInterventionAmendments(BaseTenantTestCase): +class BaseTestInterventionAmendments: # test basic api flow @classmethod def setUpTestData(cls): @@ -82,6 +85,8 @@ def setUp(self): self.active_intervention.sections.add(SectionFactory()) ReportingRequirementFactory(intervention=self.active_intervention) + +class TestInterventionAmendments(BaseTestInterventionAmendments, BaseTenantTestCase): def test_no_permission_user_forbidden(self): '''Ensure a non-staff user gets the 403 smackdown''' response = self.forced_auth_req( @@ -191,7 +196,11 @@ def test_create_amendment_with_internal_prc_review(self): assert flat.pd_ssfa assert flat.pd_ssfa_number - def test_create_amendment_with_internal_prc_review_none(self): + @patch("etools.applications.partners.utils.send_notification_with_template") + def test_create_amendment_with_internal_prc_review_none(self, mock_send): + ts = TenantSwitchFactory(name="intervention_amendment_notifications_on", countries=[connection.tenant]) + self.assertTrue(ts.is_active) + response = self.forced_auth_req( 'post', reverse('partners_api:intervention-amendments-add', args=[self.active_intervention.pk]), @@ -202,7 +211,7 @@ def test_create_amendment_with_internal_prc_review_none(self): }, request_format='multipart', ) - + self.assertEqual(mock_send.call_count, 1) self.assertEquals(response.status_code, status.HTTP_201_CREATED) self.assertEquals(response.data['intervention'], self.active_intervention.pk) @@ -265,139 +274,6 @@ def test_start_amendment(self): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.data) - @mock.patch("etools.applications.partners.tasks.send_pd_to_vision.delay") - def test_amend_intervention(self, send_to_vision_mock): - country_programme = CountryProgrammeFactory() - intervention = InterventionFactory( - agreement__partner=self.partner, - partner_authorized_officer_signatory=UserFactory( - profile__organization=self.partner.organization, - is_staff=False, realms__data=['IP Authorized Officer'] - ), - unicef_signatory=UserFactory(), - country_programme=country_programme, - submission_date=timezone.now().date(), - start=timezone.now().date() + datetime.timedelta(days=1), - end=timezone.now().date() + datetime.timedelta(days=30), - date_sent_to_partner=timezone.now().date(), - signed_by_unicef_date=timezone.now().date(), - signed_by_partner_date=timezone.now().date(), - agreement__country_programme=country_programme, - cash_transfer_modalities=[Intervention.CASH_TRANSFER_DIRECT], - budget_owner=UserFactory(), - contingency_pd=False, - unicef_court=True, - ) - intervention.flat_locations.add(LocationFactory()) - intervention.planned_budget.total_hq_cash_local = 10 - intervention.planned_budget.save() - # FundsReservationHeaderFactory(intervention=intervention, currency='USD') # frs code is unique - ReportingRequirementFactory(intervention=intervention) - unicef_user = UserFactory(is_staff=True, realms__data=[UNICEF_USER, PARTNERSHIP_MANAGER_GROUP]) - intervention.unicef_focal_points.add(unicef_user) - intervention.sections.add(SectionFactory()) - intervention.offices.add(OfficeFactory()) - intervention.partner_focal_points.add(UserFactory( - profile__organization=self.partner.organization, - is_staff=False, realms__data=[] - )) - ReportingRequirementFactory(intervention=intervention) - - amendment = InterventionAmendment.objects.create( - intervention=intervention, - types=[InterventionAmendment.TYPE_ADMIN_ERROR], - ) - amended_intervention = amendment.amended_intervention - - response = self.forced_auth_req( - 'patch', - reverse('pmp_v3:intervention-detail', args=[amended_intervention.pk]), - unicef_user, - data={ - 'start': timezone.now().date() + datetime.timedelta(days=2), - }, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK, response.data) - - amended_intervention.refresh_from_db() - self.assertEqual(amended_intervention.start, timezone.now().date() + datetime.timedelta(days=2)) - - amended_intervention.unicef_accepted = True - amended_intervention.partner_accepted = True - amended_intervention.date_sent_to_partner = timezone.now().date() - amended_intervention.status = Intervention.REVIEW - amended_intervention.save() - review = InterventionReviewFactory( - intervention=amended_intervention, overall_approval=True, - overall_approver=UserFactory( - is_staff=True, realms__data=[UNICEF_USER, PARTNERSHIP_MANAGER_GROUP] - ), - ) - - # sign amended intervention - amended_intervention.signed_by_partner_date = intervention.signed_by_partner_date - amended_intervention.signed_by_unicef_date = intervention.signed_by_unicef_date - amended_intervention.partner_authorized_officer_signatory = intervention.partner_authorized_officer_signatory - amended_intervention.unicef_signatory = intervention.unicef_signatory - amended_intervention.save() - AttachmentFactory( - code='partners_intervention_signed_pd', - file='sample1.pdf', - content_object=amended_intervention - ) - - intervention.refresh_from_db() - self.assertEqual(intervention.start, timezone.now().date() + datetime.timedelta(days=1)) - - response = self.forced_auth_req( - 'patch', - reverse('pmp_v3:intervention-signature', args=[amended_intervention.pk]), - review.overall_approver, - data={} - ) - self.assertEqual(response.status_code, status.HTTP_200_OK, response.data) - amended_intervention.refresh_from_db() - self.assertEqual('signed', response.data['status']) - - with self.captureOnCommitCallbacks(execute=True) as commit_callbacks: - response = self.forced_auth_req( - 'patch', - reverse('pmp_v3:intervention-amendment-merge', args=[amended_intervention.pk]), - intervention.budget_owner, - data={} - ) - self.assertEqual(response.status_code, status.HTTP_200_OK, response.data) - self.assertEqual(response.data['id'], intervention.id) - - intervention.refresh_from_db() - self.assertEqual(intervention.start, timezone.now().date() + datetime.timedelta(days=2)) - send_to_vision_mock.assert_called() - self.assertEqual(len(commit_callbacks), 1) - - def test_merge_error(self): - first_amendment = InterventionAmendmentFactory( - intervention=self.active_intervention, kind=InterventionAmendment.KIND_NORMAL, - ) - second_amendment = InterventionAmendmentFactory( - intervention=self.active_intervention, kind=InterventionAmendment.KIND_CONTINGENCY, - ) - second_amendment.amended_intervention.start = timezone.now().date() - datetime.timedelta(days=15) - second_amendment.amended_intervention.save() - second_amendment.merge_amendment() - - first_amendment.amended_intervention.start = timezone.now().date() - datetime.timedelta(days=14) - first_amendment.amended_intervention.status = Intervention.SIGNED - first_amendment.amended_intervention.save() - - response = self.forced_auth_req( - 'patch', - reverse('pmp_v3:intervention-amendment-merge', args=[first_amendment.amended_intervention.pk]), - self.active_intervention.budget_owner, - data={} - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn('Merge Error', response.data[0]) - def test_permissions_fields_hidden(self): amendment = InterventionAmendmentFactory(intervention=self.active_intervention) response = self.forced_auth_req( @@ -522,6 +398,7 @@ def setUp(self): ) def test_delete(self): + self.intervention.unicef_focal_points.add(self.unicef_staff) response = self.forced_auth_req( 'delete', self.url, @@ -532,6 +409,7 @@ def test_delete(self): self.assertFalse(Intervention.objects.filter(pk=self.amendment.amended_intervention.pk).exists()) def test_delete_inactive(self): + self.intervention.unicef_focal_points.add(self.unicef_staff) self.amendment.is_active = False self.amendment.save() response = self.forced_auth_req( @@ -553,6 +431,7 @@ def test_intervention_amendments_delete(self): self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) def test_delete_active(self): + self.intervention.unicef_focal_points.add(self.unicef_staff) self.amendment.is_active = True self.amendment.save() response = self.forced_auth_req( @@ -562,23 +441,157 @@ def test_delete_active(self): ) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - def test_delete_active_focal_point(self): - self.intervention.status = 'active' - self.intervention.save() - self.intervention.unicef_focal_points.add(self.unicef_staff) + def test_delete_active_partnership_manager(self): + self.amendment.is_active = True + self.amendment.save() response = self.forced_auth_req( 'delete', self.url, - user=self.unicef_staff, + user=UserFactory(is_staff=True, realms__data=[UNICEF_USER, PARTNERSHIP_MANAGER_GROUP]), ) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +class TestInterventionAmendmentsMerge(BaseTestInterventionAmendments, BaseTenantTestCase): + def setUp(self): + super().setUp() + + country_programme = CountryProgrammeFactory() + self.intervention = InterventionFactory( + agreement__partner=self.partner, + partner_authorized_officer_signatory=UserFactory( + profile__organization=self.partner.organization, + is_staff=False, realms__data=['IP Viewer'] + ), + unicef_signatory=UserFactory(), + country_programme=country_programme, + submission_date=timezone.now().date(), + start=timezone.now().date() + datetime.timedelta(days=1), + end=timezone.now().date() + datetime.timedelta(days=30), + date_sent_to_partner=timezone.now().date(), + signed_by_unicef_date=timezone.now().date(), + signed_by_partner_date=timezone.now().date(), + agreement__country_programme=country_programme, + cash_transfer_modalities=[Intervention.CASH_TRANSFER_DIRECT], + budget_owner=UserFactory(), + contingency_pd=False, + unicef_court=True, + ) + self.intervention.flat_locations.add(LocationFactory()) + self.intervention.planned_budget.total_hq_cash_local = 10 + self.intervention.planned_budget.save() + # FundsReservationHeaderFactory(intervention=intervention, currency='USD') # frs code is unique + ReportingRequirementFactory(intervention=self.intervention) + self.unicef_focal_point = UserFactory(is_staff=True) + self.intervention.unicef_focal_points.add(self.unicef_focal_point) + self.intervention.sections.add(SectionFactory()) + self.intervention.offices.add(OfficeFactory()) + self.intervention.partner_focal_points.add(UserFactory( + profile__organization=self.partner.organization, + is_staff=False, realms__data=['IP Viewer'] + )) + ReportingRequirementFactory(intervention=self.intervention) + + amendment = InterventionAmendment.objects.create( + intervention=self.intervention, + types=[InterventionAmendment.TYPE_ADMIN_ERROR], + ) + self.amended_intervention = amendment.amended_intervention - def test_delete_active_partnership_manager(self): - self.intervention.status = 'active' - self.intervention.save() response = self.forced_auth_req( - 'delete', - self.url, - user=UserFactory(is_staff=True, realms__data=[UNICEF_USER, PARTNERSHIP_MANAGER_GROUP]), + 'patch', + reverse('pmp_v3:intervention-detail', args=[self.amended_intervention.pk]), + self.unicef_focal_point, + data={ + 'start': timezone.now().date() + datetime.timedelta(days=2), + }, ) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.data) + + self.amended_intervention.refresh_from_db() + self.assertEqual(self.amended_intervention.start, timezone.now().date() + datetime.timedelta(days=2)) + + self.amended_intervention.unicef_accepted = True + self.amended_intervention.partner_accepted = True + self.amended_intervention.date_sent_to_partner = timezone.now().date() + self.amended_intervention.status = Intervention.REVIEW + self.amended_intervention.save() + review = InterventionReviewFactory( + intervention=self.amended_intervention, overall_approval=True, + overall_approver=UserFactory(is_staff=True, realms__data=[UNICEF_USER, PARTNERSHIP_MANAGER_GROUP]), + ) + + # sign amended intervention + self.amended_intervention.signed_by_partner_date = self.intervention.signed_by_partner_date + self.amended_intervention.signed_by_unicef_date = self.intervention.signed_by_unicef_date + self.amended_intervention.partner_authorized_officer_signatory = self.intervention.partner_authorized_officer_signatory + self.amended_intervention.unicef_signatory = self.intervention.unicef_signatory + self.amended_intervention.save() + AttachmentFactory( + code='partners_intervention_signed_pd', + file='sample1.pdf', + content_object=self.amended_intervention + ) + + self.intervention.refresh_from_db() + self.assertEqual(self.intervention.start, timezone.now().date() + datetime.timedelta(days=1)) + + response = self.forced_auth_req( + 'patch', + reverse('pmp_v3:intervention-signature', args=[self.amended_intervention.pk]), + review.overall_approver, + data={} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.data) + self.amended_intervention.refresh_from_db() + self.assertEqual('signed', response.data['status']) + + @mock.patch("etools.applications.partners.tasks.send_pd_to_vision.delay") + def test_amend_intervention_budget_owner(self, send_to_vision_mock): + with self.captureOnCommitCallbacks(execute=True) as commit_callbacks: + response = self.forced_auth_req( + 'patch', + reverse('pmp_v3:intervention-amendment-merge', args=[self.amended_intervention.pk]), + self.intervention.budget_owner, + data={} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.data) + self.assertEqual(response.data['id'], self.intervention.id) + + self.intervention.refresh_from_db() + self.assertEqual(self.intervention.start, timezone.now().date() + datetime.timedelta(days=2)) + send_to_vision_mock.assert_called() + self.assertEqual(len(commit_callbacks), 1) + + def test_amend_intervention_focal_point(self): + response = self.forced_auth_req( + 'patch', + reverse('pmp_v3:intervention-amendment-merge', args=[self.amended_intervention.pk]), + self.unicef_focal_point, + data={} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.data) + + def test_merge_error(self): + first_amendment = InterventionAmendmentFactory( + intervention=self.active_intervention, kind=InterventionAmendment.KIND_NORMAL, + ) + second_amendment = InterventionAmendmentFactory( + intervention=self.active_intervention, kind=InterventionAmendment.KIND_CONTINGENCY, + ) + second_amendment.amended_intervention.start = timezone.now().date() - datetime.timedelta(days=15) + second_amendment.amended_intervention.save() + second_amendment.merge_amendment() + + first_amendment.amended_intervention.start = timezone.now().date() - datetime.timedelta(days=14) + first_amendment.amended_intervention.status = Intervention.SIGNED + first_amendment.amended_intervention.save() + + response = self.forced_auth_req( + 'patch', + reverse('pmp_v3:intervention-amendment-merge', args=[first_amendment.amended_intervention.pk]), + self.active_intervention.budget_owner, + data={} + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('Merge Error', response.data[0]) diff --git a/src/etools/applications/partners/tests/test_api_partners.py b/src/etools/applications/partners/tests/test_api_partners.py index 468843f48..bbf29b8b3 100644 --- a/src/etools/applications/partners/tests/test_api_partners.py +++ b/src/etools/applications/partners/tests/test_api_partners.py @@ -138,6 +138,35 @@ def test_get_partner_details(self): data = json.loads(response.rendered_content) self.assertEqual(self.intervention.pk, data["interventions"][0]["id"]) + def test_get_partner_staff_members(self): + for __ in range(10): + UserFactory( + realms__data=['IP Viewer'], + profile__organization=self.partner.organization + ) + for __ in range(5): + user = UserFactory( + realms__data=['IP Editor'], + profile__organization=self.partner.organization + ) + user.realms.update(is_active=False) + response = self.forced_auth_req( + 'get', + self.url, + user=self.unicef_staff + ) + data = json.loads(response.rendered_content) + self.assertEqual(self.intervention.pk, data["interventions"][0]["id"]) + self.assertEqual(len(data['staff_members']), self.partner.all_staff_members.count()) + self.assertEqual( + [staff['id'] for staff in data['staff_members'] if staff['has_active_realm']], + list(self.partner.active_staff_members.values_list('id', flat=True)) + ) + self.assertEqual( + [staff['id'] for staff in data['staff_members'] if not staff['has_active_realm']], + list(self.partner.all_staff_members.filter(has_active_realm=False).values_list('id', flat=True)) + ) + def test_patch_with_core_values_assessment_attachment(self): attachment = AttachmentFactory( file="test_file.pdf", @@ -952,6 +981,7 @@ def test_post(self): ) +@skip('TODO: hotfix to be addressed') class TestPartnerOrganizationDeleteView(BaseTenantTestCase): @classmethod def setUpTestData(cls): diff --git a/src/etools/applications/partners/tests/test_epd_management/test_programmatic_visits.py b/src/etools/applications/partners/tests/test_epd_management/test_programmatic_visits.py index 9f8824855..f39a7a8d2 100644 --- a/src/etools/applications/partners/tests/test_epd_management/test_programmatic_visits.py +++ b/src/etools/applications/partners/tests/test_epd_management/test_programmatic_visits.py @@ -4,6 +4,8 @@ from rest_framework import status +from etools.applications.field_monitoring.fm_settings.tests.factories import LocationSiteFactory +from etools.applications.partners.models import InterventionPlannedVisitSite from etools.applications.partners.tests.factories import InterventionFactory, InterventionPlannedVisitsFactory from etools.applications.partners.tests.test_epd_management.base import BaseTestCase from etools.applications.users.tests.factories import PMEUserFactory, UserFactory @@ -106,6 +108,9 @@ def test_partner_permissions(self): # test functionality def test_add(self): self.assertEqual(self.draft_intervention.planned_visits.count(), 0) + site_2 = LocationSiteFactory() + site_3 = LocationSiteFactory() + site_5 = LocationSiteFactory() response = self.forced_auth_req( 'patch', reverse('pmp_v3:intervention-detail', args=[self.draft_intervention.pk]), @@ -117,12 +122,25 @@ def test_add(self): 'programmatic_q2': 2, 'programmatic_q3': 3, 'programmatic_q4': 4, + "programmatic_q1_sites": [site_3.pk], + "programmatic_q2_sites": [site_3.pk], + "programmatic_q3_sites": [site_2.pk, site_3.pk], + "programmatic_q4_sites": [site_3.pk, site_5.pk], }], }, ) self.assertEqual(response.status_code, status.HTTP_200_OK, response.data) self.assertEqual(response.data['permissions']['edit']['planned_visits'], True) self.assertEqual(self.draft_intervention.planned_visits.count(), 1) + planned_visits = self.draft_intervention.planned_visits.first() + planned_sites = InterventionPlannedVisitSite.objects.filter(planned_visits=planned_visits) + self.assertEqual(planned_sites.filter(quarter=InterventionPlannedVisitSite.Q1).count(), 1) + self.assertEqual(planned_sites.filter(quarter=InterventionPlannedVisitSite.Q2).count(), 1) + self.assertEqual(planned_sites.filter(quarter=InterventionPlannedVisitSite.Q3).count(), 2) + self.assertEqual(planned_sites.filter(quarter=InterventionPlannedVisitSite.Q4).count(), 2) + self.assertEqual(self.draft_intervention.planned_visits.first().sites.count(), 1 + 1 + 2 + 2) + self.assertEqual(len(response.data['planned_visits'][0]['programmatic_q4_sites']), 2) + self.assertIn('name', response.data['planned_visits'][0]['programmatic_q4_sites'][0]) def test_update(self): visit = InterventionPlannedVisitsFactory( diff --git a/src/etools/applications/partners/tests/test_export_partner.py b/src/etools/applications/partners/tests/test_export_partner.py index 959f74179..1d50c6168 100644 --- a/src/etools/applications/partners/tests/test_export_partner.py +++ b/src/etools/applications/partners/tests/test_export_partner.py @@ -234,7 +234,7 @@ class TestPartnerStaffMemberModelExport(PartnerModelExportTestCase): def test_invalid_format_export_api(self): response = self.forced_auth_req( 'get', - reverse('partners_api:partner-staff-members-list', args=[self.partner.pk]), + reverse('pmp_v3:partner-staff-members-list', args=[self.partner.pk]), user=self.unicef_staff, data={"format": "unknown"}, ) @@ -244,7 +244,7 @@ def test_invalid_format_export_api(self): def test_csv_export_api(self): response = self.forced_auth_req( 'get', - reverse('partners_api:partner-staff-members-list', args=[self.partner.pk]), + reverse('pmp_v3:partner-staff-members-list', args=[self.partner.pk]), user=self.unicef_staff, data={"format": "csv"}, ) @@ -258,7 +258,7 @@ def test_csv_export_api(self): def test_csv_flat_export_api(self): response = self.forced_auth_req( 'get', - reverse('partners_api:partner-staff-members-list', args=[self.partner.pk]), + reverse('pmp_v3:partner-staff-members-list', args=[self.partner.pk]), user=self.unicef_staff, data={"format": "csv_flat"}, ) diff --git a/src/etools/applications/partners/tests/test_models.py b/src/etools/applications/partners/tests/test_models.py index 32ae5b183..2820ed32f 100644 --- a/src/etools/applications/partners/tests/test_models.py +++ b/src/etools/applications/partners/tests/test_models.py @@ -594,7 +594,7 @@ def test_audits_completed_update_travel_activity(self): date_of_draft_report_to_ip=datetime.datetime(datetime.datetime.today().year, 8, 1) ) self.partner_organization.update_audits_completed() - self.assertEqual(self.partner_organization.hact_values['audits']['completed'], 2) + self.assertEqual(self.partner_organization.hact_values['audits']['completed'], 3) def test_partner_organization_get_admin_url(self): "Test that get_admin_url produces the URL we expect." @@ -681,6 +681,7 @@ def test_permission_structure(self): 'true': [ {'status': 'signed', 'group': 'Unicef Focal Point', 'condition': 'not_in_amendment_mode'}, {'status': 'active', 'group': 'Unicef Focal Point', 'condition': 'not_in_amendment_mode'}, + {'status': 'ended', 'group': 'Unicef Focal Point', 'condition': 'post_epd_temp_conditions'} ] } }) diff --git a/src/etools/applications/partners/tests/test_prp_sync.py b/src/etools/applications/partners/tests/test_prp_sync.py index 2ed942623..ab6c04bc1 100644 --- a/src/etools/applications/partners/tests/test_prp_sync.py +++ b/src/etools/applications/partners/tests/test_prp_sync.py @@ -1,135 +1,129 @@ -import json -from collections import namedtuple -from unittest.mock import patch - -from django.db import connection -from django.test import override_settings -from django.utils import timezone - -from etools.applications.core.tests.cases import BaseTenantTestCase -from etools.applications.partners.tasks import sync_partner_to_prp, sync_partners_staff_members_from_prp -from etools.applications.partners.tests.factories import InterventionFactory, PartnerFactory -from etools.applications.users.models import User -from etools.applications.users.tests.factories import UserFactory - - -class TestInterventionPartnerSyncSignal(BaseTenantTestCase): - @patch('etools.applications.partners.signals.sync_partner_to_prp.delay') - def test_intervention_sync_called(self, sync_task_mock): - intervention = InterventionFactory(date_sent_to_partner=None) - sync_task_mock.assert_not_called() - - intervention.date_sent_to_partner = timezone.now() - intervention.save() - sync_task_mock.assert_called_with(connection.tenant.name, intervention.agreement.partner_id) - - @patch('etools.applications.partners.signals.sync_partner_to_prp.delay') - def test_intervention_sync_not_called_on_save(self, sync_task_mock): - intervention = InterventionFactory(date_sent_to_partner=None) - sync_task_mock.assert_not_called() - - intervention.start = timezone.now().date() - intervention.save() - sync_task_mock.assert_not_called() - - @patch('etools.applications.partners.signals.sync_partner_to_prp.delay') - def test_intervention_sync_called_on_create(self, sync_task_mock): - intervention = InterventionFactory(date_sent_to_partner=timezone.now()) - sync_task_mock.assert_called_with(connection.tenant.name, intervention.agreement.partner_id) - - -class TestInterventionPartnerSyncTask(BaseTenantTestCase): - @override_settings(PRP_API_ENDPOINT='http://example.com/api/') - @patch( - 'etools.applications.partners.prp_api.requests.post', - return_value=namedtuple('Response', ['status_code', 'text'])(200, '{}') - ) - def test_request_to_prp_sent(self, request_mock): - intervention = InterventionFactory(date_sent_to_partner=None) - request_mock.assert_not_called() - - sync_partner_to_prp(connection.tenant.name, intervention.agreement.partner_id) - request_mock.assert_called() - - -class TestPartnerStaffMembersImportTask(BaseTenantTestCase): - @override_settings(PRP_API_ENDPOINT='http://example.com/api/') - @patch( - 'etools.applications.partners.prp_api.requests.post', - return_value=namedtuple('Response', ['status_code', 'text'])(200, '{}') - ) - def test_request_to_prp_sent(self, request_mock): - intervention = InterventionFactory(date_sent_to_partner=None) - request_mock.assert_not_called() - - sync_partner_to_prp(connection.tenant.name, intervention.agreement.partner_id) - request_mock.assert_called() - - @classmethod - def setUpTestData(cls): - cls.partner = PartnerFactory() - cls.staff_member = UserFactory( - profile__organization=cls.partner.organization, - realms__data=['IP Viewer'] - ) - cls.prp_partners_export_response_data = { - 'count': 2, - 'results': [ - { - 'id': 1, 'external_id': str(cls.partner.id), - 'unicef_vendor_number': cls.partner.vendor_number, 'name': cls.partner.name - }, - {'id': 2, 'external_id': -1, 'unicef_vendor_number': '', 'name': 'Unknown Co'}, - ] - } - cls.prp_partner_staff_members_response_data = { - 'count': 2, - 'results': [ - { - 'email': cls.staff_member.email.upper(), 'title': cls.staff_member.profile.job_title, - 'first_name': cls.staff_member.first_name, 'last_name': cls.staff_member.last_name, - 'phone_number': cls.staff_member.profile.phone_number, 'is_active': True, - }, - { - 'email': 'anonymous@example.com', 'title': 'Unknown User', - 'first_name': 'Unknown', 'last_name': 'User', - 'phone_number': '-995122341', 'is_active': True, - }, - ] - } - - def get_prp_export_response(*args, **kwargs): - url = kwargs['url'] - if '/unicef/pmp/export/partners/?page=' in url: - return namedtuple('Response', ['status_code', 'text'])( - 200, json.dumps(cls.prp_partners_export_response_data) - ) - elif '/staff-members/?page=' in url: - return namedtuple('Response', ['status_code', 'text'])( - 200, json.dumps(cls.prp_partner_staff_members_response_data) - ) - else: - return namedtuple('Response', ['status_code', 'text'])(404, '{}') - cls.get_prp_export_response = get_prp_export_response - - @override_settings(PRP_API_ENDPOINT='http://example.com/api/') - @patch('etools.applications.partners.prp_api.requests.get') - def test_sync(self, request_mock): - self.assertEqual(self.partner.active_staff_members.count(), 1) - - self.staff_member.active = False - self.staff_member.save() - - request_mock.side_effect = self.get_prp_export_response - sync_partners_staff_members_from_prp() - - self.assertTrue(self.partner.active_staff_members.count(), 2) - - # check second user created - self.assertTrue(User.objects.filter(email='anonymous@example.com').exists()) - - # check first user was updated - self.staff_member.refresh_from_db() - self.assertTrue(self.staff_member.is_active) - # and email was not changed even if provided in uppercase - self.assertNotEqual(self.staff_member.email, self.staff_member.email.upper()) +# from django.db import connection +# from django.utils import timezone +# +# from etools.applications.core.tests.cases import BaseTenantTestCase +# from etools.applications.partners.tests.factories import InterventionFactory + +# TODO clean up: endpoint removed in prp' +# class TestInterventionPartnerSyncSignal(BaseTenantTestCase): +# def test_intervention_sync_called(self, sync_task_mock): +# intervention = InterventionFactory(date_sent_to_partner=None) +# sync_task_mock.assert_not_called() +# +# intervention.date_sent_to_partner = timezone.now() +# intervention.save() +# sync_task_mock.assert_called_with(connection.tenant.name, intervention.agreement.partner_id) +# +# def test_intervention_sync_not_called_on_save(self, sync_task_mock): +# intervention = InterventionFactory(date_sent_to_partner=None) +# sync_task_mock.assert_not_called() +# +# intervention.start = timezone.now().date() +# intervention.save() +# sync_task_mock.assert_not_called() +# +# def test_intervention_sync_called_on_create(self, sync_task_mock): +# intervention = InterventionFactory(date_sent_to_partner=timezone.now()) +# sync_task_mock.assert_called_with(connection.tenant.name, intervention.agreement.partner_id) + + +# TODO clean up: endpoint removed in prp' +# class TestInterventionPartnerSyncTask(BaseTenantTestCase): +# @override_settings(PRP_API_ENDPOINT='http://example.com/api/') +# @patch( +# 'etools.applications.partners.prp_api.requests.post', +# return_value=namedtuple('Response', ['status_code', 'text'])(200, '{}') +# ) +# def test_request_to_prp_sent(self, request_mock): +# intervention = InterventionFactory(date_sent_to_partner=None) +# +# UserFactory( +# profile__organization=intervention.agreement.partner.organization, realms__data=['IP Viewer'] +# ) +# request_mock.assert_not_called() +# +# sync_partner_to_prp(connection.tenant.name, intervention.agreement.partner_id) +# request_mock.assert_called() +# +# +# class TestPartnerStaffMembersImportTask(BaseTenantTestCase): +# @override_settings(PRP_API_ENDPOINT='http://example.com/api/') +# @patch( +# 'etools.applications.partners.prp_api.requests.post', +# return_value=namedtuple('Response', ['status_code', 'text'])(200, '{}') +# ) +# def test_request_to_prp_sent(self, request_mock): +# intervention = InterventionFactory(date_sent_to_partner=None) +# request_mock.assert_not_called() +# +# sync_partner_to_prp(connection.tenant.name, intervention.agreement.partner_id) +# request_mock.assert_called() +# +# @classmethod +# def setUpTestData(cls): +# cls.partner = PartnerFactory() +# cls.staff_member = UserFactory( +# profile__organization=cls.partner.organization, +# realms__data=['IP Viewer'] +# ) +# cls.prp_partners_export_response_data = { +# 'count': 2, +# 'results': [ +# { +# 'id': 1, 'external_id': str(cls.partner.id), +# 'unicef_vendor_number': cls.partner.vendor_number, 'name': cls.partner.name +# }, +# {'id': 2, 'external_id': -1, 'unicef_vendor_number': '', 'name': 'Unknown Co'}, +# ] +# } +# cls.prp_partner_staff_members_response_data = { +# 'count': 2, +# 'results': [ +# { +# 'email': cls.staff_member.email.upper(), 'title': cls.staff_member.profile.job_title, +# 'first_name': cls.staff_member.first_name, 'last_name': cls.staff_member.last_name, +# 'phone_number': cls.staff_member.profile.phone_number, 'is_active': True, +# }, +# { +# 'email': 'anonymous@example.com', 'title': 'Unknown User', +# 'first_name': 'Unknown', 'last_name': 'User', +# 'phone_number': '-995122341', 'is_active': True, +# }, +# ] +# } +# +# def get_prp_export_response(*args, **kwargs): +# url = kwargs['url'] +# if '/unicef/pmp/export/partners/?page=' in url: +# return namedtuple('Response', ['status_code', 'text'])( +# 200, json.dumps(cls.prp_partners_export_response_data) +# ) +# elif '/staff-members/?page=' in url: +# return namedtuple('Response', ['status_code', 'text'])( +# 200, json.dumps(cls.prp_partner_staff_members_response_data) +# ) +# else: +# return namedtuple('Response', ['status_code', 'text'])(404, '{}') +# cls.get_prp_export_response = get_prp_export_response +# +# @override_settings(PRP_API_ENDPOINT='http://example.com/api/') +# @patch('etools.applications.partners.prp_api.requests.get') +# def test_sync(self, request_mock): +# self.assertEqual(self.partner.active_staff_members.count(), 1) +# +# self.staff_member.active = False +# self.staff_member.save() +# +# request_mock.side_effect = self.get_prp_export_response +# sync_partners_staff_members_from_prp() +# +# self.assertTrue(self.partner.active_staff_members.count(), 2) +# +# # check second user created +# self.assertTrue(User.objects.filter(email='anonymous@example.com').exists()) +# +# # check first user was updated +# self.staff_member.refresh_from_db() +# self.assertTrue(self.staff_member.is_active) +# # and email was not changed even if provided in uppercase +# self.assertNotEqual(self.staff_member.email, self.staff_member.email.upper()) diff --git a/src/etools/applications/partners/tests/test_serializers.py b/src/etools/applications/partners/tests/test_serializers.py index 842cc7a9a..2f88247ce 100644 --- a/src/etools/applications/partners/tests/test_serializers.py +++ b/src/etools/applications/partners/tests/test_serializers.py @@ -173,7 +173,7 @@ def test_validation_translation(self): self.assertSimpleExceptionFundamentals( context_manager, "Un PCA avec ce partenaire existe déjà pour ce cycle de programme de pays. Si " - "l'enregistrement est à l'état \"Brouillon\", veuillez le modifier." + "l'enregistrement est à l'état \"Développement\", veuillez le modifier." ) def test_create_ok_non_PCA_with_same_programme_and_partner(self): @@ -761,5 +761,5 @@ def test_retrieve(self): self.assertEquals(len(data['staff_members']), 2) self.assertCountEqual(data['staff_members'][0].keys(), [ 'active', 'created', 'email', 'first_name', 'id', - 'last_name', 'modified', 'phone', 'title' + 'last_name', 'modified', 'phone', 'title', 'has_active_realm' ]) diff --git a/src/etools/applications/partners/tests/test_tasks.py b/src/etools/applications/partners/tests/test_tasks.py index 53b9bdf92..5fbb511e3 100644 --- a/src/etools/applications/partners/tests/test_tasks.py +++ b/src/etools/applications/partners/tests/test_tasks.py @@ -7,6 +7,7 @@ from decimal import Decimal from pprint import pformat from unittest import mock +from unittest.mock import patch from django.conf import settings from django.contrib.auth import get_user_model @@ -42,7 +43,8 @@ ReportingRequirementFactory, SectionFactory, ) -from etools.applications.users.tests.factories import CountryFactory, UserFactory +from etools.applications.users.tasks import sync_realms_to_prp +from etools.applications.users.tests.factories import CountryFactory, RealmFactory, UserFactory def _build_country(name): @@ -1197,7 +1199,8 @@ def test_transfer(self): transfer_active_pds_to_new_cp() pd.refresh_from_db() - self.assertListEqual(list(pd.country_programmes.all()), [self.old_cp, self.active_cp]) + self.assertListEqual(list(pd.country_programmes.all().order_by('id')), + sorted([self.old_cp, self.active_cp], key=lambda x: x.pk)) def test_skip_transfer_if_one_programme_already_active(self): second_active_cp = CountryProgrammeFactory( @@ -1276,3 +1279,108 @@ def test_body_rendering(self, _logger_mock): str_data = synchronizer.render() self.assertIsInstance(str_data, bytes) self.assertGreater(len(str_data), 100) + + +class TestRealmsPRPExport(BaseTenantTestCase): + @classmethod + def setUpTestData(cls): + UserFactory(email='prp@example.com', realms__data=[]) + + @override_settings(UNICEF_USER_EMAIL="@another_example.com", + PRP_API_ENDPOINT='http://example.com/api/', + PRP_API_USER='prp@example.com') + @patch('etools.applications.users.signals.sync_realms_to_prp.apply_async') + @patch( + 'etools.applications.partners.prp_api.requests.post', + return_value=namedtuple('Response', ['status_code', 'text'])(200, '{}') + ) + def test_realms_sync_on_create(self, requests_post_mock, sync_mock): + sync_mock.side_effect = lambda *args, **_kwargs: sync_realms_to_prp(*args[0]) + + user = UserFactory(realms__data=[]) + self.assertFalse(user.is_unicef_user()) + with self.captureOnCommitCallbacks(execute=True) as commit_callbacks: + realm = RealmFactory(user=user) + sync_mock.assert_called_with( + (user.pk, realm.modified.timestamp()), + eta=realm.modified + datetime.timedelta(minutes=5), + ) + requests_post_mock.assert_called() + self.assertEqual(len(commit_callbacks), 1) + + @override_settings(UNICEF_USER_EMAIL="@another_example.com", + PRP_API_ENDPOINT='http://example.com/api/', + PRP_API_USER='prp@example.com') + @patch('etools.applications.users.signals.sync_realms_to_prp.apply_async') + @patch( + 'etools.applications.partners.prp_api.requests.post', + return_value=namedtuple('Response', ['status_code', 'text'])(200, '{}') + ) + def test_realms_call_once_on_create(self, requests_post_mock, sync_mock): + sync_mock.side_effect = lambda *args, **_kwargs: sync_realms_to_prp(*args[0]) + + user = UserFactory(realms__data=[]) + self.assertFalse(user.is_unicef_user()) + with self.captureOnCommitCallbacks(execute=False) as commit_callbacks: + RealmFactory(user=user) + RealmFactory(user=user) + + for callback in commit_callbacks: + callback() + + self.assertEqual(sync_mock.call_count, 2) + requests_post_mock.assert_called_once() + + @override_settings(UNICEF_USER_EMAIL="@another_example.com", + PRP_API_ENDPOINT='http://example.com/api/', + PRP_API_USER='prp@example.com') + @patch('etools.applications.users.signals.sync_realms_to_prp.apply_async') + @patch( + 'etools.applications.partners.prp_api.requests.post', + return_value=namedtuple('Response', ['status_code', 'text'])(200, '{}') + ) + def test_realms_sync_on_delete(self, requests_post_mock, sync_mock): + sync_mock.side_effect = lambda *args, **_kwargs: sync_realms_to_prp(*args[0]) + + user = UserFactory(realms__data=[]) + self.assertFalse(user.is_unicef_user()) + realm = RealmFactory(user=user) + with self.captureOnCommitCallbacks(execute=True) as commit_callbacks: + realm.delete() + sync_mock.assert_called() + requests_post_mock.assert_called() + self.assertEqual(len(commit_callbacks), 1) + + @override_settings(UNICEF_USER_EMAIL="@another_example.com", + PRP_API_ENDPOINT='http://example.com/api/', + PRP_API_USER='prp@example.com') + @patch('etools.applications.users.signals.sync_realms_to_prp.apply_async') + @patch( + 'etools.applications.partners.prp_api.requests.post', + return_value=namedtuple('Response', ['status_code', 'text'])(200, '{}') + ) + def test_realms_sync_on_change(self, requests_post_mock, sync_mock): + sync_mock.side_effect = lambda *args, **_kwargs: sync_realms_to_prp(*args[0]) + + user = UserFactory(realms__data=[]) + self.assertFalse(user.is_unicef_user()) + realm = RealmFactory(user=user) + with self.captureOnCommitCallbacks(execute=True) as commit_callbacks: + realm.is_active = False + realm.save() + sync_mock.assert_called_with( + (user.pk, realm.modified.timestamp()), + eta=realm.modified + datetime.timedelta(minutes=5), + ) + requests_post_mock.assert_called() + self.assertEqual(len(commit_callbacks), 1) + + @override_settings(UNICEF_USER_EMAIL="@example.com") + @patch('etools.applications.users.signals.sync_realms_to_prp.apply_async') + def test_realms_sync_unicef(self, sync_mock): + user = UserFactory() + self.assertTrue(user.is_unicef_user()) + with self.captureOnCommitCallbacks(execute=True) as commit_callbacks: + RealmFactory(user=user) + sync_mock.assert_not_called() + self.assertEqual(len(commit_callbacks), 0) diff --git a/src/etools/applications/partners/tests/test_v3_interventions.py b/src/etools/applications/partners/tests/test_v3_interventions.py index af178198d..c15fb0545 100644 --- a/src/etools/applications/partners/tests/test_v3_interventions.py +++ b/src/etools/applications/partners/tests/test_v3_interventions.py @@ -4,6 +4,7 @@ from unittest import mock, skip from unittest.mock import patch +from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser from django.contrib.contenttypes.models import ContentType from django.core.files.uploadedfile import SimpleUploadedFile @@ -147,11 +148,12 @@ def test_list_for_partner(self): intervention.partner_focal_points.add(staff_member) # not sent to partner - response = self.forced_auth_req( - "get", - reverse('pmp_v3:intervention-list'), - user=staff_member, - ) + with self.assertNumQueries(6): + response = self.forced_auth_req( + "get", + reverse('pmp_v3:intervention-list'), + user=staff_member, + ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 0) @@ -170,26 +172,30 @@ def test_list_for_partner(self): def test_intervention_list_without_show_amendments_flag(self): InterventionAmendmentFactory() - response = self.forced_auth_req( - 'get', - reverse('pmp_v3:intervention-list'), - user=self.unicef_user, - ) + with self.assertNumQueries(10): + response = self.forced_auth_req( + 'get', + reverse('pmp_v3:intervention-list'), + user=self.unicef_user, + ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 1) def test_intervention_list_with_show_amendments_flag(self): - InterventionAmendmentFactory() - response = self.forced_auth_req( - 'get', - reverse('pmp_v3:intervention-list'), - user=self.unicef_user, - data={'show_amendments': True} - ) + for i in range(20): + InterventionAmendmentFactory() + + with self.assertNumQueries(10): + response = self.forced_auth_req( + 'get', + reverse('pmp_v3:intervention-list'), + user=self.unicef_user, + data={'show_amendments': True} + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), 2) + self.assertEqual(len(response.data), 40) def test_not_authenticated(self): response = self.forced_auth_req( @@ -416,6 +422,51 @@ def test_confidential_permissions_partner_user(self): self.assertFalse(response.data['permissions']['view']['confidential']) self.assertFalse(response.data['permissions']['edit']['confidential']) + def test_cfei_number_permissions_unicef_focal(self): + self.intervention.unicef_focal_points.add(self.unicef_user) + self.assertFalse(self.intervention.cfei_number) + self.assertEqual(self.intervention.status, Intervention.DRAFT) + response = self.forced_auth_req( + "get", + reverse('pmp_v3:intervention-detail', args=[self.intervention.pk]), + user=self.unicef_user, + ) + self.assertTrue(response.data['permissions']['view']['cfei_number']) + self.assertTrue(response.data['permissions']['edit']['cfei_number']) + + self.intervention.status = Intervention.ACTIVE + self.intervention.save(update_fields=['status']) + response = self.forced_auth_req( + "get", + reverse('pmp_v3:intervention-detail', args=[self.intervention.pk]), + user=self.unicef_user, + ) + self.assertTrue(response.data['permissions']['view']['cfei_number']) + self.assertTrue(response.data['permissions']['edit']['cfei_number']) + + self.intervention.cfei_number = '12345' + self.intervention.save(update_fields=['cfei_number']) + response = self.forced_auth_req( + "get", + reverse('pmp_v3:intervention-detail', args=[self.intervention.pk]), + user=self.unicef_user, + ) + self.assertTrue(response.data['permissions']['view']['cfei_number']) + self.assertFalse(response.data['permissions']['edit']['cfei_number']) + + def test_cfei_number_permissions_country_office_admin(self): + country_office_admin = UserFactory( + is_staff=True, realms__data=[UNICEF_USER, "Country Office Administrator"] + ) + response = self.forced_auth_req( + "get", + reverse('pmp_v3:intervention-detail', args=[self.intervention.pk]), + user=country_office_admin, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data['permissions']['view']['cfei_number']) + self.assertTrue(response.data['permissions']['edit']['cfei_number']) + def test_pdf_partner_user(self): staff_member = UserFactory( realms__data=['IP Viewer'], @@ -762,6 +813,61 @@ def test_patch_currency(self): budget.refresh_from_db() self.assertEqual(budget.currency, "PEN") + def test_patch_has_unfunded_cash(self): + intervention = InterventionFactory() + intervention.unicef_focal_points.add(self.unicef_user) + budget = intervention.planned_budget + self.assertFalse(budget.has_unfunded_cash) + + response = self.forced_auth_req( + "patch", + reverse('pmp_v3:intervention-detail', args=[intervention.pk]), + user=self.unicef_user, + data={'planned_budget': { + "id": budget.pk, + "has_unfunded_cash": True, + }} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + budget.refresh_from_db() + self.assertTrue(budget.has_unfunded_cash) + + def test_patch_unfunded_cash_local(self): + intervention = InterventionFactory() + intervention.unicef_focal_points.add(self.unicef_user) + budget = intervention.planned_budget + self.assertEqual(budget.unfunded_cash_local, 0) + + response = self.forced_auth_req( + "patch", + reverse('pmp_v3:intervention-detail', args=[intervention.pk]), + user=self.unicef_user, + data={'planned_budget': { + "id": budget.pk, + "unfunded_cash_local": 1234, + }} + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn( + 'This programme document does not include unfunded amounts', + response.data['planned_budget']['unfunded_cash_local'] + ) + budget.has_unfunded_cash = True + budget.save(update_fields=['has_unfunded_cash']) + + response = self.forced_auth_req( + "patch", + reverse('pmp_v3:intervention-detail', args=[intervention.pk]), + user=self.unicef_user, + data={'planned_budget': { + "id": budget.pk, + "unfunded_cash_local": 1234, + }} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + budget.refresh_from_db() + self.assertEqual(budget.unfunded_cash_local, 1234) + def test_patch_country_programme(self): intervention = InterventionFactory() agreement = intervention.agreement @@ -2266,6 +2372,29 @@ def test_patch(self): self.assertIn("PD is already in Review status.", response.data) mock_send.assert_not_called() + def test_notification_sent_to_prc_secretary(self): + self.intervention.partner_accepted = True + self.intervention.unicef_accepted = True + self.intervention.date_sent_to_partner = datetime.date.today() + self.intervention.submission_date_prc = None + self.intervention.save() + + UserFactory(realms__data=[PRC_SECRETARY, UNICEF_USER]) + + # unicef reviews + mock_send = mock.Mock(return_value=self.mock_email) + with mock.patch(self.notify_path, mock_send): + response = self.forced_auth_req("patch", self.url, user=self.unicef_user, data={'review_type': 'prc'}) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.data) + self.intervention.refresh_from_db() + self.assertEqual(self.intervention.status, Intervention.REVIEW) + mock_send.assert_called() + prc_secretaries_number = get_user_model().objects.filter(realms__group__name=PRC_SECRETARY).distinct().count() + self.assertEqual( + len(mock_send.mock_calls[0].kwargs['recipients']), + self.intervention.unicef_focal_points.count() + prc_secretaries_number + ) + def test_patch_after_reject(self): self.intervention.partner_accepted = True self.intervention.unicef_accepted = True @@ -2413,20 +2542,13 @@ def test_patch(self): class TestInterventionReviews(BaseInterventionTestCase): def setUp(self): super().setUp() - self.partner = PartnerFactory() self.intervention = InterventionFactory( date_sent_to_partner=datetime.date.today(), - agreement__partner=self.partner, status=Intervention.REVIEW, ) - RealmFactory( - user=self.unicef_user, - organization=self.partner.organization, - country=connection.tenant, - group=GroupFactory(name=PRC_SECRETARY) + self.unicef_prc_secretary = UserFactory( + is_staff=True, realms__data=[UNICEF_USER, PRC_SECRETARY] ) - self.unicef_user.profile.organization = self.partner.organization - self.unicef_user.profile.save(update_fields=['organization']) def test_list(self): for __ in range(10): @@ -2441,7 +2563,7 @@ def test_list(self): "pmp_v3:intervention-reviews", args=[self.intervention.pk], ), - user=self.unicef_user, + user=self.unicef_prc_secretary, ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), review_qs.count()) @@ -2455,7 +2577,7 @@ def test_post(self): args=[self.intervention.pk], ), data={}, - user=self.unicef_user, + user=self.unicef_prc_secretary, ) self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) @@ -2467,7 +2589,7 @@ def test_get(self): "pmp_v3:intervention-reviews-detail", args=[self.intervention.pk, review.pk], ), - user=self.unicef_user + user=self.unicef_prc_secretary ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["id"], review.pk) @@ -2486,7 +2608,7 @@ def test_patch(self): data={ "overall_comment": "second", }, - user=self.unicef_user + user=self.unicef_prc_secretary ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["id"], review.pk) diff --git a/src/etools/applications/partners/tests/test_v3_partner_organizations.py b/src/etools/applications/partners/tests/test_v3_partner_organizations.py index c2d4d1d59..44b3d23b5 100644 --- a/src/etools/applications/partners/tests/test_v3_partner_organizations.py +++ b/src/etools/applications/partners/tests/test_v3_partner_organizations.py @@ -53,11 +53,12 @@ def setUp(self): class TestPartnerOrganizationList(BasePartnerOrganizationTestCase): def test_list_for_unicef(self): PartnerFactory() - response = self.forced_auth_req( - "get", - reverse('pmp_v3:partner-list'), - user=self.unicef_user, - ) + with self.assertNumQueries(4): + response = self.forced_auth_req( + "get", + reverse('pmp_v3:partner-list'), + user=self.unicef_user, + ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual( len(response.data), @@ -70,12 +71,12 @@ def test_list_for_partner(self): realms__data=['IP Viewer'], profile__organization=partner.organization ) - - response = self.forced_auth_req( - "get", - reverse('pmp_v3:partner-list'), - user=user, - ) + with self.assertNumQueries(3): + response = self.forced_auth_req( + "get", + reverse('pmp_v3:partner-list'), + user=user, + ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 1) self.assertEqual(response.data[0]["id"], partner.pk) @@ -104,8 +105,37 @@ def test_list_for_unicef(self): for __ in range(10): UserFactory( realms__data=['IP Viewer'], - profile__organization=self.partner.organization + profile__organization=partner.organization + ) + response = self.forced_auth_req( + "get", + reverse('pmp_v3:partner-staff-members-list', args=[partner.pk]), + user=self.unicef_user, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + len(response.data), + partner.all_staff_members.count(), + ) + self.assertEqual( + partner.all_staff_members.count(), + 10 + ) + + def test_list_with_realm_active_inactive(self): + partner = PartnerFactory() + for __ in range(10): + UserFactory( + realms__data=['IP Viewer'], + profile__organization=partner.organization + ) + for __ in range(5): + user = UserFactory( + realms__data=['IP Editor'], + profile__organization=partner.organization ) + user.realms.update(is_active=False) + response = self.forced_auth_req( "get", reverse('pmp_v3:partner-staff-members-list', args=[partner.pk]), @@ -116,13 +146,21 @@ def test_list_for_unicef(self): len(response.data), partner.all_staff_members.count(), ) + self.assertEqual( + partner.all_staff_members.count(), + 15 + ) + self.assertEqual( + partner.active_staff_members.count(), + 10 + ) def test_list_for_partner(self): partner = PartnerFactory() for __ in range(10): user = UserFactory( realms__data=['IP Viewer'], - profile__organization=self.partner.organization + profile__organization=partner.organization ) response = self.forced_auth_req( @@ -135,7 +173,10 @@ def test_list_for_partner(self): len(response.data), partner.all_staff_members.count(), ) - + self.assertEqual( + partner.all_staff_members.count(), + 10 + ) # partner user not able to view another partners users partner_2 = PartnerFactory() user_2 = UserFactory( diff --git a/src/etools/applications/partners/tests/test_views.py b/src/etools/applications/partners/tests/test_views.py index d1d8d4ee8..adab39b22 100644 --- a/src/etools/applications/partners/tests/test_views.py +++ b/src/etools/applications/partners/tests/test_views.py @@ -85,7 +85,6 @@ def test_urls(self): ('partner-delete', 'delete/1/', {'pk': 1}), ('partner-assessment-detail', 'assessments/1/', {'pk': 1}), ('partner-add', 'add/', {}), - ('partner-staff-members-list', '1/staff-members/', {'partner_pk': 1}), ) self.assertReversal(names_and_paths, 'partners_api:', '/api/v2/partners/') self.assertIntParamRegexes(names_and_paths, 'partners_api:') @@ -1230,35 +1229,6 @@ def test_agreement_add_amendment_type(self): self.assertEqual(len(response.data["amendments"][1]["types"]), 2) -class TestPartnerStaffMemberAPIView(BaseTenantTestCase): - @classmethod - def setUpTestData(cls): - cls.unicef_staff = UserFactory(is_staff=True) - cls.partner = PartnerFactory( - organization=OrganizationFactory(organization_type=OrganizationType.CIVIL_SOCIETY_ORGANIZATION)) - - cls.partner_staff = UserFactory( - realms__data=['IP Viewer'], - profile__organization=cls.partner.organization, - ) - cls.url = reverse( - "partners_api:partner-staff-members-list", - args=[cls.partner.pk] - ) - - def test_get(self): - response = self.forced_auth_req( - 'get', - self.url, - user=self.unicef_staff - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - data = json.loads(response.rendered_content) - self.assertIn(data[0]["first_name"], self.partner_staff.first_name) - self.assertIn(data[0]["last_name"], self.partner_staff.last_name) - - class TestInterventionViews(BaseTenantTestCase): @classmethod def setUpTestData(cls): diff --git a/src/etools/applications/partners/urls_v2.py b/src/etools/applications/partners/urls_v2.py index ecf984dc2..601105680 100644 --- a/src/etools/applications/partners/urls_v2.py +++ b/src/etools/applications/partners/urls_v2.py @@ -50,7 +50,6 @@ PartnerOrganizationListAPIView, PartnerOrganizationSimpleHactAPIView, PartnerPlannedVisitsDeleteView, - PartnerStaffMemberListAPIVIew, PartnerWithScheduledAuditCompleted, PartnerWithSpecialAuditCompleted, PlannedEngagementAPIView, @@ -129,9 +128,9 @@ re_path(r'^partners/add/$', view=PartnerOrganizationAddView.as_view(http_method_names=['post']), name='partner-add'), - re_path(r'^partners/(?P\d+)/staff-members/$', - view=PartnerStaffMemberListAPIVIew.as_view(http_method_names=['get']), - name='partner-staff-members-list'), + # re_path(r'^partners/(?P\d+)/staff-members/$', + # view=PartnerStaffMemberListAPIVIew.as_view(http_method_names=['get']), + # name='partner-staff-members-list'), re_path(r'^interventions/$', view=InterventionListAPIView.as_view(http_method_names=['get', 'post']), diff --git a/src/etools/applications/partners/utils.py b/src/etools/applications/partners/utils.py index 791ee0719..a66f87e6e 100644 --- a/src/etools/applications/partners/utils.py +++ b/src/etools/applications/partners/utils.py @@ -530,8 +530,8 @@ def send_intervention_draft_notification(): created__lt=sdate_diff, ): recipients = [ - u.user.email for u in intervention.unicef_focal_points.all() - if u.user.email + u.email for u in intervention.unicef_focal_points.all() + if u.email ] send_notification_with_template( recipients=recipients, @@ -593,7 +593,7 @@ def send_intervention_amendment_added_notification(intervention): ) -# TODO REALMS PRP +# TODO REALMS PRP - cleanup def sync_partner_staff_member(partner: PartnerOrganization, staff_member_data: PRPPartnerUserResponse): user_update_fields = { 'is_active': staff_member_data.is_active, diff --git a/src/etools/applications/partners/views/interventions_v2.py b/src/etools/applications/partners/views/interventions_v2.py index 0a130a698..121646fb4 100644 --- a/src/etools/applications/partners/views/interventions_v2.py +++ b/src/etools/applications/partners/views/interventions_v2.py @@ -328,10 +328,6 @@ def update(self, request, *args, **kwargs): logging.debug(validator.errors) raise ValidationError(validator.errors) - if tenant_switch_is_active('intervention_amendment_notifications_on') and \ - old_instance and not self.instance.in_amendment and old_instance.in_amendment: - send_intervention_amendment_added_notification(self.instance) - if getattr(self.instance, '_prefetched_objects_cache', None): # If 'prefetch_related' has been applied to a queryset, we need to # refresh the instance from the database. @@ -515,6 +511,10 @@ def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=raw_data) serializer.is_valid(raise_exception=True) self.perform_create(serializer) + + if tenant_switch_is_active('intervention_amendment_notifications_on'): + send_intervention_amendment_added_notification(serializer.instance.intervention) + headers = self.get_success_headers(serializer.data) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) diff --git a/src/etools/applications/partners/views/interventions_v3_actions.py b/src/etools/applications/partners/views/interventions_v3_actions.py index 481f8969f..53313a122 100644 --- a/src/etools/applications/partners/views/interventions_v3_actions.py +++ b/src/etools/applications/partners/views/interventions_v3_actions.py @@ -1,3 +1,5 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.db import connection, transaction from django.http import HttpResponseForbidden from django.urls import reverse @@ -16,6 +18,7 @@ PARTNERSHIP_MANAGER_GROUP, PRC_SECRETARY, user_group_permission, + UserIsUnicefFocalPoint, ) from etools.applications.partners.serializers.interventions_v3 import ( AmendedInterventionReviewActionSerializer, @@ -317,9 +320,9 @@ def update(self, request, *args, **kwargs): if response.status_code == 200: # send notification - recipients = [ + recipients = set( u.email for u in pd.unicef_focal_points.all() - ] + ) # context should be valid for both templates mentioned below context = { "reference_number": pd.reference_number, @@ -332,6 +335,12 @@ def update(self, request, *args, **kwargs): template_name = 'partners/intervention/unicef_signature' else: template_name = 'partners/intervention/unicef_sent_for_review' + recipients = recipients.union(set( + get_user_model().objects.filter( + profile__country=connection.tenant, + realms__group=Group.objects.get(name=PRC_SECRETARY), + ).distinct().values_list('email', flat=True) + )) self.send_notification( pd, @@ -639,7 +648,7 @@ def update(self, request, *args, **kwargs): class PMPAmendedInterventionMerge(InterventionDetailAPIView): permission_classes = ( - user_group_permission(PARTNERSHIP_MANAGER_GROUP) | IsInterventionBudgetOwnerPermission, + user_group_permission(PARTNERSHIP_MANAGER_GROUP) | IsInterventionBudgetOwnerPermission | UserIsUnicefFocalPoint, ) @transaction.atomic diff --git a/src/etools/applications/partners/views/partner_organization_v2.py b/src/etools/applications/partners/views/partner_organization_v2.py index 8d8b393a4..8a121dabe 100644 --- a/src/etools/applications/partners/views/partner_organization_v2.py +++ b/src/etools/applications/partners/views/partner_organization_v2.py @@ -9,7 +9,7 @@ from etools_validator.mixins import ValidatorViewMixin from rest_framework import status -from rest_framework.exceptions import ValidationError +from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.generics import ( CreateAPIView, DestroyAPIView, @@ -51,8 +51,6 @@ AssessmentExportSerializer, PartnerOrganizationExportFlatSerializer, PartnerOrganizationExportSerializer, - PartnerStaffMemberExportFlatSerializer, - PartnerStaffMemberExportSerializer, ) from etools.applications.partners.serializers.partner_organization_v2 import ( AssessmentDetailSerializer, @@ -64,14 +62,12 @@ PartnerOrganizationHactSerializer, PartnerOrganizationListSerializer, PartnerPlannedVisitsSerializer, - PartnerStaffMemberDetailSerializer, PlannedEngagementNestedSerializer, PlannedEngagementSerializer, ) from etools.applications.partners.tasks import sync_partner from etools.applications.partners.views.helpers import set_tenant_or_fail -from etools.applications.t2f.models import Travel, TravelActivity, TravelType -from etools.applications.users.models import User +from etools.applications.t2f.models import Travel, TravelType from etools.applications.utils.pagination import AppendablePageNumberPagination from etools.libraries.djangolib.models import StringConcat from etools.libraries.djangolib.views import ExternalModuleFilterMixin @@ -399,37 +395,6 @@ class PlannedEngagementAPIView(ListAPIView): serializer_class = PlannedEngagementSerializer -class PartnerStaffMemberListAPIVIew(ExternalModuleFilterMixin, ExportModelMixin, ListAPIView): - """ - Returns a list of all Partner staff members - """ - queryset = User.objects.all() - serializer_class = PartnerStaffMemberDetailSerializer - permission_classes = (AllowSafeAuthenticated,) - filter_backends = (PartnerScopeFilter,) - renderer_classes = ( - r.JSONRenderer, - r.CSVRenderer, - CSVFlatRenderer, - ) - module2filters = { - 'psea': ['partner__psea_assessment__assessor__auditor_firm_staff__user', - 'partner__psea_assessment__assessor__user'] - } - - def get_serializer_class(self, format=None): - """ - Use restriceted field set for listing - """ - query_params = self.request.query_params - if "format" in query_params.keys(): - if query_params.get("format") == 'csv': - return PartnerStaffMemberExportSerializer - if query_params.get("format") == 'csv_flat': - return PartnerStaffMemberExportFlatSerializer - return super().get_serializer_class() - - class PartnerOrganizationAssessmentListCreateView(ExportModelMixin, ListCreateAPIView): """ Returns a list of all Partner staff members @@ -493,22 +458,24 @@ class PartnerOrganizationDeleteView(DestroyAPIView): permission_classes = (PartnershipManagerRepPermission,) def delete(self, request, *args, **kwargs): - try: - partner = PartnerOrganization.objects.get(id=int(kwargs['pk'])) - except PartnerOrganization.DoesNotExist: - return Response(status=status.HTTP_404_NOT_FOUND) - if partner.agreements.exclude(status='draft').count() > 0: - raise ValidationError( - _("There was a PCA/SSFA signed with this partner or a transaction was performed " - "against this partner. The Partner record cannot be deleted") - ) - elif TravelActivity.objects.filter(partner=partner).count() > 0: - raise ValidationError(_("This partner has trips associated to it")) - elif (partner.total_ct_cp or 0) > 0: - raise ValidationError(_("This partner has cash transactions associated to it")) - else: - partner.delete() - return Response(status=status.HTTP_204_NO_CONTENT) + # TODO: hotfix to be addressed + raise PermissionDenied() + # try: + # partner = PartnerOrganization.objects.get(id=int(kwargs['pk'])) + # except PartnerOrganization.DoesNotExist: + # return Response(status=status.HTTP_404_NOT_FOUND) + # if partner.agreements.exclude(status='draft').count() > 0: + # raise ValidationError( + # _("There was a PCA/SSFA signed with this partner or a transaction was performed " + # "against this partner. The Partner record cannot be deleted") + # ) + # elif TravelActivity.objects.filter(partner=partner).count() > 0: + # raise ValidationError(_("This partner has trips associated to it")) + # elif (partner.total_ct_cp or 0) > 0: + # raise ValidationError(_("This partner has cash transactions associated to it")) + # else: + # partner.delete() + # return Response(status=status.HTTP_204_NO_CONTENT) class PartnerNotProgrammaticVisitCompliant(PartnerOrganizationListAPIView): diff --git a/src/etools/applications/partners/views/partner_organization_v3.py b/src/etools/applications/partners/views/partner_organization_v3.py index 790e81629..d2e9f0c19 100644 --- a/src/etools/applications/partners/views/partner_organization_v3.py +++ b/src/etools/applications/partners/views/partner_organization_v3.py @@ -1,11 +1,21 @@ -from django.db import connection +from django.shortcuts import get_object_or_404 -from etools.applications.partners.views.partner_organization_v2 import ( - PartnerOrganizationListAPIView, - PartnerStaffMemberListAPIVIew, +from rest_framework.generics import ListAPIView +from rest_framework_csv import renderers as r + +from etools.applications.core.mixins import ExportModelMixin +from etools.applications.core.renderers import CSVFlatRenderer +from etools.applications.partners.models import PartnerOrganization +from etools.applications.partners.permissions import AllowSafeAuthenticated +from etools.applications.partners.serializers.exports.partner_organization import ( + PartnerStaffMemberExportFlatSerializer, + PartnerStaffMemberExportSerializer, ) +from etools.applications.partners.serializers.partner_organization_v2 import PartnerStaffMemberRealmSerializer +from etools.applications.partners.views.partner_organization_v2 import PartnerOrganizationListAPIView from etools.applications.partners.views.v3 import PMPBaseViewMixin -from etools.applications.users.mixins import PARTNER_ACTIVE_GROUPS +from etools.applications.users.models import User +from etools.libraries.djangolib.views import ExternalModuleFilterMixin class PMPPartnerOrganizationListAPIView( @@ -20,20 +30,45 @@ def get_queryset(self, format=None): return qs -class PMPPartnerStaffMemberMixin(PMPBaseViewMixin): - def get_queryset(self): - qs = self.queryset - if self.is_partner_staff(): - qs = qs.filter( - realms__country=connection.tenant, - realms__organization__partner=self.current_partner(), - realms__group__name__in=PARTNER_ACTIVE_GROUPS, - ) - return qs +class PMPPartnerStaffMemberListAPIVIew( + PMPBaseViewMixin, ExternalModuleFilterMixin, ExportModelMixin, ListAPIView): + """ + Returns a list of all Partner staff members + """ + queryset = User.objects.all() + serializer_class = PartnerStaffMemberRealmSerializer + permission_classes = (AllowSafeAuthenticated,) + renderer_classes = ( + r.JSONRenderer, + r.CSVRenderer, + CSVFlatRenderer, + ) + module2filters = { + 'psea': ['partner__psea_assessment__assessor__auditor_firm_staff__user', + 'partner__psea_assessment__assessor__user'] + } + def get_serializer_class(self, format=None): + """ + Use restricted field set for listing + """ + query_params = self.request.query_params + if "format" in query_params.keys(): + if query_params.get("format") == 'csv': + return PartnerStaffMemberExportSerializer + if query_params.get("format") == 'csv_flat': + return PartnerStaffMemberExportFlatSerializer + return super().get_serializer_class() -class PMPPartnerStaffMemberListAPIVIew( - PMPPartnerStaffMemberMixin, - PartnerStaffMemberListAPIVIew, -): - """Wrapper for Partner Organizations staff members""" + def get_queryset(self, module=None): + if self.request.parser_context['kwargs'] and 'partner_pk' in self.request.parser_context['kwargs']: + partner = get_object_or_404(PartnerOrganization, pk=self.request.parser_context['kwargs']['partner_pk']) + qs = partner.all_staff_members + + if (not self.is_partner_staff() and self.request.user.is_unicef_user()) or \ + (self.is_partner_staff() and partner == self.current_partner()): + return qs + + return self.queryset.none() + + return self.queryset.none() diff --git a/src/etools/applications/partners/views/v3.py b/src/etools/applications/partners/views/v3.py index 1580d4528..319510b15 100644 --- a/src/etools/applications/partners/views/v3.py +++ b/src/etools/applications/partners/views/v3.py @@ -42,7 +42,8 @@ class PMPBaseViewMixin: def is_partner_staff(self): """Flag indicator whether user is a partner any active group out of IP... , """ - return self.request.user.is_authenticated and bool(self.request.user.get_partner()) + return self.request.user.is_authenticated and \ + 'partner' in self.request.user.profile.organization.relationship_types def current_partner(self): """List of partners the user is associated with""" diff --git a/src/etools/applications/reports/migrations/0046_auto_20230328_0930.py b/src/etools/applications/reports/migrations/0046_auto_20230328_0930.py new file mode 100644 index 000000000..ad6beaa1f --- /dev/null +++ b/src/etools/applications/reports/migrations/0046_auto_20230328_0930.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.6 on 2023-03-28 09:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reports', '0045_lowerresult_is_active'), + ] + + operations = [ + migrations.AddField( + model_name='interventionactivity', + name='unfunded_cash', + field=models.DecimalField(decimal_places=2, default=0, max_digits=20, verbose_name='Unfunded Cash'), + ), + migrations.AddField( + model_name='interventionactivityitem', + name='unfunded_cash', + field=models.DecimalField(decimal_places=2, default=0, max_digits=20, verbose_name='Unfunded Cash'), + ), + ] diff --git a/src/etools/applications/reports/models.py b/src/etools/applications/reports/models.py index 93026fab2..4bffbfddc 100644 --- a/src/etools/applications/reports/models.py +++ b/src/etools/applications/reports/models.py @@ -1042,6 +1042,7 @@ class InterventionActivity(TimeStampedModel): max_digits=20, default=0, ) + time_frames = models.ManyToManyField( 'InterventionTimeFrame', verbose_name=_('Time Frames Enabled'), diff --git a/src/etools/applications/tpm/serializers/partner.py b/src/etools/applications/tpm/serializers/partner.py index 41d9a3af4..5da9f4817 100644 --- a/src/etools/applications/tpm/serializers/partner.py +++ b/src/etools/applications/tpm/serializers/partner.py @@ -18,6 +18,13 @@ class Meta(UserSerializer.Meta): ] +class TPMPartnerStaffMemberRealmSerializer(TPMPartnerStaffMemberSerializer): + has_active_realm = serializers.BooleanField(read_only=True) + + class Meta(TPMPartnerStaffMemberSerializer.Meta): + fields = TPMPartnerStaffMemberSerializer.Meta.fields + ['has_active_realm'] + + class TPMPartnerLightSerializer(PermissionsBasedSerializerMixin, serializers.ModelSerializer): organization_id = serializers.IntegerField(read_only=True, source='organization.id') diff --git a/src/etools/applications/tpm/tests/test_views.py b/src/etools/applications/tpm/tests/test_views.py index a8ff335e1..8d52f5e10 100644 --- a/src/etools/applications/tpm/tests/test_views.py +++ b/src/etools/applications/tpm/tests/test_views.py @@ -508,6 +508,21 @@ def test_list_view(self): ) self.assertEquals(response.status_code, status.HTTP_200_OK) + def test_list_active_inactive_realms(self): + inactive_tpm_user = TPMUserFactory(tpm_partner=self.tpm_partner) + inactive_tpm_user.realms.update(is_active=False) + + response = self.forced_auth_req( + 'get', + reverse('tpm:tpmstaffmembers-list', args=(self.tpm_partner.id,)), + user=self.pme_user + ) + self.assertEquals(response.status_code, status.HTTP_200_OK) + self.assertEquals(len(response.data['results']), 3) + self.assertEquals( + [inactive_tpm_user.pk], + [staff['pk'] for staff in response.data['results'] if not staff['has_active_realm']]) + def test_detail_view(self): # TODO REALMS improve queries perf with self.assertNumQueries(28): diff --git a/src/etools/applications/tpm/tpmpartners/synchronizers.py b/src/etools/applications/tpm/tpmpartners/synchronizers.py index c767b0de8..e4544904c 100644 --- a/src/etools/applications/tpm/tpmpartners/synchronizers.py +++ b/src/etools/applications/tpm/tpmpartners/synchronizers.py @@ -3,7 +3,7 @@ from etools.applications.organizations.models import Organization from etools.applications.tpm.tpmpartners.models import TPMPartner from etools.applications.users.mixins import TPM_ACTIVE_GROUPS -from etools.applications.users.models import Country, Realm +from etools.applications.users.models import Realm from etools.applications.vision.synchronizers import VisionDataTenantSynchronizer logger = logging.getLogger(__name__) @@ -92,11 +92,7 @@ def deactivate_staff_members(partner): # # deactivate the users # users_deactivate = User.objects.filter(tpmpartners_tpmpartnerstaffmember__in=staff_members) # users_deactivate.update(is_active=False) - try: - Realm.objects.filter( - organization=partner.organization, - group__name__in=TPM_ACTIVE_GROUPS, - ).update(is_active=False) - except Country.DoesNotExist: - logging.error(f"No country with name {partner.country} exists. " - f"Cannot deactivate realms for users.") + Realm.objects.filter( + organization=partner.organization, + group__name__in=TPM_ACTIVE_GROUPS, + ).update(is_active=False) diff --git a/src/etools/applications/tpm/views.py b/src/etools/applications/tpm/views.py index c80c2eb6d..c8c5ea693 100644 --- a/src/etools/applications/tpm/views.py +++ b/src/etools/applications/tpm/views.py @@ -1,7 +1,7 @@ from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.db import connection -from django.db.models import Q +from django.db.models import Exists, OuterRef, Q from django.http import Http404 from django.utils import timezone from django.utils.translation import gettext as _ @@ -76,7 +76,7 @@ from etools.applications.tpm.serializers.partner import ( TPMPartnerLightSerializer, TPMPartnerSerializer, - TPMPartnerStaffMemberSerializer, + TPMPartnerStaffMemberRealmSerializer, ) from etools.applications.tpm.serializers.visit import ( TPMActionPointSerializer, @@ -88,6 +88,7 @@ from etools.applications.tpm.tpmpartners.models import TPMPartner from etools.applications.tpm.tpmpartners.synchronizers import TPMPartnerSynchronizer from etools.applications.users.mixins import TPM_ACTIVE_GROUPS +from etools.applications.users.models import Realm class BaseTPMViewSet( @@ -216,7 +217,7 @@ class TPMStaffMembersViewSet( ): metadata_class = PermissionBasedMetadata queryset = get_user_model().objects.all() - serializer_class = TPMPartnerStaffMemberSerializer + serializer_class = TPMPartnerStaffMemberRealmSerializer permission_classes = BaseTPMViewSet.permission_classes + [ get_permission_for_targets('tpmpartners.tpmpartner.staff_members') ] @@ -228,11 +229,15 @@ class TPMStaffMembersViewSet( def get_queryset(self): queryset = super().get_queryset() - queryset = queryset.filter( - realms__country=connection.tenant, - realms__organization=self.get_parent_object().organization, - realms__group__name__in=TPM_ACTIVE_GROUPS, - ).distinct() + context_realms_qs = Realm.objects.filter( + organization=self.get_parent_object().organization, + country=connection.tenant, + group__name__in=TPM_ACTIVE_GROUPS + ) + queryset = queryset\ + .filter(realms__in=context_realms_qs)\ + .annotate(has_active_realm=Exists(context_realms_qs.filter(user=OuterRef('pk'), is_active=True)))\ + .distinct() return queryset def get_parent_filter(self): diff --git a/src/etools/applications/travel/tests/test_views.py b/src/etools/applications/travel/tests/test_views.py index 75ed35562..30f5f1379 100644 --- a/src/etools/applications/travel/tests/test_views.py +++ b/src/etools/applications/travel/tests/test_views.py @@ -453,7 +453,7 @@ def test_search_traveller_name(self): for _ in range(10): TripFactory() - user = UserFactory(first_name="First name", last_name="Last name") + user = UserFactory(first_name="Traveller First name", last_name="Traveller Last name") trip = TripFactory(traveller=user) def _validate_response(response): @@ -465,7 +465,7 @@ def _validate_response(response): response = self.forced_auth_req( "get", reverse('travel:trip-list'), - data={"search": user.first_name[:4]}, + data={"search": user.first_name[:9]}, user=self.user, ) _validate_response(response) diff --git a/src/etools/applications/users/admin.py b/src/etools/applications/users/admin.py index 36ba69ff2..e75cd83b0 100644 --- a/src/etools/applications/users/admin.py +++ b/src/etools/applications/users/admin.py @@ -6,6 +6,7 @@ from django.http.response import HttpResponseRedirect from django.shortcuts import get_object_or_404 from django.urls import reverse +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from admin_extra_urls.decorators import button @@ -18,6 +19,7 @@ from etools.applications.funds.tasks import sync_all_delegated_frs, sync_country_delegated_fr from etools.applications.hact.tasks import update_hact_for_country, update_hact_values from etools.applications.users.models import Country, Realm, UserProfile, WorkspaceCounter +from etools.applications.users.tasks import sync_realms_to_prp from etools.applications.vision.tasks import sync_handler, vision_sync_task from etools.libraries.azure_graph_api.tasks import sync_user @@ -223,7 +225,7 @@ class UserAdminPlus(ExtraUrlMixin, UserAdmin): 'is_active', 'country', ] - list_select_related = ('country', 'office') + list_select_related = ('profile__country', 'profile__office') UserChangeForm.Meta.exclude = ('groups',) @@ -233,6 +235,12 @@ def sync_user(self, request, pk): sync_user.delay(user.username) return HttpResponseRedirect(reverse('admin:users_user_change', args=[user.pk])) + @button() + def sync_realms_to_prp(self, request, pk): + user = get_object_or_404(get_user_model(), pk=pk) + sync_realms_to_prp.delay(user.id, timezone.now().timestamp()) + return HttpResponseRedirect(reverse('admin:users_user_change', args=[user.pk])) + @button() def ad(self, request, pk): user = get_object_or_404(get_user_model(), pk=pk) @@ -353,10 +361,10 @@ def update_hact(self, request, pk): class RealmAdmin(SnapshotModelAdmin): - raw_id_fields = ('user', ) + raw_id_fields = ('user', 'organization') search_fields = ('user__email', 'user__first_name', 'user__last_name', 'country__name', 'organization__name', 'organization__vendor_number', 'group__name') - autocomplete_fields = ('country', 'organization', 'group') + autocomplete_fields = ('country', 'group') inlines = (ActivityInline, ) diff --git a/src/etools/applications/users/apps.py b/src/etools/applications/users/apps.py new file mode 100644 index 000000000..36d4b89c7 --- /dev/null +++ b/src/etools/applications/users/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig as BaseAppConfig + + +class AppConfig(BaseAppConfig): + name = __name__.rpartition('.')[0] + verbose_name = 'Users' + + def ready(self): + from . import signals # NOQA diff --git a/src/etools/applications/users/filters.py b/src/etools/applications/users/filters.py new file mode 100644 index 000000000..b29c1bf9f --- /dev/null +++ b/src/etools/applications/users/filters.py @@ -0,0 +1,28 @@ +from django.db import models + +from rest_framework.filters import BaseFilterBackend + + +class UserRoleFilter(BaseFilterBackend): + def filter_queryset(self, request, queryset, view): + if 'roles' in request.query_params and request.query_params['roles']: + return queryset.filter(realms__group__id__in=request.query_params['roles'].split(',')).distinct() + return queryset + + +class UserStatusFilter(BaseFilterBackend): + def filter_queryset(self, request, queryset, view): + if queryset and 'status' in request.query_params and request.query_params['status']: + status_list = [status.strip().lower() for status in request.query_params['status'].split(',')] + filters = models.Q() + for status in status_list: + if status == 'inactive': + filters |= models.Q(is_active=False) + if status == 'active': + filters |= models.Q(is_active=True, last_login__isnull=False, has_active_realm=True) + if status == 'invited': + filters |= models.Q(is_active=True, last_login__isnull=True, has_active_realm=True) + if status == 'no access': + filters |= models.Q(is_active=True, has_active_realm=False) + return queryset.filter(filters) + return queryset diff --git a/src/etools/applications/users/migrations/0023_alter_user_managers.py b/src/etools/applications/users/migrations/0023_alter_user_managers.py new file mode 100644 index 000000000..5fcb97944 --- /dev/null +++ b/src/etools/applications/users/migrations/0023_alter_user_managers.py @@ -0,0 +1,14 @@ +# Generated by Django 3.2.6 on 2023-05-22 11:06 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0022_userprofile_receive_tpm_notifications'), + ] + + operations = [ + migrations.RunPython(migrations.RunPython.noop, migrations.RunPython.noop) + ] diff --git a/src/etools/applications/users/mixins.py b/src/etools/applications/users/mixins.py index 5eb5d1013..c3f720ef9 100644 --- a/src/etools/applications/users/mixins.py +++ b/src/etools/applications/users/mixins.py @@ -28,12 +28,12 @@ class GroupEditPermissionMixin: "IP Admin": {"partner": ["IP Viewer", "IP Editor", "IP Authorized Officer"]}, "IP Authorized Officer": {"partner": ["IP Viewer", "IP Editor", "IP Authorized Officer"]}, "UNICEF User": {}, - "PME": {}, - "Partnership Manager": ORGANIZATION_GROUP_MAP, + "PME": {"tpm": TPM_ACTIVE_GROUPS}, + "Partnership Manager": {"partner": PARTNER_ACTIVE_GROUPS}, "UNICEF Audit Focal Point": {"audit": ["Auditor"]}, } - CAN_ADD_USER = ["IP Admin", "IP Authorized Officer", + CAN_ADD_USER = ["IP Admin", "IP Authorized Officer", "PME", "Partnership Manager", "UNICEF Audit Focal Point"] def get_user_allowed_groups(self, organization_types, user=None): diff --git a/src/etools/applications/users/models.py b/src/etools/applications/users/models.py index 43b3b7d75..a3433ad24 100644 --- a/src/etools/applications/users/models.py +++ b/src/etools/applications/users/models.py @@ -146,7 +146,7 @@ def clean(self): self.email = self.__class__.objects.normalize_email(self.email) def __str__(self): - return'{} {} ({})'.format( + return '{} {} ({})'.format( self.first_name, self.last_name, self.profile.organization.name if self.profile.organization else '-' diff --git a/src/etools/applications/users/permissions.py b/src/etools/applications/users/permissions.py index fa780e223..0bee762b6 100644 --- a/src/etools/applications/users/permissions.py +++ b/src/etools/applications/users/permissions.py @@ -2,8 +2,6 @@ from rest_framework.permissions import BasePermission -from etools.applications.users.models import PartnershipManager - class IsServiceNowUser(BasePermission): """Allows access only to super users.""" @@ -12,23 +10,8 @@ def has_permission(self, request, view): return request.user and request.user.username == settings.SERVICE_NOW_USER -class IsPartnershipManager(BasePermission): - """Allows access only to PartnershipManager.""" - - def has_permission(self, request, view): - return request.user.realms\ - .filter(country=request.user.profile.country, - organization=request.user.profile.organization, - group=PartnershipManager.as_group())\ - .exists() - - class IsActiveInRealm(BasePermission): """Allows access to users who are in the current realm, despite their groups""" def has_permission(self, request, view): - return request.user.realms \ - .filter(country=request.user.profile.country, - organization=request.user.profile.organization, - is_active=True) \ - .exists() + return request.user.groups.exists() diff --git a/src/etools/applications/users/serializers_v3.py b/src/etools/applications/users/serializers_v3.py index 746eff7a6..7d6fcc5e9 100644 --- a/src/etools/applications/users/serializers_v3.py +++ b/src/etools/applications/users/serializers_v3.py @@ -163,7 +163,7 @@ def validate_organization(self, value): if organization_id: if not self.context['request'].user.is_unicef_user(): raise PermissionDenied( - _('You do not have permission to set roles for organization with %(id)s.' + _('You do not have permission to set roles for organization with id %(id)s.' % {'id': organization_id})) organization = get_object_or_404(Organization, pk=organization_id) if not organization.relationship_types: @@ -209,6 +209,7 @@ def create_realms(self, instance, organization_id, group_ids): class UserRealmCreateSerializer(UserRealmBaseSerializer): email = serializers.CharField(required=True, write_only=True) job_title = serializers.CharField(required=False, allow_blank=True, write_only=True) + phone_number = serializers.CharField(required=False, allow_blank=True, write_only=True) class Meta(UserRealmBaseSerializer.Meta): model = get_user_model() @@ -217,6 +218,7 @@ class Meta(UserRealmBaseSerializer.Meta): 'last_name', 'email', 'job_title', + 'phone_number' ] extra_kwargs = { 'first_name': {'required': True}, @@ -233,6 +235,7 @@ def create(self, validated_data): organization_id = validated_data.pop('organization', self.context['request'].user.profile.organization.id) group_ids = validated_data.pop("groups") job_title = validated_data.pop("job_title", None) + phone_number = validated_data.pop("phone_number", None) email = validated_data.pop('email') validated_data.update({"username": email}) @@ -245,12 +248,17 @@ def create(self, validated_data): if job_title: instance.profile.job_title = job_title - instance.profile.country = connection.tenant + if phone_number: + instance.profile.phone_number = phone_number + + if not instance.profile.country or \ + not instance.realms.filter(country=instance.profile.country, is_active=True).exists(): + instance.profile.country = connection.tenant if not instance.profile.organization and instance != self.context['request'].user: instance.profile.organization_id = organization_id - instance.profile.save(update_fields=['country', 'organization_id', 'job_title']) + instance.profile.save(update_fields=['country', 'organization_id', 'job_title', 'phone_number']) self.create_realms(instance, organization_id, group_ids) instance.update_active_state() diff --git a/src/etools/applications/users/signals.py b/src/etools/applications/users/signals.py new file mode 100644 index 000000000..0624f34ed --- /dev/null +++ b/src/etools/applications/users/signals.py @@ -0,0 +1,40 @@ +import datetime + +from django.db import transaction +from django.db.models.signals import post_delete, post_save +from django.dispatch import receiver +from django.utils import timezone + +from etools.applications.users.models import Realm +from etools.applications.users.tasks import sync_realms_to_prp + + +@receiver(post_save, sender=Realm) +def sync_realms_to_prp_on_update(instance: Realm, created: bool, **kwargs): + if instance.user.is_unicef_user(): + # only external users are allowed to be synced to prp + return + + transaction.on_commit( + lambda: + sync_realms_to_prp.apply_async( + (instance.user_id, instance.modified.timestamp()), + eta=instance.modified + datetime.timedelta(minutes=5) + ) + ) + + +@receiver(post_delete, sender=Realm) +def sync_realms_to_prp_on_delete(instance: Realm, **kwargs): + if instance.user.is_unicef_user(): + # only external users are allowed to be synced to prp + return + + now = timezone.now() + transaction.on_commit( + lambda: + sync_realms_to_prp.apply_async( + (instance.user_id, now.timestamp()), + eta=now + datetime.timedelta(minutes=5) + ) + ) diff --git a/src/etools/applications/users/tasks.py b/src/etools/applications/users/tasks.py index b901093c0..c453e9b75 100644 --- a/src/etools/applications/users/tasks.py +++ b/src/etools/applications/users/tasks.py @@ -1,11 +1,18 @@ +import datetime + from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.db import connection, IntegrityError, transaction +from django.db.models import Prefetch +from django.utils import timezone from celery.utils.log import get_task_logger +from requests import HTTPError from etools.applications.environment.notifications import send_notification_with_template from etools.applications.organizations.models import Organization +from etools.applications.partners.prp_api import PRPAPI +from etools.applications.partners.serializers.prp_v1 import PRPSyncUserSerializer from etools.applications.users.models import Country, Realm, User, UserProfile from etools.config.celery import app @@ -213,3 +220,30 @@ def notify_user_on_realm_update(user_pk): template_name='users/amp/role-update', context=email_context ) + + +@app.task +def sync_realms_to_prp(user_pk, last_modified_at_timestamp, retry_counter=0): + last_modified_instance = Realm.objects.filter(user_id=user_pk).order_by('modified').last() + if last_modified_instance and last_modified_instance.modified.timestamp() > last_modified_at_timestamp: + # there were updates to user realms. skip + return + + user = User.objects.filter(pk=user_pk).prefetch_related( + Prefetch('realms', Realm.objects.filter(is_active=True).select_related('country', 'organization', 'group')), + ).get() + data = PRPSyncUserSerializer(instance=user).data + + try: + PRPAPI().send_user_realms(data) + except HTTPError as ex: + if retry_counter < 2: + logger.info(f'Received {ex} from prp api. retrying') + sync_realms_to_prp.apply_async( + (user_pk, last_modified_at_timestamp), + {'retry_counter': retry_counter + 1}, + eta=timezone.now() + datetime.timedelta(minutes=1 + retry_counter) + ) + else: + logger.exception(f'Received {ex} from prp api while trying to send realms after 3 attempts. ' + f'User pk: {user_pk}.') diff --git a/src/etools/applications/users/tests/test_views_v3.py b/src/etools/applications/users/tests/test_views_v3.py index 00918fe4f..314dcfd18 100644 --- a/src/etools/applications/users/tests/test_views_v3.py +++ b/src/etools/applications/users/tests/test_views_v3.py @@ -3,6 +3,7 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.models import Group +from django.db import connection from django.urls import reverse from rest_framework import status @@ -15,7 +16,12 @@ from etools.applications.partners.permissions import PARTNERSHIP_MANAGER_GROUP, UNICEF_USER from etools.applications.partners.tests.factories import PartnerFactory from etools.applications.tpm.models import ThirdPartyMonitor -from etools.applications.tpm.tests.factories import BaseTPMVisitFactory, SimpleTPMPartnerFactory, TPMUserFactory +from etools.applications.tpm.tests.factories import ( + BaseTPMVisitFactory, + SimpleTPMPartnerFactory, + TPMPartnerFactory, + TPMUserFactory, +) from etools.applications.users.mixins import GroupEditPermissionMixin, ORGANIZATION_GROUP_MAP from etools.applications.users.models import ( IPAdmin, @@ -27,7 +33,13 @@ UserProfile, ) from etools.applications.users.serializers_v3 import AP_ALLOWED_COUNTRIES -from etools.applications.users.tests.factories import GroupFactory, ProfileFactory, RealmFactory, UserFactory +from etools.applications.users.tests.factories import ( + GroupFactory, + PMEUserFactory, + ProfileFactory, + RealmFactory, + UserFactory, +) from etools.libraries.djangolib.models import GroupWrapper @@ -224,7 +236,7 @@ def test_not_staff(self): self.assertEqual(response.data, []) def test_api_users_list(self): - with self.assertNumQueries(3): + with self.assertNumQueries(5): response = self.forced_auth_req('get', self.url, user=self.unicef_staff) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -621,9 +633,10 @@ def setUpTestData(cls): cls.ip_admin = UserFactory(realms__data=[IPAdmin.name], profile__organization=cls.organization) cls.ip_auth_officer = UserFactory(realms__data=[IPAuthorizedOfficer.name], profile__organization=cls.organization) - cls.partnership_manager = UserFactory(realms__data=[UNICEF_USER, PartnershipManager.name]) - cls.audit_focal_point = AuditFocalPointUserFactory() - cls.unicef_user = UserFactory() + cls.partnership_manager = UserFactory(is_staff=True, realms__data=[UNICEF_USER, PartnershipManager.name]) + cls.audit_focal_point = AuditFocalPointUserFactory(is_staff=True) + cls.pme = PMEUserFactory(is_staff=True) + cls.unicef_user = UserFactory(is_staff=True) def make_request_list(self, auth_user, method='post', data=None): response = self.forced_auth_req( @@ -650,10 +663,7 @@ def test_get_list_forbidden(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_get_list_filter_by_roles(self): - data = {"roles": [ - IPEditor.as_group().pk, - IPViewer.as_group().pk - ]} + data = {"roles": f"{IPEditor.as_group().pk},{IPViewer.as_group().pk}"} for auth_user in [self.ip_viewer, self.ip_editor, self.ip_admin, self.ip_auth_officer]: response = self.make_request_list(auth_user, method='get', data=data) @@ -664,7 +674,7 @@ def test_get_list_for_partner_users(self): # uses profile.organization = self.organization for auth_user in [self.ip_viewer, self.ip_editor, self.ip_admin, self.ip_auth_officer]: - with self.assertNumQueries(4): + with self.assertNumQueries(3): response = self.make_request_list(auth_user, method='get') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['count'], 4, "Number of users in realm") @@ -675,7 +685,7 @@ def test_get_list_for_unicef_users(self): "organization_id": self.organization.id, "organization_type": self.organization.relationship_types[0] } - with self.assertNumQueries(5): + with self.assertNumQueries(4): response = self.make_request_list(auth_user, method='get', data=data) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['count'], 4) @@ -702,7 +712,7 @@ def test_get_empty_list(self): self.assertEqual(response.data['count'], 0) def test_post_forbidden(self): - for auth_user in [self.ip_viewer, self.ip_editor, self.audit_focal_point]: + for auth_user in [self.ip_viewer, self.ip_editor]: self.assertEqual(self.user.realms.count(), 0) response = self.make_request_list(auth_user, data={}) @@ -718,7 +728,7 @@ def test_post_forbidden(self): response = self.make_request_list(auth_user, data=data) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_post_create_new(self): + def test_post_new_partner_user(self): for auth_user, group in zip( [self.ip_admin, self.ip_auth_officer], [IPViewer, IPEditor]): @@ -727,14 +737,58 @@ def test_post_create_new(self): "first_name": "First Name", "last_name": f"{auth_user.id} Last Name", "email": email, + "phone_number": "+10999909999", "groups": [GroupFactory(name=group.name).pk], } response = self.make_request_list(auth_user, data=data) self.assertEqual(response.status_code, status.HTTP_201_CREATED) new_user = User.objects.get(email=email) self.assertEqual(new_user.realms.count(), 1) + self.assertEqual(new_user.profile.phone_number, data['phone_number']) self.assertEqual(group.name, response.data['realms'][0]['group_name']) + def test_post_new_auditor_user(self): + self.assertFalse(self.audit_focal_point.is_superuser) + self.assertTrue(self.audit_focal_point.is_staff) + + email = "auditor_email@example.com" + data = { + "first_name": "First Name", + "last_name": "Last Name", + "email": email, + "phone_number": "+10999909999", + "groups": [GroupFactory(name=Auditor.name).pk], + "organization": EngagementFactory().agreement.auditor_firm.organization.pk + } + response = self.make_request_list(self.audit_focal_point, data=data) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + new_user = User.objects.get(email=email) + self.assertEqual(new_user.realms.count(), 1) + self.assertEqual(new_user.profile.phone_number, data['phone_number']) + self.assertEqual(Auditor.name, response.data['realms'][0]['group_name']) + + def test_post_new_tpm_user(self): + self.assertFalse(self.pme.is_superuser) + self.assertTrue(self.pme.is_staff) + + email = "tpm_email@example.com" + data = { + "first_name": "First Name", + "last_name": "Last Name", + "email": email, + "phone_number": "+10999909999", + "groups": [GroupFactory(name=ThirdPartyMonitor.name).pk], + "organization": TPMPartnerFactory(countries=[connection.tenant]).organization.pk + } + response = self.make_request_list(self.pme, data=data) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + new_user = User.objects.get(email=email) + self.assertEqual(new_user.realms.count(), 1) + self.assertEqual(new_user.profile.phone_number, data['phone_number']) + self.assertEqual(ThirdPartyMonitor.name, response.data['realms'][0]['group_name']) + def test_post_user_exists_201(self): for auth_user, group in zip( [self.ip_admin, self.ip_auth_officer], @@ -759,6 +813,9 @@ def test_post_user_exists_201(self): self.assertIn(group.as_group(), existing_user.groups) def test_post_partnership_manager_201(self): + self.assertFalse(self.partnership_manager.is_superuser) + self.assertTrue(self.partnership_manager.is_staff) + group = GroupFactory(name=IPEditor.name) email = "new_email@example.com" data = { @@ -776,6 +833,9 @@ def test_post_partnership_manager_201(self): self.assertEqual(group.name, response.data['realms'][0]['group_name']) def test_patch_reactivate_groups(self): + self.assertFalse(self.ip_admin.is_superuser) + self.assertFalse(self.ip_admin.is_staff) + self.assertEqual(self.user.realms.count(), 0) # create IPViewer and IPEditor realms data = { diff --git a/src/etools/applications/users/views_v3.py b/src/etools/applications/users/views_v3.py index f59c59368..f4e51bf46 100644 --- a/src/etools/applications/users/views_v3.py +++ b/src/etools/applications/users/views_v3.py @@ -5,7 +5,7 @@ from django.contrib.auth.models import Group from django.core.exceptions import ValidationError as DjangoValidationError from django.db import connection, transaction -from django.db.models import OuterRef, Prefetch, Q, Subquery +from django.db.models import Exists, OuterRef, Prefetch, Q, Subquery from django.http import HttpResponseForbidden, HttpResponseRedirect from django.shortcuts import get_object_or_404 @@ -20,20 +20,22 @@ from unicef_restlib.pagination import DynamicPageNumberPagination from unicef_restlib.views import QueryStringFilterMixin, SafeTenantViewSetMixin +from etools.applications.action_points.models import PME from etools.applications.audit.models import UNICEFAuditFocalPoint from etools.applications.core.permissions import IsUNICEFUser from etools.applications.organizations.models import Organization from etools.applications.partners.permissions import user_group_permission from etools.applications.partners.views.v3 import PMPBaseViewMixin from etools.applications.users import views as v1, views_v2 as v2 +from etools.applications.users.filters import UserRoleFilter, UserStatusFilter from etools.applications.users.mixins import ( AUDIT_ACTIVE_GROUPS, GroupEditPermissionMixin, ORGANIZATION_GROUP_MAP, TPM_ACTIVE_GROUPS, ) -from etools.applications.users.models import IPAdmin, IPAuthorizedOfficer, IPEditor, Realm -from etools.applications.users.permissions import IsActiveInRealm, IsPartnershipManager +from etools.applications.users.models import IPAdmin, IPAuthorizedOfficer, IPEditor, PartnershipManager, Realm +from etools.applications.users.permissions import IsActiveInRealm from etools.applications.users.serializers import SimpleGroupSerializer, SimpleOrganizationSerializer from etools.applications.users.serializers_v3 import ( CountryDetailSerializer, @@ -208,7 +210,7 @@ class OrganizationListView(ListAPIView): """ model = Organization serializer_class = SimpleOrganizationSerializer - permission_classes = (IsAuthenticated, IsUNICEFUser | IsPartnershipManager) + permission_classes = (IsAuthenticated, IsUNICEFUser) def get_queryset(self): queryset = Organization.objects.all() \ @@ -276,7 +278,7 @@ class UserRealmViewSet( serializer_class = UserRealmRetrieveSerializer pagination_class = DynamicPageNumberPagination - filter_backends = (SearchFilter, DjangoFilterBackend, OrderingFilter) + filter_backends = (SearchFilter, DjangoFilterBackend, OrderingFilter, UserRoleFilter, UserStatusFilter) search_fields = ('first_name', 'last_name', 'email', 'profile__job_title') filter_fields = ('is_active', ) @@ -289,13 +291,16 @@ def get_permissions(self): if self.action == 'create': self.permission_classes = ( IsAuthenticated, - user_group_permission(IPAdmin.name, IPAuthorizedOfficer.name) | IsPartnershipManager) + user_group_permission( + IPAdmin.name, IPAuthorizedOfficer.name, + UNICEFAuditFocalPoint.name, PartnershipManager.name, PME.name) + ) if self.action == 'partial_update': self.permission_classes = ( IsAuthenticated, user_group_permission( - IPEditor.name, IPAdmin.name, - IPAuthorizedOfficer.name, UNICEFAuditFocalPoint.name) | IsPartnershipManager + IPEditor.name, IPAdmin.name, IPAuthorizedOfficer.name, + UNICEFAuditFocalPoint.name, PartnershipManager.name, PME.name) ) return super().get_permissions() @@ -307,14 +312,16 @@ def get_serializer_class(self): return super().get_serializer_class() def get_queryset(self): - organization_id = self.request.query_params.get('organization_id') + organization_id = self.request.query_params.get('organization_id') or self.request.data.get('organization') relationship_type = self.request.query_params.get('organization_type') if organization_id: if self.request.user.is_unicef_user(): organization = get_object_or_404( Organization.objects.all().select_related('partner', 'auditorfirm', 'tpmpartner'), pk=organization_id) - if not organization.relationship_types or relationship_type not in organization.relationship_types: + if (self.request.method == 'GET' and relationship_type is None) or \ + (relationship_type and relationship_type not in organization.relationship_types) or \ + not organization.relationship_types: logger.error(f"The provided organization id {organization_id} and type {relationship_type} do not match.") return self.model.objects.none() else: @@ -330,15 +337,12 @@ def get_queryset(self): [group for _type in organization.relationship_types for group in ORGANIZATION_GROUP_MAP.get(_type)] qs_context.update({"group__name__in": group_names}) - if self.request.query_params.get('roles'): - qs_context.update( - {"group__id__in": self.request.query_params.getlist('roles')} - ) context_realms_qs = Realm.objects.filter(**qs_context).select_related('group') return self.model.objects \ .filter(realms__in=context_realms_qs) \ - .prefetch_related(Prefetch('realms', queryset=context_realms_qs))\ + .prefetch_related(Prefetch('realms', queryset=context_realms_qs)) \ + .annotate(has_active_realm=Exists(context_realms_qs.filter(user=OuterRef('pk'), is_active=True))) \ .distinct() @transaction.atomic @@ -348,9 +352,10 @@ def create(self, request, *args, **kwargs): self.perform_create(serializer) headers = self.get_success_headers(serializer.data) - return Response(UserRealmRetrieveSerializer(instance=serializer.instance).data, + return Response(UserRealmRetrieveSerializer(instance=self.get_queryset().get(pk=serializer.instance.pk)).data, status=status.HTTP_201_CREATED, headers=headers) + @transaction.atomic def partial_update(self, request, *args, **kwargs): lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]} @@ -359,11 +364,9 @@ def partial_update(self, request, *args, **kwargs): serializer = self.get_serializer(instance, data=request.data, partial=True) serializer.is_valid(raise_exception=True) self.perform_update(serializer) - if getattr(instance, '_prefetched_objects_cache', None): instance._prefetched_objects_cache = {} - - return Response(UserRealmRetrieveSerializer(instance=serializer.instance).data) + return Response(UserRealmRetrieveSerializer(instance=self.get_queryset().get(pk=serializer.instance.pk)).data) class ExternalUserViewSet( diff --git a/src/etools/config/settings/base.py b/src/etools/config/settings/base.py index e37703bb7..b123265a5 100644 --- a/src/etools/config/settings/base.py +++ b/src/etools/config/settings/base.py @@ -611,7 +611,6 @@ def before_send(event, hint): # https://github.com/unicef/etools-partner-reporting-portal PRP_API_ENDPOINT = get_from_secrets_or_env('PRP_API_ENDPOINT', '') # example: http://172.18.0.1:8083/api PRP_API_USER = get_from_secrets_or_env('PRP_API_USER', '') -PRP_API_PASSWORD = get_from_secrets_or_env('PRP_API_PASSWORD', '') # EPD settings