diff --git a/.circleci/config.yml b/.circleci/config.yml index e7460f689..103c24e05 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -49,7 +49,7 @@ jobs: environment: PGHOST: 127.0.0.1 DATABASE_URL: "postgis://postgres:postgres@localhost:5432/circle_test" - DEPLOY_BRANCHES: "develop|staging|master|ci-updates2|epd" + DEPLOY_BRANCHES: "develop|staging|master|ci-updates2|epd|eface-forms" - image: cimg/postgres:12.9-postgis environment: POSTGRES_USER: postgres diff --git a/src/etools/applications/core/permissions.py b/src/etools/applications/core/permissions.py index 59438a9ac..6c287e6fd 100644 --- a/src/etools/applications/core/permissions.py +++ b/src/etools/applications/core/permissions.py @@ -77,6 +77,7 @@ def import_permissions(model_name): 'Assessment': settings.PACKAGE_ROOT + '/applications/psea/permission_matrix/assessment_permissions.csv', 'MonitoringActivity': settings.PACKAGE_ROOT + '/applications/field_monitoring/planning/' 'activity_validation/permissions_matrix.csv', + 'EFaceForm': settings.PACKAGE_ROOT + '/applications/eface/validation/permissions_matrix.csv', 'Trip': settings.PACKAGE_ROOT + '/applications/travel/permission_matrix/trip_permissions.csv', } diff --git a/src/etools/applications/eface/__init__.py b/src/etools/applications/eface/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/etools/applications/eface/admin.py b/src/etools/applications/eface/admin.py new file mode 100644 index 000000000..5ee764291 --- /dev/null +++ b/src/etools/applications/eface/admin.py @@ -0,0 +1,14 @@ +from django.contrib import admin + +from etools.applications.eface.models import EFaceForm, FormActivity + + +class FormActivityAdmin(admin.StackedInline): + model = FormActivity + + +@admin.register(EFaceForm) +class EFaceFormAdmin(admin.ModelAdmin): + list_display = ('reference_number', 'request_type', 'status') + list_filter = ('status',) + search_fields = ('reference_number',) diff --git a/src/etools/applications/eface/filters.py b/src/etools/applications/eface/filters.py new file mode 100644 index 000000000..8d467527b --- /dev/null +++ b/src/etools/applications/eface/filters.py @@ -0,0 +1,12 @@ +from django_filters import rest_framework as filters + +from etools.applications.eface.models import EFaceForm + + +class EFaceFormFilterSet(filters.FilterSet): + class Meta: + model = EFaceForm + fields = { + 'created': ['gte', 'lte'], + 'status': ['exact', 'in'], + } diff --git a/src/etools/applications/eface/migrations/0001_initial.py b/src/etools/applications/eface/migrations/0001_initial.py new file mode 100644 index 000000000..51667012c --- /dev/null +++ b/src/etools/applications/eface/migrations/0001_initial.py @@ -0,0 +1,74 @@ +# Generated by Django 2.2.20 on 2021-07-09 08:58 + +import datetime +from django.db import migrations, models +import django.db.models.deletion +import django.db.models.manager +from django.utils.timezone import utc +import django.utils.timezone +import django_fsm +import model_utils.fields +import unicef_djangolib.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('partners', '0086_intervention_accepted_on_behalf_of_partner'), + ] + + operations = [ + migrations.CreateModel( + name='EFaceForm', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('deleted_at', models.DateTimeField(default=datetime.datetime(1970, 1, 1, 0, 0, tzinfo=utc), verbose_name='Deleted At')), + ('reference_number_year', models.IntegerField()), + ('reference_number', models.CharField(blank=True, max_length=64, null=True, unique=True, verbose_name='Reference Number')), + ('title', models.CharField(max_length=255)), + ('currency', unicef_djangolib.fields.CurrencyField(blank=True, choices=[('GIP', 'GIP'), ('KPW', 'KPW'), ('XEU', 'XEU'), ('BHD', 'BHD'), ('BIF', 'BIF'), ('BMD', 'BMD'), ('BSD', 'BSD'), ('AFN', 'AFN'), ('ALL', 'ALL'), ('AMD', 'AMD'), ('AUD', 'AUD'), ('AZN', 'AZN'), ('BAM', 'BAM'), ('BBD', 'BBD'), ('BDT', 'BDT'), ('BZD', 'BZD'), ('CUP1', 'CUP1'), ('BTN', 'BTN'), ('ZWL', 'ZWL'), ('AWG', 'AWG'), ('CUC', 'CUC'), ('VEF01', 'VEF01'), ('BND', 'BND'), ('BRL', 'BRL'), ('ARS', 'ARS'), ('ETB', 'ETB'), ('EUR', 'EUR'), ('FJD', 'FJD'), ('GBP', 'GBP'), ('GEL', 'GEL'), ('GHS', 'GHS'), ('GNF', 'GNF'), ('GTQ', 'GTQ'), ('GYD', 'GYD'), ('HNL', 'HNL'), ('CAD', 'CAD'), ('CDF', 'CDF'), ('CLP', 'CLP'), ('CNY', 'CNY'), ('COP', 'COP'), ('CRC', 'CRC'), ('CUP', 'CUP'), ('CVE', 'CVE'), ('DJF', 'DJF'), ('DKK', 'DKK'), ('DOP', 'DOP'), ('DZD', 'DZD'), ('EGP', 'EGP'), ('HRK', 'HRK'), ('LVL', 'LVL'), ('LYD', 'LYD'), ('MAD', 'MAD'), ('MGA', 'MGA'), ('MKD', 'MKD'), ('KWD', 'KWD'), ('KYD', 'KYD'), ('LBP', 'LBP'), ('LKR', 'LKR'), ('MDL', 'MDL'), ('KZT', 'KZT'), ('LRD', 'LRD'), ('BOB', 'BOB'), ('HKD', 'HKD'), ('CHF', 'CHF'), ('KES', 'KES'), ('MYR', 'MYR'), ('NGN', 'NGN'), ('KMF', 'KMF'), ('SCR', 'SCR'), ('SEK', 'SEK'), ('TTD', 'TTD'), ('PKR', 'PKR'), ('NIO', 'NIO'), ('RWF', 'RWF'), ('BWP', 'BWP'), ('JMD', 'JMD'), ('TJS', 'TJS'), ('UYU', 'UYU'), ('RON', 'RON'), ('PYG', 'PYG'), ('SYP', 'SYP'), ('LAK', 'LAK'), ('ERN', 'ERN'), ('SLL', 'SLL'), ('PLN', 'PLN'), ('JOD', 'JOD'), ('ILS', 'ILS'), ('AED', 'AED'), ('NPR', 'NPR'), ('NZD', 'NZD'), ('SGD', 'SGD'), ('JPY', 'JPY'), ('PAB', 'PAB'), ('ZMW', 'ZMW'), ('CZK', 'CZK'), ('SOS', 'SOS'), ('LTL', 'LTL'), ('KGS', 'KGS'), ('SHP', 'SHP'), ('BGN', 'BGN'), ('TOP', 'TOP'), ('MVR', 'MVR'), ('VEF02', 'VEF02'), ('TMT', 'TMT'), ('GMD', 'GMD'), ('MZN', 'MZN'), ('RSD', 'RSD'), ('MWK', 'MWK'), ('PGK', 'PGK'), ('MXN', 'MXN'), ('XAF', 'XAF'), ('VND', 'VND'), ('INR', 'INR'), ('NOK', 'NOK'), ('XPF', 'XPF'), ('SSP', 'SSP'), ('IQD', 'IQD'), ('SRD', 'SRD'), ('SAR', 'SAR'), ('XCD', 'XCD'), ('IRR', 'IRR'), ('KPW01', 'KPW01'), ('HTG', 'HTG'), ('IDR', 'IDR'), ('XOF', 'XOF'), ('ISK', 'ISK'), ('ANG', 'ANG'), ('NAD', 'NAD'), ('MMK', 'MMK'), ('STD', 'STD'), ('VUV', 'VUV'), ('LSL', 'LSL'), ('SVC', 'SVC'), ('KHR', 'KHR'), ('SZL', 'SZL'), ('RUB', 'RUB'), ('UAH', 'UAH'), ('UGX', 'UGX'), ('THB', 'THB'), ('AOA', 'AOA'), ('YER', 'YER'), ('USD', 'USD'), ('UZS', 'UZS'), ('OMR', 'OMR'), ('SBD', 'SBD'), ('TZS', 'TZS'), ('SDG', 'SDG'), ('WST', 'WST'), ('QAR', 'QAR'), ('MOP', 'MOP'), ('MRU', 'MRU'), ('VEF', 'VEF'), ('TRY', 'TRY'), ('ZAR', 'ZAR'), ('HUF', 'HUF'), ('MUR', 'MUR'), ('PHP', 'PHP'), ('BYN', 'BYN'), ('KRW', 'KRW'), ('TND', 'TND'), ('MNT', 'MNT'), ('PEN', 'PEN')], default='', max_length=5, verbose_name='Currency')), + ('request_type', models.CharField(choices=[('dct', 'Direct Cash Transfer'), ('rmb', 'Reimbursement'), ('dp', 'Direct Payment')], max_length=3)), + ('request_represents_expenditures', models.BooleanField(default=False)), + ('expenditures_disbursed', models.BooleanField(default=False)), + ('notes', models.TextField(blank=True)), + ('authorized_amount_date', models.DateField(blank=True, null=True)), + ('requested_amount_date', models.DateField(blank=True, null=True)), + ('status', django_fsm.FSMField(choices=[('draft', 'Draft'), ('submitted', 'Submitted'), ('unicef_approved', 'UNICEF Approved'), ('finalized', 'Finalized'), ('cancelled', 'Cancelled')], default='draft', max_length=20, verbose_name='Status')), + ('date_submitted', model_utils.fields.MonitorField(blank=True, default=django.utils.timezone.now, monitor='status', null=True, when={'b', 's', 'e', 't', 'u', 'd', 'm', 'i'})), + ('date_unicef_approved', model_utils.fields.MonitorField(blank=True, default=django.utils.timezone.now, monitor='status', null=True, when={'e', 'p', '_', 'o', 'c', 'u', 'd', 'a', 'v', 'f', 'i', 'n', 'r'})), + ('date_transaction_rejected', models.DateField(blank=True, null=True)), + ('date_finalized', model_utils.fields.MonitorField(blank=True, default=django.utils.timezone.now, monitor='status', null=True, when={'e', 'z', 'd', 'a', 'f', 'i', 'n', 'l'})), + ('date_cancelled', model_utils.fields.MonitorField(blank=True, default=django.utils.timezone.now, monitor='status', null=True, when={'e', 'a', 'n', 'c', 'l', 'd'})), + ('rejection_reason', models.TextField(blank=True)), + ('transaction_rejection_reason', models.TextField(blank=True)), + ('cancel_reason', models.TextField(blank=True)), + ('intervention', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='partners.Intervention', verbose_name='Intervention')), + ], + options={ + 'abstract': False, + }, + managers=[ + ('admin_objects', django.db.models.manager.Manager()), + ], + ), + migrations.CreateModel( + name='FormActivity', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.TextField()), + ('coding', models.CharField(blank=True, max_length=100)), + ('reporting_authorized_amount', models.DecimalField(decimal_places=2, default=0, max_digits=20, verbose_name='Reporting - Authorized Amount')), + ('reporting_actual_project_expenditure', models.DecimalField(decimal_places=2, default=0, max_digits=20, verbose_name='Reporting - Actual Project Expenditure')), + ('reporting_expenditures_accepted_by_agency', models.DecimalField(decimal_places=2, default=0, max_digits=20, verbose_name='Reporting - Expenditures Accepted by Agency')), + ('reporting_balance', models.DecimalField(decimal_places=2, default=0, max_digits=20, verbose_name='Reporting - Balance')), + ('requested_amount', models.DecimalField(decimal_places=2, default=0, max_digits=20, verbose_name='Requests - Amount')), + ('requested_authorized_amount', models.DecimalField(decimal_places=2, default=0, max_digits=20, verbose_name='Requests - Authorized Amount')), + ('requested_outstanding_authorized_amount', models.DecimalField(decimal_places=2, default=0, max_digits=20, verbose_name='Requests Outstanding Authorized Amount')), + ('form', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='eface.EFaceForm')), + ], + ), + ] diff --git a/src/etools/applications/eface/migrations/0002_auto_20210712_1118.py b/src/etools/applications/eface/migrations/0002_auto_20210712_1118.py new file mode 100644 index 000000000..f5a620bdd --- /dev/null +++ b/src/etools/applications/eface/migrations/0002_auto_20210712_1118.py @@ -0,0 +1,36 @@ +# Generated by Django 2.2.20 on 2021-07-12 11:18 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('reports', '0036_merge_20210714_2201'), + ('eface', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='formactivity', + name='eepm_kind', + field=models.CharField(blank=True, choices=[('in_country', 'In-country management and support staff prorated to their contribution to the programme (representation, planning, coordination, logistics, administration, finance)'), ('operational', 'Operational costs prorated to their contribution to the programme (office space, equipment, office supplies, maintenance)'), ('planning', 'Planning, monitoring, evaluation and communication, prorated to their contribution to the programme (venue, travels, etc.)')], max_length=15), + ), + migrations.AddField( + model_name='formactivity', + name='kind', + field=models.CharField(choices=[('activity', 'Activity'), ('eepm', 'EEPM'), ('custom', 'Custom')], default='custom', max_length=8), + preserve_default=False, + ), + migrations.AddField( + model_name='formactivity', + name='pd_activity', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='reports.InterventionActivity'), + ), + migrations.AlterField( + model_name='formactivity', + name='description', + field=models.TextField(blank=True), + ), + ] diff --git a/src/etools/applications/eface/migrations/0003_auto_20210712_1328.py b/src/etools/applications/eface/migrations/0003_auto_20210712_1328.py new file mode 100644 index 000000000..793038352 --- /dev/null +++ b/src/etools/applications/eface/migrations/0003_auto_20210712_1328.py @@ -0,0 +1,53 @@ +# Generated by Django 2.2.20 on 2021-07-12 13:28 + +from django.db import migrations +import django.utils.timezone +import django_fsm +import model_utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('eface', '0002_auto_20210712_1118'), + ] + + operations = [ + migrations.RemoveField( + model_name='efaceform', + name='date_finalized', + ), + migrations.RemoveField( + model_name='efaceform', + name='date_transaction_rejected', + ), + migrations.RemoveField( + model_name='efaceform', + name='date_unicef_approved', + ), + migrations.AddField( + model_name='efaceform', + name='date_approved', + field=model_utils.fields.MonitorField(blank=True, default=django.utils.timezone.now, monitor='status', null=True, when={'e', 'a', 'r', 'p', 'd', 'o', 'v'}), + ), + migrations.AddField( + model_name='efaceform', + name='date_closed', + field=model_utils.fields.MonitorField(blank=True, default=django.utils.timezone.now, monitor='status', null=True, when={'e', 'l', 's', 'd', 'o', 'c'}), + ), + migrations.AddField( + model_name='efaceform', + name='date_pending', + field=model_utils.fields.MonitorField(blank=True, default=django.utils.timezone.now, monitor='status', null=True, when={'e', 'n', 'i', 'p', 'g', 'd'}), + ), + migrations.AddField( + model_name='efaceform', + name='date_rejected', + field=model_utils.fields.MonitorField(blank=True, default=django.utils.timezone.now, monitor='status', null=True, when={'e', 'j', 'r', 'd', 't', 'c'}), + ), + migrations.AlterField( + model_name='efaceform', + name='status', + field=django_fsm.FSMField(choices=[('draft', 'Draft'), ('submitted', 'Submitted'), ('rejected', 'Rejected'), ('pending', 'Pending (in vision)'), ('approved', 'Approved'), ('closed', 'Closed (rejected)'), ('cancelled', 'Cancelled')], default='draft', max_length=20, verbose_name='Status'), + ), + ] diff --git a/src/etools/applications/eface/migrations/0004_auto_20210714_0854.py b/src/etools/applications/eface/migrations/0004_auto_20210714_0854.py new file mode 100644 index 000000000..3f81492bc --- /dev/null +++ b/src/etools/applications/eface/migrations/0004_auto_20210714_0854.py @@ -0,0 +1,35 @@ +# Generated by Django 2.2.20 on 2021-07-14 08:54 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('eface', '0003_auto_20210712_1328'), + ] + + operations = [ + migrations.RemoveField( + model_name='efaceform', + name='currency', + ), + migrations.AddField( + model_name='efaceform', + name='submitted_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='efaceform', + name='submitted_by_unicef_date', + field=models.DateField(blank=True, null=True), + ), + migrations.AlterField( + model_name='formactivity', + name='form', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activities', to='eface.EFaceForm'), + ), + ] diff --git a/src/etools/applications/eface/migrations/0005_auto_20210722_0826.py b/src/etools/applications/eface/migrations/0005_auto_20210722_0826.py new file mode 100644 index 000000000..b61a9df24 --- /dev/null +++ b/src/etools/applications/eface/migrations/0005_auto_20210722_0826.py @@ -0,0 +1,68 @@ +# Generated by Django 2.2.20 on 2021-07-22 08:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('eface', '0004_auto_20210714_0854'), + ] + + operations = [ + migrations.RenameField( + model_name='efaceform', + old_name='authorized_amount_date', + new_name='authorized_amount_date_start', + ), + migrations.RenameField( + model_name='efaceform', + old_name='requested_amount_date', + new_name='requested_amount_date_start', + ), + migrations.AddField( + model_name='efaceform', + name='authorized_amount_date_end', + field=models.DateField(blank=True, null=True), + ), + migrations.AddField( + model_name='efaceform', + name='reporting_actual_project_expenditure', + field=models.DecimalField(decimal_places=2, default=0, max_digits=20, verbose_name='Reporting - Actual Project Expenditure'), + ), + migrations.AddField( + model_name='efaceform', + name='reporting_authorized_amount', + field=models.DecimalField(decimal_places=2, default=0, max_digits=20, verbose_name='Reporting - Authorized Amount'), + ), + migrations.AddField( + model_name='efaceform', + name='reporting_balance', + field=models.DecimalField(decimal_places=2, default=0, max_digits=20, verbose_name='Reporting - Balance'), + ), + migrations.AddField( + model_name='efaceform', + name='reporting_expenditures_accepted_by_agency', + field=models.DecimalField(decimal_places=2, default=0, max_digits=20, verbose_name='Reporting - Expenditures Accepted by Agency'), + ), + migrations.AddField( + model_name='efaceform', + name='requested_amount', + field=models.DecimalField(decimal_places=2, default=0, max_digits=20, verbose_name='Requests - Amount'), + ), + migrations.AddField( + model_name='efaceform', + name='requested_amount_date_end', + field=models.DateField(blank=True, null=True), + ), + migrations.AddField( + model_name='efaceform', + name='requested_authorized_amount', + field=models.DecimalField(decimal_places=2, default=0, max_digits=20, verbose_name='Requests - Authorized Amount'), + ), + migrations.AddField( + model_name='efaceform', + name='requested_outstanding_authorized_amount', + field=models.DecimalField(decimal_places=2, default=0, max_digits=20, verbose_name='Requests Outstanding Authorized Amount'), + ), + ] diff --git a/src/etools/applications/eface/migrations/0006_auto_20210803_0738.py b/src/etools/applications/eface/migrations/0006_auto_20210803_0738.py new file mode 100644 index 000000000..cf6bd4179 --- /dev/null +++ b/src/etools/applications/eface/migrations/0006_auto_20210803_0738.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.20 on 2021-08-03 07:38 + +from django.db import migrations +import unicef_djangolib.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('eface', '0005_auto_20210722_0826'), + ] + + operations = [ + migrations.RemoveField( + model_name='efaceform', + name='title', + ), + migrations.AddField( + model_name='efaceform', + name='currency', + field=unicef_djangolib.fields.CurrencyField(blank=True, choices=[('GIP', 'GIP'), ('KPW', 'KPW'), ('XEU', 'XEU'), ('BHD', 'BHD'), ('BIF', 'BIF'), ('BMD', 'BMD'), ('BSD', 'BSD'), ('AFN', 'AFN'), ('ALL', 'ALL'), ('AMD', 'AMD'), ('AUD', 'AUD'), ('AZN', 'AZN'), ('BAM', 'BAM'), ('BBD', 'BBD'), ('BDT', 'BDT'), ('BZD', 'BZD'), ('CUP1', 'CUP1'), ('BTN', 'BTN'), ('ZWL', 'ZWL'), ('AWG', 'AWG'), ('CUC', 'CUC'), ('VEF01', 'VEF01'), ('BND', 'BND'), ('BRL', 'BRL'), ('ARS', 'ARS'), ('ETB', 'ETB'), ('EUR', 'EUR'), ('FJD', 'FJD'), ('GBP', 'GBP'), ('GEL', 'GEL'), ('GHS', 'GHS'), ('GNF', 'GNF'), ('GTQ', 'GTQ'), ('GYD', 'GYD'), ('HNL', 'HNL'), ('CAD', 'CAD'), ('CDF', 'CDF'), ('CLP', 'CLP'), ('CNY', 'CNY'), ('COP', 'COP'), ('CRC', 'CRC'), ('CUP', 'CUP'), ('CVE', 'CVE'), ('DJF', 'DJF'), ('DKK', 'DKK'), ('DOP', 'DOP'), ('DZD', 'DZD'), ('EGP', 'EGP'), ('HRK', 'HRK'), ('LVL', 'LVL'), ('LYD', 'LYD'), ('MAD', 'MAD'), ('MGA', 'MGA'), ('MKD', 'MKD'), ('KWD', 'KWD'), ('KYD', 'KYD'), ('LBP', 'LBP'), ('LKR', 'LKR'), ('MDL', 'MDL'), ('KZT', 'KZT'), ('LRD', 'LRD'), ('BOB', 'BOB'), ('HKD', 'HKD'), ('CHF', 'CHF'), ('KES', 'KES'), ('MYR', 'MYR'), ('NGN', 'NGN'), ('KMF', 'KMF'), ('SCR', 'SCR'), ('SEK', 'SEK'), ('TTD', 'TTD'), ('PKR', 'PKR'), ('NIO', 'NIO'), ('RWF', 'RWF'), ('BWP', 'BWP'), ('JMD', 'JMD'), ('TJS', 'TJS'), ('UYU', 'UYU'), ('RON', 'RON'), ('PYG', 'PYG'), ('SYP', 'SYP'), ('LAK', 'LAK'), ('ERN', 'ERN'), ('SLL', 'SLL'), ('PLN', 'PLN'), ('JOD', 'JOD'), ('ILS', 'ILS'), ('AED', 'AED'), ('NPR', 'NPR'), ('NZD', 'NZD'), ('SGD', 'SGD'), ('JPY', 'JPY'), ('PAB', 'PAB'), ('ZMW', 'ZMW'), ('CZK', 'CZK'), ('SOS', 'SOS'), ('LTL', 'LTL'), ('KGS', 'KGS'), ('SHP', 'SHP'), ('BGN', 'BGN'), ('TOP', 'TOP'), ('MVR', 'MVR'), ('VEF02', 'VEF02'), ('TMT', 'TMT'), ('GMD', 'GMD'), ('MZN', 'MZN'), ('RSD', 'RSD'), ('MWK', 'MWK'), ('PGK', 'PGK'), ('MXN', 'MXN'), ('XAF', 'XAF'), ('VND', 'VND'), ('INR', 'INR'), ('NOK', 'NOK'), ('XPF', 'XPF'), ('SSP', 'SSP'), ('IQD', 'IQD'), ('SRD', 'SRD'), ('SAR', 'SAR'), ('XCD', 'XCD'), ('IRR', 'IRR'), ('KPW01', 'KPW01'), ('HTG', 'HTG'), ('IDR', 'IDR'), ('XOF', 'XOF'), ('ISK', 'ISK'), ('ANG', 'ANG'), ('NAD', 'NAD'), ('MMK', 'MMK'), ('STD', 'STD'), ('VUV', 'VUV'), ('LSL', 'LSL'), ('SVC', 'SVC'), ('KHR', 'KHR'), ('SZL', 'SZL'), ('RUB', 'RUB'), ('UAH', 'UAH'), ('UGX', 'UGX'), ('THB', 'THB'), ('AOA', 'AOA'), ('YER', 'YER'), ('USD', 'USD'), ('UZS', 'UZS'), ('OMR', 'OMR'), ('SBD', 'SBD'), ('TZS', 'TZS'), ('SDG', 'SDG'), ('WST', 'WST'), ('QAR', 'QAR'), ('MOP', 'MOP'), ('MRU', 'MRU'), ('VEF', 'VEF'), ('TRY', 'TRY'), ('ZAR', 'ZAR'), ('HUF', 'HUF'), ('MUR', 'MUR'), ('PHP', 'PHP'), ('BYN', 'BYN'), ('KRW', 'KRW'), ('TND', 'TND'), ('MNT', 'MNT'), ('PEN', 'PEN')], default='', max_length=5, verbose_name='Currency'), + ), + ] diff --git a/src/etools/applications/eface/migrations/__init__.py b/src/etools/applications/eface/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/etools/applications/eface/models.py b/src/etools/applications/eface/models.py new file mode 100644 index 000000000..b3607e854 --- /dev/null +++ b/src/etools/applications/eface/models.py @@ -0,0 +1,318 @@ +from django.conf import settings +from django.db import connection, models +from django.db.models import Sum +from django.db.models.base import ModelBase +from django.utils import timezone +from django.utils.translation import ugettext_lazy as _ + +from django_fsm import FSMField, transition +from model_utils import Choices +from model_utils.fields import MonitorField +from model_utils.models import TimeStampedModel +from unicef_djangolib.fields import CurrencyField + +from etools.applications.core.permissions import import_permissions +from etools.applications.eface.transition_permissions import ( + user_is_partner_focal_point_permission, + user_is_programme_officer_permission, +) +from etools.applications.field_monitoring.planning.mixins import ProtectUnknownTransitionsMeta +from etools.libraries.djangolib.models import SoftDeleteMixin + + +class EFaceFormMeta(ProtectUnknownTransitionsMeta, ModelBase): + pass + + +class EFaceForm( + SoftDeleteMixin, + TimeStampedModel, + metaclass=EFaceFormMeta +): + """ + programme code & title + project code & title + + responsible officers + The focal points from the intervention will most likely be the responsible officers. skip for now + """ + REQUEST_TYPE_CHOICES = ( + ('dct', _('Direct Cash Transfer')), + ('rmb', _('Reimbursement')), + ('dp', _('Direct Payment')), + ) + + STATUSES = Choices( + ('draft', _('Draft')), + ('submitted', _('Submitted')), + ('rejected', _('Rejected')), + ('pending', _('Pending (in vision)')), + ('approved', _('Approved')), + ('closed', _('Closed (rejected)')), + ('cancelled', _('Cancelled')), + ) + TRANSITION_SIDE_EFFECTS = { + } + AUTO_TRANSITIONS = {} + + reference_number_year = models.IntegerField() + reference_number = models.CharField( + verbose_name=_('Reference Number'), + max_length=64, + blank=True, + null=True, + unique=True, + ) + + intervention = models.ForeignKey('partners.Intervention', verbose_name=_('Intervention'), on_delete=models.PROTECT) + currency = CurrencyField(verbose_name=_('Currency'), null=False, default='') + + request_type = models.CharField(choices=REQUEST_TYPE_CHOICES, max_length=3) + + # certification + request_represents_expenditures = models.BooleanField(default=False) + expenditures_disbursed = models.BooleanField(default=False) + + notes = models.TextField(blank=True) + + submitted_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True) + submitted_by_unicef_date = models.DateField(blank=True, null=True) + + authorized_amount_date_start = models.DateField(blank=True, null=True) + authorized_amount_date_end = models.DateField(blank=True, null=True) + requested_amount_date_start = models.DateField(blank=True, null=True) + requested_amount_date_end = models.DateField(blank=True, null=True) + + status = FSMField(verbose_name=_('Status'), max_length=20, choices=STATUSES, default=STATUSES.draft) + + # status dates + date_submitted = MonitorField(monitor='status', when=STATUSES.submitted, blank=True, null=True) + date_rejected = MonitorField(monitor='status', when=STATUSES.rejected, blank=True, null=True) + date_pending = MonitorField(monitor='status', when=STATUSES.pending, blank=True, null=True) + date_approved = MonitorField(monitor='status', when=STATUSES.approved, blank=True, null=True) + date_closed = MonitorField(monitor='status', when=STATUSES.closed, blank=True, null=True) + date_cancelled = MonitorField(monitor='status', when=STATUSES.cancelled, blank=True, null=True) + + rejection_reason = models.TextField(blank=True) + transaction_rejection_reason = models.TextField(blank=True) + cancel_reason = models.TextField(blank=True) + + # activity totals + reporting_authorized_amount = models.DecimalField( + max_digits=20, + decimal_places=2, + default=0, + verbose_name=_('Reporting - Authorized Amount') + ) + reporting_actual_project_expenditure = models.DecimalField( + max_digits=20, + decimal_places=2, + default=0, + verbose_name=_('Reporting - Actual Project Expenditure') + ) + reporting_expenditures_accepted_by_agency = models.DecimalField( + max_digits=20, + decimal_places=2, + default=0, + verbose_name=_('Reporting - Expenditures Accepted by Agency') + ) + reporting_balance = models.DecimalField( + max_digits=20, + decimal_places=2, + default=0, + verbose_name=_('Reporting - Balance') + ) + requested_amount = models.DecimalField( + max_digits=20, + decimal_places=2, + default=0, + verbose_name=_('Requests - Amount') + ) + requested_authorized_amount = models.DecimalField( + max_digits=20, + decimal_places=2, + default=0, + verbose_name=_('Requests - Authorized Amount') + ) + requested_outstanding_authorized_amount = models.DecimalField( + max_digits=20, + decimal_places=2, + default=0, + verbose_name=_('Requests Outstanding Authorized Amount') + ) + + def get_reference_number(self): + number = '{country}/{type}{year}{id}'.format( + country=connection.tenant.country_short_code or '', + type=self.request_type, + year=self.reference_number_year, + id=self.id + ) + return number + + def save(self, **kwargs): + if not self.reference_number_year: + self.reference_number_year = timezone.now().year + + if not self.currency: + self.currency = self.intervention.planned_budget.currency + + if not self.reference_number: + # to create a reference number we need a pk + super().save() + self.reference_number = self.get_reference_number() + + super().save() + + def update_totals(self): + aggregates = self.activities.aggregate( + reporting_authorized_amount=Sum('reporting_authorized_amount'), + reporting_actual_project_expenditure=Sum('reporting_actual_project_expenditure'), + reporting_expenditures_accepted_by_agency=Sum('reporting_expenditures_accepted_by_agency'), + reporting_balance=Sum('reporting_balance'), + requested_amount=Sum('requested_amount'), + requested_authorized_amount=Sum('requested_authorized_amount'), + requested_outstanding_authorized_amount=Sum('requested_outstanding_authorized_amount'), + ) + self.reporting_authorized_amount = aggregates['reporting_authorized_amount'] or 0 + self.reporting_actual_project_expenditure = aggregates['reporting_actual_project_expenditure'] or 0 + self.reporting_expenditures_accepted_by_agency = aggregates['reporting_expenditures_accepted_by_agency'] or 0 + self.reporting_balance = aggregates['reporting_balance'] or 0 + self.requested_amount = aggregates['requested_amount'] or 0 + self.requested_authorized_amount = aggregates['requested_authorized_amount'] or 0 + self.requested_outstanding_authorized_amount = aggregates['requested_outstanding_authorized_amount'] or 0 + self.save() + + @classmethod + def permission_structure(cls): + permissions = import_permissions(cls.__name__) + return permissions + + @transition( + field=status, source=[STATUSES.draft, STATUSES.rejected], target=STATUSES.submitted, + permission=user_is_partner_focal_point_permission, + ) + def submit(self): + pass + + @transition( + field=status, source=STATUSES.submitted, target=STATUSES.pending, + permission=user_is_programme_officer_permission, + ) + def send_to_vision(self): + pass + + @transition( + field=status, source=STATUSES.submitted, target=STATUSES.rejected, + permission=user_is_programme_officer_permission, + ) + def reject(self): + pass + + # todo: permissions - vision only; for poc manual transition will be available by programme officer + @transition( + field=status, source=STATUSES.pending, target=STATUSES.approved, + permission=user_is_programme_officer_permission, + ) + def transaction_approve(self): + pass + + # todo: permissions - vision only; for poc manual transition will be available by programme officer + @transition( + field=status, source=STATUSES.pending, target=STATUSES.closed, + permission=user_is_programme_officer_permission, + ) + def transaction_reject(self): + pass + + @transition( + field=status, + source=[ + STATUSES.draft, + STATUSES.rejected, + ], + target=STATUSES.cancelled, + permission=user_is_partner_focal_point_permission, + ) + def cancel(self): + pass + + +class FormActivity(models.Model): + KIND_CHOICES = Choices( + ('activity', _('Activity')), + ('eepm', _('EEPM')), + ('custom', _('Custom')), + ) + EEPM_CHOICES = Choices( + ('in_country', _('In-country management and support staff prorated to their contribution to the programme ' + '(representation, planning, coordination, logistics, administration, finance)')), + ('operational', _('Operational costs prorated to their contribution to the programme ' + '(office space, equipment, office supplies, maintenance)')), + ('planning', _('Planning, monitoring, evaluation and communication, ' + 'prorated to their contribution to the programme (venue, travels, etc.)')), + ) + + form = models.ForeignKey(EFaceForm, on_delete=models.CASCADE, related_name='activities') + kind = models.CharField(choices=KIND_CHOICES, max_length=8) + + pd_activity = models.ForeignKey('reports.InterventionActivity', blank=True, null=True, on_delete=models.SET_NULL) + eepm_kind = models.CharField(choices=EEPM_CHOICES, max_length=15, blank=True) + description = models.TextField(blank=True) + + coding = models.CharField(max_length=100, blank=True) + + reporting_authorized_amount = models.DecimalField( + max_digits=20, + decimal_places=2, + default=0, + verbose_name=_('Reporting - Authorized Amount') + ) + reporting_actual_project_expenditure = models.DecimalField( + max_digits=20, + decimal_places=2, + default=0, + verbose_name=_('Reporting - Actual Project Expenditure') + ) + reporting_expenditures_accepted_by_agency = models.DecimalField( + max_digits=20, + decimal_places=2, + default=0, + verbose_name=_('Reporting - Expenditures Accepted by Agency') + ) + reporting_balance = models.DecimalField( + max_digits=20, + decimal_places=2, + default=0, + verbose_name=_('Reporting - Balance') + ) + requested_amount = models.DecimalField( + max_digits=20, + decimal_places=2, + default=0, + verbose_name=_('Requests - Amount') + ) + requested_authorized_amount = models.DecimalField( + max_digits=20, + decimal_places=2, + default=0, + verbose_name=_('Requests - Authorized Amount') + ) + requested_outstanding_authorized_amount = models.DecimalField( + max_digits=20, + decimal_places=2, + default=0, + verbose_name=_('Requests Outstanding Authorized Amount') + ) + + def __str__(self): + return f'{self.form} - {self.description}' + + def save(self, **kwargs): + if self.reporting_authorized_amount and self.reporting_expenditures_accepted_by_agency: + self.reporting_balance = self.reporting_authorized_amount - self.reporting_expenditures_accepted_by_agency + + if self.requested_authorized_amount: + self.requested_outstanding_authorized_amount = self.reporting_balance + self.requested_authorized_amount + + super().save(**kwargs) diff --git a/src/etools/applications/eface/permissions.py b/src/etools/applications/eface/permissions.py new file mode 100644 index 000000000..4f4a54554 --- /dev/null +++ b/src/etools/applications/eface/permissions.py @@ -0,0 +1,45 @@ +from rest_framework.permissions import BasePermission + +from etools.applications.eface.models import EFaceForm +from etools.applications.eface.validation.permissions import EFaceFormPermissions + + +def eface_form_field_is_editable_permission(field): + """ + Check the user is able to edit selected eface form field. + View should either implement get_root_object to return instance of EFaceForm (if view is nested), + or return EFaceForm instance via get_object (can be used for detail actions). + """ + + class FieldPermission(BasePermission): + def has_permission(self, request, view): + if not view.kwargs: + # This is needed for swagger to be able to build the correct structure + # https://github.com/unicef/etools/pull/2540/files#r356446025 + return True + + if hasattr(view, 'get_root_object'): + instance = view.get_root_object() + else: + instance = view.get_object() + + ps = EFaceForm.permission_structure() + permissions = EFaceFormPermissions( + user=request.user, instance=instance, permission_structure=ps + ) + return permissions.get_permissions()['edit'].get(field) + + def has_object_permission(self, request, view, obj): + return True + + return FieldPermission + + +class IsPartnerFocalPointPermission(BasePermission): + def has_object_permission(self, request, view, obj): + return obj.intervention.partner_focal_points.filter(email=request.user.email).exists() + + +class IsUNICEFFocalPointPermission(BasePermission): + def has_object_permission(self, request, view, obj): + return obj.intervention.unicef_focal_points.filter(email=request.user.email).exists() diff --git a/src/etools/applications/eface/serializers.py b/src/etools/applications/eface/serializers.py new file mode 100644 index 000000000..658790ab9 --- /dev/null +++ b/src/etools/applications/eface/serializers.py @@ -0,0 +1,281 @@ +import datetime + +from django.db import transaction +from django.utils.translation import ugettext_lazy as _ + +from rest_framework import serializers +from rest_framework.exceptions import ValidationError +from unicef_restlib.fields import SeparatedReadWriteField + +from etools.applications.eface.models import EFaceForm, FormActivity +from etools.applications.eface.validation.permissions import EFaceFormPermissions +from etools.applications.partners.serializers.interventions_v3 import InterventionDetailSerializer +from etools.applications.users.serializers_v3 import MinimalUserSerializer + + +class CustomInterventionDetailSerializer(InterventionDetailSerializer): + # hack to exclude extra fields + permissions = None + available_actions = None + + class Meta(InterventionDetailSerializer.Meta): + fields = [ + field for field in InterventionDetailSerializer.Meta.fields + if field not in ['permissions', 'available_actions'] + ] + + +class MonthYearDateField(serializers.Field): + default_error_messages = { + 'unknown_type': _('Unknown input type. String required.'), + 'invalid': _('Date has wrong format. Required format: mm/yyyy.'), + } + + def to_internal_value(self, data): + if isinstance(data, datetime.date): + return data + + if not isinstance(data, str): + self.fail('unknown_type') + + try: + value = datetime.datetime.strptime(data, '%m/%Y') + except ValueError: + self.fail('invalid') + + return value.replace(day=1).date() + + def to_representation(self, value): + return value.strftime('%m/%Y') + + +class FormUserSerializer(MinimalUserSerializer): + title = serializers.CharField(source='profile.job_title') + + class Meta(MinimalUserSerializer.Meta): + fields = MinimalUserSerializer.Meta.fields + ( + 'title', + ) + + +class EFaceFormListSerializer(serializers.ModelSerializer): + authorized_amount_date_start = MonthYearDateField(required=False) + authorized_amount_date_end = MonthYearDateField(required=False) + requested_amount_date_start = MonthYearDateField(required=False) + requested_amount_date_end = MonthYearDateField(required=False) + intervention_reference_number = serializers.ReadOnlyField(source='intervention.reference_number') + + class Meta: + model = EFaceForm + fields = ( + 'id', + 'reference_number', + 'status', + 'intervention', + 'intervention_reference_number', + 'currency', + 'request_type', + 'request_represents_expenditures', + 'expenditures_disbursed', + 'notes', + 'submitted_by', + 'submitted_by_unicef_date', + 'authorized_amount_date_start', + 'authorized_amount_date_end', + 'requested_amount_date_start', + 'requested_amount_date_end', + 'date_submitted', + 'date_rejected', + 'date_pending', + 'date_approved', + 'date_closed', + 'date_cancelled', + 'rejection_reason', + 'transaction_rejection_reason', + 'cancel_reason', + 'reporting_authorized_amount', + 'reporting_actual_project_expenditure', + 'reporting_expenditures_accepted_by_agency', + 'reporting_balance', + 'requested_amount', + 'requested_authorized_amount', + 'requested_outstanding_authorized_amount', + ) + read_only_fields = ( + 'reporting_authorized_amount', + 'reporting_actual_project_expenditure', + 'reporting_expenditures_accepted_by_agency', + 'reporting_balance', + 'requested_amount', + 'requested_authorized_amount', + 'requested_outstanding_authorized_amount', + ) + + +class FormActivitySerializer(serializers.ModelSerializer): + default_error_messages = { + 'pd_activity_required': _('PD Activity is required'), + 'eepm_kind_required': _('EEPM kind is required'), + 'description_required': _('Description is required'), + } + id = serializers.IntegerField(required=False) + + class Meta: + model = FormActivity + fields = ( + 'id', + 'kind', + 'pd_activity', + 'eepm_kind', + 'description', + 'coding', + 'reporting_authorized_amount', + 'reporting_actual_project_expenditure', + 'reporting_expenditures_accepted_by_agency', + 'reporting_balance', + 'requested_amount', + 'requested_authorized_amount', + 'requested_outstanding_authorized_amount', + ) + read_only_fields = ( + 'reporting_balance', + 'requested_outstanding_authorized_amount', + ) + + def validate(self, validated_data): + validated_data = super().validate(validated_data) + if 'kind' in validated_data: + kind = validated_data['kind'] + if kind == FormActivity.KIND_CHOICES.activity: + if 'pd_activity' not in validated_data: + self.fail('pd_activity_required') + elif kind == FormActivity.KIND_CHOICES.eepm: + if 'eepm_kind' not in validated_data: + self.fail('eepm_kind_required') + elif kind == FormActivity.KIND_CHOICES.custom: + if 'description' not in validated_data: + self.fail('description_required') + + return validated_data + + +class EFaceFormSerializer(EFaceFormListSerializer): + actions_available = serializers.SerializerMethodField() + permissions = serializers.SerializerMethodField() + intervention = SeparatedReadWriteField(read_field=CustomInterventionDetailSerializer()) + submitted_by = SeparatedReadWriteField(read_field=FormUserSerializer()) + activities = SeparatedReadWriteField( + read_field=FormActivitySerializer(many=True), + write_field=serializers.JSONField(required=False), # we validate activities later + ) + + class Meta(EFaceFormListSerializer.Meta): + fields = EFaceFormListSerializer.Meta.fields + ( + 'actions_available', + 'permissions', + 'activities', + ) + + def get_permissions(self, obj): + user = self.context['request'].user + ps = EFaceForm.permission_structure() + permissions = EFaceFormPermissions( + user=user, + instance=self.instance, + permission_structure=ps, + ) + return permissions.get_permissions() + + def get_actions_available(self, obj): + default_ordering = [ + 'submit', + 'send_to_vision', + 'reject', + 'transaction_approve', + 'transaction_reject', + 'cancel', + ] + available_actions = [ + # todo: + # "download_comments", + # "export", + # "generate_pdf", + ] + user = self.context['request'].user + + if obj.status == EFaceForm.STATUSES.draft: + if self._is_partner_user(obj, user): + available_actions.append('submit') + available_actions.append('cancel') + if self._is_programme_officer(obj, user): + available_actions.append('cancel') + + if obj.status == EFaceForm.STATUSES.rejected: + if self._is_programme_officer(obj, user): + available_actions.append('submit') + + if obj.status == EFaceForm.STATUSES.submitted: + if self._is_programme_officer(obj, user): + available_actions.append('send_to_vision') + available_actions.append('reject') + + # temporary actions + if obj.status == EFaceForm.STATUSES.pending: + if self._is_programme_officer(obj, user): + available_actions.append('transaction_approve') + available_actions.append('transaction_reject') + + if obj.status in [ + EFaceForm.STATUSES.draft, + EFaceForm.STATUSES.rejected, + ]: + if self._is_programme_officer(obj, user) or self._is_partner_user(obj, user): + available_actions.append('cancel') + + return [action for action in default_ordering if action in available_actions] + + # todo: make cached + def _is_partner_user(self, obj, user): + # todo: update on responsible officers implementation + return obj.intervention.partner_focal_points.filter(email=user.email).exists() + + # todo: make cached + def _is_programme_officer(self, obj, user): + return obj.intervention.unicef_focal_points.filter(email=user.email).exists() + + def set_activities(self, instance, activities): + if activities is None: + return + + updated_pks = [] + for i, item in enumerate(activities): + item_instance = FormActivity.objects.filter(pk=item.get('id')).first() + if item_instance: + serializer = FormActivitySerializer(data=item, instance=item_instance, partial=self.partial) + else: + serializer = FormActivitySerializer(data=item) + if not serializer.is_valid(): + raise ValidationError({'activities': {i: serializer.errors}}) + + updated_pks.append(serializer.save(form=instance).pk) + + # cleanup, remove unused options + removed_activities = instance.activities.exclude(pk__in=updated_pks).delete() + + # calculate totals if something changed + if removed_activities or updated_pks: + instance.update_totals() + + @transaction.atomic + def create(self, validated_data): + activities = validated_data.pop('activities', None) + instance = super().create(validated_data) + self.set_activities(instance, activities) + return instance + + @transaction.atomic + def update(self, instance, validated_data): + activities = validated_data.pop('activities', None) + instance = super().update(instance, validated_data) + self.set_activities(instance, activities) + return instance diff --git a/src/etools/applications/eface/tests/__init__.py b/src/etools/applications/eface/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/etools/applications/eface/tests/factories.py b/src/etools/applications/eface/tests/factories.py new file mode 100644 index 000000000..0dd5ad7f0 --- /dev/null +++ b/src/etools/applications/eface/tests/factories.py @@ -0,0 +1,21 @@ +import factory.fuzzy + +from etools.applications.eface.models import EFaceForm, FormActivity +from etools.applications.partners.tests.factories import InterventionFactory + + +class EFaceFormFactory(factory.django.DjangoModelFactory): + class Meta: + model = EFaceForm + + intervention = factory.SubFactory(InterventionFactory) + request_type = factory.fuzzy.FuzzyChoice(dict(EFaceForm.REQUEST_TYPE_CHOICES).keys()) + + +class FormActivityFactory(factory.django.DjangoModelFactory): + class Meta: + model = FormActivity + + form = factory.SubFactory(EFaceFormFactory) + kind = 'custom' + description = factory.fuzzy.FuzzyText() diff --git a/src/etools/applications/eface/tests/test_views.py b/src/etools/applications/eface/tests/test_views.py new file mode 100644 index 000000000..06f42c348 --- /dev/null +++ b/src/etools/applications/eface/tests/test_views.py @@ -0,0 +1,182 @@ +from django.utils import timezone + +from rest_framework import status + +from etools.applications.eface.tests.factories import EFaceFormFactory, FormActivityFactory +from etools.applications.field_monitoring.tests.base import APIViewSetTestCase +from etools.applications.partners.tests.factories import ( + InterventionFactory, + InterventionResultLinkFactory, + PartnerStaffFactory, +) +from etools.applications.reports.models import ResultType +from etools.applications.reports.tests.factories import InterventionActivityFactory, ResultFactory +from etools.applications.users.tests.factories import UserFactory + + +class TestFormsView(APIViewSetTestCase): + base_view = 'eface_v1:forms' + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.unicef_user = UserFactory() + + def test_list(self): + forms = [ + EFaceFormFactory(), + EFaceFormFactory(), + ] + self._test_list(self.unicef_user, forms) + + def test_partner_list(self): + form1 = EFaceFormFactory() + EFaceFormFactory() + staff_member = PartnerStaffFactory() + form1.intervention.partner_focal_points.add(staff_member) + self._test_list(staff_member.user, [form1]) + + def test_detail_pd_activities_presented(self): + form = EFaceFormFactory() + activity = InterventionActivityFactory( + result__result_link=InterventionResultLinkFactory( + intervention=form.intervention, + cp_output=ResultFactory(result_type__name=ResultType.OUTPUT), + ), + ) + response = self._test_retrieve(self.unicef_user, form) + self.assertEqual( + response.data['intervention']['result_links'][0]['ll_results'][0]['activities'][0]['id'], + activity.id + ) + + def test_detail_activities_presented(self): + form = EFaceFormFactory() + activity = FormActivityFactory(form=form) + response = self._test_retrieve(self.unicef_user, form) + self.assertIn('activities', response.data) + self.assertEqual(activity.id, response.data['activities'][0]['id']) + + def test_detail_user_title_presented(self): + submitted_by = UserFactory() + form = EFaceFormFactory(submitted_by=submitted_by) + response = self._test_retrieve(self.unicef_user, form) + self.assertEqual(response.data['submitted_by']['title'], submitted_by.profile.job_title) + + def test_update(self): + form = EFaceFormFactory() + staff_member = PartnerStaffFactory() + form.intervention.partner_focal_points.add(staff_member) + response = self._test_update(staff_member.user, form, {'currency': 'AUD'}) + self.assertEqual(response.data['currency'], 'AUD') + form.refresh_from_db() + self.assertEqual(form.currency, 'AUD') + + def test_create(self): + staff_member = PartnerStaffFactory() + self._test_create( + staff_member.user, + { + 'intervention': InterventionFactory(agreement__partner=staff_member.partner).pk, + 'request_type': 'dct', + } + ) + + def test_flow(self): + form = EFaceFormFactory() + staff_member = PartnerStaffFactory() + form.intervention.partner_focal_points.add(staff_member) + form.intervention.unicef_focal_points.add(self.unicef_user) + + def goto(next_status, user, extra_data=None): + data = { + 'status': next_status + } + if extra_data: + data.update(extra_data) + + return self._test_update(user, form, data) + + response = goto('submitted', staff_member.user) + self.assertEqual(response.data['status'], 'submitted') + response = goto('rejected', self.unicef_user) + self.assertEqual(response.data['status'], 'rejected') + response = goto('submitted', staff_member.user) + self.assertEqual(response.data['status'], 'submitted') + response = goto('pending', self.unicef_user) + self.assertEqual(response.data['status'], 'pending') + response = goto('approved', self.unicef_user) + self.assertEqual(response.data['status'], 'approved') + + def test_cancel(self): + form = EFaceFormFactory() + staff_member = PartnerStaffFactory() + form.intervention.partner_focal_points.add(staff_member) + form.intervention.unicef_focal_points.add(self.unicef_user) + response = self._test_update(staff_member.user, form, {'status': 'cancelled', 'cancel_reason': 'test'}) + self.assertEqual(response.data['status'], 'cancelled') + + def test_bad_transition(self): + form = EFaceFormFactory() + form.intervention.unicef_focal_points.add(self.unicef_user) + self._test_update(self.unicef_user, form, {'status': 'finalized'}, expected_status=400) + + def test_month_year_input(self): + form = EFaceFormFactory() + staff_member = PartnerStaffFactory() + form.intervention.partner_focal_points.add(staff_member) + now = timezone.now().date() + response = self._test_update(staff_member.user, form, {'authorized_amount_date_start': now.strftime('%m/%Y')}) + self.assertEqual(response.data['authorized_amount_date_start'], now.strftime('%m/%Y')) + form.refresh_from_db() + self.assertEqual(form.authorized_amount_date_start, now.replace(day=1)) + + def test_change_activities(self): + form = EFaceFormFactory() + staff_member = PartnerStaffFactory() + form.intervention.partner_focal_points.add(staff_member) + activity = FormActivityFactory(form=form, kind='custom') + response = self._test_update( + staff_member.user, form, + { + 'activities': [ + { + 'kind': 'custom', + 'description': 'test', + }, + { + 'id': activity.id, + 'description': 'new', + }, + { + 'kind': 'activity', + 'pd_activity': InterventionActivityFactory( + result__result_link=InterventionResultLinkFactory( + intervention=form.intervention, + cp_output=ResultFactory(result_type__name=ResultType.OUTPUT), + ), + ).id, + } + ], + } + ) + activity.refresh_from_db() + self.assertEqual(activity.description, 'new') + self.assertEqual(len(response.data['activities']), 3) + self.assertEqual(form.activities.count(), 3) + + third_activity = form.activities.filter(kind='activity').first() + self.assertIsNotNone(third_activity.pd_activity) + + +class UsersAPITestCase(APIViewSetTestCase): + base_view = 'eface_v1:users' + + def test_list(self): + user1 = UserFactory(is_staff=True) + user = UserFactory(is_staff=True) + UserFactory() + self._test_list(user, [user1, user]) + + def test_not_staff(self): + self._test_list(UserFactory(is_staff=False), expected_status=status.HTTP_403_FORBIDDEN) diff --git a/src/etools/applications/eface/transition_permissions.py b/src/etools/applications/eface/transition_permissions.py new file mode 100644 index 000000000..d8f7ae946 --- /dev/null +++ b/src/etools/applications/eface/transition_permissions.py @@ -0,0 +1,6 @@ +def user_is_partner_focal_point_permission(form, user): + return form.intervention.partner_focal_points.filter(email=user.email).exists() + + +def user_is_programme_officer_permission(form, user): + return form.intervention.unicef_focal_points.filter(email=user.email).exists() diff --git a/src/etools/applications/eface/urls.py b/src/etools/applications/eface/urls.py new file mode 100644 index 000000000..4f314dffa --- /dev/null +++ b/src/etools/applications/eface/urls.py @@ -0,0 +1,14 @@ +from django.conf.urls import include, url + +from rest_framework_nested import routers + +from etools.applications.eface import views + +root_api = routers.SimpleRouter() +root_api.register(r'forms', views.EFaceFormsViewSet, basename='forms') +root_api.register(r'users', views.UsersViewSet, basename='users') + +app_name = 'eface_v1' +urlpatterns = [ + url(r'^', include(root_api.urls)), +] diff --git a/src/etools/applications/eface/validation/__init__.py b/src/etools/applications/eface/validation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/etools/applications/eface/validation/permissions.py b/src/etools/applications/eface/validation/permissions.py new file mode 100644 index 000000000..cae368f76 --- /dev/null +++ b/src/etools/applications/eface/validation/permissions.py @@ -0,0 +1,23 @@ +from etools.applications.partners.permissions import PMPPermissions + + +class EFaceFormPermissions(PMPPermissions): + MODEL_NAME = 'eface.EFaceForm' + EXTRA_FIELDS = [ + 'activities_reporting_expenditures_accepted_by_agency', + 'activities_requested_authorized_amount', + ] + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self.user_groups = set(self.user_groups) + self.user_groups.add('All Users') + + if self.instance.intervention.partner_focal_points.filter(email=self.user.email).exists(): + self.user_groups.add('Partner Focal Point') + + if self.instance.intervention.unicef_focal_points.filter(email=self.user.email).exists(): + self.user_groups.add('UNICEF Focal Point') + + self.condition_map = {} diff --git a/src/etools/applications/eface/validation/permissions_matrix.csv b/src/etools/applications/eface/validation/permissions_matrix.csv new file mode 100644 index 000000000..cedc1bbda --- /dev/null +++ b/src/etools/applications/eface/validation/permissions_matrix.csv @@ -0,0 +1,23 @@ +Field no,Field Name,Group,Condition,Status,Action,Allowed +,intervention,Partner Focal Point,,draft,edit,TRUE +,request_type,Partner Focal Point,,draft,edit,TRUE +,currency,Partner Focal Point,,draft,edit,TRUE +,currency,Partner Focal Point,,rejected,edit,TRUE +,authorized_amount_date,Partner Focal Point,,draft,edit,TRUE +,requested_amount_date,Partner Focal Point,,rejected,edit,TRUE +,authorized_amount_date,Partner Focal Point,,draft,edit,TRUE +,requested_amount_date,Partner Focal Point,,rejected,edit,TRUE +,activities,Partner Focal Point,,draft,edit,TRUE +,activities,Partner Focal Point,,rejected,edit,TRUE + +,activities_reporting_expenditures_accepted_by_agency,UNICEF Focal Point,,pending,edit,TRUE +,activities_requested_authorized_amount,UNICEF Focal Point,,pending,edit,TRUE +,request_represents_expenditures,UNICEF Focal Point,,pending,edit,TRUE +,expenditures_disbursed,UNICEF Focal Point,,pending,edit,TRUE +,notes,UNICEF Focal Point,,pending,edit,TRUE +,submitted_by,UNICEF Focal Point,,pending,edit,TRUE +,submitted_by_unicef_date,UNICEF Focal Point,,pending,edit,TRUE + +,rejection_reason,UNICEF Focal Point,,*,edit,TRUE +,transaction_rejection_reason,UNICEF Focal Point,,*,edit,TRUE +,cancel_reason,Partner Focal Point,,*,edit,TRUE diff --git a/src/etools/applications/eface/validation/validator.py b/src/etools/applications/eface/validation/validator.py new file mode 100644 index 000000000..4388197f3 --- /dev/null +++ b/src/etools/applications/eface/validation/validator.py @@ -0,0 +1,72 @@ +from django.utils.translation import ugettext_lazy as _ + +from etools_validator.exceptions import StateValidationError +from etools_validator.utils import check_required_fields, check_rigid_fields +from etools_validator.validation import CompleteValidation + +from etools.applications.eface.validation.permissions import EFaceFormPermissions + + +def cancel_reason_provided(form): + if not form.cancel_reason: + raise StateValidationError([_('Cancellation reason should be provided.')]) + return True + + +class EFaceFormValid(CompleteValidation): + VALIDATION_CLASS = 'eface.EFaceForm' + BASIC_VALIDATIONS = [] + VALID_ERRORS = {} + + PERMISSIONS_CLASS = EFaceFormPermissions + + def check_required_fields(self, form): + required_fields = [f for f in self.permissions['required'] if self.permissions['required'][f]] + required_valid, fields = check_required_fields(form, required_fields) + if not required_valid: + raise StateValidationError(['Required fields not completed in {}: {}'.format( + form.status, ', '.join(f for f in fields))]) + + def check_rigid_fields(self, form, related=False): + # this can be set if running in a task and old_instance is not set + if self.disable_rigid_check: + return + rigid_fields = [f for f in self.permissions['edit'] if not self.permissions['edit'][f]] + rigid_valid, field = check_rigid_fields(form, rigid_fields, related=related) + if not rigid_valid: + raise StateValidationError(['Cannot change fields while in {}: {}'.format(form.status, field)]) + + def state_draft_valid(self, instance, user=None): + self.check_required_fields(instance) + self.check_rigid_fields(instance, related=True) + # reject_reason_provided(instance, self.old_status) + return True + + def state_submitted_valid(self, instance, user=None): + self.check_required_fields(instance) + self.check_rigid_fields(instance, related=True) + return True + + def state_rejected_valid(self, instance, user=None): + self.check_required_fields(instance) + self.check_rigid_fields(instance, related=True) + return True + + def state_pending_valid(self, instance, user=None): + self.check_required_fields(instance) + self.check_rigid_fields(instance, related=True) + return True + + def state_approved_valid(self, instance, user=None): + self.check_required_fields(instance) + self.check_rigid_fields(instance, related=True) + return True + + def state_closed_valid(self, instance, user=None): + self.check_required_fields(instance) + self.check_rigid_fields(instance, related=True) + return True + + def state_cancelled_valid(self, instance, user=None): + cancel_reason_provided(instance) + return True diff --git a/src/etools/applications/eface/views.py b/src/etools/applications/eface/views.py new file mode 100644 index 000000000..7c66fb801 --- /dev/null +++ b/src/etools/applications/eface/views.py @@ -0,0 +1,118 @@ +from django.contrib.auth import get_user_model +from django.db import transaction + +from django_filters.rest_framework import DjangoFilterBackend +from etools_validator.mixins import ValidatorViewMixin +from rest_framework import mixins, status, viewsets +from rest_framework.exceptions import ValidationError +from rest_framework.filters import OrderingFilter, SearchFilter +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from unicef_restlib.pagination import DynamicPageNumberPagination +from unicef_restlib.views import MultiSerializerViewSetMixin, SafeTenantViewSetMixin + +from etools.applications.eface.filters import EFaceFormFilterSet +from etools.applications.eface.models import EFaceForm +from etools.applications.eface.permissions import IsPartnerFocalPointPermission, IsUNICEFFocalPointPermission +from etools.applications.eface.serializers import EFaceFormListSerializer, EFaceFormSerializer, FormUserSerializer +from etools.applications.eface.validation.validator import EFaceFormValid +from etools.applications.field_monitoring.permissions import IsEditAction, IsListAction, IsObjectAction, IsReadAction +from etools.applications.partners.permissions import UserIsPartnerStaffMemberPermission, UserIsStaffPermission + + +class EFaceBaseViewSet( + SafeTenantViewSetMixin, + MultiSerializerViewSetMixin, +): + pagination_class = DynamicPageNumberPagination + permission_classes = [IsAuthenticated, ] + + +class EFaceFormsViewSet( + EFaceBaseViewSet, + ValidatorViewMixin, + viewsets.ModelViewSet, +): + """ + Retrieve and Update Agreement. + """ + queryset = EFaceForm.objects.select_related('intervention').order_by('id') + serializer_class = EFaceFormSerializer + filter_backends = (DjangoFilterBackend, OrderingFilter, SearchFilter) + filter_class = EFaceFormFilterSet + ordering_fields = ( + 'created', 'status', + ) + search_fields = ('reference_number',) + pagination_class = DynamicPageNumberPagination + serializer_action_classes = { + 'list': EFaceFormListSerializer, + } + permission_classes = EFaceBaseViewSet.permission_classes + [ + IsReadAction | + (IsEditAction & IsListAction & UserIsPartnerStaffMemberPermission) | + (IsEditAction & (IsObjectAction & (IsPartnerFocalPointPermission | IsUNICEFFocalPointPermission))) + ] + + def get_queryset(self): + queryset = super().get_queryset() + + if 'UNICEF User' not in [g.name for g in self.request.user.groups.all()]: + queryset = queryset.filter(intervention__partner_focal_points__user=self.request.user).distinct() + + return queryset + + @transaction.atomic + def create(self, request, *args, **kwargs): + related_fields = [] + nested_related_names = [] + serializer = self.my_create( + request, + related_fields, + nested_related_names=nested_related_names, + **kwargs + ) + instance = serializer.instance + + validator = EFaceFormValid(instance, user=request.user) + if not validator.is_valid: + raise ValidationError(validator.errors) + + headers = self.get_success_headers(serializer.data) + + return Response( + self.get_serializer_class()(instance, context=self.get_serializer_context()).data, + status=status.HTTP_201_CREATED, + headers=headers + ) + + @transaction.atomic + def update(self, request, *args, **kwargs): + related_fields = [] + nested_related_names = [] + instance, old_instance, _serializer = self.my_update( + request, + related_fields, + nested_related_names=nested_related_names, + **kwargs + ) + + validator = EFaceFormValid(instance, old=old_instance, user=request.user) + if not validator.is_valid: + raise ValidationError(validator.errors) + + return Response(self.get_serializer_class()(instance, context=self.get_serializer_context()).data) + + +class UsersViewSet(EFaceBaseViewSet, mixins.ListModelMixin, viewsets.GenericViewSet): + queryset = get_user_model().objects.all() + serializer_class = FormUserSerializer + permission_classes = EFaceBaseViewSet.permission_classes + [UserIsStaffPermission] + + def get_queryset(self, pk=None): + qs = super().get_queryset() + + return qs.filter( + profile__country=self.request.user.profile.country, + is_staff=True, + ).prefetch_related('profile').order_by("first_name") diff --git a/src/etools/applications/field_monitoring/tests/base.py b/src/etools/applications/field_monitoring/tests/base.py index 6249c1e16..c4eb1f7af 100644 --- a/src/etools/applications/field_monitoring/tests/base.py +++ b/src/etools/applications/field_monitoring/tests/base.py @@ -73,7 +73,7 @@ def _test_list(self, user, expected_objects=None, expected_status=status.HTTP_20 def _test_create(self, user, data, expected_status=status.HTTP_201_CREATED, field_errors=None, **kwargs): response = self.make_list_request(user, method='post', data=data, **kwargs) - self.assertEqual(response.status_code, expected_status) + self.assertEqual(response.status_code, expected_status, response.data) if field_errors: self.assertListEqual(list(response.data.keys()), field_errors) @@ -83,7 +83,7 @@ def _test_create(self, user, data, expected_status=status.HTTP_201_CREATED, fiel def _test_retrieve(self, user, instance, expected_status=status.HTTP_200_OK, field_errors=None): response = self.make_detail_request(user, instance) - self.assertEqual(response.status_code, expected_status) + self.assertEqual(response.status_code, expected_status, response.data) if field_errors: self.assertListEqual(list(response.data.keys()), field_errors) @@ -108,6 +108,6 @@ def _test_update(self, user, instance, data, expected_status=status.HTTP_200_OK, def _test_destroy(self, user, instance, expected_status=status.HTTP_204_NO_CONTENT): response = self.make_detail_request(user, instance, method='delete') - self.assertEqual(response.status_code, expected_status) + self.assertEqual(response.status_code, expected_status, response.data) return response diff --git a/src/etools/applications/partners/tests/test_amendments.py b/src/etools/applications/partners/tests/test_amendments.py index ece72a919..722e9eb5f 100644 --- a/src/etools/applications/partners/tests/test_amendments.py +++ b/src/etools/applications/partners/tests/test_amendments.py @@ -743,6 +743,7 @@ def test_related_fields(self): # basically there should be reverse relations to parent model and fields you're confident about to being ignored ignored_fields = { 'partners.Intervention': [ + 'efaceform', 'frs', 'special_reporting_requirements', 'quarters', @@ -769,7 +770,7 @@ def test_related_fields(self): 'reports.ReportingRequirement': ['intervention'], 'reports.AppliedIndicator': ['lower_result'], # time_frames are being copied separately as quarters - 'reports.InterventionActivity': ['result', 'time_frames'], + 'reports.InterventionActivity': ['result', 'time_frames', 'formactivity'], 'reports.LowerResult': ['result_link'], # interventionsupplyitem is secondary relation. will be copied as partners.InterventionSupplyItem.result 'partners.InterventionResultLink': ['intervention', 'interventionsupplyitem'], diff --git a/src/etools/applications/partners/tests/test_api_interventions.py b/src/etools/applications/partners/tests/test_api_interventions.py index c9eb3f159..925a80a30 100644 --- a/src/etools/applications/partners/tests/test_api_interventions.py +++ b/src/etools/applications/partners/tests/test_api_interventions.py @@ -113,6 +113,7 @@ class TestInterventionsAPI(BaseTenantTestCase): "date_sent_to_partner", "document_currency", "document_type", + "efaceform", "end", "engagement", "equity_narrative", diff --git a/src/etools/config/settings/base.py b/src/etools/config/settings/base.py index a7f3451df..448403b70 100644 --- a/src/etools/config/settings/base.py +++ b/src/etools/config/settings/base.py @@ -232,6 +232,7 @@ def get_from_secrets_or_env(var_name, default=None): 'etools.applications.field_monitoring.data_collection', 'etools.applications.field_monitoring.analyze', 'etools.applications.comments', + 'etools.applications.eface', 'etools.applications.travel', 'etools.applications.ecn', 'unicef_snapshot', diff --git a/src/etools/config/urls.py b/src/etools/config/urls.py index a165cd075..3c9d8b56f 100644 --- a/src/etools/config/urls.py +++ b/src/etools/config/urls.py @@ -69,6 +69,7 @@ re_path(r'^api/static_data/$', StaticDataView.as_view({'get': 'list'}), name='public_static'), # *************** API version 1 ******************** + re_path(r'^api/eface/v1/', include('etools.applications.eface.urls')), re_path(r'^locations/', include('unicef_locations.urls')), re_path(r'^locations/cartodbtables/$', CartoDBTablesView.as_view(), name='cartodbtables'), re_path(r'^locations/autocomplete/$', LocationQuerySetView.as_view(), name='locations_autocomplete'),