From dc2513d620f7e3767cda66233cfa03db49b46148 Mon Sep 17 00:00:00 2001 From: David Pollard Date: Fri, 8 Nov 2024 16:48:44 -0800 Subject: [PATCH 1/9] Move logged in user decorator to more export_layer function --- landmapper/app/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/landmapper/app/views.py b/landmapper/app/views.py index 1497d64..09bdde8 100644 --- a/landmapper/app/views.py +++ b/landmapper/app/views.py @@ -722,7 +722,6 @@ def get_property_pdf_georef(request, property_id, map_type="aerial"): #* #* Export shapefile #* -@login_required(login_url='/auth/login/') def export_shapefile(db_user, db_pw_command, database_name, shpdir, filename, query): """ Helper function to export a shapefile using pgsql2shp. @@ -752,6 +751,7 @@ def zip_shapefile(shpdir, filename): #* #* Export Layer #* +@login_required(login_url='/auth/login/') def export_layer(request, property_id): ''' (called on request for download GIS data) From 58c59db04a01fccd3a3967028239cbccf1853af7 Mon Sep 17 00:00:00 2001 From: David Pollard Date: Fri, 8 Nov 2024 17:11:22 -0800 Subject: [PATCH 2/9] Add test for exportshp --- landmapper/app/tests/test_exportshp.py | 85 ++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 landmapper/app/tests/test_exportshp.py diff --git a/landmapper/app/tests/test_exportshp.py b/landmapper/app/tests/test_exportshp.py new file mode 100644 index 0000000..814139f --- /dev/null +++ b/landmapper/app/tests/test_exportshp.py @@ -0,0 +1,85 @@ +from django.test import TestCase, Client +from django.urls import reverse +from unittest.mock import patch, MagicMock +from django.conf import settings +from django.http import HttpResponse +from app.models import PropertyRecord, User +from app import properties +import os +import io +import zipfile +import subprocess +import unittest + +class ExportLayerTests(TestCase): + + def setUp(self): + self.client = Client() + self.user = User.objects.create_user(username='testy', password='astrongpassword') + self.property_record = PropertyRecord.objects.create( + user=self.user, + name='Test Property', + geometry_final='POINT(0 0)' + ) + self.url = reverse('export_layer', args=[self.property_record.id]) + + @patch('app.views.properties.get_property_by_id') + @patch('app.views.export_shapefile') + @patch('app.views.zip_shapefile') + def test_export_layer_success(self, mock_zip_shapefile, mock_export_shapefile, mock_get_property_by_id): + mock_get_property_by_id.return_value = self.property_record + mock_zip_shapefile.return_value = io.BytesIO(b'some_zip_data') + + self.client.login(username='testy', password='astrongpassword') + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response['Content-Type'], 'application/zip') + self.assertIn('attachment; filename=', response['Content-Disposition']) + mock_export_shapefile.assert_called_once() + mock_zip_shapefile.assert_called_once() + + @patch('app.views.properties.get_property_by_id') + def test_export_layer_property_not_found(self, mock_get_property_by_id): + mock_get_property_by_id.side_effect = PropertyRecord.DoesNotExist + + self.client.login(username='testuser', password='astrongpassword') + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.content.decode(), 'Property not found or you do not have permission to access it.') + + @patch('app.views.properties.get_property_by_id') + @patch('app.views.export_shapefile') + @patch('app.views.zip_shapefile') + def test_export_layer_export_error(self, mock_zip_shapefile, mock_export_shapefile, mock_get_property_by_id): + mock_get_property_by_id.return_value = self.property_record + mock_export_shapefile.side_effect = subprocess.CalledProcessError(1, 'pgsql2shp') + + self.client.login(username='testy', password='astrongpassword') + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 500) + self.assertEqual(response.content.decode(), 'Error exporting shapefile.') + mock_export_shapefile.assert_called_once() + mock_zip_shapefile.assert_not_called() + + # Test case to ensure that unauthenticated users are redirected to the login page. + def test_export_layer_user_not_authenticated(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 302) # Redirect to login + + @patch('app.views.properties.get_property_by_id') + @patch('app.views.export_shapefile') + @patch('app.views.zip_shapefile') + def test_export_layer_cleanup(self, mock_zip_shapefile, mock_export_shapefile, mock_get_property_by_id): + mock_get_property_by_id.return_value = self.property_record + mock_zip_shapefile.return_value = io.BytesIO(b'some_zip_data') + + # Patch os.remove and os.rmdir to prevent actual file system changes during the test + with patch('os.remove') as mock_remove, patch('os.rmdir') as mock_rmdir: + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + mock_remove.assert_called() + mock_rmdir.assert_called_once() + From f99236076362721c13fc9c9f7cecff865b1e9bb6 Mon Sep 17 00:00:00 2001 From: David Pollard Date: Tue, 12 Nov 2024 15:53:59 -0800 Subject: [PATCH 3/9] Revise share and shapefile icons --- .../app/static/landmapper/img/icon/icon-pdf.svg | 10 ++++++++++ .../app/static/landmapper/img/icon/icon-share.svg | 2 +- .../app/static/landmapper/img/icon/icon-shp.svg | 11 ++++++++++- 3 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 landmapper/app/static/landmapper/img/icon/icon-pdf.svg diff --git a/landmapper/app/static/landmapper/img/icon/icon-pdf.svg b/landmapper/app/static/landmapper/img/icon/icon-pdf.svg new file mode 100644 index 0000000..c68e765 --- /dev/null +++ b/landmapper/app/static/landmapper/img/icon/icon-pdf.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/landmapper/app/static/landmapper/img/icon/icon-share.svg b/landmapper/app/static/landmapper/img/icon/icon-share.svg index 454579f..f788381 100644 --- a/landmapper/app/static/landmapper/img/icon/icon-share.svg +++ b/landmapper/app/static/landmapper/img/icon/icon-share.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/landmapper/app/static/landmapper/img/icon/icon-shp.svg b/landmapper/app/static/landmapper/img/icon/icon-shp.svg index 79e6673..0e026c8 100644 --- a/landmapper/app/static/landmapper/img/icon/icon-shp.svg +++ b/landmapper/app/static/landmapper/img/icon/icon-shp.svg @@ -3,4 +3,13 @@ COLLECTION: Gis Mapping Icons LICENSE: GPL License AUTHOR: Viglino --> - \ No newline at end of file + + + + + + + + + + \ No newline at end of file From 9e41c3459b3b06843f91e5e0c0b386ce0bf455f3 Mon Sep 17 00:00:00 2001 From: David Pollard Date: Tue, 12 Nov 2024 15:55:32 -0800 Subject: [PATCH 4/9] Ability to use rgba() with CSS primary color variables --- landmapper/app/static/landmapper/css/base.css | 4 +++- landmapper/app/static/landmapper/css/or.css | 7 +++++++ landmapper/app/static/landmapper/css/wa.css | 7 +++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/landmapper/app/static/landmapper/css/base.css b/landmapper/app/static/landmapper/css/base.css index 420c82d..16bef08 100644 --- a/landmapper/app/static/landmapper/css/base.css +++ b/landmapper/app/static/landmapper/css/base.css @@ -30,6 +30,8 @@ --primary-color: var(--primary-color-state, var(--green)); --secondary-color: var(--secondary-color-state, var(--yellow)); +--primary-color-rgb: var(--primary-rgb-state, 120, 162, 47); + --primary-color-hover: color-mix(in srgb, var(--primary-color) 50%, black); --secondary-color-hover: color-mix(in srgb, var(--secondary-color) 90%, white); @@ -125,7 +127,7 @@ a { hr { border-top: 1px solid var(--dark-grey); height: 1px; - margin: 3vh 0; + margin: 1vh 0 1.5vh; } strong { diff --git a/landmapper/app/static/landmapper/css/or.css b/landmapper/app/static/landmapper/css/or.css index 0131e83..d836b1b 100644 --- a/landmapper/app/static/landmapper/css/or.css +++ b/landmapper/app/static/landmapper/css/or.css @@ -3,8 +3,15 @@ --primary-color-or: #002A86; --secondary-color-or: #FFEA0F; + /** + * For use with rgba() and hsla() functions + */ + --primary-rgb-or: 0, 42, 134; + --primary-color-state: var(--primary-color-or); --secondary-color-state: var(--secondary-color-or); + + --primary-rgb-state: var(--primary-rgb-or); } .navbar-brand:after { diff --git a/landmapper/app/static/landmapper/css/wa.css b/landmapper/app/static/landmapper/css/wa.css index 62e089c..079e73c 100644 --- a/landmapper/app/static/landmapper/css/wa.css +++ b/landmapper/app/static/landmapper/css/wa.css @@ -3,8 +3,15 @@ --primary-color-wa: #008457; --secondary-color-wa: #FFD520; + /** + * For use with rgba() and hsla() functions + */ + --primary-rgb-wa: 0, 132, 87; + --primary-color-state: var(--primary-color-wa); --secondary-color-state: var(--secondary-color-wa); + + --primary-rgb-state: var(--primary-rgb-wa); } .navbar-brand:after { From 919a56ea11b96adfac94db62e04716f91a78125b Mon Sep 17 00:00:00 2001 From: David Pollard Date: Tue, 12 Nov 2024 15:56:16 -0800 Subject: [PATCH 5/9] Use revised share and shp icons --- .../app/static/landmapper/css/report.css | 33 +++++++++- .../landmapper/report/report-overview.html | 60 ++++++++++++------- .../landmapper/report/report-share.html | 6 +- 3 files changed, 72 insertions(+), 27 deletions(-) diff --git a/landmapper/app/static/landmapper/css/report.css b/landmapper/app/static/landmapper/css/report.css index fd27987..5b59853 100644 --- a/landmapper/app/static/landmapper/css/report.css +++ b/landmapper/app/static/landmapper/css/report.css @@ -71,9 +71,9 @@ p { .anchor-links { font-size: 16px; - line-height: 30px; + line-height: 1.75; letter-spacing: 0; - margin: 2vh 0px; + margin: 1vh 0px; word-spacing: 0; } @@ -82,11 +82,22 @@ p { } .anchor-links a { + border: none; color: var(--primary-color); font-family: var(--primary-font-heavy); font-weight: 900; padding: 2px; margin: 0; + text-decoration: none; +} + +.anchor-links a:hover { + background: rgba(var(--primary-color-rgb), 0.15); + border: none; + color: var(--primary-color); + opacity: 1; + outline: 5px rgba(var(--primary-color-rgb), 0.05); + text-decoration: none; } .legend-wrap { @@ -132,6 +143,24 @@ p { /* vertical-align: text-bottom; */ } +.btn-action { + background: transparent; + border: none; + padding: .5em .75em; +} + +.btn-action:hover { + background: rgba(var(--primary-color-rgb), 0.15); + border: none; + outline: 5px rgba(var(--primary-color-rgb), 0.05); +} + +.btn-action .icon { + height: 100%; + margin: 0 auto; + width: 2.5em; +} + .icon-copy { margin: .125em auto; width: 2.25em; diff --git a/landmapper/app/templates/landmapper/report/report-overview.html b/landmapper/app/templates/landmapper/report/report-overview.html index 6a5d868..d0af5a3 100644 --- a/landmapper/app/templates/landmapper/report/report-overview.html +++ b/landmapper/app/templates/landmapper/report/report-overview.html @@ -41,38 +41,37 @@

