diff --git a/indicators/forms.py b/indicators/forms.py index f33878c9e..32e50c6c3 100755 --- a/indicators/forms.py +++ b/indicators/forms.py @@ -236,6 +236,10 @@ def __init__(self, *args, **kwargs): super(IndicatorForm, self).__init__(*args, **kwargs) + # Making the data_points field readonly for participant count indicators + if indicator and indicator.admin_type == Indicator.ADMIN_PARTICIPANT_COUNT: + self.fields['data_points'].widget.attrs['readonly'] = True + # per mercycorps/TolaActivity#2452 remove textarea max length validation to provide a more # user-friendly js-based validation (these textarea checks aren't enforced at db-level anyway) for field in ['name', 'definition', 'justification', 'rationale_for_target', @@ -475,7 +479,7 @@ def clean(self): clean_name = cleaned_data['name'] clean_admin_type = cleaned_data['admin_type'] if clean_admin_type != Indicator.ADMIN_PARTICIPANT_COUNT and \ - clean_name == Indicator.PARTICIPANT_COUNT_INDICATOR_NAME: + Indicator.PARTICIPANT_COUNT_INDICATOR_NAME.lower() in clean_name.lower(): raise ValidationError( # Translators: This is an error message that appears when a user tries to use an off-limits name _('The indicator name you have selected is reserved. Please enter a different name')) diff --git a/indicators/management/commands/create_participant_count_indicators.py b/indicators/management/commands/create_participant_count_indicators.py index 77fa8a890..57c73262a 100644 --- a/indicators/management/commands/create_participant_count_indicators.py +++ b/indicators/management/commands/create_participant_count_indicators.py @@ -6,7 +6,7 @@ from django.core.management.base import BaseCommand from django.conf import settings from django.db import transaction - +from dateutil.relativedelta import relativedelta from indicators.models import ( Indicator, DisaggregationType, DisaggregationLabel, OutcomeTheme ) @@ -30,6 +30,7 @@ def add_arguments(self, parser): '--suppress_output', action='store_true', help="Supresses the output so tests don't get too messy") parser.add_argument('--clean', action='store_true') + parser.add_argument('--delete_pilot_pc_indicators', action='store_true') @transaction.atomic def handle(self, *args, **options): @@ -55,6 +56,20 @@ def handle(self, *args, **options): theme.save() return + if options['delete_pilot_pc_indicators']: + pcind_id_list = [12385, 12053, 12051, 12052, 12050, 12054, 12069, 12068, 12065, 12072, 12063, 12064, 12066, + 12071, 12070, 12060, 12057, 12056, 12059, 12058, 12061, 12055, 12062] + # Call indicators on an object to object basis to force cascading delete. + deleted_ind = 0 + for pcid in pcind_id_list: + if Indicator.objects.filter(pk=pcid).exists(): + ind_to_delete = Indicator.objects.get(pk=pcid) + ind_to_delete.delete(force_policy=HARD_DELETE) + deleted_ind += 1 + + if not options['suppress_output']: + print(f'{deleted_ind} pilot pc indicators deleted, {len(pcind_id_list)-deleted_ind} not found') + if options['create_disaggs_themes']: sadd_label_text = 'Age Unknown M, Age Unknown F, Age Unknown Sex Unknown, 0-5 M, 0-5 F, 0-5 Sex Unknown, 6-9 M, 6-9 F, 6-9 Sex Unknown, 10-14 M, 10-14 F, 10-14 Sex Unknown, 15-19 M, 15-19 F, 15-19 Sex Unknown, 20-24 M, 20-24 F, 20-24 Sex Unknown, 25-34 M, 25-34 F, 25-34 Sex Unknown, 35-49 M, 35-49 F, 35-49 Sex Unknown, 50+ M, 50+ F, 50+ Sex Unknown' sadd_label_list = sadd_label_text.split(', ') @@ -62,7 +77,7 @@ def handle(self, *args, **options): sector_list = sorted([ 'Agriculture', 'Cash and Voucher Assistance', 'Environment (DRR, Energy and Water)', 'Infrastructure (non - WASH, non - energy)', 'Governance and Partnership', 'Employment', 'WASH', - 'Financial Services', 'Nutrition', 'Health (non - nutrition)'] + 'Financial Services', 'Nutrition', 'Public Health (non - nutrition, non - WASH)'] ) actual_disagg_labels = ['Direct', 'Indirect'] @@ -94,7 +109,8 @@ def handle(self, *args, **options): counts = { 'eligible_programs': 0, 'pc_indicator_does_not_exist': 0, 'has_rf': 0, 'indicators_created': 0,} - reporting_start_date = date.fromisoformat(settings.REPORTING_YEAR_START_DATE) + # Subtract one year from the reporting year start date + reporting_start_date = date.fromisoformat(settings.REPORTING_YEAR_START_DATE) - relativedelta(years=1) eligible_programs = Program.objects.filter(reporting_period_end__gte=reporting_start_date) counts['eligible_programs'] = eligible_programs.count() diff --git a/indicators/serializers_new/participant_count_serializers.py b/indicators/serializers_new/participant_count_serializers.py index 8d4e987be..b6be4f266 100644 --- a/indicators/serializers_new/participant_count_serializers.py +++ b/indicators/serializers_new/participant_count_serializers.py @@ -1,5 +1,7 @@ import copy -from rest_framework import serializers +import re +from rest_framework import serializers, exceptions +from django.utils.translation import ugettext_lazy as _ from django.db.models import Count, BooleanField, Q from indicators.models import ( PeriodicTarget, Result, OutcomeTheme, DisaggregationType, DisaggregationLabel, DisaggregatedValue @@ -123,17 +125,254 @@ class Meta: 'disaggregations' ] + def empty_evidence(self): + """ + Utility method for checking if both evidence fields [evidence_url, record_name] are empty. + + Returns + True if both fields are empty else False + """ + return self.initial_data.get('record_name') == '' and self.initial_data.get('evidence_url') == '' \ + or self.initial_data.get('record_name') is None and self.initial_data.get('evidence_url') is None + + def validate_date_collected(self, value): + """ + Validates that date_collected is in the range of self.context.get('program').start_date and self.context.get('program').end_date + + Params + value + - The value of date_collected + + Raises + ValidationError + - If validation fails + + Returns + Value of date_collected + """ + program = self.context.get('program') + + if program.reporting_period_start <= value <= program.reporting_period_end: + return value + + # Translators: An error message detailing that the selected date should be within the reporting period for the fiscal year + raise exceptions.ValidationError(_('This date should be within the fiscal year of the reporting period.')) + + def validate_outcome_themes(self, value): + """ + Validates that the length of outcome_themes is greater than 0 + + Params + value + - The value of outcome_themes + + Raises + ValidationError + - If validation fails + + Returns + Value of outcome_themes + """ + if len(value) > 0: + return value + + # Translators: An error message detailing that outcome themes are required and that multiple outcome themes can be selected + raise exceptions.ValidationError(_('Please complete this field. You can select more than one outcome theme.')) + + def validate_evidence_url(self, value): + """ + Validates that evidence_url matches the regex pattern and that record_name is not None + + Params + value + - The value of evidence_url + + Raises + ValidationError + - If validation fails + + Returns + Value of evidence_url + """ + pattern = r"^(http(s)?|file):\/\/.+" + + # Both evidence fields are empty. Return value instead of raising exception + if self.empty_evidence(): + return value + + if self.initial_data.get('record_name') is None or len(self.initial_data.get('record_name')) == 0: + # Translators: An error message detailing that the record name must be included along the a evidence link + raise exceptions.ValidationError(_('A record name must be included along with the link.')) + + if re.match(pattern, value) is None: + # Translators: An error message detailing that the evidence link was invalid + raise exceptions.ValidationError(_('Please enter a valid evidence link.')) + + return value + + def validate_record_name(self, value): + """ + Validates that record_name is set alongside evidence_url + + Params + value + - The value of record_name + + Raises + ValidationError + - If validation fails + + Returns + Value of record_name + """ + + # Both evidence fields are empty. Return value instead of raising exception + if self.empty_evidence(): + return value + + if self.initial_data.get('evidence_url') is None or len(self.initial_data.get('evidence_url')) == 0: + # Translators: An error message detailing that an evidence link must be included along with the record name + raise exceptions.ValidationError(_('A link must be included along with the record name.')) + + return value + + def disaggregations_are_valid(self, used_disaggregations): + """ + Checks that each disaggregation passes validation + + Params + used_disaggregations + - A dict with a total value for each disaggregation type + + Raises + ValidationError + - If validation fails + + Returns + True if validation passes + """ + sadd_with_direct = used_disaggregations['SADD (including unknown) with double counting']['direct']['value'] + sadd_without_direct = used_disaggregations['SADD (including unknown) without double counting']['direct']['value'] + actual_with_direct = used_disaggregations['Actual with double counting']['direct']['value'] + actual_with_indirect = used_disaggregations['Actual with double counting']['indirect']['value'] + actual_without_direct = used_disaggregations['Actual without double counting']['direct']['value'] + actual_without_indirect = used_disaggregations['Actual without double counting']['indirect']['value'] + sectors_direct = used_disaggregations['Sectors Direct with double counting']['direct']['value'] + sectors_indirect = used_disaggregations['Sectors Indirect with double counting']['indirect']['value'] + + if not sadd_with_direct == actual_with_direct: + # Translators: An error message detailing that the sum of 'SADD with double counting' should be equal to the sum of 'Direct with double counting' + raise exceptions.ValidationError(_("The sum of 'SADD with double counting' should be equal to the sum of 'Direct with double counting'.")) + + if not sadd_without_direct == actual_without_direct: + # Translators: An error message detailing that the sum of 'SADD without double counting' should be equal to the sum of 'Direct without double counting' + raise exceptions.ValidationError(_("The sum of 'SADD without double counting' should be equal to the sum of 'Direct without double counting'.")) + + if actual_with_direct == 0 or actual_with_indirect == 0: + # Translators: An error message detailing that the fields Direct and Indirect total participants with double counting is required + raise exceptions.ValidationError(_("Direct/indirect total participants with double counting is required. Please complete these fields.")) + + if (actual_without_direct + actual_without_indirect) > (actual_with_direct + actual_with_indirect): + # Translators: An error message detailing that the Direct and Indirect without double counting should be equal to or lower than the value of Direct and Indirect with double counting + raise exceptions.ValidationError(_("Direct/indirect without double counting should be equal to or lower than direct/indirect with double counting.")) + + if (sectors_direct + sectors_indirect) > (actual_with_direct + actual_with_indirect): + # Translators: An error message detailing that the Sector values should be less to or equal to the sum of Direct and Indirect with double counting value + raise exceptions.ValidationError(_("Sector values should be less than or equal to the 'Direct/Indirect with double counting' value.")) + + return True + + def process_disaggregations(self, disaggregations, instance, preserve_old_ids=False): + """ + Processes disaggregations for creation and also creates the used_disaggregations dict with total values + + Params + disaggregations + - The disaggregations object from the request + instance + - instance of the Results object + preserve_old_ids + - Optional defaults to False + - Used for updating in order to get the old_label_ids + + Returns + new_value_objs, old_label_ids if preserve_old_ids is True else new_value_objs + """ + new_value_objs = list() + old_label_ids = list() + # A dict to hold total values for disaggregations + used_disaggregations = { + "SADD (including unknown) with double counting": { + "direct": { + "value": 0 + } + }, + "SADD (including unknown) without double counting": { + "direct": { + "value": 0 + } + }, + "Sectors Direct with double counting": { + "direct": { + "value": 0 + } + }, + "Sectors Indirect with double counting": { + "indirect": { + "value": 0 + } + }, + "Actual without double counting": { + "direct": { + "value": 0 + }, + "indirect": { + "value": 0 + } + }, + "Actual with double counting": { + "direct": { + "value": 0 + }, + "indirect": { + "value": 0 + } + } + } + + for disagg in disaggregations: + disagg_type = disagg['disaggregation_type'] + for label_value in disagg['labels']: + if preserve_old_ids: + old_label_ids.append(label_value['disaggregationlabel_id']) + if label_value['value']: + if disagg_type in ['Actual with double counting', 'Actual without double counting']: + key = label_value['label'].lower() + else: + key = disagg['count_type'].lower() + + try: + used_disaggregations[disagg_type][key]['value'] += int(label_value['value']) + except ValueError: + used_disaggregations[disagg_type][key]['value'] += float(label_value['value']) + + new_value_objs.append(DisaggregatedValue( + category_id=label_value['disaggregationlabel_id'], result=instance, value=label_value['value'])) + + if self.disaggregations_are_valid(used_disaggregations): + if preserve_old_ids: + return new_value_objs, old_label_ids + else: + return new_value_objs + def create(self, validated_data): disaggregations = validated_data.pop('disaggregations') outcome_themes = validated_data.pop('outcome_themes') result = Result.objects.create(**validated_data) result.outcome_themes.add(*outcome_themes) - value_objs = [] - for disagg in disaggregations: - for label_value in disagg['labels']: - if label_value['value']: - value_objs.append(DisaggregatedValue( - category_id=label_value['disaggregationlabel_id'], result=result, value=label_value['value'])) + + value_objs = self.process_disaggregations(disaggregations, result) + # There's lots of warnings about batch_create in the Django documentation, e.g. it skips the # save method and won't trigger signals, but I don't we need any of those things in this case. DisaggregatedValue.objects.bulk_create(value_objs) @@ -148,14 +387,7 @@ def update(self, instance, validated_data): instance.outcome_themes.remove() instance.outcome_themes.add(*outcome_themes) - new_value_objs = [] - old_label_ids = [] - for disagg in disaggregations: - for label in disagg['labels']: - old_label_ids.append(label['disaggregationlabel_id']) - if label['value']: - new_value_objs.append(DisaggregatedValue( - category_id=label['disaggregationlabel_id'], result=instance, value=label['value'])) + new_value_objs, old_label_ids = self.process_disaggregations(disaggregations, instance, preserve_old_ids=True) old_disagg_values = DisaggregatedValue.objects.filter(category_id__in=old_label_ids, result=instance) old_disagg_values.delete() diff --git a/indicators/tests/serializer_tests/test_participant_count_serializer_validation.py b/indicators/tests/serializer_tests/test_participant_count_serializer_validation.py new file mode 100644 index 000000000..5698a6a06 --- /dev/null +++ b/indicators/tests/serializer_tests/test_participant_count_serializer_validation.py @@ -0,0 +1,94 @@ +""" +Test Cases for the validation on PCResultSerializerWrite +""" +from django import test +from datetime import date +from indicators.serializers_new import participant_count_serializers +from factories.indicators_models import IndicatorFactory +from factories.workflow_models import ProgramFactory + + +class TestPCResultSerializerWriteValidation(test.TestCase): + + serializer = participant_count_serializers.PCResultSerializerWrite + error_messages = { + 'empty_outcome': 'Please complete this field. You can select more than one outcome theme.', + 'invalid_evidence': 'Please enter a valid evidence link.', + 'empty_record_with_evidence': 'A record name must be included along with the link.', + 'record_with_empty_evidence': 'A link must be included along with the record name.', + 'invalid_date_collected': 'This date should be within the fiscal year of the reporting period.' + } + + def setUp(self): + self.program = ProgramFactory(reporting_period_start=date(year=2022, month=1, day=1), reporting_period_end=date(year=2022, month=10, day=1)) + self.indicator = IndicatorFactory(program=self.program) + + def example_data(self): + return { + "outcome_themes": [2, 3], + "achieved": 5, + "indicator": self.indicator.pk, + "disaggregations": [], + "evidence_url": "https://docs.google.com/document/d/1YHopunXtY781Z5uI1Xf-iswO8ERtaCaon0N7YPCjeAo/edit?usp=sharing", + "record_name": "test evidence", + "date_collected": date(year=2022, month=2, day=10) + } + + def test_with_correct_data(self): + result = self.serializer(data=self.example_data(), context={"program": self.program}) + result.is_valid(raise_exception=True) + + def test_with_empty_outcome_themes(self): + data = self.example_data() + data['outcome_themes'] = [] + result = self.serializer(data=data, context={"program": self.program}) + result.is_valid() + self.assertEquals(self.error_messages['empty_outcome'], str(result.errors['outcome_themes'][0])) + + def test_invalid_evidence_url(self): + data = self.example_data() + data['evidence_url'] = 'abcd' + result = self.serializer(data=data, context={"program": self.program}) + result.is_valid() + self.assertEquals(self.error_messages['invalid_evidence'], str(result.errors['evidence_url'][0])) + + def test_empty_record_with_evidence(self): + data = self.example_data() + data['record_name'] = '' + result = self.serializer(data=data, context={"program": self.program}) + result.is_valid() + self.assertEquals(self.error_messages['empty_record_with_evidence'], str(result.errors['evidence_url'][0])) + + def test_record_with_empty_evidence(self): + data = self.example_data() + data['evidence_url'] = '' + result = self.serializer(data=data, context={"program": self.program}) + result.is_valid() + self.assertEquals(self.error_messages['record_with_empty_evidence'], str(result.errors['record_name'][0])) + + def test_invalid_date_collected(self): + data = self.example_data() + data['date_collected'] = date(year=2022, month=11, day=1) + result = self.serializer(data=data, context={"program": self.program}) + result.is_valid() + self.assertEquals(self.error_messages['invalid_date_collected'], str(result.errors['date_collected'][0])) + + def test_without_evidence(self): + """ + Evidence is not included in request + """ + data = self.example_data() + del data['evidence_url'] + del data['record_name'] + result = self.serializer(data=data, context={'program': self.program}) + result.is_valid(raise_exception=True) + + def test_empty_evidence(self): + """ + Evidence is empty and included in request + """ + data = self.example_data() + data['evidence_url'] = '' + data['record_name'] = '' + result = self.serializer(data=data, context={'program': self.program}) + result.is_valid(raise_exception=True) diff --git a/indicators/tests/test_management_create_participant_count_indicators.py b/indicators/tests/test_management_create_participant_count_indicators.py new file mode 100644 index 000000000..32624a285 --- /dev/null +++ b/indicators/tests/test_management_create_participant_count_indicators.py @@ -0,0 +1,98 @@ +from django import test +from django.core import management +from datetime import date +from indicators.models import Indicator, IndicatorType, OutcomeTheme, DisaggregationType, PeriodicTarget, DisaggregationLabel +from factories.indicators_models import IndicatorTypeFactory, ReportingFrequencyFactory, ReportingFrequency, LevelFactory +from factories.workflow_models import ProgramFactory + + + +class TestManagementCreateParticipantCountIndicators(test.TestCase): + expected_lengths = { + 'outcome_themes': 5, + 'disagg_types': 6, + 'periodic_target': 1, + 'indicators': 1 + } + + def setUp(self): + IndicatorTypeFactory(indicator_type=IndicatorType.PC_INDICATOR_TYPE) + ReportingFrequencyFactory(frequency=ReportingFrequency.PC_REPORTING_FREQUENCY) + program = ProgramFactory(reporting_period_start=date(2022, 2, 1), reporting_period_end=date(2022, 12, 1)) + LevelFactory(name="test", program=program) + + def indicators(self): + indicators = Indicator.objects.filter(admin_type=Indicator.ADMIN_PARTICIPANT_COUNT) + return len(indicators) + + def outcome_themes(self): + outcome_themes = OutcomeTheme.objects.all() + return len(outcome_themes) + + def disagg_types(self): + disagg_types = DisaggregationType.objects.all() + return len(disagg_types) + + def periodic_target(self): + periodic_target = PeriodicTarget.objects.all() + return len(periodic_target) + + def create_disagg_type(self, **kwargs): + actual_disagg_labels = ['Direct', 'Indirect'] + + disagg_type = DisaggregationType(disaggregation_type='Actual with double counting', global_type=DisaggregationType.DISAG_PARTICIPANT_COUNT, **kwargs) + disagg_type.save() + + for label in actual_disagg_labels: + disagg_label = DisaggregationLabel(label=label, disaggregation_type=disagg_type, customsort=1) + disagg_label.save() + + def create_outcome(self): + outcome_theme = OutcomeTheme(name='Resilience', is_active=True) + outcome_theme.save() + + def assertions(self): + self.assertEquals(self.outcome_themes(), self.expected_lengths['outcome_themes']) + self.assertEquals(self.disagg_types(), self.expected_lengths['disagg_types']) + self.assertEquals(self.periodic_target(), self.expected_lengths['periodic_target']) + self.assertEquals(self.indicators(), self.expected_lengths['indicators']) + + def test_disagg_type_archived(self): + self.create_disagg_type(is_archived=True) + management.call_command( + 'create_participant_count_indicators', execute=True, create_disaggs_themes=True, suppress_output=True, verbosity=0) + self.assertions() + + def test_disagg_type_exists(self): + self.create_disagg_type() + management.call_command( + 'create_participant_count_indicators', execute=True, create_disaggs_themes=True, suppress_output=True, verbosity=0) + self.assertions() + + def test_outcome_exists(self): + self.create_outcome() + management.call_command( + 'create_participant_count_indicators', execute=True, create_disaggs_themes=True, suppress_output=True, verbosity=0) + self.assertions() + + def test_dry_run(self): + management.call_command( + 'create_participant_count_indicators', execute=False, create_disaggs_themes=True, suppress_output=True, verbosity=0) + + self.assertEquals(self.outcome_themes(), self.expected_lengths['outcome_themes']) + self.assertEquals(self.disagg_types(), self.expected_lengths['disagg_types']) + self.assertEquals(self.periodic_target(), 0) # Not created when execute is False + self.assertEquals(self.indicators(), 0) # Not created when execute is False + + """ + Commenting out the test. The job on github will freeze from the input selection. + def test_without_create_disaggs_themes(self): + management.call_command( + 'create_participant_count_indicators', execute=True, create_disaggs_themes=False, suppress_output=False, verbosity=0) + """ + + def test_command(self): + management.call_command( + 'create_participant_count_indicators', execute=True, create_disaggs_themes=True, suppress_output=True, verbosity=0) + + self.assertions() diff --git a/indicators/utils.py b/indicators/utils.py index 8cefbf26a..41f2a546d 100644 --- a/indicators/utils.py +++ b/indicators/utils.py @@ -6,15 +6,16 @@ def create_participant_count_indicator(program, top_level, disaggregations_qs): definition_text = ( + "** Add Direct Participants definition **\n\n" + "** Add Indirect Participants definition **\n\n" + "** Add methodology for double counting **\n\n" "Participants are defined as “all people who have received tangible benefit – directly " "or indirectly from the project.” We distinguish between direct and indirect:\n\n" "Direct participants – are those who have received a tangible benefit from the program, " "either as the actual program participants or the intended recipients of the program benefits. " "This means individuals or communities.\n\n" "Indirect participants – are those who received a tangible benefit through their proximity to " - "or contact with program participants or activities.\n\n" - "** Add Direct Participants definition **\n\n" - "** Add Indirect Participants definition **" + "or contact with program participants or activities." ) indicator_justification = ( "Participant reach is crucial to take decisions on the program implementation. It provides insights " diff --git a/indicators/views/bulk_indicator_import_views.py b/indicators/views/bulk_indicator_import_views.py index 58ab2fbe8..3d6f95e5d 100644 --- a/indicators/views/bulk_indicator_import_views.py +++ b/indicators/views/bulk_indicator_import_views.py @@ -573,7 +573,7 @@ def post(self, request, *args, **kwargs): level_refs = {} for level in program.levels.all(): ontology = f' {level.display_ontology}' if len(level.display_ontology) > 0 else '' - level_refs[f'{gettext(str(level.leveltier))}{ontology}'] = level + level_refs[f'{gettext(str(level.leveltier))}{ontology}'.strip()] = level non_fatal_errors = [] # Errors that can be highlighted on a spreadsheet and get sent back to the user fatal_errors = [] # So bad that it's not possible to highlight a spreadsheet and send it back to the suer @@ -678,7 +678,7 @@ def post(self, request, *args, **kwargs): # Check if the value of the Level column matches the one used in the level header level_cell = ws.cell(current_row_index, FIRST_USED_COLUMN) - if level_cell.value != current_tier: + if level_cell.value.strip() != current_tier: level_cell.fill = PatternFill('solid', fgColor=RED_ERROR) fatal_errors.append(ERROR_UNEXPECTED_LEVEL) diff --git a/indicators/views/views_indicators.py b/indicators/views/views_indicators.py index ef2d4efa1..4fcf0405e 100755 --- a/indicators/views/views_indicators.py +++ b/indicators/views/views_indicators.py @@ -162,7 +162,7 @@ def participant_count_result_create_for_indicator(request, pk, *args, **kwargs): } result_data.update(request.data) - result = pc_serializers.PCResultSerializerWrite(data=result_data) + result = pc_serializers.PCResultSerializerWrite(data=result_data, context={"program": indicator.program}) if result.is_valid(): result_obj = result.save() ProgramAuditLog.log_result_created( @@ -207,7 +207,7 @@ def participant_count_result_update(request, pk, *args, **kwargs): 'outcome_themes': request.data.pop('outcome_theme') }) - result_serializer = pc_serializers.PCResultSerializerWrite(result, data=result_data) + result_serializer = pc_serializers.PCResultSerializerWrite(result, data=result_data, context={"program": indicator.program}) if result_serializer.is_valid(): updated_result = result_serializer.save() ProgramAuditLog.log_result_updated( diff --git a/js/pages/results_form_PC/components/ActualValueFields.js b/js/pages/results_form_PC/components/ActualValueFields.js index 80a2fc4ad..28fbd584a 100644 --- a/js/pages/results_form_PC/components/ActualValueFields.js +++ b/js/pages/results_form_PC/components/ActualValueFields.js @@ -33,7 +33,7 @@ const ActualValueFields = ({ disaggregationData, setDisaggregationData, formErro