diff --git a/requirements.txt b/requirements.txt index efbaa84ed1..b5a184c1cc 100755 --- a/requirements.txt +++ b/requirements.txt @@ -14,7 +14,7 @@ django-countries==3.4.1 django-cron==0.4.6 django-dynamic-fixture>=1.9.0 openpyxl==2.3.5 -datapackage>=0.6.1 +datapackage==0.8.1 jsontableschema==0.6.5 python-dateutil==2.5.3 py4j==0.10.2.1 diff --git a/wildlifelicensing/apps/payments/forms.py b/wildlifelicensing/apps/payments/forms.py index 8102309f24..5f6bb2e0d3 100644 --- a/wildlifelicensing/apps/payments/forms.py +++ b/wildlifelicensing/apps/payments/forms.py @@ -15,6 +15,12 @@ class PaymentsReportForm(forms.Form): end = forms.DateTimeField(required=True, widget=forms.DateTimeInput( format=date_format )) + banked_start = forms.DateTimeField(required=True, widget=forms.DateTimeInput( + format=date_format + )) + banked_end = forms.DateTimeField(required=True, widget=forms.DateTimeInput( + format=date_format + )) def __init__(self, *args, **kwargs): super(PaymentsReportForm, self).__init__(*args, **kwargs) @@ -35,5 +41,10 @@ def __init__(self, *args, **kwargs): end = today_ten_pm_aest_local + delta start = end + relativedelta(weeks=-1) + banked_start = (start - datetime.timedelta(days=start.weekday())).replace(hour=0, minute=0) + banked_end = (banked_start + datetime.timedelta(days=6)).replace(hour=23, minute=59, second=59) + self.fields['start'].initial = start self.fields['end'].initial = end + self.fields['banked_start'].initial = banked_start + self.fields['banked_end'].initial = banked_end diff --git a/wildlifelicensing/apps/payments/views.py b/wildlifelicensing/apps/payments/views.py index cad937b654..1c8886065c 100644 --- a/wildlifelicensing/apps/payments/views.py +++ b/wildlifelicensing/apps/payments/views.py @@ -89,9 +89,14 @@ def get(self, request): if form.is_valid(): start = form.cleaned_data.get('start') end = form.cleaned_data.get('end') + banked_start = form.cleaned_data.get('banked_start') + banked_end = form.cleaned_data.get('banked_end') # here start and end should be timezone aware (with the settings.TIME_ZONE start = timezone.make_aware(start) if not timezone.is_aware(start) else start end = timezone.make_aware(end) if not timezone.is_aware(end) else end + banked_start = timezone.make_aware(banked_start) if not timezone.is_aware(banked_start) else banked_start + banked_end = timezone.make_aware(banked_end) if not timezone.is_aware(banked_end) else banked_end + url = request.build_absolute_uri( reverse('payments:ledger-report') ) @@ -99,8 +104,8 @@ def get(self, request): 'system': PAYMENT_SYSTEM_ID, 'start': start, 'end': end, - 'banked_start': start, - 'banked_end': end + 'banked_start': banked_start, + 'banked_end': banked_end } if 'items' in request.GET: data['items'] = True diff --git a/wildlifelicensing/apps/reports/templates/wl/reports.html b/wildlifelicensing/apps/reports/templates/wl/reports.html index 6aef1a406b..2a92c7808d 100755 --- a/wildlifelicensing/apps/reports/templates/wl/reports.html +++ b/wildlifelicensing/apps/reports/templates/wl/reports.html @@ -114,12 +114,18 @@

Payments

