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:
+
+ - title/ca: Títol en català. Això dependrà de la configuració d'idioma predeterminat a la teva plataforma.
+ - body/ca: Descripció en català. Això dependrà de la configuració d'idioma predeterminat a la teva plataforma.
+ - scope/id: ID de l'Àmbit
+ - category/id: ID de la Categoria
+ - external_author/name: Nom de l'autor de la proposta extern a la plataforma Decidim
+ - meeting_url: Url de la trobada a la plataforma Decidim
+
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:
+
+ - title/en: Title on English language. This will depend on your platform language configuration.
+ - body/en: Body on English language. This will depend on your platform language configuration.
+ - scope/id: ID for the Scope
+ - category/id: ID for the Category
+ - external_author/name: Proposal author name outside of the Decidim platform
+ - meeting_url: Meeting url on the Decidim platform
+
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:
+
+ - title/es: Título en castellano. Esto dependerá de la configuración de idioma predeterminado de tu plataforma.
+ - body/en: Descripción en castellano. Esto dependerá de la configuración de idioma predeterminado de tu plataforma.
+ - scope/id: ID del ámbito
+ - category/id: ID de la Categoría
+ - external_author/name: Nombre del autor de la propuesta externo a la plataforma Decidim
+ - meeting_url: Url del encuentro en la plataforma Decidim
+
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:
+
+ - title/ca: Títol en catalan. Açò depenerà dera configuracion d'idiòma predeterminat ara tua plataforma.
+ - body/ca: Descripcion en catalan. Açò depenerà dera configuracion d'idiòma predeterminat ara tua plataforma.
+ - scope/id: ID der encastre
+ - category/id: ID dera Categoria
+ - external_author/name: Nòm der autor dera prepausa extèrna ara plataforma Decidim
+ - meeting_url: Url dera trobada ara plataforma Decidim
+
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