{{ property_name }}

{% endif %} {% endfor %} + +
-
+ {% if user.is_authenticated %} -

All maps of your property

+

Export your property data

- + {% endif %} + +

Download and share your report

{% if user.is_authenticated %} - - - PDF + + PDF icon + {% comment %} PDF {% endcomment %} - - - {% else %} @@ -88,6 +87,23 @@

All maps of your property

{% include "landmapper/report/report-share.html" %}
+ +
+ +

All maps of your property

+ + diff --git a/landmapper/app/templates/landmapper/report/report-share.html b/landmapper/app/templates/landmapper/report/report-share.html index 8d8410a..9a87d64 100644 --- a/landmapper/app/templates/landmapper/report/report-share.html +++ b/landmapper/app/templates/landmapper/report/report-share.html @@ -1,6 +1,6 @@ {% load static %} - \ No newline at end of file From d46fc8db1b322a9ba39a21c44442d086e42778b6 Mon Sep 17 00:00:00 2001 From: David Pollard Date: Tue, 12 Nov 2024 16:04:21 -0800 Subject: [PATCH 6/9] Disabled button styles on report page --- .../app/static/landmapper/css/report.css | 4 +++ .../landmapper/report/report-overview.html | 32 ++++++++++++------- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/landmapper/app/static/landmapper/css/report.css b/landmapper/app/static/landmapper/css/report.css index 5b59853..4c444e6 100644 --- a/landmapper/app/static/landmapper/css/report.css +++ b/landmapper/app/static/landmapper/css/report.css @@ -34,6 +34,10 @@ p { margin: 4px 0; } + .action-wrap button:disabled { + pointer-events: none; + } + #copy-to-account { background-color: var(--info); } diff --git a/landmapper/app/templates/landmapper/report/report-overview.html b/landmapper/app/templates/landmapper/report/report-overview.html index d0af5a3..3249e67 100644 --- a/landmapper/app/templates/landmapper/report/report-overview.html +++ b/landmapper/app/templates/landmapper/report/report-overview.html @@ -44,23 +44,31 @@

