diff --git a/docsearch/forms.py b/docsearch/forms.py index 7f391bc..e0a1877 100644 --- a/docsearch/forms.py +++ b/docsearch/forms.py @@ -50,8 +50,12 @@ def to_python(self, value): """ if not value: return None + + try: + val = json.loads(value) + except json.decoder.JSONDecodeError: + raise ValidationError(("Please check the formatting for your GeoJSON")) - val = json.loads(value) if val.get('type') != 'GeometryCollection': val = { 'type': 'GeometryCollection', @@ -69,6 +73,13 @@ class LicenseForm(ModelForm): 'GeometryCollection and paste it in the box above.' )) + def clean_geometry(self): + data = self.cleaned_data['geometry'] + if not data.valid or data.empty: + raise ValidationError(("Please enter valid GeoJSON")) + + return data + class Meta: model = License fields = '__all__' diff --git a/docsearch/models.py b/docsearch/models.py index bf6b4cf..171f40b 100644 --- a/docsearch/models.py +++ b/docsearch/models.py @@ -7,6 +7,9 @@ from django.contrib.postgres import fields as pg_fields from django.contrib.postgres import forms as pg_forms from django.contrib.gis.db import models as gis_models +from django.core.validators import FileExtensionValidator + +from .validators import validate_positive_int, validate_int_btwn, validate_int_range, validate_int_array, validate_date, validate_license_num class InclusiveIntegerRangeFormField(pg_forms.IntegerRangeField): @@ -153,6 +156,8 @@ def get_delete_url(self): 'to save the values 1, 2, and 3, record them as 1,2,3.' ) +DATE_FIELD_HELP_TEXT = ('Enter the date as "YYYY-MM-DD"') + class Book(BaseDocumentModel): township = InclusiveIntegerRangeField( @@ -164,69 +169,100 @@ class Book(BaseDocumentModel): max_length=255, null=True, blank=True, + validators=[validate_int_range(min=9, max=15)], help_text=RANGE_FIELD_HELP_TEXT) section = InclusiveIntegerRangeField( max_length=255, null=True, blank=True, + validators=[validate_int_range(min=1, max=36)], help_text=RANGE_FIELD_HELP_TEXT) - source_file = models.FileField(upload_to='BOOKS') + source_file = models.FileField(upload_to='BOOKS', validators=[FileExtensionValidator(['pdf'])]) class ControlMonumentMap(BaseDocumentModel): - township = models.PositiveIntegerField(null=True) - range = models.PositiveIntegerField(null=True) + PART_OF_SECTION_CHOICES = [ + ("E1/2", "E1/2"), + ("W1/2", "W1/2") + ] + + township = models.PositiveIntegerField( + null=True, + ) + range = models.PositiveIntegerField( + null=True, + validators=[validate_int_btwn(9, 15)] + ) section = pg_fields.ArrayField( models.PositiveIntegerField(null=True), + validators=[validate_int_array(1, 36)], help_text=ARRAY_FIELD_HELP_TEXT ) - part_of_section = models.CharField(max_length=255, null=True, blank=True) - source_file = models.FileField(upload_to='CONTROL_MONUMENT_MAPS') + part_of_section = models.CharField( + max_length=4, + null=True, + blank=True, + choices=PART_OF_SECTION_CHOICES, + ) + source_file = models.FileField(upload_to='CONTROL_MONUMENT_MAPS', validators=[FileExtensionValidator(['pdf'])]) class SurplusParcel(BaseDocumentModel): surplus_parcel = models.CharField(max_length=255, null=True, blank=True) description = models.TextField(null=True, blank=True) - source_file = models.FileField(upload_to='DEEP_PARCEL_SURPLUS') + source_file = models.FileField(upload_to='DEEP_PARCEL_SURPLUS', validators=[FileExtensionValidator(['pdf'])]) class DeepTunnel(BaseDocumentModel): description = models.TextField() - source_file = models.FileField(upload_to='DEEP_PARCEL_SURPLUS') + source_file = models.FileField(upload_to='DEEP_PARCEL_SURPLUS', validators=[FileExtensionValidator(['pdf'])]) class Dossier(BaseDocumentModel): - file_number = models.CharField(max_length=255) - document_number = models.CharField(max_length=3) - source_file = models.FileField(upload_to='DOSSIER_FILES') + file_number = models.CharField(max_length=255, validators=[validate_positive_int]) + document_number = models.CharField(max_length=3, validators=[validate_positive_int]) + source_file = models.FileField( + upload_to='DOSSIER_FILES', + validators=[FileExtensionValidator(['pdf'])] + ) class Easement(BaseDocumentModel): - easement_number = models.CharField(max_length=255, null=True, blank=True) + easement_number = models.CharField(max_length=255, validators=[validate_positive_int], null=True, blank=True) description = models.TextField(null=True, blank=True) - source_file = models.FileField(upload_to='EASEMENTS') + source_file = models.FileField(upload_to='EASEMENTS', validators=[FileExtensionValidator(['pdf'])]) class FlatDrawing(BaseDocumentModel): - area = models.PositiveIntegerField(null=True, blank=True) + area = models.PositiveIntegerField(null=True, blank=True, validators=[validate_int_btwn(1, 33)]) section = models.PositiveIntegerField(null=True, blank=True) map_number = models.CharField(max_length=255, null=True, blank=True) location = models.TextField(blank=True, null=True) building_id = models.IntegerField(verbose_name='Building ID', null=True, blank=True) description = models.TextField(blank=True, null=True) job_number = models.CharField(max_length=255, blank=True, null=True) - number_of_sheets = models.CharField(max_length=255, null=True, blank=True) - date = models.CharField(max_length=255, null=True, blank=True) - cross_ref_area = models.PositiveIntegerField(null=True, blank=True) - cross_ref_section = models.PositiveIntegerField(null=True, blank=True) + number_of_sheets = models.CharField(max_length=255, null=True, blank=True, validators=[validate_positive_int]) + date = models.CharField(max_length=255, null=True, blank=True, validators=[validate_date], help_text=DATE_FIELD_HELP_TEXT) + cross_ref_area = models.PositiveIntegerField(null=True, blank=True, validators=[validate_int_btwn(1, 33)]) + cross_ref_section = models.PositiveIntegerField(null=True, blank=True, validators=[validate_int_btwn(1, 36)]) cross_ref_map_number = models.CharField( max_length=255, blank=True, null=True ) hash = models.CharField(max_length=255, null=True, blank=True) - cad_file = models.FileField('CAD file', null=True, blank=True) - source_file = models.FileField(upload_to='FLAT_DRAWINGS') + cad_file = models.FileField( + 'CAD file', + null=True, + blank=True, + validators=[FileExtensionValidator([ + 'dwg', + 'dxf', + 'dgn', + 'stl' + ])], + ) + source_file = models.FileField(upload_to='FLAT_DRAWINGS', validators=[FileExtensionValidator(['pdf'])]) class IndexCard(BaseDocumentModel): @@ -234,19 +270,52 @@ class IndexCard(BaseDocumentModel): township = models.CharField(max_length=255) section = models.CharField(max_length=255, null=True, blank=True) corner = models.CharField(max_length=255, null=True, blank=True) - source_file = models.FileField(upload_to='INDEX_CARDS') + source_file = models.FileField(upload_to='INDEX_CARDS', validators=[FileExtensionValidator(['pdf'])]) class License(BaseDocumentModel): - license_number = models.CharField(max_length=255, null=True, blank=True) + TYPE_CHOICES = [ + ("combined_sewer", "Combined Sewer"), + ("electric", "Electric"), + ("gas", "Gas"), + ("pipeline", "Pipeline"), + ("sanitary_sewer", "Sanitary Sewer"), + ("storm sewer", "Storm Sewer"), + ("telecom", "Telecom"), + ("water main", "Water Main"), + ("other", "Other"), + ] + + STATUS_CHOICES = [ + ("TBD", "TBD"), + ("active", "Active"), + ("cancelled", "Cancelled"), + ("continuous", "Continuous"), + ("expired", "Expired"), + ("indefinite", "Indefinite"), + ("perpetual", "Perpetual"), + ] + + license_number = models.CharField( + max_length=255, + null=True, blank=True, + validators=[validate_license_num], + help_text='Enter as a hyphenated string starting with "O" and ending with an integer (i.e. O-100)' + ) description = models.TextField(null=True, blank=True) geometry = gis_models.GeometryCollectionField(blank=True, null=True) - type = models.CharField(max_length=255, null=True, blank=True) + type = models.CharField(max_length=255, choices=TYPE_CHOICES, null=True, blank=True) entity = models.CharField(max_length=255, null=True, blank=True) - diameter = models.PositiveIntegerField(null=True, blank=True) + diameter = models.PositiveIntegerField(null=True, blank=True, validators=[validate_positive_int]) material = models.CharField(max_length=255, null=True, blank=True) - end_date = models.CharField(max_length=255, null=True, blank=True) - status = models.CharField(max_length=255, null=True, blank=True) + end_date = models.CharField( + max_length=255, + null=True, + blank=True, + validators=[validate_date], + help_text=DATE_FIELD_HELP_TEXT + ) + status = models.CharField(max_length=255, choices=STATUS_CHOICES, null=True, blank=True) agreement_type = models.CharField(max_length=255, null=True, blank=True) township = pg_fields.ArrayField( models.PositiveIntegerField(null=True), @@ -255,31 +324,33 @@ class License(BaseDocumentModel): ) range = pg_fields.ArrayField( models.PositiveIntegerField(null=True), + validators=[validate_int_array(9, 15)], help_text=ARRAY_FIELD_HELP_TEXT, default=list ) section = pg_fields.ArrayField( models.PositiveIntegerField(null=True), + validators=[validate_int_array(1, 36)], help_text=ARRAY_FIELD_HELP_TEXT, default=list ) - source_file = models.FileField(upload_to='LICENSES') + source_file = models.FileField(upload_to='LICENSES', validators=[FileExtensionValidator(['pdf'])]) class ProjectFile(BaseDocumentModel): - area = models.PositiveIntegerField(null=True, blank=True) - section = models.PositiveIntegerField(null=True, blank=True) + area = models.PositiveIntegerField(null=True, blank=True, validators=[validate_int_btwn(1, 33)]) + section = models.PositiveIntegerField(null=True, blank=True, validators=[validate_int_btwn(1, 36)]) job_number = models.CharField(max_length=255, null=True, blank=True) job_name = models.CharField(max_length=255, null=True, blank=True) description = models.TextField(null=True, blank=True) - cabinet_number = models.CharField(max_length=255, null=True, blank=True) + cabinet_number = models.CharField(max_length=255, null=True, blank=True, validators=[validate_int_btwn(1, 10)]) drawer_number = models.CharField(max_length=255, null=True, blank=True) - source_file = models.FileField(upload_to='PROJECT_FILES') + source_file = models.FileField(upload_to='PROJECT_FILES', validators=[FileExtensionValidator(['pdf'])]) class RightOfWay(BaseDocumentModel): folder_tab = models.CharField(max_length=255) - source_file = models.FileField(upload_to='RIGHT_OF_WAY') + source_file = models.FileField(upload_to='RIGHT_OF_WAY', validators=[FileExtensionValidator(['pdf'])]) class Meta: verbose_name_plural = 'rights of way' @@ -296,29 +367,31 @@ class Survey(BaseDocumentModel): ) section = pg_fields.ArrayField( models.PositiveIntegerField(null=True, blank=True), + validators=[validate_int_array(1, 36)], help_text=ARRAY_FIELD_HELP_TEXT ) range = pg_fields.ArrayField( models.PositiveIntegerField(null=True, blank=True), + validators=[validate_int_array(9, 15)], help_text=ARRAY_FIELD_HELP_TEXT ) map_number = models.CharField(max_length=255, null=True, blank=True) location = models.TextField(blank=True, null=True) description = models.TextField(blank=True, null=True) job_number = models.CharField(max_length=255, blank=True, null=True) - number_of_sheets = models.CharField(max_length=255, blank=True, null=True) - date = models.CharField(max_length=255, blank=True, null=True) - cross_ref_area = models.PositiveIntegerField(blank=True, null=True) - cross_ref_section = models.PositiveIntegerField(blank=True, null=True) + number_of_sheets = models.CharField(max_length=255, blank=True, null=True, validators=[validate_positive_int]) + date = models.CharField(max_length=255, blank=True, null=True, validators=[validate_date], help_text=DATE_FIELD_HELP_TEXT) + cross_ref_area = models.PositiveIntegerField(blank=True, null=True, validators=[validate_int_btwn(1, 33)]) + cross_ref_section = models.PositiveIntegerField(blank=True, null=True, validators=[validate_int_btwn(1, 36)]) cross_ref_map_number = models.CharField( max_length=255, blank=True, null=True ) hash = models.CharField(max_length=255, null=True, blank=True) - source_file = models.FileField(upload_to='SURVEYS') + source_file = models.FileField(upload_to='SURVEYS', validators=[FileExtensionValidator(['pdf'])]) class Title(BaseDocumentModel): - control_number = models.CharField(max_length=255) - source_file = models.FileField(upload_to='TITLES') + control_number = models.CharField(max_length=255, validators=[validate_positive_int]) + source_file = models.FileField(upload_to='TITLES', validators=[FileExtensionValidator(['pdf'])]) diff --git a/docsearch/static/css/custom.css b/docsearch/static/css/custom.css index a132804..c607301 100644 --- a/docsearch/static/css/custom.css +++ b/docsearch/static/css/custom.css @@ -151,3 +151,7 @@ footer a.dropdown-item { color:red; font-size: 1.3em; } + +.invalid-feedback { + display: block; +} diff --git a/docsearch/validators.py b/docsearch/validators.py new file mode 100644 index 0000000..f991b62 --- /dev/null +++ b/docsearch/validators.py @@ -0,0 +1,126 @@ +from django.core.exceptions import ValidationError +import re + +def validate_positive_int(value): + # Check that the value is a positive integer + + if not value.isnumeric() or int(value) <= 0: + raise ValidationError( + ("Please enter a positive number, '%(value)s' is not valid."), + params={"value": value}, + ) + + +def validate_int_range(min, max): + # Check that a 2 integer range is between the min and max + + def validator(value): + # Change the NumericRange obj to an indexable array of ints + value_list = value.__str__().strip('][').split(', ') + if value_list[1] == 'None': + raise ValidationError("Please enter values into both fields") + value_list = [int(num) for num in value_list] + + if value_list[0] < min or value_list[1] > max: + raise ValidationError( + ("Please enter a valid range between %(min)s and %(max)s. Range %(value0)s and %(value1)s is not valid."), + params={ + "value0": value_list[0], + "value1": value_list[1], + "min": min, + "max": max + }, + ) + + return validator + + +def validate_int_btwn(min, max): + # Check that a single integer is between the min and max + + def validator(value): + if int(value) < min or int(value) > max: + raise ValidationError( + ("Please enter a number between %(min)s and %(max)s. The number %(value)s is not valid."), + params={ + "value": value, + "min": min, + "max": max + }, + ) + + return validator + + +def validate_int_array(min, max): + # Check that a list/array of integers is between the min and max + + def validator(value): + valid = True + invalid_ints = [] + + for int in value: + if int < min or int > max: + valid = False + invalid_ints.append(int) + + if not valid: + invalid_ints = [str(i) for i in invalid_ints] + raise ValidationError( + ("Please enter numbers between %(min)s and %(max)s. The number(s) %(invalid)s is/are not valid."), + params={ + "invalid": ", ".join(invalid_ints), + "min": min, + "max": max + }, + ) + + return validator + + +def validate_date(value): + pattern = re.compile(r"^\d{4}\-(0[1-9]|1[012])\-(0[1-9]|[12][0-9]|3[01])$") + date_error='Please enter the date as "YYYY-MM-DD", ensuring that the date exists. The date %(value)s is not valid.' + + if not re.fullmatch(pattern, value): + raise ValidationError( + (date_error), + params={"value": value,}, + ) + year = int(value[0:4]) + month = int(value[5:7]) + day = int(value[8:]) + + # Validate days for most months + if month in [4,6,9,11] and day > 30: + raise ValidationError( + (date_error), + params={"value": value,}, + ) + elif month in [1,3,5,7,8,10,12] and day > 31: + raise ValidationError( + (date_error), + params={"value": value,}, + ) + + # Account for February + if year % 4 == 0 and month == 2 and day > 29: + raise ValidationError( + (date_error), + params={"value": value,}, + ) + elif year % 4 != 0 and month == 2 and day > 28: + raise ValidationError( + (date_error), + params={"value": value,}, + ) + + +def validate_license_num(value): + # Check that the license number is a hyphenated string starting with "O" and ending with an integer + + if not value[:2] == "O-" or not value[2:].isnumeric(): + raise ValidationError( + ("Please enter a hyphenated string starting with 'O' and ending with an integer, '%(value)s' is not valid."), + params={"value": value}, + ) \ No newline at end of file diff --git a/docsearch/views/licenses.py b/docsearch/views/licenses.py index 1c604d5..faac0e1 100644 --- a/docsearch/views/licenses.py +++ b/docsearch/views/licenses.py @@ -1,14 +1,32 @@ from docsearch import models from leaflet.forms.widgets import LeafletWidget +from django.contrib.gis.geos import GEOSException, GEOSGeometry +from django.contrib.gis.gdal.error import GDALException +import logging from docsearch.forms import LicenseForm from . import base as base_views +logger = logging.getLogger("django.contrib.gis") + +class StricterLeafletWidget(LeafletWidget): + def deserialize(self, value): + try: + return GEOSGeometry(value) + except (GEOSException, ValueError, TypeError, GDALException) as err: + logger.error("Error creating geometry from value '%s' (%s)", value, err) + return None + + def get_context(self, name, value, attrs): + context = super().get_context(name, value, attrs) + if not context['serialized'] and isinstance(value, str): + context['serialized'] = value + return context class MapWidgetMixin: def get_form(self): form = super().get_form() - form.fields['geometry'].widget = LeafletWidget(attrs={ + form.fields['geometry'].widget = StricterLeafletWidget(attrs={ 'map_height': '400px', 'map_width': '100%', 'display_raw': True