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/report.css b/landmapper/app/static/landmapper/css/report.css index fd27987..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); } @@ -71,9 +75,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 +86,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 +147,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/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 { 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 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/templates/landmapper/report/report-overview.html b/landmapper/app/templates/landmapper/report/report-overview.html index 6a5d868..11d39bc 100644 --- a/landmapper/app/templates/landmapper/report/report-overview.html +++ b/landmapper/app/templates/landmapper/report/report-overview.html @@ -41,44 +41,60 @@

{{ property_name }}

{% endif %} {% endfor %} + +
-
+

Export your property data

-

All maps of your property

+
+ + {% 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 %} + + + + + {% endif %} + +
+ +
+ +

Download and share your report

{% if user.is_authenticated %} - - - PDF + + PDF icon + {% comment %} PDF {% endcomment %} - - - {% else %} - - - - all maps + + + PDF icon + {% comment %} PDF {% endcomment %} Login to download @@ -88,6 +104,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 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() + 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 1497d64..3423725 100644 --- a/landmapper/app/views.py +++ b/landmapper/app/views.py @@ -719,10 +719,7 @@ def get_property_pdf_georef(request, property_id, map_type="aerial"): pass return response -#* -#* 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. @@ -730,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. @@ -749,38 +744,38 @@ def zip_shapefile(shpdir, filename): zip_buffer.seek(0) return zip_buffer -#* -#* Export Layer -#* -def export_layer(request, property_id): + +@login_required(login_url='/auth/login/') +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_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')}" - 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.id};" + 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)