diff --git a/Gemfile b/Gemfile index 3b0d37ba..1814d4ce 100644 --- a/Gemfile +++ b/Gemfile @@ -5,6 +5,7 @@ source "https://rubygems.org" ruby RUBY_VERSION DECIDIM_VERSION = { git: "https://github.com/CodiTramuntana/decidim", branch: "release/0.28-stable_decidim_templates" }.freeze +gem "concurrent-ruby", "1.3.4" gem "decidim", DECIDIM_VERSION gem "decidim-templates", DECIDIM_VERSION diff --git a/Gemfile.lock b/Gemfile.lock index 49d4ecd7..2c506f52 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -499,7 +499,7 @@ GEM activesupport (>= 2) nokogiri (>= 1.4) htmlentities (4.3.4) - i18n (1.14.6) + i18n (1.14.7) concurrent-ruby (~> 1.0) i18n-tasks (1.0.14) activesupport (>= 4.0.2) @@ -536,9 +536,10 @@ GEM activerecord kaminari-core (= 1.2.2) kaminari-core (1.2.2) - launchy (3.0.1) + launchy (3.1.0) addressable (~> 2.8) childprocess (~> 5.0) + logger (~> 1.6) letter_opener (1.10.0) launchy (>= 2.2, < 4) letter_opener_web (3.0.0) @@ -549,7 +550,7 @@ GEM listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - logger (1.6.4) + logger (1.6.5) loofah (2.24.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) @@ -608,8 +609,8 @@ GEM rack-protection omniauth-facebook (5.0.0) omniauth-oauth2 (~> 1.2) - omniauth-google-oauth2 (1.2.0) - jwt (>= 2.9) + omniauth-google-oauth2 (1.2.1) + jwt (>= 2.9.2) oauth2 (~> 2.0) omniauth (~> 2.0) omniauth-oauth2 (~> 1.8) @@ -636,9 +637,9 @@ GEM activerecord (>= 5.2) request_store (~> 1.1) parallel (1.26.3) - parallel_tests (4.8.0) + parallel_tests (4.9.0) parallel - parser (3.3.6.0) + parser (3.3.7.0) ast (~> 2.4.1) racc pg (1.4.6) @@ -657,7 +658,7 @@ GEM psych (4.0.6) stringio public_suffix (6.0.1) - puma (6.5.0) + puma (6.6.0) nio4r (~> 2.0) racc (1.8.1) rack (2.2.10) @@ -716,7 +717,7 @@ GEM rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) - recaptcha (5.18.0) + recaptcha (5.19.0) redcarpet (3.6.0) redis (4.8.1) regexp_parser (2.10.0) @@ -769,7 +770,7 @@ GEM rubocop-ast (>= 1.28.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.37.0) + rubocop-ast (1.38.0) parser (>= 3.3.1.0) rubocop-capybara (2.21.0) rubocop (~> 1.41) @@ -800,7 +801,7 @@ GEM sass-listen (4.0.0) rb-fsevent (~> 0.9, >= 0.9.4) rb-inotify (~> 0.9, >= 0.9.7) - selenium-webdriver (4.27.0) + selenium-webdriver (4.28.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) @@ -845,10 +846,10 @@ GEM sys-uname (1.0.4) ffi (>= 1.0.0) temple (0.10.3) - terminal-table (3.0.2) - unicode-display_width (>= 1.1.1, < 3) + terminal-table (4.0.0) + unicode-display_width (>= 1.1.1, < 4) thor (1.3.2) - tilt (2.5.0) + tilt (2.6.0) timeout (0.4.3) trailblazer-option (0.1.2) tzinfo (2.0.6) @@ -908,6 +909,7 @@ DEPENDENCIES bootsnap byebug capybara-screenshot + concurrent-ruby (= 1.3.4) daemons database_cleaner decidim! diff --git a/app/decorators/decidim/coauthorable_decorator.rb b/app/decorators/decidim/coauthorable_decorator.rb new file mode 100644 index 00000000..6ff41d10 --- /dev/null +++ b/app/decorators/decidim/coauthorable_decorator.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Decidim::CoauthorableDecorator + def add_external_author(external_author_name, organization) + external_author = Decidim::ExternalAuthor.find_or_create_by(name: external_author_name, organization: organization) + + return if coauthorships.exists?(decidim_author_id: external_author.id, decidim_author_type: external_author.class.base_class.name) + + generate_coauthorship(external_author) + + authors << external_author + end + + def add_location(meeting_url) + segment_id = meeting_url.split("/").last.to_i + + location = Decidim::Meetings::Meeting.find(segment_id) + + return if coauthorships.exists?(decidim_author_id: location.id, decidim_author_type: location.class.base_class.name) + + generate_coauthorship(location) + + authors << location + end + + private + + def generate_coauthorship(new_author) + coauthorship_attributes = { author: new_author } + if persisted? + coauthorships.create!(coauthorship_attributes) + else + coauthorships.build(coauthorship_attributes) + end + end +end + +::Decidim::Coauthorable.prepend ::Decidim::CoauthorableDecorator diff --git a/app/decorators/decidim/proposals/import/proposal_creator_decorator.rb b/app/decorators/decidim/proposals/import/proposal_creator_decorator.rb index 558aa0a0..fbc6edea 100644 --- a/app/decorators/decidim/proposals/import/proposal_creator_decorator.rb +++ b/app/decorators/decidim/proposals/import/proposal_creator_decorator.rb @@ -10,6 +10,19 @@ def finish_without_notif! end resource end + + def produce + if data[:meeting_url].present? + resource.add_location(data[:meeting_url]) + elsif data.dig(:external_author, "name").present? || data[:"external_author/name"].present? + resource.add_external_author((data.dig(:external_author, "name") || data[:'external_author/name']), + context[:current_organization]) + else + resource.add_coauthor(context[:current_user], user_group: context[:user_group]) + end + + resource + end end end end diff --git a/app/models/decidim/external_author.rb b/app/models/decidim/external_author.rb new file mode 100644 index 00000000..24403094 --- /dev/null +++ b/app/models/decidim/external_author.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Decidim + class ExternalAuthor < ApplicationRecord + include Decidim::ActsAsAuthor + include Decidim::Followable + + belongs_to :organization, foreign_key: "decidim_organization_id", class_name: "Decidim::Organization" + + validates :name, presence: true, uniqueness: { scope: :organization } + + def presenter + Decidim::ExternalAuthorPresenter.new(self) + end + end +end diff --git a/app/presenters/decidim/external_author_presenter.rb b/app/presenters/decidim/external_author_presenter.rb new file mode 100644 index 00000000..e1c76f42 --- /dev/null +++ b/app/presenters/decidim/external_author_presenter.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Decidim + class ExternalAuthorPresenter < SimpleDelegator + include ActionView::Helpers::UrlHelper + + delegate :name, to: :__getobj__ + + def avatar_url(_something) + ActionController::Base.helpers.asset_pack_path("media/images/default-avatar.svg") + end + + def respond_to_missing?(*) + true + end + + def method_missing(method, *args) + if method.to_s.ends_with?("?") + false + elsif [:profile_path, :badge, :followers, :followers_count, :cache_key_with_version].include?(method) + "" + else + super + end + end + end +end diff --git a/config/locales/ca_proposals.yml b/config/locales/ca_proposals.yml index 3e4a0e9c..224a3a2e 100644 --- a/config/locales/ca_proposals.yml +++ b/config/locales/ca_proposals.yml @@ -30,8 +30,22 @@ ca: application_helper: filter_type_values: amendments: Esmenes + admin: + imports: + help: + proposals: | + El document ha d'incloure els següents noms de columna en cas d'arxius CSV o Excel o noms de claus en el cas d'arxius JSON: + proposals: filters: amendment_type: Tipus show: proposal_in_evaluation_reason: 'Aquesta proposta està en avaluació perquè:' + diff --git a/config/locales/en_proposals.yml b/config/locales/en_proposals.yml index 9003c059..55a07045 100644 --- a/config/locales/en_proposals.yml +++ b/config/locales/en_proposals.yml @@ -14,6 +14,19 @@ en: email_outro: You have received this notification because you are following "%{participatory_space_title}". You can unfollow it from the previous link. email_subject: New proposals added to %{participatory_space_title} proposals: + admin: + imports: + help: + proposals: | + The file must have the following column names in case of CSV or Excel files, or key names in case of JSON files: + proposals: show: proposal_in_evaluation_reason: 'This proposal is under evaluation because:' diff --git a/config/locales/es_proposals.yml b/config/locales/es_proposals.yml index 4ee4cabe..ec41477e 100644 --- a/config/locales/es_proposals.yml +++ b/config/locales/es_proposals.yml @@ -14,6 +14,19 @@ es: email_outro: Has recibido esta notificación porque estas siguiendo el espacio "%{participatory_space_title}". Puedes dejar de recibir notificaciones siguiendo el enlace anterior. email_subject: Nuevas propuestas añadidas a %{participatory_space_title} proposals: + admin: + imports: + help: + proposals: | + El documento de importación debe contener los siguientes nombres de columna en caso de archivos CSV o Excel o nombres de claves en caso de archivos JSON: + proposals: show: proposal_in_evaluation_reason: 'Esta propuesta está en evaluación porque:' diff --git a/config/locales/oc_proposals.yml b/config/locales/oc_proposals.yml index 14c972ef..508de74e 100644 --- a/config/locales/oc_proposals.yml +++ b/config/locales/oc_proposals.yml @@ -365,6 +365,18 @@ oc: exports: comments: Comentaris proposals: Propostes + imports: + help: + proposals: | + Eth document li cau includir es següents nòms de colomna en cas d'archius CSV o Excel o nòms de claus en cas d'archius JSON: + models: proposal: name: Proposta diff --git a/db/migrate/20241105163000_create_decidim_external_authors.rb b/db/migrate/20241105163000_create_decidim_external_authors.rb new file mode 100644 index 00000000..4dd1687f --- /dev/null +++ b/db/migrate/20241105163000_create_decidim_external_authors.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true +# This migration comes from decidim (originally 20220629194812) + +class CreateDecidimExternalAuthors < ActiveRecord::Migration[6.1] + def change + create_table :decidim_external_authors do |t| + t.string :name, null: false + t.integer :decidim_organization_id, index: true, foreign_key: true + + t.timestamps + + t.index [:decidim_organization_id, :name], + name: "index_unique_name_and_organization", + unique: true + end + + add_index :decidim_external_authors, :name, unique: true + end +end diff --git a/lib/decidim/core/test/gencat_factories.rb b/lib/decidim/core/test/gencat_factories.rb new file mode 100644 index 00000000..36655848 --- /dev/null +++ b/lib/decidim/core/test/gencat_factories.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :external_author, class: "Decidim::ExternalAuthor" do + transient do + skip_injection { false } + end + + name { generate(:name) } + organization + end +end diff --git a/spec/decorators/decidim/proposals/import/proposal_creator_decorator_spec.rb b/spec/decorators/decidim/proposals/import/proposal_creator_decorator_spec.rb index 98ee9698..ca84a890 100644 --- a/spec/decorators/decidim/proposals/import/proposal_creator_decorator_spec.rb +++ b/spec/decorators/decidim/proposals/import/proposal_creator_decorator_spec.rb @@ -18,11 +18,13 @@ latitude: Faker::Address.latitude, longitude: Faker::Address.longitude, component: component, - published_at: moment + published_at: moment, + "external_author/name": "Extenal author name" } end let(:organization) { create(:organization, available_locales: [:en]) } let(:user) { create(:user, organization: organization) } + let(:meeting) { create(:meeting) } let(:context) do { current_organization: organization, @@ -53,4 +55,54 @@ end end end + + describe "#produce" do + it "makes a new proposal with external author" do + record = subject.produce + + expect(record).to be_a(Decidim::Proposals::Proposal) + expect(record.category).to eq(category) + expect(record.scope).to eq(scope) + expect(record.title["en"]).to eq(data[:"title/en"]) + expect(record.body["en"]).to eq(data[:"body/en"]) + expect(record.address).to eq(data[:address]) + expect(record.latitude).to eq(data[:latitude]) + expect(record.longitude).to eq(data[:longitude]) + expect(record.published_at).to be >= (moment) + expect(record.coauthorships.first.author.name).to eq(data["external_author/name".to_sym]) + end + end + + describe "#produce with meeting_url" do + let(:data) do + { + id: 1337, + category: category, + scope: scope, + "title/en": Faker::Lorem.sentence, + "body/en": Faker::Lorem.paragraph(sentence_count: 3), + address: "#{Faker::Address.street_name}, #{Faker::Address.city}", + latitude: Faker::Address.latitude, + longitude: Faker::Address.longitude, + component: component, + published_at: moment, + "meeting_url": "url_meeting/#{meeting.id}" + } + end + + it "makes a new proposal with location" do + record = subject.produce + + expect(record).to be_a(Decidim::Proposals::Proposal) + expect(record.category).to eq(category) + expect(record.scope).to eq(scope) + expect(record.title["en"]).to eq(data[:"title/en"]) + expect(record.body["en"]).to eq(data[:"body/en"]) + expect(record.address).to eq(data[:address]) + expect(record.latitude).to eq(data[:latitude]) + expect(record.longitude).to eq(data[:longitude]) + expect(record.published_at).to be >= (moment) + expect(record.coauthorships.first.author.id).to eq(meeting.id) + end + end end diff --git a/spec/factories.rb b/spec/factories.rb index 972b3440..66e51a3e 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -4,3 +4,4 @@ require "decidim/participatory_processes/test/factories" require "decidim/proposals/test/factories" require "decidim/meetings/test/factories" +require "decidim/core/test/gencat_factories" diff --git a/spec/models/external_author_spec.rb b/spec/models/external_author_spec.rb new file mode 100644 index 00000000..161ff8c9 --- /dev/null +++ b/spec/models/external_author_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "ExternalAuthor" do + subject { external_author } + + let(:organization) { create(:organization) } + let(:external_author) { build(:external_author, organization: organization) } + + it { is_expected.to be_valid } + + describe "name" do + context "when it has a name" do + let(:second_external_author) { build(:external_author, name: "External Author") } + + it "returns the name" do + expect(second_external_author.name).to eq("External Author") + end + end + end + + describe "validations" do + context "when the name is empty" do + before do + external_author.name = "" + end + + it "is not valid" do + expect(external_author).not_to be_valid + expect(external_author.errors[:name]).to include("cannot be blank") + end + end + + context "when the name already exists" do + it "cannot have duplicates on the same organization" do + external_author.save! + + expect(build(:external_author, organization: external_author.organization, + name: external_author.name)).not_to be_valid + end + end + end +end