From 207e7cff600a9e91db29d8c9425ffce104711849 Mon Sep 17 00:00:00 2001 From: Tiago Santos Date: Sun, 23 Jun 2024 21:57:21 +0200 Subject: [PATCH] feat(Fmu): Download shapefiles from Admin Allows downloading the FMU's shapefiles. --- .github/workflows/linters.yml | 3 ++ .github/workflows/security.yml | 3 ++ .github/workflows/tests.yml | 5 ++ Gemfile | 1 + Gemfile.lock | 2 + app/admin/fmu.rb | 60 ++++++++++++++++++--- app/assets/javascripts/active_admin.js | 1 + app/assets/stylesheets/active_admin.scss | 6 +++ app/services/shapefile_service.rb | 69 ++++++++++++++++++++++++ config/locales/en.yml | 3 ++ config/locales/fr.yml | 3 ++ spec/services/shapefile_service_spec.rb | 22 ++++++++ 12 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 app/services/shapefile_service.rb create mode 100644 spec/services/shapefile_service_spec.rb diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index f95cf5f25..f5abaac01 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -8,6 +8,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install dependencies + run: | + sudo apt-get install libgdal-dev - name: Set up Ruby uses: ruby/setup-ruby@v1 with: diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 17039d1fb..ed1033859 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -8,6 +8,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install dependencies + run: | + sudo apt-get install libgdal-dev - name: Set up Ruby uses: ruby/setup-ruby@v1 with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d36399a74..79b7ce218 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,6 +29,10 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install dependencies + run: | + sudo apt-get install libgdal-dev + - name: Set up Ruby uses: ruby/setup-ruby@v1 with: @@ -55,6 +59,7 @@ jobs: run: | sudo apt update --fix-missing sudo apt-get -yqq install gdal-bin + sudo apt-get install libgdal-dev npm install -g mjml - name: Set up Ruby diff --git a/Gemfile b/Gemfile index cd247f371..58735d597 100644 --- a/Gemfile +++ b/Gemfile @@ -47,6 +47,7 @@ gem "pg" gem "rails", "~> 7.1.3" gem "rgeo" gem "rgeo-geojson" +gem "gdal" # API gem "jsonapi-resources", "0.9.12" diff --git a/Gemfile.lock b/Gemfile.lock index 59a87a7b3..7228a7dd0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -313,6 +313,7 @@ GEM googleapis-common-protos-types (~> 1.15) googleauth (~> 1.11) grpc (~> 1.65) + gdal (3.0.0) globalid (1.2.1) activesupport (>= 6.1) globalize (6.3.0) @@ -827,6 +828,7 @@ DEPENDENCIES email_spec factory_bot_rails faker + gdal globalize globalize-versioning! google-cloud-translate diff --git a/app/admin/fmu.rb b/app/admin/fmu.rb index 8e27a1575..96bfd9fc9 100644 --- a/app/admin/fmu.rb +++ b/app/admin/fmu.rb @@ -4,21 +4,48 @@ extend BackRedirectable extend Versionable - menu false - - active_admin_paranoia - - config.order_clause - controller do def scoped_collection end_of_association_chain.includes(:country, :operator) end + + def download_shapefiles(fmus) + file_content = ShapefileService.generate_shapefile(fmus) + + filename = "fmus" + filename = fmus.first&.name if fmus.size == 1 + filename = filename.gsub(/[^0-9A-Za-z ]/, "")[0..30] + filename += ".zip" + + send_data file_content, type: "application/zip", filename: filename, disposition: "attachment" + end end scope -> { I18n.t("active_admin.all") }, :all, default: true scope -> { I18n.t("active_admin.free") }, :filter_by_free_aa + menu false + + active_admin_paranoia + + config.order_clause + config.batch_actions = true + + batch_action :destroy, false + batch_action :download_shapefiles do |ids| + fmus = batch_action_collection.find(ids) + download_shapefiles(fmus) + end + + member_action :download_shapefile, method: :get do + fmu = Fmu.find(params[:id]) + download_shapefiles([fmu]) + end + + action_item :download_shapefile, only: :show do + link_to I18n.t("active_admin.fmus_page.download_shapefile"), download_shapefile_admin_fmu_path(fmu), method: :get + end + permit_params :id, :name, :certification_fsc, :certification_pefc, :certification_olb, :certification_pafc, :certification_fsc_cw, :certification_tlv, :certification_ls, :esri_shapefiles_zip, :forest_type, :country_id, @@ -42,6 +69,19 @@ def scoped_collection } end + sidebar "Shapefiles" do + div do + link_to "Download Filtered Shapefiles", download_filtered_shapefiles_admin_fmus_path( + q: params[:q]&.to_unsafe_h + ), class: "shapefiles_button" + end + end + + collection_action :download_filtered_shapefiles, method: :get do + fmus = Fmu.ransack(params.dig(:q)).result(distinct: true) + download_shapefiles(fmus) + end + csv do column :id column :name @@ -88,6 +128,7 @@ def scoped_collection end index do + selectable_column column :id, sortable: true column :name, sortable: true column :country, sortable: "country_translations.name" @@ -100,7 +141,12 @@ def scoped_collection column "TLV", :certification_tlv column "LS", :certification_ls - actions + actions defaults: false do |fmu| + item I18n.t("active_admin.fmus_page.download_shapefile"), download_shapefile_admin_fmu_path(fmu), method: :get + item I18n.t("active_admin.view"), admin_fmu_path(fmu) + item I18n.t("active_admin.edit"), edit_admin_fmu_path(fmu) + item I18n.t("active_admin.delete"), admin_fmu_path(fmu), method: :delete, data: {confirm: I18n.t("active_admin.fmus_page.confirm_delete")} + end end form do |f| diff --git a/app/assets/javascripts/active_admin.js b/app/assets/javascripts/active_admin.js index 162fc4665..0d5ba45a1 100644 --- a/app/assets/javascripts/active_admin.js +++ b/app/assets/javascripts/active_admin.js @@ -16,6 +16,7 @@ //= require quality_controls //= require active_admin/active_admin_globalize + //= require chartkick //= require Chart.bundle diff --git a/app/assets/stylesheets/active_admin.scss b/app/assets/stylesheets/active_admin.scss index 2f7a71015..2f4e2d57a 100644 --- a/app/assets/stylesheets/active_admin.scss +++ b/app/assets/stylesheets/active_admin.scss @@ -93,6 +93,12 @@ $to-be-reviewed: #99AA99; } } +.shapefiles_button { + @extend .button; + display: block !important; + text-align: center; +} + // To increase the width of observations-details .col-details { min-width: 500px; diff --git a/app/services/shapefile_service.rb b/app/services/shapefile_service.rb new file mode 100644 index 000000000..2841dc440 --- /dev/null +++ b/app/services/shapefile_service.rb @@ -0,0 +1,69 @@ +require "gdal" +require "rgeo" +require "zip" + +class ShapefileService + # Generate a shapefile from a collection of objects that respond to the `geometry` method + # The `geometry` method should return an RGeo::Feature object + def self.generate_shapefile(shapes) + # Create a temporary directory to store the shapefile components + Dir.mktmpdir do |dir| + shapes_name = (shapes.size == 1) ? shapes.first.name : "shapes" + + shapefile_path = File.join(dir, "#{shapes_name}.shp") + + # Initialize GDAL + driver = Gdal::Ogr.get_driver_by_name("ESRI Shapefile") + datasource = driver.create_data_source(shapefile_path) + layer = datasource.create_layer("shapes", nil, Gdal::Ogr::WKBPOLYGON) + + # Define the fields + field_defn = Gdal::Ogr::FieldDefn.new("id", Gdal::Ogr::OFTINTEGER) + layer.create_field(field_defn) + field_defn = Gdal::Ogr::FieldDefn.new("name", Gdal::Ogr::OFTSTRING) + layer.create_field(field_defn) + field_defn = Gdal::Ogr::FieldDefn.new("operator", Gdal::Ogr::OFTSTRING) + layer.create_field(field_defn) + + shapes.each do |shape| + feature = Gdal::Ogr::Feature.new(layer.get_layer_defn) + feature.set_field("id", shape.id) + feature.set_field("name", shape.name) + feature.set_field("operator", shape.operator&.name) + geometry = Gdal::Ogr.create_geometry_from_wkt(shape.geometry.as_text) + feature.set_geometry(geometry) + layer.create_feature(feature) + end + + datasource.sync_to_disk + + # Write the .prj file + prj_content = generate_prj_content + prj_path = File.join(dir, "#{shapes_name}.prj") + File.write(prj_path, prj_content) + + # Collect the generated shapefile parts + files = Dir.glob("#{dir}/*") + + zipfile_path = File.join(dir, "#{shapes_name}.zip") + Zip::File.open(zipfile_path, Zip::File::CREATE) do |zipfile| + files.each do |file| + zipfile.add(File.basename(file), file) + end + end + + # Read the zipfile content + zip_content = File.read(zipfile_path) + + # Return the zip content + zip_content + end + end + + def self.generate_prj_content + 'GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,' \ + 'AUTHORITY["EPSG","7030"]],AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0,' \ + 'AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,' \ + 'AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4326"]]' + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 9f27d84f4..467a75bdf 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -329,6 +329,9 @@ en: min_fine: 'Minimum Fine' indicator_apv: 'Indicator APV' law_details: 'Law details' + fmus_page: + download_shapefile: 'Download shapefile' + confirm_delete: 'Are you sure you want to delete this FMU?' operator_page: producer: 'Producer' producer_activated: 'Producer activated' diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 4c465e6ed..ce3038107 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -361,6 +361,9 @@ fr: min_fine: 'Amende minimale' indicator_apv: 'Indicateur APV' law_details: 'Détails de la loi' + fmus_page: + download_shapefile: 'Télécharger shapefile' + confirm_delete: 'Êtes-vous sûr de vouloir supprimer cette FMU?' required_operator_document_page: exists: 'Existe' publication_authorization: 'Autorisation de publication' diff --git a/spec/services/shapefile_service_spec.rb b/spec/services/shapefile_service_spec.rb new file mode 100644 index 000000000..c9141e813 --- /dev/null +++ b/spec/services/shapefile_service_spec.rb @@ -0,0 +1,22 @@ +require "rails_helper" + +RSpec.describe ShapefileService do + describe ".generate_shapefile" do + let(:fmu1) { create(:fmu_geojson) } + let(:fmu2) { create(:fmu_geojson) } + + it "returns a zip file with the shapefile components" do + file_content = described_class.generate_shapefile([fmu1.reload, fmu2.reload]) + zip_file = Tempfile.new("shapes.zip") + zip_file.write(file_content) + zip_file.rewind + + Zip::File.open(zip_file) do |zip| + expect(zip.glob("*.shp").count).to eq(1) + expect(zip.glob("*.shx").count).to eq(1) + expect(zip.glob("*.dbf").count).to eq(1) + expect(zip.glob("*.prj").count).to eq(1) + end + end + end +end