{% csrf_token %}
-
+
{% bootstrap_field payments_form.start %}
-
+
{% bootstrap_field payments_form.end %}
+
+ {% bootstrap_field payments_form.banked_start %} +
+
+ {% bootstrap_field payments_form.banked_end %} +
diff --git a/wildlifelicensing/apps/returns/schemas/regulation17-returns-schema.json b/wildlifelicensing/apps/returns/schemas/regulation17-returns-schema.json index b9b499960c..c471f27d73 100644 --- a/wildlifelicensing/apps/returns/schemas/regulation17-returns-schema.json +++ b/wildlifelicensing/apps/returns/schemas/regulation17-returns-schema.json @@ -1,150 +1,177 @@ { "name": "reg-17", - "title": "Returns for regulation 17", "resources": [ { - "name": "regulation-17", - "title": "Regulation 17", "path": "", "schema": { "fields": [ { - "name": "LOCATION", "type": "string", + "name": "LOCATION", "constraints": { "required": true } }, { - "name": "SITE", "type": "string", + "name": "SITE", "constraints": { "required": true } }, { - "name": "DATUM", - "type": "string", "default": "GDA94", "constraints": { - "required": true - } + "required": true, + "enum": [ + "GDA94", + "WGS84", + "AGD84", + "AGD66" + ] + }, + "type": "string", + "name": "DATUM" }, { - "name": "LATITUDE", "type": "number", + "name": "LATITUDE", "constraints": { - "required": true, - "minimum": -90.0, - "maximum": 90.0 + "minimum": -60.0, + "maximum": 0, + "required": true } }, { - "name": "LONGITUDE", "type": "number", + "name": "LONGITUDE", "constraints": { - "minimum": -180.0, - "maximum": 180.0 + "minimum": 80.0, + "maximum": 170.0, + "required": true } }, { + "type": "number", "name": "ZONE", - "type": "string" + "constraints": { + "required": true, + "enum": [ + 49, + 50, + 51, + 52 + ] + } }, { + "type": "number", "name": "EASTING", - "type": "number" + "constraints": { + "required": true + } }, { + "type": "number", "name": "NORTHING", - "type": "number" + "constraints": { + "required": true + } }, { + "type": "number", "name": "ACCURACY", - "type": "string", "constraints": { "required": true } }, { + "type": "date", "name": "DATE", - "type": "string", + "format": "fmt:%d/%m/%Y", "constraints": { "required": true } }, { + "type": "number", "name": "NAME_ID", - "type": "string", "constraints": { - "required": true + "required": false } }, { - "name": "SPECIES_NAME", "type": "string", + "name": "SPECIES_NAME", "constraints": { "required": true + }, + "wl": { + "type": "species", + "speciesType": "fauna" } }, { - "name": "COMMON_NAME", - "type": "string" + "type": "string", + "name": "COMMON_NAME" }, { - "name": "SPECIES_GROUP", "type": "string", + "name": "SPECIES_GROUP", "constraints": { "required": true } }, { - "name": "COUNT", "type": "number", + "name": "COUNT", "constraints": { "required": true } }, { - "name": "IDENTIFIER", - "type": "string" + "type": "string", + "name": "IDENTIFIER" }, { - "name": "CERTAINTY", - "type": "string" + "type": "string", + "name": "CERTAINTY" }, { - "name": "METHOD", "type": "string", + "name": "METHOD", "constraints": { "required": true } }, { - "name": "FATE", "type": "string", + "name": "FATE", "constraints": { "required": true } }, { - "name": "SAMPLES", - "type": "string" + "type": "string", + "name": "SAMPLES" }, { - "name": "MARKING", - "type": "string" + "type": "string", + "name": "MARKING" }, { - "name": "TRANSMITTER", - "type": "string" + "type": "string", + "name": "TRANSMITTER" }, { - "name": "VOUCHER_REF", - "type": "string" + "type": "string", + "name": "VOUCHER_REF" } ] - } + }, + "name": "regulation-17", + "title": "Regulation 17" } - ] -} + ], + "title": "Returns for regulation 17" +} \ No newline at end of file diff --git a/wildlifelicensing/apps/returns/tests/test_schema.py b/wildlifelicensing/apps/returns/tests/test_schema.py index f04db7d38e..c1e8446c55 100644 --- a/wildlifelicensing/apps/returns/tests/test_schema.py +++ b/wildlifelicensing/apps/returns/tests/test_schema.py @@ -1,3 +1,4 @@ +from __future__ import unicode_literals import datetime from django.utils import six @@ -159,6 +160,18 @@ def test_date_custom_format(self): value = '30 Nov 14' self.assertEqual(field.cast(value), datetime.date(2014, 11, 30)) + format_ = 'fmt:%d/%m/%Y' + descriptor = { + 'name': 'Date with fmt', + 'type': 'date', + 'format': format_ + } + field = SchemaField(descriptor) + value = '12/07/2016' + value = field.cast(value) + self.assertEqual(type(value), datetime.date) + self.assertEqual(value, datetime.date(2016, 7, 12)) + def test_string(self): # test that a blank string '' is not accepted when the field is required null_values = ['null', 'none', 'nil', 'nan', '-', ''] @@ -207,8 +220,39 @@ def test_datetime_any(self): f.cast(v) -class TestSpeciesField(TestCase): +class TestSchemaFieldValidation(TestCase): + + def test_enums(self): + """ + Test that if a field has an enum constraint and if the data doesn't fit the error message should give the list + of the possible values. + :return: + """ + descriptor = { + "name": "Enum", + "title": "Test Enum message", + "type": "string", + "format": "default", + "constraints": { + "required": False, + "enum": ["val1", "val2", "val3"] + } + } + f = SchemaField(descriptor) + valid_values = ['val1', 'val2', 'val3', ''] # non required should accept blank + for v in valid_values: + self.assertIsNone(f.validation_error(v)) + + wrong_values = ['xxx'] + for v in wrong_values: + msg = f.validation_error(v) + self.assertTrue(msg) + # test that the error message contains each of the enum values. + for vv in f.constraints['enum']: + self.assertTrue(msg.find(vv) >= 0) + +class TestSpeciesField(TestCase): def test_no_species(self): descriptor = clone(helpers.GENERIC_SCHEMA) sch = Schema(descriptor) @@ -248,3 +292,240 @@ def test_species_by_wl_tag(self): sch = Schema(descriptor) self.assertEqual(1, len(sch.species_fields)) self.assertEquals(field['name'], sch.species_fields[0].name) + + +class TestLatLongEastingNorthingCase(TestCase): + """ + Test the conditional requirement between long/lat and easting/northing. + One or the other must be required. + """ + + def setUp(self): + self.schema_descriptor = { + "fields": [ + { + "type": "string", + "name": "DATUM", + "constraints": { + "required": True, + "enum": ["GDA94", "WGS84", "AGD84", "AGD66"] + }, + }, + { + "type": "number", + "name": "LATITUDE", + "constraints": { + "minimum": -60.0, + "maximum": 0, + "required": True + } + }, + { + "type": "number", + "name": "LONGITUDE", + "constraints": { + "minimum": 80.0, + "maximum": 170.0, + "required": True + } + }, + { + "type": "number", + "name": "ZONE", + "constraints": { + "required": True, + "enum": [49, 50, 51, 52] + } + }, + { + "type": "number", + "name": "EASTING", + "constraints": { + "required": True, + } + }, + { + "type": "number", + "name": "NORTHING", + "constraints": { + "required": True, + } + }, + ] + } + self.schema = Schema(self.schema_descriptor) + self.assertTrue(self.schema.is_lat_long_easting_northing_schema()) + + def test_lat_long_only(self): + """ + Lat/Long + datum should not generate an error + :return: + """ + data = { + "DATUM": "WGS84", + "LATITUDE": -32, + "LONGITUDE": 116, + "EASTING": None, + "NORTHING": None, + "ZONE": None + } + self.assertTrue(self.schema.is_row_valid(data)) + + # datum always required + data = { + "DATUM": None, + "LATITUDE": -32, + "LONGITUDE": 116, + "EASTING": None, + "NORTHING": None, + "ZONE": None + } + self.assertFalse(self.schema.is_row_valid(data)) + errors = self.schema.get_error_fields(data) + self.assertEqual(len(errors), 1) + self.assertEqual('DATUM', errors[0][0]) + + def test_east_north_only(self): + """ + Northing/Easting + Datum + Zone should be valid + :return: + """ + data = { + "DATUM": "WGS84", + "LATITUDE": None, + "LONGITUDE": None, + "EASTING": 123456, + "NORTHING": 654321, + "ZONE": 50 + } + self.assertTrue(self.schema.is_row_valid(data)) + + # datum always required + data = { + "DATUM": None, + "LATITUDE": None, + "LONGITUDE": None, + "EASTING": 123456, + "NORTHING": 654321, + "ZONE": 50 + } + self.assertFalse(self.schema.is_row_valid(data)) + errors = self.schema.get_error_fields(data) + self.assertEqual(len(errors), 1) + self.assertEqual('DATUM', errors[0][0]) + + # ZONE always required + data = { + "DATUM": "WGS84", + "LATITUDE": None, + "LONGITUDE": None, + "EASTING": 123456, + "NORTHING": 654321, + "ZONE": '' + } + self.assertFalse(self.schema.is_row_valid(data)) + errors = self.schema.get_error_fields(data) + self.assertEqual(len(errors), 1) + self.assertEqual('ZONE', errors[0][0]) + + def test_no_lat_long_and_no_east_north(self): + data = { + "DATUM": "WGS84", + "LATITUDE": '', + "LONGITUDE": '', + "EASTING": None, + "NORTHING": None, + "ZONE": 50 + } + self.assertFalse(self.schema.is_row_valid(data)) + error_fields = [e[0] for e in self.schema.get_error_fields(data)] + self.assertTrue('LATITUDE' in error_fields) + self.assertTrue('LONGITUDE' in error_fields) + self.assertTrue('EASTING' in error_fields) + self.assertTrue('NORTHING' in error_fields) + + def test_half_baked_data(self): + """ + Missing either lat or long ot east or north + :return: + """ + data = { + "DATUM": "WGS84", + "LATITUDE": -32, + "LONGITUDE": None, + "EASTING": None, + "NORTHING": None, + "ZONE": 50 + } + self.assertFalse(self.schema.is_row_valid(data)) + error_fields = [e[0] for e in self.schema.get_error_fields(data)] + self.assertFalse('LATITUDE' in error_fields) + self.assertTrue('NORTHING' in error_fields) + self.assertTrue('LONGITUDE' in error_fields) + self.assertTrue('EASTING' in error_fields) + + data = { + "DATUM": "WGS84", + "LATITUDE": None, + "LONGITUDE": 115, + "EASTING": None, + "NORTHING": None, + "ZONE": 50 + } + self.assertFalse(self.schema.is_row_valid(data)) + error_fields = [e[0] for e in self.schema.get_error_fields(data)] + self.assertFalse('LONGITUDE' in error_fields) + self.assertTrue('EASTING' in error_fields) + self.assertTrue('LATITUDE' in error_fields) + self.assertTrue('NORTHING' in error_fields) + + data = { + "DATUM": "WGS84", + "LATITUDE": None, + "LONGITUDE": None, + "EASTING": 123456, + "NORTHING": None, + "ZONE": 50 + } + self.assertFalse(self.schema.is_row_valid(data)) + error_fields = [e[0] for e in self.schema.get_error_fields(data)] + self.assertTrue('LONGITUDE' in error_fields) + self.assertFalse('EASTING' in error_fields) + self.assertTrue('LATITUDE' in error_fields) + self.assertTrue('NORTHING' in error_fields) + + data = { + "DATUM": "WGS84", + "LATITUDE": None, + "LONGITUDE": None, + "EASTING": None, + "NORTHING": 645321, + "ZONE": 50 + } + self.assertFalse(self.schema.is_row_valid(data)) + error_fields = [e[0] for e in self.schema.get_error_fields(data)] + self.assertFalse('NORTHING' in error_fields) + self.assertTrue('LATITUDE' in error_fields) + self.assertTrue('LONGITUDE' in error_fields) + self.assertTrue('EASTING' in error_fields) + + def test_mixed_data(self): + """ + Test that data that mixed lat/long and noth/east are not valid + E.g provide lat but not long and provide easting but not northing + :return: + """ + data = { + "DATUM": "WGS84", + "LATITUDE": -32, + "LONGITUDE": None, + "EASTING": 12345, + "NORTHING": None, + "ZONE": 50 + } + self.assertFalse(self.schema.is_row_valid(data)) + error_fields = [e[0] for e in self.schema.get_error_fields(data)] + self.assertFalse('LATITUDE' in error_fields) + self.assertFalse('EASTING' in error_fields) + self.assertTrue('LONGITUDE' in error_fields) + self.assertTrue('NORTHING' in error_fields) diff --git a/wildlifelicensing/apps/returns/utils_schema.py b/wildlifelicensing/apps/returns/utils_schema.py index fef2240195..52f119e0a3 100644 --- a/wildlifelicensing/apps/returns/utils_schema.py +++ b/wildlifelicensing/apps/returns/utils_schema.py @@ -226,7 +226,11 @@ def validation_error(self, value): try: self.cast(value) except Exception as e: - error = e.message + error = "{}".format(e) + # Override the default enum exception message to include all possible values + if error.find('enum array') and self.constraints.enum: + values = [str(v) for v in self.constraints.enum] + error = "The value must be one the following: {}".format(values) return error def __str__(self): @@ -252,6 +256,10 @@ def get(self, k, d=None): def required(self): return self.get('required', False) + @property + def enum(self): + return self.get('enum') + class Schema: """ @@ -293,9 +301,12 @@ def headers(self): def field_names(self): return [f.name for f in self.fields] - def get_field_by_mame(self, name): + def get_field_by_mame(self, name, icase=False): + if icase and name: + name = name.lower() for f in self.fields: - if f.name == name: + field_name = f.name.lower() if icase else f.name + if field_name == name: return f return None @@ -310,6 +321,56 @@ def field_validation_error(self, field_name, value): def is_field_valid(self, field_name, value): return self.field_validation_error(field_name, value) is None + def is_lat_long_easting_northing_schema(self): + """ + True if there is a latitude, longitude, easting, northing, and zone field + :return: + """ + field_names = [name.lower() for name in self.field_names] + return all([ + 'latitude' in field_names, + 'longitude' in field_names, + 'easting' in field_names, + 'northing' in field_names, + 'zone' in field_names + ]) + + def post_validate_lat_long_easting_northing(self, field_validation): + """ + We want conditional requirements: either lat/long or northing/easting. + The goal is to remove the requirements on lat/long if we have east/north and vice versa. + Rules: + If lat and no northing remove northing error and zone error + If northing and no latitude remove latitude error. + If long and no easting remove easting error and zone error + If easting and no longitude remove longitude error. + :param field_validation: We expect that the data has been validated at the field level and the argument should be + the result of this validation (see validate_row()). + Expected format: + {field_name: { 'value': value, 'error': None|msg}} + :return: + """ + if not self.is_lat_long_easting_northing_schema(): + return field_validation + lat_validation = field_validation.get(self.get_field_by_mame('latitude', icase=True).name, {}) + north_validation = field_validation.get(self.get_field_by_mame('northing', icase=True).name, {}) + long_validation = field_validation.get(self.get_field_by_mame('longitude', icase=True).name, {}) + east_validation = field_validation.get(self.get_field_by_mame('easting', icase=True).name, {}) + zone_validation = field_validation.get(self.get_field_by_mame('zone', icase=True).name, {}) + if lat_validation.get('value') and long_validation.get('value'): + if not north_validation.get('value'): + north_validation['error'] = None + zone_validation['error'] = None + if not east_validation.get('value'): + east_validation['error'] = None + zone_validation['error'] = None + if east_validation.get('value') and north_validation.get('value'): + if not lat_validation.get('value'): + lat_validation['error'] = None + if not long_validation.get('value'): + long_validation['error'] = None + return field_validation + def validate_row(self, row): """ The row must be a dictionary or a list of key value @@ -323,12 +384,16 @@ def validate_row(self, row): """ row = dict(row) result = {} + # field validation for field_name, value in row.items(): error = self.field_validation_error(field_name, value) result[field_name] = { 'value': value, 'error': error } + # Special case for lat/long easting/northing + if self.is_lat_long_easting_northing_schema(): + result = self.post_validate_lat_long_easting_northing(result) return result def rows_validator(self, rows): diff --git a/wildlifelicensing/apps/returns/views.py b/wildlifelicensing/apps/returns/views.py index e5e5ee0082..42a481efe6 100755 --- a/wildlifelicensing/apps/returns/views.py +++ b/wildlifelicensing/apps/returns/views.py @@ -1,8 +1,7 @@ import os import shutil import tempfile - -from datetime import date +import datetime from django.views.generic.base import TemplateView, View from django.shortcuts import render, get_object_or_404, redirect @@ -92,7 +91,7 @@ def _set_submitted(self, ret): ret.lodgement_number = '%s-%s' % (str(ret.licence.licence_type.pk).zfill(LICENCE_TYPE_NUM_CHARS), str(ret.pk).zfill(LODGEMENT_NUMBER_NUM_CHARS)) - ret.lodgement_date = date.today() + ret.lodgement_date = datetime.date.today() if is_officer(self.request.user): ret.proxy_customer = self.request.user @@ -150,7 +149,8 @@ def get_context_data(self, **kwargs): kwargs['tables'].append(table) - kwargs['upload_spreadsheet_form'] = UploadSpreadsheetForm() + if 'upload_spreadsheet_form' not in kwargs: + kwargs['upload_spreadsheet_form'] = UploadSpreadsheetForm() kwargs['nil_return_form'] = NilReturnForm() return super(EnterReturnView, self).get_context_data(**kwargs) @@ -176,12 +176,22 @@ def post(self, request, *args, **kwargs): if worksheet is not None: table_data = excel.TableData(worksheet) schema = Schema(ret.return_type.get_schema_by_name(table.get('name'))) - validated_rows = list(schema.rows_validator(table_data.rows_by_col_header_it())) + excel_rows = list(table_data.rows_by_col_header_it()) + validated_rows = list(schema.rows_validator(excel_rows)) + # We want to stringify the datetime/date that might have been created by the excel parser + for vr in validated_rows: + for col, validation in vr.items(): + value = validation.get('value') + if isinstance(value, datetime.datetime) or isinstance(value, datetime.date): + validation['value'] = value.strftime(DATE_FORMAT) table['data'] = validated_rows else: messages.warning(request, 'Missing worksheet ' + table.get('name')) finally: shutil.rmtree(temp_file_dir) + else: + context['upload_spreadsheet_form'] = form + elif 'draft' in request.POST or 'draft_continue' in request.POST: _create_return_data_from_post_data(ret, context['tables'], request.POST)