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
{% endfor %}
@@ -234,11 +240,11 @@
{{ officer.first_name }} {{ officer.last_name }}
{{ officer.title }}
- {{ officer.email }}
+ {{ officer.email|text_wrap:30 }}
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 @@
- (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:
-- (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. -
-- (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. -
-+ 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. +
++ 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. +
++ 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. +
-+ 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. +
-+ 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