{{ property_name }}


- {% if user.is_authenticated %} - -

Export your property data

+

Export your property data

-
+
+ {% if user.is_authenticated %} + -
+ {% else %} -
- - {% endif %} + + + + {% endif %} + +
+ +

Download and share your report

@@ -74,10 +82,10 @@

Download and share your report

{% else %} - - - - all maps + + + PDF icon + {% comment %} PDF {% endcomment %} Login to download From 0e328013d4088938af987d2cb8260ad93483479d Mon Sep 17 00:00:00 2001 From: David Pollard Date: Wed, 13 Nov 2024 15:57:25 -0800 Subject: [PATCH 7/9] Rewrite pgsql2shp query to use WHERE id=property_id.pk --- .../app/templates/landmapper/report/report-overview.html | 4 ++-- landmapper/app/views.py | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/landmapper/app/templates/landmapper/report/report-overview.html b/landmapper/app/templates/landmapper/report/report-overview.html index 3249e67..87c4631 100644 --- a/landmapper/app/templates/landmapper/report/report-overview.html +++ b/landmapper/app/templates/landmapper/report/report-overview.html @@ -51,7 +51,7 @@

Export your property data

{% if user.is_authenticated %} - @@ -59,7 +59,7 @@

Export your property data

{% else %} - diff --git a/landmapper/app/views.py b/landmapper/app/views.py index 09bdde8..5eb9362 100644 --- a/landmapper/app/views.py +++ b/landmapper/app/views.py @@ -768,19 +768,22 @@ def export_layer(request, property_id): return HttpResponse('User not authenticated. Please log in.', status=401) try: - property_record = properties.get_property_by_id(property_id, request.user) + # property_record = properties.get_property_by_id(property_id, request.user) + property_record = PropertyRecord.objects.get(pk=property_id) except PropertyRecord.DoesNotExist: return HttpResponse('Property not found or you do not have permission to access it.', status=404) db_user = settings.DATABASES['default']['USER'] db_pw_command = f" -P {settings.DATABASES['default']['PASSWORD']}" if settings.DATABASES['default']['PASSWORD'] else "" database_name = settings.DATABASES['default']['NAME'] - filename = f"{property_record.name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + # filename = f"{property_record.name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + filename = f"{property_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}" shpdir = os.path.join(settings.SHAPEFILE_EXPORT_DIR, filename) os.makedirs(shpdir, exist_ok=True) try: - query = f"SELECT * FROM app_propertyrecord WHERE id = {property_record.id};" + + query = f"SELECT * FROM app_propertyrecord WHERE id={property_record.pk};" export_shapefile(db_user, db_pw_command, database_name, shpdir, filename, query) zip_buffer = zip_shapefile(shpdir, filename) From a8c29e273cdba0200ed3975c44fa866a94516297 Mon Sep 17 00:00:00 2001 From: David Pollard Date: Wed, 13 Nov 2024 17:50:50 -0800 Subject: [PATCH 8/9] Handle spaces and special characters in the download name --- landmapper/app/static/landmapper/js/report.js | 14 ++++++-- landmapper/app/urls.py | 2 +- landmapper/app/views.py | 34 +++++++------------ 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/landmapper/app/static/landmapper/js/report.js b/landmapper/app/static/landmapper/js/report.js index b6a8cf4..cc3c28e 100644 --- a/landmapper/app/static/landmapper/js/report.js +++ b/landmapper/app/static/landmapper/js/report.js @@ -35,8 +35,13 @@ if (copyToAccountBtn) { * Add event listener to the export layer button */ function exportLayerHandler() { - const propertyId = this.getAttribute('data-property-id'); - fetch(`/export_layer/${propertyId}/shp`, { + const propertyPk = this.getAttribute('data-property-id'); + const exportLayerButton = this; + + // Disable the button to prevent multiple clicks + exportLayerButton.disabled = true; + + fetch(`/export_layer/${propertyPk}/shp`, { method: 'GET', headers: { 'X-Requested-With': 'XMLHttpRequest' @@ -48,7 +53,7 @@ if (copyToAccountBtn) { const a = document.createElement('a'); a.style.display = 'none'; a.href = url; - a.download = `${propertyId}.zip`; + a.download = `${propertyPk}.zip`; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); @@ -56,6 +61,9 @@ if (copyToAccountBtn) { .catch(error => { console.error('Error:', error); alert('An error occurred while exporting the layer.'); + }) + .finally(() => { + exportLayerButton.disabled = false; }); } diff --git a/landmapper/app/urls.py b/landmapper/app/urls.py index a75b8ad..ffb6ed9 100644 --- a/landmapper/app/urls.py +++ b/landmapper/app/urls.py @@ -46,7 +46,7 @@ path('admin_export_property_records/', exportPropertyRecords, name='export_property_records'), path('accounts/profile/', homeRedirect, name='account_confirm_email'), path('auth/email/', homeRedirect, name='auth_email'), - path('export_layer//shp', export_layer, name='export_layer'), + path('export_layer//shp', export_layer, name='export_layer'), # path('tinymce/', include('tinymce.urls')), re_path(r'^tinymce/', include('tinymce.urls')), ] diff --git a/landmapper/app/views.py b/landmapper/app/views.py index 5eb9362..3423725 100644 --- a/landmapper/app/views.py +++ b/landmapper/app/views.py @@ -719,9 +719,7 @@ def get_property_pdf_georef(request, property_id, map_type="aerial"): pass return response -#* -#* Export shapefile -#* + def export_shapefile(db_user, db_pw_command, database_name, shpdir, filename, query): """ Helper function to export a shapefile using pgsql2shp. @@ -729,9 +727,7 @@ def export_shapefile(db_user, db_pw_command, database_name, shpdir, filename, qu export_command = f"pgsql2shp -u {db_user}{db_pw_command} -f {shpdir}/{filename} {database_name} \"{query}\"" subprocess.run(export_command, shell=True, check=True) -#* -#* Zip Shapefile -#* + def zip_shapefile(shpdir, filename): """ Helper function to zip the shapefile. @@ -748,42 +744,38 @@ def zip_shapefile(shpdir, filename): zip_buffer.seek(0) return zip_buffer -#* -#* Export Layer -#* + @login_required(login_url='/auth/login/') -def export_layer(request, property_id): +def export_layer(request, property_pk): ''' (called on request for download GIS data) IN: - Layer (default: property, leave modular to support forest_type, soil, others...) - Format (default: zipped .shp, leave modular to support json & others) - property + property_pk: Primary key of the property to export OUT: - property layer in requested format + Zipped shapefile of the property layer USES: pgsql2shp (OGR/PostGIS built-in) ''' + import re + if not request.user.is_authenticated: return HttpResponse('User not authenticated. Please log in.', status=401) try: - # property_record = properties.get_property_by_id(property_id, request.user) - property_record = PropertyRecord.objects.get(pk=property_id) + property_record = PropertyRecord.objects.get(pk=property_pk) except PropertyRecord.DoesNotExist: return HttpResponse('Property not found or you do not have permission to access it.', status=404) db_user = settings.DATABASES['default']['USER'] db_pw_command = f" -P {settings.DATABASES['default']['PASSWORD']}" if settings.DATABASES['default']['PASSWORD'] else "" database_name = settings.DATABASES['default']['NAME'] - # filename = f"{property_record.name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}" - filename = f"{property_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}" - shpdir = os.path.join(settings.SHAPEFILE_EXPORT_DIR, filename) + sanitized_name = re.sub(r'[^a-zA-Z0-9_-]', '_', property_record.name) + filename = f"{sanitized_name}" + shpdir = os.path.join(settings.SHAPEFILE_EXPORT_DIR, property_pk) os.makedirs(shpdir, exist_ok=True) try: - - query = f"SELECT * FROM app_propertyrecord WHERE id={property_record.pk};" + query = f"SELECT id, name, date_created, date_modified, geometry_final, record_taxlots FROM app_propertyrecord WHERE id={property_record.pk};" export_shapefile(db_user, db_pw_command, database_name, shpdir, filename, query) zip_buffer = zip_shapefile(shpdir, filename) From cbc24ef96744e1e395af4fceb6753d7a0bb70136 Mon Sep 17 00:00:00 2001 From: David Pollard Date: Wed, 13 Nov 2024 18:03:09 -0800 Subject: [PATCH 9/9] Disable export button when anon user shares to a logged in user --- .../templates/landmapper/report/report-overview.html | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/landmapper/app/templates/landmapper/report/report-overview.html b/landmapper/app/templates/landmapper/report/report-overview.html index 87c4631..11d39bc 100644 --- a/landmapper/app/templates/landmapper/report/report-overview.html +++ b/landmapper/app/templates/landmapper/report/report-overview.html @@ -48,13 +48,22 @@

Export your property data

- {% if user.is_authenticated %} + {% if user.is_authenticated and property.user_id == user_id %} + + {% elif user.is_authenticated and property.user_id != user_id %} + + + + + Copy to your properties to export {% else %}