diff --git a/.gitignore b/.gitignore index 4ac14d61c..e2ed8d2db 100644 --- a/.gitignore +++ b/.gitignore @@ -1,65 +1,87 @@ +# OS generated files +#################### +.DS_Store + +# Log files +########### *.log login.log.* *.pot -*.pyc -# Python bytecode: -*.py[co] - -# Packaging files: -*.egg* +error.log -# Editor temp files: +# Editor configuration & temp files +################################### +.idea/ +.vscode/ +.nova/ *.swp *.swo *~ +.eslintrc.yml -# SQLite3 database files: -*.db +# Python Temp files +################### +*.py[co] +*.pyc +__pycache__ -# Logs: -*.log +# Python packages +################# +*.egg* # unused? -#allvritualenv +# Local database +################ +*.db + +# Virtual environments +###################### .env/ venv/ venv-ta/ +.vagrant # unused? +# Non-production config & secrets +################################# conf conf/awstats.conf - -#Misc. -.idea/ -.vscode/ -.DS_Store -media/ client_secrets.json tola/settings/local_secret.py -*.crt -*.key -error.log -*.secret settings.secret.yml -assets/ -templates/links.html -coverage_html -.coverage -coverage/ tola/settings/test_local.py -log_convert_lop_to_numeric +*.key +*.secret +*.crt -# SASS Source Map -*.css.map +# Django collected static files +############################### +media/ -# node modules +# Frontend assets +################# node_modules - +*.css.map webpack-stats-local.json -webpack-stats-vagrant.json -.vagrant -site.retry -.eslintrc.yml -requirements.txt -htmlcov +webpack-stats-vagrant.json # unused? -# ingore temp files in bulk import dir, which will always start with yyyymmdd +# temp files in bulk import dir, which will always start with yyyymmdd +###################################################################### indicators/bulk_import_files/[0-9]* + +# Are we sure we want to ignore these? +###################################### +requirements.txt + +# Probably related to automated tests we no longer use +###################################################### +coverage_html +.coverage +coverage/ +htmlcov + +# ??? # +####### +assets/ +templates/links.html +log_convert_lop_to_numeric +site.retry # ansible? + diff --git a/README.md b/README.md index 01a827f82..31a1842ad 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ $ mysql --version At the Terminal command line, run the following commands to install: ```bash -$ brew install python@3 +$ brew install python@3.8 $ brew install mysql@5.7 ``` @@ -62,15 +62,34 @@ export LDFLAGS="-L/usr/local/opt/mysql@5.7/lib" export CPPFLAGS="-I/usr/local/opt/mysql@5.7/include" ``` +For __macOS with ARM chip__ add this instead: +```text +export PATH="/opt/homebrew/opt/openssl@1.1/bin:$PATH" +export LIBRARY_PATH="/opt/homebrew/opt/openssl@1.1/lib/:$LIBRARY_PATH" +export PATH="/opt/homebrew/opt/mysql@5.7/bin:$PATH" +export PATH="/opt/homebrew/opt/mysql-client@5.7/bin:$PATH" +export PKG_CONFIG_PATH="/opt/homebrew/opt/mysql-client@5.7/lib/pkgconfig" + +export LDFLAGS="-L/opt/homebrew/opt/openssl@1.1/lib" +export CPPFLAGS="-I/opt/homebrew/opt/openssl@1.1/include" +export LDFLAGS="-L/opt/homebrew/opt/mysql@5.7/lib" +export CPPFLAGS="-I/opt/homebrew/opt/mysql@5.7/include" +``` + Back at the command line: ```bash $ source ~/.bash_profile # if using bash, e.g on MacOS 10.14 or older $ source ~/.zshrc # if using zsh, e.g. on MacOS 10.15 or newer -$ brew install mysql-client +$ brew install mysql-client $ brew install py2cairo pango $ pip3 install virtualenv ``` +For __macOS ARM chip__ add this instead you might have to specify the mysql_client version: +```bash +$ brew install mysql-client@5.7 +``` + You should now also start the mysql server: ```bash brew services start mysql@5.7 diff --git a/babel.config.json b/babel.config.json index 6e2852584..c472cde5d 100644 --- a/babel.config.json +++ b/babel.config.json @@ -15,6 +15,18 @@ { "loose": true } + ], + [ + "@babel/plugin-proposal-private-methods", + { + "loose": true + } + ], + [ + "@babel/plugin-proposal-private-property-in-object", + { + "loose": true + } ] ], "env": { diff --git a/dev-requirements.txt b/dev-requirements.txt index 03dad09d6..47567f953 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with python 3.8 +# This file is autogenerated by pip-compile with python 3.9 # To update, run: # # pip-compile dev-requirements.in diff --git a/factories/indicators_models.py b/factories/indicators_models.py index 2b02dd75b..5dc68a1d9 100644 --- a/factories/indicators_models.py +++ b/factories/indicators_models.py @@ -22,6 +22,7 @@ Result, ExternalService, ReportingFrequency, + IDAAOutcomeTheme, Indicator, IndicatorType, Level, @@ -383,3 +384,11 @@ class Meta: name = Faker('text') is_active = True + + +class IDAAOutcomeThemeFactory(DjangoModelFactory): + class Meta: + model = IDAAOutcomeTheme + + name = Sequence(lambda n: f'IDAAOutcometheme {n}') + is_active = True diff --git a/factories/workflow_models.py b/factories/workflow_models.py index 747ddc636..e33d5f3c5 100644 --- a/factories/workflow_models.py +++ b/factories/workflow_models.py @@ -20,6 +20,7 @@ from factories.django_models import UserFactory, UserOnlyFactory from workflow.models import ( Country, + IDAASector, Organization, ProfileType, Sector, @@ -28,6 +29,8 @@ Program, CountryAccess, ProgramAccess, + IDAASector, + GaitID, PROGRAM_ROLE_CHOICES, COUNTRY_ROLE_CHOICES ) @@ -123,10 +126,18 @@ def password(obj, create, extracted, **kwargs): obj.user.save() +class GaitIDFactory(DjangoModelFactory): + + class Meta: + model = GaitID + django_get_or_create = ('gaitid',) + + gaitid = Sequence(lambda n: "%0030d" % n) + + class ProgramFactory(DjangoModelFactory): class Meta: model = Program - django_get_or_create = ('gaitid',) class Params: active = True @@ -138,7 +149,7 @@ class Params: ) name = 'Health and Survival for Syrians in Affected Regions' - gaitid = Sequence(lambda n: "%0030d" % n) + gaitid = RelatedFactory(GaitIDFactory, factory_related_name='program') country = RelatedFactory(CountryFactory, country='United States', code='US') funding_status = LazyAttribute(lambda o: "funded" if o.active else "Inactive") _using_results_framework = Program.RF_ALWAYS @@ -177,11 +188,16 @@ class Params: age = False name = Faker('company') + gaitid = RelatedFactory(GaitIDFactory, factory_related_name='program') funding_status = LazyAttribute(lambda o: "Funded" if o.active else "Inactive") _using_results_framework = LazyAttribute( lambda o: Program.RF_ALWAYS if o.migrated is None else Program.MIGRATED if o.migrated else Program.NOT_MIGRATED ) + @post_generation + def gaitid(self, create, extracted, **kwargs): + GaitIDFactory(gaitid='123456', program=self) + @lazy_attribute def reporting_period_start(self): year = datetime.date.today().year @@ -341,6 +357,13 @@ class Meta: sector = Sequence(lambda n: 'Sector {0}'.format(n)) +class IDAASectorFactory(DjangoModelFactory): + class Meta: + model = IDAASector + + sector = Sequence(lambda n: f'Sector {n}') + + class ProfileType(DjangoModelFactory): class Meta: model = ProfileType diff --git a/indicators/admin.py b/indicators/admin.py index d2c2c8610..e27b3c171 100755 --- a/indicators/admin.py +++ b/indicators/admin.py @@ -5,22 +5,43 @@ from django.utils.translation import gettext_lazy as _ from django.utils.html import format_html from indicators.models import ( - Indicator, IndicatorType, Result, StrategicObjective, Objective, Level, - ExternalService, ExternalServiceRecord, DataCollectionFrequency, - DisaggregationType, PeriodicTarget, DisaggregationLabel, ReportingFrequency, - ExternalServiceAdmin, ExternalServiceRecordAdmin, PeriodicTargetAdmin, OutcomeTheme + BulkIndicatorImportFile, + DataCollectionFrequency, + DisaggregationLabel, + DisaggregationType, + ExternalService, + ExternalServiceRecord, + IDAAOutcomeTheme, + Indicator, + IndicatorType, + Level, + LevelTier, + LevelTierTemplate, + Objective, + OutcomeTheme, + PeriodicTarget, + PinnedReport, + ReportingFrequency, + Result, + StrategicObjective, ) from workflow.models import Sector, Program, Country from import_export import resources, fields from import_export.widgets import ForeignKeyWidget, ManyToManyWidget from import_export.admin import ImportExportModelAdmin from simple_history.admin import SimpleHistoryAdmin +from admin_auto_filters.filters import AutocompleteFilter, AutocompleteFilterFactory + DISAG_COUNTRY_ONLY = DisaggregationType.DISAG_COUNTRY_ONLY DISAG_GLOBAL = DisaggregationType.DISAG_GLOBAL DISAG_PARTICIPANT_COUNT = DisaggregationType.DISAG_PARTICIPANT_COUNT +######### +# Filters +######### + class BooleanListFilterWithDefault(admin.SimpleListFilter): all_value = 'all' @@ -67,6 +88,68 @@ def default_value(self): return 0 +class ProgramByUserFilter(admin.SimpleListFilter): + """ + Creates a list filter for Programs in the active user's country + """ + title = "Program (in your country)" + parameter_name = 'program' + + def lookups(self, request, model_admin): + user_country = request.user.tola_user.country + programs = Program.objects.filter(country__in=[user_country]).values('id', 'name') + programs_tuple = () + for p in programs: + programs_tuple = [(p['id'], p['name']) for p in programs] + return programs_tuple + + def queryset(self, request, queryset): + if self.value(): + queryset = queryset.filter(program__in=[self.value()]) + return queryset + + +class CountryFilter(admin.SimpleListFilter): + title = 'country' + parameter_name = 'country' + + def lookups(self, request, model_admin): + countries = Country.objects.all().values('id', 'country') + if request.user.is_superuser is False: + user_country = request.user.tola_user.country + countries = countries.filter(pk=user_country.pk) + countries_tuple = [(c['id'], c['country']) for c in countries] + return countries_tuple + + def queryset(self, request, queryset): + if self.value(): + if queryset.model == Objective: + queryset = queryset.filter(program__country=self.value()) + else: + queryset = queryset.filter(country=self.value()) + return queryset + + +# Autocomplete filters +class ProgramFilter(AutocompleteFilter): + title = 'Program' + field_name = 'program' + + +class ApprovedByFilter(AutocompleteFilter): + title = 'Originator' + field_name = 'approved_by' + + +class IndicatorFilter(AutocompleteFilter): + title = 'Indicator' + field_name = 'indicator' + + +########### +# Resources +########### + # TODO: is this obsolete? class IndicatorResource(resources.ModelResource): @@ -90,31 +173,30 @@ class Meta: 'program') -class IndicatorListFilter(admin.SimpleListFilter): - title = "Program" - parameter_name = 'program' - - def lookups(self, request, model_admin): - user_country = request.user.tola_user.country - programs = Program.objects.filter(country__in=[user_country]).values('id', 'name') - programs_tuple = () - for p in programs: - programs_tuple = [(p['id'], p['name']) for p in programs] - return programs_tuple +class ResultResource(resources.ModelResource): + class Meta: + model = Result + # import_id_fields = ['id'] - def queryset(self, request, queryset): - if self.value(): - queryset = queryset.filter(program__in=[self.value()]) - return queryset +######################### +# Customized model admins +######################### +@admin.register(Indicator) class IndicatorAdmin(ImportExportModelAdmin, SimpleHistoryAdmin): + autocomplete_fields = ('program',) resource_class = IndicatorResource - list_display = ('indicator_types', 'name', 'sector') + list_display = ('name', 'indicator_types', 'sector', 'create_date', 'edit_date',) search_fields = ('name', 'number', 'program__name') - list_filter = (IndicatorListFilter, 'sector') - display = 'Indicators' + list_filter = ( + ProgramFilter, + ProgramByUserFilter, + 'indicator_type', + 'sector' + ) filter_horizontal = ('objectives', 'strategic_objectives', 'disaggregation') + readonly_fields = ('create_date', 'edit_date',) def get_queryset(self, request): queryset = super(IndicatorAdmin, self).get_queryset(request) @@ -125,27 +207,110 @@ def get_queryset(self, request): return queryset -class CountryFilter(admin.SimpleListFilter): - title = 'country' - parameter_name = 'country' +@admin.register(ExternalService) +class ExternalServiceAdmin(admin.ModelAdmin): + list_display = ('name', 'url', 'feed_url', 'create_date', 'edit_date') + readonly_fields = ('create_date', 'edit_date',) - def lookups(self, request, model_admin): - countries = Country.objects.all().values('id', 'country') + +@admin.register(ExternalServiceRecord) +class ExternalServiceRecordAdmin(admin.ModelAdmin): + list_display = ('external_service', 'full_url', 'record_id', 'create_date', 'edit_date') + readonly_fields = ('create_date', 'edit_date',) + + +@admin.register(PeriodicTarget) +class PeriodicTargetAdmin(admin.ModelAdmin): + autocomplete_fields = ('indicator',) + list_display = ('period', 'target', 'customsort', 'indicator', 'create_date', 'edit_date',) + search_fields = ('indicator__name', 'period',) + list_filter = ( + IndicatorFilter, + AutocompleteFilterFactory('Program', 'indicator__program') + ) + readonly_fields = ('create_date', 'edit_date',) + + +@admin.register(Objective) +class ObjectiveAdmin(admin.ModelAdmin): + autocomplete_fields = ('program',) + list_display = ('name', 'program', 'create_date', 'edit_date',) + search_fields = ('name', 'program__name') + list_filter = (ProgramFilter, CountryFilter) + readonly_fields = ('create_date', 'edit_date',) + + def get_queryset(self, request): + queryset = super(ObjectiveAdmin, self).get_queryset(request) if request.user.is_superuser is False: user_country = request.user.tola_user.country - countries = countries.filter(pk=user_country.pk) - countries_tuple = [(c['id'], c['country']) for c in countries] - return countries_tuple + programs = Program.objects.filter(country__in=[user_country]).values('id') + program_ids = [p['id'] for p in programs] + queryset = queryset.filter(program__in=program_ids) + return queryset - def queryset(self, request, queryset): - if self.value(): - if queryset.model == Objective: - queryset = queryset.filter(program__country=self.value()) - else: - queryset = queryset.filter(country=self.value()) + +@admin.register(StrategicObjective) +class StrategicObjectiveAdmin(admin.ModelAdmin): + list_display = ('country', 'name', 'create_date', 'edit_date',) + search_fields = ('country__country', 'name') + list_filter = (CountryFilter,) # ('country__country',) + readonly_fields = ('create_date', 'edit_date',) + + def get_queryset(self, request): + queryset = super(StrategicObjectiveAdmin, self).get_queryset(request) + if request.user.is_superuser is False: + user_country = request.user.tola_user.country + queryset = queryset.filter(country=user_country) return queryset +@admin.register(Result) +class ResultAdmin(ImportExportModelAdmin, SimpleHistoryAdmin): + resource_class = ResultResource + list_display = ( + 'indicator', + 'date_collected', + 'program', + 'achieved', + 'create_date', + 'edit_date' + ) + search_fields = ( + 'indicator__name', + 'program__name', + 'periodic_target__period', + ) + list_filter = ( + ProgramFilter, + IndicatorFilter, + ApprovedByFilter, + 'indicator__program__country__country', + ) + readonly_fields = ('create_date', 'edit_date') + autocomplete_fields = ( + 'periodic_target', + 'approved_by', + 'indicator', + 'program', + 'outcome_themes', + 'site', + ) + date_hierarchy = 'date_collected' + + +@admin.register(OutcomeTheme) +class OutcomeThemeAdmin(admin.ModelAdmin): + list_display = ('name', 'is_active', 'create_date') + readonly_fields = ('create_date',) + search_fields = ('name',) + + +################# +# Disaggregations +################# + +# Includes proxy admin models and inline admin models + class DisaggregationCategoryAdmin(SortableInlineAdminMixin, admin.StackedInline): model = DisaggregationLabel min_num = 2 @@ -168,7 +333,6 @@ def indicator_count(self, instance): class DisaggregationAdmin(admin.ModelAdmin): """Abstract base class for the two kinds of disaggregation admins (country and global)""" - display = _('Disaggregation') inlines = [ DisaggregationCategoryAdmin, ] @@ -212,18 +376,31 @@ def get_queryset(self, request): output_field=models.IntegerField() )) + class GlobalDisaggregation(DisaggregationType): """Proxy model to allow for two admins for one model (disaggregation)""" class Meta: proxy = True + verbose_name = _("Global Disaggregation") + verbose_name_plural = _("Global Disaggregations") + + +@admin.register(DisaggregationType) +class DisaggregationTypeAdmin(admin.ModelAdmin): + list_display = ('disaggregation_type', 'country', 'create_date', 'edit_date') + list_filter = ('global_type', 'is_archived', 'country') + search_fields = ('disaggregation_type', 'country__country') + readonly_fields = ('create_date', 'edit_date') + @admin.register(GlobalDisaggregation) class GlobalDisaggregationAdmin(DisaggregationAdmin): - list_display = ('disaggregation_type', 'global_type', 'pretty_archived', 'program_count', 'categories') + list_display = ('disaggregation_type', 'global_type', 'pretty_archived', 'program_count', 'categories', 'create_date', 'edit_date',) list_filter = (ArchivedFilter,) sortable_by = ('disaggregation_type', 'program_count') - exclude = ('create_date', 'edit_date', 'country') + exclude = ('country',) # not applicable to global disaggregations + readonly_fields = ('create_date', 'edit_date',) GLOBAL_TYPES = [DISAG_GLOBAL, DISAG_PARTICIPANT_COUNT] COLUMN_WIDTH = 70 # width of the "categories list" column before truncation @@ -237,14 +414,17 @@ class CountryDisaggregation(DisaggregationType): """Proxy model to allow for two admins for one model (disaggregation)""" class Meta: proxy = True + verbose_name = _("Country Disaggregation") + verbose_name_plural = _("Country Disaggregations") @admin.register(CountryDisaggregation) class CountryDisaggregationAdmin(DisaggregationAdmin): - list_display = ('disaggregation_type', 'country', 'pretty_archived', 'program_count', 'categories') + list_display = ('disaggregation_type', 'country', 'pretty_archived', 'program_count', 'categories', 'create_date', 'edit_date',) list_filter = (ArchivedFilter, 'country') sortable_by = ('disaggregation_type', 'program_count', 'country') - exclude = ('create_date', 'edit_date', 'global_type',) + exclude = ('global_type',) # not applicable to country disaggregations + readonly_fields = ('create_date', 'edit_date',) GLOBAL_TYPES = [DISAG_COUNTRY_ONLY] COLUMN_WIDTH = 50 @@ -253,70 +433,70 @@ def save_model(self, request, obj, form, change): super().save_model(request, obj, form, change) -class ObjectiveAdmin(admin.ModelAdmin): - list_display = ('program', 'name') - search_fields = ('name', 'program__name') - list_filter = (CountryFilter,) # ('program__country__country',) - display = 'Program Objectives' +@admin.register(IDAAOutcomeTheme) +class IDAAOutcomeThemeAdmin(admin.ModelAdmin): + list_display = ('name', 'is_active', 'create_date') + list_filter = ('is_active',) + search_fields = ('name',) + readonly_fields = ('create_date',) - def get_queryset(self, request): - queryset = super(ObjectiveAdmin, self).get_queryset(request) - if request.user.is_superuser is False: - user_country = request.user.tola_user.country - programs = Program.objects.filter(country__in=[user_country]).values('id') - program_ids = [p['id'] for p in programs] - queryset = queryset.filter(program__in=program_ids) - return queryset +@admin.register(IndicatorType) +class IndicatorTypeAdmin(admin.ModelAdmin): + list_display = ('indicator_type', 'create_date', 'edit_date') + readonly_fields = ('create_date', 'edit_date') -class StrategicObjectiveAdmin(admin.ModelAdmin): - list_display = ('country', 'name') - search_fields = ('country__country', 'name') - list_filter = (CountryFilter,) # ('country__country',) - display = 'Country Strategic Objectives' - def get_queryset(self, request): - queryset = super(StrategicObjectiveAdmin, self).get_queryset(request) - if request.user.is_superuser is False: - user_country = request.user.tola_user.country - queryset = queryset.filter(country=user_country) - return queryset +@admin.register(Level) +class LevelAdmin(admin.ModelAdmin): + list_display = ('name', 'parent', 'program', 'customsort', 'create_date', 'edit_date') + autocomplete_fields = ('parent', 'program') + search_fields = ('name', 'parent__name', 'program__name') + list_filter = (ProgramFilter,) + readonly_fields = ('create_date', 'edit_date') -class ResultResource(resources.ModelResource): - class Meta: - model = Result - # import_id_fields = ['id'] +@admin.register(DataCollectionFrequency) +class DataCollectionFrequencyAdmin(admin.ModelAdmin): + list_display = ('frequency', 'description', 'sort_order', 'create_date', 'edit_date') + readonly_fields = ('create_date', 'edit_date') -class ResultAdmin(ImportExportModelAdmin, SimpleHistoryAdmin): - resource_class = ResultResource - list_display = ('indicator', 'program') - search_fields = ('indicator', 'program', 'owner__username') - list_filter = ('indicator__program__country__country', 'program', 'approved_by') - display = 'Indicators Results' +@admin.register(ReportingFrequency) +class ReportingFrequencyAdmin(admin.ModelAdmin): + list_display = ('frequency', 'description', 'sort_order', 'create_date', 'edit_date') + readonly_fields = ('create_date', 'edit_date') -class ReportingFrequencyAdmin(admin.ModelAdmin): - list_display = ('frequency', 'description', 'create_date', 'edit_date') - display = 'Reporting Frequency' +@admin.register(PinnedReport) +class PinnedReportAdmin(admin.ModelAdmin): + list_display = ('name', 'tola_user', 'program', 'creation_date') + autocomplete_fields = ('tola_user', 'program') + readonly_fields = ('creation_date',) -class OutcomeThemeAdmin(admin.ModelAdmin): - list_display = ('name', 'is_active', 'create_date') - display = 'Outcome Theme' - readonly_fields = ('create_date',) +@admin.register(LevelTier) +class LevelTierAdmin(admin.ModelAdmin): + autocomplete_fields = ('program',) + list_display = ('name', 'program', 'tier_depth', 'create_date', 'edit_date') + search_fields = ('name', 'program__name',) + list_filter = (ProgramFilter, 'tier_depth') + readonly_fields = ('create_date', 'edit_date') -admin.site.register(IndicatorType) -admin.site.register(Indicator, IndicatorAdmin) -admin.site.register(ReportingFrequency) -admin.site.register(Result, ResultAdmin) -admin.site.register(Objective, ObjectiveAdmin) -admin.site.register(StrategicObjective, StrategicObjectiveAdmin) -admin.site.register(Level) -admin.site.register(ExternalService, ExternalServiceAdmin) -admin.site.register(ExternalServiceRecord, ExternalServiceRecordAdmin) -admin.site.register(DataCollectionFrequency) -admin.site.register(PeriodicTarget, PeriodicTargetAdmin) -admin.site.register(OutcomeTheme, OutcomeThemeAdmin) +@admin.register(LevelTierTemplate) +class LevelTierTemplateAdmin(admin.ModelAdmin): + autocomplete_fields = ('program',) + list_display = ('names', 'program', 'create_date', 'edit_date') + search_fields = ('names', 'program__name',) + readonly_fields = ('create_date', 'edit_date') + + +@admin.register(BulkIndicatorImportFile) +class BulkIndicatorImportFileAdmin(admin.ModelAdmin): + autocomplete_fields = ('program', 'user') + list_display = ('file_name', 'file_type', 'program', 'create_date',) + search_fields = ('file_name', 'program__name',) + list_filter = ('file_type',) + readonly_fields = ('create_date',) + diff --git a/indicators/fixtures/one_program_home_page.yaml b/indicators/fixtures/one_program_home_page.yaml index 2b59a8c60..a39b3bc61 100644 --- a/indicators/fixtures/one_program_home_page.yaml +++ b/indicators/fixtures/one_program_home_page.yaml @@ -10,7 +10,6 @@ - model: workflow.program pk: 1 fields: - gaitid: '1' name: Basic One Year Program funding_status: Funded cost_center: null @@ -23,6 +22,13 @@ reporting_period_end: 2016-12-31 sector: [] country: [1] +- model: workflow.gaitid + pk: 1 + fields: + gaitid: '1' + program: 1 + create_date: 2022-04-01 + edit_date: 2022-04-01 - model: indicators.indicator pk: 1 fields: diff --git a/indicators/fixtures/one_year_program.yaml b/indicators/fixtures/one_year_program.yaml index 5fa16dfd9..f06e665f2 100644 --- a/indicators/fixtures/one_year_program.yaml +++ b/indicators/fixtures/one_year_program.yaml @@ -10,7 +10,6 @@ - model: workflow.program pk: 1 fields: - gaitid: '1' name: Basic One Year Program funding_status: Funded cost_center: null @@ -23,3 +22,10 @@ reporting_period_end: 2017-12-31 sector: [] country: [1] +- model: workflow.gaitid + pk: 1 + fields: + gaitid: '1' + program: 1 + create_date: 2022-04-01 + edit_date: 2022-04-01 diff --git a/indicators/management/commands/create_qa_programs.py b/indicators/management/commands/create_qa_programs.py index 86395d9dc..0165febc4 100644 --- a/indicators/management/commands/create_qa_programs.py +++ b/indicators/management/commands/create_qa_programs.py @@ -20,7 +20,7 @@ DisaggregationLabel, ) from workflow.models import Program, Country, Organization, TolaUser, CountryAccess, ProgramAccess, SiteProfile, Region -from .qa_program_widgets.qa_widgets import Cleaner, ProgramFactory, IndicatorFactory, user_profiles, standard_countries +from .qa_program_widgets.qa_widgets import Cleaner, ProgramFactory, IndicatorFactory, user_profiles class Command(BaseCommand): @@ -39,6 +39,7 @@ def add_arguments(self, parser): parser.add_argument('--create_test_users', action='store_true') parser.add_argument('--names') parser.add_argument('--named_only', action='store_true') + parser.add_argument('--user_permissions', action='store_true') def handle(self, *args, **options): # *********** @@ -76,148 +77,166 @@ def handle(self, *args, **options): 'Karen': 'kbarkemeyer@mercycorps.org', 'Marie': 'mbakke@mercycorps.org', 'Marco': 'mscagliusi@mercycorps.org', + 'Paul': 'psouders@mercycorps.org', 'PaQ': None, } - program_factory = ProgramFactory(tolaland) + all_countries = Country.objects.all() - if options['names']: - tester_names = options['names'].split(',') - else: - tester_names = named_testers.keys() - for t_name in tester_names: - program_name = 'QA program - {}'.format(t_name) + if options['user_permissions']: + print('Skipping creating test programs, adjusting (test) user permissions only') + + if not options['user_permissions']: + program_factory = ProgramFactory(tolaland) + + if options['names']: + tester_names = options['names'].split(',') + else: + tester_names = named_testers.keys() + for t_name in tester_names: + program_name = 'QA program - {}'.format(t_name) + print(f'Creating {program_name}') + program = program_factory.create_program(program_name) + indicator_factory = IndicatorFactory(program, tolaland) + indicator_factory.create_standard_indicators(personal_indicator=True) + + if options['named_only']: + self.assign_permissions(all_countries, named_testers, options['named_only'], tolaland) + return + + program_name = 'QA program -- Multi-country Program' + print(f'Creating {program_name}') + program = program_factory.create_program(program_name, multi_country=True) + indicator_factory = IndicatorFactory(program, tolaland) + indicator_factory.create_standard_indicators() + + program_name = 'QA program -- Custom Results Framework' + print(f'Creating {program_name}') + program = program_factory.create_program(program_name, create_levels=False) + template_names = [] + tier_depth = LevelTier.MAX_TIERS + for tier_number in range(tier_depth): + tier_name = f"Tier {tier_number + 1}" + LevelTier.objects.create(program=program, name=tier_name, tier_depth=tier_number + 1) + template_names.append(tier_name) + LevelTierTemplate.objects.create(program=program, names=(','.join(template_names))) + self.generate_levels(None, program, LevelTier.MAX_TIERS) + generated_levels = Level.objects.filter(program=program).order_by('id') + + # Select top level and a couple of other levels to have no indicators. They should be levels with child + # levels because what would be the point of creating the level then. + indicatorless_levels = [] + parent_levels = list(generated_levels.values_list('parent_id', flat=True)) + if tier_depth-2 > 2: + tier2_with_children = [ + level.id for level in generated_levels if level.level_depth == 2 and level.id in parent_levels] + tier6_with_children = [ + level.id for level in generated_levels if level.level_depth == tier_depth-2 and level.id in parent_levels] + indicatorless_levels.extend([tier2_with_children[:1][0], tier6_with_children[:1][0]]) + top_level_id = Level.objects.filter(program=program, parent__isnull=True)[0].id + indicatorless_levels.append(top_level_id) + else: + indicatorless_levels = [int(tier_depth/2)] + + # Create pc indicator + if program.reporting_period_end >= date(2021, 7, 1): + top_level = Level.objects.filter(program=program, parent__isnull=True)[0] + program_factory.create_pc_indicator(program, top_level) + + indicator_factory = IndicatorFactory(program, tolaland) + indicator_factory.create_standard_indicators(indicatorless_levels=indicatorless_levels) + + program_name = 'QA program -- Ghost of Programs Past' + print(f'Creating {program_name}') + # Hard code dates so no pc indicator will be created. + passed_start_date = date(2019, 2, 1) + passed_end_date = date(2021, 5, 31) + # passed_end_date = program_factory.default_start_date - timedelta(days=1) + # passed_start_date = (passed_end_date + relativedelta(months=-19)).replace(day=1) + program = program_factory.create_program( + program_name, start_date=passed_start_date, end_date=passed_end_date) + indicator_factory = IndicatorFactory(program, tolaland) + indicator_factory.create_standard_indicators() + + program_name = 'QA program -- Ghost of Programs Future' + print(f'Creating {program_name}') + future_start_date = (date.today() + relativedelta(months=6)).replace(day=1) + future_end_date = (future_start_date + relativedelta(months=19)).replace(day=28) + future_end_date = (future_end_date + relativedelta(days=5)).replace(day=1) - timedelta(days=1) + program = program_factory.create_program( + program_name, start_date=future_start_date, end_date=future_end_date,) + indicator_factory = IndicatorFactory(program, tolaland) + indicator_params = deepcopy(indicator_factory.standard_params_base) + indicator_params.extend(deepcopy(indicator_factory.null_supplements_params)) + null_level_list = ['results'] * len(indicator_params) + fail_message = self.set_null_levels(indicator_params, null_level_list, program.name) + if fail_message: + print(fail_message) + program.delete() + else: + indicator_factory.create_indicators(indicator_params) + # supplemental_params = deepcopy(indicator_factory.null_supplements_params) + # null_level_list = ['results'] * len(supplemental_params) + # fail_message = self.set_null_levels(supplemental_params, null_level_list, program.name) + # if fail_message: + # print(fail_message) + # program.delete() + # else: + # indicator_factory.create_indicators(supplemental_params, apply_skips=True) + + program_name = 'QA program -- I Love Indicators So Much' print(f'Creating {program_name}') program = program_factory.create_program(program_name) indicator_factory = IndicatorFactory(program, tolaland) - indicator_factory.create_standard_indicators(personal_indicator=True) + indicator_factory.create_standard_indicators() + indicator_factory.create_standard_indicators(indicator_suffix='moar1') + print('Creating moar indicators') + indicator_factory.create_standard_indicators(indicator_suffix='moar2') + indicator_factory.create_standard_indicators(indicator_suffix='moar3') + + + # Not longer useful since we cannot create programs without rf + # program_name = 'QA program --- Pre-Satsuma' + # print(f'Creating {program_name}') + # program = program_factory.create_program(program_name, post_satsuma=False) + # indicator_factory = IndicatorFactory(program, tolaland) + # indicator_factory.create_standard_indicators(apply_skips=False, apply_satsuma_skips=True) + + # Create programs with various levels of no data indicators + program_name = 'QA program --- No Indicators Here' + print(f'Creating {program_name}') + program_factory.create_program('QA program --- No Indicators Here') - if options['named_only']: - self.assign_permissions(named_testers, options['named_only'], tolaland) - return + program_name = 'QA program --- No Results Here' + print(f'Creating {program_name}') + program = program_factory.create_program(program_name) + indicator_factory = IndicatorFactory(program, tolaland) + indicator_params = deepcopy(indicator_factory.standard_params_base) + long_null_levels = ['results'] * len(indicator_params) + fail_message = self.set_null_levels(indicator_params, long_null_levels, program.name) + if fail_message: + print(fail_message) + program.delete() + else: + indicator_factory.create_indicators(indicator_params) - program_name = 'QA program -- Multi-country Program' - print(f'Creating {program_name}') - program = program_factory.create_program(program_name, multi_country=True) - indicator_factory = IndicatorFactory(program, tolaland) - indicator_factory.create_standard_indicators() - - program_name = 'QA program -- Custom Results Framework' - print(f'Creating {program_name}') - program = program_factory.create_program(program_name, create_levels=False) - template_names = [] - tier_depth = LevelTier.MAX_TIERS - for tier_number in range(tier_depth): - tier_name = f"Tier {tier_number + 1}" - LevelTier.objects.create(program=program, name=tier_name, tier_depth=tier_number + 1) - template_names.append(tier_name) - LevelTierTemplate.objects.create(program=program, names=(','.join(template_names))) - self.generate_levels(None, program, LevelTier.MAX_TIERS) - generated_levels = Level.objects.filter(program=program).order_by('id') - - # Select top level and a couple of other levels to have no indicators. They should be levels with child - # levels because what would be the point of creating the level then. - indicatorless_levels = [] - parent_levels = list(generated_levels.values_list('parent_id', flat=True)) - if tier_depth-2 > 2: - tier2_with_children = [ - level.id for level in generated_levels if level.level_depth == 2 and level.id in parent_levels] - tier6_with_children = [ - level.id for level in generated_levels if level.level_depth == tier_depth-2 and level.id in parent_levels] - indicatorless_levels.extend([tier2_with_children[:1][0], tier6_with_children[:1][0]]) - top_level_id = Level.objects.filter(program=program, parent__isnull=True)[0].id - indicatorless_levels.append(top_level_id) - else: - indicatorless_levels = [int(tier_depth/2)] - indicator_factory = IndicatorFactory(program, tolaland) - indicator_factory.create_standard_indicators(indicatorless_levels=indicatorless_levels) - - program_name = 'QA program -- Ghost of Programs Past' - print(f'Creating {program_name}') - passed_end_date = program_factory.default_start_date - timedelta(days=1) - passed_start_date = (passed_end_date + relativedelta(months=-19)).replace(day=1) - program = program_factory.create_program( - program_name, start_date=passed_start_date, end_date=passed_end_date) - indicator_factory = IndicatorFactory(program, tolaland) - indicator_factory.create_standard_indicators() - - program_name = 'QA program -- Ghost of Programs Future' - print(f'Creating {program_name}') - future_start_date = (date.today() + relativedelta(months=6)).replace(day=1) - future_end_date = (future_start_date + relativedelta(months=19)).replace(day=28) - future_end_date = (future_end_date + relativedelta(days=5)).replace(day=1) - timedelta(days=1) - program = program_factory.create_program( - program_name, start_date=future_start_date, end_date=future_end_date,) - indicator_factory = IndicatorFactory(program, tolaland) - indicator_params = deepcopy(indicator_factory.standard_params_base) - indicator_params.extend(deepcopy(indicator_factory.null_supplements_params)) - null_level_list = ['results'] * len(indicator_params) - fail_message = self.set_null_levels(indicator_params, null_level_list, program.name) - if fail_message: - print(fail_message) - program.delete() - else: - indicator_factory.create_indicators(indicator_params) - # supplemental_params = deepcopy(indicator_factory.null_supplements_params) - # null_level_list = ['results'] * len(supplemental_params) - # fail_message = self.set_null_levels(supplemental_params, null_level_list, program.name) - # if fail_message: - # print(fail_message) - # program.delete() - # else: - # indicator_factory.create_indicators(supplemental_params, apply_skips=True) - - program_name = 'QA program -- I Love Indicators So Much' - print(f'Creating {program_name}') - program = program_factory.create_program(program_name) - indicator_factory = IndicatorFactory(program, tolaland) - indicator_factory.create_standard_indicators() - indicator_factory.create_standard_indicators(indicator_suffix='moar1') - print('Creating moar indicators') - indicator_factory.create_standard_indicators(indicator_suffix='moar2') - indicator_factory.create_standard_indicators(indicator_suffix='moar3') - - program_name = 'QA program --- Pre-Satsuma' - print(f'Creating {program_name}') - program = program_factory.create_program(program_name, post_satsuma=False) - indicator_factory = IndicatorFactory(program, tolaland) - indicator_factory.create_standard_indicators(apply_skips=False, apply_satsuma_skips=True) - - # Create programs with various levels of no data indicators - program_name = 'QA program --- No Indicators Here' - print(f'Creating {program_name}') - program_factory.create_program('QA program --- No Indicators Here') - - program_name = 'QA program --- No Results Here' - print(f'Creating {program_name}') - program = program_factory.create_program(program_name) - indicator_factory = IndicatorFactory(program, tolaland) - indicator_params = deepcopy(indicator_factory.standard_params_base) - long_null_levels = ['results'] * len(indicator_params) - fail_message = self.set_null_levels(indicator_params, long_null_levels, program.name) - if fail_message: - print(fail_message) - program.delete() - else: - indicator_factory.create_indicators(indicator_params) - - program_name = 'QA program --- No Evidence Here' - print(f'Creating {program_name}') - program = program_factory.create_program(program_name) - indicator_factory = IndicatorFactory(program, tolaland) - indicator_params = deepcopy(indicator_factory.standard_params_base) - long_null_levels = ['evidence'] * len(indicator_params) - fail_message = self.set_null_levels(indicator_params, long_null_levels, program.name) - if fail_message: - print(fail_message) - program.delete() - else: - indicator_factory.create_indicators(indicator_params) + program_name = 'QA program --- No Evidence Here' + print(f'Creating {program_name}') + program = program_factory.create_program(program_name) + indicator_factory = IndicatorFactory(program, tolaland) + indicator_params = deepcopy(indicator_factory.standard_params_base) + long_null_levels = ['evidence'] * len(indicator_params) + fail_message = self.set_null_levels(indicator_params, long_null_levels, program.name) + if fail_message: + print(fail_message) + program.delete() + else: + indicator_factory.create_indicators(indicator_params) # Create test users and assign permissions last to ensure same permissions are applied to Tolaland programs - self.assign_permissions(named_testers, options['named_only'], tolaland, test_password) + self.assign_permissions(all_countries, named_testers, options['named_only'], tolaland, test_password) - def assign_permissions(self, named_testers, named_only, tolaland, test_password=None): + def assign_permissions(self, all_countries, named_testers, named_only, tolaland, test_password=None): for super_user in TolaUser.objects.filter(user__is_superuser=True): ca, created = CountryAccess.objects.get_or_create(country=tolaland, tolauser=super_user) ca.role = 'basic_admin' @@ -227,7 +246,7 @@ def assign_permissions(self, named_testers, named_only, tolaland, test_password= named_user_objs = TolaUser.objects.filter(user__email__in=named_tester_emails).select_related() for tola_user in named_user_objs: print(f'Assigning {tola_user.user.email} lots of permissions') - for country in Country.objects.filter(country__in=standard_countries): + for country in all_countries: ca, created = CountryAccess.objects.get_or_create( country=Country.objects.get(country=country), tolauser=tola_user @@ -240,7 +259,7 @@ def assign_permissions(self, named_testers, named_only, tolaland, test_password= country=country, program=program, tolauser=tola_user, defaults={'role': 'high'}) if not named_only: - self.create_test_users(test_password) + self.create_test_users(all_countries, test_password) @staticmethod @@ -280,7 +299,7 @@ def set_null_levels(param_base, null_levels, program_name): return False @staticmethod - def create_test_users(password): + def create_test_users(all_countries, password): created_users = [] existing_users = [] for username, profile in user_profiles.items(): @@ -288,7 +307,11 @@ def create_test_users(password): if profile['home_country']: home_country = Country.objects.get(country=profile['home_country']) - accessible_countries = Country.objects.filter(country__in=profile['accessible_countries']) + acc_countries = profile['accessible_countries'] + if isinstance(acc_countries, bool): + accessible_countries = all_countries + else: + accessible_countries = Country.objects.filter(country__in=profile['accessible_countries']) user, created = User.objects.get_or_create( username=username, @@ -320,16 +343,19 @@ def create_test_users(password): # country as such. If the user isn't part of MC org, you have to do it on a program by program basis. for accessible_country in accessible_countries: if tola_user.organization.name == 'Mercy Corps': - CountryAccess.objects.get_or_create( + ca, created = CountryAccess.objects.get_or_create( tolauser=tola_user, country=accessible_country, defaults={"role": 'user'}) - - # Need to also do program by program for MC members if the permission level is high because - # default with country access is low. - if tola_user.organization.name != 'Mercy Corps' or profile['permission_level'] != 'low': - for program in accessible_country.program_set.all(): - ProgramAccess.objects.get_or_create( - country=accessible_country, program=program, tolauser=tola_user, - role=profile['permission_level']) + if username == 'mc-basicadmin': + ca.role = 'basic_admin' + ca.save() + + # Need to also do program by program for MC member with medium/high permission level (permissions + # default to low) or in case management command is run with user_permissions flag (permissions have to + # be adjusted for all users in case a new program is added). + for program in accessible_country.program_set.all(): + ProgramAccess.objects.get_or_create( + country=accessible_country, program=program, tolauser=tola_user, + role=profile['permission_level']) # Add ProgramAccess links between tola_users and programs for access_profile in profile.get('program_access', []): @@ -344,22 +370,6 @@ def create_test_users(password): except IntegrityError: pass - # Create/upgrade admin levels for each country listed in the profile - try: - country_names = profile['admin'] - if country_names == 'all': - country_names = list(Country.objects.all().values_list('country', flat=True)) - except KeyError: - country_names = [] - - for country_name in country_names: - ca, created = CountryAccess.objects.get_or_create( - country=Country.objects.get(country=country_name), - tolauser=tola_user - ) - ca.role = 'basic_admin' - ca.save() - if len(created_users) > 0: print('\nCreated the following test users: {}\n'.format(', '.join(sorted(created_users)))) if len(existing_users) > 0: diff --git a/indicators/management/commands/qa_program_widgets/qa_widgets.py b/indicators/management/commands/qa_program_widgets/qa_widgets.py index 21973d0c1..a900cb5d1 100644 --- a/indicators/management/commands/qa_program_widgets/qa_widgets.py +++ b/indicators/management/commands/qa_program_widgets/qa_widgets.py @@ -19,9 +19,21 @@ DisaggregationType, DisaggregatedValue, LevelTier, + IDAAOutcomeTheme +) +from workflow.models import ( + Program, + Country, + Organization, + TolaUser, + SiteProfile, + Sector, + GaitID, + FundCode, + IDAASector, ) -from workflow.models import Program, Country, Organization, TolaUser, SiteProfile, Sector from indicators.views.views_indicators import generate_periodic_targets +from indicators.utils import create_participant_count_indicator class ProgramFactory: @@ -35,6 +47,19 @@ def __init__(self, country): self.default_start_date = (date.today() + relativedelta(months=-18)).replace(day=1) self.default_end_date = (self.default_start_date + relativedelta(months=+32)).replace(day=1) - timedelta(days=1) + def create_gait_id(self, program_id): + # Create unique GAIT ID + gid_exists = True + while gid_exists: + gaitid = random.randint(1, 99999) + gid_exists = GaitID.objects.filter(gaitid=gaitid).exists() + gait_id = GaitID(gaitid=gaitid, program_id=program_id) + gait_id.donor = "QATestDonor" + gait_id.donor_dept = "QATestDonorDept" + gait_id.save() + fund_code = 77777 + FundCode.objects.get_or_create(fund_code=fund_code, gaitid=gait_id) + def create_program( self, name, start_date=False, end_date=False, post_satsuma=True, multi_country=False, create_levels=True): if not start_date: @@ -42,21 +67,36 @@ def create_program( if not end_date: end_date = self.default_end_date + external_program_id = random.randint(10000, 99999) + program = Program.objects.create(**{ 'name': name, + 'external_program_id': external_program_id, 'start_date': start_date, 'end_date': end_date, 'reporting_period_start': start_date, 'reporting_period_end': end_date, 'funding_status': 'Funded', - 'gaitid': 'fake_gait_id_{}'.format(random.randint(1, 9999)), '_using_results_framework': Program.RF_ALWAYS if post_satsuma else Program.NOT_MIGRATED, }) program.country.add(self.country) + if multi_country: country2 = Country.objects.get(country="United States - MCNW") program.country.add(country2) + self.create_gait_id(program.id) + + idaa_sectors = IDAASector.objects.all() + idaa_sector_list = list(idaa_sectors.exclude(sector="(Empty)")) + idaa_sector = random.choice(idaa_sector_list) + program.idaa_sector.add(idaa_sector) + + idaa_outcome_themes = IDAAOutcomeTheme.objects.all() + idaa_outcome_themes_list = list(idaa_outcome_themes.exclude(name="(Empty)")) + idaa_outcometheme = random.choice(idaa_outcome_themes_list) + program.idaa_outcome_theme.add(idaa_outcometheme) + if create_levels: self.create_levels(program, deepcopy(self.sample_levels)) @@ -81,6 +121,14 @@ def create_levels(program, level_template): level.program = program level.save() level_map[level_fix['pk']] = level + if not parent and program.reporting_period_end >= date(2021, 7, 1): + ProgramFactory.create_pc_indicator(program, level) + + @staticmethod + def create_pc_indicator(program, level): + disaggregations = DisaggregationType.objects.filter( + global_type=DisaggregationType.DISAG_PARTICIPANT_COUNT) + create_participant_count_indicator(program, level, disaggregations) class IndicatorFactory: @@ -578,14 +626,15 @@ def clean_programs(): print('\nPrograms not deleted') -standard_countries = ['Afghanistan', 'Haiti', 'Jordan', 'Tolaland', 'United States - MCNW'] +# standard_countries = ['Afghanistan', 'Haiti', 'Jordan', 'Tolaland', 'United States - MCNW'] +all_countries = True TEST_ORG, created = Organization.objects.get_or_create(name='Test') MC_ORG = Organization.objects.get(name='Mercy Corps') user_profiles = { 'mc-low': { 'first_last': ['mc-low-first', 'mc-low-last'], 'email': 'tolatestone@mercycorps.org', - 'accessible_countries': standard_countries, + 'accessible_countries': all_countries, 'permission_level': 'low', 'home_country': 'United States - MCNW', 'org': MC_ORG, @@ -593,7 +642,7 @@ def clean_programs(): 'mc-medium': { 'first_last': ['mc-med-first', 'mc-med-last'], 'email': 'tolatesttwo@mercycorps.org', - 'accessible_countries': standard_countries, + 'accessible_countries': all_countries, 'permission_level': 'medium', 'home_country': 'United States - MCNW', 'org': MC_ORG, @@ -601,7 +650,7 @@ def clean_programs(): 'mc-high': { 'first_last': ['mc-high-first', 'mc-high-last'], 'email': 'tolatestthree@mercycorps.org', - 'accessible_countries': standard_countries, + 'accessible_countries': all_countries, 'permission_level': 'high', 'home_country': 'United States - MCNW', 'org': MC_ORG, @@ -609,16 +658,16 @@ def clean_programs(): 'mc-basicadmin': { 'first_last': ['mc-basicadmin-first', 'mc-basicadmin-last'], 'email': 'mcbasicadmin@example.com', - 'accessible_countries': standard_countries, + 'accessible_countries': all_countries, 'permission_level': 'high', 'home_country': 'United States - MCNW', 'org': MC_ORG, - 'admin': 'all' + # 'admin': 'all' }, 'gmail-low': { 'first_last': ['gmail-low-first', 'gmail-low-last'], 'email': 'mctest.low@gmail.com', - 'accessible_countries': standard_countries, + 'accessible_countries': all_countries, 'permission_level': 'low', 'home_country': None, 'org': TEST_ORG, @@ -626,7 +675,7 @@ def clean_programs(): 'gmail-medium': { 'first_last': ['gmail-med-first', 'gmail-med-last'], 'email': 'mctest.medium@gmail.com', - 'accessible_countries': standard_countries, + 'accessible_countries': all_countries, 'permission_level': 'medium', 'home_country': None, 'org': TEST_ORG, @@ -634,7 +683,7 @@ def clean_programs(): 'gmail-high': { 'first_last': ['gmail-high-first', 'gmail-high-last'], 'email': 'mctest.high@gmail.com', - 'accessible_countries': standard_countries, + 'accessible_countries': all_countries, 'permission_level': 'high', 'home_country': None, 'org': TEST_ORG, @@ -642,7 +691,7 @@ def clean_programs(): 'external-low': { 'first_last': ['external-low-first', 'external-low-last'], 'email': 'external-low@example.com', - 'accessible_countries': standard_countries, + 'accessible_countries': all_countries, 'permission_level': 'low', 'home_country': None, 'org': TEST_ORG, @@ -650,7 +699,7 @@ def clean_programs(): 'external-medium': { 'first_last': ['external-med-first', 'external-med-last'], 'email': 'external-medium@example.com', - 'accessible_countries': standard_countries, + 'accessible_countries': all_countries, 'permission_level': 'medium', 'home_country': None, 'org': TEST_ORG, @@ -658,7 +707,7 @@ def clean_programs(): 'external-high': { 'first_last': ['external-high-first', 'external-high-last'], 'email': 'external-high@example.com', - 'accessible_countries': standard_countries, + 'accessible_countries': all_countries, 'permission_level': 'high', 'home_country': None, 'org': TEST_ORG, @@ -668,8 +717,8 @@ def clean_programs(): 'email': 'demo1@example.com', 'accessible_countries': ['Ethiopia'], 'permission_level': 'low', - 'home_country': 'Ethiopia', - 'org': MC_ORG, + 'home_country': None, + 'org': TEST_ORG, }, 'demo2': { 'first_last': ['demo', 'two'], @@ -678,7 +727,7 @@ def clean_programs(): 'permission_level': 'medium', 'home_country': None, 'org': TEST_ORG, - 'program_access': [('Ethiopia', 'Collaboration in Cross-Border Areas', 'medium')] + 'program_access': [('Ethiopia', 'DREAMS', 'medium')] }, 'demo3': { 'first_last': ['demo', 'three'], @@ -687,7 +736,7 @@ def clean_programs(): 'permission_level': 'high', 'home_country': None, 'org': TEST_ORG, - 'program_access': [('Ethiopia', 'Collaboration in Cross-Border Areas', 'high')] + 'program_access': [('Ethiopia', 'DREAMS', 'high')] }, } diff --git a/indicators/migrations/0111_idaaoutcometheme.py b/indicators/migrations/0111_idaaoutcometheme.py new file mode 100644 index 000000000..55eaf58f9 --- /dev/null +++ b/indicators/migrations/0111_idaaoutcometheme.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.12 on 2022-06-15 16:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('indicators', '0110_outcome_theme_blank_true'), + ] + + operations = [ + migrations.CreateModel( + name='IDAAOutcomeTheme', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=256, verbose_name='Outcome theme name')), + ('is_active', models.BooleanField(default=True, verbose_name='Active?')), + ('create_date', models.DateTimeField(auto_now_add=True, verbose_name='Creation date')), + ], + ), + ] diff --git a/indicators/migrations/0112_alter_idaaoutcometheme_options.py b/indicators/migrations/0112_alter_idaaoutcometheme_options.py new file mode 100644 index 000000000..4b12eb9fb --- /dev/null +++ b/indicators/migrations/0112_alter_idaaoutcometheme_options.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.12 on 2022-06-15 16:56 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('indicators', '0111_idaaoutcometheme'), + ] + + operations = [ + migrations.AlterModelOptions( + name='idaaoutcometheme', + options={'ordering': ('name',)}, + ), + ] diff --git a/indicators/migrations/0113_populate_idaaoutcomethemes.py b/indicators/migrations/0113_populate_idaaoutcomethemes.py new file mode 100644 index 000000000..c4fbef7cc --- /dev/null +++ b/indicators/migrations/0113_populate_idaaoutcomethemes.py @@ -0,0 +1,30 @@ +# Generated by Django 3.2.12 on 2022-08-12 13:13 + +from django.db import migrations + + +idaa_outcome_themes = [ + "Food Security", + "Water Security", + "Economic Opportunities", + "Peace and Stability", + "NA" +] + + +def populate_idaa_outcome_themes(apps, schema_editor): + idaa_outcome_themes_model = apps.get_model('indicators', 'IDAAOutcomeTheme') + + for idaa_outcome_theme in idaa_outcome_themes: + idaa_outcome_themes_model.objects.get_or_create(name=idaa_outcome_theme) + + +class Migration(migrations.Migration): + + dependencies = [ + ('indicators', '0112_alter_idaaoutcometheme_options'), + ] + + operations = [ + migrations.RunPython(populate_idaa_outcome_themes) + ] diff --git a/indicators/migrations/0114_verbose_names.py b/indicators/migrations/0114_verbose_names.py new file mode 100644 index 000000000..983d202dd --- /dev/null +++ b/indicators/migrations/0114_verbose_names.py @@ -0,0 +1,115 @@ +# Generated by Django 3.2.12 on 2022-10-06 18:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('indicators', '0113_populate_idaaoutcomethemes'), + ] + + operations = [ + migrations.AlterModelOptions( + name='bulkindicatorimportfile', + options={'verbose_name': 'Bulk Indicator Import File', 'verbose_name_plural': 'Bulk Indicator Import Files'}, + ), + migrations.AlterModelOptions( + name='countrydisaggregation', + options={'verbose_name': 'Country Disaggregation', 'verbose_name_plural': 'Country Disaggregations'}, + ), + migrations.AlterModelOptions( + name='datacollectionfrequency', + options={'ordering': ['sort_order'], 'verbose_name': 'Data Collection Frequency', 'verbose_name_plural': 'Data Collection Frequencies'}, + ), + migrations.AlterModelOptions( + name='disaggregatedvalue', + options={'verbose_name': 'Disaggregated Value', 'verbose_name_plural': 'Disaggregated Values'}, + ), + migrations.AlterModelOptions( + name='disaggregationlabel', + options={'ordering': ['customsort'], 'verbose_name': 'Disaggregation Label', 'verbose_name_plural': 'Disaggregation Labels'}, + ), + migrations.AlterModelOptions( + name='disaggregationtype', + options={'verbose_name': 'Disaggregation Type', 'verbose_name_plural': 'Disaggregation Types'}, + ), + migrations.AlterModelOptions( + name='externalservice', + options={'verbose_name': 'External Service', 'verbose_name_plural': 'External Services'}, + ), + migrations.AlterModelOptions( + name='externalservicerecord', + options={'verbose_name': 'External Service Record', 'verbose_name_plural': 'External Service Records'}, + ), + migrations.AlterModelOptions( + name='globaldisaggregation', + options={'verbose_name': 'Global Disaggregation', 'verbose_name_plural': 'Global Disaggregations'}, + ), + migrations.AlterModelOptions( + name='historicalresult', + options={'get_latest_by': 'history_date', 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical Indicator Result'}, + ), + migrations.AlterModelOptions( + name='idaaoutcometheme', + options={'ordering': ('name',), 'verbose_name': 'IDAA Outcome Theme', 'verbose_name_plural': 'IDAA Outcome Themes'}, + ), + migrations.AlterModelOptions( + name='indicator', + options={'ordering': ('create_date',), 'verbose_name': 'Indicator', 'verbose_name_plural': 'Indicators'}, + ), + migrations.AlterModelOptions( + name='indicatortype', + options={'verbose_name': 'Indicator Type', 'verbose_name_plural': 'Indicator Types'}, + ), + migrations.AlterModelOptions( + name='level', + options={'ordering': ('customsort',), 'verbose_name': 'Level', 'verbose_name_plural': 'Levels'}, + ), + migrations.AlterModelOptions( + name='leveltier', + options={'ordering': ('tier_depth',), 'verbose_name': 'Level Tier', 'verbose_name_plural': 'Level Tiers'}, + ), + migrations.AlterModelOptions( + name='leveltiertemplate', + options={'verbose_name': 'Level Tier Templates', 'verbose_name_plural': 'Level Tier Templates'}, + ), + migrations.AlterModelOptions( + name='objective', + options={'ordering': ('program', 'name'), 'verbose_name': 'Program Objective', 'verbose_name_plural': 'Program Objectives'}, + ), + migrations.AlterModelOptions( + name='outcometheme', + options={'verbose_name': 'Outcome Theme', 'verbose_name_plural': 'Outcome Themes'}, + ), + migrations.AlterModelOptions( + name='periodictarget', + options={'ordering': ('customsort', '-create_date'), 'verbose_name': 'Periodic Target', 'verbose_name_plural': 'Periodic Targets'}, + ), + migrations.AlterModelOptions( + name='pinnedreport', + options={'ordering': ['-creation_date'], 'verbose_name': 'Pinned Report', 'verbose_name_plural': 'Pinned Reports'}, + ), + migrations.AlterModelOptions( + name='reportingfrequency', + options={'ordering': ['sort_order'], 'verbose_name': 'Reporting Frequency', 'verbose_name_plural': 'Reporting Frequencies'}, + ), + migrations.AlterModelOptions( + name='result', + options={'ordering': ('indicator', 'date_collected'), 'verbose_name': 'Indicator Result', 'verbose_name_plural': 'Indicator Results'}, + ), + migrations.AlterModelOptions( + name='strategicobjective', + options={'ordering': ('country', 'name'), 'verbose_name': 'Country Strategic Objective', 'verbose_name_plural': 'Country Strategic Objectives'}, + ), + migrations.AlterField( + model_name='disaggregationtype', + name='global_type', + field=models.IntegerField(choices=[(0, 'Not global'), (1, 'Global (all countries and programs)'), (2, 'Global (participant count only)')], default=0, verbose_name='Global Disaggregation'), + ), + migrations.AlterField( + model_name='idaaoutcometheme', + name='name', + field=models.CharField(max_length=256, unique=True, verbose_name='Outcome theme name'), + ), + ] diff --git a/indicators/migrations/0115_alter_idaaoutcometheme_name.py b/indicators/migrations/0115_alter_idaaoutcometheme_name.py new file mode 100644 index 000000000..711d34feb --- /dev/null +++ b/indicators/migrations/0115_alter_idaaoutcometheme_name.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.12 on 2022-10-06 20:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('indicators', '0114_verbose_names'), + ] + + operations = [ + migrations.AlterField( + model_name='idaaoutcometheme', + name='name', + field=models.CharField(max_length=255, unique=True, verbose_name='Outcome theme name'), + ), + ] diff --git a/indicators/models.py b/indicators/models.py index fe6fda413..bf48c2656 100755 --- a/indicators/models.py +++ b/indicators/models.py @@ -22,7 +22,6 @@ from django.urls import reverse from django.utils import formats, timezone from django.utils.translation import gettext_lazy as _ -from django.contrib import admin from django.utils.functional import cached_property import django.template.defaultfilters @@ -70,17 +69,12 @@ class IndicatorType(models.Model): class Meta: verbose_name = _("Indicator Type") + verbose_name_plural = _("Indicator Types") def __str__(self): return self.indicator_type -class IndicatorTypeAdmin(admin.ModelAdmin): - list_display = ('indicator_type', 'description', 'create_date', - 'edit_date') - display = 'Indicator Type' - - class StrategicObjective(SafeDeleteModel): name = models.CharField(_("Name"), max_length=135, blank=True) country = models.ForeignKey(Country, on_delete=models.CASCADE, null=True, blank=True, verbose_name=_("Country")) @@ -90,7 +84,8 @@ class StrategicObjective(SafeDeleteModel): edit_date = models.DateTimeField(_("Edit date"), null=True, blank=True) class Meta: - verbose_name = _("Country Strategic Objectives") + verbose_name = _("Country Strategic Objective") + verbose_name_plural = _("Country Strategic Objectives") ordering = ('country', 'name') def __str__(self): @@ -112,6 +107,7 @@ class Objective(models.Model): class Meta: verbose_name = _("Program Objective") + verbose_name_plural = _("Program Objectives") ordering = ('program', 'name') def __str__(self): @@ -136,6 +132,7 @@ class Level(models.Model): class Meta: ordering = ('customsort', ) verbose_name = _("Level") + verbose_name_plural = _("Levels") unique_together = ('parent', 'customsort') def __str__(self): @@ -304,11 +301,6 @@ def logged_field_order(): return ['name', 'assumptions'] -class LevelAdmin(admin.ModelAdmin): - list_display = ('name') - display = 'Levels' - - class LevelTier(models.Model): MAX_TIERS = 8 @@ -416,6 +408,7 @@ class Meta: ordering = ('tier_depth', ) # Translators: Indicators are assigned to Levels. Levels are organized in a hierarchy of Tiers. verbose_name = _("Level Tier") + verbose_name_plural = _("Level Tiers") unique_together = (('name', 'program'), ('program', 'tier_depth')) def __str__(self): @@ -439,7 +432,8 @@ class LevelTierTemplate(models.Model): class Meta: # Translators: Indicators are assigned to Levels. Levels are organized in a hierarchy of Tiers. There are several templates that users can choose from with different names for the Tiers. - verbose_name = _("Level tier templates") + verbose_name = _("Level Tier Templates") + verbose_name_plural = _("Level Tier Templates") # same as verbose_name def __str__(self): return ",".join(self.names) @@ -522,7 +516,7 @@ class DisaggregationType(models.Model): disaggregation_type = models.CharField(_("Disaggregation"), max_length=135) country = models.ForeignKey(Country, on_delete=models.CASCADE, null=True, blank=True, verbose_name="Country") global_type = models.IntegerField( - default=DISAG_COUNTRY_ONLY, choices=GLOBAL_TYPE_CHOICES, verbose_name=_("Global disaggregation")) + default=DISAG_COUNTRY_ONLY, choices=GLOBAL_TYPE_CHOICES, verbose_name=_("Global Disaggregation")) is_archived = models.BooleanField(default=False, verbose_name=_("Archived")) selected_by_default = models.BooleanField(default=False) create_date = models.DateTimeField(_("Create date"), null=True, blank=True) @@ -532,6 +526,8 @@ class DisaggregationType(models.Model): class Meta: unique_together = ['disaggregation_type', 'country'] + verbose_name = _("Disaggregation Type") + verbose_name_plural = _("Disaggregation Types") def __str__(self): return self.disaggregation_type @@ -632,6 +628,8 @@ class DisaggregationLabel(models.Model): class Meta: ordering = ['customsort'] unique_together = ['disaggregation_type', 'label'] + verbose_name = _("Disaggregation Label") + verbose_name_plural = _("Disaggregation Labels") def save(self, *args, **kwargs): if self.create_date is None: @@ -662,6 +660,8 @@ class Meta: constraints = [ models.UniqueConstraint(fields=['result', 'category'], name='unique_disaggregation_per_result') ] + verbose_name = _("Disaggregated Value") + verbose_name_plural = _("Disaggregated Values") class ReportingFrequency(models.Model): @@ -677,6 +677,7 @@ class ReportingFrequency(models.Model): class Meta: verbose_name = _("Reporting Frequency") + verbose_name_plural = _("Reporting Frequencies") ordering = ['sort_order'] def __str__(self): @@ -694,22 +695,36 @@ class DataCollectionFrequency(models.Model): class Meta: verbose_name = _("Data Collection Frequency") + verbose_name_plural = _("Data Collection Frequencies") ordering = ['sort_order'] def __str__(self): return self.frequency -class DataCollectionFrequencyAdmin(admin.ModelAdmin): - list_display = ('frequency', 'description', 'create_date', 'edit_date') - display = 'Data Collection Frequency' - - class OutcomeTheme(models.Model): name = models.CharField(max_length=256, verbose_name=_('Outcome theme name')) is_active = models.BooleanField(verbose_name=_('Active?')) create_date = models.DateTimeField(auto_now_add=True, verbose_name=_('Creation date')) + class Meta: + verbose_name = _('Outcome Theme') + verbose_name_plural = _('Outcome Themes') + + +class IDAAOutcomeTheme(models.Model): + name = models.CharField(max_length=255, unique=True, verbose_name=_('Outcome theme name')) + is_active = models.BooleanField(verbose_name=_('Active?'), default=True) + create_date = models.DateTimeField(auto_now_add=True, verbose_name=_('Creation date')) + + class Meta: + ordering = ('name',) + verbose_name = _('IDAA Outcome Theme') + verbose_name_plural = _('IDAA Outcome Themes') + + def __str__(self): + return self.name + class ExternalService(models.Model): name = models.CharField(_("Name"), max_length=255, blank=True) @@ -720,16 +735,12 @@ class ExternalService(models.Model): class Meta: verbose_name = _("External Service") + verbose_name_plural = _("External Services") def __str__(self): return self.name -class ExternalServiceAdmin(admin.ModelAdmin): - list_display = ('name', 'url', 'feed_url', 'create_date', 'edit_date') - display = 'External Indicator Data Service' - - class ExternalServiceRecord(models.Model): external_service = models.ForeignKey( ExternalService, blank=True, null=True, on_delete=models.SET_NULL, @@ -741,16 +752,12 @@ class ExternalServiceRecord(models.Model): class Meta: verbose_name = _("External Service Record") + verbose_name_plural = _("External Service Records") def __str__(self): return self.full_url -class ExternalServiceRecordAdmin(admin.ModelAdmin): - list_display = ('external_service', 'full_url', 'record_id', 'create_date', - 'edit_date') - display = 'Exeternal Indicator Data Service' - # pylint: disable=W0223 class DecimalSplit(models.Func): function = 'SUBSTRING_INDEX' @@ -1564,6 +1571,7 @@ class Indicator(SafeDeleteModel): class Meta: ordering = ('create_date',) verbose_name = _("Indicator") + verbose_name_plural = _("Indicators") unique_together = ['level', 'level_order', 'deleted'] def __str__(self): @@ -1969,10 +1977,15 @@ class BulkIndicatorImportFile(models.Model): file_type = models.IntegerField(choices=FILE_TYPE_CHOICES) file_name = models.CharField(max_length=100) program = models.ForeignKey(Program, on_delete=models.CASCADE, related_name='bulk_indicator_import_files') + # TODO: elsewhere we refer to users as tola_user or tolauser user = models.ForeignKey( TolaUser, on_delete=models.SET_NULL, related_name='bulk_indicator_import_files', null=True) create_date = models.DateTimeField(auto_now=True) + class Meta: + verbose_name = _("Bulk Indicator Import File") + verbose_name_plural = _("Bulk Indicator Import Files") + @classmethod def get_file_path(cls, file_name): return os.path.join(settings.SITE_ROOT, cls.FILE_STORAGE_PATH, file_name) @@ -2026,6 +2039,7 @@ class PeriodicTarget(models.Model): class Meta: ordering = ('customsort', '-create_date') verbose_name = _("Periodic Target") + verbose_name_plural = _("Periodic Targets") unique_together = (('indicator', 'customsort'),) @property @@ -2261,18 +2275,16 @@ def period_generator(start, end): return period_generator -class PeriodicTargetAdmin(admin.ModelAdmin): - list_display = ('period', 'target', 'customsort',) - display = 'Indicator Periodic Target' - list_filter = ('period',) - - class ResultManager(models.Manager): def get_queryset(self): return super(ResultManager, self).get_queryset().prefetch_related( 'site' ).select_related('program', 'indicator') + class Meta: + verbose_name = _("Result Manager") + verbose_name_plural = _("Result Managers") + class Result(models.Model): data_key = models.UUIDField( @@ -2320,7 +2332,8 @@ class Result(models.Model): class Meta: ordering = ('indicator', 'date_collected') - verbose_name_plural = "Indicator Output/Outcome Result" + verbose_name = _("Indicator Result") + verbose_name_plural = _("Indicator Results") def __str__(self): return u'{}: {}'.format(self.indicator, self.periodic_target) @@ -2407,12 +2420,6 @@ def logged_field_order(): 'evidence_name', 'sites'] -class ResultAdmin(admin.ModelAdmin): - list_display = ('indicator', 'date_collected', 'create_date', 'edit_date') - list_filter = ['indicator__program__country__country'] - display = 'Indicator Output/Outcome Result' - - class PinnedReport(models.Model): """ A named IPTT report for a given program and user @@ -2429,6 +2436,8 @@ class Meta: constraints = [ models.UniqueConstraint(fields=['name', 'tola_user', 'program'], name='unique_pinned_report_name') ] + verbose_name = _("Pinned Report") + verbose_name_plural = _("Pinned Reports") def parse_query_string(self): return QueryDict(self.query_string) @@ -2563,3 +2572,6 @@ def default_report(program_id): report_type='timeperiods', query_string='timeperiods=7&timeframe=2&numrecentperiods=2', ) + + def __str__(self): + return self.name diff --git a/indicators/tests/iptt_tests/iptt_excel_export_functional_language.py b/indicators/tests/iptt_tests/iptt_excel_export_functional_language.py index bdadeead8..526e4b996 100644 --- a/indicators/tests/iptt_tests/iptt_excel_export_functional_language.py +++ b/indicators/tests/iptt_tests/iptt_excel_export_functional_language.py @@ -73,8 +73,9 @@ DATE_FORMATS = { ENGLISH: lambda d: d.strftime('%b %-d, %Y'), - FRENCH: lambda d: d.strftime('%-d %b. %Y').lower() if d.month not in [3, 5, 6, 7, 8] else ( - d.strftime('%-d juil. %Y') if d.month == 7 else d.strftime('%-d %B %Y').lower()), + FRENCH: lambda d: d.strftime('%-d %b. %Y').lower() if d.month not in [3, 5, 6, 7, 8, 9] else ( + d.strftime('%-d juil. %Y') if d.month == 7 else ( + d.strftime('%-d sept. %Y') if d.month == 9 else d.strftime('%-d %B %Y').lower())), SPANISH: lambda d: d.strftime('%-d %b. %Y').title() if d.month not in [5, 9] else ( d.strftime('%-d Sept. %Y') if d.month == 9 else d.strftime('%-d %B %Y').title()), } diff --git a/indicators/tests/test_participant_count_indicators_results.py b/indicators/tests/test_participant_count_indicators_results.py index f9f5fbab4..5e5e9cdb6 100644 --- a/indicators/tests/test_participant_count_indicators_results.py +++ b/indicators/tests/test_participant_count_indicators_results.py @@ -1,13 +1,16 @@ import json +from datetime import datetime, date + from django import test from django.urls import reverse from django.core import management from factories import workflow_models as w_factories +from factories import ResultFactory from factories.indicators_models import IndicatorTypeFactory, ReportingFrequencyFactory -from indicators.models import Indicator, IndicatorType, ReportingFrequency, OutcomeTheme +from indicators.models import Indicator, IndicatorType, ReportingFrequency, OutcomeTheme, Result from workflow.models import PROGRAM_ROLE_CHOICES @@ -15,16 +18,44 @@ class TestParticipantCountSetup(test.TestCase): """ Test the views for participant count result creation and updates """ + today = date.today() + def setUp(self): + program_start = date(self.today.year, self.today.month, 1) + program_end = date(self.today.year + 1, 6, 30) self.country = w_factories.CountryFactory() - self.program = w_factories.RFProgramFactory(country=[self.country], tiers=True, levels=1) + self.program = w_factories.RFProgramFactory( + country=[self.country], tiers=True, levels=1, + reporting_period_start=program_start, reporting_period_end=program_end) self.tola_user = w_factories.TolaUserFactory(country=self.country) self.client = test.Client() IndicatorTypeFactory(indicator_type=IndicatorType.PC_INDICATOR_TYPE) ReportingFrequencyFactory(frequency=ReportingFrequency.PC_REPORTING_FREQUENCY) + first_fiscal_year = self.today.year if self.today.month < 7 else self.today.year + 1 + self.period_string = 'FY' + str(first_fiscal_year) + self.pt_start = str(date(first_fiscal_year - 1, 7, 1)) + self.pt_end = str(date(first_fiscal_year, 6, 30)) + self.view_only = False + + def create_indicator_utils(self): + """ + Create pc indicator, login, grant access + :return pc indicator + """ + management.call_command( + 'create_participant_count_indicators', execute=True, create_disaggs_themes=True, suppress_output=True) + self.client.force_login(self.tola_user.user) + w_factories.grant_program_access( + self.tola_user, self.program, self.country, PROGRAM_ROLE_CHOICES[2][0]) + indicator = Indicator.objects.filter(admin_type=Indicator.ADMIN_PARTICIPANT_COUNT)[0] + return indicator + def has_correct_permission(self, tola_user, access_level, status_code): + """ + Set up and test permissions + """ self.client.force_login(tola_user.user) w_factories.grant_program_access( tola_user, self.program, self.country, access_level) @@ -33,32 +64,87 @@ def has_correct_permission(self, tola_user, access_level, status_code): self.assertEqual(response.status_code, status_code) def get_base_result_data(self, indicator=None, result=None): - # Gets the base dataset for a result. Only really useful after underlying data has been created, like - # Outcome Themes and PC indicators + """ + Gets the base dataset for a result. Only really useful after underlying data has been created, like + Outcome Themes and PC indicators + :return base result data + """ if not indicator and not result: raise NotImplementedError('Arguments must include either indicator or result') if result and indicator: indicator = result.indicator if indicator.admin_type != Indicator.ADMIN_PARTICIPANT_COUNT: raise NotImplementedError('Indicator is not a Participant Count indicator') - result_data = {'outcome_themes': OutcomeTheme.objects.values('id', 'name')} + result_data = {'outcome_themes': OutcomeTheme.objects.values('id', 'name', 'is_active')} result_data['program_start_date'] = indicator.program.reporting_period_start result_data['program_start_end'] = indicator.program.reporting_period_end result_data['disaggregations'] = [] for disagg in indicator.disaggregation.all().prefetch_related('disaggregationlabel_set', 'disaggregationlabel_set__disaggregatedvalue_set'): disagg_dict = {'pk': disagg.pk, 'disaggregation_type': disagg.disaggregation_type, 'labels': []} - for label in disagg.labels: + for count, label in enumerate(disagg.labels): disagg_dict['labels'].append({ - 'label_id': label.id, 'label': label.name}) + 'disaggregationlabel_id': label.id, 'label': label.name}) value = label.disaggregatedvalue_set.filter(result=result) if value: - disagg_dict.update({'value_id': value[0].pk, 'value': value[0].value}) + disagg_dict['labels'][count].update({'value_id': value[0].pk, 'value': value[0].value}) + else: + disagg_dict['labels'][count].update({'value_id': None, 'value': None}) + if 'Indirect' in disagg_dict['disaggregation_type']: + disagg_dict.update({'count_type': 'indirect'}) else: - disagg_dict.update({'value_id': None, 'value': None}) + disagg_dict.update({'count_type': 'direct'}) + result_data['disaggregations'].append(disagg_dict) return result_data + def update_base_results(self, base_result_data): + """ + Add disaggregation values to base_result_data, collect values in list + :returns updated base result data, disagg value list + """ + updated_disagg_values = [] + for disagg in base_result_data['disaggregations']: + if disagg['disaggregation_type'] == 'SADD (including unknown) with double counting': + disagg['labels'][1]['value'] = 1 + updated_disagg_values.append(1.00) + if disagg['disaggregation_type'] == 'Actual with double counting': + disagg['labels'][0]['value'] = 1 + updated_disagg_values.append(1.00) + disagg['labels'][1]['value'] = 1 + updated_disagg_values.append(1.00) + return base_result_data, updated_disagg_values + + def create_result(self, pt, indicator): + """ + Create result object + :param pt: + :param indicator: + :return: result + """ + result = ResultFactory( + periodic_target=pt, + indicator=indicator, + program=self.program, + date_collected=self.today + ) + return result + + def get_outcome_theme_list(self, result): + """ + Create list with outcome theme ids + :param result: + :return: outcome theme id list + """ + ot_object = result.outcome_themes.filter(is_active=True) + ot_list = [] + for ot in ot_object: + ot_list.append(getattr(ot, 'id')) + return ot_list + def test_result_create_view_permissions(self): + """ + Tests that permissions are correct + """ management.call_command( 'create_participant_count_indicators', execute=True, create_disaggs_themes=True, suppress_output=True) for access_level in [l[0] for l in PROGRAM_ROLE_CHOICES]: @@ -68,18 +154,90 @@ def test_result_create_view_permissions(self): self.has_correct_permission(tola_user, access_level, 200) def test_result_create_view_data(self): - management.call_command( - 'create_participant_count_indicators', execute=True, create_disaggs_themes=True, suppress_output=True) - self.client.force_login(self.tola_user.user) - w_factories.grant_program_access( - self.tola_user, self.program, self.country, PROGRAM_ROLE_CHOICES[2][0]) - indicator = Indicator.objects.filter(admin_type=Indicator.ADMIN_PARTICIPANT_COUNT)[0] + """ + Tests data for create result view + """ + indicator = self.create_indicator_utils() response = self.client.get(reverse('pcountcreate', args=[indicator.pk])) + data = json.loads(response.content) + self.assertSetEqual( - set(json.loads(response.content).keys()), - {'outcome_themes', 'disaggregations', 'program_start_date', 'program_end_date', 'periodic_target', 'pt_start_date', 'pt_end_date'}) + set(data.keys()), + {'outcome_themes', 'disaggregations', 'program_start_date', 'program_end_date', 'periodic_target', + 'pt_start_date', 'pt_end_date'}) + self.assertEqual(data['pt_start_date'], self.pt_start) + self.assertEqual(data['pt_end_date'], self.pt_end) + + def test_result_create_view(self): + """ + Tests create result view functionality + """ + indicator = self.create_indicator_utils() + # Get base result data and update it + base_result_data = self.get_base_result_data(indicator) + updated_base_result_data, updated_disagg_values = self.update_base_results(base_result_data) + disaggs = updated_base_result_data['disaggregations'] + outcome_theme = [base_result_data['outcome_themes'].last()['id']] + payload = {'disaggregations': disaggs, 'outcome_theme': outcome_theme, + 'date_collected': date.today(), 'indicator': indicator.pk} + response_post = self.client.post(reverse('pcountcreate', args=[indicator.pk]), data=payload, + content_type='application/json') + updated_result = Result.objects.filter(indicator=indicator.pk).first() + outcome_themes_list = self.get_outcome_theme_list(updated_result) + disagg_values = [] + for disagg in updated_result.disaggregated_values: + disagg_values.append(disagg.value) + + self.assertEqual(response_post.status_code, 200) + self.assertEqual(outcome_themes_list, outcome_theme) + self.assertEqual(disagg_values, updated_disagg_values) + + + def test_result_update(self): + """ + Tests result update view functionality with preset result and modified result data + """ + indicator = self.create_indicator_utils() + + # First fiscal year periodic target for pc indicator + pt = indicator.periodictargets.all().first() + # Create result object in db + result = self.create_result(pt, indicator) + # Get base result data base_result_data = self.get_base_result_data(indicator) + # Add outcome theme 'Economic Opportunity' + base_outcome_theme = base_result_data['outcome_themes'].first()['id'] + result.outcome_themes.add(base_outcome_theme) + response_get = self.client.get(reverse('pcountupdate', args=[result.pk])) + data = json.loads(response_get.content) + self.assertEqual(response_get.status_code, 200) + self.assertEqual(data['periodic_target']['period'], self.period_string) + self.assertEqual(data['date_collected'], str(self.today)) + self.assertTrue(data['outcome_themes'][0][2]) + self.assertEqual(data['view_only'], self.view_only) + self.assertEqual(data['pt_start_date'], self.pt_start) + self.assertEqual(data['pt_end_date'], self.pt_end) + # Modify and save result data + updated_base_result_data, updated_disagg_values = self.update_base_results(base_result_data) + disaggs = updated_base_result_data['disaggregations'] + # Adding a second outcome theme to the existing one + outcome_theme = [base_outcome_theme, base_result_data['outcome_themes'].last()['id']] + url = reverse('pcountupdate', args=[result.pk]) + payload = {'disaggregations': disaggs, 'outcome_theme': outcome_theme, + 'rationale': 'test'} + response_post = self.client.put(url, data=payload, content_type='application/json') + # Getting updated result object + updated_result = Result.objects.get(pk=result.pk) + # Fetching outcome themes from updated result object and adding them to list + ot_list = self.get_outcome_theme_list(updated_result) + disagg_values = [] + for disagg in updated_result.disaggregated_values: + disagg_values.append(disagg.value) + self.assertEqual(response_post.status_code, 200) + self.assertEqual(updated_result.periodic_target.period, self.period_string) + self.assertEqual(ot_list, outcome_theme) + self.assertEqual(disagg_values, updated_disagg_values) diff --git a/indicators/tests/test_pc_indicators_program_period_change.py b/indicators/tests/test_pc_indicators_program_period_change.py index bc269d980..d6b33886e 100644 --- a/indicators/tests/test_pc_indicators_program_period_change.py +++ b/indicators/tests/test_pc_indicators_program_period_change.py @@ -48,9 +48,14 @@ def test_periodic_target_recalculate_with_reporting_period_change(self): self.assertEqual(pc_indicator.count(), 0) # Update reporting period + start_year = datetime.date.today().year if datetime.date.today().month < 7 else datetime.date.today().year + 1 + end_year = start_year + 2 + start_date = datetime.date(start_year, 1, 1) + end_date = datetime.date(end_year, 12, 31) + period_names = ['FY' + str(start_year), 'FY' + str(start_year + 1), 'FY' + str(end_year), 'FY' + str(end_year + 1)] self.client.post(reverse('reportingperiod_update', kwargs={'pk': self.program.pk}), - {'reporting_period_start': '2023-01-01', - 'reporting_period_end': '2025-12-31', + {'reporting_period_start': start_date, + 'reporting_period_end': end_date, 'rationale': 'test'}) pc_indicator = self.program.indicator_set.filter(admin_type=Indicator.ADMIN_PARTICIPANT_COUNT) self.assertEqual(pc_indicator.count(), 1) @@ -59,59 +64,58 @@ def test_periodic_target_recalculate_with_reporting_period_change(self): for pt in periodictargets: periods.append(pt.period) self.assertEqual(periodictargets.count(), 4) - self.assertEqual(periods, ['FY2023', 'FY2024', 'FY2025', 'FY2026']) + self.assertEqual(periods, period_names) # Add one fiscal year at the end + end_date_plus_one = datetime.date(end_year + 1, 12, 31) self.client.post(reverse('reportingperiod_update', kwargs={'pk': self.program.pk}), - {'reporting_period_start': '2023-01-01', - 'reporting_period_end': '2026-12-31', + {'reporting_period_start': start_date, + 'reporting_period_end': end_date_plus_one, 'rationale': 'test'}) periodictargets = pc_indicator.first().periodictargets.all() self.assertEqual(periodictargets.count(), 5) # Subtract one fiscal year from the end self.client.post(reverse('reportingperiod_update', kwargs={'pk': self.program.pk}), - {'reporting_period_start': '2023-01-01', - 'reporting_period_end': '2025-12-31', + {'reporting_period_start': start_date, + 'reporting_period_end': end_date, 'rationale': 'test'}) periodictargets = pc_indicator.first().periodictargets.all() self.assertEqual(periodictargets.count(), 4) - # Add one fiscal to the front + # Subtract one fiscal from the front + start_date_plus_one = datetime.date(start_year + 1, 1, 1) self.client.post(reverse('reportingperiod_update', kwargs={'pk': self.program.pk}), - {'reporting_period_start': '2022-01-01', - 'reporting_period_end': '2025-12-31', + {'reporting_period_start': start_date_plus_one, + 'reporting_period_end': end_date, 'rationale': 'test'}) periodictargets = pc_indicator.first().periodictargets.all() - self.assertEqual(periodictargets.count(), 5) + self.assertEqual(periodictargets.count(), 3) - # Subtract one fiscal from the front + # Add one fiscal to the front self.client.post(reverse('reportingperiod_update', kwargs={'pk': self.program.pk}), - {'reporting_period_start': '2023-01-01', - 'reporting_period_end': '2025-12-31', + {'reporting_period_start': start_date, + 'reporting_period_end': end_date, 'rationale': 'test'}) periodictargets = pc_indicator.first().periodictargets.all() self.assertEqual(periodictargets.count(), 4) # Subtract fiscal year from both ends + end_date_minus_one = datetime.date(end_year - 1, 12, 31) self.client.post(reverse('reportingperiod_update', kwargs={'pk': self.program.pk}), - {'reporting_period_start': '2024-01-01', - 'reporting_period_end': '2024-12-31', + {'reporting_period_start': start_date_plus_one, + 'reporting_period_end': end_date_minus_one, 'rationale': 'test'}) periodictargets = pc_indicator.first().periodictargets.all() self.assertEqual(periodictargets.count(), 2) # Add fiscal year to both ends self.client.post(reverse('reportingperiod_update', kwargs={'pk': self.program.pk}), - {'reporting_period_start': '2022-01-01', - 'reporting_period_end': '2025-12-31', + {'reporting_period_start': start_date, + 'reporting_period_end': end_date, 'rationale': 'test'}) periodictargets = pc_indicator.first().periodictargets.all() - periods = [] - for pt in periodictargets: - periods.append(pt.period) - self.assertEqual(periodictargets.count(), 5) - self.assertEqual(periods, ['FY2022', 'FY2023', 'FY2024', 'FY2025', 'FY2026']) + self.assertEqual(periodictargets.count(), 4) # Add result to first pt self.result = i_factories.ResultFactory( @@ -123,13 +127,13 @@ def test_periodic_target_recalculate_with_reporting_period_change(self): # Confirm pt with added result cannot be deleted self.client.post(reverse('reportingperiod_update', kwargs={'pk': self.program.pk}), - {'reporting_period_start': '2020-01-01', - 'reporting_period_end': '2020-12-31', + {'reporting_period_start': start_date_plus_one, + 'reporting_period_end': end_date_minus_one, 'rationale': 'test'}) periodictargets = pc_indicator.first().periodictargets.all() periods = [] for pt in periodictargets: periods.append(pt.period) - self.assertEqual(periodictargets.count(), 1) - self.assertEqual(periods, ['FY2022']) + self.assertEqual(periodictargets.count(), 3) + self.assertEqual(periods, period_names[:3]) diff --git a/indicators/tests/test_qa_program_mgt_cmd.py b/indicators/tests/test_qa_program_mgt_cmd.py index b5be2316b..7de85a416 100644 --- a/indicators/tests/test_qa_program_mgt_cmd.py +++ b/indicators/tests/test_qa_program_mgt_cmd.py @@ -6,11 +6,14 @@ from django.urls import reverse from factories.indicators_models import ( DisaggregationTypeFactory, - IndicatorTypeFactory + IndicatorTypeFactory, + IDAAOutcomeThemeFactory ) -from factories.workflow_models import OrganizationFactory, TolaUserFactory, SectorFactory, CountryFactory +from factories.workflow_models import OrganizationFactory, TolaUserFactory, SectorFactory, CountryFactory,\ + IDAASectorFactory +from factories.indicators_models import ReportingFrequencyFactory from workflow.models import Program, Country, ProgramAccess, TolaUser -from indicators.models import Indicator +from indicators.models import Indicator, IndicatorType, ReportingFrequency @test.tag('slow') @@ -23,7 +26,11 @@ def setUpClass(cls): OrganizationFactory(pk=1, name="Mercy Corps") DisaggregationTypeFactory(pk=109, disaggregation_type="Sex and Age Disaggregated Data (SADD)") cls.indicator_type = IndicatorTypeFactory() + IndicatorTypeFactory(indicator_type=IndicatorType.PC_INDICATOR_TYPE) + ReportingFrequencyFactory(frequency=ReportingFrequency.PC_REPORTING_FREQUENCY) SectorFactory.create_batch(size=5) + IDAASectorFactory.create_batch(size=5) + IDAAOutcomeThemeFactory.create_batch(size=5) sys.stdout = io.StringIO() management.call_command('create_qa_programs', names='test_program', named_only=True) sys.stdout = sys.__stdout__ @@ -45,7 +52,14 @@ def tearDown(self): sys.stdout = sys.__stdout__ def test_null_values(self): - qa_indicator = self.program.indicator_set.first() + qa_indicators = self.program.indicator_set.all() + + # Make sure first indicator is PC indicator + pc_indicator = qa_indicators[0] + self.assertEqual(pc_indicator.__dict__['name'], Indicator.PARTICIPANT_COUNT_INDICATOR_NAME) + + # First test indicator + qa_indicator = qa_indicators[1] qa_data = {k: qa_indicator.__dict__[k] for k in qa_indicator.__dict__ if k in self.required_indicator_keys} qa_data['indicator_key'] = str(uuid.uuid4()) qa_data['level'] = qa_indicator.level.id diff --git a/indicators/tests/test_results_framework.py b/indicators/tests/test_results_framework.py index e4fae93f5..93173e7a1 100644 --- a/indicators/tests/test_results_framework.py +++ b/indicators/tests/test_results_framework.py @@ -11,10 +11,12 @@ from indicators.models import Indicator -start_date = datetime.date(2022, 1, 1) -end_date = datetime.date(2024, 12, 31) -period_names = ['FY2022', 'FY2023', 'FY2024', 'FY2025'] -customsort_vals = [2022, 2023, 2024, 2025] +start_year = datetime.date.today().year if datetime.date.today().month < 7 else datetime.date.today().year + 1 +end_year = start_year + 2 +start_date = datetime.date(start_year, 1, 1) +end_date = datetime.date(end_year, 12, 31) +period_names = ['FY' + str(start_year), 'FY' + str(start_year + 1), 'FY' + str(end_year), 'FY' + str(end_year + 1)] +customsort_vals = [start_year, start_year + 1, end_year, end_year + 1] def get_generic_level_post_data(program_pk, **kwargs): diff --git a/indicators/views/view_utils.py b/indicators/views/view_utils.py index 7e96ae3b4..7a00f253f 100644 --- a/indicators/views/view_utils.py +++ b/indicators/views/view_utils.py @@ -154,7 +154,7 @@ def program_rollup_data(program, for_csv=False): dict = { "unique_id": program.pk, "program_name": program.name, - "gait_id": program.gaitid, + "gait_id": program.gaitids, "countries": " / ".join([c.country for c in program.country.all()]) if for_csv else [c.country for c in program.country.all()], "sectors": " / ".join(set([i.sector.sector for i in program.indicator_set.all() if i.sector and i.sector.sector])) if for_csv else set([i.sector.sector for i in program.indicator_set.all() if i.sector and i.sector.sector]), diff --git a/indicators/views/views_program.py b/indicators/views/views_program.py index 557d33723..8dbdee4fd 100644 --- a/indicators/views/views_program.py +++ b/indicators/views/views_program.py @@ -318,7 +318,7 @@ def get_parent_segment(ontology_segments, level): direction_of_change_map[indicator.direction_of_change] if indicator.direction_of_change else 'None', indicator.lop_target_calculated if indicator.lop_target_calculated else indicator.lop_target, program.name, - program.gaitid if program.gaitid else "no gait_id, program id {}".format(program.id), + program.gaitids if program.gaitids else "no gait_id, program id {}".format(program.id), '/'.join([c.strip() for c in program.countries.split(',')]), '/'.join(set(regions)), 'Active' if program.funding_status == 'Funded' else 'Inactive', diff --git a/js/apiv2.js b/js/apiv2.js index 631cebfff..7a51aedb6 100644 --- a/js/apiv2.js +++ b/js/apiv2.js @@ -81,6 +81,19 @@ const api = { .then(response => response.data) .catch(this.logFailure); }, + async getProgramPeriodData(programPk) { + return await this.apiSession.get(`/workflow/api/program_period_update/${programPk}/`) + .then(response => ({...response.data, status: response.status})) + .catch(this.logFailure); + }, + async updateProgramPeriodData(programPk, data) { + return await this.apiSession.put(`/workflow/api/program_period_update/${programPk}/`, data) + .then(response => response) + .catch((err) => { + this.logFailure(err.response.data) + return err.response; + }); + }, ipttFilterData(programPk) { return this.apiInstance.get(`/iptt/${programPk}/filter_data/`) .then(response => response.data) diff --git a/js/base.js b/js/base.js index 4cb46523f..57d0f3ddf 100644 --- a/js/base.js +++ b/js/base.js @@ -2,7 +2,26 @@ import '@babel/polyfill' import '../scss/tola.scss'; import 'react-virtualized/styles.css' +import React from 'react'; +import ReactDOM from 'react-dom'; +/* + * Allows the Django templated Country Page to render the React Program Period modals with + * unique classnames that include the Program IDs. + */ +import { ProgramPeriod } from './pages/program_page/components/program_period'; +// Find all the program period modals buttons and render the program period modal compenent +let programModalList = document.querySelectorAll('[class^="program-period__button"'); +programModalList.forEach(program => { + let programID = program.getAttribute('class').split("--")[1]; + ReactDOM.render(, document.querySelector(`.program-period__button--${programID}`)) +}) +// Find all the program period modals links and render the program period modal compenent +let programLinkList = document.querySelectorAll('[class^="program-period__link"'); +programLinkList.forEach(program => { + let programID = program.getAttribute('class').split("--")[1]; + ReactDOM.render(, document.querySelector(`.program-period__link--${programID}`)) +}) /* * Moved legacy app.js code here - Contains global functions called by template code @@ -492,6 +511,8 @@ const CONTROL_CHARACTER_KEYCODES = [ 46, //delete ] +const NUMPAD_CHARACTER_KEYCODE = 110; + const SPANISH = 'es'; const FRENCH = 'fr'; const ENGLISH = 'en'; @@ -523,6 +544,10 @@ function getValidatedNumericInput(selector) { // don't do anything (allow key to be used as normal) return; } + // allow the numpad decimal key + if(e.keyCode == NUMPAD_CHARACTER_KEYCODE){ + return; + } // if decimal point/comma, and already 2 digits to the right of it, and cursor is to the right of it, prevent: let curVal = `${$(e.target).val()}`; let floatingPointPosition = Math.max(curVal.indexOf('.'), curVal.indexOf(',')); diff --git a/js/components/checkboxed-multi-select.js b/js/components/checkboxed-multi-select.js index bab4d4814..2c1a6c10d 100644 --- a/js/components/checkboxed-multi-select.js +++ b/js/components/checkboxed-multi-select.js @@ -129,6 +129,7 @@ class CheckboxedMultiSelect extends React.Component { placeholderButtonLabel={ this.props.placeholder } getDropdownButtonLabel={ this.makeLabel } components={{MenuList, Group }} + className={ this.props.className } />; } } diff --git a/js/pages/program_page/components/program_period.js b/js/pages/program_page/components/program_period.js new file mode 100644 index 000000000..e68fae751 --- /dev/null +++ b/js/pages/program_page/components/program_period.js @@ -0,0 +1,517 @@ +import React, { useState, useEffect } from 'react'; +import api from '../../../apiv2'; + +const ProgramPeriod = ({programPk, heading, headingClass}) => { + + // Helper Functions + function processDateString(datestr) { + if (!datestr) return null; + // take a date string, return a Date, for datepickers, handles iso dates and human input dates + //check for an iso date, and if so parse it with regex (to avoid 0 padding glitches on chrome) + var isodate = /(\d{4})\-(\d{1,2})\-(\d{1,2})/.exec(datestr); + var defaultDate; + if (isodate !== null) { + //regex found an iso date, instance a date from the regex parsing: + //note: month is 0-index in JS because reasons, so -1 to parse human iso date: + defaultDate = new Date(isodate[1], isodate[2]-1, isodate[3]); + } else { + defaultDate = new Date( + datestr.substring(datestr.length - 4, datestr.length), + jQuery.inArray(datestr.substring(0, 3), $(this).datepicker('option', 'monthNamesShort')) + 1, + 0 + ); + } + return defaultDate; + } + + const INDICATOR_TRACKING_DESCRIPTION = { + en: `Some programs may wish to customize the date range for time-based target periods to better correspond to the program’s implementation phase or the phase in which indicators are measured, tracked, and reported. In these cases, programs may adjust the indicator tracking start and end dates below. Indicator tracking dates must begin on the first day of the month and end on the last day of the month, and they may not fall outside of the official program start and end dates. Please note that the indicator tracking dates should be adjusted before indicator periodic targets are set up and the program begins submitting indicator results. To adjust the indicator tracking start date or to move the end date earlier after targets are set up and results submitted, please refer to the + TolaData User Guide.`, + es: `Algunos programas pueden desear personalizar el rango de fechas para los periodos objetivo basados en el tiempo para que se correspondan mejor con la fase de implementación del programa o con la fase en la que se miden, rastrean y reportan los indicadores. En estos casos, los programas pueden ajustar las fechas de inicio y fin del seguimiento de los indicadores que se indican a continuación. Las fechas de seguimiento de los indicadores deben comenzar el primer día del mes y terminar el último día del mes, y no pueden quedar fuera de las fechas oficiales de inicio y fin del programa. Tenga en cuenta que las fechas de seguimiento de los indicadores deben ajustarse antes de que se establezcan las metas periódicas de los indicadores y el programa comience a presentar los resultados de los indicadores. Para ajustar la fecha de inicio del seguimiento de los indicadores o para adelantar la fecha de finalización después de que se hayan establecido los objetivos y se hayan presentado los resultados, consulte la + Guía del Usuario de TolaData.`, + fr: `Certains programmes peuvent souhaiter personnaliser la plage de dates pour les périodes cibles temporelles afin de mieux correspondre à la phase de mise en œuvre du programme ou à la phase dans laquelle les indicateurs sont mesurés, suivis et rapportés. Dans ces cas, les programmes peuvent ajuster les dates de début et de fin de suivi des indicateurs ci-dessous. Les dates de suivi des indicateurs doivent commencer le premier jour du mois et se terminer le dernier jour du mois, et elles ne peuvent pas tomber en dehors des dates officielles de début et de fin du programme. Veuillez noter que les dates de suivi des indicateurs doivent être ajustées avant que les cibles périodiques des indicateurs soient établies et que le programme commence à soumettre les résultats des indicateurs. Pour ajuster la date de début du suivi des indicateurs ou pour avancer la date de fin après la mise en place des cibles et la soumission des résultats, veuillez vous référer au + Guide de l'Utilisateur de TolaData.`, + }; + + + const setupDatePickers = () => { + // Setup Date pickers + const commonOpts = { + changeMonth: true, + changeYear: true, + showButtonPanel: true, + dateFormat: 'yy-mm-dd', + }; + + // Editable Indicator Tracking Start Date + $(start_date_id).datepicker( + $.extend(true, commonOpts, { + beforeShow: function (input, inst) { + $("#ui-datepicker-div").addClass("month-only"); + // The datepicker will preserve the maxDate and defaultDate options from its last use, + // so we need to reset them before we set the input field value. + $(this).datepicker('option', 'maxDate', "+10y"); + let datestr; + if ((datestr = $(this).val()).length > 0) { + let defaultDate = processDateString(datestr); + $(this).datepicker('option', 'defaultDate', defaultDate); + $(this).datepicker('setDate', defaultDate); + } + else { + $(this).datepicker('option', 'defaultDate', new Date()); + } + + // If the end date field has a value, set the maxDate to that value. + let selectedDate; + if ((selectedDate = $(end_date_id).val()).length > 0) { + let selectedEndDate = processDateString(selectedDate); + let year = selectedEndDate.getFullYear(); + let month = selectedEndDate.getMonth(); + $(this).datepicker("option", "maxDate", new Date(year, month, 1)); + } + }, + }) + ).focus(function(){ + const field = $(this); + field.trigger("blur"); // Prevents users from clicking in and typing into the text field + setTimeout(() => { + // hide the days part of the calendar + $(".ui-datepicker-calendar").hide(); + // hide the "Today" button + $("#ui-datepicker-div button.ui-datepicker-current").hide(); + $("#ui-datepicker-div").position({ + my: "left top", + at: "left bottom", + of: $(this) + }); + }, 1); + // detach it from the field so that onclose the field is not populated automatically + $('.ui-datepicker-calendar').detach(); + $('.ui-datepicker-close').on("click", function() { + // this is only called when the done button is clicked. + const month = $("#ui-datepicker-div .ui-datepicker-month :selected").val(); + const year = $("#ui-datepicker-div .ui-datepicker-year :selected").val(); + let newDate = new Date(year, month, 1); + field.datepicker('setDate', newDate); + field.trigger('change'); + setProgramPeriodInputs( prevState => ({...prevState, indicator_tracking_start_date: field.val()})); + }); + }); + + // Editable Indicator Tracking End Date + $(end_date_id).datepicker( + $.extend(true, commonOpts, { + beforeShow: function(input, inst) { + $("#ui-datepicker-div").addClass("month-only"); + // The datepicker will preserve the minDate option from its last use, so we need to reset it + // before we set the input field value. + $(this).datepicker('option', 'minDate', "-10y"); + let datestr; + if ((datestr = $(this).val()).length > 0) { + let defaultDate = processDateString(datestr); + $(this).datepicker('option', 'defaultDate', defaultDate); + $(this).datepicker('setDate', defaultDate); + } + else { + $(this).datepicker('option', 'defaultDate', new Date()); + } + // If the start date field has a value, set the minDate to that value. + let selectedDate; + if ((selectedDate = $(start_date_id).val()).length > 0) { + let selectedStartDate = processDateString(selectedDate); + let year = selectedStartDate.getFullYear(); + let month = selectedStartDate.getMonth() + 1; + $(this).datepicker("option", "minDate", new Date(year, month, 0)); + } + } + }) + ).focus(function(){ + const field = $(this); + field.trigger("blur"); // Prevents users from clicking in and typing into the text field + setTimeout(() => { + // hide the days part of the calendar + $(".ui-datepicker-calendar").hide(); + // hide the "Today" button + $("#ui-datepicker-div button.ui-datepicker-current").hide(); + $("#ui-datepicker-div").position({ + my: "left top", + at: "left bottom", + of: $(this) + }); + }, 1); + // detach it from the field so that onclose the field is not populated automatically + $('.ui-datepicker-calendar').detach(); + $('.ui-datepicker-close').on("click", function() { + // this is only called when the done button is clicked. + const month = $("#ui-datepicker-div .ui-datepicker-month :selected").val(); + const year = $("#ui-datepicker-div .ui-datepicker-year :selected").val(); + let newDate = new Date(year, parseInt(month) + 1, 0); + field.datepicker('setDate', newDate); + field.trigger('change'); + setProgramPeriodInputs( prevState => ({...prevState, indicator_tracking_end_date: field.val()})); + }); + }); + } + + // Handle validation of the editable start and end dates before sending them to the backend. + const handleValidation = () => { + let valid = true; + let errorMessages = []; + let hasStartChanged = origData.start_date === programPeriodInputs.indicator_tracking_start_date; + let hasEndChanged = origData.end_date === programPeriodInputs.indicator_tracking_end_date; + + if (hasStartChanged && programPeriodInputs.indicator_tracking_start_date === "") { + errorMessages.push(gettext("You must enter values for the indicator tracking period start date before saving.")); + $(start_date_id).addClass('is-invalid'); + valid = false; + } + if (hasEndChanged && programPeriodInputs.indicator_tracking_end_date === "") { + errorMessages.push(gettext("You must enter values for the indicator tracking period end date before saving.")); + $(end_date_id).addClass('is-invalid'); + valid = false; + } + if (programPeriodInputs.indicator_tracking_start_date > programPeriodInputs.indicator_tracking_end_date) { + errorMessages.push(gettext("The end date must come after the start date.")); + $(start_date_id).addClass('is-invalid'); + $(end_date_id).addClass('is-invalid'); + valid = false; + } + if (hasStartChanged && idaaDates.start_date && programPeriodInputs.indicator_tracking_start_date < idaaDates.start_date) { + errorMessages.push(gettext("The indicator tracking start date must be later than or equal to the IDAA start date.")); + $(start_date_id).addClass('is-invalid'); + valid = false; + } + if (hasEndChanged && idaaDates.end_date && programPeriodInputs.indicator_tracking_end_date > idaaDates.end_date) { + errorMessages.push(gettext("The indicator tracking end date must be earlier than or equal to the IDAA end date.")); + $(end_date_id).addClass('is-invalid'); + valid = false; + } + errorMessages.map((msg) => { + createAlert( "danger", msg, false, alertMessageDiv ); + }) + return valid; + } + + // Component States + const [lockForm, setLockForm] = useState(false); + const [idaaDates, setIdaaDates] = useState({}); + const [programPeriodInputs, setProgramPeriodInputs] = useState({}); + const [origData, setOrigData] = useState({}); + const start_date_id = `#indicator-tracking__date--start-${programPk}`; + const end_date_id = `#indicator-tracking__date--end-${programPk}`; + const alertMessageDiv = `#program-period__alert--${programPk}`; + + // On component mount + useEffect(() => { + + $(`#program-period__modal--${programPk}`).on('show.bs.modal', () => { + + $(".indicator-tracking__description").append(INDICATOR_TRACKING_DESCRIPTION[window.userLang]); // Adds the description text based on users language + + // Get data on mount with API Call + api.getProgramPeriodData(programPk) + .then(response => { + let { + start_date: idaa_start_date, + end_date: idaa_end_date, + reporting_period_start: indicator_tracking_start_date, + reporting_period_end: indicator_tracking_end_date + } = response; + // Update the state variables + setOrigData({ + readOnly: response.readonly, + has_time_aware_targets: response.has_time_aware_targets, + indicator_tracking_start_date: indicator_tracking_start_date || null, + indicator_tracking_end_date: indicator_tracking_end_date || null, + }); + setIdaaDates({...idaaDates, + start_date: idaa_start_date || null, + end_date: idaa_end_date || null, + }); + setProgramPeriodInputs({...programPeriodInputs, + indicator_tracking_start_date: indicator_tracking_start_date || null, + indicator_tracking_end_date: indicator_tracking_end_date || null, + }); + + // Setting the datepicker values for the editable Indicator Tracking Start Date + $(start_date_id).datepicker("setDate", indicator_tracking_start_date); + + // Setting the Min Start Date: + let startMinDate; + if (idaa_start_date) { + // If IDAA start date exist and the Indicator Tracking start date does not exist or is later than IDAA start date, then set min date to the IDAA start date + if (!indicator_tracking_start_date || idaa_start_date < indicator_tracking_start_date) { + startMinDate = processDateString(idaa_start_date); + // If IDAA start date exist and is later than Indicator Tracking start date, then set min date to the Indicator Tracking start date + } else { + startMinDate = processDateString(indicator_tracking_start_date); + } + } else { + // If IDAA start date does not exist but the Indicator Tracking start date does exist, set min date to the Indicator Tracking end date + if (indicator_tracking_start_date) { + startMinDate = processDateString(indicator_tracking_start_date); + // If the IDAA start date and the Indicator Tracking start date both do not exist, set min date to null. + } else { + startMinDate = null; + } + } + // If theres no selected min start date, set it to 10 years back from the current date. + if (!startMinDate) { + startMinDate = new Date() + startMinDate.setFullYear(startMinDate.getFullYear() - 10) + }; + $(`#indicator-tracking__date--start-${programPk}`).datepicker("option", "minDate", new Date(startMinDate.getFullYear(), startMinDate.getMonth(), 1)); + + // Setting the datepicker values for the editable Indicator Tracking End Date + $(end_date_id).datepicker("setDate", indicator_tracking_end_date); + // Setting the Max End Date: + let endMaxDate; + if (idaa_end_date) { + // If IDAA end date exist and the Indicator Tracking end date does not exist or is earlier than IDAA end date, then set max date to the IDAA end date + if (!indicator_tracking_end_date || idaa_end_date > indicator_tracking_end_date) { + endMaxDate = processDateString(idaa_end_date); + // If IDAA end date exist and is earlier than Indicator Tracking end date, then set max date to the Indicator Tracking end date + } else { + endMaxDate = processDateString(indicator_tracking_end_date); + } + } else { + // If IDAA end date does not exist but the Indicator Tracking end date does exist, set max date to the Indicator Tracking end date + if (indicator_tracking_end_date) { + endMaxDate = processDateString(indicator_tracking_end_date); + // If the IDAA end date and the Indicator Tracking end date both do not exist, set max date to null. + } else { + endMaxDate = null; + } + } + // If theres no selected max start date, set it to 10 years up from the current date. + if (!endMaxDate) { + endMaxDate = new Date() + endMaxDate.setFullYear(endMaxDate.getFullYear() + 10) + }; + $(`#indicator-tracking__date--end-${programPk}`).datepicker("option", "maxDate", new Date(endMaxDate.getFullYear(), endMaxDate.getMonth() + 1, 0)); + }) + .catch(() => { + createAlert( + "danger", + gettext("Error. Could not retrieve data from server. Please report this to the Tola team."), + false, + alertMessageDiv + ); + }) + }) + + // Setup the Indicator tracking start and end dates datepicker + setupDatePickers(); + }, []) + + // On component unmount/hide + $(`#program-period__modal--${programPk}`).on('hide.bs.modal', function () { + // Need to remove the month-only class from the date-picker so it doesn't interfere with other datepickers + // on the page. Can't do it in the picker because it results in a calendar flash before picker is closed. + $("#ui-datepicker-div").removeClass("month-only"); + $(".indicator-tracking__description").empty(); // Removes the description text when modal is closed + $(".dynamic-alert").remove() // Removes all alert boxes + $(start_date_id).removeClass("is-invalid") + $(end_date_id).removeClass("is-invalid") + }) + + // Send updated data to backend with API Call + const handleUpdateData = () => { + setLockForm(true); // Lock form to prevent button smashing + + // Removes all alert boxes and invalid fields prior to valdating again + $(".dynamic-alert").remove() + $(start_date_id).removeClass("is-invalid") + $(end_date_id).removeClass("is-invalid") + + // API calls function that sends the editable indicator tracking start and end dates along with the rationale for the update. + let sendData = (rationale) => { + let data = { + reporting_period_start: programPeriodInputs.indicator_tracking_start_date, + reporting_period_end: programPeriodInputs.indicator_tracking_end_date, + rationale: rationale, + }; + api.updateProgramPeriodData(programPk, data) + .then(res => { + if (res.status === 200) { + window.unified_success_message( + // # Translators: This is the text of an alert that is triggered upon a successful change to the the start and end dates of the reporting period + gettext('Indicator tracking period is updated.') + ); + // Allows the success message to be visible and read before the page is refreshed + setTimeout(() => { + window.location.reload(); + }, 3000); + } else { + setLockForm(false); // Unlock form for editing and resubmission + if(res.data) { + Object.keys(res.data).map((err) => { + if (err === "reporting_period_start") { + $(start_date_id).addClass('is-invalid'); + createAlert( + "danger", + res.data[err][0], + false, + alertMessageDiv + ) + } + if (err === "reporting_period_end") { + $(end_date_id).addClass('is-invalid'); + createAlert( + "danger", + res.data[err][0], + false, + alertMessageDiv + ) + } + }) + } else { + createAlert( + "danger", + gettext('There was a problem saving your changes.'), + false, + alertMessageDiv + ) + } + } + }) + } + + // Text to display in the rationale popup. + const INDICATOR_TRACKING_CHANGE_TEXT = gettext( 'This action may result in changes to your periodic targets. If you have already set up periodic targets for your indicators, you may need to enter additional target values to cover the entire indicator tracking period. For future reference, please provide a reason for modifying the indicator tracking period.' ) + + // Only update if there has been a change in the editable dates. + if (origData.indicator_tracking_start_date !== programPeriodInputs.indicator_tracking_start_date || origData.indicator_tracking_end_date !== programPeriodInputs.indicator_tracking_end_date) { + if (handleValidation()) { + window.create_unified_changeset_notice({ + header: gettext("Reason for change"), + show_icon: true, + message_text: INDICATOR_TRACKING_CHANGE_TEXT, + include_rationale: true, + rationale_required: true, + context: document.getElementById(`program-period__content--${programPk}`), + on_submit: (rationale) => sendData(rationale) + }) + } + } else { + // If there has not been a change in the editable dates, just close the modal when the save changes button is clicked. + $(`#program-period__modal--${programPk}`).modal('hide'); + } + } + + // Handle Cancel changes button click to restore indicator tracking start and end dates to original database dates + const handleCancelChanges = () => { + setProgramPeriodInputs({ + indicator_tracking_start_date: origData.indicator_tracking_start_date, + indicator_tracking_end_date: origData.indicator_tracking_end_date + }); + $(start_date_id).datepicker("setDate", origData.indicator_tracking_start_date); + $(end_date_id).datepicker("setDate", origData.indicator_tracking_end_date); + } + + return ( + + {heading} + + + + ) +} + +export { ProgramPeriod }; \ No newline at end of file diff --git a/js/pages/program_page/index.js b/js/pages/program_page/index.js index 8b0653b29..ec3f8d852 100644 --- a/js/pages/program_page/index.js +++ b/js/pages/program_page/index.js @@ -92,7 +92,6 @@ ReactDOM.render(, ReactDOM.render(, document.querySelector('#sites-sidebar')); - /* * Copied and modified JS from indicator_list_modals.js to allow modals to work * without being completely converted to React diff --git a/js/pages/results_form_PC/components/CommonFields.js b/js/pages/results_form_PC/components/CommonFields.js index 1d17eb680..e05a222fb 100644 --- a/js/pages/results_form_PC/components/CommonFields.js +++ b/js/pages/results_form_PC/components/CommonFields.js @@ -19,19 +19,19 @@ const CommonFields = ({ commonFieldsInput, setCommonFieldsInput, outcomeThemesDa let maxResultDate = commonFieldsInput.program_end_date < commonFieldsInput.pt_end_date ? commonFieldsInput.program_end_date : commonFieldsInput.pt_end_date; let minResultDate = commonFieldsInput.program_start_date > commonFieldsInput.pt_start_date ? commonFieldsInput.program_start_date : commonFieldsInput.pt_start_date; - $('.datepicker').datepicker({ + $('#id_date_collected--pc').datepicker({ dateFormat: "yy-mm-dd", maxDate: formatDate(localdate()) < maxResultDate ? formatDate(localdate()) : maxResultDate, // Only allow results for the past so if the current date is less than the maxResultDate, use the current date as the max date allowed. minDate: minResultDate, }); if (commonFieldsInput.date_collected) { - $('.datepicker').datepicker("setDate", commonFieldsInput.date_collected); + $('#id_date_collected--pc').datepicker("setDate", commonFieldsInput.date_collected); setSelectedDate(commonFieldsInput.date_collected); } // Capture the value of the datepicker and triggers an update of state - $('.datepicker').on('change', () => { + $('#id_date_collected--pc').on('change', () => { setWasUpdated(true); - var date = $('.datepicker').datepicker('getDate'); + var date = $('#id_date_collected--pc').datepicker('getDate'); setSelectedDate(date); }) }) @@ -90,7 +90,8 @@ const CommonFields = ({ commonFieldsInput, setCommonFieldsInput, outcomeThemesDa name="periodic_target--pc" id="id_periodic_target--pc" className="form-control" - required autoComplete="off" + required + autoComplete="off" disabled value={commonFieldsInput.periodic_target && commonFieldsInput.periodic_target.period || ""} /> diff --git a/js/pages/results_form_PC/resultsFormPC.js b/js/pages/results_form_PC/resultsFormPC.js index 5cf74a1f8..eda83aaf8 100644 --- a/js/pages/results_form_PC/resultsFormPC.js +++ b/js/pages/results_form_PC/resultsFormPC.js @@ -82,7 +82,7 @@ const PCResultsForm = ({indicatorID="", resultID="", readOnly}) => { }; if (!commonFieldsInput.periodic_target || Object.keys(commonFieldsInput.periodic_target).length === 0) { - detectedErrors = {...detectedErrors, fiscal_year: gettext("You cannot change the fiscal year during the current reporting period. ")}; + detectedErrors = {...detectedErrors, fiscal_year: gettext("You cannot change the fiscal year during the current reporting period.")}; } else { delete detectedErrors.periodic_target }; if (!commonFieldsInput.outcome_theme || commonFieldsInput.outcome_theme.length === 0) { diff --git a/js/pages/tola_management_pages/country/components/edit_objectives.js b/js/pages/tola_management_pages/country/components/edit_objectives.js index df4e2f3f6..5585d47f5 100644 --- a/js/pages/tola_management_pages/country/components/edit_objectives.js +++ b/js/pages/tola_management_pages/country/components/edit_objectives.js @@ -118,12 +118,13 @@ class StrategicObjectiveForm extends React.Component { {objective.id=='new' && (
+
)} {objective.id!='new' && (
- +
)}
diff --git a/js/pages/tola_management_pages/program/api.js b/js/pages/tola_management_pages/program/api.js index ae4ee9b6e..0d76cb4ed 100644 --- a/js/pages/tola_management_pages/program/api.js +++ b/js/pages/tola_management_pages/program/api.js @@ -37,9 +37,6 @@ export const updateProgramFundingStatusBulk = (ids, funding_status) => { export const fetchProgramHistory = (id) => api.get(`/tola_management/program/${id}/history/`) -export const syncGAITDates = (id) => api.put(`/tola_management/program/${id}/sync_gait_dates/`) - - export default { fetchPrograms, fetchProgramsForFilter, @@ -47,6 +44,5 @@ export default { createProgram, updateProgram, updateProgramFundingStatusBulk, - validateGaitId, - syncGAITDates + validateGaitId } diff --git a/js/pages/tola_management_pages/program/components/edit_program_profile.js b/js/pages/tola_management_pages/program/components/edit_program_profile.js index 58c1bed20..ea8e61cea 100644 --- a/js/pages/tola_management_pages/program/components/edit_program_profile.js +++ b/js/pages/tola_management_pages/program/components/edit_program_profile.js @@ -1,8 +1,9 @@ -import React from 'react' -import Select from 'react-select' -import { observer } from "mobx-react" -import CheckboxedMultiSelect from 'components/checkboxed-multi-select' -import classNames from 'classnames' +import React from 'react'; +import Select from 'react-select'; +import { observer } from "mobx-react"; +import CheckboxedMultiSelect from 'components/checkboxed-multi-select'; +import classNames from 'classnames'; +import HelpPopover from '../../../../components/helpPopover.js'; const ErrorFeedback = observer(({errorMessages}) => { @@ -24,133 +25,566 @@ export default class EditProgramProfile extends React.Component { const {program_data} = props this.state = { - original_data: Object.assign({}, program_data), - managed_data: Object.assign({}, program_data) + formEditable: false, + original_data: $.extend(true, {}, program_data), + managed_data: $.extend(true, {}, program_data), + formErrors: {}, + gaitRowErrors: {}, + gaitRowErrorsFields: {}, } } + componentDidMount() { + // Set the form to editable for demo, dev2, dev, and localhost servers + let editableEnv = ["demo", "dev", "local"].reduce((editable, env) => { + if (!editable) editable = window.location.href.includes(env); + return editable; + }, false) + this.setState({ + formEditable: editableEnv + }) + + // If there are no GAIT IDs on mount, add a empty Gait Row + this.state.managed_data.gaitid.length === 0 && this.appendGaitRow(); + + $(document).ready(() => { + let startDate = this.state.managed_data.start_date; + let endDate = this.state.managed_data.end_date; + let today = new Date(); + let latest = new Date(); + latest.setHours(0,0,0,0); + latest.setFullYear(today.getFullYear() + 10); + let earliest = new Date(); + earliest.setHours(0,0,0,0); + earliest.setFullYear(today.getFullYear() - 10); + + // Program Start Date setup + $("#program-start-date").datepicker({ + dateFormat: "yy-mm-dd", + minDate: earliest, + }); + if (this.state.managed_data) { + $('#program-start-date').datepicker("setDate", startDate); + }; + $('#program-start-date').on('change', () => { + let updatedDate = $('#program-start-date').datepicker('getDate'); + this.updateFormField('start_date', window.formatDate(updatedDate)); + }); + // Program End Date setup + $("#program-end-date").datepicker({ + dateFormat: "yy-mm-dd", + maxDate: latest, + }); + if (this.state.managed_data) { + $('#program-end-date').datepicker("setDate", endDate); + }; + $('#program-end-date').on('change', () => { + let updatedDate = $('#program-end-date').datepicker('getDate'); + this.updateFormField('end_date', window.formatDate(updatedDate)); + }); + }); + } + + + // ***** Action functions ***** + hasUnsavedDataAction() { this.props.onIsDirtyChange(JSON.stringify(this.state.managed_data) != JSON.stringify(this.state.original_data)) } save() { - const program_id = this.props.program_data.id - const program_data = this.state.managed_data - this.props.onUpdate(program_id, program_data) + if (this.validate()) this.props.onUpdate(this.props.program_data.id, this.state.managed_data); } - saveNew(e) { - e.preventDefault() - const program_data = this.state.managed_data - this.props.onCreate(program_data) + saveNew() { + if (this.validate()) this.props.onCreate(this.state.managed_data); } - updateFormField(fieldKey, val) { + resetForm() { this.setState({ - managed_data: Object.assign(this.state.managed_data, {[fieldKey]: val}) + managed_data: $.extend(true, {}, this.state.original_data), + formErrors: {}, + gaitRowErrors: {}, + gaitRowErrorsFields: {} }, () => this.hasUnsavedDataAction()) } - resetForm() { + updateFormField(fieldKey, val) { this.setState({ - managed_data: Object.assign({}, this.state.original_data) + managed_data: Object.assign(this.state.managed_data, {[fieldKey]: val}) }, () => this.hasUnsavedDataAction()) } formErrors(fieldKey) { - return this.props.errors[fieldKey] + return this.state.formErrors[fieldKey]; + } + + formErrorsGaitRow(index) { + if (this.state.gaitRowErrors[index] ) { + let errorMessages = new Set(); + this.state.gaitRowErrors[index].map((msg) => errorMessages.add(Object.values(msg)[0]) ) + return [...errorMessages]; + } + } + + // ***** Gait row functions ***** + + // Function to update the fields in a gait row + updateGaitRow(label, val, index) { + let updatedRows = [...this.state.managed_data.gaitid]; + updatedRows[index][label] = val; + this.updateFormField("gaitid", updatedRows); + } + + // Function to add a new gait row + appendGaitRow() { + const newRow = { + gaitid: "", + donor: "", + donor_dept: "", + fund_code: [], + }; + this.setState({ + managed_data: $.extend(true, this.state.managed_data, {gaitid: [...this.state.managed_data.gaitid, newRow]}) + }) + } + + // Function to delete a gait row + deleteGaitRow(index) { + let updatedRow = [...this.state.managed_data.gaitid]; + updatedRow.splice(index, 1); + this.updateFormField("gaitid", updatedRow); + } + + // Function to handle updating the fund code field + updateFundCode(label, value, index) { + let val = value.split(/[, ]+/); + val = val.map((code) => { + if (!code) { + return ''; + } else if ( /\D+/.test(code)) { + return parseInt(code.slice(0, code.length - 1)) || ""; + } + return parseInt(code); + }); + this.updateGaitRow(label, val, index); + } + + // Function to create a comma separated list to display converted from an array of items + createDisplayList(listArray) { + if (!listArray) return null; + listArray = [...listArray]; + if (Array.isArray(listArray)) { + listArray = listArray.reduce((list, item, i) => { + let separator = i === 0 ? "" : ", "; + item = item.label || item[1] || item; + return list + separator + item; + }, ""); + } + return listArray; + } + + // Function to create a alphabetically (by index order from backend) sorted selected list to display data from thier options + handleSelected = (selected, options) => { + let resultsObj = {}; + selected.map((id) => { + let index; + let foundOption = options.find((option, idx) => { + index = idx; + return option.value == id; + }); + resultsObj[index] = foundOption; + }) + return Object.values(resultsObj); + } + + + // ***** Validations ***** + validate() { + let isValid = true; + let detectedErrors = {}; + let formdata = this.state.managed_data; + + // Adds error message text to the detected errors object + let addErrorMessage = (type, field, msg, idx) => { + isValid = false; + if (type === 'normal') { + detectedErrors[field] ? detectedErrors[field].push(msg) : detectedErrors[field] = [msg]; + } else if (type === 'gaitRow') { + detectedGaitRowErrors[idx] ? detectedGaitRowErrors[idx].push({[field]: msg}) : detectedGaitRowErrors[idx] = [{[field]: msg}]; + } + } + + // Required fields validations + let requiredFields = ['name', 'external_program_id', 'start_date', 'end_date', 'funding_status', 'country']; + requiredFields.map(field => { + if (!formdata[field] || formdata[field].length === 0) { + addErrorMessage("normal", field, gettext('This field may not be left blank.')); + } + }) + + // Start and End date validations + let startDate = window.localDateFromISOStr(formdata.start_date); + let endDate = window.localDateFromISOStr(formdata.end_date); + let currentYear = new Date().getFullYear(); + let earliest = new Date(); + let latest = new Date(); + earliest.setFullYear(currentYear - 10); + latest.setFullYear(currentYear + 10); + if (formdata.start_date.length > 0) { + if (startDate < earliest) { + addErrorMessage("normal", "start_date", gettext("The program start date may not be more than 10 years in the past.")); + } + if (formdata.end_date.length > 0 && startDate > endDate) { + addErrorMessage("normal", "start_date", gettext("The program start date may not be after the program end date.")); + } + } + if (formdata.end_date.length > 0) { + if (endDate > latest) { + addErrorMessage("normal", "end_date", gettext("The program end date may not be more than 10 years in the future.")); + } + if (formdata.start_date.length > 0 && endDate < startDate) { + addErrorMessage("normal", "end_date", gettext("The program end date may not be before the program start date.")) + } + } + + // Gait ID, Fund code, Donor, and Donor dept section validations + let detectedGaitRowErrors = {}; + let gaitRowErrorsFields = {}; + let uniqueGaitIds = {}; + + formdata.gaitid.map((currentRow, idx) => { + + // The first row's GAIT ID is required + if (idx === 0) { + if (currentRow.gaitid.length === 0) { + addErrorMessage("gaitRow", 'gaitid', gettext('GAIT IDs may not be left blank.'), idx); + } + } + + // Duplicate Gait Ids validation + if (currentRow.gaitid) { + if (uniqueGaitIds.hasOwnProperty(currentRow.gaitid)) { + addErrorMessage('gaitRow', 'gaitid', gettext('Duplicate GAIT ID numbers are not allowed.'), uniqueGaitIds[currentRow.gaitid]); + addErrorMessage('gaitRow', 'gaitid', gettext('Duplicate GAIT ID numbers are not allowed.'), idx); + } else { + uniqueGaitIds[currentRow.gaitid] = idx; + } + } + + // Validation for if fund codes, donor, or donor dept is filled in but GAIT ID is left blank + if (idx > 0 && currentRow.gaitid.length === 0) { + if (currentRow.fund_code.length > 0 || currentRow.donor.length > 0 || currentRow.donor_dept.length > 0) { + addErrorMessage('gaitRow', "gaitid", gettext('GAIT IDs may not be left blank.'), idx); + } + } + + // Validation for each Fund code + currentRow.fund_code.map(currentFundCode => { + currentFundCode = currentFundCode.toString(); + let firstDigit = parseInt(currentFundCode.slice(0, 1)); + if (currentFundCode.length !== 5) { + addErrorMessage("gaitRow", "fund_code", gettext("Fund codes may only be 5 digits long."), idx); + } + if ([3, 7, 9].indexOf(firstDigit) === -1) { + addErrorMessage("gaitRow", "fund_code", gettext("Fund codes may only begin with a 3, 7, or 9 (e.g., 30000)."), idx); + } + }) + + // Create the invalid field arrays for the GAIT Rows to highlight + Object.keys(detectedGaitRowErrors).map((index) => { + let fieldNames = new Set(); + detectedGaitRowErrors[index].map((msg) => { + fieldNames.add(Object.keys(msg)[0]); + }) + gaitRowErrorsFields = {...gaitRowErrorsFields, [index]: [...fieldNames]}; + }) + }); + + this.setState({ + formErrors: detectedErrors, + gaitRowErrors: detectedGaitRowErrors, + gaitRowErrorsFields: gaitRowErrorsFields + }); + return isValid; } + // ***** Render Componenent ***** render() { - const formdata = this.state.managed_data - const selectedCountries = formdata.country.map(x=>this.props.countryOptions.find(y=>y.value==x)) - const selectedSectors = formdata.sector.map(x=>this.props.sectorOptions.find(y=>y.value==x)) + const formdata = this.state.managed_data; + const selectedCountries = this.handleSelected(formdata.country, this.props.countryOptions); + const selectedIDAASectors = this.handleSelected(formdata.idaa_sector, this.props.idaaSectorOptions); + const selectedOutcomeThemes = this.handleSelected(formdata.idaa_outcome_theme, this.props.idaaOutcomeThemesOptions); + return (
-

{this.props.program_data.name ? this.props.program_data.name+': ' : ''}{gettext("Profile")}

-
-
- +

{this.props.program_data.name ? this.props.program_data.name+': ' : ''}{gettext("Profile")} + + + +

+ +
+ this.updateFormField('name', e.target.value) } - className={classNames('form-control', { 'is-invalid': this.formErrors('name') })} - id="program-name-input" - required /> - + /> +
- + this.updateFormField('gaitid', e.target.value) } - className={classNames('form-control', { 'is-invalid': this.formErrors('gaitid') })} - id="program-gait-input" - disabled={!this.props.new} - /> - + id="program-id-input" + className={classNames('form-control', { 'is-invalid': this.state.formErrors['external_program_id'] })} + type="text" + placeholder={ !this.state.formEditable ? gettext("None") : "" } + maxLength={4} + required + disabled={!this.state.formEditable} + value={formdata.external_program_id || ""} + onChange={(e) => this.updateFormField('external_program_id', e.target.value.replace(/[^0-9]/g, "")) } + /> +
- - this.updateFormField('fundCode', e.target.value) } - className={classNames('form-control', { 'is-invalid': this.formErrors('fundCode') })} - id="program-fund-code-input" - disabled={!this.props.new} + +
+ - +
+
- -