diff --git a/.github/actions/spelling/excludes.txt b/.github/actions/spelling/excludes.txt index 28c5f8f988d94..9801f099f9e43 100644 --- a/.github/actions/spelling/excludes.txt +++ b/.github/actions/spelling/excludes.txt @@ -101,6 +101,7 @@ ^\Qdecidim-core/app/views/layouts/decidim/_edit_link.html.erb\E$ ^\Qdecidim-core/lib/decidim/db/common-passwords.txt\E$ ^\Qdecidim-core/spec/mailers/decidim_devise_mailer_spec.rb\E$ +^\Qdecidim-core/spec/db/data/add_short_name_to_organizations_spec.rb\E$ ^\Qdecidim-dev/lib/decidim/dev/assets/assemblies.json\E$ ^\Qdecidim-dev/lib/decidim/dev/assets/base64_content.html\E$ ^\Qdecidim-dev/lib/decidim/dev/assets/empty_file.csv\E$ diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 66d3cf4d9aa30..e1d7d086eb790 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -281,6 +281,8 @@ evanfuture evt Exampledocument exitstatus +Existente +EXISTINGCITY faketoken Fal fcell @@ -547,6 +549,7 @@ msword multichoice multifield multitenant +mycity myengine mypass myprovider @@ -639,7 +642,7 @@ pamplona pandoc paramxx partcipatory -participatoryspaceprivateusers +participatoryspace patrimonigracia Peguera pepito diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 6b4064b6a917f..d011956bcd854 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -1,46 +1,41 @@ # Decidim GitHub Actions workflows -We use GitHub Actions as CI. - -- `lint_code.yml`: runs the linters for Ruby, JS and ERB files. -- `ci_main.yml`: runs the tests for the main folder -- `ci_core.yml`: runs the tests for the `decidim-core` module. The remaining workflows (except noted) are based on this one. - -Individual workflows with changes: - -- `ci_generators.yml`: `decidim-generators` does not need to create the test_app, so this command is removed. Screenshots uploads and chromedriver setup steps are also not needed for this module and thus removed. We also customize the gems path after running `bundle install`: - -```yml -# ci_generators.yml -- run: bundle install --path vendor/bundle --jobs 4 --retry 3 - name: Install Ruby deps -- run: cp -R vendor/bundle decidim-generators -- run: bundle exec rspec - name: RSpec - working-directory: ${{ env.DECIDIM_MODULE }} -``` - -- `ci_javascript.yml`: Runs tests for the JS files. Tests must run from the project root folder. You will need to install NodeJS and the JS dependencies: - -```yml -- uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} -- run: npm ci - name: Install JS deps -- run: npm run test - name: Test JS files -``` - -- Some specs are split in three workflows, so if we need to retry this particular workflow we do not need to retry all the module suite. For instance proposals: - - - `ci_proposals_system_admin.yml`: Runs the system specs for the admin section - - `ci_proposals_system_public.yml`: Runs the system specs for the public section - - `ci_proposals_unit_tests.yml`: Runs the unit tests - -- `ci_performance_metrics_monitoring.yml`: Runs Lighthouse metrics expectations against the app to detect any performance regression. The expectations can be found in `lighthouse_budget.json`, where a time is defined for each metric: - - - [First Contentful Paint](https://web.dev/first-contentful-paint/): 2 seconds - - [Speed Index](https://web.dev/speed-index/): 4 seconds - - [Time to Interactive](https://web.dev/interactive/): 5 seconds - - [Largest Contentful Paint](https://web.dev/lcp/): 2.5 seconds +We use GitHub Actions as CI with two key optimizations: **workflow splitting** and **composite actions**. + +## Architecture + +### Composite Actions + +- `test_app.yml`: [Reusable workflow](https://docs.github.com/en/actions/using-workflows/reusing-workflows) that provides all common CI setup (Ruby, Node.js, database, Chrome, etc.) +- All `ci_*.yml` workflows use this composite action via `uses: ./.github/workflows/test_app.yml` +- Reduces duplication and simplifies maintenance + +### Workflow Splitting + +Large test suites are split into [parallel workflows](https://docs.github.com/en/actions/using-jobs/using-jobs-in-a-workflow) to reduce execution time: + +## Core Workflows + +- `lint_code.yml`: Lints Ruby, JS, and ERB files +- `ci_main.yml`: Tests for main folder +- `ci_core.yml`: Base template for module testing using `test_app.yml` + +## Special Cases + +- `ci_generators.yml`: No test app needed, uses custom gem path setup +- `ci_javascript.yml`: Runs JS tests from project root with Node.js setup + +## Split Workflows (Parallel Execution) + +Modules with large test suites are split across multiple workflows: + +- Proposals: `ci_proposals_system_admin.yml`, `ci_proposals_system_public.yml`, `ci_proposals_unit_tests.yml` +- Similar patterns for other large modules + +## Performance Monitoring + +- `ci_performance_metrics_monitoring.yml`: Lighthouse CI with budgets: + - [First Contentful Paint](https://web.dev/first-contentful-paint/): 2s + - [Speed Index](https://web.dev/speed-index/): 4s + - [Time to Interactive](https://web.dev/interactive/): 5s + - [Largest Contentful Paint](https://web.dev/lcp/): 2.5s diff --git a/.github/workflows/ci_api.yml b/.github/workflows/ci_api.yml index 855c44d3fac4e..e7f078c2a2175 100644 --- a/.github/workflows/ci_api.yml +++ b/.github/workflows/ci_api.yml @@ -33,5 +33,6 @@ jobs: with: working-directory: "decidim-api" test_command: bundle exec parallel_test --type rspec --pattern spec/ + bullet_enabled: true bullet_n_plus_one: true bullet_unused_eager_loading: true diff --git a/.github/workflows/test_app.yml b/.github/workflows/test_app.yml index 58132b6431b16..69849a2b5b2f5 100644 --- a/.github/workflows/test_app.yml +++ b/.github/workflows/test_app.yml @@ -54,7 +54,7 @@ jobs: PARALLEL_TEST_PROCESSORS: 3 services: validator: - image: ghcr.io/validator/validator:latest + image: ghcr.io/validator/validator@sha256:7667b0ffa6d395c27aa8f9e21db1cfe6b66549245a3972e9397d255de5ef0ec6 ports: ["8888:8888"] postgres: image: postgres:14 diff --git a/Gemfile.lock b/Gemfile.lock index fcc840f633463..9a7d810772349 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -914,6 +914,7 @@ GEM PLATFORMS arm64-darwin-23 + arm64-darwin-25 x86_64-linux DEPENDENCIES diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f116a7bea1227..32d3a4f13b831 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -94,7 +94,7 @@ At the moment we are adding this gem so we can start doing data migrations for f You can read more about this change on PR [#15501](https://github.com/decidim/decidim/pull/15501). -#### 2.4. Fix gitignore for ServiceWorker related files +### 2.4. Fix gitignore for ServiceWorker related files We detected a bug where some dynamic files are not added to the gitignore, so they could be committed to the repository. For fixing it, you need to add them to your gitignore file: @@ -104,7 +104,17 @@ echo "/public/sw.js*" >> .gitignore You can read more about this change on PR [#15601](https://github.com/decidim/decidim/pull/15601). -### 2.5. Add locale to the url +### 2.5. Data migration for organization short_name + +A new data migration has been added to populate the `short_name` field for existing organizations. This field is required for the PWA (Progressive Web App) manifest to properly display the application name on mobile devices' home screens. + +The migration automatically generates a short_name for each organization based on its name by removing spaces and truncating to 12 characters maximum. Organizations with names that result in less than 3 characters after processing will not have a short_name set and will need to be configured manually through the admin panel. + +This migration runs automatically when executing `bin/rails data:migrate` as part of the upgrade process. + +You can read more about this change on PR [#15729](https://github.com/decidim/decidim/pull/15729). + +### 2.6. Add locale to the url For a long time Decidim has been using internally the user browser to detect the language of the user. This has been changed to use the locale of the url instead. @@ -119,7 +129,7 @@ It also enables the users of multi language platforms to share the links to the You can read more about this change on PR [#14432](https://github.com/decidim/decidim/pull/14432). -### 2.6. [[TITLE OF THE ACTION]] +### 2.7. [[TITLE OF THE ACTION]] You can read more about this change on PR [#XXXX](https://github.com/decidim/decidim/pull/XXXX). diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 6dfc3ce28997c..12d2dfe08cd78 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -70,7 +70,7 @@ search: ## %w(*.jpg *.png *.gif *.svg *.ico *.eot *.otf *.ttf *.woff *.woff2 *.pdf *.css *.sass *.scss *.less *.yml *.json) exclude: - decidim-dev/lib/decidim/dev/assets/iso-8859-15.md - - decidim-dev/lib/decidim/dev/assets/import_participatory_space_private_users_iso8859-1.csv + - decidim-dev/lib/decidim/dev/assets/import_members_iso8859-1.csv - decidim-comments/app/assets/javascripts/decidim/comments/bundle.js - decidim-comments/app/assets/javascripts/decidim/comments/bundle.js.map - "*.jpeg" diff --git a/decidim-accountability/config/locales/eu.yml b/decidim-accountability/config/locales/eu.yml index ce4b3076b7fc3..6cb8ecdf900b6 100644 --- a/decidim-accountability/config/locales/eu.yml +++ b/decidim-accountability/config/locales/eu.yml @@ -270,7 +270,7 @@ eu: one: Emaitza 1 other: "%{count} emaitza" home_header: - global_status: Exekuzio-egoera orokorra + global_status: Helburuen betetze-maila milestones: title: Mugarriak no_results: Ez dago proiekturik diff --git a/decidim-accountability/config/locales/ja.yml b/decidim-accountability/config/locales/ja.yml index ad00838eb53c7..67af1d5a694b7 100644 --- a/decidim-accountability/config/locales/ja.yml +++ b/decidim-accountability/config/locales/ja.yml @@ -287,6 +287,7 @@ ja: accountability: actions: comment: コメント + vote_comment: コメントに投票 name: アカウンタビリティ settings: global: diff --git a/decidim-accountability/config/locales/tr-TR.yml b/decidim-accountability/config/locales/tr-TR.yml index 6f722b4ce4d98..129985b4ef9ae 100644 --- a/decidim-accountability/config/locales/tr-TR.yml +++ b/decidim-accountability/config/locales/tr-TR.yml @@ -1,6 +1,10 @@ tr: activemodel: attributes: + milestone: + description: Açıklama + entry_date: Tarih + title: Başlık result: decidim_accountability_status_id: Durum decidim_category_id: Kategori @@ -36,6 +40,7 @@ tr: decidim: accountability: actions: + add_milestone: Aşama ekle confirm_delete_result: Bu sonucu silmek istediğinizden emin misiniz? confirm_destroy: Bu %{name}silmek istediğinize emin misiniz? deleted_results_info: Silinen sonuçlar çöp kutusundan geri yüklenebilir. @@ -43,6 +48,7 @@ tr: edit: Düzenle import: Sonuçları başka bir bileşenden içe aktar import_csv: Sonuçları CSV dosyasından içe aktarın + new_milestone: Yeni aşama new_result: Yeni sonuç new_status: Yeni Durum preview: Ön izleme @@ -83,6 +89,20 @@ tr: create: invalid: Sonuçlar içe aktarılırken bir sorun oluştu. success: Dosya aktarımı başladı. Birkaç dakika içinde içe aktarma işleminin sonucunu içeren bir e-posta alacaksınız. + milestones: + create: + invalid: Bu aşama oluşturulurken bir hata oluştu. + success: Aşama başarıyla oluşturuldu. + destroy: + success: Aşama başarıyla silindi. + edit: + title: Aşamayı düzenle + update: Aşamayı güncelle + new: + create: Aşama oluştur + update: + invalid: Bu aşama güncellenirken bir hata oluştu. + success: Aşama başarıyla güncellendi. models: result: name: Sonuç @@ -133,6 +153,7 @@ tr: invalid: Sonuçlar %{results} için sınıflandırmalar %{taxonomies} seçilemiyor select_a_result: Bir sonuç seç select_a_taxonomy: Bir sınıflandırma seç + success: '%{results} için sınıflandırmalar %{taxonomies} başarıyla güncellendi' shared: subnav: statuses: durumlar @@ -157,6 +178,7 @@ tr: result: create: "%{user_name} sonuç yaratmıştır %{resource_name} içinde %{space_name}" delete: "%{user_name} %{resource_name} sonuçtan %{space_name}sildi" + restore: "%{user_name}, %{space_name}'deki %{resource_name} sonucunu geri yükledi" update: "%{user_name} güncellenen sonuç %{resource_name} in %{space_name}" status: create: "48 / 5.000\nÇeviri sonuçları\nÇeviri sonucu\n%{user_name}, %{resource_name} kaydını oluşturdu" @@ -168,6 +190,8 @@ tr: content_blocks: highlighted_results: results: Sonuç + creation: + text: Bu sonuç eklendi import_mailer: import: errors: Hatalar @@ -177,8 +201,18 @@ tr: success: Sonuçların içe aktarılması başarılı. Sonuçları yönetim arayüzünde inceleyebilirsiniz. import_projects_mailer: import: + added_projects: + one: Projelerden bir sonuç içe aktarıldı. + other: "%{count} sonuç projelerden içe aktarıldı." subject: Projeler başarıyla aktarılmıştır success: '%{component_name} bileşenindekiprojeler başarıyla aktarılmıştır. Sonuçları yönetim arayüzünde inceleyebilirsiniz.' + import_proposals_mailer: + import: + added_proposals: + one: Tekliflerden bir sonuç içe aktarıldı + other: "%{count} sonuç tekliflerden içe aktarıldı." + subject: Tekliflerin içe aktarımı başarılı + success: Teklifler %{component_name} bileşenindeki sonuçlara başarıyla aktarılmıştır. Sonuçları yönetim arayüzünde inceleyebilirsiniz. last_activity: new_result: 'Yeni sonuç:' models: @@ -203,6 +237,8 @@ tr: other: "%{count} sonuç" home_header: global_status: Genel yürütme durumu + milestones: + title: Aşamalar no_results: Proje Bulunamadı search: search: İşlemleri ara @@ -214,19 +250,32 @@ tr: results: status_id_eq: label: Durum + tooltips: + deleted_results_info: Bu sonuç silinemiyor components: accountability: actions: comment: Yorum + vote_comment: Yorumu oyla name: Sorumluluk settings: global: + clear_all: Tümünü sil comments_enabled: Yorumlar etkin comments_max_length: Maksimum yorum uzunluğu (Varsayılan değer için 0 bırakın) + default_taxonomy: Varsayılan sınıflandırma + default_taxonomy_help: Varsayılan olarak hangi sınıflandırmayı göstermek istediğinizi seçin. Eğer herhangi bir sınıflandırma seçilmezse, sonuçlar liste biçiminde gösterilecektir. + define_taxonomy_filters: Bu ayarı kullanmadan önce lütfen bu katılımcı alan için bazı filtreler tanımlayın. display_progress_enabled: İlerlemeyi göster intro: Tanıtım + no_taxonomy_filters_found: Sınıflandırma filtresi bulunamadı. + taxonomy_filters_add: Filtre ekle step: comments_blocked: Yorumlar engellendi + download_your_data: + show: + result_comments: Sonuç yorumlarını dışa aktar + results: Sonuçları içe aktar events: accountability: proposal_linked: @@ -241,8 +290,15 @@ tr: notification_title: '%{proposal_title} teklifini içeren %{resource_title} sonucu: %{progress} tamamlandı.' open_data: help: + result_comments: + author: Bu yorumu yapan katılımcının adı + body: Yorumun kendisi + created_at: Bu yorumun oluşturulduğu tarih + root_commentable_url: Bu yoruma bağlı kaynağın URL bağlantısı results: + address: Sonucun adresi (varsa) created_at: Sonucun oluşturulma tarihi + taxonomies: participatory_spaces: highlighted_results: see_all: Tüm sonuçları gör (%{count}) diff --git a/decidim-accountability/lib/decidim/api/accountability_type.rb b/decidim-accountability/lib/decidim/api/accountability_type.rb index 7a583baf55fed..8a33b085c37ea 100644 --- a/decidim-accountability/lib/decidim/api/accountability_type.rb +++ b/decidim-accountability/lib/decidim/api/accountability_type.rb @@ -19,8 +19,8 @@ def results Result.where(component: object).includes(:component) end - def result(**args) - Result.where(component: object).find_by(id: args[:id]) + def result(id:) + Result.where(component: object).find(id) end def statuses @@ -28,7 +28,7 @@ def statuses end def status(id:) - Status.where(component: object).find_by(id:) + Status.where(component: object).find(id) end end end diff --git a/decidim-accountability/spec/system/accountability_breadcrumbs_spec.rb b/decidim-accountability/spec/system/accountability_breadcrumbs_spec.rb new file mode 100644 index 0000000000000..61b89ac754456 --- /dev/null +++ b/decidim-accountability/spec/system/accountability_breadcrumbs_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Accountability Breadcrumb" do + include_context "with a component" + + let(:manifest_name) { "accountability" } + let!(:results) { create_list(:result, 5, component:) } + + describe "index" do + let(:path) { decidim_participatory_process_accountability.results_path(participatory_process_slug: participatory_process.slug, component_id: component.id, locale: I18n.locale) } + + before do + visit path + end + + it "shows the correct information in breadcrumb (space, component)" do + within(".menu-bar") do + expect(page).to have_content(translated(component.participatory_space.title)) + expect(page).to have_content(translated(component.name)) + end + end + end + + describe "show" do + let(:path) { decidim_participatory_process_accountability.result_path(id: result.id, participatory_process_slug: participatory_process.slug, component_id: component.id, locale: I18n.locale) } + let(:results_count) { 1 } + let(:result) { results.first } + + before do + visit path + end + + it "shows the correct information in breadcrumb (space, component, result)" do + within(".menu-bar") do + expect(page).to have_content(translated(component.participatory_space.title)) + expect(page).to have_content(translated(component.name)) + expect(page).to have_content(translated(result.title)) + end + end + + context "with subresults" do + let!(:subresults) { create_list(:result, 3, component:, parent: result) } + let(:first_subresult) { subresults.first } + + before do + visit current_path + end + + it "shows the correct information in breadcrumb (space, component, result, subresult)" do + click_on translated(first_subresult.title) + within(".menu-bar") do + expect(page).to have_content(translated(component.participatory_space.title)) + expect(page).to have_content(translated(component.name)) + expect(page).to have_content(translated(result.title)) + expect(page).to have_content(translated(first_subresult.title)) + end + end + end + end + + describe "versions", versioning: true do + let!(:result) { create(:result, progress: 25.0, component:) } + let(:path) { decidim_participatory_process_accountability.result_path(id: result.id, participatory_process_slug: participatory_process.slug, component_id: component.id, locale: I18n.locale) } + + before do + Decidim.traceability.update!( + result, + "test suite", + progress: 50.0 + ) + visit path + + click_on "see other versions" + end + + it "shows the correct information in breadcrumb (space, component, result)" do + within(".menu-bar") do + expect(page).to have_content(translated(component.participatory_space.title)) + expect(page).to have_content(translated(component.name)) + expect(page).to have_content(translated(result.title)) + end + end + end +end diff --git a/decidim-accountability/spec/system/explore_results_spec.rb b/decidim-accountability/spec/system/explore_results_spec.rb index 78df1e1ab6f97..eeaf643e24aba 100644 --- a/decidim-accountability/spec/system/explore_results_spec.rb +++ b/decidim-accountability/spec/system/explore_results_spec.rb @@ -155,10 +155,6 @@ end it "shows all results for the given process and taxonomy" do - within(".menu-bar") do - expect(page).to have_content(translated(component.name)) - end - within("#results") do expect(page).to have_css(".card__list", count: results_count) @@ -179,10 +175,6 @@ end it "shows all result info" do - within(".menu-bar") do - expect(page).to have_content(translated(component.name)) - expect(page).to have_content(translated(result.title)) - end expect(page).to have_i18n_content(result.title) expect(page).to have_i18n_content(result.description, strip_tags: true) expect(page).to have_content(result.reference) @@ -278,12 +270,6 @@ it "the result is mentioned in the subresult page" do click_on translated(first_subresult.title) expect(page).to have_i18n_content(result.title) - - within(".menu-bar") do - expect(page).to have_content(translated(component.name)) - expect(page).to have_content(translated(result.title)) - expect(page).to have_content(translated(first_subresult.title)) - end end it "a banner links back to the result" do diff --git a/decidim-accountability/spec/system/explore_versions_spec.rb b/decidim-accountability/spec/system/explore_versions_spec.rb index bdaf47efd7817..c2feb05e51c1e 100644 --- a/decidim-accountability/spec/system/explore_versions_spec.rb +++ b/decidim-accountability/spec/system/explore_versions_spec.rb @@ -37,10 +37,6 @@ end it "lists all versions" do - within(".menu-bar") do - expect(page).to have_content(translated(component.name)) - expect(page).to have_content(translated(result.title)) - end expect(page).to have_link("Version 1 of 2") expect(page).to have_link("Version 2 of 2") end diff --git a/decidim-accountability/spec/types/accountability_type_spec.rb b/decidim-accountability/spec/types/accountability_type_spec.rb index ca3d338b2424e..0b53ecb8412ff 100644 --- a/decidim-accountability/spec/types/accountability_type_spec.rb +++ b/decidim-accountability/spec/types/accountability_type_spec.rb @@ -43,8 +43,8 @@ module Accountability context "when the result does not belong to the component" do let!(:result) { create(:result, component: create(:accountability_component)) } - it "returns null" do - expect(response["result"]).to be_nil + it "raises error" do + expect { response }.to raise_error(Decidim::Api::Errors::NotFoundError, "Result not found") end end end @@ -97,8 +97,8 @@ module Accountability context "when the status does not belong to the component" do let!(:status) { create(:status, component: create(:accountability_component)) } - it "returns null" do - expect(response["status"]).to be_nil + it "raises error" do + expect { response }.to raise_error(Decidim::Api::Errors::NotFoundError, "Status not found") end end end diff --git a/decidim-accountability/spec/types/result_type_spec.rb b/decidim-accountability/spec/types/result_type_spec.rb index 72836f3725d35..480003db3518c 100644 --- a/decidim-accountability/spec/types/result_type_spec.rb +++ b/decidim-accountability/spec/types/result_type_spec.rb @@ -20,6 +20,12 @@ module Accountability include_examples "localizable interface" include_examples "referable interface" + shared_examples "unauthorized Result" do + it "throws Decidim::Api::Errors::UnauthorizedObjectError" do + expect { response }.to raise_error(Decidim::Api::Errors::UnauthorizedObjectError, "You cannot view or edit this Result because you do not have permissions") + end + end + describe "id" do let(:query) { "{ id }" } @@ -97,9 +103,7 @@ module Accountability let(:model) { create(:result, component: current_component) } let(:query) { "{ id }" } - it "returns nothing" do - expect(response).to be_nil - end + it_behaves_like "unauthorized Result" end context "when participatory space is private but transparent" do @@ -119,9 +123,7 @@ module Accountability let(:model) { create(:result, component: current_component) } let(:query) { "{ id }" } - it "returns nothing" do - expect(response).to be_nil - end + it_behaves_like "unauthorized Result" end context "when component is not published" do @@ -129,9 +131,7 @@ module Accountability let(:model) { create(:result, component: current_component) } let(:query) { "{ id }" } - it "returns nothing" do - expect(response).to be_nil - end + it_behaves_like "unauthorized Result" end end end diff --git a/decidim-admin/app/commands/decidim/admin/create_participatory_space_private_user.rb b/decidim-admin/app/commands/decidim/admin/create_participatory_space_private_user.rb deleted file mode 100644 index f95c1da6588cc..0000000000000 --- a/decidim-admin/app/commands/decidim/admin/create_participatory_space_private_user.rb +++ /dev/null @@ -1,98 +0,0 @@ -# frozen_string_literal: true - -module Decidim - module Admin - # A command with all the business logic when creating a new participatory space - # private user in the system. - class CreateParticipatorySpacePrivateUser < Decidim::Command - delegate :current_user, to: :form - # Public: Initializes the command. - # - # form - A form object with the params. - # private_user_to - The private_user_to that will hold the - # user role - def initialize(form, private_user_to, via_csv: false) - @form = form - @private_user_to = private_user_to - @via_csv = via_csv - end - - # Executes the command. Broadcasts these events: - # - # - :ok when everything is valid. - # - :invalid if the form was not valid and we could not proceed. - # - # Returns nothing. - def call - return broadcast(:invalid) if form.invalid? - - ActiveRecord::Base.transaction do - @user ||= existing_user || new_user - create_private_user - end - - broadcast(:ok) - rescue ActiveRecord::RecordInvalid - form.errors.add(:email, :taken) - broadcast(:invalid) - end - - private - - attr_reader :form, :private_user_to, :user - - def create_private_user - action = @via_csv ? "create_via_csv" : "create" - Decidim.traceability.perform_action!( - action, - Decidim::ParticipatorySpacePrivateUser, - current_user, - resource: { - title: user.name - } - ) do - Decidim::ParticipatorySpacePrivateUser.find_or_create_by!( - user:, - privatable_to: @private_user_to, - role: form.role, - published: form.published - ) - end - end - - def existing_user - return @existing_user if defined?(@existing_user) - - @existing_user = User.find_by( - email: form.email.downcase, - organization: private_user_to.organization - ) - - InviteUserAgain.call(@existing_user, invitation_instructions) if @existing_user&.invitation_pending? - - @existing_user - end - - def new_user - @new_user ||= InviteUser.call(user_form) do - on(:ok) do |user| - return user - end - end - end - - def user_form - OpenStruct.new(name: form.name, - email: form.email.downcase, - organization: private_user_to.organization, - admin: false, - invited_by: current_user, - invitation_instructions:) - end - - def invitation_instructions - "invite_private_user" - end - end - end -end diff --git a/decidim-admin/app/commands/decidim/admin/destroy_participatory_space_private_user.rb b/decidim-admin/app/commands/decidim/admin/destroy_participatory_space_private_user.rb deleted file mode 100644 index 6b15e2a84b796..0000000000000 --- a/decidim-admin/app/commands/decidim/admin/destroy_participatory_space_private_user.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module Decidim - module Admin - # A command with all the business logic to destroy a participatory space private user. - class DestroyParticipatorySpacePrivateUser < Decidim::Commands::DestroyResource - private - - def extra_params - { - resource: { - title: resource.user.name - } - } - end - - def run_after_hooks - return unless resource.privatable_to.respond_to?(:private_space?) - return unless resource.privatable_to.private_space? - return if resource.privatable_to.respond_to?(:is_transparent) && resource.privatable_to.is_transparent? - - # When private user is destroyed, a hook to destroy the follows of user on private non-transparent assembly - # or private participatory process and the follows of their children - DestroyPrivateUsersFollowsJob.perform_later(resource.decidim_user_id, resource.privatable_to) - end - end - end -end diff --git a/decidim-admin/app/commands/decidim/admin/participatory_space/create_member.rb b/decidim-admin/app/commands/decidim/admin/participatory_space/create_member.rb new file mode 100644 index 0000000000000..e4dd150dd8ce1 --- /dev/null +++ b/decidim-admin/app/commands/decidim/admin/participatory_space/create_member.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module Decidim + module Admin + module ParticipatorySpace + # A command with all the business logic when creating a new participatory space + # member in the system. + class CreateMember < Decidim::Command + delegate :current_user, to: :form + # Public: Initializes the command. + # + # form - A form object with the params. + # member_to - The member_to that will hold the + # user role + def initialize(form, member_to, via_csv: false) + @form = form + @member_to = member_to + @via_csv = via_csv + end + + # Executes the command. Broadcasts these events: + # + # - :ok when everything is valid. + # - :invalid if the form was not valid and we could not proceed. + # + # Returns nothing. + def call + return broadcast(:invalid) if form.invalid? + + ActiveRecord::Base.transaction do + @user ||= existing_user || new_user + create_member + end + + broadcast(:ok) + rescue ActiveRecord::RecordInvalid + form.errors.add(:email, :taken) + broadcast(:invalid) + end + + private + + attr_reader :form, :member_to, :user + + def create_member + action = @via_csv ? "create_via_csv" : "create" + Decidim.traceability.perform_action!( + action, + Decidim::ParticipatorySpace::Member, + current_user, + resource: { + title: user.name + } + ) do + Decidim::ParticipatorySpace::Member.find_or_create_by!( + user:, + privatable_to: @member_to, + role: form.role, + published: form.published + ) + end + end + + def existing_user + return @existing_user if defined?(@existing_user) + + @existing_user = User.find_by( + email: form.email.downcase, + organization: member_to.organization + ) + + InviteUserAgain.call(@existing_user, invitation_instructions) if @existing_user&.invitation_pending? + + @existing_user + end + + def new_user + @new_user ||= InviteUser.call(user_form) do + on(:ok) do |user| + return user + end + end + end + + def user_form + OpenStruct.new(name: form.name, + email: form.email.downcase, + organization: member_to.organization, + admin: false, + invited_by: current_user, + invitation_instructions:) + end + + def invitation_instructions + "invite_member" + end + end + end + end +end diff --git a/decidim-admin/app/commands/decidim/admin/participatory_space/destroy_member.rb b/decidim-admin/app/commands/decidim/admin/participatory_space/destroy_member.rb new file mode 100644 index 0000000000000..6cce98160b482 --- /dev/null +++ b/decidim-admin/app/commands/decidim/admin/participatory_space/destroy_member.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Decidim + module Admin + module ParticipatorySpace + # A command with all the business logic to destroy a member. + class DestroyMember < Decidim::Commands::DestroyResource + private + + def extra_params + { + resource: { + title: resource.user.name + } + } + end + + def run_after_hooks + return unless resource.privatable_to.respond_to?(:private_space?) + return unless resource.privatable_to.private_space? + return if resource.privatable_to.respond_to?(:is_transparent) && resource.privatable_to.is_transparent? + + # When member is destroyed, a hook to destroy the follows of user on private non-transparent assembly + # or private participatory process and the follows of their children + DestroyMembersFollowsJob.perform_later(resource.decidim_user_id, resource.privatable_to) + end + end + end + end +end diff --git a/decidim-admin/app/commands/decidim/admin/participatory_space/import_member_csv.rb b/decidim-admin/app/commands/decidim/admin/participatory_space/import_member_csv.rb new file mode 100644 index 0000000000000..d9ce06ea98eda --- /dev/null +++ b/decidim-admin/app/commands/decidim/admin/participatory_space/import_member_csv.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "csv" + +module Decidim + module Admin + module ParticipatorySpace + class ImportMemberCsv < Decidim::Command + include Decidim::Admin::CustomImport + + delegate :current_user, to: :form + # Public: Initializes the command. + # + # form - the form object containing the uploaded file + # members_to - The members_to that will hold the user role + def initialize(form, members_to) + @form = form + @members_to = members_to + end + + # Executes the command. Broadcasts these events: + # + # - :ok when everything is valid. + # - :invalid if the form was not valid and we could not proceed. + # + # Returns nothing. + def call + return broadcast(:invalid) unless @form.valid? + + process_csv + broadcast(:ok) + end + + private + + attr_reader :form + + def process_csv + process_import_file(@form.file) do |(email, user_name)| + ImportMemberCsvJob.perform_later(email, user_name, @members_to, current_user) if email.present? && user_name.present? + end + end + end + end + end +end diff --git a/decidim-admin/app/commands/decidim/admin/participatory_space/publish_all_members.rb b/decidim-admin/app/commands/decidim/admin/participatory_space/publish_all_members.rb new file mode 100644 index 0000000000000..ef0a037976127 --- /dev/null +++ b/decidim-admin/app/commands/decidim/admin/participatory_space/publish_all_members.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Decidim + module Admin + module ParticipatorySpace + class PublishAllMembers < Decidim::Command + # Public: Initializes the command. + # + # participatory_space - the participatory space + # current_user - the current user + def initialize(participatory_space, current_user) + @participatory_space = participatory_space + @current_user = current_user + end + + # Executes the command. Broadcasts these events: + # + # - :ok when everything is valid. + # - :invalid if the form was not valid and we could not proceed. + # + # Returns nothing. + def call + publish_all + create_action_log + broadcast(:ok) + rescue ActiveRecord::RecordInvalid + broadcast(:invalid) + end + + private + + attr_reader :participatory_space, :current_user + + def publish_all + # rubocop:disable Rails/SkipsModelValidations + # Using update_all for performance reasons + participatory_space.members.update_all(published: true) + # rubocop:enable Rails/SkipsModelValidations + end + + def create_action_log + Decidim.traceability.perform_action!( + "publish_all_members", + participatory_space, + current_user, + members_ids: participatory_space.members.pluck(:id) + ) + end + end + end + end +end diff --git a/decidim-admin/app/commands/decidim/admin/participatory_space/unpublish_all_members.rb b/decidim-admin/app/commands/decidim/admin/participatory_space/unpublish_all_members.rb new file mode 100644 index 0000000000000..128e0edfaefa8 --- /dev/null +++ b/decidim-admin/app/commands/decidim/admin/participatory_space/unpublish_all_members.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Decidim + module Admin + module ParticipatorySpace + class UnpublishAllMembers < Decidim::Command + # Public: Initializes the command. + # + # participatory_space - the participatory space + # current_user - the current user + def initialize(participatory_space, current_user) + @participatory_space = participatory_space + @current_user = current_user + end + + # Executes the command. Broadcasts these events: + # + # - :ok when everything is valid. + # - :invalid if the form was not valid and we could not proceed. + # + # Returns nothing. + def call + unpublish_all + create_action_log + broadcast(:ok) + rescue ActiveRecord::RecordInvalid + broadcast(:invalid) + end + + private + + attr_reader :participatory_space, :current_user + + def unpublish_all + # rubocop:disable Rails/SkipsModelValidations + # Using update_all for performance reasons + participatory_space.members.update_all(published: false) + # rubocop:enable Rails/SkipsModelValidations + end + + def create_action_log + Decidim.traceability.perform_action!( + "unpublish_all_members", + participatory_space, + current_user, + members_ids: participatory_space.members.pluck(:id) + ) + end + end + end + end +end diff --git a/decidim-admin/app/commands/decidim/admin/participatory_space/update_member.rb b/decidim-admin/app/commands/decidim/admin/participatory_space/update_member.rb new file mode 100644 index 0000000000000..7f235a06c756e --- /dev/null +++ b/decidim-admin/app/commands/decidim/admin/participatory_space/update_member.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Decidim + module Admin + module ParticipatorySpace + # A command with all the business logic when updating a participatory space + # member. + class UpdateMember < Decidim::Commands::UpdateResource + fetch_form_attributes :role, :published + end + end + end +end diff --git a/decidim-admin/app/commands/decidim/admin/process_participatory_space_private_user_import_csv.rb b/decidim-admin/app/commands/decidim/admin/process_participatory_space_private_user_import_csv.rb deleted file mode 100644 index 3f49c5f14924d..0000000000000 --- a/decidim-admin/app/commands/decidim/admin/process_participatory_space_private_user_import_csv.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -require "csv" - -module Decidim - module Admin - class ProcessParticipatorySpacePrivateUserImportCsv < Decidim::Command - include Decidim::Admin::CustomImport - - delegate :current_user, to: :form - # Public: Initializes the command. - # - # form - the form object containing the uploaded file - # private_users_to - The private_users_to that will hold the user role - def initialize(form, private_users_to) - @form = form - @private_users_to = private_users_to - end - - # Executes the command. Broadcasts these events: - # - # - :ok when everything is valid. - # - :invalid if the form was not valid and we could not proceed. - # - # Returns nothing. - def call - return broadcast(:invalid) unless @form.valid? - - process_csv - broadcast(:ok) - end - - private - - attr_reader :form - - def process_csv - process_import_file(@form.file) do |(email, user_name)| - ImportParticipatorySpacePrivateUserCsvJob.perform_later(email, user_name, @private_users_to, current_user) if email.present? && user_name.present? - end - end - end - end -end diff --git a/decidim-admin/app/commands/decidim/admin/publish_all_participatory_space_private_users.rb b/decidim-admin/app/commands/decidim/admin/publish_all_participatory_space_private_users.rb deleted file mode 100644 index 38abcc5c7a0f6..0000000000000 --- a/decidim-admin/app/commands/decidim/admin/publish_all_participatory_space_private_users.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -module Decidim - module Admin - class PublishAllParticipatorySpacePrivateUsers < Decidim::Command - # Public: Initializes the command. - # - # participatory_space - the participatory space - # current_user - the current user - def initialize(participatory_space, current_user) - @participatory_space = participatory_space - @current_user = current_user - end - - # Executes the command. Broadcasts these events: - # - # - :ok when everything is valid. - # - :invalid if the form was not valid and we could not proceed. - # - # Returns nothing. - def call - publish_all - create_action_log - broadcast(:ok) - rescue ActiveRecord::RecordInvalid - broadcast(:invalid) - end - - private - - attr_reader :participatory_space, :current_user - - def publish_all - # rubocop:disable Rails/SkipsModelValidations - # Using update_all for performance reasons - participatory_space.participatory_space_private_users.update_all(published: true) - # rubocop:enable Rails/SkipsModelValidations - end - - def create_action_log - Decidim.traceability.perform_action!( - "publish_all_members", - participatory_space, - current_user, - private_users_ids: participatory_space.participatory_space_private_users.pluck(:id) - ) - end - end - end -end diff --git a/decidim-admin/app/commands/decidim/admin/unpublish_all_participatory_space_private_users.rb b/decidim-admin/app/commands/decidim/admin/unpublish_all_participatory_space_private_users.rb deleted file mode 100644 index f32c2274e1fc4..0000000000000 --- a/decidim-admin/app/commands/decidim/admin/unpublish_all_participatory_space_private_users.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -module Decidim - module Admin - class UnpublishAllParticipatorySpacePrivateUsers < Decidim::Command - # Public: Initializes the command. - # - # participatory_space - the participatory space - # current_user - the current user - def initialize(participatory_space, current_user) - @participatory_space = participatory_space - @current_user = current_user - end - - # Executes the command. Broadcasts these events: - # - # - :ok when everything is valid. - # - :invalid if the form was not valid and we could not proceed. - # - # Returns nothing. - def call - unpublish_all - create_action_log - broadcast(:ok) - rescue ActiveRecord::RecordInvalid - broadcast(:invalid) - end - - private - - attr_reader :participatory_space, :current_user - - def unpublish_all - # rubocop:disable Rails/SkipsModelValidations - # Using update_all for performance reasons - participatory_space.participatory_space_private_users.update_all(published: false) - # rubocop:enable Rails/SkipsModelValidations - end - - def create_action_log - Decidim.traceability.perform_action!( - "unpublish_all_members", - participatory_space, - current_user, - private_users_ids: participatory_space.participatory_space_private_users.pluck(:id) - ) - end - end - end -end diff --git a/decidim-admin/app/commands/decidim/admin/update_participatory_space_private_user.rb b/decidim-admin/app/commands/decidim/admin/update_participatory_space_private_user.rb deleted file mode 100644 index 96f0c4923fb1d..0000000000000 --- a/decidim-admin/app/commands/decidim/admin/update_participatory_space_private_user.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -module Decidim - module Admin - # A command with all the business logic when updating a participatory space - # private user. - class UpdateParticipatorySpacePrivateUser < Decidim::Commands::UpdateResource - fetch_form_attributes :role, :published - end - end -end diff --git a/decidim-admin/app/controllers/concerns/decidim/participatory_space_private_users/admin/filterable.rb b/decidim-admin/app/controllers/concerns/decidim/participatory_space_private_users/admin/filterable.rb deleted file mode 100644 index 1fc1b13edac48..0000000000000 --- a/decidim-admin/app/controllers/concerns/decidim/participatory_space_private_users/admin/filterable.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -require "active_support/concern" - -module Decidim - module ParticipatorySpacePrivateUsers - module Admin - module Filterable - extend ActiveSupport::Concern - - included do - include Decidim::Admin::Filterable - - private - - def base_query - collection - end - - def filters - [ - :user_invitation_sent_at_not_null, - :user_invitation_accepted_at_not_null - ] - end - - def search_field_predicate - :user_name_or_user_email_cont - end - end - end - end - end -end diff --git a/decidim-admin/app/controllers/decidim/admin/concerns/has_private_users.rb b/decidim-admin/app/controllers/decidim/admin/concerns/has_private_users.rb deleted file mode 100644 index d44c4998fe8c3..0000000000000 --- a/decidim-admin/app/controllers/decidim/admin/concerns/has_private_users.rb +++ /dev/null @@ -1,169 +0,0 @@ -# frozen_string_literal: true - -module Decidim - module Admin - module Concerns - # PrivateUsers can be related to any ParticipatorySpace, in order to - # manage the private users for a given type, you should create a new - # controller and include this concern. - # - # The only requirement is to define a `privatable_to` method that - # returns an instance of the model to relate the private_user to. - module HasPrivateUsers - extend ActiveSupport::Concern - - included do - include Decidim::ParticipatorySpacePrivateUsers::Admin::Filterable - helper PaginateHelper - helper_method :privatable_to, :participatory_space_private_users - - # rubocop:disable Rails/LexicallyScopedActionFilter - before_action :set_private_user, only: [:edit, :update, :destroy, :resend_invitation] - # rubocop:enable Rails/LexicallyScopedActionFilter - - def index - enforce_permission_to :read, :space_private_user - - render template: "decidim/admin/participatory_space_private_users/index" - end - - def new - enforce_permission_to :create, :space_private_user - @form = form(ParticipatorySpacePrivateUserForm).from_params({}, privatable_to:) - render template: "decidim/admin/participatory_space_private_users/new" - end - - def edit - enforce_permission_to :update, :space_private_user, private_user: @private_user - @form = form(ParticipatorySpacePrivateUserForm).from_model(@private_user) - render template: "decidim/admin/participatory_space_private_users/edit" - end - - def update - enforce_permission_to :update, :space_private_user, private_user: @private_user - @form = form(ParticipatorySpacePrivateUserForm).from_params(params, privatable_to:) - - UpdateParticipatorySpacePrivateUser.call(@form, @private_user) do - on(:ok) do - flash[:notice] = I18n.t("participatory_space_private_users.update.success", scope: "decidim.admin") - redirect_to action: :index - end - - on(:invalid) do - flash.now[:alert] = I18n.t("participatory_space_private_users.update.error", scope: "decidim.admin") - render template: "decidim/admin/participatory_space_private_users/edit", status: :unprocessable_entity - end - end - end - - def create - enforce_permission_to :create, :space_private_user - @form = form(ParticipatorySpacePrivateUserForm).from_params(params, privatable_to:) - - CreateParticipatorySpacePrivateUser.call(@form, current_participatory_space) do - on(:ok) do - flash[:notice] = I18n.t("participatory_space_private_users.create.success", scope: "decidim.admin") - redirect_to action: :index - end - - on(:invalid) do - flash.now[:alert] = I18n.t("participatory_space_private_users.create.error", scope: "decidim.admin") - render template: "decidim/admin/participatory_space_private_users/new", status: :unprocessable_entity - end - end - end - - def destroy - enforce_permission_to :destroy, :space_private_user, private_user: @private_user - - DestroyParticipatorySpacePrivateUser.call(@private_user, current_user) do - on(:ok) do - flash[:notice] = I18n.t("participatory_space_private_users.destroy.success", scope: "decidim.admin") - redirect_to after_destroy_path - end - - on(:invalid) do - flash.now[:alert] = I18n.t("participatory_space_private_users.destroy.error", scope: "decidim.admin") - render template: "decidim/admin/participatory_space_private_users/index", status: :unprocessable_entity - end - end - end - - def resend_invitation - enforce_permission_to :invite, :space_private_user, private_user: @private_user - InviteUserAgain.call(@private_user.user, "invite_private_user") do - on(:ok) do - flash[:notice] = I18n.t("users.resend_invitation.success", scope: "decidim.admin") - end - - on(:invalid) do - flash[:alert] = I18n.t("users.resend_invitation.error", scope: "decidim.admin") - end - end - - redirect_to after_destroy_path - end - - def publish_all - PublishAllParticipatorySpacePrivateUsers.call(current_participatory_space, current_user) do - on(:ok) do - flash[:notice] = I18n.t("participatory_space_private_users.publish_all.success", scope: "decidim.admin") - redirect_to action: :index - end - - on(:invalid) do - flash[:alert] = I18n.t("participatory_space_private_users.publish_all.error", scope: "decidim.admin") - redirect_to action: :index - end - end - end - - def unpublish_all - UnpublishAllParticipatorySpacePrivateUsers.call(current_participatory_space, current_user) do - on(:ok) do - flash[:notice] = I18n.t("participatory_space_private_users.unpublish_all.success", scope: "decidim.admin") - redirect_to action: :index - end - - on(:invalid) do - flash[:alert] = I18n.t("participatory_space_private_users.unpublish_all.error", scope: "decidim.admin") - redirect_to action: :index - end - end - end - - # Public: Returns a String or Object that will be passed to `redirect_to` after - # destroying a private user. By default it redirects to the privatable_to. - # - # It can be redefined at controller level if you need to redirect elsewhere. - def after_destroy_path - privatable_to - end - - # Public: The only method to be implemented at the controller. You need to - # return the object where the attachment will be attached to. - def privatable_to - raise NotImplementedError - end - - def collection - # there is an unidentified corner case where Decidim::User - # may have been destroyed, but the related ParticipatorySpacePrivateUser - # remains in the database. That is why filtering by not null users - @collection ||= privatable_to - .participatory_space_private_users - .includes(:user).where.not("decidim_users.id" => nil) - end - - def participatory_space_private_users - filtered_collection - end - - def set_private_user - @private_user = collection.find(params[:id]) - end - end - end - end - end -end diff --git a/decidim-admin/app/controllers/decidim/admin/concerns/has_private_users_csv_import.rb b/decidim-admin/app/controllers/decidim/admin/concerns/has_private_users_csv_import.rb deleted file mode 100644 index e9940d1b0c8bc..0000000000000 --- a/decidim-admin/app/controllers/decidim/admin/concerns/has_private_users_csv_import.rb +++ /dev/null @@ -1,65 +0,0 @@ -# frozen_string_literal: true - -module Decidim - module Admin - module Concerns - # PrivateUsers can be related to any ParticipatorySpace, in order to - # import private users from csv for a given type, you should create a new - # controller and include this concern. - # - # The only requirement is to define a `privatable_to` method that - # returns an instance of the model to relate the private_user to. - module HasPrivateUsersCsvImport - extend ActiveSupport::Concern - - included do - helper_method :privatable_to - - def new - enforce_permission_to :csv_import, :space_private_user - @form = form(ParticipatorySpacePrivateUserCsvImportForm).from_params({}, privatable_to:) - @count = Decidim::ParticipatorySpacePrivateUser.by_participatory_space(privatable_to).count - render template: "decidim/admin/participatory_space_private_users_csv_imports/new" - end - - def create - enforce_permission_to :csv_import, :space_private_user - @form = form(ParticipatorySpacePrivateUserCsvImportForm).from_params(params, privatable_to:) - - ProcessParticipatorySpacePrivateUserImportCsv.call(@form, current_participatory_space) do - on(:ok) do - flash[:notice] = I18n.t("participatory_space_private_users_csv_imports.create.success", scope: "decidim.admin") - redirect_to after_import_path - end - - on(:invalid) do - flash[:alert] = I18n.t("participatory_space_private_users_csv_imports.create.invalid", scope: "decidim.admin") - render template: "decidim/admin/participatory_space_private_users_csv_imports/new", status: :unprocessable_entity - end - end - end - - def destroy_all - enforce_permission_to :csv_import, :space_private_user - Decidim::ParticipatorySpacePrivateUser.by_participatory_space(privatable_to).delete_all - redirect_to new_participatory_space_private_users_csv_imports_path - end - - # Public: Returns a String or Object that will be passed to `redirect_to` after - # importing private users. By default it redirects to the privatable_to. - # - # It can be redefined at controller level if you need to redirect elsewhere. - def after_import_path - privatable_to - end - - # Public: The only method to be implemented at the controller. You need to - # return the object where the attachment will be attached to. - def privatable_to - raise NotImplementedError - end - end - end - end - end -end diff --git a/decidim-admin/app/controllers/decidim/admin/participatory_space/concerns/has_members.rb b/decidim-admin/app/controllers/decidim/admin/participatory_space/concerns/has_members.rb new file mode 100644 index 0000000000000..782b1a9c19178 --- /dev/null +++ b/decidim-admin/app/controllers/decidim/admin/participatory_space/concerns/has_members.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +module Decidim + module Admin + module ParticipatorySpace + module Concerns + # Members can be related to any ParticipatorySpace, in order to + # manage the members for a given type, you should create a new + # controller and include this concern. + # + # The only requirement is to define a `privatable_to` method that + # returns an instance of the model to relate the member to. + module HasMembers + extend ActiveSupport::Concern + + included do + include Decidim::Admin::ParticipatorySpace::Concerns::MembersFilterable + helper PaginateHelper + helper_method :privatable_to, :members + + # rubocop:disable Rails/LexicallyScopedActionFilter + before_action :set_member, only: [:edit, :update, :destroy, :resend_invitation] + # rubocop:enable Rails/LexicallyScopedActionFilter + + def index + enforce_permission_to :read, :space_member + + render template: "decidim/admin/members/index" + end + + def new + enforce_permission_to :create, :space_member + @form = form(MemberForm).from_params({}, privatable_to:) + render template: "decidim/admin/members/new" + end + + def edit + enforce_permission_to :update, :space_member, member: @member + @form = form(MemberForm).from_model(@member) + render template: "decidim/admin/members/edit" + end + + def update + enforce_permission_to :update, :space_member, member: @member + @form = form(MemberForm).from_params(params, privatable_to:) + + UpdateMember.call(@form, @member) do + on(:ok) do + flash[:notice] = I18n.t("members.update.success", scope: "decidim.admin") + redirect_to action: :index + end + + on(:invalid) do + flash.now[:alert] = I18n.t("members.update.error", scope: "decidim.admin") + render template: "decidim/admin/members/edit", status: :unprocessable_entity + end + end + end + + def create + enforce_permission_to :create, :space_member + @form = form(MemberForm).from_params(params, privatable_to:) + + CreateMember.call(@form, current_participatory_space) do + on(:ok) do + flash[:notice] = I18n.t("members.create.success", scope: "decidim.admin") + redirect_to action: :index + end + + on(:invalid) do + flash.now[:alert] = I18n.t("members.create.error", scope: "decidim.admin") + render template: "decidim/admin/members/new", status: :unprocessable_entity + end + end + end + + def destroy + enforce_permission_to :destroy, :space_member, member: @member + + DestroyMember.call(@member, current_user) do + on(:ok) do + flash[:notice] = I18n.t("members.destroy.success", scope: "decidim.admin") + redirect_to after_destroy_path + end + + on(:invalid) do + flash.now[:alert] = I18n.t("members.destroy.error", scope: "decidim.admin") + render template: "decidim/admin/members/index", status: :unprocessable_entity + end + end + end + + def resend_invitation + enforce_permission_to :invite, :space_member, member: @member + InviteUserAgain.call(@member.user, "invite_member") do + on(:ok) do + flash[:notice] = I18n.t("users.resend_invitation.success", scope: "decidim.admin") + end + + on(:invalid) do + flash[:alert] = I18n.t("users.resend_invitation.error", scope: "decidim.admin") + end + end + + redirect_to after_destroy_path + end + + def publish_all + PublishAllMembers.call(current_participatory_space, current_user) do + on(:ok) do + flash[:notice] = I18n.t("members.publish_all.success", scope: "decidim.admin") + redirect_to action: :index + end + + on(:invalid) do + flash[:alert] = I18n.t("members.publish_all.error", scope: "decidim.admin") + redirect_to action: :index + end + end + end + + def unpublish_all + UnpublishAllMembers.call(current_participatory_space, current_user) do + on(:ok) do + flash[:notice] = I18n.t("members.unpublish_all.success", scope: "decidim.admin") + redirect_to action: :index + end + + on(:invalid) do + flash[:alert] = I18n.t("members.unpublish_all.error", scope: "decidim.admin") + redirect_to action: :index + end + end + end + + # Public: Returns a String or Object that will be passed to `redirect_to` after + # destroying a member. By default it redirects to the privatable_to. + # + # It can be redefined at controller level if you need to redirect elsewhere. + def after_destroy_path + privatable_to + end + + # Public: The only method to be implemented at the controller. You need to + # return the object where the attachment will be attached to. + def privatable_to + raise NotImplementedError + end + + def collection + # there is an unidentified corner case where Decidim::User + # may have been destroyed, but the related Member + # remains in the database. That is why filtering by not null users + @collection ||= privatable_to + .members + .includes(:user).where.not("decidim_users.id" => nil) + end + + def members + filtered_collection + end + + def set_member + @member = collection.find(params[:id]) + end + end + end + end + end + end +end diff --git a/decidim-admin/app/controllers/decidim/admin/participatory_space/concerns/has_members_csv_import.rb b/decidim-admin/app/controllers/decidim/admin/participatory_space/concerns/has_members_csv_import.rb new file mode 100644 index 0000000000000..7b05c389b9083 --- /dev/null +++ b/decidim-admin/app/controllers/decidim/admin/participatory_space/concerns/has_members_csv_import.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Decidim + module Admin + module ParticipatorySpace + module Concerns + # Members can be related to any ParticipatorySpace, in order to + # import members from csv for a given type, you should create a new + # controller and include this concern. + # + # The only requirement is to define a `privatable_to` method that + # returns an instance of the model to relate the member to. + module HasMembersCsvImport + extend ActiveSupport::Concern + + included do + helper_method :privatable_to + + def new + enforce_permission_to :csv_import, :space_member + @form = form(MemberCsvImportForm).from_params({}, privatable_to:) + @count = Decidim::ParticipatorySpace::Member.by_participatory_space(privatable_to).count + render template: "decidim/admin/members_csv_imports/new" + end + + def create + enforce_permission_to :csv_import, :space_member + @form = form(MemberCsvImportForm).from_params(params, privatable_to:) + + ImportMemberCsv.call(@form, current_participatory_space) do + on(:ok) do + flash[:notice] = I18n.t("members_csv_imports.create.success", scope: "decidim.admin") + redirect_to after_import_path + end + + on(:invalid) do + flash[:alert] = I18n.t("members_csv_imports.create.invalid", scope: "decidim.admin") + render template: "decidim/admin/members_csv_imports/new", status: :unprocessable_entity + end + end + end + + def destroy_all + enforce_permission_to :csv_import, :space_member + Decidim::ParticipatorySpace::Member.by_participatory_space(privatable_to).delete_all + redirect_to new_members_csv_imports_path + end + + # Public: Returns a String or Object that will be passed to `redirect_to` after + # importing members. By default it redirects to the privatable_to. + # + # It can be redefined at controller level if you need to redirect elsewhere. + def after_import_path + privatable_to + end + + # Public: The only method to be implemented at the controller. You need to + # return the object where the attachment will be attached to. + def privatable_to + raise NotImplementedError + end + end + end + end + end + end +end diff --git a/decidim-admin/app/controllers/decidim/admin/participatory_space/concerns/members_filterable.rb b/decidim-admin/app/controllers/decidim/admin/participatory_space/concerns/members_filterable.rb new file mode 100644 index 0000000000000..a1af45aa7e282 --- /dev/null +++ b/decidim-admin/app/controllers/decidim/admin/participatory_space/concerns/members_filterable.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "active_support/concern" + +module Decidim + module Admin + module ParticipatorySpace + module Concerns + module MembersFilterable + extend ActiveSupport::Concern + + included do + include Decidim::Admin::Filterable + + private + + def base_query + collection + end + + def filters + [ + :user_invitation_sent_at_not_null, + :user_invitation_accepted_at_not_null + ] + end + + def search_field_predicate + :user_name_or_user_email_cont + end + end + end + end + end + end +end diff --git a/decidim-admin/app/forms/decidim/admin/participatory_space/member_csv_import_form.rb b/decidim-admin/app/forms/decidim/admin/participatory_space/member_csv_import_form.rb new file mode 100644 index 0000000000000..14efbcbca108c --- /dev/null +++ b/decidim-admin/app/forms/decidim/admin/participatory_space/member_csv_import_form.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "csv" + +module Decidim + module Admin + module ParticipatorySpace + # A form object used to upload CSV to batch members. + # + class MemberCsvImportForm < Form + include Decidim::HasUploadValidations + include Decidim::Admin::CustomImport + + attribute :file, Decidim::Attributes::Blob + attribute :user_name, String + attribute :email, String + + validates :file, presence: true, file_content_type: { allow: ["text/csv"] } + validate :validate_csv + + def validate_csv + return if file.blank? + + process_import_file(file) do |(_email, user_name)| + errors.add(:user_name, :invalid) if user_name.blank? || !user_name.match?(UserBaseEntity::REGEXP_NAME) + end + rescue CSV::MalformedCSVError + errors.add(:file, :malformed) + end + end + end + end +end diff --git a/decidim-admin/app/forms/decidim/admin/participatory_space/member_form.rb b/decidim-admin/app/forms/decidim/admin/participatory_space/member_form.rb new file mode 100644 index 0000000000000..ddc8665d8e211 --- /dev/null +++ b/decidim-admin/app/forms/decidim/admin/participatory_space/member_form.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Decidim + module Admin + module ParticipatorySpace + # A form object used to create members from the + # admin dashboard. + # + class MemberForm < Form + include TranslatableAttributes + + mimic :member + + attribute :name, String + attribute :email, String + attribute :published, Boolean + + translatable_attribute :role, String + + validates :name, :email, presence: true + + validates :name, format: { with: UserBaseEntity::REGEXP_NAME } + end + end + end +end diff --git a/decidim-admin/app/forms/decidim/admin/participatory_space_admin_user_form.rb b/decidim-admin/app/forms/decidim/admin/participatory_space_admin_user_form.rb index 79118d1e44a5b..bd38aa416be28 100644 --- a/decidim-admin/app/forms/decidim/admin/participatory_space_admin_user_form.rb +++ b/decidim-admin/app/forms/decidim/admin/participatory_space_admin_user_form.rb @@ -2,7 +2,7 @@ module Decidim module Admin - class ParticipatorySpaceAdminUserForm < ParticipatorySpacePrivateUserForm + class ParticipatorySpaceAdminUserForm < Decidim::Admin::ParticipatorySpace::MemberForm attribute :role, String validates :role, presence: true diff --git a/decidim-admin/app/forms/decidim/admin/participatory_space_private_user_csv_import_form.rb b/decidim-admin/app/forms/decidim/admin/participatory_space_private_user_csv_import_form.rb deleted file mode 100644 index 5a09652fdb59d..0000000000000 --- a/decidim-admin/app/forms/decidim/admin/participatory_space_private_user_csv_import_form.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -require "csv" - -module Decidim - module Admin - # A form object used to upload CSV to batch participatory space private users. - # - class ParticipatorySpacePrivateUserCsvImportForm < Form - include Decidim::HasUploadValidations - include Decidim::Admin::CustomImport - - attribute :file, Decidim::Attributes::Blob - attribute :user_name, String - attribute :email, String - - validates :file, presence: true, file_content_type: { allow: ["text/csv"] } - validate :validate_csv - - def validate_csv - return if file.blank? - - process_import_file(file) do |(_email, user_name)| - errors.add(:user_name, :invalid) if user_name.blank? || !user_name.match?(UserBaseEntity::REGEXP_NAME) - end - rescue CSV::MalformedCSVError - errors.add(:file, :malformed) - end - end - end -end diff --git a/decidim-admin/app/forms/decidim/admin/participatory_space_private_user_form.rb b/decidim-admin/app/forms/decidim/admin/participatory_space_private_user_form.rb deleted file mode 100644 index d92ee013d51c4..0000000000000 --- a/decidim-admin/app/forms/decidim/admin/participatory_space_private_user_form.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -module Decidim - module Admin - # A form object used to create participatory space private users from the - # admin dashboard. - # - class ParticipatorySpacePrivateUserForm < Form - include TranslatableAttributes - - mimic :participatory_space_private_user - - attribute :name, String - attribute :email, String - attribute :published, Boolean - - translatable_attribute :role, String - - validates :name, :email, presence: true - - validates :name, format: { with: UserBaseEntity::REGEXP_NAME } - end - end -end diff --git a/decidim-admin/app/jobs/decidim/admin/destroy_private_users_follows_job.rb b/decidim-admin/app/jobs/decidim/admin/destroy_private_users_follows_job.rb deleted file mode 100644 index 04265cb72d121..0000000000000 --- a/decidim-admin/app/jobs/decidim/admin/destroy_private_users_follows_job.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -module Decidim - module Admin - class DestroyPrivateUsersFollowsJob < ApplicationJob - queue_as :default - - def perform(decidim_user_id, space) - return unless space.respond_to?(:private_space?) - - return unless space.private_space? - - return if space.respond_to?(:is_transparent) && space.is_transparent? - - user = Decidim::User.find_by(id: decidim_user_id) - - return if user.blank? - - return if space.respond_to?(:can_participate?) && space.can_participate?(user) - - follows = Decidim::Follow.where(user: user) - follows.where(followable: space).destroy_all - - destroy_children_follows(follows, space) - end - - def destroy_children_follows(follows, space) - follows.map do |follow| - object = follow.followable.presence - next unless object.respond_to?(:decidim_component_id) - - follow.destroy if space.component_ids.include?(object.decidim_component_id) - end - end - end - end -end diff --git a/decidim-admin/app/jobs/decidim/admin/import_participatory_space_private_user_csv_job.rb b/decidim-admin/app/jobs/decidim/admin/import_participatory_space_private_user_csv_job.rb deleted file mode 100644 index adaeabfc294c8..0000000000000 --- a/decidim-admin/app/jobs/decidim/admin/import_participatory_space_private_user_csv_job.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -module Decidim - module Admin - # Custom ApplicationJob scoped to the admin panel. - # - class ImportParticipatorySpacePrivateUserCsvJob < ApplicationJob - queue_as :exports - - def perform(email, user_name, privatable_to, current_user) - return if email.blank? || user_name.blank? - - params = { - name: user_name, - email: email.downcase.strip - } - private_user_form = ParticipatorySpacePrivateUserForm.from_params(params, privatable_to:) - .with_context( - current_user:, - current_participatory_space: privatable_to - ) - - Decidim::Admin::CreateParticipatorySpacePrivateUser.call(private_user_form, privatable_to, via_csv: true) - end - end - end -end diff --git a/decidim-admin/app/jobs/decidim/admin/participatory_space/destroy_members_follows_job.rb b/decidim-admin/app/jobs/decidim/admin/participatory_space/destroy_members_follows_job.rb new file mode 100644 index 0000000000000..efc40670a36db --- /dev/null +++ b/decidim-admin/app/jobs/decidim/admin/participatory_space/destroy_members_follows_job.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Decidim + module Admin + module ParticipatorySpace + class DestroyMembersFollowsJob < ApplicationJob + queue_as :default + + def perform(decidim_user_id, space) + return unless space.respond_to?(:private_space?) + + return unless space.private_space? + + return if space.respond_to?(:is_transparent) && space.is_transparent? + + user = Decidim::User.find_by(id: decidim_user_id) + + return if user.blank? + + return if space.respond_to?(:can_participate?) && space.can_participate?(user) + + follows = Decidim::Follow.where(user:) + follows.where(followable: space).destroy_all + + destroy_children_follows(follows, space) + end + + def destroy_children_follows(follows, space) + follows.map do |follow| + object = follow.followable.presence + next unless object.respond_to?(:decidim_component_id) + + follow.destroy if space.component_ids.include?(object.decidim_component_id) + end + end + end + end + end +end diff --git a/decidim-admin/app/jobs/decidim/admin/participatory_space/import_member_csv_job.rb b/decidim-admin/app/jobs/decidim/admin/participatory_space/import_member_csv_job.rb new file mode 100644 index 0000000000000..d8b9cc106964f --- /dev/null +++ b/decidim-admin/app/jobs/decidim/admin/participatory_space/import_member_csv_job.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Decidim + module Admin + module ParticipatorySpace + # Custom ApplicationJob scoped to the admin panel. + # + class ImportMemberCsvJob < ApplicationJob + queue_as :exports + + def perform(email, user_name, privatable_to, current_user) + return if email.blank? || user_name.blank? + + params = { + name: user_name, + email: email.downcase.strip + } + member_form = MemberForm.from_params(params, privatable_to:) + .with_context( + current_user:, + current_participatory_space: privatable_to + ) + + CreateMember.call(member_form, privatable_to, via_csv: true) + end + end + end + end +end diff --git a/decidim-admin/app/queries/decidim/admin/newsletter_recipients.rb b/decidim-admin/app/queries/decidim/admin/newsletter_recipients.rb index 697ce9f9bf04c..e74b6c589a422 100644 --- a/decidim-admin/app/queries/decidim/admin/newsletter_recipients.rb +++ b/decidim-admin/app/queries/decidim/admin/newsletter_recipients.rb @@ -129,7 +129,7 @@ def private_member_ids return unless @form.send_to_private_members return [] if private_spaces.blank? - Decidim::ParticipatorySpacePrivateUser.private_user_ids_for_participatory_spaces(private_spaces) + Decidim::ParticipatorySpace::Member.member_ids_for_participatory_spaces(private_spaces) end end end diff --git a/decidim-admin/app/views/decidim/admin/participatory_space_private_users/_form.html.erb b/decidim-admin/app/views/decidim/admin/members/_form.html.erb similarity index 100% rename from decidim-admin/app/views/decidim/admin/participatory_space_private_users/_form.html.erb rename to decidim-admin/app/views/decidim/admin/members/_form.html.erb diff --git a/decidim-admin/app/views/decidim/admin/participatory_space_private_users/edit.html.erb b/decidim-admin/app/views/decidim/admin/members/edit.html.erb similarity index 60% rename from decidim-admin/app/views/decidim/admin/participatory_space_private_users/edit.html.erb rename to decidim-admin/app/views/decidim/admin/members/edit.html.erb index c3bfd1492cf1f..494738f61b422 100644 --- a/decidim-admin/app/views/decidim/admin/participatory_space_private_users/edit.html.erb +++ b/decidim-admin/app/views/decidim/admin/members/edit.html.erb @@ -7,8 +7,8 @@
- <%= decidim_form_for(@form, url: participatory_space_private_user_path(current_participatory_space, @private_user), html: { class: "form-defaults form edit_participatory_space_private_user" }) do |f| %> - <%= render partial: "decidim/admin/participatory_space_private_users/form", object: f %> + <%= decidim_form_for(@form, url: member_path(current_participatory_space, @member), html: { class: "form-defaults form edit_member" }) do |f| %> + <%= render partial: "decidim/admin/members/form", object: f %>
<%= f.submit t(".update"), class: "button button__sm button__secondary" %> diff --git a/decidim-admin/app/views/decidim/admin/participatory_space_private_users/index.html.erb b/decidim-admin/app/views/decidim/admin/members/index.html.erb similarity index 55% rename from decidim-admin/app/views/decidim/admin/participatory_space_private_users/index.html.erb rename to decidim-admin/app/views/decidim/admin/members/index.html.erb index ee68d751bde8b..bfb33b5d93305 100644 --- a/decidim-admin/app/views/decidim/admin/participatory_space_private_users/index.html.erb +++ b/decidim-admin/app/views/decidim/admin/members/index.html.erb @@ -1,18 +1,18 @@ <% add_decidim_page_title(t(".title")) %> -
+

<%= t(".title") %> - <% if allowed_to? :create, :space_private_user %> - <%= link_to t(".publish_all"), publish_all_participatory_space_private_users_path(current_participatory_space), class: "button button__sm button__transparent-secondary publish-all", method: :post %> - <%= link_to t(".unpublish_all"), unpublish_all_participatory_space_private_users_path(current_participatory_space), class: "button button__sm button__transparent-secondary unpublish-all", method: :post %> - <%= link_to t(".import_via_csv"), new_participatory_space_private_users_csv_imports_path, class: "button button__sm button__transparent-secondary import" %> - <%= link_to t("actions.participatory_space_private_user.new", scope: "decidim.admin"), url_for(action: :new), class: "button button__sm button__secondary new" %> + <% if allowed_to? :create, :space_member %> + <%= link_to t(".publish_all"), publish_all_members_path(current_participatory_space), class: "button button__sm button__transparent-secondary publish-all", method: :post %> + <%= link_to t(".unpublish_all"), unpublish_all_members_path(current_participatory_space), class: "button button__sm button__transparent-secondary unpublish-all", method: :post %> + <%= link_to t(".import_via_csv"), new_members_csv_imports_path, class: "button button__sm button__transparent-secondary import" %> + <%= link_to t("actions.member.new", scope: "decidim.admin"), url_for(action: :new), class: "button button__sm button__secondary new" %> <% end %>

- <%= admin_filter_selector(:participatory_space_private_users) %> - <% if participatory_space_private_users.any? %> + <%= admin_filter_selector(:members) %> + <% if members.any? %>
@@ -36,39 +36,39 @@ - <% participatory_space_private_users.each do |private_user| %> + <% members.each do |member| %>
"> - <%= private_user.user.name %> + <%= member.user.name %> "> - <%= private_user.user.email %> + <%= member.user.email %> "> - <% if private_user.published %> + <% if member.published %> <%= icon "check-line", class: "text-success" %> <% end %> "> - <% if private_user.user.invitation_sent_at %> - <%= l private_user.user.invitation_sent_at, format: :short %> + <% if member.user.invitation_sent_at %> + <%= l member.user.invitation_sent_at, format: :short %> <% end %> "> - <% if private_user.user.invitation_accepted_at %> - <%= l private_user.user.invitation_accepted_at, format: :short %> + <% if member.user.invitation_accepted_at %> + <%= l member.user.invitation_accepted_at, format: :short %> <% end %> " class="table-list__actions"> -
-
- <%= decidim_paginate participatory_space_private_users %> + <%= decidim_paginate members %> <% end %>
diff --git a/decidim-admin/app/views/decidim/admin/participatory_space_private_users/new.html.erb b/decidim-admin/app/views/decidim/admin/members/new.html.erb similarity index 61% rename from decidim-admin/app/views/decidim/admin/participatory_space_private_users/new.html.erb rename to decidim-admin/app/views/decidim/admin/members/new.html.erb index 703a2ad0890fe..aebdfd6551717 100644 --- a/decidim-admin/app/views/decidim/admin/participatory_space_private_users/new.html.erb +++ b/decidim-admin/app/views/decidim/admin/members/new.html.erb @@ -7,8 +7,8 @@
- <%= decidim_form_for(@form, url: participatory_space_private_users_path(current_participatory_space), html: { class: "form-defaults form new_participatory_space_private_user" }) do |f| %> - <%= render partial: "decidim/admin/participatory_space_private_users/form", object: f %> + <%= decidim_form_for(@form, url: members_path(current_participatory_space), html: { class: "form-defaults form new_member" }) do |f| %> + <%= render partial: "decidim/admin/members/form", object: f %>
<%= f.submit t(".create"), class: "button button__sm button__secondary" %> diff --git a/decidim-admin/app/views/decidim/admin/participatory_space_private_users_csv_imports/new.html.erb b/decidim-admin/app/views/decidim/admin/members_csv_imports/new.html.erb similarity index 86% rename from decidim-admin/app/views/decidim/admin/participatory_space_private_users_csv_imports/new.html.erb rename to decidim-admin/app/views/decidim/admin/members_csv_imports/new.html.erb index 6aa33b05e379e..959c24ebedeea 100644 --- a/decidim-admin/app/views/decidim/admin/participatory_space_private_users_csv_imports/new.html.erb +++ b/decidim-admin/app/views/decidim/admin/members_csv_imports/new.html.erb @@ -18,7 +18,7 @@ <% if @count != 0 %>

<%= t(".destroy.explanation", count: @count) %>

<%= link_to t(".destroy.button"), - destroy_all_participatory_space_private_users_csv_imports_path, + destroy_all_members_csv_imports_path, method: :delete, class: "button button__sm button__secondary alert", data: { confirm: t(".destroy.confirm") } %> @@ -35,7 +35,7 @@
- <%= decidim_form_for(@form, url: participatory_space_private_users_csv_imports_path, html: { class: "form form-defaults" }) do |form| %> + <%= decidim_form_for(@form, url: members_csv_imports_path, html: { class: "form form-defaults" }) do |form| %>

<%= t(".explanation") %>

<%= t(".example_file") %>

diff --git a/decidim-admin/config/locales/ca-IT.yml b/decidim-admin/config/locales/ca-IT.yml index 214daf8c77306..f5e6e706dd919 100644 --- a/decidim-admin/config/locales/ca-IT.yml +++ b/decidim-admin/config/locales/ca-IT.yml @@ -125,6 +125,9 @@ ca-IT: show_in_footer: Mostra al peu de pàgina title: Títol weight: Ordre de posició + taxonomy: + item_name: Nom de l'element + parent_id: Taxonomia mare user_group_csv_verification: file: Fitxer errors: diff --git a/decidim-admin/config/locales/ca.yml b/decidim-admin/config/locales/ca.yml index 660ecbdc5bfcd..21afd22f41205 100644 --- a/decidim-admin/config/locales/ca.yml +++ b/decidim-admin/config/locales/ca.yml @@ -125,6 +125,9 @@ ca: show_in_footer: Mostra al peu de pàgina title: Títol weight: Ordre de posició + taxonomy: + item_name: Nom de l'element + parent_id: Taxonomia mare user_group_csv_verification: file: Fitxer errors: diff --git a/decidim-admin/config/locales/cs.yml b/decidim-admin/config/locales/cs.yml index a5966825647b8..b0103ff991807 100644 --- a/decidim-admin/config/locales/cs.yml +++ b/decidim-admin/config/locales/cs.yml @@ -35,6 +35,7 @@ cs: id: ID newsletter: body: Tělo + send_to_all_users: Poslat všem účastníkům send_to_followers: Odeslat sledujícím send_to_participants: Odeslat účastníkům subject: Předmět @@ -292,11 +293,13 @@ cs: block_user: bulk_new: action: Zablokovat účty a odeslat zdůvodnění + already_reported_html: Pokračováním v této akci také skryjete veškerý obsah účastníků. description: Blokováním uživatele bude jejich účet nepoužitelný. Ve svém zdůvodnění můžete uvést veškerá pravidla pro způsob, jakým byste uvažovali o odblokování uživatele. justification: Odůvodnění title: Zablokovat uživatele new: action: Blokovat účet a odeslat odůvodnění + already_reported_html: Pokračováním v této akci také skryjete veškerý obsah účastníků. description: Blokováním uživatele bude jejich účet nepoužitelný. Ve svém zdůvodnění můžete uvést veškerá pravidla pro způsob, jakým byste uvažovali o odblokování uživatele. justification: Odůvodnění title: Blokovat uživatele %{name} @@ -392,6 +395,7 @@ cs: form: domain_too_short: Doména je příliš krátká update: + error: Nepodařilo se aktualizovat seznam povolených externích domén. success: Seznam povolených externích domén byl úspěšně aktualizován. exports: export_as: "%{name} jako %{export_format}" @@ -454,6 +458,9 @@ cs: search_placeholder: name_or_nickname_or_email_cont: Hledat %{collection} podle e-mailu, jména nebo přezdívky report_count_eq: Počet nahlášení + reported_id_string_or_reported_content_cont: Hledat %{collection} podle nahlášeného Id nebo obsahu + title_cont: Hledat %{collection} podle názvu + user_name_or_user_nickname_or_user_email_cont: Hledat %{collection} podle e-mailu, jména nebo přezdívky state_eq: label: Stav values: @@ -473,6 +480,7 @@ cs: import_csv: explanation: 'Pokyny pro soubor:' message_1: CSV soubory jsou podporovány + message_2: ".csv soubor s daty e-mailu" help_sections: error: Při aktualizaci sekcí nápovědy došlo k chybě. form: @@ -559,6 +567,7 @@ cs: logs: filters: participatory_space: Najít participační prostor + text: Hledat podle e-mailu, jména nebo přezdívky user: Uživatel logs_list: no_logs_yet: Zatím nejsou žádné logy. @@ -690,6 +699,7 @@ cs: update_moderated_user_button: Zrušit nahlášení uživatelů title: Akce unblock: Odblokovat uživatele + unreport: Zpět hlášení cancel: Zrušit name: Jméno nickname: Přezdívka @@ -716,6 +726,7 @@ cs: title: Vrátit skrytí update_moderation_button: Odkrýt vybrané zdroje unreport: + title: Zpět hlášení update_moderation_button: Zrušit nahlášení vybraných zdrojů cancel: Zrušit selected: vybráno @@ -1242,6 +1253,9 @@ cs: taxonomy_filters: Filtry taxonomie pro "%{taxonomy}" users: Administrátoři tooltips: + cannot_destroy_taxonomy_filter: Nelze zničit tento filtr taxonomie + cannot_edit_taxonomy_filter: Nelze upravit tento filtr taxonomie + deleted_attachment_collections_info: Tuto složku nelze odstranit, protože obsahuje přílohy. deleted_component_info: Tato komponenta může být odstraněna pouze v případě, že je stav 'Nezveřejněno'. trash_management: restore: @@ -1270,6 +1284,7 @@ cs: last_day: Poslední den last_month: Minulý měsíc last_week: Minulý týden + no_users_count_statistics_yet: Zatím nejsou žádné statistiky počtu účastníků. participants: Účastníci forms: errors: @@ -1284,6 +1299,7 @@ cs: parent_hidden: Nelze odkrýt tento zdroj, protože jeho nadřazená položka je stále skrytá. title: Akce unhide: Vrátit skrytí + unreport: Zpět hlášení admin: reportable: bulk_action: diff --git a/decidim-admin/config/locales/de.yml b/decidim-admin/config/locales/de.yml index aec907a9c4969..7c398e3ab3f97 100644 --- a/decidim-admin/config/locales/de.yml +++ b/decidim-admin/config/locales/de.yml @@ -35,6 +35,7 @@ de: id: ID newsletter: body: Haupttext + send_to_all_users: An alle Teilnehmende senden send_to_followers: An Follower senden send_to_participants: An Teilnehmende senden subject: Betreff @@ -292,11 +293,13 @@ de: block_user: bulk_new: action: Konten sperren und Begründung senden + already_reported_html: Wenn Sie diese Aktion fortsetzen, werden Sie auch alle Inhalte der Teilnehmenden ausblenden. description: Das Blockieren eines Teilnehmenden wird das verknüpfte Konto unbrauchbar machen. Sie können dies begründen und Richtlinien dafür bieten, wie der Teilnehmende vorgehen kann, damit das Konto wieder entsperrt wird. justification: Begründung title: Teilnehmende blockieren new: action: Konto sperren und Begründung senden + already_reported_html: Wenn Sie diese Aktion fortsetzen, werden Sie auch alle Inhalte der Teilnehmenden ausblenden. description: Das Blockieren eines Benutzers wird sein Konto unbrauchbar machen. Sie können begründen und Richtlinien dafür bieten, wie der Benutzer vorgehen könnte, damit Sie in Betracht ziehen, die Blockierung wieder aufzuheben. justification: Begründung title: Benutzer %{name} blockieren @@ -390,6 +393,7 @@ de: form: domain_too_short: Domain zu kurz update: + error: Das Aktualisieren der Liste der zulässigen externen Domains ist fehlgeschlagen. success: Die Liste der zulässigen externen Domains wurde erfolgreich aktualisiert. exports: export_as: "%{name} als %{export_format}" @@ -474,6 +478,7 @@ de: import_csv: explanation: 'Hinweise für die Datei:' message_1: CSV-Dateien werden unterstützt + message_2: "CSV-Datei mit E-Mail-Daten" help_sections: error: Beim Aktualisieren der Hilfeabschnitte ist ein Fehler aufgetreten. form: diff --git a/decidim-admin/config/locales/en.yml b/decidim-admin/config/locales/en.yml index c3331fed510df..b9d8f4808ce09 100644 --- a/decidim-admin/config/locales/en.yml +++ b/decidim-admin/config/locales/en.yml @@ -34,6 +34,11 @@ en: help_section: content: Content id: ID + member: + email: Email + name: Name + member_csv_import: + file: File newsletter: body: Body send_to_all_users: Send to all participants @@ -92,11 +97,6 @@ en: welcome_notification_body: Welcome notification body welcome_notification_subject: Welcome notification subject youtube_handler: YouTube handler - participatory_space_private_user: - email: Email - name: Name - participatory_space_private_user_csv_import: - file: File scope: code: Code name: Name @@ -133,6 +133,10 @@ en: file: File errors: models: + member_csv_import: + attributes: + file: + malformed: Malformed import file, please read through the instructions carefully and make sure the file is UTF-8 encoded. newsletter: attributes: base: @@ -141,10 +145,6 @@ en: attributes: official_img_footer: allowed_file_content_types: Invalid image file - participatory_space_private_user_csv_import: - attributes: - file: - malformed: Malformed import file, please read through the instructions carefully and make sure the file is UTF-8 encoded. user_group_csv_verification: attributes: file: @@ -192,12 +192,12 @@ en: export: Export export-selection: Export selection import: Import + member: + new: New member menu_hidden: Hide from menu moderate: Manage moderations newsletter: new: New newsletter - participatory_space_private_user: - new: New participatory space private user per_page: Per page permissions: Manage permissions restore: Restore @@ -419,6 +419,17 @@ en: values: 'false': 'No' 'true': 'Yes' + members: + user_invitation_accepted_at_not_null: + label: Invitation accepted + values: + 'false': Not accepted + 'true': Accepted + user_invitation_sent_at_not_null: + label: Invitation sent + values: + 'false': Not sent + 'true': Sent moderated_users: reports_reason_eq: label: Report reason @@ -434,17 +445,6 @@ en: values: 'false': Officialized 'true': Not officialized - participatory_space_private_users: - user_invitation_accepted_at_not_null: - label: Invitation accepted - values: - 'false': Not accepted - 'true': Accepted - user_invitation_sent_at_not_null: - label: Invitation sent - values: - 'false': Not sent - 'true': Sent private_space_eq: label: Private values: @@ -577,6 +577,53 @@ en: explanation: Managed participants can be promoted to standard participants. It means they will be invited to the application and you will not be able to manage them again. The invited participant will receive an email to accept your invitation. new_managed_user_promotion: New managed participant promotion promote: Promote + members: + create: + error: There was a problem adding a member for this participatory space. + success: Member access successfully created. + destroy: + error: There was a problem deleting a member for this participatory space. + success: Member access successfully destroyed. + edit: + title: Edit Member. + update: Update + index: + import_via_csv: Import via CSV + publish_all: Publish all + title: Member + unpublish_all: Unpublish all + new: + create: Create + title: New Member. + publish_all: + error: There was a problem publishing all the members for this participatory space. + success: Successfully published all members for this participatory space + unpublish_all: + error: There was a problem unpublishing all the members for this participatory space. + success: Successfully unpublished all members for this participatory space + update: + error: There was a problem updating the member for this participatory space. + success: Member successfully updated + members_csv_imports: + create: + invalid: There was a problem reading the CSV file. Please make sure you have followed the instructions. + success: CSV file uploaded successfully, we are sending an invitation email to participants. This might take a while. + new: + csv_upload: + title: Upload your CSV file + destroy: + button: Delete all members + confirm: Are you sure you want to delete all members? This action cannot be undone, you will not be able to recover them. + empty: You have no members. + explanation: You have %{count} members. + title: Delete members + example_file: 'Example file:' + explanation: Upload your CSV file. It must have two columns with email in the first column of the file and name in the last column of the file of the users that you want to add to the participatory space, without headers. Avoid using invalid chars like `<>?%&^*#@()[]=+:;"{}\|` in user name. + explanation_example: | + john.doe@example.org%{csv_col_sep}John Doe + jane.doe@example.org%{csv_col_sep}Jane Doe + title: Import members via CSV + upload: Upload menu: admin_log: Admin activity log admins: Admins @@ -635,6 +682,8 @@ en: reason: Reason started_at: Started at user: Participant + member: + name: Member newsletter: fields: created_at: Created at @@ -643,8 +692,6 @@ en: sent_to: Sent to subject: Subject name: Newsletter - participatory_space_private_user: - name: Participatory space private participant scope: fields: name: Name @@ -919,53 +966,6 @@ en: form: add: Add to allowed list title: Allowed list of external domains - participatory_space_private_users: - create: - error: There was a problem adding a private participant for this participatory space. - success: Participatory space private participant access successfully created. - destroy: - error: There was a problem deleting a private participant for this participatory space. - success: Participatory space private participant access successfully destroyed. - edit: - title: Edit Participatory Space private participant. - update: Update - index: - import_via_csv: Import via CSV - publish_all: Publish all - title: Participatory space private participant - unpublish_all: Unpublish all - new: - create: Create - title: New Participatory Space private participant. - publish_all: - error: There was a problem publishing all the private participants for this participatory space. - success: Successfully published all private participants for this participatory space - unpublish_all: - error: There was a problem unpublishing all the private participants for this participatory space. - success: Successfully unpublished all private participants for this participatory space - update: - error: There was a problem updating the private participant for this participatory space. - success: Participatory space private participant successfully updated - participatory_space_private_users_csv_imports: - create: - invalid: There was a problem reading the CSV file. Please make sure you have followed the instructions. - success: CSV file uploaded successfully, we are sending an invitation email to participants. This might take a while. - new: - csv_upload: - title: Upload your CSV file - destroy: - button: Delete all private participants - confirm: Are you sure you want to delete all private participants? This action cannot be undone, you will not be able to recover them. - empty: You have no private participants. - explanation: You have %{count} private participants. - title: Delete private participants - example_file: 'Example file:' - explanation: Upload your CSV file. It must have two columns with email in the first column of the file and name in the last column of the file of the users that you want to add to the participatory space, without headers. Avoid using invalid chars like `<>?%&^*#@()[]=+:;"{}\|` in user name. - explanation_example: | - john.doe@example.org%{csv_col_sep}John Doe - jane.doe@example.org%{csv_col_sep}Jane Doe - title: Import private participants via CSV - upload: Upload reminders: create: error: There was a problem creating reminders. diff --git a/decidim-admin/config/locales/es-MX.yml b/decidim-admin/config/locales/es-MX.yml index bf9df930afd23..67926595a9eb8 100644 --- a/decidim-admin/config/locales/es-MX.yml +++ b/decidim-admin/config/locales/es-MX.yml @@ -125,6 +125,9 @@ es-MX: show_in_footer: Mostrar en el pie de página title: Título weight: Orden de posición + taxonomy: + item_name: Nombre del elemento + parent_id: Taxonomía madre user_group_csv_verification: file: Expediente errors: diff --git a/decidim-admin/config/locales/es-PY.yml b/decidim-admin/config/locales/es-PY.yml index a600e0380bba1..efec79c30f92b 100644 --- a/decidim-admin/config/locales/es-PY.yml +++ b/decidim-admin/config/locales/es-PY.yml @@ -125,6 +125,9 @@ es-PY: show_in_footer: Mostrar en el pie de página title: Título weight: Orden de posición + taxonomy: + item_name: Nombre del elemento + parent_id: Taxonomía madre user_group_csv_verification: file: Expediente errors: diff --git a/decidim-admin/config/locales/es.yml b/decidim-admin/config/locales/es.yml index fc007682585c5..d5dc956249129 100644 --- a/decidim-admin/config/locales/es.yml +++ b/decidim-admin/config/locales/es.yml @@ -125,6 +125,9 @@ es: show_in_footer: Mostrar en el pie de página title: Título weight: Orden de posición + taxonomy: + item_name: Nombre del elemento + parent_id: Taxonomía madre user_group_csv_verification: file: Archivo errors: diff --git a/decidim-admin/config/locales/eu.yml b/decidim-admin/config/locales/eu.yml index 8bb5f29f864c1..4d9201be4b03a 100644 --- a/decidim-admin/config/locales/eu.yml +++ b/decidim-admin/config/locales/eu.yml @@ -125,6 +125,9 @@ eu: show_in_footer: Erakutsi orri-oinean title: Izenburua weight: Kokapenaren hurrenkera + taxonomy: + item_name: Elementuaren izena + parent_id: Nagusia user_group_csv_verification: file: Fitxategia errors: diff --git a/decidim-admin/config/locales/fi-plain.yml b/decidim-admin/config/locales/fi-plain.yml index ad15d7a023ba4..0441ead0dd5af 100644 --- a/decidim-admin/config/locales/fi-plain.yml +++ b/decidim-admin/config/locales/fi-plain.yml @@ -35,6 +35,7 @@ fi-pl: id: ID newsletter: body: Runko + send_to_all_users: Lähetä kaikille osallistujille send_to_followers: Lähetä seuraajille send_to_participants: Lähetä osallistujille subject: Otsikko @@ -292,11 +293,13 @@ fi-pl: block_user: bulk_new: action: Estä tilit ja lähetä perustelut + already_reported_html: Tämä toiminto piilottaa myös kaiken sisällön, jonka kyseinen osallistuja on luonut. description: Käyttäjätilien estäminen estää kyseisten käyttäjien pääsyn omalle tililleen. Voit antaa perusteluissasi ja ohjeistuksissasi tietoa siitä, mitä käyttäjät voivat tehdä palauttaakseen pääsyn tileilleen. justification: Perustelut title: Estä käyttäjät new: action: Estä tili ja lähetä perustelut + already_reported_html: Tämä toiminto piilottaa myös kaiken sisällön, jonka kyseinen osallistuja on luonut. description: Estämällä käyttäjän estät kyseisen käyttäjän pääsyn hänen omalle tililleen. Voit antaa perusteluissasi ja ohjeistuksissasi tietoa siitä, mitä kyseinen käyttäjä voi tehdä palauttaakseen pääsyn tililleen. justification: Perustelut title: Estä käyttäjä %{name} @@ -390,6 +393,7 @@ fi-pl: form: domain_too_short: Verkkotunnus on liian lyhyt update: + error: Sallittujen ulkoisten verkkotunnusten luettelon päivittäminen epäonnistui. success: Sallittujen ulkoisten verkkotunnusten luettelon päivittäminen onnistui. exports: export_as: "%{name} muodossa %{export_format}" @@ -474,6 +478,7 @@ fi-pl: import_csv: explanation: 'Tiedoston ohjeistus:' message_1: CSV-tiedostomuotoa tuetaan + message_2: "sähköpostiosoitteet sisältävä .csv-tiedosto" help_sections: error: Ohjeosioiden päivitys epäonnistui. form: @@ -1265,6 +1270,7 @@ fi-pl: last_day: Viimeinen päivä last_month: Viimeinen kuukausi last_week: Viimeinen viikko + no_users_count_statistics_yet: Käyttäjämäärätilastoja ei vielä ole. participants: Osallistujat forms: errors: diff --git a/decidim-admin/config/locales/fi.yml b/decidim-admin/config/locales/fi.yml index c23852d5a88de..d9f5cae7357b9 100644 --- a/decidim-admin/config/locales/fi.yml +++ b/decidim-admin/config/locales/fi.yml @@ -35,6 +35,7 @@ fi: id: ID newsletter: body: Runko + send_to_all_users: Lähetä kaikille osallistujille send_to_followers: Lähetä seuraajille send_to_participants: Lähetä osallistujille subject: Otsikko @@ -292,11 +293,13 @@ fi: block_user: bulk_new: action: Estä tilit ja lähetä perustelut + already_reported_html: Tämä toiminto piilottaa myös kaiken sisällön, jonka kyseinen osallistuja on luonut. description: Käyttäjätilien estäminen estää kyseisten käyttäjien pääsyn omalle tililleen. Voit antaa perusteluissasi ja ohjeistuksissasi tietoa siitä, mitä käyttäjät voivat tehdä palauttaakseen pääsyn tileilleen. justification: Perustelut title: Estä käyttäjät new: action: Estä tili ja lähetä perustelut + already_reported_html: Tämä toiminto piilottaa myös kaiken sisällön, jonka kyseinen osallistuja on luonut. description: Estämällä käyttäjän estät kyseisen käyttäjän pääsyn hänen omalle tililleen. Voit antaa perusteluissasi ja ohjeistuksissasi tietoa siitä, mitä kyseinen käyttäjä voi tehdä palauttaakseen pääsyn tililleen. justification: Perustelut title: Estä käyttäjä %{name} @@ -390,6 +393,7 @@ fi: form: domain_too_short: Verkkotunnus on liian lyhyt update: + error: Sallittujen ulkoisten verkkotunnusten luettelon päivittäminen epäonnistui. success: Sallittujen ulkoisten verkkotunnusten luettelon päivittäminen onnistui. exports: export_as: "%{name} muodossa %{export_format}" @@ -474,6 +478,7 @@ fi: import_csv: explanation: 'Tiedoston ohjeistus:' message_1: CSV-tiedostomuotoa tuetaan + message_2: "sähköpostiosoitteet sisältävä .csv-tiedosto" help_sections: error: Ohjeosioiden päivitys epäonnistui. form: @@ -1265,6 +1270,7 @@ fi: last_day: Viimeinen päivä last_month: Viimeinen kuukausi last_week: Viimeinen viikko + no_users_count_statistics_yet: Käyttäjämäärätilastoja ei vielä ole. participants: Osallistujat forms: errors: diff --git a/decidim-admin/config/locales/fr-CA.yml b/decidim-admin/config/locales/fr-CA.yml index 1116d7962e3be..f54972134ee6a 100644 --- a/decidim-admin/config/locales/fr-CA.yml +++ b/decidim-admin/config/locales/fr-CA.yml @@ -124,6 +124,9 @@ fr-CA: show_in_footer: Montrer dans le pied de page title: Titre weight: Rang + taxonomy: + item_name: Nom de l’élément + parent_id: Parent user_group_csv_verification: file: Fichier errors: diff --git a/decidim-admin/config/locales/fr.yml b/decidim-admin/config/locales/fr.yml index 45583581c9155..61845fa999563 100644 --- a/decidim-admin/config/locales/fr.yml +++ b/decidim-admin/config/locales/fr.yml @@ -124,6 +124,9 @@ fr: show_in_footer: Montrer dans le pied de page title: Titre weight: Rang d'affichage + taxonomy: + item_name: Nom de l’élément + parent_id: Parent user_group_csv_verification: file: Fichier errors: diff --git a/decidim-admin/config/locales/ja.yml b/decidim-admin/config/locales/ja.yml index 4b8dcfef4ca01..31285675954b2 100644 --- a/decidim-admin/config/locales/ja.yml +++ b/decidim-admin/config/locales/ja.yml @@ -35,6 +35,7 @@ ja: id: ID newsletter: body: 本文 + send_to_all_users: 全参加者に送信 send_to_followers: フォロワーに送信 send_to_participants: 参加者に送信 subject: 件名 @@ -292,11 +293,13 @@ ja: block_user: bulk_new: action: アカウントをブロックして理由を送信 + already_reported_html: この操作を続行すると、参加者のコンテンツもすべて非表示になります。 description: ユーザーをブロックすると、そのアカウントは利用できなくなります。ユーザーのブロックを解除することを検討する方法に関するガイドラインを判定通知に含めることができます。 justification: 判定理由 title: ユーザーをブロック new: action: アカウントをブロックして理由を送信 + already_reported_html: この操作を続行すると、参加者のコンテンツもすべて非表示になります。 description: ユーザーをブロックすると、そのアカウントは利用できなくなります。ユーザーのブロックを解除することを検討する方法に関するガイドラインを判定通知に含めることができます。 justification: 判定理由 title: ユーザー %{name} をブロックする @@ -389,6 +392,7 @@ ja: form: domain_too_short: ドメインが短すぎます update: + error: 許可された外部ドメインのリストを更新できませんでした。 success: 許可された外部ドメインのリストを更新しました。 exports: export_as: "%{name} を %{export_format} 形式で取得" @@ -473,6 +477,7 @@ ja: import_csv: explanation: 'ファイルのガイダンス:' message_1: CSVファイルがサポートされています + message_2: "電子メールデータを含む.csvファイル" help_sections: error: ヘルプセクションの更新中に問題が発生しました。 form: @@ -1257,6 +1262,7 @@ ja: last_day: 直近24時間 last_month: 直近1ヶ月間 last_week: 直近1週間 + no_users_count_statistics_yet: 参加者数の統計データはまだありません。 participants: 参加者 forms: errors: diff --git a/decidim-admin/config/locales/sv.yml b/decidim-admin/config/locales/sv.yml index 69100c45997e5..1354f28b7297b 100644 --- a/decidim-admin/config/locales/sv.yml +++ b/decidim-admin/config/locales/sv.yml @@ -124,6 +124,9 @@ sv: show_in_footer: Visa i sidfoten title: Titel weight: Position i listan + taxonomy: + item_name: Objektnamn + parent_id: Överordnad user_group_csv_verification: file: Fil errors: diff --git a/decidim-admin/config/locales/tr-TR.yml b/decidim-admin/config/locales/tr-TR.yml index 70d22c1a67775..2a0f1186da356 100644 --- a/decidim-admin/config/locales/tr-TR.yml +++ b/decidim-admin/config/locales/tr-TR.yml @@ -35,6 +35,7 @@ tr: id: İD newsletter: body: Vücut + send_to_all_users: Bütün katılımcılara gönder send_to_followers: Takipçilere gönder send_to_participants: Katılımcılara gönder subject: konu @@ -165,6 +166,7 @@ tr: decidim: admin: actions: + actions_label: '%{resource} için işlemler' add: Eklemek attachment: new: Yeni ek @@ -181,6 +183,7 @@ tr: Fikrinizi değiştirirseniz daha sonra yeniden yükleyebilirsiniz. export-selection: Seçimi dışa aktar import: İçe aktar + menu_hidden: Menüden gizle newsletter: new: Yeni bülten participatory_space_private_user: @@ -281,6 +284,7 @@ tr: block_user: bulk_new: action: Hesapları engelle ve gerekçe gönder. + already_reported_html: Bu işleme devam ederek katılımcının tüm içeriklerini de gizleyeceksiniz. description: Bir kullanıcıyı engellemek, kullanıcının hesabını kullanılamaz hale getirir. Gerekçenizde engeli kaldırmak için gereken yönergeleri ekleyebilirsiniz. justification: Gerekçe title: Kullanıcıları engelle @@ -300,6 +304,7 @@ tr: create: error: Bu bileşeni oluştururken bir hata oluştu. success: Bileşen başarıyla oluşturuldu. + success_landing_page: Bileşen başarıyla oluşturuldu. Alanın ana sayfasına bu bileşen için bir içerik engeli ekleyebilirsiniz. Yapılandırmak için Ana sayfaya gidiniz. edit: title: Bileşeni düzenle update: Güncelleştirme @@ -375,6 +380,7 @@ tr: form: domain_too_short: Alan adı çok kısa update: + error: İzin verilen harici alan adı listesi güncellenemedi. success: İzin verilen harici alan adı listesi başarıyla güncellendi. exports: export_as: "%{name} olarak %{export_format}" @@ -433,6 +439,9 @@ tr: 'true': Yayından kaldırıldı remove_all: Tümünü kaldır search_label: Arama + search_placeholder: + name_or_nickname_or_email_cont: '%{collection}''da e-posta, isim veya takma ad ile ara' + user_name_or_user_nickname_or_user_email_cont: '%{collection}''da e-posta, isim veya takma ad ile ara' state_eq: label: Durum values: @@ -452,6 +461,7 @@ tr: import_csv: explanation: 'Dosya kılavuzu:' message_1: CSV dosyaları desteklenmektedir. + message_2: "e-posta verileri içeren .csv dosyası" help_sections: error: Yardım bölümleri güncellenirken bir hata oluştu. form: @@ -506,6 +516,7 @@ tr: records: detail: Lütfen satırların doğru biçimlendirildiğinden ve geçerli kayıtlar içerdiğinden emin olun. missing_headers: + detail: Lütfen dosyanın istenen sütunlara sahip olduğundan emin olunuz. message: one: Eksik sütun %{columns}. other: Eksik sütunlar %{columns}. @@ -518,12 +529,14 @@ tr: actions: back: Geri download_example: Örneği indir + download_example_format: file_legend: Ayrıştırılmak üzere bir içe aktarma dosyası ekleyin. import: İçe aktar notice: "%{count} %{resource_name} başarıyla içe aktarıldı." logs: filters: participatory_space: Katılımcı alanı bul + text: Kullanıcı e-postası, isim veya kullanıcı adı ile ara user: Kullanıcı logs_list: no_logs_yet: Henüz kayıt yok. @@ -550,6 +563,7 @@ tr: content: Şikayet edilmiş içerik external_domain_allowlist: İzin verilen harici alan adları help_sections: Yardım bölümleri + homepage: Ana sayfa düzeni impersonations: impersonations manage: yönetme moderation: Küresel denetimler @@ -683,6 +697,10 @@ tr: report_language: Rapor dili report_reason: Nedeni title: Rapor ayrıntıları + new_import: + accepted_mime_types: + json: JSON + xlsx: XLSX newsletter_templates: index: preview_template: Ön izleme @@ -692,6 +710,8 @@ tr: preview: 'Şablon özizlemesi: %{template_name}' use_template: Bu şablonu kullan newsletters: + confirm_recipients: + title: Alıcıları doğrula create: error: Bu bülteni oluştururken bir hata oluştu. deliver: @@ -726,6 +746,7 @@ tr: followers_help: Listede seçili herhangi bir katılımcı süreci takip eden bütün onaylanmış kullanıcılara haber bülteni gönderir. participants_help: Listede seçili herhangi bir katılımcı sürece katılmış olan bütün onaylanmış kullanıcılara haber bülteni gönderir. recipients_count: Bu haber bülteni %{count} kullanıcıya gönderilecektir. + select_participatory_processes: Katılımcı süreçleri seç select_spaces: Haber bültenini bölmek için alanları seçin send_to_all_users: Tüm kullanıcılara gönder title: Teslim etmek için alıcıları seçiniz @@ -768,6 +789,7 @@ tr: officialized: resmileştirilmiş reofficialize: Reofficialize reports: Raporlar + send_message: Mesaj Gönder status: durum unofficialize: Unofficialize new: @@ -789,9 +811,17 @@ tr: title: Kuruluş düzenle update: Güncelleştirme form: + admin_terms_of_service: Yönetici kullanım koşulları + colors: + colors_title: Organizasyon renkleri + colors_warning_html: Uyarı! Bu renkleri değiştirmek renk kontrastını bozabilir. Seçtiğiniz kontrastı veya diğer benzer araçları WebAIM Contrast Checker'dan kontrol edebilirsiniz. + explanation: Bu araç organizasyonun web sitesinde kullanılacak renk paletinde eşit aralıklarla yerleştirilmiş tonlardan oluşan bir renk düzeni seçmenize yardımcı olur. + extra_features: İlave özellikler facebook: Facebook github: GitHub instagram: Instagram + logos: + organization_logos: Organizasyon logoları rich_text_editor_in_public_views_help: Bazı metin alanlarında, katılımcılar zengin metin düzenleyicisini kullanarak HTML etiketleri ekleyebilecekler. social_handlers: Sosyal twitter: x @@ -829,11 +859,18 @@ tr: new: create: yaratmak title: Yeni Katılımcı Uzay özel kullanıcısı. + publish_all: + success: Bu katılımcı alan için özel katılımcılar başarıyla yayınlandı participatory_space_private_users_csv_imports: + create: + success: CSV dosyası başarıyla yüklendi, katılımcılara bir davet e-postası gönderiyoruz. Bu biraz zaman alabilir. new: csv_upload: title: CSV dosyanızı yükleyin destroy: + button: Tüm özel kullanıcıları sil + confirm: Tüm özel kullanıcıları silmek istediğinizden emin misiniz? Bu işlem geri alınamaz, silinen katılımcıları geri yükleyemeyeceksiniz. + empty: Özel kullanıcınız yok. title: Özel Kullanıcıları kaldır example_file: 'Örnek dosya:' upload: Yükle @@ -877,10 +914,14 @@ tr: success: Kapsam başarıyla güncellendi share_tokens: actions: + confirm_destroy: Bu erişimi silmek istediğinizden emin misiniz? destroy: Sil edit: Düzenle preview: Ön İzleme + create: + invalid: Erişim linki üretilirken bir hata oluştu. edit: + title: '%{name} için yeni erişim linki' update: Güncelle form: automatic: Otomatik @@ -893,6 +934,19 @@ tr: 'true': 'Evet' index: copied: Erişim bağlantısı kopyalandı! + copy_message: Metin başarıyla panoya kopyalandı. + create_new_token: İlk erişim linkini oluştur! + empty_html: Şu an aktif olan bir erişim linki yok. %{new_token_link} + never: Asla + new_share_token_button: Yeni erişim linki + share_tokens_help_html: | + Diğerlerinin bu yayınlanmamış kaynağı görüntüleyebilmesi için bir erişim linki oluşturun ve paylaşın. + Erişim linkleri sadece kayıtlı kullanıcılar için geçerli olabilir ve gerekirse bir son kullanım tarihi belirlenebilir. + Yeni bir erişim linki paylaşmak için, oluşturduktan sonra "%{clipboard} ikonunu kullanarak linki kopyalayın. + title: '%{name} için erişim linkleri' + new: + create: Oluştur + title: '%{name} için yeni erişim linki' shared: gallery: add_images: Resim ekle @@ -925,9 +979,34 @@ tr: new: title: Yeni sayfa topic: + empty: Bu konu altında hiçbir sayfa yok. without_topic: Konu olmayan sayfalar update: error: Bu sayfa güncellenirken bir hata oluştu. + success: Sayfa başarıyla güncellendi. + taxonomies: + destroy: + invalid: Bu sınıflandırmayı kaldırırken bir hata oluştu. + success: Sınıflandırma başarıyla kaldırıldı. + edit: + description: Bu kategorideki ögeler katılımcı alanları veya bileşenler gibi kaynakları sınıflandırmak veya filtrelemek için kullanılacaktır. Listeyi yeniden düzenlemek için ögeleri sürükleyerek bırakınız. + new: + title: Yeni kategori + update: + success: Kategori başarıyla güncellendi. + taxonomy_filters: + create: + error: Bu kategori filtresi oluşturulurken bir hata oluştu. + destroy: + success: Kategori filtresi başarıyla kaldırıldı. + form: + no_items: Bu kategori için herhangi bir öge mevcut değil. Kategori içindeki yapılandırma ayarlarından ögeler oluşturabilirsiniz. + table: + components_count: Bunu kullanan bileşenler + internal_name: + taxonomy_filters_selector: + new: + save: titles: admin_log: Yönetici günlüğü area_types: Alan türleri @@ -941,6 +1020,10 @@ tr: participants: Kullanıcılar scope_types: Kapsam türleri scopes: kapsamları + tooltips: + cannot_edit_taxonomy_filter: Bu kategori filtresi düzenlenemiyor + deleted_attachment_collections_info: Bu klasör ek dosyalar içerdiğinden silinemiyor. + deleted_component_info: Bu bileşen sadece durumu 'Yayınlanmamış' ise silinebilir. users: create: error: Bu kullanıcıyı davet ederken bir hata oluştu. @@ -954,12 +1037,14 @@ tr: role: Rol new: create: Davet et + title: Yeni yönetici davet et users_statistics: users_count: admins: Yöneticiler last_day: Son gün last_month: Geçen ay last_week: Geçen hafta + no_users_count_statistics_yet: Henüz katılımcı sayısı istatistiği yok. participants: Katılımcılar moderations: actions: diff --git a/decidim-admin/lib/decidim/admin/engine.rb b/decidim-admin/lib/decidim/admin/engine.rb index 9cf85b753ecc0..e80ed6aee9201 100644 --- a/decidim-admin/lib/decidim/admin/engine.rb +++ b/decidim-admin/lib/decidim/admin/engine.rb @@ -44,7 +44,6 @@ class Engine < ::Rails::Engine Decidim.icons.register(name: "earth-line", icon: "earth-line", category: "system", description: "Earth line", engine: :admin) Decidim.icons.register(name: "attachment-2", icon: "attachment-2", category: "system", description: "", engine: :admin) - Decidim.icons.register(name: "spy-line", icon: "spy-line", category: "system", description: "", engine: :admin) Decidim.icons.register(name: "refresh-line", icon: "refresh-line", category: "system", description: "", engine: :admin) Decidim.icons.register(name: "zoom-in-line", icon: "zoom-in-line", category: "system", description: "", engine: :admin) Decidim.icons.register(name: "add-line", icon: "add-line", category: "system", description: "", engine: :admin) diff --git a/decidim-admin/spec/commands/decidim/admin/deliver_newsletter_spec.rb b/decidim-admin/spec/commands/decidim/admin/deliver_newsletter_spec.rb index 69ae26b33b841..653d2be104c7f 100644 --- a/decidim-admin/spec/commands/decidim/admin/deliver_newsletter_spec.rb +++ b/decidim-admin/spec/commands/decidim/admin/deliver_newsletter_spec.rb @@ -315,11 +315,11 @@ def user_localized_body(user) context "when spaces selected" do let!(:participatory_process) { create(:participatory_process, organization:, private_space: true) } let!(:component) { create(:dummy_component, organization:, participatory_space: participatory_process) } - let!(:private_users) do - create_list(:participatory_space_private_user, 30) do |private_user| - private_user.user = create(:user, :confirmed, newsletter_notifications_at: Time.current, organization:) - private_user.privatable_to = participatory_process - private_user.save! + let!(:members) do + create_list(:member, 30) do |member| + member.user = create(:user, :confirmed, newsletter_notifications_at: Time.current, organization:) + member.privatable_to = participatory_process + member.save! end end let(:participatory_space_types) do @@ -339,7 +339,7 @@ def user_localized_body(user) ] end - let!(:deliverable_users) { Decidim::User.where(id: private_users.map(&:decidim_user_id)) } + let!(:deliverable_users) { Decidim::User.where(id: members.map(&:decidim_user_id)) } let!(:undeliverable_users) do create_list(:user, rand(2..9), :confirmed, organization:, newsletter_notifications_at: Time.current) diff --git a/decidim-admin/spec/commands/decidim/admin/create_participatory_space_private_user_spec.rb b/decidim-admin/spec/commands/decidim/admin/participatory_space/create_member_spec.rb similarity index 81% rename from decidim-admin/spec/commands/decidim/admin/create_participatory_space_private_user_spec.rb rename to decidim-admin/spec/commands/decidim/admin/participatory_space/create_member_spec.rb index ef1602ab84e81..e0871b6c4c8e3 100644 --- a/decidim-admin/spec/commands/decidim/admin/create_participatory_space_private_user_spec.rb +++ b/decidim-admin/spec/commands/decidim/admin/participatory_space/create_member_spec.rb @@ -2,8 +2,8 @@ require "spec_helper" -module Decidim::Admin - describe CreateParticipatorySpacePrivateUser do +module Decidim::Admin::ParticipatorySpace + describe CreateMember do subject { described_class.new(form, privatable_to, via_csv:) } let(:via_csv) { false } @@ -16,7 +16,7 @@ module Decidim::Admin let(:form) do double( invalid?: invalid, - delete_current_private_participants?: delete, + delete_current_members?: delete, email:, current_user:, name:, @@ -37,12 +37,12 @@ module Decidim::Admin end context "when everything is ok" do - it "creates the private user" do + it "creates the member" do subject.call - participatory_space_private_users = Decidim::ParticipatorySpacePrivateUser.where(user:) + members = Decidim::ParticipatorySpace::Member.where(user:) - expect(participatory_space_private_users.count).to eq 1 + expect(members.count).to eq 1 end it "creates a new user with no application admin privileges" do @@ -55,7 +55,7 @@ module Decidim::Admin .to receive(:perform_action!) .with( "create", - Decidim::ParticipatorySpacePrivateUser, + Decidim::ParticipatorySpace::Member, current_user, resource: { title: user.name } ) @@ -74,7 +74,7 @@ module Decidim::Admin .to receive(:perform_action!) .with( "create_via_csv", - Decidim::ParticipatorySpacePrivateUser, + Decidim::ParticipatorySpace::Member, current_user, resource: { title: user.name } ) @@ -104,7 +104,7 @@ module Decidim::Admin end end - context "when a private user exist" do + context "when a member exist" do before do subject.call end @@ -112,9 +112,9 @@ module Decidim::Admin it "does not get created twice" do expect { subject.call }.to broadcast(:ok) - participatory_space_private_users = Decidim::ParticipatorySpacePrivateUser.where(user:) + members = Decidim::ParticipatorySpace::Member.where(user:) - expect(participatory_space_private_users.count).to eq 1 + expect(members.count).to eq 1 end end @@ -125,10 +125,10 @@ module Decidim::Admin it "still finds the user" do expect { subject.call }.to broadcast(:ok) - participatory_space_private_users = Decidim::ParticipatorySpacePrivateUser.where(user: admin) + members = Decidim::ParticipatorySpace::Member.where(user: admin) participatory_space_admin = Decidim::User.where(email: "admin@example.org") - expect(participatory_space_private_users.count).to eq 1 + expect(members.count).to eq 1 expect(participatory_space_admin.first.admin?).to be true end end diff --git a/decidim-admin/spec/commands/decidim/admin/destroy_participatory_space_private_user_spec.rb b/decidim-admin/spec/commands/decidim/admin/participatory_space/destroy_member_spec.rb similarity index 68% rename from decidim-admin/spec/commands/decidim/admin/destroy_participatory_space_private_user_spec.rb rename to decidim-admin/spec/commands/decidim/admin/participatory_space/destroy_member_spec.rb index 43ba90f553ff8..354190ba526ca 100644 --- a/decidim-admin/spec/commands/decidim/admin/destroy_participatory_space_private_user_spec.rb +++ b/decidim-admin/spec/commands/decidim/admin/participatory_space/destroy_member_spec.rb @@ -2,17 +2,17 @@ require "spec_helper" -module Decidim::Admin - describe DestroyParticipatorySpacePrivateUser do - subject { described_class.new(participatory_space_private_user, user) } +module Decidim::Admin::ParticipatorySpace + describe DestroyMember do + subject { described_class.new(member, user) } let(:organization) { create(:organization) } let(:user) { create(:user, :admin, :confirmed, organization:) } - let(:participatory_space_private_user) { create(:participatory_space_private_user, user:) } + let(:member) { create(:member, user:) } - it "destroys the participatory space private user" do + it "destroys the member" do subject.call - expect { participatory_space_private_user.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { member.reload }.to raise_error(ActiveRecord::RecordNotFound) end it "broadcasts ok" do @@ -26,7 +26,7 @@ module Decidim::Admin .to receive(:perform_action!) .with( :delete, - participatory_space_private_user, + member, user, resource: { title: user.name } ) @@ -40,14 +40,14 @@ module Decidim::Admin context "when assembly is private and user follows assembly" do let(:normal_user) { create(:user, organization:) } let(:assembly) { create(:assembly, :private, :published, organization: user.organization) } - let!(:participatory_space_private_user) { create(:participatory_space_private_user, user: normal_user, privatable_to: assembly) } + let!(:member) { create(:member, user: normal_user, privatable_to: assembly) } let!(:follow) { create(:follow, followable: assembly, user: normal_user) } context "and assembly is transparent" do it "does not enqueue a job" do assembly.update(is_transparent: true) expect(Decidim::Follow.where(user: normal_user).count).to eq(1) - expect { subject.call }.not_to have_enqueued_job(DestroyPrivateUsersFollowsJob) + expect { subject.call }.not_to have_enqueued_job(DestroyMembersFollowsJob) end end @@ -55,7 +55,7 @@ module Decidim::Admin it "enqueues a job" do assembly.update(is_transparent: false) expect(Decidim::Follow.where(user: normal_user).count).to eq(1) - expect { subject.call }.to have_enqueued_job(DestroyPrivateUsersFollowsJob) + expect { subject.call }.to have_enqueued_job(DestroyMembersFollowsJob) end end end @@ -63,14 +63,14 @@ module Decidim::Admin context "when participatory process is private" do let(:normal_user) { create(:user, organization:) } let(:participatory_process) { create(:participatory_process, :private, :published, organization: user.organization) } - let!(:participatory_space_private_user) { create(:participatory_space_private_user, user: normal_user, privatable_to: participatory_process) } + let!(:member) { create(:member, user: normal_user, privatable_to: participatory_process) } context "and user follows process" do let!(:follow) { create(:follow, followable: participatory_process, user: normal_user) } it "enqueues a job" do expect(Decidim::Follow.where(user: normal_user).count).to eq(1) - expect { subject.call }.to have_enqueued_job(DestroyPrivateUsersFollowsJob) + expect { subject.call }.to have_enqueued_job(DestroyMembersFollowsJob) end end end diff --git a/decidim-admin/spec/commands/decidim/admin/process_participatory_space_private_user_import_csv_spec.rb b/decidim-admin/spec/commands/decidim/admin/participatory_space/import_member_csv_spec.rb similarity index 59% rename from decidim-admin/spec/commands/decidim/admin/process_participatory_space_private_user_import_csv_spec.rb rename to decidim-admin/spec/commands/decidim/admin/participatory_space/import_member_csv_spec.rb index ff733c42aff8f..ab159b7bf823a 100644 --- a/decidim-admin/spec/commands/decidim/admin/process_participatory_space_private_user_import_csv_spec.rb +++ b/decidim-admin/spec/commands/decidim/admin/participatory_space/import_member_csv_spec.rb @@ -2,20 +2,20 @@ require "spec_helper" -module Decidim::Admin - describe ProcessParticipatorySpacePrivateUserImportCsv do - subject { described_class.new(form, private_users_to) } +module Decidim::Admin::ParticipatorySpace + describe ImportMemberCsv do + subject { described_class.new(form, members_to) } let(:current_user) { create(:user, :admin, organization:) } let(:organization) { create(:organization) } - let(:private_users_to) { create(:participatory_process, organization:) } - let(:file) { upload_test_file(Decidim::Dev.test_file("import_participatory_space_private_users.csv", "text/csv"), return_blob: true) } + let(:members_to) { create(:participatory_process, organization:) } + let(:file) { upload_test_file(Decidim::Dev.test_file("import_members.csv", "text/csv"), return_blob: true) } let(:validity) { true } let(:form) do double( current_user:, - private_users_to:, + members_to:, current_organization: organization, file:, valid?: validity @@ -30,14 +30,14 @@ module Decidim::Admin end it "does not enqueue any job" do - expect(ImportParticipatorySpacePrivateUserCsvJob).not_to receive(:perform_later) + expect(ImportMemberCsvJob).not_to receive(:perform_later) subject.call end end context "when the CSV file has BOM" do - let(:file) { upload_test_file(Decidim::Dev.test_file("import_participatory_space_private_users_with_bom.csv", "text/csv"), return_blob: true) } + let(:file) { upload_test_file(Decidim::Dev.test_file("import_members_with_bom.csv", "text/csv"), return_blob: true) } let(:email) { "john.doe@example.org" } it "broadcasts ok" do @@ -45,7 +45,7 @@ module Decidim::Admin end it "enqueues a job for each present value without BOM" do - expect(ImportParticipatorySpacePrivateUserCsvJob).to receive(:perform_later).with(email, kind_of(String), private_users_to, current_user) + expect(ImportMemberCsvJob).to receive(:perform_later).with(email, kind_of(String), members_to, current_user) subject.call end @@ -56,7 +56,7 @@ module Decidim::Admin end it "enqueues a job for each present value" do - expect(ImportParticipatorySpacePrivateUserCsvJob).to receive(:perform_later).twice.with(kind_of(String), kind_of(String), private_users_to, current_user) + expect(ImportMemberCsvJob).to receive(:perform_later).twice.with(kind_of(String), kind_of(String), members_to, current_user) subject.call end diff --git a/decidim-admin/spec/commands/decidim/admin/publish_all_participatory_space_private_users_spec.rb b/decidim-admin/spec/commands/decidim/admin/participatory_space/publish_all_members_spec.rb similarity index 72% rename from decidim-admin/spec/commands/decidim/admin/publish_all_participatory_space_private_users_spec.rb rename to decidim-admin/spec/commands/decidim/admin/participatory_space/publish_all_members_spec.rb index 409887aee336e..d1088c5a1bfd9 100644 --- a/decidim-admin/spec/commands/decidim/admin/publish_all_participatory_space_private_users_spec.rb +++ b/decidim-admin/spec/commands/decidim/admin/participatory_space/publish_all_members_spec.rb @@ -2,20 +2,20 @@ require "spec_helper" -module Decidim::Admin - describe PublishAllParticipatorySpacePrivateUsers do +module Decidim::Admin::ParticipatorySpace + describe PublishAllMembers do subject { described_class.new(privatable_to, current_user) } let!(:privatable_to) { create(:participatory_process) } let!(:user) { create(:user, email: "my_email@example.org", organization: privatable_to.organization) } - let!(:private_user) { create(:participatory_space_private_user, :unpublished, user:, privatable_to:, role:) } + let!(:member) { create(:member, :unpublished, user:, privatable_to:, role:) } let(:role) { generate_localized_title(:role) } let(:current_user) { create(:user, email: "admin@example.org", organization: privatable_to.organization) } it "updates the published attribute" do subject.call - expect(private_user.reload.published).to be(true) + expect(member.reload.published).to be(true) end it "creates an action log" do diff --git a/decidim-admin/spec/commands/decidim/admin/unpublish_all_participatory_space_private_users_spec.rb b/decidim-admin/spec/commands/decidim/admin/participatory_space/unpublish_all_members_spec.rb similarity index 72% rename from decidim-admin/spec/commands/decidim/admin/unpublish_all_participatory_space_private_users_spec.rb rename to decidim-admin/spec/commands/decidim/admin/participatory_space/unpublish_all_members_spec.rb index dbe01ab48bb50..e461a9686b5b6 100644 --- a/decidim-admin/spec/commands/decidim/admin/unpublish_all_participatory_space_private_users_spec.rb +++ b/decidim-admin/spec/commands/decidim/admin/participatory_space/unpublish_all_members_spec.rb @@ -2,20 +2,20 @@ require "spec_helper" -module Decidim::Admin - describe UnpublishAllParticipatorySpacePrivateUsers do +module Decidim::Admin::ParticipatorySpace + describe UnpublishAllMembers do subject { described_class.new(privatable_to, current_user) } let!(:privatable_to) { create(:participatory_process) } let!(:user) { create(:user, email: "my_email@example.org", organization: privatable_to.organization) } - let!(:private_user) { create(:participatory_space_private_user, :published, user:, privatable_to:, role:) } + let!(:member) { create(:member, :published, user:, privatable_to:, role:) } let(:role) { generate_localized_title(:role) } let(:current_user) { create(:user, email: "admin@example.org", organization: privatable_to.organization) } it "updates the published attribute" do subject.call - expect(private_user.reload.published).to be(false) + expect(member.reload.published).to be(false) end it "creates an action log" do diff --git a/decidim-admin/spec/commands/decidim/admin/update_participatory_space_private_user_spec.rb b/decidim-admin/spec/commands/decidim/admin/participatory_space/update_member_spec.rb similarity index 71% rename from decidim-admin/spec/commands/decidim/admin/update_participatory_space_private_user_spec.rb rename to decidim-admin/spec/commands/decidim/admin/participatory_space/update_member_spec.rb index 8b2da3f6b99d3..a9a1c4d766c8c 100644 --- a/decidim-admin/spec/commands/decidim/admin/update_participatory_space_private_user_spec.rb +++ b/decidim-admin/spec/commands/decidim/admin/participatory_space/update_member_spec.rb @@ -2,12 +2,12 @@ require "spec_helper" -module Decidim::Admin - describe UpdateParticipatorySpacePrivateUser do - subject { described_class.new(form, private_user) } +module Decidim::Admin::ParticipatorySpace + describe UpdateMember do + subject { described_class.new(form, member) } let!(:privatable_to) { create(:participatory_process) } - let!(:private_user) { create(:participatory_space_private_user, :unpublished, user:, role:) } + let!(:member) { create(:member, :unpublished, user:, role:) } let!(:user) { create(:user, email: "my_email@example.org", organization: privatable_to.organization) } let!(:current_user) { create(:user, email: "some_email@example.org", organization: privatable_to.organization) } @@ -35,13 +35,13 @@ module Decidim::Admin it "updates the role" do subject.call - expect(translated(private_user.reload.role)).to eq(translated_attribute(role)) + expect(translated(member.reload.role)).to eq(translated_attribute(role)) end it "updates the published status" do subject.call - expect(private_user.reload.published).to eq(published) + expect(member.reload.published).to eq(published) end end end diff --git a/decidim-admin/spec/forms/decidim/admin/participatory_space/member_csv_import_form_spec.rb b/decidim-admin/spec/forms/decidim/admin/participatory_space/member_csv_import_form_spec.rb new file mode 100644 index 0000000000000..7d1ef2c5c00f1 --- /dev/null +++ b/decidim-admin/spec/forms/decidim/admin/participatory_space/member_csv_import_form_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Admin + module ParticipatorySpace + describe MemberCsvImportForm do + subject do + described_class.from_params( + attributes + ).with_context( + current_user:, + current_organization: + ) + end + + let(:current_organization) { create(:organization) } + let(:current_user) { create(:user, organization: current_organization) } + + let(:attributes) do + { + "file" => file + } + end + let(:file) { upload_test_file(Decidim::Dev.asset("import_members.csv")) } + + context "when everything is OK" do + it { is_expected.to be_valid } + end + + context "when file is missing" do + let(:file) { nil } + + it { is_expected.to be_invalid } + end + + context "when user name contains invalid chars" do + let(:file) { upload_test_file(Decidim::Dev.asset("import_members_nok.csv")) } + + it { is_expected.to be_invalid } + end + + context "when the CSV separator is incorrect" do + let(:file) { upload_test_file(Decidim::Dev.asset("import_members_invalid_col_sep.csv")) } + + it { is_expected.to be_invalid } + end + + context "when the provided file is encoded with incorrect character set" do + let(:file) { upload_test_file(Decidim::Dev.asset("import_members_iso8859-1.csv")) } + + it { is_expected.to be_invalid } + + it "adds the correct error" do + subject.valid? + expect(subject.errors[:file].join).to eq("Malformed import file, please read through the instructions carefully and make sure the file is UTF-8 encoded.") + end + end + end + end + end +end diff --git a/decidim-admin/spec/forms/decidim/admin/participatory_space/member_form_spec.rb b/decidim-admin/spec/forms/decidim/admin/participatory_space/member_form_spec.rb new file mode 100644 index 0000000000000..b273724ea2d9c --- /dev/null +++ b/decidim-admin/spec/forms/decidim/admin/participatory_space/member_form_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Admin + module ParticipatorySpace + describe MemberForm do + subject { described_class.from_params(attributes) } + + let(:email) { "my_email@example.org" } + let(:name) { "John Wayne" } + let(:attributes) do + { + "member" => { + "email" => email, + "name" => name + } + } + end + + context "when everything is OK" do + it { is_expected.to be_valid } + end + + context "when email is missing" do + let(:email) { nil } + + it { is_expected.to be_invalid } + end + end + end + end +end diff --git a/decidim-admin/spec/forms/participatory_space_private_user_csv_import_form_spec.rb b/decidim-admin/spec/forms/participatory_space_private_user_csv_import_form_spec.rb deleted file mode 100644 index 0a99ef331870e..0000000000000 --- a/decidim-admin/spec/forms/participatory_space_private_user_csv_import_form_spec.rb +++ /dev/null @@ -1,61 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -module Decidim - module Admin - describe ParticipatorySpacePrivateUserCsvImportForm do - subject do - described_class.from_params( - attributes - ).with_context( - current_user:, - current_organization: - ) - end - - let(:current_organization) { create(:organization) } - let(:current_user) { create(:user, organization: current_organization) } - - let(:attributes) do - { - "file" => file - } - end - let(:file) { upload_test_file(Decidim::Dev.asset("import_participatory_space_private_users.csv")) } - - context "when everything is OK" do - it { is_expected.to be_valid } - end - - context "when file is missing" do - let(:file) { nil } - - it { is_expected.to be_invalid } - end - - context "when user name contains invalid chars" do - let(:file) { upload_test_file(Decidim::Dev.asset("import_participatory_space_private_users_nok.csv")) } - - it { is_expected.to be_invalid } - end - - context "when the CSV separator is incorrect" do - let(:file) { upload_test_file(Decidim::Dev.asset("import_participatory_space_private_users_invalid_col_sep.csv")) } - - it { is_expected.to be_invalid } - end - - context "when the provided file is encoded with incorrect character set" do - let(:file) { upload_test_file(Decidim::Dev.asset("import_participatory_space_private_users_iso8859-1.csv")) } - - it { is_expected.to be_invalid } - - it "adds the correct error" do - subject.valid? - expect(subject.errors[:file].join).to eq("Malformed import file, please read through the instructions carefully and make sure the file is UTF-8 encoded.") - end - end - end - end -end diff --git a/decidim-admin/spec/forms/participatory_space_private_user_form_spec.rb b/decidim-admin/spec/forms/participatory_space_private_user_form_spec.rb deleted file mode 100644 index 96c3fdd7df1a8..0000000000000 --- a/decidim-admin/spec/forms/participatory_space_private_user_form_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -module Decidim - module Admin - describe ParticipatorySpacePrivateUserForm do - subject { described_class.from_params(attributes) } - - let(:email) { "my_email@example.org" } - let(:name) { "John Wayne" } - let(:attributes) do - { - "participatory_space_private_user" => { - "email" => email, - "name" => name - } - } - end - - context "when everything is OK" do - it { is_expected.to be_valid } - end - - context "when email is missing" do - let(:email) { nil } - - it { is_expected.to be_invalid } - end - end - end -end diff --git a/decidim-admin/spec/jobs/decidim/admin/participatory_space/destroy_members_follows_job_spec.rb b/decidim-admin/spec/jobs/decidim/admin/participatory_space/destroy_members_follows_job_spec.rb new file mode 100644 index 0000000000000..c5de1536aea5e --- /dev/null +++ b/decidim-admin/spec/jobs/decidim/admin/participatory_space/destroy_members_follows_job_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Admin + module ParticipatorySpace + describe DestroyMembersFollowsJob do + let(:organization) { create(:organization) } + let!(:user) { create(:user, :admin, :confirmed, organization:) } + let!(:normal_user) { create(:user, organization:) } + let!(:follow) { create(:follow, followable: participatory_space, user: normal_user) } + let(:component) { create(:dummy_component, participatory_space:) } + let(:resource) { create(:dummy_resource, component:, author: user) } + let!(:followed_resource) { create(:follow, followable: resource, user: normal_user) } + + context "when assembly is private and non transparent" do + let(:participatory_space) { create(:assembly, :private, :published, :opaque, organization: user.organization) } + + it "deletes follows of non members" do + # we have 2 follows, one for assembly, and one for a "child" resource + expect { described_class.perform_now(normal_user.id, participatory_space) }.to change(Decidim::Follow, :count).by(-2) + end + end + + context "when assembly is private but transparent" do + let(:participatory_space) { create(:assembly, :private, :published, organization: user.organization) } + + it "preserves follows of non members" do + # we have 2 follows, one for assembly, and one for a "child" resource + expect { described_class.perform_now(normal_user.id, participatory_space) }.not_to change(Decidim::Follow, :count) + end + end + + context "when assembly is public" do + let(:participatory_space) { create(:assembly, :published, organization: user.organization) } + + it "preserves follows of non members" do + # we have 2 follows, one for assembly, and one for a "child" resource + expect { described_class.perform_now(normal_user.id, participatory_space) }.not_to change(Decidim::Follow, :count) + end + end + + context "when process is private" do + let(:participatory_space) { create(:participatory_process, :private, :published, organization: user.organization) } + + it "deletes follows of non members" do + # we have 2 follows, one for process, and one for a "child" resource + expect { described_class.perform_now(normal_user.id, participatory_space) }.to change(Decidim::Follow, :count).by(-2) + end + end + + context "when process is public" do + let(:participatory_space) { create(:participatory_process, :published, organization: user.organization) } + + it "preserves follows of non members" do + expect { described_class.perform_now(normal_user.id, participatory_space) }.not_to change(Decidim::Follow, :count) + end + end + end + end + end +end diff --git a/decidim-admin/spec/jobs/decidim/admin/participatory_space/import_member_csv_job_spec.rb b/decidim-admin/spec/jobs/decidim/admin/participatory_space/import_member_csv_job_spec.rb new file mode 100644 index 0000000000000..fe8bf9841094e --- /dev/null +++ b/decidim-admin/spec/jobs/decidim/admin/participatory_space/import_member_csv_job_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Admin + module ParticipatorySpace + describe ImportMemberCsvJob do + let!(:email) { "my_user@example.org" } + let!(:user_name) { "My User Name" } + let(:user) { create(:user, :admin, organization:) } + let(:organization) { create(:organization) } + let(:privatable_to) { create(:participatory_process, organization:) } + + context "when the member not exists" do + it "delegates the work to a command" do + expect(Decidim::Admin::ParticipatorySpace::CreateMember).to receive(:call) + described_class.perform_now(email, user_name, privatable_to, user) + end + end + end + end + end +end diff --git a/decidim-admin/spec/jobs/destroy_private_users_follows_job_spec.rb b/decidim-admin/spec/jobs/destroy_private_users_follows_job_spec.rb deleted file mode 100644 index 15ad1b33c7240..0000000000000 --- a/decidim-admin/spec/jobs/destroy_private_users_follows_job_spec.rb +++ /dev/null @@ -1,61 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -module Decidim - module Admin - describe DestroyPrivateUsersFollowsJob do - let(:organization) { create(:organization) } - let!(:user) { create(:user, :admin, :confirmed, organization:) } - let!(:normal_user) { create(:user, organization:) } - let!(:follow) { create(:follow, followable: participatory_space, user: normal_user) } - let(:component) { create(:dummy_component, participatory_space:) } - let(:resource) { create(:dummy_resource, component: component, author: user) } - let!(:followed_resource) { create(:follow, followable: resource, user: normal_user) } - - context "when assembly is private and non transparent" do - let(:participatory_space) { create(:assembly, :private, :published, :opaque, organization: user.organization) } - - it "deletes follows of non private users" do - # we have 2 follows, one for assembly, and one for a "child" resource - expect { described_class.perform_now(normal_user.id, participatory_space) }.to change(Decidim::Follow, :count).by(-2) - end - end - - context "when assembly is private but transparent" do - let(:participatory_space) { create(:assembly, :private, :published, organization: user.organization) } - - it "preserves follows of non private users" do - # we have 2 follows, one for assembly, and one for a "child" resource - expect { described_class.perform_now(normal_user.id, participatory_space) }.not_to change(Decidim::Follow, :count) - end - end - - context "when assembly is public" do - let(:participatory_space) { create(:assembly, :published, organization: user.organization) } - - it "preserves follows of non private users" do - # we have 2 follows, one for assembly, and one for a "child" resource - expect { described_class.perform_now(normal_user.id, participatory_space) }.not_to change(Decidim::Follow, :count) - end - end - - context "when process is private" do - let(:participatory_space) { create(:participatory_process, :private, :published, organization: user.organization) } - - it "deletes follows of non private users" do - # we have 2 follows, one for process, and one for a "child" resource - expect { described_class.perform_now(normal_user.id, participatory_space) }.to change(Decidim::Follow, :count).by(-2) - end - end - - context "when process is public" do - let(:participatory_space) { create(:participatory_process, :published, organization: user.organization) } - - it "preserves follows of non private users" do - expect { described_class.perform_now(normal_user.id, participatory_space) }.not_to change(Decidim::Follow, :count) - end - end - end - end -end diff --git a/decidim-admin/spec/jobs/import_participatory_space_private_user_csv_job_spec.rb b/decidim-admin/spec/jobs/import_participatory_space_private_user_csv_job_spec.rb deleted file mode 100644 index 9b712c1527114..0000000000000 --- a/decidim-admin/spec/jobs/import_participatory_space_private_user_csv_job_spec.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -module Decidim - module Admin - describe ImportParticipatorySpacePrivateUserCsvJob do - let!(:email) { "my_user@example.org" } - let!(:user_name) { "My User Name" } - let(:user) { create(:user, :admin, organization:) } - let(:organization) { create(:organization) } - let(:privatable_to) { create(:participatory_process, organization:) } - - context "when the participatory space private user not exists" do - it "delegates the work to a command" do - expect(Decidim::Admin::CreateParticipatorySpacePrivateUser).to receive(:call) - described_class.perform_now(email, user_name, privatable_to, user) - end - end - end - end -end diff --git a/decidim-admin/spec/queries/newsletter_recipients_spec.rb b/decidim-admin/spec/queries/newsletter_recipients_spec.rb index a52eeba563ecb..ae022ea8a6c8f 100644 --- a/decidim-admin/spec/queries/newsletter_recipients_spec.rb +++ b/decidim-admin/spec/queries/newsletter_recipients_spec.rb @@ -163,7 +163,7 @@ module Decidim::Admin before do recipients.each do |member| - create(:participatory_space_private_user, privatable_to: participatory_process, user: member) + create(:member, privatable_to: participatory_process, user: member) end end diff --git a/decidim-admin/spec/system/admin_invite_spec.rb b/decidim-admin/spec/system/admin_invite_spec.rb index 7f920ad6d8daf..e37b3ead355f9 100644 --- a/decidim-admin/spec/system/admin_invite_spec.rb +++ b/decidim-admin/spec/system/admin_invite_spec.rb @@ -10,6 +10,7 @@ let(:params) do { name: "Gotham City", + short_name: "GothamCity", reference_prefix: "JKR", host: "decide.lvh.me", organization_admin_name: "Fiorello Henry La Guardia", diff --git a/decidim-admin/spec/system/admin_manages_newsletters_spec.rb b/decidim-admin/spec/system/admin_manages_newsletters_spec.rb index 04520b06392c1..1e60d09cec460 100644 --- a/decidim-admin/spec/system/admin_manages_newsletters_spec.rb +++ b/decidim-admin/spec/system/admin_manages_newsletters_spec.rb @@ -472,15 +472,15 @@ def select_verification_type(types) context "when private members are selected" do context "with private members" do let!(:participatory_process) { create(:participatory_process, organization:, skip_injection: true, private_space: true) } - let!(:private_users) do - create_list(:participatory_space_private_user, 30) do |private_user| - private_user.user = create(:user, :confirmed, newsletter_notifications_at: Time.current, organization:) - private_user.privatable_to = participatory_process - private_user.save! + let!(:members) do + create_list(:member, 30) do |member| + member.user = create(:user, :confirmed, newsletter_notifications_at: Time.current, organization:) + member.privatable_to = participatory_process + member.save! end end - let(:recipients_count) { private_users.size } + let(:recipients_count) { members.size } it "sends to private members", :slow do visit decidim_admin.select_recipients_to_deliver_newsletter_path(newsletter) diff --git a/decidim-admin/spec/system/participatory_space_private_user_import_via_csv_spec.rb b/decidim-admin/spec/system/member_import_via_csv_spec.rb similarity index 70% rename from decidim-admin/spec/system/participatory_space_private_user_import_via_csv_spec.rb rename to decidim-admin/spec/system/member_import_via_csv_spec.rb index 7236ff8024a13..9f3cf008cd235 100644 --- a/decidim-admin/spec/system/participatory_space_private_user_import_via_csv_spec.rb +++ b/decidim-admin/spec/system/member_import_via_csv_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" -describe "Admin manages participatory space private users via csv import" do +describe "Admin manages members via csv import" do let(:organization) { create(:organization) } let!(:user) { create(:user, :admin, :confirmed, organization:) } @@ -18,19 +18,19 @@ click_on "Import via CSV" end - it "show the form to add some private users via csv" do + it "show the form to add some members via csv" do expect(page).to have_content("Upload your CSV file") end context "when there are no existing users" do it "does not propose to delete" do - expect(page).to have_content("You have no private participants.") + expect(page).to have_content("You have no members.") end end context "when there are existing users" do before do - create_list(:assembly_private_user, 3, privatable_to: assembly, user: create(:user, organization: assembly.organization)) + create_list(:assembly_member, 3, privatable_to: assembly, user: create(:user, organization: assembly.organization)) visit current_path end @@ -41,11 +41,11 @@ it "ask you for confirmation and delete existing users" do find(".alert").click - expect(page).to have_content("Are you sure you want to delete all private participants?") + expect(page).to have_content("Are you sure you want to delete all members?") click_on("OK") - expect(page).to have_content("You have no private participants") + expect(page).to have_content("You have no members") end end end diff --git a/decidim-admin/spec/system/participatory_space_private_user_spec.rb b/decidim-admin/spec/system/member_spec.rb similarity index 59% rename from decidim-admin/spec/system/participatory_space_private_user_spec.rb rename to decidim-admin/spec/system/member_spec.rb index ac1babe7a3816..3c028e2e56978 100644 --- a/decidim-admin/spec/system/participatory_space_private_user_spec.rb +++ b/decidim-admin/spec/system/member_spec.rb @@ -2,13 +2,13 @@ require "spec_helper" -describe "Admin checks pagination on participatory space private users" do +describe "Admin checks pagination on members" do let(:organization) { create(:organization) } let!(:user) { create(:user, :admin, :confirmed, organization:) } let(:assembly) { create(:assembly, organization:, private_space: true) } - let!(:private_users) { create_list(:assembly_private_user, 26, privatable_to: assembly, user: create(:user, organization: assembly.organization)) } + let!(:members) { create_list(:assembly_member, 26, privatable_to: assembly, user: create(:user, organization: assembly.organization)) } before do switch_to_host(organization.host) @@ -19,8 +19,8 @@ end end - it "shows private users of the participatory space and changes page correctly" do + it "shows members of the participatory space and changes page correctly" do find("li a", text: "Next").click - expect(page).to have_current_path "#{decidim_admin_assemblies.participatory_space_private_users_path(assembly_slug: assembly.slug)}?page=2" + expect(page).to have_current_path "#{decidim_admin_assemblies.members_path(assembly_slug: assembly.slug)}?page=2" end end diff --git a/decidim-api/app/models/decidim/api/api_user.rb b/decidim-api/app/models/decidim/api/api_user.rb index 9f9cda8c8b76e..211ab0bdbf7c4 100644 --- a/decidim-api/app/models/decidim/api/api_user.rb +++ b/decidim-api/app/models/decidim/api/api_user.rb @@ -54,7 +54,7 @@ def confirmed? end def follows?(followable) - Decidim::Follow.where(user: self, followable: followable).any? + Decidim::Follow.where(user: self, followable:).any? end # Public: whether the user accepts direct messages from another diff --git a/decidim-api/config/locales/en.yml b/decidim-api/config/locales/en.yml new file mode 100644 index 0000000000000..f28fd3c1c73a9 --- /dev/null +++ b/decidim-api/config/locales/en.yml @@ -0,0 +1,12 @@ +--- +en: + decidim: + api: + errors: + invalid_locale: Invalid locale provided + locale_argument_error: There was an error while internally handling i18n data + not_found: "%{type} not found" + permission_not_set: Permission has not been set for this %{type} + unauthorized_field: You cannot view or edit %{field} field on %{type} because you do not have permission + unauthorized_mutation: You do not have permission to perform this mutation + unauthorized_object: You cannot view or edit this %{type} because you do not have permissions diff --git a/decidim-api/docs/usage.md b/decidim-api/docs/usage.md index f6ee207d54bbe..2a85a9acdeee5 100644 --- a/decidim-api/docs/usage.md +++ b/decidim-api/docs/usage.md @@ -52,637 +52,4 @@ Response (formatted) should look something like this: } ``` -The most practical way to experiment with GraphQL, however, is just to use the in-browser IDE GraphiQL. It provides access to the documentation and auto-complete (use CTRL-Space) for writing queries. - -From now on, we will skip the "query" keyword for the purpose of readability. You can skip it too if you are using GraphiQL, if you are querying directly (by using CURL for instance) you will need to include it. - -### Signing in to the API - -In case you want to use the API as a sign in user to perform mutations representing a user in Decidim, you have two available options for such integrations through the system administration panel: - -1. Creating an OAuth application and implementing the OAuth authentication flow for the users of your application. Use this option for participant-facing applications where the participants represent themselves in Decidim through the API. -2. Creating API credentials and signing in to the API with these credentials to perform the operations as a signed in machine user. Use this option for machine-to-machine automations where there is no real end user interacting with Decidim. - -If you only want to test the GraphQL queries as a signed in user, you can use the normal Decidim authentication functionality to sign in and then use the GraphiQL IDE to perform these queries as a signed in user. - -#### OAuth flow for participant-facing applications - -Participant-facing applications where the participants need to interact with Decidim through GraphQL mutations can be integrated using OAuth applications. In order to configure such integration capability from the system administration panel, create a new OAuth application and provide the necessary details for your integration. Note that the "application type" for such applications would typically be "Public". For more information regarding the application types, refer to [RFC 6749 Section 2.1. (OAuth client types)](https://datatracker.ietf.org/doc/html/rfc6749#section-2.1). - -In order to use the OAuth access tokens to represent the user through the API, please select the following scopes as "Available" scopes for the application: - -* `user` - Authenticated users have the ability to represent a logged in user in Decidim -* `api:read` - Authenticated users have the ability to read data from the API -* `api:write` - Authenticated users have the ability to write data through the API (in case your external application needs to perform mutations over the API on behalf of the user) - -Once configured, you can now use any OAuth authentication library to perform the OAuth authentication flow with your application users and receive an access token to utilize the Decidim API representing the signed in user. Please note that with public OAuth clients especially (and recommended also for confidential clients), you have to use [PKCE](https://datatracker.ietf.org/doc/html/rfc7636) with the authorization flow. - -Once the OAuth application is created, you can authenticate against it with the following steps: - -1. Send the user to perform an OAuth authorization request at Decidim with the required API scopes (`user`, `api:read` and `api:write` if you want to perform mutations over the API). Along with the authorization request, also send the additional parameters required by PKCE (`code_challenge` and `code_challenge_method`). -2. Receive an OAuth authorization code back to your application's configured redirect URI. -3. Utilizing the received authorization code, request an OAuth access token from the OAuth token endpoint. Along with the token request, also send the additional parameter required by PKCE `code_verifier`. -4. The issued token is a JSON Web Token (JWT) when the authorization request contains the defined scopes. This token can be now used to represent the user in further calls to the API by passing the token with its type (`Bearer`) within the HTTP Authorization header with the request to the API. - -When doing the requests to the API, you also need to pass the OAuth client ID within the `X-Jwt-Aud` header of the requests in order for the token to be recognized as a valid token for the issued client. Passing the bearer token to the `Authorization` header and the OAuth client ID to the `X-Jwt-Aud` header, you can send the following HTTP request to the API to validate that the token works and the user is recognized as signed in: - -```http -POST /api HTTP/1.1 -Accept: application/json -Authorization: Bearer token -Content-Length: 53 -Content-Type: application/json -Host: DOMAIN -X-Jwt-Aud: OAUTH_CLIENT_ID - -{"query":"{ session { user { id name nickname } } }"} -``` - -You should see the user details in the response in case the token is valid and you have configured the API correctly. If the response does not contain the user details, please refer to the Decidim configuration documentation. - -Once the interaction with the API is completed, it is recommended to revoke the tokens, which is similar to the user signing out of the application. This can be done utilizing the OAuth revocation endpoint provided by Decidim. After the token is revoked, it is no longer valid and the user has to perform a re-authorization the next time they want to utilize the API. - -In case you need tokens with a longer life span, you can either look into the Decidim documentation to extend the validity period of the access tokens or enable refresh tokens for the OAuth application when configuring it. However, note that tokens with longer lifespan can weaken the security of your system and make your application users vulnerable to security threats. Such use cases should be carefully planned and the security concerns should be addressed seriously. - -#### API credentials flow for machine-to-machine automations - -The API credentials represent an administrative user in Decidim that performs administrative tasks on behalf of the end users. This type of integration flows should never live on devices that the participants have access to. These types of integrations are meant for different types of automations, such as transferring proposal answers or meeting reports back to Decidim from an external system automatically, e.g. once a day. - -Note that these credentials are highly sensitive and have elevated permissions, so take good care of the system security where you are planning to store these credentials. If these credentials end up in participants' hands, the whole system is compromised and no longer secure. You should always primarily create OAuth integrations where the end users will manually perform the authorization for the application to perform actions on behalf of them. - -Once you have validated that this is the correct way for your integration to operate, you can create the API credentials from the system administration panel. You will receive an API key and API secret after creating the credentials. These credentials should be also manually rotated on a regular basis to prevent unauthorized access to the system with these credentials in case they are leaked. The credentials have to be manually rotated in order to prevent external applications breaking because they cannot rotate the credentials themselves and they are typically statically configured for these applications. - -Given you have issued the API key and API secret, you can now send a sign in request to the API using these credentials as follows: - -```bash -curl -s -i -H "Content-type: application/x-www-form-urlencoded" \ - -d "api_user[key]=PASTE_API_KEY_HERE" \ - -d "api_user[secret]=PASTE_API_SECRET_HERE" \ - -X POST https://DOMAIN/api/sign_in | grep 'Authorization' | cut -d ' ' -f2- -``` - -After running this command, you should see the following string in the console, where `token` is replaced with the access token: - -```bash -Bearer token -``` - -This string is passed to the following requests within the HTTP `Authorization` header to represent the user during API calls. You can use the following example query to test it out and confirm that signing in works as expected: - -```bash -curl -w "\n" -H "Content-Type: application/json" \ - -H "Authorization: Bearer token" \ - -d '{"query":"{ session { user { id name nickname } } }"}' \ - -X POST https://DOMAIN/api -``` - -You should see the user details in the response in case the token is valid and you have configured the API correctly. If the response does not contain the user details, please refer to the Decidim configuration documentation. - -Once the API interaction is done, you should always make an HTTP DELETE request to `/api/sign_out` with the same token in order to revoke the token from further access as follows: - -```bash -curl -s -o /dev/null -w "HTTP %{http_code}\n" \ - -H "Authorization: Bearer token" \ - -X DELETE http://DOMAIN/api/sign_out -``` - -### Usage limits - -Decidim is just a Rails application, meaning that any particular installation may implement custom limits in order to access the API (and the application in general). - -By default (particular installations may change that), API uses the same limitations as the whole Decidim website, provided by the Gem [Rack::Attack](https://github.com/kickstarter/rack-attack). These are 100 maximum requests per minute per IP to prevent DoS attacks - -### Decidim structure, Types, collections and Polymorphism - -There are no endpoints in the GraphQL specification, instead objects are organized according to their "Type". - -These objects can be grouped in a single, complex query. Also, objects may accept parameters, which are "Types" as well. - -Each "Type" is just a pre-defined structure with fields, or just an Scalar (Strings, Integers, Booleans, ...). - -For instance, to obtain *all the participatory processes in a Decidim installation published since January 2018* and order them by published date, we could execute the next query: - -```graphql -{ - participatoryProcesses(filter: {publishedSince: "2018-01-01"}, order: {publishedAt: "asc"}) { - slug - title { - translation(locale: "en") - } - } -} -``` - -Response should look like: - -```json -{ - "data": { - "participatoryProcesses": [ - { - "slug": "consectetur-at", - "title": { - "translation": "Soluta consectetur quos fugit aut." - } - }, - { - "slug": "nostrum-earum", - "title": { - "translation": "Porro hic ipsam cupiditate reiciendis." - } - } - ] - } -} -``` - -#### What happened? - -In the former query, each keyword represents a type, the words `publishedSince`, `publishedAt`, `slug`, `locale` are scalars, all of them Strings. - -The other keywords however, are objects representing certain entities: - -* `participatoryProcesses` is a type that represents a collection of participatory spaces. It accepts arguments (`filter` and `order`), which are other object types as well. `slug` and `title` are the fields of the participatory process we are interested in, there are "Types" too. -* `filter` is a [ParticipatoryProcessFilter](#ParticipatoryProcessFilter)\* input type, it has several properties that allows us to refine our search. One of them is the `publishedSince` property with the initial date from which to list entries. -* `order` is a [ParticipatoryProcessSort](#ParticipatoryProcessSort) type, works the same way as the filter but with the goal of ordering the results. -* `title` is a [TranslatedField](#TranslatedField) type, which allows us to deal with multi-language fields. - -Finally, note that the returned object is an array, each item of which is a representation of the object we requested. - -> \***About how filters and sorting are organized** -> -> There are two types of objects to filter and ordering collections in Decidim, they all work in a similar fashion. The type involved in filtering always have the suffix "Filter", for ordering it has the suffix "Sort". -> -> The types used to filter participatory spaces are: [ParticipatoryProcessFilter](#ParticipatoryProcessFilter), [AssemblyFilter](#AssemblyFilter), and so on. -> -> Other collections (or connections) may have their own filters (i.e. [ComponentFilter](#ComponentFilter)). -> -> Each filter has its own properties, you should check any object in particular for details. The way they work with multi-languages fields, however, is the same: -> -> We can say we have some searchable object with a multi-language field called *title*, and we have a filter that allows us to search through this field. How should it work? Should we look up content for every language in the field? or should we stick to a specific language? -> -> In our case, we have decided to search only one particular language of a multi-language field but we let you choose which language to search. -> If no language is specified, the configured as default in the organization will be used. The keyword to specify the language is `locale`, and it should be provided in the 2 letters ISO 639-1 format (en = English, es = Spanish, ...). -> -> Example (this is not a real Decidim query): -> -> ```graphql -> some_collection(filter: { locale: "en", title: "ideas"}) { -> id -> } -> ``` -> -> The same applies to sorting ([ParticipatoryProcessSort](#ParticipatoryProcessSort), [AssemblySort](#AssemblySort), etc.) -> -> In this case, the content of the field (*title*) only allows 2 values: *ASC* and *DESC*. -> -> Example of ordering alphabetically by the title content in French language: -> -> ```graphql -> some_collection(order: { locale: "en", title: "asc"}) { -> id -> } -> ``` -> -> Of course, you can combine both filter and order. Also remember to check availability of this type of behaviour for any particular filter/sort. - -#### Decidim main types - -Decidim has 2 main types of objects through which content is provided. These are Participatory Spaces and Components. - -A participatory space is the first level, currently there are 5 officially supported: *Participatory Processes*, *Assemblies*, *Conferences* and *Initiatives*. For each participatory process there will correspond a collection type and a "single item" type. - -The previous example uses the collection type for participatory processes. You can try `assemblies`, `conferences`, or `initiatives` for the others. Note that each collection can implement their own filter and order types with different properties. - -As an example for a single item query, you can run: - -```graphql -{ - participatoryProcess(slug: "consectetur-at") { - slug - title { - translation(locale: "en") - } - } -} -``` - -And the response will be: - -```json -{ - "data": { - "participatoryProcess": { - "slug": "consectetur-at", - "title": { - "translation": "Soluta consectetur quos fugit aut." - } - } - } -} -``` - -#### What is different? - -First, note that we are querying, in singular, the type `participatoryProcess`, with a different parameter, `slug`\*, (a String). We can use the `id` instead if we know it. - -Second, the response is not an Array, it is just the object we requested. We can expect to return `null` if the object is not found. - -> \* The `slug` is a convenient way to find a participatory space as is (usually) in the URL. -> -> For instance, consider this real case from Barcelona: -> -> https://www.decidim.barcelona/processes/patrimonigracia -> -> The word `patrimonigracia` indicates the "slug". - -#### Components - -Every participatory space may (and should) have some components. There are 9 official components, these are `Proposals`, `Page`, `Meetings`, `Budgets`, `Surveys`, `Accountability`, `Debates` and `Blog`. Plugins may add their own components. - -If you know the `id`\* of a specific component you can obtain it by querying it directly: - -```graphql -{ - component(id:2) { - id - name { - translation(locale:"en") - } - __typename - participatorySpace { - id - type - } - } -} -``` - -Response: - -```json -{ - "data": { - "component": { - "id": "2", - "name": { - "translation": "Meetings" - }, - "__typename": "Meetings", - "participatorySpace": { - "id": "1", - "type": "Decidim::ParticipatoryProcess" - } - } - } -} -``` - -The process is analogue as what has been explained in the case of searching for one specific participatory process. - -> \*Note that the `id` of a component is present also in the URL after the letter "f": -> -> https://www.decidim.barcelona/processes/patrimonigracia/f/3257/ -> -> In this case, 3257. - -##### What about component's collections? - -Glad you asked, component's collections cannot be retrieved directly, the are available *in the context* of a participatory space. - -For instance, we can query all the components in an particular Assembly as follows: - -```graphql -{ - assembly(id: 3) { - components { - id - name { - translation(locale: "en") - } - __typename - } - } -} -``` - -The response will be similar to: - -```json -{ - "data": { - "assembly": { - "components": [ - { - "id": "42", - "name": { - "translation": "Accountability" - }, - "__typename": "Component" - }, - { - "id": "38", - "name": { - "translation": "Meetings" - }, - "__typename": "Meetings" - }, - { - "id": "37", - "name": { - "translation": "Page" - }, - "__typename": "Pages" - }, - { - "id": "39", - "name": { - "translation": "Proposals" - }, - "__typename": "Proposals" - } - ] - } - } -} -``` - -We can also apply some filters by using the [ComponentFilter](#ComponentFilter) type. In the next query we would like to *find all the components with geolocation enabled in the assembly with id=2*: - -```graphql -{ - assembly(id: 2) { - components(filter: {withGeolocationEnabled: true}) { - id - name { - translation(locale: "en") - } - __typename - } - } -} -``` - -The response: - -```json -{ - "data": { - "assembly": { - "components": [ - { - "id": "39", - "name": { - "translation": "Meetings" - }, - "__typename": "Meetings" - } - ] - } - } -} -``` - -Note that, in this case, there is only one component returned, "Meetings". In some cases Proposals can be geolocated too therefore would be returned in this query. - -### Polymorphism and connections - -Many relationships between tables in Decidim are polymorphic, this means that the related object can belong to different classes and share just a few properties in common. - -For instance, components in a participatory space are polymorphic, while the concept of component is generic and all of them share properties like *published date*, *name* or *weight*, they differ in the rest. *Proposals* have the *status* field while *Meetings* have an *agenda*. - -Another example are the case of linked resources, these are properties that may link objects of different nature between components or participatory spaces. - -In a very simplified way (to know more please refer to the official guide), GraphQL polymorphism is handled through the operator `... on`. You will know when a field is polymorphic because the property `__typename`, which tells you the type of that particular object, will change accordingly. - -In the previous examples we have queried for this property: - -Response fragment: - -```json - "components": [ - { - "id": "38", - "name": { - "translation": "Meetings" - }, - "__typename": "Meetings" - } -``` - -So, if we want to access the rest of the properties in a polymorphic object, we should do it through the `... on` operator as follows: - -```graphql -{ - assembly(id: 2) { - components { - id - ... on Proposals { - - } - } - } -} -``` - -Consider this query: - -```graphql -{ - assembly(id: 3) { - components(filter: {type: "Proposals"}) { - id - name { - translation(locale: "en") - } - ... on Proposals { - proposals(order: {likeCount: "desc"}, first: 2) { - edges { - node { - id - likes { - name - } - } - } - } - } - } - } -} -``` - -The response: - -```json -{ - "data": { - "assembly": { - "components": [ - { - "id": "39", - "name": { - "translation": "Proposals" - }, - "proposals": { - "edges": [ - { - "node": { - "id": "35", - "likes": [ - { - "name": "Ms. Johnathon Schaefer" - }, - { - "name": "Linwood Lakin PhD 3 4 endr1" - }, - { - "name": "Gracie Emmerich" - }, - { - "name": "Randall Rath 3 4 endr3" - }, - { - "name": "Jolene Schmitt MD" - }, - { - "name": "Clarence Hammes IV 3 4 endr5" - }, - { - "name": "Omar Mayer" - }, - { - "name": "Raymundo Jaskolski 3 4 endr7" - } - ] - } - }, - { - "node": { - "id": "33", - "likes": [ - { - "name": "Spring Brakus" - }, - { - "name": "Reiko Simonis IV 3 2 endr1" - }, - { - "name": "Dr. Jim Denesik" - }, - { - "name": "Dr. Mack Schoen 3 2 endr3" - } - ] - } - } - ] - } - } - ] - } - } -} -``` - -#### What is going on? - -Until the `... on Proposals` line, there is nothing new. We are requesting the *Assembly* participatory space identified by the `id=3`, then listing all its components with the type "Proposals". All the components share the *id* and *name* properties, so we can just add them at the query. - -After that, we want content specific from the *Proposals* type. In order to do that we must tell the server that the content we will request shall only be executed if the types matches *Proposals*. We do that by wrapping the rest of the query in the `... on Proposals` clause. - -The next line is just a property of the type *Proposals* which is a type of collection called a "connection". A connection works similar as normal collection (such as *components*) but it can handle more complex cases. - -Typically, a connection is used to paginate long results, for this purpose the results are not directly available but encapsulated inside the list *edges* in several *node* results. Also there are more arguments available in order to navigate between pages. This are the arguments: - -* `first`: Returns the first *n* elements from the list -* `after`: Returns the elements in the list that come after the specified *cursor* -* `last`: Returns the last *n* elements from the list -* `before`: Returns the elements in the list that come before the specified *cursor* - -Example: - -```graphql -{ - assembly(id: 3) { - components(filter: {type: "Proposals"}) { - id - name { - translation(locale: "en") - } - ... on Proposals { - proposals(first:2,after:"Mg") { - pageInfo { - endCursor - startCursor - hasPreviousPage - hasNextPage - } - edges { - node { - id - likes { - name - } - } - } - } - } - } - } -} -``` - -Being the response: - -```json -{ - "data": { - "assembly": { - "components": [ - { - "id": "39", - "name": { - "translation": "Proposals" - }, - "proposals": { - "pageInfo": { - "endCursor": "NA", - "startCursor": "Mw", - "hasPreviousPage": false, - "hasNextPage": true - }, - "edges": [ - { - "node": { - "id": "32", - "likes": [] - } - }, - { - "node": { - "id": "31", - "likes": [ - { - "name": "Mr. Nicolas Raynor" - }, - { - "name": "Gerry Fritsch PhD 3 1 endr1" - } - ] - } - } - ] - } - } - ] - } - } -} -``` - -As you can see, a part from the *edges* list, you can access to the object *pageInfo* which gives you the information needed to navigate through the different pages. - -For more info on how connections work, you can check the official guide: - -https://graphql.org/learn/pagination/ +For additional examples of queries and mutations, check the additional [GraphQL API documentation](https://docs.decidim.org/en/develop/develop/api/index.html) of Decidim. diff --git a/decidim-api/lib/decidim/api/component_mutation_type.rb b/decidim-api/lib/decidim/api/component_mutation_type.rb index b661cd0a1a30a..35bb10b2e7841 100644 --- a/decidim-api/lib/decidim/api/component_mutation_type.rb +++ b/decidim-api/lib/decidim/api/component_mutation_type.rb @@ -11,8 +11,7 @@ def self.resolve_type(obj, _ctx) mod = obj.manifest_name.camelize "Decidim::#{mod}::#{mod}MutationType".constantize rescue NameError - Rails.logger.warn("Mutation type not found for #{mod}: #{e.message}") - nil + raise GraphQL::ExecutionError, "Mutation type not found for #{mod}" end end end diff --git a/decidim-api/lib/decidim/api/errors/attribute_validation_error.rb b/decidim-api/lib/decidim/api/errors/attribute_validation_error.rb new file mode 100644 index 0000000000000..6a754974bb4cc --- /dev/null +++ b/decidim-api/lib/decidim/api/errors/attribute_validation_error.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Decidim + module Api + module Errors + class AttributeValidationError < GraphQL::ExecutionError + def initialize(messages, ast_node: nil, options: nil, extensions: nil) + @ast_node = ast_node + @options = options + @extensions = extensions + + @messages = messages + + message_str = + if messages.is_a?(ActiveModel::Errors) + messages.full_messages.join(", ") + elsif messages.is_a?(Array) + messages.map { |a| a[:message] }.join(", ") + else + messages.to_s + end + super(message_str) + end + + def to_h + hash = {} + if @messages.is_a?(ActiveModel::Errors) + hash["message"] = @messages.map do |error| + # This is the GraphQL argument which corresponds to the validation error: + local_path = ["attributes", error.attribute.to_s.camelize(:lower)] + { + path: local_path, + message: error.message + } + end + end + + hash["message"] = @messages if @messages.is_a?(Array) + + if ast_node + hash["locations"] = [ + { + "line" => ast_node.line, + "column" => ast_node.col + } + ] + end + + hash["path"] = path if path + + hash.merge!(options) if options + + if extensions + hash["extensions"] = extensions.transform_keys do |(key, value), ext| + ext[key.to_s] = value + end + end + + hash.merge!({ "extensions" => { "code" => "ATTRIBUTE_VALIDATION_ERROR" } }) + + hash + end + + def message + return @messages.full_messages.join(", ") if @messages.is_a?(ActiveModel::Errors) + return @messages.map { |a| a[:message] }.join(", ") if @messages.is_a?(Array) + + @messages.to_s + end + end + end + end +end diff --git a/decidim-api/lib/decidim/api/errors/invalid_locale_error.rb b/decidim-api/lib/decidim/api/errors/invalid_locale_error.rb new file mode 100644 index 0000000000000..3f7ed57395b5b --- /dev/null +++ b/decidim-api/lib/decidim/api/errors/invalid_locale_error.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Decidim + module Api + module Errors + # i18n-tasks-use t("decidim.api.errors.invalid_locale") + class InvalidLocaleError < GraphQL::ExecutionError + def to_h + super.merge({ "extensions" => { "code" => "INVALID_LOCALE_ERROR" } }) + end + end + end + end +end diff --git a/decidim-api/lib/decidim/api/errors/locale_error.rb b/decidim-api/lib/decidim/api/errors/locale_error.rb new file mode 100644 index 0000000000000..8302aa2a485b1 --- /dev/null +++ b/decidim-api/lib/decidim/api/errors/locale_error.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Decidim + module Api + module Errors + # i18n-tasks-use t("decidim.api.errors.locale_argument_error") + class LocaleError < GraphQL::ExecutionError + def to_h + super.merge({ "extensions" => { "code" => "LOCALE_ERROR" } }) + end + end + end + end +end diff --git a/decidim-api/lib/decidim/api/errors/mutation_not_authorized_error.rb b/decidim-api/lib/decidim/api/errors/mutation_not_authorized_error.rb new file mode 100644 index 0000000000000..19c513f8a6c30 --- /dev/null +++ b/decidim-api/lib/decidim/api/errors/mutation_not_authorized_error.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Decidim + module Api + module Errors + # i18n-tasks-use t("decidim.api.errors.unauthorized_mutation") + class MutationNotAuthorizedError < GraphQL::ExecutionError + def to_h + super.merge({ "extensions" => { "code" => "MUTATION_NOT_AUTHORIZED_ERROR" } }) + end + end + end + end +end diff --git a/decidim-api/lib/decidim/api/errors/not_found_error.rb b/decidim-api/lib/decidim/api/errors/not_found_error.rb new file mode 100644 index 0000000000000..bca12968a291b --- /dev/null +++ b/decidim-api/lib/decidim/api/errors/not_found_error.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Decidim + module Api + module Errors + # i18n-tasks-use t("decidim.api.errors.not_found") + class NotFoundError < GraphQL::ExecutionError + def to_h + super.merge({ "extensions" => { "code" => "NOT_FOUND_ERROR" } }) + end + end + end + end +end diff --git a/decidim-api/lib/decidim/api/errors/permission_not_set_error.rb b/decidim-api/lib/decidim/api/errors/permission_not_set_error.rb new file mode 100644 index 0000000000000..9242555a8e89f --- /dev/null +++ b/decidim-api/lib/decidim/api/errors/permission_not_set_error.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Decidim + module Api + module Errors + # i18n-tasks-use t("decidim.api.errors.permission_not_set") + class PermissionNotSetError < GraphQL::ExecutionError + def to_h + super.merge({ "extensions" => { "code" => "PERMISSION_NOT_SET_ERROR" } }) + end + end + end + end +end diff --git a/decidim-api/lib/decidim/api/errors/unauthorized_field_error.rb b/decidim-api/lib/decidim/api/errors/unauthorized_field_error.rb new file mode 100644 index 0000000000000..713f5d4bde55c --- /dev/null +++ b/decidim-api/lib/decidim/api/errors/unauthorized_field_error.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Decidim + module Api + module Errors + # i18n-tasks-use t("decidim.api.errors.unauthorized_field") + class UnauthorizedFieldError < GraphQL::ExecutionError + def to_h + super.merge({ "extensions" => { "code" => "UNAUTHORIZED_FIELD_ERROR" } }) + end + end + end + end +end diff --git a/decidim-api/lib/decidim/api/errors/unauthorized_object_error.rb b/decidim-api/lib/decidim/api/errors/unauthorized_object_error.rb new file mode 100644 index 0000000000000..8dff8305a6fc2 --- /dev/null +++ b/decidim-api/lib/decidim/api/errors/unauthorized_object_error.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Decidim + module Api + module Errors + # i18n-tasks-use t("decidim.api.errors.unauthorized_object") + class UnauthorizedObjectError < GraphQL::ExecutionError + def to_h + super.merge({ "extensions" => { "code" => "UNAUTHORIZED_OBJECT_ERROR" } }) + end + end + end + end +end diff --git a/decidim-api/lib/decidim/api/errors/validation_error.rb b/decidim-api/lib/decidim/api/errors/validation_error.rb new file mode 100644 index 0000000000000..ead1bb3239509 --- /dev/null +++ b/decidim-api/lib/decidim/api/errors/validation_error.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Decidim + module Api + module Errors + class ValidationError < GraphQL::ExecutionError + def to_h + super.merge({ "extensions" => { "code" => "VALIDATION_ERROR" } }) + end + end + end + end +end diff --git a/decidim-api/lib/decidim/api/graphql_permissions.rb b/decidim-api/lib/decidim/api/graphql_permissions.rb index 09c2c4bb2598e..a25f5e529b47e 100644 --- a/decidim-api/lib/decidim/api/graphql_permissions.rb +++ b/decidim-api/lib/decidim/api/graphql_permissions.rb @@ -44,12 +44,7 @@ def allowed_to?(action, subject, object, context, scope: :public) permission_action = Decidim::PermissionAction.new(scope:, action:, subject:) permission_chain(object).inject(permission_action) do |current_permission_action, permission_class| - permission_context = - if scope == :admin - local_admin_context(object, context) - else - local_context(object, context) - end + permission_context = local_user_context(object, context) permission_class.new( context[:current_user], @@ -57,6 +52,8 @@ def allowed_to?(action, subject, object, context, scope: :public) permission_context ).permissions end.allowed? + rescue Decidim::PermissionAction::PermissionNotSetError + false end # Injects into context object current_participatory_space and current_component keys as they are needed @@ -77,7 +74,7 @@ def local_context(object, context) context.to_h end - def local_admin_context(object, context) + def local_user_context(object, context) context = local_context(object, context) component = context[:current_component] diff --git a/decidim-api/lib/decidim/api/query_type.rb b/decidim-api/lib/decidim/api/query_type.rb index cbce0ddee80a3..e037fd3f9eb70 100644 --- a/decidim-api/lib/decidim/api/query_type.rb +++ b/decidim-api/lib/decidim/api/query_type.rb @@ -5,6 +5,97 @@ module Api # This type represents the root query type of the whole API. class QueryType < Decidim::Api::Types::BaseObject description "The root query of this schema" + + field :component, Decidim::Core::ComponentInterface, null: true do + description "Lists the components this space contains." + argument :id, GraphQL::Types::ID, required: true, description: "The ID of the component to be found" + end + field :decidim, Core::DecidimType, "Decidim's framework properties.", null: true + field :moderated_users, type: [Decidim::Core::UserModerationType], null: true, + description: "The moderated users for the current organization" + field :moderations, type: [Decidim::Core::ModerationType], null: true, + description: "The moderation for the current organization" + field :organization, Core::OrganizationType, "The current organization", null: true + field :participant_details, type: Decidim::Core::ParticipantDetailsType, null: true do + description "Participant details visible to admin users only" + argument :id, GraphQL::Types::ID, "The ID of the participant", required: true + argument :nickname, GraphQL::Types::String, "The @nickname of the participant", required: false + end + field :session, Core::SessionType, description: "Return's information about the logged in user", null: true + field :static_page_topics, type: [Decidim::Core::StaticPageTopicType], null: true, + description: "The static page topics for the current organization" + field :static_pages, type: [Decidim::Core::StaticPageType], null: true, + description: "The static pages for the current organization" + field :user, + type: Core::UserType, null: true, + description: "A participant (user or group) in the current organization" do + argument :id, GraphQL::Types::ID, "The ID of the participant", required: false + argument :nickname, GraphQL::Types::String, "The @nickname of the participant", required: false + end + field :users, + type: [Core::UserType], null: true, + description: "The participants (users or groups) for the current organization" do + argument :filter, Decidim::Core::UserEntityInputFilter, "Provides several methods to filter the results", required: false + argument :order, Decidim::Core::UserEntityInputSort, "Provides several methods to order the results", required: false + end + + def component(id: {}) + component = Decidim::Component.published.find_by(id:) + component&.organization == context[:current_organization] ? component : nil + end + + def session + context[:current_user] + end + + def decidim + Decidim + end + + def organization + context[:current_organization] + end + + def user(id: nil, nickname: nil) + Core::UserEntityFinder.new.call(object, { id:, nickname: }, context) + end + + def users(filter: {}, order: {}) + Core::UserEntityList.new.call(object, { filter:, order: }, context) + end + + def participant_details(id: nil, nickname: nil) + participant = Decidim::Core::UserEntityFinder.new.call(object, { id:, nickname: }, context) + return nil unless participant + + return nil unless Decidim::Core::ParticipantDetailsType.authorized?(participant, context) + + Decidim::ActionLogger.log( + "read", + context[:current_user], + participant, + nil, + {} + ) + + participant + end + + def static_pages + Decidim::StaticPage.accessible_for(organization, context[:current_user]) + end + + def static_page_topics + static_pages.collect(&:topic).uniq.compact_blank + end + + def moderated_users + Decidim::UserModeration.joins(:user).where(decidim_users: { decidim_organization_id: organization&.id }).where.not(decidim_users: { blocked_at: nil }) + end + + def moderations + Decidim::Moderation.where(participatory_space: organization.participatory_spaces).includes(:reports).hidden + end end end end diff --git a/decidim-api/lib/decidim/api/schema.rb b/decidim-api/lib/decidim/api/schema.rb index 8a0905419bece..16903e19b452b 100644 --- a/decidim-api/lib/decidim/api/schema.rb +++ b/decidim-api/lib/decidim/api/schema.rb @@ -12,6 +12,32 @@ class Schema < GraphQL::Schema max_complexity Decidim::Api.schema_max_complexity orphan_types(Api.orphan_types) + + def self.unauthorized_object(error) + # Add a top-level error to the response instead of returning nil: + raise Decidim::Api::Errors::UnauthorizedObjectError, I18n.t("decidim.api.errors.unauthorized_object", type: error.type.graphql_name) + end + + def self.unauthorized_field(error) + # Add a top-level error to the response instead of returning nil: + raise Decidim::Api::Errors::UnauthorizedFieldError, I18n.t("decidim.api.errors.unauthorized_field", type: error.type.graphql_name, field: error.field.graphql_name) + end + + rescue_from(ActiveRecord::RecordNotFound) do |_err, _obj, _args, _ctx, field| + raise Decidim::Api::Errors::NotFoundError, I18n.t("decidim.api.errors.not_found", type: field.type.unwrap.graphql_name) + end + + rescue_from(Decidim::PermissionAction::PermissionNotSetError) do |_err, _obj, _args, _ctx, field| + raise Decidim::Api::Errors::PermissionNotSetError, I18n.t("decidim.api.errors.permission_not_set", type: field.type.unwrap.graphql_name) + end + + rescue_from(I18n::InvalidLocale) do |_err, _obj, _args, _ctx, _field| + raise Decidim::Api::Errors::InvalidLocaleError, I18n.t("decidim.api.errors.invalid_locale") + end + + rescue_from(I18n::ArgumentError) do |err, _obj, _args, _ctx, _field| + raise Decidim::Api::Errors::LocaleError, I18n.t("decidim.api.errors.locale_argument_error", message: err.message) + end end end end diff --git a/decidim-api/lib/decidim/api/test/component_context.rb b/decidim-api/lib/decidim/api/test/component_context.rb index dd1ce779078b6..5a9c4f3362947 100644 --- a/decidim-api/lib/decidim/api/test/component_context.rb +++ b/decidim-api/lib/decidim/api/test/component_context.rb @@ -42,25 +42,33 @@ end end -shared_examples "with resource visibility" do - let(:process_space_factory) { :participatory_process } +shared_examples "graphQL not found space" do let(:space_type) { "participatoryProcess" } - shared_examples "graphQL visible resource" do - it "is visible" do - expect(response[space_type]["components"].first[lookout_key]).to eq(query_result) - end + it "should not be visible" do + expect { response }.to raise_error(Decidim::Api::Errors::NotFoundError, "#{space_type.classify} not found") end +end - shared_examples "graphQL hidden space" do - it "should not be visible" do - expect(response[space_type]).to be_nil +shared_examples "with resource visibility" do + let(:process_space_factory) { :participatory_process } + let(:space_type) { "participatoryProcess" } + + shared_examples "graphQL visible resource" do |visible: true| + if visible + it "should be visible" do + expect(response[space_type]["components"].first[lookout_key]).to eq(query_result) + end + else + it "should not be visible" do + expect(response[space_type]["components"]).to be_empty + end end end shared_examples "graphQL hidden component" do it "should not be visible" do - expect(response[space_type]["components"].first).to be_nil + expect(response[space_type]["components"]).to be_empty end end @@ -75,7 +83,7 @@ shared_examples "graphQL space hidden to visitor" do context "when user is visitor" do let!(:current_user) { nil } - it_behaves_like "graphQL hidden space" + it_behaves_like "graphQL not found space" end end @@ -118,13 +126,13 @@ context "when user is member" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } - let!(:participatory_space_private_user) { create(:participatory_space_private_user, user: current_user, privatable_to: participatory_process) } + let!(:member) { create(:member, user: current_user, privatable_to: participatory_process) } it_behaves_like "graphQL visible resource" end context "when user is member" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } - let!(:participatory_space_private_user) { create(:participatory_space_private_user, user: current_user, privatable_to: participatory_process) } + let!(:member) { create(:member, user: current_user, privatable_to: participatory_process) } it_behaves_like "graphQL visible resource" end @@ -142,7 +150,7 @@ context "when the user is space admin" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } let!(:role) { create(:participatory_process_user_role, participatory_process:, user: current_user, role: "admin") } - it_behaves_like "graphQL visible resource" + it_behaves_like "graphQL visible resource", visible: false end context "when the user is space collaborator" do @@ -160,7 +168,7 @@ context "when the user is space evaluator" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } let!(:role) { create(:participatory_process_user_role, participatory_process:, user: current_user, role: "evaluator") } - it_behaves_like "graphQL visible resource" + it_behaves_like "graphQL visible resource", visible: false end context "when user is visitor" do @@ -176,7 +184,7 @@ context "when user is member" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } - let!(:participatory_space_private_user) { create(:participatory_space_private_user, user: current_user, privatable_to: participatory_process) } + let!(:member) { create(:member, user: current_user, privatable_to: participatory_process) } it_behaves_like "graphQL hidden component" end end @@ -240,7 +248,7 @@ context "when user is member" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } - let!(:participatory_space_private_user) { create(:assembly_private_user, user: current_user, privatable_to: participatory_process) } + let!(:member) { create(:assembly_member, user: current_user, privatable_to: participatory_process) } it_behaves_like "graphQL visible resource" end @@ -258,13 +266,13 @@ context "when the user is space admin" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } let!(:role) { create(:assembly_user_role, assembly: participatory_process, user: current_user, role: "admin") } - it_behaves_like "graphQL visible resource" + it_behaves_like "graphQL visible resource", visible: false end context "when the user is space collaborator" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } let!(:role) { create(:assembly_user_role, assembly: participatory_process, user: current_user, role: "collaborator") } - it_behaves_like "graphQL visible resource" + it_behaves_like "graphQL visible resource", visible: false end context "when the user is space moderator" do @@ -276,7 +284,7 @@ context "when the user is space evaluator" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } let!(:role) { create(:assembly_user_role, assembly: participatory_process, user: current_user, role: "evaluator") } - it_behaves_like "graphQL visible resource" + it_behaves_like "graphQL visible resource", visible: false end context "when user is visitor" do @@ -291,7 +299,7 @@ context "when user is member" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } - let!(:participatory_space_private_user) { create(:assembly_private_user, user: current_user, privatable_to: participatory_process) } + let!(:member) { create(:assembly_member, user: current_user, privatable_to: participatory_process) } it_behaves_like "graphQL hidden component" end end @@ -308,38 +316,38 @@ context "when the user is space admin" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } let!(:role) { create(:participatory_process_user_role, participatory_process:, user: current_user, role: "admin") } - it_behaves_like "graphQL hidden space" + it_behaves_like "graphQL not found space" end context "when the user is space collaborator" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } let!(:role) { create(:participatory_process_user_role, participatory_process:, user: current_user, role: "collaborator") } - it_behaves_like "graphQL hidden space" + it_behaves_like "graphQL not found space" end context "when the user is space moderator" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } let!(:role) { create(:participatory_process_user_role, participatory_process:, user: current_user, role: "moderator") } - it_behaves_like "graphQL hidden space" + it_behaves_like "graphQL not found space" end context "when the user is space evaluator" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } let!(:role) { create(:participatory_process_user_role, participatory_process:, user: current_user, role: "evaluator") } - it_behaves_like "graphQL hidden space" + it_behaves_like "graphQL not found space" end it_behaves_like "graphQL space hidden to visitor" context "when user is normal user" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } - it_behaves_like "graphQL hidden space" + it_behaves_like "graphQL not found space" end context "when user is member" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } - let!(:participatory_space_private_user) { create(:participatory_space_private_user, user: current_user, privatable_to: participatory_process) } + let!(:member) { create(:member, user: current_user, privatable_to: participatory_process) } it_behaves_like "graphQL visible resource" end end @@ -352,36 +360,36 @@ context "when the user is space admin" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } let!(:role) { create(:participatory_process_user_role, participatory_process:, user: current_user, role: "admin") } - it_behaves_like "graphQL hidden space" + it_behaves_like "graphQL not found space" end context "when the user is space collaborator" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } let!(:role) { create(:participatory_process_user_role, participatory_process:, user: current_user, role: "collaborator") } - it_behaves_like "graphQL hidden space" + it_behaves_like "graphQL not found space" end context "when the user is space moderator" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } let!(:role) { create(:participatory_process_user_role, participatory_process:, user: current_user, role: "moderator") } - it_behaves_like "graphQL hidden space" + it_behaves_like "graphQL not found space" end context "when the user is space evaluator" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } let!(:role) { create(:participatory_process_user_role, participatory_process:, user: current_user, role: "evaluator") } - it_behaves_like "graphQL hidden space" + it_behaves_like "graphQL not found space" end it_behaves_like "graphQL space hidden to visitor" context "when user is member" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } - let!(:participatory_space_private_user) { create(:participatory_space_private_user, user: current_user, privatable_to: participatory_process) } + let!(:member) { create(:member, user: current_user, privatable_to: participatory_process) } it_behaves_like "graphQL hidden component" end context "when user is normal user" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } - it_behaves_like "graphQL hidden space" + it_behaves_like "graphQL not found space" end end end @@ -397,38 +405,38 @@ context "when the user is space admin" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } let!(:role) { create(:participatory_process_user_role, participatory_process:, user: current_user, role: "admin") } - it_behaves_like "graphQL hidden space" + it_behaves_like "graphQL not found space" end context "when the user is space collaborator" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } let!(:role) { create(:participatory_process_user_role, participatory_process:, user: current_user, role: "collaborator") } - it_behaves_like "graphQL hidden space" + it_behaves_like "graphQL not found space" end context "when the user is space moderator" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } let!(:role) { create(:participatory_process_user_role, participatory_process:, user: current_user, role: "moderator") } - it_behaves_like "graphQL hidden space" + it_behaves_like "graphQL not found space" end context "when the user is space evaluator" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } let!(:role) { create(:participatory_process_user_role, participatory_process:, user: current_user, role: "evaluator") } - it_behaves_like "graphQL hidden space" + it_behaves_like "graphQL not found space" end it_behaves_like "graphQL space hidden to visitor" context "when user is member" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } - let!(:participatory_space_private_user) { create(:participatory_space_private_user, user: current_user, privatable_to: participatory_process) } - it_behaves_like "graphQL hidden space" + let!(:member) { create(:member, user: current_user, privatable_to: participatory_process) } + it_behaves_like "graphQL not found space" end context "when user is normal user" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } - it_behaves_like "graphQL hidden space" + it_behaves_like "graphQL not found space" end end @@ -440,38 +448,38 @@ context "when the user is space admin" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } let!(:role) { create(:participatory_process_user_role, participatory_process:, user: current_user, role: "admin") } - it_behaves_like "graphQL hidden space" + it_behaves_like "graphQL not found space" end context "when the user is space collaborator" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } let!(:role) { create(:participatory_process_user_role, participatory_process:, user: current_user, role: "collaborator") } - it_behaves_like "graphQL hidden space" + it_behaves_like "graphQL not found space" end context "when the user is space moderator" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } let!(:role) { create(:participatory_process_user_role, participatory_process:, user: current_user, role: "moderator") } - it_behaves_like "graphQL hidden space" + it_behaves_like "graphQL not found space" end context "when the user is space evaluator" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } let!(:role) { create(:participatory_process_user_role, participatory_process:, user: current_user, role: "evaluator") } - it_behaves_like "graphQL hidden space" + it_behaves_like "graphQL not found space" end it_behaves_like "graphQL space hidden to visitor" context "when user is member" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } - let!(:participatory_space_private_user) { create(:participatory_space_private_user, user: current_user, privatable_to: participatory_process) } - it_behaves_like "graphQL hidden space" + let!(:member) { create(:member, user: current_user, privatable_to: participatory_process) } + it_behaves_like "graphQL not found space" end context "when user is normal user" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } - it_behaves_like "graphQL hidden space" + it_behaves_like "graphQL not found space" end end end diff --git a/decidim-api/lib/decidim/api/test/type_context.rb b/decidim-api/lib/decidim/api/test/type_context.rb index dd1b52c7ddf7d..0e45659141250 100644 --- a/decidim-api/lib/decidim/api/test/type_context.rb +++ b/decidim-api/lib/decidim/api/test/type_context.rb @@ -28,6 +28,26 @@ execute_query query, variables.stringify_keys end + def raise_proper_error(error) + code = error.dig("extensions", "code") + + # Matches the error code with the Error class + # For instance, if the error code is NOT_FOUND_ERROR then it will raise the "Decidim::Api::Errors::NotFoundError" class + raise "Decidim::Api::Errors::#{code.downcase.classify}".constantize, error["message"] if %w( + LOCALE_ERROR + NOT_FOUND_ERROR + INVALID_LOCALE_ERROR + PERMISSION_NOT_SET_ERROR + ATTRIBUTE_VALIDATION_ERROR + UNAUTHORIZED_FIELD_ERROR + UNAUTHORIZED_OBJECT_ERROR + MUTATION_NOT_AUTHORIZED_ERROR + VALIDATION_ERROR + ).include?(code) + + raise GraphQL::ExecutionError, error["message"] + end + def execute_query(query, variables) result = schema.execute( query, @@ -41,7 +61,7 @@ def execute_query(query, variables) variables: ) - raise StandardError, result["errors"].map { |e| e["message"] }.join(", ") if result["errors"] + raise_proper_error(result["errors"].first) if result["errors"] result["data"] end diff --git a/decidim-api/lib/decidim/api/types.rb b/decidim-api/lib/decidim/api/types.rb index d179b78ccf7bc..0899324b07f38 100644 --- a/decidim-api/lib/decidim/api/types.rb +++ b/decidim-api/lib/decidim/api/types.rb @@ -9,6 +9,18 @@ module Api autoload :GraphqlPermissions, "decidim/api/graphql_permissions" autoload :ComponentMutationType, "decidim/api/component_mutation_type" + module Errors + autoload :LocaleError, "decidim/api/errors/locale_error" + autoload :InvalidLocaleError, "decidim/api/errors/invalid_locale_error" + autoload :AttributeValidationError, "decidim/api/errors/attribute_validation_error" + autoload :MutationNotAuthorizedError, "decidim/api/errors/mutation_not_authorized_error" + autoload :NotFoundError, "decidim/api/errors/not_found_error" + autoload :PermissionNotSetError, "decidim/api/errors/permission_not_set_error" + autoload :UnauthorizedFieldError, "decidim/api/errors/unauthorized_field_error" + autoload :UnauthorizedObjectError, "decidim/api/errors/unauthorized_object_error" + autoload :ValidationError, "decidim/api/errors/validation_error" + end + module Types autoload :BaseArgument, "decidim/api/types/base_argument" autoload :BaseEnum, "decidim/api/types/base_enum" diff --git a/decidim-api/lib/decidim/api/types/base_mutation.rb b/decidim-api/lib/decidim/api/types/base_mutation.rb index 2a0d2a44588b3..a6970dc53dc04 100644 --- a/decidim-api/lib/decidim/api/types/base_mutation.rb +++ b/decidim-api/lib/decidim/api/types/base_mutation.rb @@ -5,12 +5,40 @@ module Api module Types class BaseMutation < GraphQL::Schema::RelayClassicMutation include Decidim::Api::GraphqlPermissions + include Decidim::FormFactory object_class BaseObject field_class Types::BaseField input_object_class BaseInputObject required_scopes "api:read", "api:write" + + def set_locale(locale:, toggle_translations:) + raise I18n::InvalidLocale, "#{locale} is not a valid locale" unless available_locales.include?(locale) + + I18n.locale = locale.presence + RequestStore.store[:toggle_machine_translations] = toggle_translations + end + + def current_user + context[:current_user] + end + + def current_component + context[:current_component] + end + + def current_organization + context[:current_organization] + end + + def available_locales + if current_organization.present? + current_organization.available_locales + else + I18n.available_locales.map(&:to_s) + end + end end end end diff --git a/decidim-api/spec/controllers/decidim/api/sessions_controller_spec.rb b/decidim-api/spec/controllers/decidim/api/sessions_controller_spec.rb index 3487fa3dbd5a4..67c555669644c 100644 --- a/decidim-api/spec/controllers/decidim/api/sessions_controller_spec.rb +++ b/decidim-api/spec/controllers/decidim/api/sessions_controller_spec.rb @@ -9,7 +9,7 @@ let(:organization) { create(:organization) } let(:api_key) { "user_key" } let(:api_secret) { "decidim123456789" } - let!(:user) { create(:api_user, organization: organization, api_key: api_key, api_secret: api_secret) } + let!(:user) { create(:api_user, organization:, api_key:, api_secret:) } let(:params) do { api_user: { @@ -36,7 +36,7 @@ describe "sign in" do it "returns JWT token when credentials are valid" do expect(request.env[Warden::JWTAuth::Hooks::PREPARED_TOKEN_ENV_KEY]).not_to be_present - post :create, params: params + post(:create, params:) expect(response).to have_http_status(:ok) token = request.env[Warden::JWTAuth::Hooks::PREPARED_TOKEN_ENV_KEY] expect(token).to be_present @@ -53,7 +53,7 @@ it "renders resource without JWT token in body when `Tokendispatcher::ENV_KEY` is nil" do request.env[Warden::JWTAuth::Middleware::TokenDispatcher::ENV_KEY] = nil - post :create, params: params + post(:create, params:) expect(request.env[Warden::JWTAuth::Hooks::PREPARED_TOKEN_ENV_KEY]).to be_present parsed_response_body = JSON.parse(response.body) expect(parsed_response_body.has_key?("jwt_token")).to be(false) diff --git a/decidim-api/spec/lib/decidim/api/errors/attribute_validation_error_spec.rb b/decidim-api/spec/lib/decidim/api/errors/attribute_validation_error_spec.rb new file mode 100644 index 0000000000000..0396c7f37b279 --- /dev/null +++ b/decidim-api/spec/lib/decidim/api/errors/attribute_validation_error_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require "spec_helper" +require "active_model" + +module Decidim + module Api + module Errors + describe AttributeValidationError do + subject { described_class.new(messages) } + let(:messages) { [] } + + context "when initialized with an Array of hashes" do + let(:messages) do + [ + { + path: %w(attributes body), + message: "is too short (under 15 characters)" + }, + { + path: %w(attributes title), + message: "is too long" + } + ] + end + + describe "#to_h" do + it { expect(subject.to_h).to include("extensions" => { "code" => "ATTRIBUTE_VALIDATION_ERROR" }) } + it { expect(subject.to_h).to include("message" => messages) } + end + + describe "#message" do + it { expect(subject.message).to eq("is too short (under 15 characters), is too long") } + end + end + + context "when initialized with ActiveModel::Errors" do + let(:dummy_model_class) do + Class.new do + include ActiveModel::Model + + attr_accessor :body, :title + + validates :body, presence: true + validates :title, presence: true + + def self.name + "DummyModel" + end + end + end + let(:messages) do + model = dummy_model_class.new + model.errors.add(:body, :too_short, count: 15) + model.errors.add(:title, :too_long, count: 1) + model.errors + end + + describe "#to_h" do + it { expect(subject.to_h).to include("extensions" => { "code" => "ATTRIBUTE_VALIDATION_ERROR" }) } + + it { + expect(subject.to_h).to include("message" => [ + { + path: %w(attributes body), + message: "is too short (under 15 characters)" + }, + { + path: %w(attributes title), + message: "is too long (maximum is 1 character)" + } + ]) + } + end + + describe "#message" do + it { expect(subject.message).to include("is too short (under 15 characters)") } + it { expect(subject.message).to include("is too long") } + end + end + end + end + end +end diff --git a/decidim-core/spec/lib/query_extensions_spec.rb b/decidim-api/spec/lib/decidim/api/query_type_spec.rb similarity index 99% rename from decidim-core/spec/lib/query_extensions_spec.rb rename to decidim-api/spec/lib/decidim/api/query_type_spec.rb index e271e2ed17746..de9e300ba03eb 100644 --- a/decidim-core/spec/lib/query_extensions_spec.rb +++ b/decidim-api/spec/lib/decidim/api/query_type_spec.rb @@ -4,8 +4,8 @@ require "decidim/api/test" module Decidim - module Core - describe Decidim::Api::QueryType do + module Api + describe QueryType do include_context "with a graphql class type" describe "component" do diff --git a/decidim-api/spec/requests/apiauth_spec.rb b/decidim-api/spec/requests/apiauth_spec.rb index a742445a9bb0b..98a47d99ffd13 100644 --- a/decidim-api/spec/requests/apiauth_spec.rb +++ b/decidim-api/spec/requests/apiauth_spec.rb @@ -15,12 +15,12 @@ context "with api user" do let(:key) { "dummykey123456" } let(:secret) { "decidim123456789" } - let!(:user) { create(:api_user, organization: organization, api_key: key, api_secret: secret) } + let!(:user) { create(:api_user, organization:, api_key: key, api_secret: secret) } let(:params) do { api_user: { - key: key, - secret: secret + key:, + secret: } } end @@ -35,7 +35,7 @@ end it "signs in" do - post sign_in_path, params: params + post(sign_in_path, params:) expect(response.headers["Authorization"]).to be_present expect(response.body["jwt_token"]).to be_present parsed_response_body = JSON.parse(response.body) @@ -51,7 +51,7 @@ end it "signs out" do - post sign_in_path, params: params + post(sign_in_path, params:) expect(response).to have_http_status(:ok) authorization = response.headers["Authorization"] original_count = Decidim::Api::JwtDenylist.count @@ -61,7 +61,7 @@ context "when signed in" do before do - post sign_in_path, params: params + post sign_in_path, params: end it "can use token to post to api" do @@ -78,7 +78,7 @@ context "when not signed in" do it "does not return session details" do - post "/api", params: { query: query } + post "/api", params: { query: } parsed_response = JSON.parse(response.body) expect(parsed_response).to match("data" => { "session" => nil }) end @@ -87,7 +87,7 @@ context "with normal user" do let(:password) { "decidim123456789" } - let!(:user) { create(:user, :confirmed, organization: organization, password:) } + let!(:user) { create(:user, :confirmed, organization:, password:) } let(:params) do { user: { @@ -98,7 +98,7 @@ end it "does not authenticate user" do - post sign_in_path, params: params + post(sign_in_path, params:) parsed_response = JSON.parse(response.body) anonymized_key = parsed_response["api_key"] diff --git a/decidim-assemblies/app/controllers/decidim/assemblies/admin/members_controller.rb b/decidim-assemblies/app/controllers/decidim/assemblies/admin/members_controller.rb new file mode 100644 index 0000000000000..1b6429e3acda6 --- /dev/null +++ b/decidim-assemblies/app/controllers/decidim/assemblies/admin/members_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Decidim + module Assemblies + module Admin + # Controller that allows managing assembly members + # on assemblies + class MembersController < Decidim::Assemblies::Admin::ApplicationController + include Concerns::AssemblyAdmin + include Decidim::Admin::ParticipatorySpace::Concerns::HasMembers + + def after_destroy_path + members_path(current_assembly) + end + + def privatable_to + current_assembly + end + end + end + end +end diff --git a/decidim-assemblies/app/controllers/decidim/assemblies/admin/members_csv_imports_controller.rb b/decidim-assemblies/app/controllers/decidim/assemblies/admin/members_csv_imports_controller.rb new file mode 100644 index 0000000000000..e1a35451304c8 --- /dev/null +++ b/decidim-assemblies/app/controllers/decidim/assemblies/admin/members_csv_imports_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Decidim + module Assemblies + module Admin + # Controller that allows importing assembly members + # on assemblies + class MembersCsvImportsController < Decidim::Admin::ApplicationController + include Concerns::AssemblyAdmin + include Decidim::Admin::ParticipatorySpace::Concerns::HasMembersCsvImport + + def after_import_path + members_path(current_assembly) + end + + def privatable_to + current_assembly + end + end + end + end +end diff --git a/decidim-assemblies/app/controllers/decidim/assemblies/admin/participatory_space_private_users_controller.rb b/decidim-assemblies/app/controllers/decidim/assemblies/admin/participatory_space_private_users_controller.rb deleted file mode 100644 index c852029aa5fc1..0000000000000 --- a/decidim-assemblies/app/controllers/decidim/assemblies/admin/participatory_space_private_users_controller.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Decidim - module Assemblies - module Admin - # Controller that allows managing assembly private users - # on assemblies - class ParticipatorySpacePrivateUsersController < Decidim::Assemblies::Admin::ApplicationController - include Concerns::AssemblyAdmin - include Decidim::Admin::Concerns::HasPrivateUsers - - def after_destroy_path - participatory_space_private_users_path(current_assembly) - end - - def privatable_to - current_assembly - end - end - end - end -end diff --git a/decidim-assemblies/app/controllers/decidim/assemblies/admin/participatory_space_private_users_csv_imports_controller.rb b/decidim-assemblies/app/controllers/decidim/assemblies/admin/participatory_space_private_users_csv_imports_controller.rb deleted file mode 100644 index 7e2dbc07455fb..0000000000000 --- a/decidim-assemblies/app/controllers/decidim/assemblies/admin/participatory_space_private_users_csv_imports_controller.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Decidim - module Assemblies - module Admin - # Controller that allows importing assembly private users - # on assemblies - class ParticipatorySpacePrivateUsersCsvImportsController < Decidim::Admin::ApplicationController - include Concerns::AssemblyAdmin - include Decidim::Admin::Concerns::HasPrivateUsersCsvImport - - def after_import_path - participatory_space_private_users_path(current_assembly) - end - - def privatable_to - current_assembly - end - end - end - end -end diff --git a/decidim-assemblies/app/controllers/decidim/assemblies/participatory_space_private_users_controller.rb b/decidim-assemblies/app/controllers/decidim/assemblies/members_controller.rb similarity index 85% rename from decidim-assemblies/app/controllers/decidim/assemblies/participatory_space_private_users_controller.rb rename to decidim-assemblies/app/controllers/decidim/assemblies/members_controller.rb index 1420c746d03b9..4c9211e9929bd 100644 --- a/decidim-assemblies/app/controllers/decidim/assemblies/participatory_space_private_users_controller.rb +++ b/decidim-assemblies/app/controllers/decidim/assemblies/members_controller.rb @@ -2,9 +2,9 @@ module Decidim module Assemblies - class ParticipatorySpacePrivateUsersController < Decidim::Assemblies::ApplicationController + class MembersController < Decidim::Assemblies::ApplicationController include ParticipatorySpaceContext - include Decidim::HasMembersPage + include Decidim::ParticipatorySpace::HasMembersPage def index raise ActionController::RoutingError, "No members for this assembly" if members.none? diff --git a/decidim-assemblies/app/helpers/decidim/assemblies/assemblies_helper.rb b/decidim-assemblies/app/helpers/decidim/assemblies/assemblies_helper.rb index 7cee36698de2d..643a3ae029a36 100644 --- a/decidim-assemblies/app/helpers/decidim/assemblies/assemblies_helper.rb +++ b/decidim-assemblies/app/helpers/decidim/assemblies/assemblies_helper.rb @@ -20,8 +20,8 @@ def assembly_nav_items(participatory_space) *(if participatory_space.members_public_page? [{ name: t("assembly_member_menu_item", scope: "layouts.decidim.assembly_navigation"), - url: decidim_assemblies.assembly_participatory_space_private_users_path(participatory_space, locale: current_locale), - active: is_active_link?(decidim_assemblies.assembly_participatory_space_private_users_path(participatory_space, locale: current_locale), :inclusive) + url: decidim_assemblies.assembly_members_path(participatory_space, locale: current_locale), + active: is_active_link?(decidim_assemblies.assembly_members_path(participatory_space, locale: current_locale), :inclusive) }] end ) diff --git a/decidim-assemblies/app/models/decidim/assembly.rb b/decidim-assemblies/app/models/decidim/assembly.rb index 43f794a1a7b2e..208cc845bae6b 100644 --- a/decidim-assemblies/app/models/decidim/assembly.rb +++ b/decidim-assemblies/app/models/decidim/assembly.rb @@ -31,7 +31,7 @@ class Assembly < ApplicationRecord include Decidim::Traceable include Decidim::Loggable include Decidim::ParticipatorySpaceResourceable - include Decidim::HasPrivateUsers + include Decidim::ParticipatorySpace::HasMembers include Decidim::Searchable include Decidim::HasUploadValidations include Decidim::TranslatableResource @@ -92,7 +92,7 @@ class Assembly < ApplicationRecord index_on_create: ->(_assembly) { false }, index_on_update: ->(assembly) { assembly.visible? }) - # Overwriting existing method Decidim::HasPrivateUsers.public_spaces + # Overwriting existing method Decidim::ParticipatorySpace::HasMembers.public_spaces def self.public_spaces where(private_space: false).or(where(private_space: true).where(is_transparent: true)).published end @@ -169,7 +169,7 @@ def self.ransackable_attributes(auth_object = nil) return base unless auth_object&.admin? - base + %w(published_at private_space parent_id) + base + %w(published_at created_at private_space parent_id) end def self.ransackable_associations(_auth_object = nil) diff --git a/decidim-assemblies/app/packs/src/decidim/assemblies/controllers/assembly_admin/assembly_admin.test.js b/decidim-assemblies/app/packs/src/decidim/assemblies/controllers/assembly_admin/assembly_admin.test.js index 12e5ed74c9a7a..0629e11cc3264 100644 --- a/decidim-assemblies/app/packs/src/decidim/assemblies/controllers/assembly_admin/assembly_admin.test.js +++ b/decidim-assemblies/app/packs/src/decidim/assemblies/controllers/assembly_admin/assembly_admin.test.js @@ -748,7 +748,7 @@ describe("AssemblyAdminController", () => { -

You will be able to manage private participants after setting it as private

+

You will be able to manage members after setting it as private

<%= render "decidim/shared/resource_actions", resource: post do %> - <%= render "decidim/blogs/posts/menu_actions", post: post %> + <%= render "decidim/blogs/posts/menu_actions", post: %> <% end %>
diff --git a/decidim-blogs/config/locales/cs.yml b/decidim-blogs/config/locales/cs.yml index 8125c6a3e8400..4320b8cef0208 100644 --- a/decidim-blogs/config/locales/cs.yml +++ b/decidim-blogs/config/locales/cs.yml @@ -111,6 +111,7 @@ cs: destroy: Smazat like: Líbí se mi update: Aktualizovat + vote_comment: Komentář hlasování name: Blog settings: global: diff --git a/decidim-blogs/config/locales/ja.yml b/decidim-blogs/config/locales/ja.yml index c794a68e776e7..c2741bde08503 100644 --- a/decidim-blogs/config/locales/ja.yml +++ b/decidim-blogs/config/locales/ja.yml @@ -105,6 +105,7 @@ ja: destroy: 削除 like: いいね update: 更新 + vote_comment: コメントに投票 name: ブログ settings: global: diff --git a/decidim-blogs/lib/decidim/api/post_type.rb b/decidim-blogs/lib/decidim/api/post_type.rb index 5e83e70cff8bd..894eb6c470f77 100644 --- a/decidim-blogs/lib/decidim/api/post_type.rb +++ b/decidim-blogs/lib/decidim/api/post_type.rb @@ -33,8 +33,6 @@ def self.authorized?(object, context) ].all? super && chain - rescue Decidim::PermissionAction::PermissionNotSetError - false end end end diff --git a/decidim-blogs/spec/system/blogs_breadcrumbs_spec.rb b/decidim-blogs/spec/system/blogs_breadcrumbs_spec.rb new file mode 100644 index 0000000000000..d3d4dd0b6bf99 --- /dev/null +++ b/decidim-blogs/spec/system/blogs_breadcrumbs_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Blogs Breadcrumb" do + include_context "with a component" + let(:manifest_name) { "blogs" } + let!(:post) { create(:post, component:) } + + before do + visit_component + end + + describe "index" do + it "shows the correct information in breadcrumb (space, component)" do + within(".menu-bar") do + expect(page).to have_content(translated(component.participatory_space.title)) + expect(page).to have_content(translated(component.name)) + end + end + end + + describe "show" do + it "shows the correct information in breadcrumb (space, component, post)" do + click_on translated(post.title) + + within(".menu-bar") do + expect(page).to have_content(translated(component.participatory_space.title)) + expect(page).to have_content(translated(component.name)) + expect(page).to have_content(translated(post.title)) + end + end + end +end diff --git a/decidim-blogs/spec/system/explore_posts_spec.rb b/decidim-blogs/spec/system/explore_posts_spec.rb index 9cba82f5d0380..babc8cdd00eb2 100644 --- a/decidim-blogs/spec/system/explore_posts_spec.rb +++ b/decidim-blogs/spec/system/explore_posts_spec.rb @@ -37,10 +37,13 @@ visit_component end - it "shows the component name in the sidebar" do + it "shows the correct information in breadcrumb" do within(".menu-bar") do expect(page).to have_content(translated(component.name)) end + end + + it "shows the component name in the sidebar" do within("aside") do expect(page).to have_content(translated(component.name)) end @@ -89,10 +92,6 @@ within ".author__name" do expect(page).to have_content("Official") end - within(".menu-bar") do - expect(page).to have_content(translated(component.name)) - expect(page).to have_content(translated(post.title)) - end end end diff --git a/decidim-blogs/spec/types/blogs_type_spec.rb b/decidim-blogs/spec/types/blogs_type_spec.rb index 5a9ae0829a6e9..3c0ae899a9e9f 100644 --- a/decidim-blogs/spec/types/blogs_type_spec.rb +++ b/decidim-blogs/spec/types/blogs_type_spec.rb @@ -39,8 +39,8 @@ module Blogs context "when the post does not belong to the component" do let!(:post) { create(:post, component: create(:post_component)) } - it "returns null" do - expect(response["post"]).to be_nil + it "raises error" do + expect { response }.to raise_error(Decidim::Api::Errors::NotFoundError, "Post not found") end end end diff --git a/decidim-blogs/spec/types/post_type_spec.rb b/decidim-blogs/spec/types/post_type_spec.rb index 294b106257278..26c47eeb54cc9 100644 --- a/decidim-blogs/spec/types/post_type_spec.rb +++ b/decidim-blogs/spec/types/post_type_spec.rb @@ -17,6 +17,12 @@ module Blogs include_examples "likeable interface" include_examples "followable interface" + shared_examples "unauthorized Post" do + it "throws Decidim::Api::Errors::UnauthorizedObjectError" do + expect { response }.to raise_error(Decidim::Api::Errors::UnauthorizedObjectError, "You cannot view or edit this Post because you do not have permissions") + end + end + describe "id" do let(:query) { "{ id }" } @@ -55,9 +61,7 @@ module Blogs let(:model) { create(:post, component: current_component) } let(:query) { "{ id }" } - it "returns nothing" do - expect(response).to be_nil - end + it_behaves_like "unauthorized Post" end context "when participatory space is private but transparent" do @@ -77,9 +81,7 @@ module Blogs let(:model) { create(:post, component: current_component) } let(:query) { "{ id }" } - it "returns nothing" do - expect(response).to be_nil - end + it_behaves_like "unauthorized Post" end context "when component is not published" do @@ -87,9 +89,7 @@ module Blogs let(:model) { create(:post, component: current_component) } let(:query) { "{ id }" } - it "returns nothing" do - expect(response).to be_nil - end + it_behaves_like "unauthorized Post" end context "when post is moderated" do @@ -97,9 +97,7 @@ module Blogs let(:query) { "{ id }" } let(:root_value) { model.reload } - it "returns all the required fields" do - expect(response).to be_nil - end + it_behaves_like "unauthorized Post" end context "when post is not published" do @@ -107,9 +105,7 @@ module Blogs let(:model) { create(:post, published_at: nil, component: current_component) } let(:query) { "{ id }" } - it "returns nothing" do - expect(response).to be_nil - end + it_behaves_like "unauthorized Post" end end end diff --git a/decidim-budgets/app/controllers/decidim/budgets/projects_controller.rb b/decidim-budgets/app/controllers/decidim/budgets/projects_controller.rb index 9f0508765e23c..7d25b6cf20497 100644 --- a/decidim-budgets/app/controllers/decidim/budgets/projects_controller.rb +++ b/decidim-budgets/app/controllers/decidim/budgets/projects_controller.rb @@ -100,6 +100,28 @@ def items } ].select { |item| item[:enabled] } end + + def add_breadcrumb_item + return {} if project.blank? + + { + label: translated_attribute(project.title), + url: Decidim::EngineRouter.main_proxy(current_component).budget_project_url(budget, project, locale: current_locale), + active: false, + resource: project + } + end + + def add_parent_breadcrumb_item + return {} if budget.blank? + + { + label: translated_attribute(budget.title), + url: Decidim::EngineRouter.main_proxy(current_component).budget_projects_url(budget, locale: current_locale), + active: false, + resource: budget + } + end end end end diff --git a/decidim-budgets/app/views/decidim/budgets/projects/_budget_summary.html.erb b/decidim-budgets/app/views/decidim/budgets/projects/_budget_summary.html.erb index fe9e4acd4f924..4d8fe7883cad7 100644 --- a/decidim-budgets/app/views/decidim/budgets/projects/_budget_summary.html.erb +++ b/decidim-budgets/app/views/decidim/budgets/projects/_budget_summary.html.erb @@ -1,6 +1,6 @@
" class="budget-summary <%= responsive ? "block md:hidden" : "hidden md:block" %>" data-progress-reference data-safe-url="<%= budget_url(budget) %>"> <% if responsive %> - <%= render partial: "decidim/budgets/projects/order_progress_summary/content_responsive", locals: { focus_mode_origin: focus_mode_origin } %> + <%= render partial: "decidim/budgets/projects/order_progress_summary/content_responsive", locals: { focus_mode_origin: } %> <% else %> <%= render partial: "decidim/budgets/projects/order_progress_summary/content" %> <% end %> diff --git a/decidim-budgets/config/locales/cs.yml b/decidim-budgets/config/locales/cs.yml index 49d004a5beaef..c0b116066b67f 100644 --- a/decidim-budgets/config/locales/cs.yml +++ b/decidim-budgets/config/locales/cs.yml @@ -303,6 +303,8 @@ cs: dynamic_help: minimum_reached: Dosáhli jste minima, abyste mohli hlasovat minimum: Minimum + minimum_projects_rule: + description: "Vyberte alespoň %{minimum_number} projektů, které chcete a hlasujete podle vašich preferencí." orders: highest_cost: Nejvyšší náklady label: Seřadit projekty podle diff --git a/decidim-budgets/config/locales/de.yml b/decidim-budgets/config/locales/de.yml index f8073ef1aab84..ea5a29aa6b7f5 100644 --- a/decidim-budgets/config/locales/de.yml +++ b/decidim-budgets/config/locales/de.yml @@ -307,8 +307,8 @@ de: remove: Projekt %{resource_name} aus Ihrer Abstimmung entfernen. selected: Ausgewählt votes: - one: Abstimmung - other: Abstimmungen + one: Stimme + other: Stimmen you_voted: Sie haben dafür gestimmt project_budget_button: add: Zur Abstimmung hinzufügen diff --git a/decidim-budgets/config/locales/it.yml b/decidim-budgets/config/locales/it.yml index 4f1c3bd162f0a..010a436baa194 100644 --- a/decidim-budgets/config/locales/it.yml +++ b/decidim-budgets/config/locales/it.yml @@ -100,6 +100,8 @@ it: order: status: title: OK, il tuo voto è stato acquisito. + order_pdf: + title: Ok, il tuo voto è stato acquisito. order_summary_mailer: order_summary: selected_projects: 'I progetti che hai selezionato sono:' diff --git a/decidim-budgets/config/locales/ja.yml b/decidim-budgets/config/locales/ja.yml index 3ad5109ac8a3e..f4c87059a3e9c 100644 --- a/decidim-budgets/config/locales/ja.yml +++ b/decidim-budgets/config/locales/ja.yml @@ -328,6 +328,7 @@ ja: actions: comment: コメント vote: 投票 + vote_comment: コメントに投票 name: 予算 settings: global: diff --git a/decidim-budgets/lib/decidim/api/budget_type.rb b/decidim-budgets/lib/decidim/api/budget_type.rb index 0e08730102dcd..1f3de24edeb81 100644 --- a/decidim-budgets/lib/decidim/api/budget_type.rb +++ b/decidim-budgets/lib/decidim/api/budget_type.rb @@ -23,8 +23,6 @@ def url def self.authorized?(object, context) super && object.visible? - rescue Decidim::PermissionAction::PermissionNotSetError - false end end end diff --git a/decidim-budgets/lib/decidim/api/budgets_type.rb b/decidim-budgets/lib/decidim/api/budgets_type.rb index f2e4ff44503dc..f54ed84eaf134 100644 --- a/decidim-budgets/lib/decidim/api/budgets_type.rb +++ b/decidim-budgets/lib/decidim/api/budgets_type.rb @@ -15,8 +15,8 @@ def budgets Budget.where(component: object).includes(:component) end - def budget(**args) - Budget.where(component: object).find_by(id: args[:id]) + def budget(id:) + Decidim::Core::ComponentFinderBase.new(model_class: Budget).call(object, { id: }, context) end end end diff --git a/decidim-budgets/lib/decidim/api/project_type.rb b/decidim-budgets/lib/decidim/api/project_type.rb index f26390c3a70e3..d293ed9371458 100644 --- a/decidim-budgets/lib/decidim/api/project_type.rb +++ b/decidim-budgets/lib/decidim/api/project_type.rb @@ -54,8 +54,6 @@ def self.authorized?(object, context) ].all? super && chain - rescue Decidim::PermissionAction::PermissionNotSetError - false end end end diff --git a/decidim-budgets/spec/system/budgets_breadcrumbs_spec.rb b/decidim-budgets/spec/system/budgets_breadcrumbs_spec.rb new file mode 100644 index 0000000000000..a491d85ed8048 --- /dev/null +++ b/decidim-budgets/spec/system/budgets_breadcrumbs_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Budgets Breadcrumb" do + let(:organization) { create(:organization) } + let(:participatory_space) { create(:participatory_process, :with_steps, :published, organization:, title: { "en" => "Participatory space" }) } + let(:component) { create(:budgets_component, :published, :with_votes_disabled, participatory_space:, name: { "en" => "Component" }) } + let(:budget) { create(:budget, component:, title: { "en" => "Budget" }) } + let!(:project) { create(:project, budget:, title: { "en" => "Project" }) } + let(:router) { Decidim::EngineRouter.main_proxy(component) } + + before do + switch_to_host(organization.host) + end + + context "when visiting the budgets index page" do + it "shows the correct information in breadcrumb (space, component)" do + visit router.root_path(locale: I18n.locale) + + within ".menu-bar" do + expect(page).to have_content(translated(component.participatory_space.title)) + expect(page).to have_content(translated(component.name)) + end + end + end + + context "when visiting single budget page" do + it "shows the correct information in breadcrumb (space, component, budget)" do + visit router.budget_path(budget, locale: I18n.locale) + + within ".menu-bar" do + expect(page).to have_content(translated(component.participatory_space.title)) + expect(page).to have_content(translated(component.name)) + expect(page).to have_content(translated(budget.title)) + end + end + end + + context "when visiting single project page" do + it "shows the correct information in breadcrumb (space, component, budget, project)" do + visit router.budget_project_path(budget, project, locale: I18n.locale) + + within ".menu-bar" do + expect(page).to have_content(translated(component.participatory_space.title)) + expect(page).to have_content(translated(component.name)) + expect(page).to have_content(translated(budget.title)) + expect(page).to have_content(translated(project.title)) + end + end + end +end diff --git a/decidim-budgets/spec/system/explore_projects_spec.rb b/decidim-budgets/spec/system/explore_projects_spec.rb index 7fa16548097fb..044ecd1564a71 100644 --- a/decidim-budgets/spec/system/explore_projects_spec.rb +++ b/decidim-budgets/spec/system/explore_projects_spec.rb @@ -110,7 +110,7 @@ [-142.15275006889419, 33.33377235135252], [-55.28745034772282, -35.587843900166945] ] - Decidim::Budgets::Project.where(budget: budget).geocoded.each_with_index do |project, index| + Decidim::Budgets::Project.where(budget:).geocoded.each_with_index do |project, index| project.update!(latitude: coordinates[index][0], longitude: coordinates[index][1]) if coordinates[index] end diff --git a/decidim-budgets/spec/types/budget_type_spec.rb b/decidim-budgets/spec/types/budget_type_spec.rb index 40b169938a459..006f869243dfc 100644 --- a/decidim-budgets/spec/types/budget_type_spec.rb +++ b/decidim-budgets/spec/types/budget_type_spec.rb @@ -10,7 +10,7 @@ module Budgets let(:model) { create(:budget, :with_projects) } it_behaves_like "traceable interface" do - let(:author) { create(:user, :admin, organization: model.component.organization) } + let(:author) { create(:user, :admin, :confirmed, organization: model.component.organization) } end describe "id" do diff --git a/decidim-budgets/spec/types/budgets_type_spec.rb b/decidim-budgets/spec/types/budgets_type_spec.rb index 46b62664470c9..1aba5e98f8454 100644 --- a/decidim-budgets/spec/types/budgets_type_spec.rb +++ b/decidim-budgets/spec/types/budgets_type_spec.rb @@ -40,8 +40,8 @@ module Budgets context "when the budget does not belong to the component" do let!(:budget) { create(:budget, component: create(:budgets_component)) } - it "returns null" do - expect(response["budget"]).to be_nil + it "raises error" do + expect { response }.to raise_error(Decidim::Api::Errors::NotFoundError, "Budget not found") end end end diff --git a/decidim-budgets/spec/types/integration_schema_spec.rb b/decidim-budgets/spec/types/integration_schema_spec.rb index d164389d7087d..29cb3dceae115 100644 --- a/decidim-budgets/spec/types/integration_schema_spec.rb +++ b/decidim-budgets/spec/types/integration_schema_spec.rb @@ -123,6 +123,12 @@ } end + shared_examples "unauthorized Budget" do + it "throws Decidim::Api::Errors::UnauthorizedObjectError" do + expect { response }.to raise_error(Decidim::Api::Errors::UnauthorizedObjectError, "You cannot view or edit this Budget because you do not have permissions") + end + end + describe "commentable" do let(:component_fragment) { nil } @@ -275,8 +281,33 @@ context "when user is visitor" do let!(:current_user) { nil } + let(:component_fragment) do + %( + fragment fooComponent on Budgets { + budget(id: #{budget.id}) { + createdAt + description { + translation(locale:"#{locale}") + } + id + title { + translation(locale:"#{locale}") + } + total_budget + updatedAt + url + versions { + id + } + versionsCount + weight + } + } + ) + end + it "should be visible" do - expect(response["participatoryProcess"]["components"].first[lookout_key]).to eq(query_result.merge("projects" => [nil, nil])) + expect(response["participatoryProcess"]["components"].first[lookout_key]).to eq(query_result.except("projects")) end end @@ -295,16 +326,14 @@ context "when the user is admin" do let!(:current_user) { create(:user, :admin, :confirmed, organization: current_organization) } - it "should not be visible" do - expect(response["participatoryProcess"]["components"].first[lookout_key]).to be_nil - end + it_behaves_like "unauthorized Budget" end context "when user is visitor" do let!(:current_user) { nil } it "should not be visible" do - expect(response["participatoryProcess"]["components"].first).to be_nil + expect(response["participatoryProcess"]["components"]).to be_empty end end @@ -312,7 +341,7 @@ let!(:current_user) { create(:user, :confirmed, organization: current_organization) } it "should not be visible" do - expect(response["participatoryProcess"]["components"].first).to be_nil + expect(response["participatoryProcess"]["components"]).to be_empty end end end @@ -327,25 +356,19 @@ context "when the user is admin" do let!(:current_user) { create(:user, :admin, :confirmed, organization: current_organization) } - it "should not be visible" do - expect(response["participatoryProcess"]["components"].first[lookout_key]).to be_nil - end + it_behaves_like "unauthorized Budget" end context "when user is visitor" do let!(:current_user) { nil } - it "should not be visible" do - expect(response["participatoryProcess"]).to be_nil - end + it_behaves_like "graphQL not found space" end context "when user is normal user" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } - it "should not be visible" do - expect(response["participatoryProcess"]).to be_nil - end + it_behaves_like "graphQL not found space" end end @@ -355,25 +378,19 @@ context "when the user is admin" do let!(:current_user) { create(:user, :admin, :confirmed, organization: current_organization) } - it "should not be visible" do - expect(response["participatoryProcess"]["components"].first[lookout_key]).to be_nil - end + it_behaves_like "unauthorized Budget" end context "when user is visitor" do let!(:current_user) { nil } - it "should not be visible" do - expect(response["participatoryProcess"]).to be_nil - end + it_behaves_like "graphQL not found space" end context "when user is normal user" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } - it "should not be visible" do - expect(response["participatoryProcess"]).to be_nil - end + it_behaves_like "graphQL not found space" end end end @@ -421,18 +438,62 @@ end end end - context "when user is visitor" do let!(:current_user) { nil } + let(:component_fragment) do + %( + fragment fooComponent on Budgets { + budget(id: #{budget.id}) { + createdAt + description { + translation(locale:"#{locale}") + } + id + title { + translation(locale:"#{locale}") + } + total_budget + updatedAt + url + versions { + id + } + versionsCount + weight + } + } + ) + end + it "is visible" do - expect(response["assembly"]["components"].first[lookout_key]).to eq(query_result.merge("projects" => [nil, nil])) + expect(response["assembly"]["components"].first[lookout_key]).to eq(query_result.except("projects")) + end + + context "and requests projects that is not supposed to see" do + let!(:current_user) { nil } + + let(:component_fragment) do + %( + fragment fooComponent on Budgets { + budget(id: #{budget.id}) { + id + projects { + id + } + } + }) + end + + it "throws Decidim::Api::Errors::UnauthorizedObjectError" do + expect { response }.to raise_error(Decidim::Api::Errors::UnauthorizedObjectError, "You cannot view or edit this Project because you do not have permissions") + end end end context "when user is member" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } - let!(:participatory_space_private_user) { create(:assembly_private_user, user: current_user, privatable_to: participatory_process) } + let!(:member) { create(:assembly_member, user: current_user, privatable_to: participatory_process) } it "is visible" do expect(response["assembly"]["components"].first[lookout_key]).to eq(query_result) @@ -454,9 +515,7 @@ context "when the user is admin" do let!(:current_user) { create(:user, :admin, :confirmed, organization: current_organization) } - it "is visible" do - expect(response["assembly"]["components"].first[lookout_key]).to be_nil - end + it_behaves_like "unauthorized Budget" end %w(admin collaborator evaluator).each do |role| @@ -465,16 +524,17 @@ let!(:role) { create(:assembly_user_role, assembly: participatory_process, user: current_user, role:) } it "is visible" do - expect(response["assembly"]["components"].first[lookout_key]).to be_nil + expect(response["assembly"]["components"]).to be_empty end end end + context "when the user is space moderator" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } let!(:role) { create(:assembly_user_role, assembly: participatory_process, user: current_user, role: "moderator") } it "is visible" do - expect(response["assembly"]["components"].first).to be_nil + expect(response["assembly"]["components"]).to be_empty end end @@ -482,15 +542,15 @@ let!(:current_user) { nil } it "should not be visible" do - expect(response["assembly"]["components"].first).to be_nil + expect(response["assembly"]["components"]).to be_empty end context "when user is member" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } - let!(:participatory_space_private_user) { create(:assembly_private_user, user: current_user, privatable_to: participatory_process) } + let!(:member) { create(:assembly_member, user: current_user, privatable_to: participatory_process) } it "should not be visible" do - expect(response["assembly"]["components"].first).to be_nil + expect(response["assembly"]["components"]).to be_empty end end end @@ -499,7 +559,7 @@ let!(:current_user) { create(:user, :confirmed, organization: current_organization) } it "should not be visible" do - expect(response["assembly"]["components"].first).to be_nil + expect(response["assembly"]["components"]).to be_empty end end end @@ -514,25 +574,19 @@ context "when the user is admin" do let!(:current_user) { create(:user, :admin, :confirmed, organization: current_organization) } - it "should not be visible" do - expect(response["participatoryProcess"]["components"].first[lookout_key]).to be_nil - end + it_behaves_like "unauthorized Budget" end context "when user is visitor" do let!(:current_user) { nil } - it "should not be visible" do - expect(response["participatoryProcess"]).to be_nil - end + it_behaves_like "graphQL not found space" end context "when user is normal user" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } - it "should not be visible" do - expect(response["participatoryProcess"]).to be_nil - end + it_behaves_like "graphQL not found space" end end @@ -542,25 +596,19 @@ context "when the user is admin" do let!(:current_user) { create(:user, :admin, :confirmed, organization: current_organization) } - it "should not be visible" do - expect(response["participatoryProcess"]["components"].first[lookout_key]).to be_nil - end + it_behaves_like "unauthorized Budget" end context "when user is visitor" do let!(:current_user) { nil } - it "should not be visible" do - expect(response["participatoryProcess"]).to be_nil - end + it_behaves_like "graphQL not found space" end context "when user is normal user" do let!(:current_user) { create(:user, :confirmed, organization: current_organization) } - it "should not be visible" do - expect(response["participatoryProcess"]).to be_nil - end + it_behaves_like "graphQL not found space" end end end diff --git a/decidim-budgets/spec/types/project_type_spec.rb b/decidim-budgets/spec/types/project_type_spec.rb index 94448418103db..cd19a363da937 100644 --- a/decidim-budgets/spec/types/project_type_spec.rb +++ b/decidim-budgets/spec/types/project_type_spec.rb @@ -21,6 +21,12 @@ module Budgets include_examples "referable interface" include_examples "followable interface" + shared_examples "unauthorized Project" do + it "throws Decidim::Api::Errors::UnauthorizedObjectError" do + expect { response }.to raise_error(Decidim::Api::Errors::UnauthorizedObjectError, "You cannot view or edit this Project because you do not have permissions") + end + end + describe "id" do let(:query) { "{ id }" } @@ -155,9 +161,7 @@ module Budgets let(:model) { create(:project, budget:) } let(:query) { "{ id }" } - it "returns nothing" do - expect(response).to be_nil - end + it_behaves_like "unauthorized Project" end context "when participatory space is not published" do @@ -167,9 +171,7 @@ module Budgets let(:model) { create(:project, budget:) } let(:query) { "{ id }" } - it "returns nothing" do - expect(response).to be_nil - end + it_behaves_like "unauthorized Project" end context "when component is not published" do @@ -177,9 +179,7 @@ module Budgets let(:model) { create(:project, component:) } let(:query) { "{ id }" } - it "returns nothing" do - expect(response).to be_nil - end + it_behaves_like "unauthorized Project" end context "when budget is not visible" do @@ -189,10 +189,11 @@ module Budgets let(:query) { "{ id }" } let(:root_value) { model.reload } - it "returns all the required fields" do + before do allow(model).to receive(:visible?).and_return(false) - expect(response).to be_nil end + + it_behaves_like "unauthorized Project" end end end diff --git a/decidim-collaborative_texts/lib/decidim/collaborative_texts/seeds.rb b/decidim-collaborative_texts/lib/decidim/collaborative_texts/seeds.rb index e0866a2fc4512..be0737377fcd2 100644 --- a/decidim-collaborative_texts/lib/decidim/collaborative_texts/seeds.rb +++ b/decidim-collaborative_texts/lib/decidim/collaborative_texts/seeds.rb @@ -29,7 +29,7 @@ def create_component! name: Decidim::Components::Namer.new(participatory_space.organization.available_locales, :collaborative_texts).i18n_name, manifest_name: :collaborative_texts, published_at: Time.current, - participatory_space: participatory_space + participatory_space: } Decidim.traceability.perform_action!( diff --git a/decidim-collaborative_texts/spec/commands/decidim/collaborative_texts/admin/update_document_spec.rb b/decidim-collaborative_texts/spec/commands/decidim/collaborative_texts/admin/update_document_spec.rb index 3cea1fb8dc5bc..fea4327a4f361 100644 --- a/decidim-collaborative_texts/spec/commands/decidim/collaborative_texts/admin/update_document_spec.rb +++ b/decidim-collaborative_texts/spec/commands/decidim/collaborative_texts/admin/update_document_spec.rb @@ -22,7 +22,7 @@ module Admin title:, body:, draft?: draft, - draft: draft, + draft:, accepting_suggestions:, coauthorships: [Decidim::Coauthorship.new(author: organization)] ) @@ -68,11 +68,11 @@ module Admin .with(last_version, user, updated_keys, { extra: { document_id: document.id, - title: title, + title:, version_number: 1 }, resource: { - title: title + title: }, participatory_space: { title: document.participatory_space.title @@ -116,11 +116,11 @@ module Admin .with(Decidim::CollaborativeTexts::Version, user, { document:, body: document.body, draft: true }, { extra: { document_id: document.id, - title: title, + title:, version_number: 2 }, resource: { - title: title + title: }, participatory_space: { title: document.participatory_space.title diff --git a/decidim-collaborative_texts/spec/controllers/decidim/collaborative_texts/admin/documents_controller_spec.rb b/decidim-collaborative_texts/spec/controllers/decidim/collaborative_texts/admin/documents_controller_spec.rb index b454c80814c34..483d81c2785eb 100644 --- a/decidim-collaborative_texts/spec/controllers/decidim/collaborative_texts/admin/documents_controller_spec.rb +++ b/decidim-collaborative_texts/spec/controllers/decidim/collaborative_texts/admin/documents_controller_spec.rb @@ -15,8 +15,8 @@ module Admin let(:document_versions) { [build(:collaborative_text_version)] } let(:params) do { - title: title, - body: body + title:, + body: } end let(:title) { "A nice test document" } @@ -69,7 +69,7 @@ module Admin describe "POST #create" do it "creates a new document and redirects to index" do expect do - post :create, params: params + post :create, params: end.to change(Document, :count).by(1) expect(response).to redirect_to(documents_path) expect(flash[:notice]).to eq("Document successfully created.") @@ -80,7 +80,7 @@ module Admin it "does not create a document" do expect do - post :create, params: params + post :create, params: end.not_to change(Document, :count) expect(response).to render_template(:new) expect(flash.now[:alert]).to eq("There was a problem creating the document.") @@ -98,7 +98,7 @@ module Admin describe "PATCH #update" do it "updates the document and redirects to index" do - patch :update, params: { id: collaborative_text_document.id, title: "Updated Title", body: body } + patch :update, params: { id: collaborative_text_document.id, title: "Updated Title", body: } expect(response).to redirect_to(documents_path) end end @@ -121,7 +121,7 @@ module Admin let(:title) { "" } it "does not update the document settings" do - patch :update_settings, params: { id: collaborative_text_document.id, title: "", body: body } + patch :update_settings, params: { id: collaborative_text_document.id, title: "", body: } expect(response).to render_template(:edit_settings) expect(flash.now[:alert]).to eq("There was a problem updating the document.") end diff --git a/decidim-collaborative_texts/spec/controllers/decidim/collaborative_texts/suggestions_controller_spec.rb b/decidim-collaborative_texts/spec/controllers/decidim/collaborative_texts/suggestions_controller_spec.rb index 730dc04bb30b7..bf1a9103e38e5 100644 --- a/decidim-collaborative_texts/spec/controllers/decidim/collaborative_texts/suggestions_controller_spec.rb +++ b/decidim-collaborative_texts/spec/controllers/decidim/collaborative_texts/suggestions_controller_spec.rb @@ -30,7 +30,7 @@ module CollaborativeTexts describe "GET #index" do it "returns a success response" do - get :index, params: params + get(:index, params:) expect(response).to have_http_status(:ok) body = JSON.parse(response.body) expect(body.first.keys).to contain_exactly("changeset", "createdAt", "id", "profileHtml", "status", "summary", "type") @@ -60,7 +60,7 @@ module CollaborativeTexts let(:first_node) { "1" } it "returns an error when user is not signed in" do - post :create, params: params + post(:create, params:) expect(response).to have_http_status(:unprocessable_entity) body = JSON.parse(response.body) expect(body["message"]).to eq("You are not authorized to perform this action.") @@ -73,7 +73,7 @@ module CollaborativeTexts it "creates a new suggestion" do expect do - post :create, params: params + post :create, params: end.to change(Suggestion, :count).by(1) expect(response).to have_http_status(:ok) body = JSON.parse(response.body) @@ -84,7 +84,7 @@ module CollaborativeTexts let(:first_node) { "" } it "returns an error" do - post :create, params: params + post(:create, params:) expect(response).to have_http_status(:unprocessable_entity) body = JSON.parse(response.body) expect(body["message"]).to eq("There was a problem creating the suggestion. Invalid selected nodes.") diff --git a/decidim-collaborative_texts/spec/models/decidim/collaborative_texts/document_spec.rb b/decidim-collaborative_texts/spec/models/decidim/collaborative_texts/document_spec.rb index 1ff9a5332099a..9ea7444532d8a 100644 --- a/decidim-collaborative_texts/spec/models/decidim/collaborative_texts/document_spec.rb +++ b/decidim-collaborative_texts/spec/models/decidim/collaborative_texts/document_spec.rb @@ -33,7 +33,7 @@ module CollaborativeTexts it "current version points to last created" do document.save! - version = create(:collaborative_text_version, created_at: 1.second.from_now, document: document) + version = create(:collaborative_text_version, created_at: 1.second.from_now, document:) expect(document.reload.document_versions.count).to eq(4) expect(document.document_versions_count).to eq(4) expect(document.current_version).to eq(version) diff --git a/decidim-collaborative_texts/spec/permissions/decidim/collaborative_texts/permissions_spec.rb b/decidim-collaborative_texts/spec/permissions/decidim/collaborative_texts/permissions_spec.rb index 22cc9b757c886..cd3c2e6dd3f74 100644 --- a/decidim-collaborative_texts/spec/permissions/decidim/collaborative_texts/permissions_spec.rb +++ b/decidim-collaborative_texts/spec/permissions/decidim/collaborative_texts/permissions_spec.rb @@ -9,7 +9,7 @@ let(:context) do { current_component: collaborative_text_component, - document: document + document: } end let(:collaborative_text_component) { create(:collaborative_text_component) } diff --git a/decidim-collaborative_texts/spec/presenters/decidim/collaborative_texts/admin_log/document_presenter_spec.rb b/decidim-collaborative_texts/spec/presenters/decidim/collaborative_texts/admin_log/document_presenter_spec.rb index 5078e72d81458..b970af4e0892a 100644 --- a/decidim-collaborative_texts/spec/presenters/decidim/collaborative_texts/admin_log/document_presenter_spec.rb +++ b/decidim-collaborative_texts/spec/presenters/decidim/collaborative_texts/admin_log/document_presenter_spec.rb @@ -6,7 +6,7 @@ module Decidim module CollaborativeTexts module AdminLog describe DocumentPresenter do - let(:action_log) { double("ActionLog", action: action, resource: resource, extra: extra, version: version) } + let(:action_log) { double("ActionLog", action:, resource:, extra:, version:) } let(:resource) { double("Document", document_versions: [document]) } let(:document) { create(:collaborative_text_document, body: "This is and example body") } let(:extra) { { "extra" => { "version_number" => "2", "body" => document.body } } } diff --git a/decidim-collaborative_texts/spec/system/collaborative_tests_breadcrumbs_spec.rb b/decidim-collaborative_texts/spec/system/collaborative_tests_breadcrumbs_spec.rb new file mode 100644 index 0000000000000..23e02bf5cc047 --- /dev/null +++ b/decidim-collaborative_texts/spec/system/collaborative_tests_breadcrumbs_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "CollaborativeTexts Breadcrumb" do + include_context "with a component" + let(:manifest_name) { "collaborative_texts" } + let!(:component) do + create(:collaborative_text_component, + manifest:, + participatory_space: participatory_process) + end + let!(:document) { create(:collaborative_text_document, :with_body, :published, component:) } + + before do + visit_component + end + + describe "index" do + it "shows the correct information in breadcrumb (space, component)" do + within(".menu-bar") do + expect(page).to have_content(translated(component.participatory_space.title)) + expect(page).to have_content(translated(component.name)) + end + end + end + + describe "show" do + it "shows the correct information in breadcrumb (space, component, document)" do + click_on document.title + + within(".menu-bar") do + expect(page).to have_content(translated(component.participatory_space.title)) + expect(page).to have_content(translated(component.name)) + expect(page).to have_content(translated(document.title)) + end + end + end +end diff --git a/decidim-collaborative_texts/spec/system/user_sends_suggestions_spec.rb b/decidim-collaborative_texts/spec/system/user_sends_suggestions_spec.rb index 13bdf4b263e25..e12a570f1bc38 100644 --- a/decidim-collaborative_texts/spec/system/user_sends_suggestions_spec.rb +++ b/decidim-collaborative_texts/spec/system/user_sends_suggestions_spec.rb @@ -46,9 +46,6 @@ end it "lists all the documents" do - within(".menu-bar") do - expect(page).to have_content(translated(component.name)) - end within("aside") do expect(page).to have_content(translated(component.name)) end @@ -60,11 +57,6 @@ it "shows the document details" do click_on document.title - within(".menu-bar") do - expect(page).to have_content(translated(component.name)) - expect(page).to have_content(translated(document.title)) - end - expect(page).to have_content(translated(document.title)) within("aside") do expect(page).to have_content("Index") diff --git a/decidim-collaborative_texts/spec/types/document_type_spec.rb b/decidim-collaborative_texts/spec/types/document_type_spec.rb index 7961837fdfad8..6cb15cef4d6e0 100644 --- a/decidim-collaborative_texts/spec/types/document_type_spec.rb +++ b/decidim-collaborative_texts/spec/types/document_type_spec.rb @@ -13,6 +13,12 @@ module CollaborativeTexts include_examples "traceable interface" include_examples "timestamps interface" + shared_examples "unauthorized Document" do + it "throws Decidim::Api::Errors::UnauthorizedObjectError" do + expect { response }.to raise_error(Decidim::Api::Errors::UnauthorizedObjectError, "You cannot view or edit this CollaborativeText because you do not have permissions") + end + end + describe "id" do let(:query) { "{ id }" } @@ -93,9 +99,7 @@ module CollaborativeTexts let(:model) { create(:collaborative_text_document, :published, component: current_component) } let(:query) { "{ id }" } - it "returns nothing" do - expect(response).to be_nil - end + it_behaves_like "unauthorized Document" end context "when participatory space is private but transparent" do @@ -115,9 +119,7 @@ module CollaborativeTexts let(:model) { create(:collaborative_text_document, :published, component: current_component) } let(:query) { "{ id }" } - it "returns nothing" do - expect(response).to be_nil - end + it_behaves_like "unauthorized Document" end context "when component is not published" do @@ -125,9 +127,7 @@ module CollaborativeTexts let(:model) { create(:collaborative_text_document, :published, component: current_component) } let(:query) { "{ id }" } - it "returns nothing" do - expect(response).to be_nil - end + it_behaves_like "unauthorized Document" end context "when document is not published" do @@ -135,9 +135,7 @@ module CollaborativeTexts let(:model) { create(:collaborative_text_document, :published, published_at: nil, component: current_component) } let(:query) { "{ id }" } - it "returns nothing" do - expect(response).to be_nil - end + it_behaves_like "unauthorized Document" end end end diff --git a/decidim-collaborative_texts/spec/types/documents_type_spec.rb b/decidim-collaborative_texts/spec/types/documents_type_spec.rb index ad8b42dd8ec39..cad1e4492b98d 100644 --- a/decidim-collaborative_texts/spec/types/documents_type_spec.rb +++ b/decidim-collaborative_texts/spec/types/documents_type_spec.rb @@ -39,8 +39,8 @@ module CollaborativeTexts context "when the document does not belong to the component" do let!(:document) { create(:collaborative_text_document, :published, component: create(:collaborative_text_component)) } - it "returns null" do - expect(response["collaborativeText"]).to be_nil + it "raises error" do + expect { response }.to raise_error(Decidim::Api::Errors::NotFoundError, "CollaborativeText not found") end end end diff --git a/decidim-collaborative_texts/spec/types/integration_schema_spec.rb b/decidim-collaborative_texts/spec/types/integration_schema_spec.rb index aa03116aea08f..314f5e9548b8a 100644 --- a/decidim-collaborative_texts/spec/types/integration_schema_spec.rb +++ b/decidim-collaborative_texts/spec/types/integration_schema_spec.rb @@ -36,7 +36,7 @@ let!(:current_component) { create(:collaborative_text_component, participatory_space: participatory_process) } let!(:document) { create(:collaborative_text_document, component: current_component, published_at: 2.days.ago) } let!(:document_version) { create(:collaborative_text_version, document:) } - let!(:suggestions) { create_list(:collaborative_text_suggestion, 2, document_version: document_version) } + let!(:suggestions) { create_list(:collaborative_text_suggestion, 2, document_version:) } let(:author) { nil } let(:document_single_result) do { diff --git a/decidim-comments/app/cells/decidim/comments/comment_s_cell.rb b/decidim-comments/app/cells/decidim/comments/comment_s_cell.rb index 001cb75b5ef1c..4e035c23d1f05 100644 --- a/decidim-comments/app/cells/decidim/comments/comment_s_cell.rb +++ b/decidim-comments/app/cells/decidim/comments/comment_s_cell.rb @@ -12,7 +12,7 @@ class CommentSCell < Decidim::CardSCell private def title - resource_link_text + sanitize(translated_attribute(model.body)) end def resource_path diff --git a/decidim-comments/app/resolvers/decidim/comments/vote_comment_resolver.rb b/decidim-comments/app/resolvers/decidim/comments/vote_comment_resolver.rb index aeccb5ef96e0a..2c9384e6123d4 100644 --- a/decidim-comments/app/resolvers/decidim/comments/vote_comment_resolver.rb +++ b/decidim-comments/app/resolvers/decidim/comments/vote_comment_resolver.rb @@ -14,8 +14,9 @@ def call(obj, _args, ctx) on(:ok) do |comment| return comment end + on(:invalid) do - return GraphQL::ExecutionError.new(I18n.t("votes.create.error", scope: "decidim.comments")) + raise GraphQL::ExecutionError, I18n.t("votes.create.error", scope: "decidim.comments") end end end diff --git a/decidim-comments/lib/decidim/api/commentable_mutation_type.rb b/decidim-comments/lib/decidim/api/commentable_mutation_type.rb index 7ce370b8a8a58..0fcada3818574 100644 --- a/decidim-comments/lib/decidim/api/commentable_mutation_type.rb +++ b/decidim-comments/lib/decidim/api/commentable_mutation_type.rb @@ -22,6 +22,10 @@ def add_comment(body:, alignment: nil) on(:ok) do |comment| return comment end + + on(:invalid) do + raise GraphQL::ExecutionError, t("create.error", scope: "decidim.comments.comments") + end end end end diff --git a/decidim-comments/spec/system/search_comments_spec.rb b/decidim-comments/spec/system/search_comments_spec.rb index 4a898f2c3e744..93a610dcf3681 100644 --- a/decidim-comments/spec/system/search_comments_spec.rb +++ b/decidim-comments/spec/system/search_comments_spec.rb @@ -16,5 +16,22 @@ searchables << comment end + context "when there is a link in the comment search result" do + let(:search_input_selector) { "input#input-search" } + + before do + create(:comment, body: "Here is an interesting link: https://github.com/decidim", commentable:) + visit decidim.root_path + field = find(search_input_selector) + field.set "Here is an interesting" + send_keys(:enter) + end + + it "does not allow clickable link" do + expect(page).to have_no_link(href: "https://github.com/decidim") + expect(page).to have_text("Here is an interesting link: https://github.com/decidim") + end + end + include_examples "searchable results" end diff --git a/decidim-comments/spec/types/comment_type_spec.rb b/decidim-comments/spec/types/comment_type_spec.rb index d65bacb7473ab..d2ba11f4faa9f 100644 --- a/decidim-comments/spec/types/comment_type_spec.rb +++ b/decidim-comments/spec/types/comment_type_spec.rb @@ -11,6 +11,12 @@ module Comments let(:model) { create(:comment) } let(:sgid) { double("sgid", to_s: "1234") } + shared_examples "unauthorized Comment" do + it "throws Decidim::Api::Errors::UnauthorizedObjectError" do + expect { response }.to raise_error(Decidim::Api::Errors::UnauthorizedObjectError, "You cannot view or edit this Comment because you do not have permissions") + end + end + context "when participatory space is unpublished" do let(:participatory_space) { create(:assembly, :unpublished) } let(:component) { create(:dummy_component, :published, participatory_space:) } @@ -20,9 +26,7 @@ module Comments let(:model) { create(:comment, commentable:) } let(:query) { "{ id }" } - it "returns nothing" do - expect(response).to be_nil - end + it_behaves_like "unauthorized Comment" end context "when participatory space is private and transparent" do @@ -45,9 +49,7 @@ module Comments let(:model) { create(:comment, commentable:) } let(:query) { "{ id }" } - it "returns nothing" do - expect(response).to be_nil - end + it_behaves_like "unauthorized Comment" end context "when component is unpublished" do @@ -57,9 +59,7 @@ module Comments let(:model) { create(:comment, commentable:) } let(:query) { "{ id }" } - it "returns nothing" do - expect(response).to be_nil - end + it_behaves_like "unauthorized Comment" end context "when resource is unpublished" do @@ -68,9 +68,7 @@ module Comments let(:model) { create(:comment, commentable:) } let(:query) { "{ id }" } - it "returns nothing" do - expect(response).to be_nil - end + it_behaves_like "unauthorized Comment" end context "when resource is moderated" do @@ -80,27 +78,21 @@ module Comments let(:model) { create(:comment, commentable:) } let(:query) { "{ id }" } - it "returns nothing" do - expect(response).to be_nil - end + it_behaves_like "unauthorized Comment" end describe "deleted comment" do let(:model) { create(:comment, :deleted) } let(:query) { "{ id }" } - it "returns nothing" do - expect(response).to be_nil - end + it_behaves_like "unauthorized Comment" end describe "moderated comment" do let(:model) { create(:comment, :moderated) } let(:query) { "{ id }" } - it "returns nothing" do - expect(response).to be_nil - end + it_behaves_like "unauthorized Comment" end describe "author" do diff --git a/decidim-conferences/app/models/decidim/conference.rb b/decidim-conferences/app/models/decidim/conference.rb index b48faa15ccbaf..ae978d9d85525 100644 --- a/decidim-conferences/app/models/decidim/conference.rb +++ b/decidim-conferences/app/models/decidim/conference.rb @@ -157,7 +157,7 @@ def self.ransackable_attributes(auth_object = nil) return base unless auth_object&.admin? - base + %w(published_at) + base + %w(published_at created_at) end def self.ransackable_associations(_auth_object = nil) diff --git a/decidim-conferences/config/locales/fi-plain.yml b/decidim-conferences/config/locales/fi-plain.yml index 8fb676a94b9e0..74c65f2c96147 100644 --- a/decidim-conferences/config/locales/fi-plain.yml +++ b/decidim-conferences/config/locales/fi-plain.yml @@ -622,6 +622,7 @@ fi-pl: open_data: help: conferences: + component_settings: Konferenssin komponenttiasetukset created_at: Tilan luontiaika decidim_scope_id: Konferenssin teema description: Konferenssin pitkä kuvaus diff --git a/decidim-conferences/config/locales/fi.yml b/decidim-conferences/config/locales/fi.yml index 6f67310e6bfa9..3cfc05873cbc9 100644 --- a/decidim-conferences/config/locales/fi.yml +++ b/decidim-conferences/config/locales/fi.yml @@ -622,6 +622,7 @@ fi: open_data: help: conferences: + component_settings: Konferenssin komponenttiasetukset created_at: Tilan luontiaika decidim_scope_id: Konferenssin teema description: Konferenssin pitkä kuvaus diff --git a/decidim-conferences/config/locales/ja.yml b/decidim-conferences/config/locales/ja.yml index 64b14a53698ec..76a038fad586d 100644 --- a/decidim-conferences/config/locales/ja.yml +++ b/decidim-conferences/config/locales/ja.yml @@ -617,6 +617,7 @@ ja: open_data: help: conferences: + component_settings: カンファレンススペースのコンポーネント設定 created_at: このスペースが作成された日時 decidim_scope_id: カンファレンスのスコープ description: カンファレンスの詳しい説明 diff --git a/decidim-conferences/lib/decidim/api/conference_registration_type_type.rb b/decidim-conferences/lib/decidim/api/conference_registration_type_type.rb index a4c6062292abd..54916546008e1 100644 --- a/decidim-conferences/lib/decidim/api/conference_registration_type_type.rb +++ b/decidim-conferences/lib/decidim/api/conference_registration_type_type.rb @@ -22,8 +22,6 @@ def self.authorized?(object, context) ].all? super && chain - rescue Decidim::PermissionAction::PermissionNotSetError - false end end end diff --git a/decidim-conferences/lib/decidim/api/conference_speaker_type.rb b/decidim-conferences/lib/decidim/api/conference_speaker_type.rb index 2992a2a024184..239890b9393c0 100644 --- a/decidim-conferences/lib/decidim/api/conference_speaker_type.rb +++ b/decidim-conferences/lib/decidim/api/conference_speaker_type.rb @@ -30,8 +30,6 @@ def self.authorized?(object, context) ].all? super && chain - rescue Decidim::PermissionAction::PermissionNotSetError - false end end end diff --git a/decidim-conferences/spec/lib/decidim/conferences/query_extensions_spec.rb b/decidim-conferences/spec/lib/decidim/conferences/query_extensions_spec.rb index 741da0678c957..2ebb49d9f1aa7 100644 --- a/decidim-conferences/spec/lib/decidim/conferences/query_extensions_spec.rb +++ b/decidim-conferences/spec/lib/decidim/conferences/query_extensions_spec.rb @@ -38,8 +38,8 @@ module Conferences let!(:conference) { create(:conference) } let(:id) { conference.id } - it "returns nil" do - expect(response["conference"]).to be_nil + it_behaves_like "graphQL not found space" do + let(:space_type) { "conference" } end end end diff --git a/decidim-conferences/spec/system/admin/admin_filters_conferences_spec.rb b/decidim-conferences/spec/system/admin/admin_filters_conferences_spec.rb new file mode 100644 index 0000000000000..e4ba765868f31 --- /dev/null +++ b/decidim-conferences/spec/system/admin/admin_filters_conferences_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Admin sorting conferences" do + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, :confirmed, organization:) } + + let!(:old_conference) { create(:conference, title: { en: "Old conference" }, created_at: 3.weeks.ago, organization:) } + let!(:recent_conference) { create(:conference, title: { en: "Recent conference" }, created_at: 1.day.ago, organization:) } + let!(:newest_conference) { create(:conference, title: { en: "Newest conference" }, created_at: Time.current, organization:) } + + before do + switch_to_host(organization.host) + login_as user, scope: :user + visit decidim_admin_conferences.conferences_path + end + + context "when sorting conferences by their creation" do + it "sorts by created_at descending by default" do + within "table thead" do + click_link "Created at" + end + + titles = page.all("table tbody tr td:first-child") + expect(titles[0].text).to include("Newest conference") + expect(titles[1].text).to include("Recent conference") + expect(titles[2].text).to include("Old conference") + end + + it "sorts by created_at ascending when clicked again" do + within "table thead" do + click_link "Created at" + click_link "Created at" + end + + titles = page.all("table tbody tr td:first-child") + expect(titles[0].text).to include("Old conference") + expect(titles[1].text).to include("Recent conference") + expect(titles[2].text).to include("Newest conference") + end + end +end diff --git a/decidim-conferences/spec/system/admin/admin_manages_conference_soft_delete_spec.rb b/decidim-conferences/spec/system/admin/admin_manages_conference_soft_delete_spec.rb index d1fecf196bbf0..6321649f6f4f2 100644 --- a/decidim-conferences/spec/system/admin/admin_manages_conference_soft_delete_spec.rb +++ b/decidim-conferences/spec/system/admin/admin_manages_conference_soft_delete_spec.rb @@ -14,12 +14,12 @@ it_behaves_like "manage trashed resource", "conference" context "when a user is collaborator" do - let!(:conference) { create(:conference, organization: organization) } - let!(:collaborator_user) { create(:user, :admin_terms_accepted, :confirmed, organization: organization) } + let!(:conference) { create(:conference, organization:) } + let!(:collaborator_user) { create(:user, :admin_terms_accepted, :confirmed, organization:) } let!(:collaborator_role) do create(:conference_user_role, user: collaborator_user, - conference: conference, + conference:, role: :collaborator) end @@ -36,12 +36,12 @@ end context "when a user is evaluator" do - let!(:conference) { create(:conference, organization: organization) } - let!(:evaluator_user) { create(:user, :admin_terms_accepted, :confirmed, organization: organization) } + let!(:conference) { create(:conference, organization:) } + let!(:evaluator_user) { create(:user, :admin_terms_accepted, :confirmed, organization:) } let!(:evaluator_role) do create(:conference_user_role, user: evaluator_user, - conference: conference, + conference:, role: :evaluator) end @@ -58,12 +58,12 @@ end context "when a user is moderator" do - let!(:conference) { create(:conference, organization: organization) } - let!(:moderator_user) { create(:user, :admin_terms_accepted, :confirmed, organization: organization) } + let!(:conference) { create(:conference, organization:) } + let!(:moderator_user) { create(:user, :admin_terms_accepted, :confirmed, organization:) } let!(:moderator_role) do create(:conference_user_role, user: moderator_user, - conference: conference, + conference:, role: :moderator) end @@ -80,12 +80,12 @@ end context "when a user is a space admin" do - let!(:conference) { create(:conference, organization: organization) } - let!(:admin_user) { create(:user, :admin_terms_accepted, :confirmed, organization: organization) } + let!(:conference) { create(:conference, organization:) } + let!(:admin_user) { create(:user, :admin_terms_accepted, :confirmed, organization:) } let!(:admin_role) do create(:conference_user_role, user: admin_user, - conference: conference, + conference:, role: :admin) end diff --git a/decidim-conferences/spec/system/conference_breadcrumb_spec.rb b/decidim-conferences/spec/system/conferences_breadcrumbs_spec.rb similarity index 96% rename from decidim-conferences/spec/system/conference_breadcrumb_spec.rb rename to decidim-conferences/spec/system/conferences_breadcrumbs_spec.rb index b55a1c4579ce2..22e97ae41826d 100644 --- a/decidim-conferences/spec/system/conference_breadcrumb_spec.rb +++ b/decidim-conferences/spec/system/conferences_breadcrumbs_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" -describe "Conference Breadcrumb" do +describe "Conferences Breadcrumb" do let(:organization) { create(:organization) } let(:participatory_space) { create(:conference, :published, organization:) } let(:component) { create(:proposal_component, :published, participatory_space:) } diff --git a/decidim-conferences/spec/types/conference_type_spec.rb b/decidim-conferences/spec/types/conference_type_spec.rb index d12a166b469e3..121de1003b4bb 100644 --- a/decidim-conferences/spec/types/conference_type_spec.rb +++ b/decidim-conferences/spec/types/conference_type_spec.rb @@ -204,8 +204,8 @@ module Conferences context "when registrations are disabled" do let(:registrations_enabled) { false } - it "does not return any registration type" do - expect(response["registrationTypes"]).to eq([nil]) + it "throws Decidim::Api::Errors::UnauthorizedObjectError" do + expect { response }.to raise_error(Decidim::Api::Errors::UnauthorizedObjectError, "You cannot view or edit this ConferenceRegistrationType because you do not have permissions") end end diff --git a/decidim-core/app/cells/decidim/participatory_space_private_user/show.erb b/decidim-core/app/cells/decidim/member/show.erb similarity index 100% rename from decidim-core/app/cells/decidim/participatory_space_private_user/show.erb rename to decidim-core/app/cells/decidim/member/show.erb diff --git a/decidim-core/app/cells/decidim/participatory_space_private_user_cell.rb b/decidim-core/app/cells/decidim/member_cell.rb similarity index 86% rename from decidim-core/app/cells/decidim/participatory_space_private_user_cell.rb rename to decidim-core/app/cells/decidim/member_cell.rb index cd6aac893d571..a4427a667a36f 100644 --- a/decidim-core/app/cells/decidim/participatory_space_private_user_cell.rb +++ b/decidim-core/app/cells/decidim/member_cell.rb @@ -2,7 +2,7 @@ module Decidim # This cell renders the card for an instance of an Assembly member - class ParticipatorySpacePrivateUserCell < Decidim::ViewModel + class MemberCell < Decidim::ViewModel property :name property :role property :nickname diff --git a/decidim-core/app/cells/decidim/share_widget/modal.erb b/decidim-core/app/cells/decidim/share_widget/modal.erb index 61a0573556414..1c94de87b2df8 100644 --- a/decidim-core/app/cells/decidim/share_widget/modal.erb +++ b/decidim-core/app/cells/decidim/share_widget/modal.erb @@ -1,6 +1,6 @@ <%= decidim_modal id: "socialShare", class: "share-modal" do %>
-

<%= t("share", scope: "decidim.shared.share_modal") %>

+

<%= t("share", scope: "decidim.shared.share_modal") %>

diff --git a/decidim-core/app/commands/decidim/destroy_account.rb b/decidim-core/app/commands/decidim/destroy_account.rb index 6199f2ffbf6cb..8ad711b72fc0d 100644 --- a/decidim-core/app/commands/decidim/destroy_account.rb +++ b/decidim-core/app/commands/decidim/destroy_account.rb @@ -28,7 +28,7 @@ def call destroy_user_badges destroy_user_likes destroy_user_reports - destroy_participatory_space_private_user + destroy_member delegate_destroy_to_participatory_spaces end @@ -101,8 +101,8 @@ def destroy_follows Decidim::Follow.where(user: current_user).find_each(&:destroy) end - def destroy_participatory_space_private_user - Decidim::ParticipatorySpacePrivateUser.where(user: current_user).find_each(&:destroy) + def destroy_member + Decidim::ParticipatorySpace::Member.where(user: current_user).find_each(&:destroy) end def delegate_destroy_to_participatory_spaces diff --git a/decidim-core/app/controllers/concerns/decidim/has_members_page.rb b/decidim-core/app/controllers/concerns/decidim/has_members_page.rb deleted file mode 100644 index f0ff4f9ebd135..0000000000000 --- a/decidim-core/app/controllers/concerns/decidim/has_members_page.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -require "active_support/concern" - -module Decidim - module HasMembersPage - extend ActiveSupport::Concern - - included do - helper_method :collection - - private - - def can_visit_index? - current_user_can_visit_space? && current_participatory_space.members_public_page? - end - - def members - @members ||= current_participatory_space.participatory_space_private_users.published - end - - alias_method :collection, :members - end - end -end diff --git a/decidim-core/app/controllers/concerns/decidim/participatory_space/has_members_page.rb b/decidim-core/app/controllers/concerns/decidim/participatory_space/has_members_page.rb new file mode 100644 index 0000000000000..edda01621be6c --- /dev/null +++ b/decidim-core/app/controllers/concerns/decidim/participatory_space/has_members_page.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "active_support/concern" + +module Decidim + module ParticipatorySpace + module HasMembersPage + extend ActiveSupport::Concern + + included do + helper_method :collection + + private + + def can_visit_index? + current_user_can_visit_space? && current_participatory_space.members_public_page? + end + + def members + @members ||= current_participatory_space.members.published + end + + alias_method :collection, :members + end + end + end +end diff --git a/decidim-core/app/helpers/decidim/amendments_helper.rb b/decidim-core/app/helpers/decidim/amendments_helper.rb index 1873619b9dc9d..29fa088e71bd2 100644 --- a/decidim-core/app/helpers/decidim/amendments_helper.rb +++ b/decidim-core/app/helpers/decidim/amendments_helper.rb @@ -17,9 +17,9 @@ def emendation_announcement_for(emendation) end # Checks if the user can participate in a participatory space - # based on its settings related with Decidim::HasPrivateUsers. + # based on its settings related with Decidim::ParticipatorySpace::HasMembers. def can_participate_in_private_space? - return true unless current_participatory_space.class.included_modules.include?(HasPrivateUsers) + return true unless current_participatory_space.class.included_modules.include?(Decidim::ParticipatorySpace::HasMembers) current_participatory_space.can_participate?(current_user) end diff --git a/decidim-core/app/helpers/decidim/menu_helper.rb b/decidim-core/app/helpers/decidim/menu_helper.rb index d84f6aeab96b5..d31e9aaf0ea6e 100644 --- a/decidim-core/app/helpers/decidim/menu_helper.rb +++ b/decidim-core/app/helpers/decidim/menu_helper.rb @@ -69,10 +69,10 @@ def menu_highlighted_participatory_process # The queries already include the order by weight Decidim::ParticipatoryProcesses::OrganizationParticipatoryProcesses.new(current_organization) | Decidim::ParticipatoryProcesses::PromotedParticipatoryProcesses.new - ).select(&:published?).map { |process| remove_private_space_if_not_private_user(process) }&.compact&.first + ).select(&:published?).map { |process| remove_private_space_if_not_member(process) }&.compact&.first end - def remove_private_space_if_not_private_user(process) + def remove_private_space_if_not_member(process) return nil if process.private_space == true && !process.can_participate?(current_user) process diff --git a/decidim-core/app/models/decidim/participatory_space/member.rb b/decidim-core/app/models/decidim/participatory_space/member.rb new file mode 100644 index 0000000000000..fe86d330ff8ac --- /dev/null +++ b/decidim-core/app/models/decidim/participatory_space/member.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Decidim + module ParticipatorySpace + # This class gives a given User access to a given private Member + class Member < ApplicationRecord + include Decidim::DownloadYourData + include ParticipatorySpaceUser + include Decidim::TranslatableResource + + belongs_to :privatable_to, polymorphic: true + + translatable_fields :role + + delegate :email, :name, to: :user + + scope :by_participatory_space, ->(privatable_to) { where(privatable_to_id: privatable_to.id, privatable_to_type: privatable_to.class.to_s) } + scope :published, -> { where(published: true) } + + def self.user_collection(user) + where(decidim_user_id: user.id) + end + + def self.member_ids_for_participatory_spaces(spaces) + joins(:user).where(privatable_to: spaces).distinct.pluck(:decidim_user_id) + end + + def self.export_serializer + Decidim::DownloadYourDataSerializers::DownloadYourDataMemberSerializer + end + + def self.log_presenter_class_for(_log) + Decidim::AdminLog::ParticipatorySpace::MemberPresenter + end + + ransacker :invitation_sent_at do + Arel.sql(%{("invitation_sent_at")::text}) + end + + def self.ransackable_attributes(auth_object = nil) + return [] unless auth_object&.admin? + + %w(name nickname email invitation_accepted_at last_sign_in_at invitation_sent_at role) + end + + def self.ransackable_associations(_auth_object = nil) + %w(user) + end + + def target_space_association = :privatable_to + end + end +end diff --git a/decidim-core/app/models/decidim/participatory_space_private_user.rb b/decidim-core/app/models/decidim/participatory_space_private_user.rb deleted file mode 100644 index cd8676f037dd2..0000000000000 --- a/decidim-core/app/models/decidim/participatory_space_private_user.rb +++ /dev/null @@ -1,51 +0,0 @@ -# frozen_string_literal: true - -module Decidim - # This class gives a given User access to a given private ParticipatorySpacePrivateUser - class ParticipatorySpacePrivateUser < ApplicationRecord - include Decidim::DownloadYourData - include ParticipatorySpaceUser - include Decidim::TranslatableResource - - belongs_to :privatable_to, polymorphic: true - - translatable_fields :role - - delegate :email, :name, to: :user - - scope :by_participatory_space, ->(privatable_to) { where(privatable_to_id: privatable_to.id, privatable_to_type: privatable_to.class.to_s) } - scope :published, -> { where(published: true) } - - def self.user_collection(user) - where(decidim_user_id: user.id) - end - - def self.private_user_ids_for_participatory_spaces(spaces) - joins(:user).where(privatable_to: spaces).distinct.pluck(:decidim_user_id) - end - - def self.export_serializer - Decidim::DownloadYourDataSerializers::DownloadYourDataParticipatorySpacePrivateUserSerializer - end - - def self.log_presenter_class_for(_log) - Decidim::AdminLog::ParticipatorySpacePrivateUserPresenter - end - - ransacker :invitation_sent_at do - Arel.sql(%{("invitation_sent_at")::text}) - end - - def self.ransackable_attributes(auth_object = nil) - return [] unless auth_object&.admin? - - %w(name nickname email invitation_accepted_at last_sign_in_at invitation_sent_at role) - end - - def self.ransackable_associations(_auth_object = nil) - %w(user) - end - - def target_space_association = :privatable_to - end -end diff --git a/decidim-core/app/presenters/decidim/admin_log/participatory_space/member_presenter.rb b/decidim-core/app/presenters/decidim/admin_log/participatory_space/member_presenter.rb new file mode 100644 index 0000000000000..a86d0687fcbdc --- /dev/null +++ b/decidim-core/app/presenters/decidim/admin_log/participatory_space/member_presenter.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Decidim + module AdminLog + module ParticipatorySpace + # This class holds the logic to present a `Decidim::ParticipatorySpace::MemberPresenter` + # for the `AdminLog` log. + # + # Usage should be automatic and you should not need to call this class + # directly, but here is an example: + # + # action_log = Decidim::ActionLog.last + # view_helpers # => this comes from the views + # MemberPresenter.new(action_log, view_helpers).present + class MemberPresenter < Decidim::Log::BasePresenter + private + + def diff_fields_mapping + { + name: :string, + email: :string + } + end + + def action_string + case action + when "create", "create_via_csv", "delete" + "decidim.admin_log.member.#{action}" + else + super + end + end + + def i18n_labels_scope + "activemodel.attributes.member" + end + end + end + end +end diff --git a/decidim-core/app/presenters/decidim/admin_log/participatory_space_private_user_presenter.rb b/decidim-core/app/presenters/decidim/admin_log/participatory_space_private_user_presenter.rb deleted file mode 100644 index 27787e840f560..0000000000000 --- a/decidim-core/app/presenters/decidim/admin_log/participatory_space_private_user_presenter.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -module Decidim - module AdminLog - # This class holds the logic to present a `Decidim::ParticipatorySpacePrivateUserPresenter` - # for the `AdminLog` log. - # - # Usage should be automatic and you should not need to call this class - # directly, but here is an example: - # - # action_log = Decidim::ActionLog.last - # view_helpers # => this comes from the views - # ParticipatorySpacePrivateUserPresenter.new(action_log, view_helpers).present - class ParticipatorySpacePrivateUserPresenter < Decidim::Log::BasePresenter - private - - def diff_fields_mapping - { - name: :string, - email: :string - } - end - - def action_string - case action - when "create", "create_via_csv", "delete" - "decidim.admin_log.participatory_space_private_user.#{action}" - else - super - end - end - - def i18n_labels_scope - "activemodel.attributes.participatory_space_private_user" - end - end - end -end diff --git a/decidim-core/app/presenters/decidim/organization_presenter.rb b/decidim-core/app/presenters/decidim/organization_presenter.rb index 1b542a8afdb70..7ad70c9e25d77 100644 --- a/decidim-core/app/presenters/decidim/organization_presenter.rb +++ b/decidim-core/app/presenters/decidim/organization_presenter.rb @@ -7,6 +7,10 @@ def html_name translated_attribute(name).html_safe end + def html_short_name + translated_attribute(short_name).html_safe + end + def translated_description ActionView::Base.full_sanitizer.sanitize(translated_attribute(description)).html_safe end diff --git a/decidim-core/app/presenters/decidim/participatory_space/member_presenter.rb b/decidim-core/app/presenters/decidim/participatory_space/member_presenter.rb new file mode 100644 index 0000000000000..c36faa25b1a81 --- /dev/null +++ b/decidim-core/app/presenters/decidim/participatory_space/member_presenter.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Decidim + module ParticipatorySpace + # + # Decorator for participatory space members + # + class MemberPresenter < SimpleDelegator + delegate :profile_url, to: :user, allow_nil: true + + def name + user ? user.name : full_name + end + + def nickname + user.nickname if user + end + + def avatar_url(variant = nil) + return user.avatar_url(variant) if user.present? + + non_user_avatar_path(variant) + end + + def non_user_avatar_path(variant = nil) + return non_user_avatar.default_url(variant) unless non_user_avatar.attached? + + non_user_avatar.path(variant:) + end + + def non_user_avatar + attached_uploader(:non_user_avatar) + end + + def deleted? + false + end + + private + + def user + @user ||= if (user = __getobj__.user.presence) + Decidim::UserPresenter.new(user) + end + end + end + end +end diff --git a/decidim-core/app/presenters/decidim/participatory_space_private_user_presenter.rb b/decidim-core/app/presenters/decidim/participatory_space_private_user_presenter.rb deleted file mode 100644 index ab086c9095f29..0000000000000 --- a/decidim-core/app/presenters/decidim/participatory_space_private_user_presenter.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -module Decidim - # - # Decorator for assembly members - # - class ParticipatorySpacePrivateUserPresenter < SimpleDelegator - delegate :profile_url, to: :user, allow_nil: true - - def name - user ? user.name : full_name - end - - def nickname - user.nickname if user - end - - def avatar_url(variant = nil) - return user.avatar_url(variant) if user.present? - - non_user_avatar_path(variant) - end - - def non_user_avatar_path(variant = nil) - return non_user_avatar.default_url(variant) unless non_user_avatar.attached? - - non_user_avatar.path(variant:) - end - - def non_user_avatar - attached_uploader(:non_user_avatar) - end - - def deleted? - false - end - - private - - def user - @user ||= if (user = __getobj__.user.presence) - Decidim::UserPresenter.new(user) - end - end - end -end diff --git a/decidim-core/app/queries/decidim/last_activity.rb b/decidim-core/app/queries/decidim/last_activity.rb index b54d2bf7f30e8..5025ad8a2d0db 100644 --- a/decidim-core/app/queries/decidim/last_activity.rb +++ b/decidim-core/app/queries/decidim/last_activity.rb @@ -79,7 +79,7 @@ def filter_spaces(query) Decidim.participatory_space_manifests.map do |manifest| klass = manifest.model_class_name.constantize - condition = if klass.include?(Decidim::HasPrivateUsers) + condition = if klass.include?(Decidim::ParticipatorySpace::HasMembers) Arel.sql( <<~SQL.squish ( diff --git a/decidim-core/app/serializers/decidim/exporters/participatory_space_serializer.rb b/decidim-core/app/serializers/decidim/exporters/participatory_space_serializer.rb index 6e02322533747..a74a9c6764cef 100644 --- a/decidim-core/app/serializers/decidim/exporters/participatory_space_serializer.rb +++ b/decidim-core/app/serializers/decidim/exporters/participatory_space_serializer.rb @@ -28,7 +28,7 @@ def serialize short_description: resource.short_description, description: resource.description, promoted: resource.promoted, - component_settings: component_settings + component_settings: } end diff --git a/decidim-core/app/views/decidim/devise/registrations/new.html.erb b/decidim-core/app/views/decidim/devise/registrations/new.html.erb index 512b21edaa5ff..dc1ed200a32d2 100644 --- a/decidim-core/app/views/decidim/devise/registrations/new.html.erb +++ b/decidim-core/app/views/decidim/devise/registrations/new.html.erb @@ -34,6 +34,7 @@ <%= f.text_field :name, help_text: t("decidim.devise.registrations.new.username_help"), autocomplete: "name", placeholder: "John Doe" %> <%= f.email_field :email, autocomplete: "email", placeholder: t("placeholder_email", scope: "decidim.devise.shared") %> + <%= t("placeholder_email", scope: "decidim.devise.shared") %> <%= render partial: "decidim/account/password_fields", locals: { form: f, user: :user } %>
diff --git a/decidim-core/app/views/decidim/devise/shared/_tos_fields.html.erb b/decidim-core/app/views/decidim/devise/shared/_tos_fields.html.erb index 81fa0e1823971..8520d40cde49c 100644 --- a/decidim-core/app/views/decidim/devise/shared/_tos_fields.html.erb +++ b/decidim-core/app/views/decidim/devise/shared/_tos_fields.html.erb @@ -1,13 +1,14 @@

<%= t("decidim.devise.registrations.new.tos_title") %>

+ Required field -
+
<% terms_of_service_summary_content_blocks.each do |content_block| %> <%= cell content_block.manifest.cell, content_block %> <% end %>
- <%= form.check_box :tos_agreement, label: t("decidim.devise.registrations.new.tos_agreement", link: link_to(t("decidim.devise.registrations.new.terms"), decidim.page_path("terms-of-service", locale: current_locale))), label_options: { class: "form__wrapper-checkbox-label" } %> + <%= form.check_box :tos_agreement, label: t("decidim.devise.registrations.new.tos_agreement", link: link_to(t("decidim.devise.registrations.new.terms"), decidim.page_path("terms-of-service", locale: current_locale))), label_options: { class: "form__wrapper-checkbox-label" }, "aria-describedby": "terms_of_service_summary", "required": "required" %>
diff --git a/decidim-core/app/views/decidim/manifests/show.json.erb b/decidim-core/app/views/decidim/manifests/show.json.erb index 2c3b6d412af13..7b3ed0e32981b 100644 --- a/decidim-core/app/views/decidim/manifests/show.json.erb +++ b/decidim-core/app/views/decidim/manifests/show.json.erb @@ -1,5 +1,6 @@ { "name": "<%= organization_params.html_name %>", + "short_name": "<%= organization_params.html_short_name %>", "lang": "<%= organization_params.default_locale %>", "description": "<%= organization_params.translated_description %>", "display": "<%= organization_params.pwa_display %>", diff --git a/decidim-core/app/views/decidim/participatory_space/members/_member.html.erb b/decidim-core/app/views/decidim/participatory_space/members/_member.html.erb new file mode 100644 index 0000000000000..8cd1bdbf22597 --- /dev/null +++ b/decidim-core/app/views/decidim/participatory_space/members/_member.html.erb @@ -0,0 +1 @@ +<%= cell "decidim/member", Decidim::ParticipatorySpace::MemberPresenter.new(member) %> diff --git a/decidim-core/app/views/decidim/participatory_space_private_users/_participatory_space_private_user.html.erb b/decidim-core/app/views/decidim/participatory_space_private_users/_participatory_space_private_user.html.erb deleted file mode 100644 index 69f415e14ccb2..0000000000000 --- a/decidim-core/app/views/decidim/participatory_space_private_users/_participatory_space_private_user.html.erb +++ /dev/null @@ -1 +0,0 @@ -<%= cell "decidim/participatory_space_private_user", Decidim::ParticipatorySpacePrivateUserPresenter.new(participatory_space_private_user) %> diff --git a/decidim-core/app/views/devise/mailer/invite_private_user.html.erb b/decidim-core/app/views/devise/mailer/invite_member.html.erb similarity index 81% rename from decidim-core/app/views/devise/mailer/invite_private_user.html.erb rename to decidim-core/app/views/devise/mailer/invite_member.html.erb index 3694d05a6827d..382aebadde83d 100644 --- a/decidim-core/app/views/devise/mailer/invite_private_user.html.erb +++ b/decidim-core/app/views/devise/mailer/invite_member.html.erb @@ -2,9 +2,9 @@ diff --git a/decidim-core/app/views/devise/mailer/invite_private_user.text.erb b/decidim-core/app/views/devise/mailer/invite_member.text.erb similarity index 68% rename from decidim-core/app/views/devise/mailer/invite_private_user.text.erb rename to decidim-core/app/views/devise/mailer/invite_member.text.erb index 8f572859214a1..aeab6dba9d470 100644 --- a/decidim-core/app/views/devise/mailer/invite_private_user.text.erb +++ b/decidim-core/app/views/devise/mailer/invite_member.text.erb @@ -1,9 +1,9 @@ <%= t("devise.mailer.invitation_instructions.hello", email: @resource.name) %> <% if @resource.invited_by.present? %> -<%= t("devise.mailer.invitation_instructions.invited_you_as_private_user", invited_by: @resource.invited_by.name, application: organization_name(@resource.organization)) %> +<%= t("devise.mailer.invitation_instructions.invited_you_as_member", invited_by: @resource.invited_by.name, application: organization_name(@resource.organization)) %> <% else %> -<%= t("devise.mailer.invitation_instructions.someone_invited_you_as_private_user", application: organization_name(@resource.organization)) %> +<%= t("devise.mailer.invitation_instructions.someone_invited_you_as_member", application: organization_name(@resource.organization)) %> <% end %> <%= accept_invitation_url(@resource, invitation_token: @token, host: @resource.organization.host) %> diff --git a/decidim-core/config/locales/ca-IT.yml b/decidim-core/config/locales/ca-IT.yml index 0a86bd17e725e..ac5e3960be53b 100644 --- a/decidim-core/config/locales/ca-IT.yml +++ b/decidim-core/config/locales/ca-IT.yml @@ -1291,8 +1291,10 @@ ca-IT: action_error: S'ha produït un error en actualitzar la configuració de les notificacions. no_notifications: Encara no hi ha notificacions. show: + deleted: El contingut ha estat eliminat per l'autora. missing_event: Vaja, aquesta notificació pertany a un element que ja no està disponible. Pots descartar-la. moderated: El contingut s'ha amagat correctament a través de la moderació. + not_available: Vaja, aquesta notificació pertany a un element que ja no està disponible. Pots descartar-la. notifications_digest_mailer: header: daily: Resum diari de notificacions diff --git a/decidim-core/config/locales/ca.yml b/decidim-core/config/locales/ca.yml index 74e0bb8631177..31d0d886544ea 100644 --- a/decidim-core/config/locales/ca.yml +++ b/decidim-core/config/locales/ca.yml @@ -1291,8 +1291,10 @@ ca: action_error: S'ha produït un error en actualitzar la configuració de les notificacions. no_notifications: Encara no hi ha notificacions. show: + deleted: El contingut ha estat eliminat per l'autora. missing_event: Vaja, aquesta notificació pertany a un element que ja no està disponible. Pots descartar-la. moderated: El contingut s'ha amagat correctament a través de la moderació. + not_available: Vaja, aquesta notificació pertany a un element que ja no està disponible. Pots descartar-la. notifications_digest_mailer: header: daily: Resum diari de notificacions diff --git a/decidim-core/config/locales/cs.yml b/decidim-core/config/locales/cs.yml index cae9d70d597ec..21bcbc147e5f3 100644 --- a/decidim-core/config/locales/cs.yml +++ b/decidim-core/config/locales/cs.yml @@ -1721,6 +1721,16 @@ cs: instructions_1: Pokud máte přístup k tomuto e-mailovému účtu, zkontrolujte prosím svou doručenou poštu pro pokyny. notify_deprecation_to_owner: greeting: Dobrý den %{name}, + set_password: Nastavte své heslo + subject: Důležitá aktualizace vašeho profilu skupiny + notify_user_group_patched: + body_3_html: Abychom mohli pokračovat v přístupu k vašemu účtu a sdílet přístup, potřebujeme, abyste si nastavili nový e-mail a heslo. Vezměte prosím na vědomí, že každý člen vaší skupiny obdrží tento e-mail. Chcete-li zajistit trvalý přístup, musíte se interně dohodnout na sdílených přihlašovacích údajích (e-mail a heslo), které budou všichni používat. + greeting: Dobrý den %{name}, + instructions_1_html: 'K přihlášení použijte následující dočasné přihlašovací údaje:

Uživatelské jméno: %{email}
heslo: %{password}

' + instructions_2_html: Nastavte novou e-mailovou adresu a heslo. + instructions_3_html: Sdílejte vybrané přihlašovací údaje s vašimi kolegy, aby celá skupina mohla pokračovat v přístupu k účtu. + instructions_title_html: "Co potřebujete udělat" + subject: Důležitá aktualizace vašeho profilu skupiny user_report_mailer: notify: body_1: Uživatel %{user} byl nahlášen od %{token} diff --git a/decidim-core/config/locales/en.yml b/decidim-core/config/locales/en.yml index 22a5736e3bed1..ff435fea47134 100644 --- a/decidim-core/config/locales/en.yml +++ b/decidim-core/config/locales/en.yml @@ -267,6 +267,10 @@ en: results: results impersonation_log: manage: "%{user_name} has managed %{resource_name} because %{reason}" + member: + create: "%{user_name} invited %{resource_name} to be a member" + create_via_csv: "%{user_name} invited %{resource_name} via CSV to be a member" + delete: "%{user_name} removed the participant %{resource_name} as a member" moderation: bulk_hide: "%{user_name} hid %{reported_count} resources" bulk_unhide: "%{user_name} unhid %{reported_count} resources" @@ -285,10 +289,6 @@ en: organization: update: "%{user_name} updated the organization settings" update_external_domain: "%{user_name} updated the organization external domains" - participatory_space_private_user: - create: "%{user_name} invited %{resource_name} to be a private participant" - create_via_csv: "%{user_name} invited %{resource_name} via CSV to be a private participant" - delete: "%{user_name} removed the participant %{resource_name} as a private participant" scope: create: "%{user_name} created the %{resource_name} scope" create_with_parent: "%{user_name} created the %{resource_name} scope inside the %{parent_scope} scope" @@ -774,13 +774,13 @@ en: id: The unique identifier of this notification resource_type: The type of the resource that the notification is related to updated_at: The date and time when the notification was last updated - participatory_space_private_users: - created_at: The date and time when this private user was created - id: The unique identifier of this private user - privatable_to: To which space this private user belongs - published: Wether this private user is published or not - role: The role that this private user has - updated_at: The date and time when this private user was last updated + participatory_space_members: + created_at: The date and time when this member was created + id: The unique identifier of this member + privatable_to: To which space this member belongs + published: Wether this member is published or not + role: The role that this member has + updated_at: The date and time when this member was last updated reports: created_at: The date and time when this report was created details: The details of this report @@ -1847,16 +1847,16 @@ en: If you do not want to accept the invitation, please ignore this email.
Your account will not be created until you access the link above and set your nickname and password. invited_you_as_admin: "%{invited_by} has invited you as an admin of %{application}. You can accept it through the link below." - invited_you_as_private_user: "%{invited_by} has invited you as a private participant of %{application}. You can accept it through the link below." + invited_you_as_member: "%{invited_by} has invited you as a member of %{application}. You can accept it through the link below." someone_invited_you: Someone has invited you to %{application}. You can accept it through the link below. someone_invited_you_as_admin: Someone has invited you as an admin of %{application}, you can accept it through the link below. - someone_invited_you_as_private_user: Someone has invited you as private participant of %{application}, you can accept it through the link below. + someone_invited_you_as_member: Someone has invited you as member of %{application}, you can accept it through the link below. subject: Invitation instructions invite_admin: subject: You have been invited to manage %{organization} invite_collaborator: subject: You have been invited to collaborate on %{organization} - invite_private_user: + invite_member: subject: You have been invited to a private participatory process on %{organization} organization_admin_invitation_instructions: subject: You have been invited to manage %{organization} diff --git a/decidim-core/config/locales/es-MX.yml b/decidim-core/config/locales/es-MX.yml index ea65d73fd35ba..cefa402feb4ab 100644 --- a/decidim-core/config/locales/es-MX.yml +++ b/decidim-core/config/locales/es-MX.yml @@ -1294,8 +1294,10 @@ es-MX: action_error: Se ha producido un error al actualizar la configuración de las notificaciones. no_notifications: No hay notificaciones aún. show: + deleted: El contenido ha sido eliminado por la autora. missing_event: Vaya, esta notificación pertenece a un elemento que ya no está disponible. Puedes descartarla. moderated: El contenido se ha ocultado mediante la moderación. + not_available: Vaya, esta notificación pertenece a un elemento que ya no está disponible. Puedes descartarla. notifications_digest_mailer: header: daily: Resumen diario de notificaciones diff --git a/decidim-core/config/locales/es-PY.yml b/decidim-core/config/locales/es-PY.yml index 1f0e6651ded69..ee62da8a9c0e9 100644 --- a/decidim-core/config/locales/es-PY.yml +++ b/decidim-core/config/locales/es-PY.yml @@ -1294,8 +1294,10 @@ es-PY: action_error: Se ha producido un error al actualizar la configuración de las notificaciones. no_notifications: No hay notificaciones aún. show: + deleted: El contenido ha sido eliminado por la autora. missing_event: Vaya, esta notificación pertenece a un elemento que ya no está disponible. Puedes descartarla. moderated: El contenido se ha ocultado mediante la moderación. + not_available: Vaya, esta notificación pertenece a un elemento que ya no está disponible. Puedes descartarla. notifications_digest_mailer: header: daily: Resumen diario de notificaciones diff --git a/decidim-core/config/locales/es.yml b/decidim-core/config/locales/es.yml index 61c55696f6fae..cbc6fa42c3acd 100644 --- a/decidim-core/config/locales/es.yml +++ b/decidim-core/config/locales/es.yml @@ -1291,8 +1291,10 @@ es: action_error: Se ha producido un error al actualizar la configuración de las notificaciones. no_notifications: Aún no hay notificaciones. show: + deleted: El contenido ha sido eliminado por la autora. missing_event: Vaya, esta notificación pertenece a un elemento que ya no está disponible. Puedes descartarla. moderated: El contenido se ha ocultado mediante la moderación. + not_available: Vaya, esta notificación pertenece a un elemento que ya no está disponible. Puedes descartarla. notifications_digest_mailer: header: daily: Resumen diario de notificaciones diff --git a/decidim-core/config/locales/eu.yml b/decidim-core/config/locales/eu.yml index 46876a3a63f13..4fdad1a1492f7 100644 --- a/decidim-core/config/locales/eu.yml +++ b/decidim-core/config/locales/eu.yml @@ -383,11 +383,10 @@ eu: help_text: Berrikusi aldaketak eta onartu edo ukatu zuzenketa hau. Jakinarazpen bat bidaliko zaio/e bere egileari/ei. announcement: accepted: |- - For the Aldaketa hori %{amendable_type} %{amendable_link} izan da - onartu %{announcement_date}. + %{amendable_type} %{proposal_link} erako zuzenketa hau %{date}ean onartu da. evaluating: |- - For the Aldaketa hori %{amendable_type} %{amendable_link} - ebaluatu dago. + Aldaketa hau %{amendable_type} %{proposal_link} rako + ebaluatzen ari da. promoted: Sustatu hau %{amendable_type}. rejected: "%{amendable_type} %{amendable_link} en zuzenketa\nukatu da %{announcement_date}." withdrawn: |- @@ -876,7 +875,7 @@ eu: email_subject: Onartu da %{emendation_author_nickname} ren %{amendable_title} zuzenketa notification_title: '%{emendation_author_nickname} egileak sortutako zuzenketa onartu da honetarako: %{amendable_title}.' follower: - email_intro: 'Zuzenketa bat bertan bera onartu %{amendable_title}. Orrialde hau ikusi dezakezu:' + email_intro: '%{amendable_title} rako zuzenketa bat onartu da. Orrialde honetan ikus dezakezue:' email_outro: Jakinarazpen hau jaso duzu %{amendable_title} jarraitzen duzulako. Aurreko estekan sartu jakinarazpenak jasotzeari utzi nahi badiozu. email_subject: '%{emendation_author_nickname} ren aldaketa onartuta honetarako: %{amendable_title}' notification_title: '%{emendation_author_nickname} k sortutako zuzenketa onartu da honetarako: %{amendable_title}.' @@ -1053,7 +1052,7 @@ eu: message_2: Irudi honetarako gomendatzen den tamaina da 512x512. image: explanation: 'Jarraibideak irudirako:' - message_1: Ahal dela, testurik gabeko irudi etzana. + message_1: Testurik ez duen irudi etzan bat, ahal dela. message_2: Zerbitzuak irudia moztu egiten du. file_validation: allowed_file_extensions: 'Onartutako artxiboen formatua: %{extensions}' @@ -1292,8 +1291,10 @@ eu: action_error: Arazo bat egon da jakinarazpenaren konfigurazioa eguneratzean. no_notifications: Oraindik ez dago jakinarazpenik. show: + deleted: Egileak edukia ezabatu egin du. missing_event: Aiba, jakinarazpen hau dagoeneko eskuragarri ez dagoen item batena da. Baztertu dezakezu. moderated: Edukia ezkutatu egin da, moderazioaren bidez. + not_available: Hara, jakinarazpen hau dagoeneko erabilgarri ez dagoen elementu bati dagokio. Alde batera utz dezakezu. notifications_digest_mailer: header: daily: Jakinarazpenen eguneroko laburpena @@ -1470,7 +1471,7 @@ eu: footer_sub_hero: footer_sub_hero_body_html: Eraiki dezagun gizarte irekiago, gardenago eta kolaboratiboagoa
Lotu, hartu parte eta erabaki. footer_sub_hero_headline: Ongi etorri %{organization} erakundearen partaidetzarako plataformara. - register: Sortu kontu bat + register: Kontu bat sortu hero: participate: Hartu parte participate_title: Parte hartu plataformako prozesuetan @@ -1606,7 +1607,7 @@ eu: term_input_placeholder: Bilatu searches: filters: - jump_to: 'Salto egin:' + jump_to: 'Hona jo:' resource: "%{label} %{collection} artean" search: Bilatu state: @@ -1641,7 +1642,7 @@ eu: help: "Bilaketa-baldintzak aldatzen direnean, beheko inprimakiak dinamikoki iragazten ditu bilaketaren\nemaitzak." skip: Joan emaitzetara flag_modal: - already_reported: Eduki hau jada salatuta dago, eta administratzaile batek berrikusiko du. + already_reported: Eduki horren berri ematen da dagoeneko, eta administrazio batek berrikusiko du. close: Itxi description: Eduki hau desegokia da? does_not_belong: Bertan badago legez kontrako jardunik, suizidio-mehatxurik, informazio pertsonalik edo beste zernahi, zure ustez %{organization_name}-ri ez dagokionik. @@ -1650,7 +1651,7 @@ eu: offensive: Bertan badago arrazakeriarik, sexismorik, irainik, eraso pertsonalik, heriotza-mehatxurik, suizidio-eskaerarik edo beste edozein eratako gorroto-diskurtsorik. reason: Arrazoia report: Salatu - spam: Bertan badago clickbait-ik, publizitaterik edo iruzurrik. + spam: Clickbait, publizitatea, iruzurrak edo script bot-ak ditu. title: Salatu eduki desegokia flag_user_modal: already_reported: Eduki hau jada salatuta dago, eta administratzaile batek berrikusiko du. @@ -1780,7 +1781,7 @@ eu: version_index: '%{total} en %{index} bertsioa' welcome_notification: default_body:

Kaixo {{name}}, eskerrik asko {{organization}}-era lotzeagatik eta ongi etorria!

  • Hemen zer egin dezakezun ideia azkar bat nahi baduzu, begiratu Laguntza atalean.
  • Behin irakurri ondoren, zure lehen garaikurra jasoko duzu. Hemen da garaikur guztien zerrenda zuk lor ditzakezunak {{organization}} ean parte hartuz.
  • Azkenik, lotu beste pertsona batzuei eta partekatu haiekin inplikatzearen eta {{organization}} ean parte hartzearen esperientzia. Egin proposamenak, iruzkinak, eztabaidak, pentsatu nola lagundu denon onerako, eman argudioak konbentzitzeko, entzun eta irakurri zeure burua konbentzitzeko, adierazi zure ideiak modu zehatzean, erantzun pazientziaz eta erabakiz, defendatu zure ideiak eta mantendu burua zabalik, beste pertsona batzuen ideietan lankidetzan aritzeko.
- default_subject: Eskerrik asko sartzeko {{organization}}! + default_subject: Thanks for joining {{organization}}! wizard_step_form: wizard_aside: back: Atzera @@ -1791,9 +1792,9 @@ eu: confirmations: confirmed: Zure helbide elektronikoa ondo egiaztatu da. new: - resend_confirmation_instructions: Berrikusi berrespen-argibideak + resend_confirmation_instructions: Berrespen-jarraibideak birbidali send_instructions: Jarraibideak azaltzen dituen mezu elektroniko bat jasoko duzu, zure helbide elektronikoa berretsi ahal izateko minutu gutxi barru. - send_paranoid_instructions: Zure helbide elektronikoa gure datu-basean badago, zure helbide elektronikoa berretsi ahal izateko gutxienez minutu bat jasoko duzu. + send_paranoid_instructions: Zure helbide elektronikoa gure datu-basean badago, mezu elektroniko bat jasoko duzu zure helbide elektronikoa minutu gutxi barru baieztatzeko jarraibideekin. failure: already_authenticated: Saioa hasi duzu. inactive: Zure kontua ez dago oraindik aktibatuta. @@ -1816,7 +1817,7 @@ eu: header: Bidali gonbidapena submit_button: Bidali gonbidapena no_invitations_remaining: Ez da gonbidapenik geratzen - send_instructions: Gonbidapen mezu bat bidali da %{email}. + send_instructions: Gonbidapen-mezu elektroniko bat %{email} helbidera bidali da. updated: Pasahitza ondo ezarri da. Saioa hasi duzu. updated_not_active: Pasahitza ondo ezarri da. mailer: @@ -2163,13 +2164,13 @@ eu: user_profile: account: Kontua authorizations: Baimenak - delete_my_account: Ezabatu nire kontua + delete_my_account: Nire kontua ezabatu my_data: Nire datuak notifications_settings: Jakinarazpen-konfigurazioa profile: Profila title: Parte-hartzailearen ezarpenak locale: - name: Euskara + name: Ingelesa name_with_error: Euskera (akatsa!) number: currency: @@ -2178,7 +2179,7 @@ eu: format_html: "%u %n" password_validator: denied: ukatu egin da - domain_included_in_password: domeinu izen honen antzekoa da + domain_included_in_password: domeinu-izen horren oso antzekoa da email_included_in_password: zure posta elektronikoaren antzekoa da fallback: ez da zuzena name_included_in_password: zure izenaren antzekoa da diff --git a/decidim-core/config/locales/fa-IR.yml b/decidim-core/config/locales/fa-IR.yml index 88215f82cbd5d..74e6c76216548 100644 --- a/decidim-core/config/locales/fa-IR.yml +++ b/decidim-core/config/locales/fa-IR.yml @@ -1 +1,3 @@ fa: + locale: + name: فارسی diff --git a/decidim-core/config/locales/fi-plain.yml b/decidim-core/config/locales/fi-plain.yml index a0c9e7990d578..c1eb13f63ddce 100644 --- a/decidim-core/config/locales/fi-plain.yml +++ b/decidim-core/config/locales/fi-plain.yml @@ -760,6 +760,7 @@ fi-pl: created_at: Keskustelun luontiaika id: Keskustelun yksilöivä tunniste messages: Keskustelun viestit + updated_at: Keskustelun viimeisimmän päivityksen ajankohta notifications: created_at: Ilmoituksen luontiaika event_class: Tapahtuman tyyppi, jota ilmoitus koskee @@ -784,6 +785,7 @@ fi-pl: reason: Ilmoituksen syy updated_at: Ilmoituksen viimeisimmän päivityksen ajankohta users: + about: Osallistujan profiilikuvaus accepted_tos_version: Käyttäjän palvelun käyttöehtojen hyväksymisaika admin: Onko tämä käyttäjä hallintakäyttäjä confirmation_sent_at: Käyttäjätilin vahvistusviestin lähetysaika @@ -794,6 +796,7 @@ fi-pl: delete_reason: Käyttäjätilin poistamisen syy deleted_at: Käyttäjätilin poistamisen ajankohta email: Käyttäjätilin sähköpostiosoite + followers_count: Käyttäjää seuraavien osallistujien lukumäärä following_count: Käyttäjän seuraamien osallistujien lukumäärä id: Käyttäjätilin yksilöivä tunniste invitation_accepted_at: Käyttäjätilin kutsun hyväksymisen ajankohta @@ -801,12 +804,16 @@ fi-pl: invitation_sent_at: Käyttäjän kutsun lähetyksen ajankohta invitations_count: Käyttäjälle lähetettyjen kutsujen lukumäärä invited_by: Kutsun lähettänyt käyttäjä + last_sign_in_at: Edellisen kirjautumisen ajankohta + last_sign_in_ip: Edellisen kirjautumisen IP-osoite locale: Käyttäjän valitsema kieliasetus managed: Onko tämä käyttäjätili toisen käyttäjän hallinnoima name: Käyttäjän nimi + newsletter_notifications_at: Uutiskirjeiden tilauksen ajankohta nickname: Käyttäjän nimimerkki notification_settings: Käyttäjän määrittämät ilmoitusten asetukset notifications_sending_frequency: Käyttäjän vastaanottamien ilmoitusten tiheys + officialized_as: Virallistamisen nimi (valtuutettu, kunnanjohtaja, pormestari, jne.) officialized_at: Käyttäjän virallistamisen ajankohta organization: Käyttäjän organisaatio password_updated_at: Salasanan viimeisimmän päivityksen ajankohta @@ -844,6 +851,7 @@ fi-pl: files: file_cannot_be_processed: Tiedostoa ei voida käsitellä file_resolution_too_large: Tiedoston resoluutio on liian suuri + not_inside_organization: Tiedostoa ei ole liitetty mihinkään organisaatioon. internal_server_error: copied: Teksti kopioitu! copy_error_message_clarification: Kopioi virheviesti leikepöydälle @@ -1039,6 +1047,7 @@ fi-pl: file: explanation: 'Tiedoston ohjeistus:' message_1: On oltava kuva tai asiakirja. + message_2: Mikäli lataat kuvan, käytä mieluiten vaakasuuntaista kuvaa, joka ei sisällä ole tekstiä. Palvelu rajaa kuvaa. icon: explanation: 'Ikonin ohjeistus:' message_1: On oltava neliömuotoinen kuva. @@ -1281,9 +1290,13 @@ fi-pl: same_language: Sisältö on lisätty toivomallasi kielellä (%{language}), minkä takia tässä viestissä ei näytetä automaattista käännöstä. translated_text: 'Automaattisesti käännetty teksti:' notifications: + action_error: Ilmoitusasetusten päivittäminen epäonnistui. no_notifications: Ei vielä ilmoituksia. show: + deleted: Sisällön laatija on poistanut tämän sisällön. missing_event: Hups, tämä ilmoitus kuuluu kohteeseen, joka ei ole enää käytettävissä. Voit merkitä sen luetuksi. + moderated: Moderoija on piilottanut tämän sisällön. + not_available: Tämä ilmoitus kuuluu kohteeseen, joka ei ole enää käytettävissä. Voit merkitä sen luetuksi. notifications_digest_mailer: header: daily: Päivittäinen ilmoitusyhteenveto @@ -1292,6 +1305,7 @@ fi-pl: hello: Hei %{name}, intro: daily: 'Tässä on lista edellisen päivän ilmoituksista niistä asioista, joita seuraat:' + real_time: 'Tämä on ilmoitus seuraamistasi alustalla tapahtuneista asioista:' weekly: 'Tässä on lista edellisen viikon ilmoituksista niistä asioista, joita seuraat:' outro: Tämä ilmoitus on lähetetty sinulle, koska seuraat ilmoitukseen liittyviä sisältöjä tai niiden tekijöitä. Voit lopettaa seuraamisen näiden sisältöjen sivuilta. see_more: Näytä lisää ilmoituksia @@ -1434,7 +1448,9 @@ fi-pl: title: Miten nämä tiedostot avataan ja miten niiden kanssa työskennellään? license: body_1_html: '%{organization_name} -palvelun tietokanta on lisensoitu %{link_database} -lisenssillä. Kaikki tietokannan sisällöt on lisensoitu %{link_contents} -lisenssillä.' + license_contents_link: https://opendatacommons.org/licenses/dbcl/1.0/ license_contents_name: Tietokannan sisällön lisenssi + license_database_link: https://opendatacommons.org/licenses/odbl/1.0/ license_database_name: Avoimien tietojen lisenssi title: Lisenssi title: Avoin data @@ -1457,6 +1473,7 @@ fi-pl: footer_sub_hero: footer_sub_hero_body_html: Rakennetaan entistä avoimempi, läpinäkyvämpi ja yhteistyökykyisempi yhteiskunta.
Liity, osallistu ja päätä. footer_sub_hero_headline: Tervetuloa yhteisölliseen osallisuuspalveluun %{organization}. + register: Luo tili hero: participate: Osallistu participate_title: Osallistu alustan prosesseihin @@ -1464,6 +1481,7 @@ fi-pl: statistics: headline: Tilastot sub_hero: + register: Luo tili register_title: Luo tili index: standalone_pages: Sivut @@ -2113,6 +2131,8 @@ fi-pl: expire_time_html: Istuntosi vanhenee %{minutes} minuutin kuluttua. language_chooser: choose_language: Valitse kieli + navigation: + aria_label: 'Navigaatiovalikko: %{title}' notifications_dashboard: mark_all_as_read: Merkitse kaikki luetuiksi mark_as_read: Merkitse luetuiksi diff --git a/decidim-core/config/locales/fi.yml b/decidim-core/config/locales/fi.yml index e5a87fc68225f..7fcb93aaee6dc 100644 --- a/decidim-core/config/locales/fi.yml +++ b/decidim-core/config/locales/fi.yml @@ -760,6 +760,7 @@ fi: created_at: Keskustelun luontiaika id: Keskustelun yksilöivä tunniste messages: Keskustelun viestit + updated_at: Keskustelun viimeisimmän päivityksen ajankohta notifications: created_at: Ilmoituksen luontiaika event_class: Tapahtuman tyyppi, jota ilmoitus koskee @@ -784,6 +785,7 @@ fi: reason: Ilmoituksen syy updated_at: Ilmoituksen viimeisimmän päivityksen ajankohta users: + about: Osallistujan profiilikuvaus accepted_tos_version: Käyttäjän palvelun käyttöehtojen hyväksymisaika admin: Onko tämä käyttäjä hallintakäyttäjä confirmation_sent_at: Käyttäjätilin vahvistusviestin lähetysaika @@ -794,6 +796,7 @@ fi: delete_reason: Käyttäjätilin poistamisen syy deleted_at: Käyttäjätilin poistamisen ajankohta email: Käyttäjätilin sähköpostiosoite + followers_count: Käyttäjää seuraavien osallistujien lukumäärä following_count: Käyttäjän seuraamien osallistujien lukumäärä id: Käyttäjätilin yksilöivä tunniste invitation_accepted_at: Käyttäjätilin kutsun hyväksymisen ajankohta @@ -801,12 +804,16 @@ fi: invitation_sent_at: Käyttäjän kutsun lähetyksen ajankohta invitations_count: Käyttäjälle lähetettyjen kutsujen lukumäärä invited_by: Kutsun lähettänyt käyttäjä + last_sign_in_at: Edellisen kirjautumisen ajankohta + last_sign_in_ip: Edellisen kirjautumisen IP-osoite locale: Käyttäjän valitsema kieliasetus managed: Onko tämä käyttäjätili toisen käyttäjän hallinnoima name: Käyttäjän nimi + newsletter_notifications_at: Uutiskirjeiden tilauksen ajankohta nickname: Käyttäjän nimimerkki notification_settings: Käyttäjän määrittämät ilmoitusten asetukset notifications_sending_frequency: Käyttäjän vastaanottamien ilmoitusten tiheys + officialized_as: Virallistamisen nimi (valtuutettu, kunnanjohtaja, pormestari, jne.) officialized_at: Käyttäjän virallistamisen ajankohta organization: Käyttäjän organisaatio password_updated_at: Salasanan viimeisimmän päivityksen ajankohta @@ -844,6 +851,7 @@ fi: files: file_cannot_be_processed: Tiedostoa ei voida käsitellä file_resolution_too_large: Tiedoston resoluutio on liian suuri + not_inside_organization: Tiedostoa ei ole liitetty mihinkään organisaatioon. internal_server_error: copied: Teksti kopioitu! copy_error_message_clarification: Kopioi virheviesti leikepöydälle @@ -1039,6 +1047,7 @@ fi: file: explanation: 'Tiedoston ohjeistus:' message_1: On oltava kuva tai asiakirja. + message_2: Mikäli lataat kuvan, käytä mieluiten vaakasuuntaista kuvaa, joka ei sisällä ole tekstiä. Palvelu rajaa kuvaa. icon: explanation: 'Kuvakkeen ohjeistus:' message_1: On oltava neliömuotoinen kuva. @@ -1281,9 +1290,13 @@ fi: same_language: Sisältö on lisätty toivomallasi kielellä (%{language}), minkä takia tässä viestissä ei näytetä automaattista käännöstä. translated_text: 'Automaattisesti käännetty teksti:' notifications: + action_error: Ilmoitusasetusten päivittäminen epäonnistui. no_notifications: Ei vielä ilmoituksia. show: + deleted: Sisällön laatija on poistanut tämän sisällön. missing_event: Hups, tämä ilmoitus kuuluu kohteeseen, joka ei ole enää käytettävissä. Voit merkitä sen luetuksi. + moderated: Moderoija on piilottanut tämän sisällön. + not_available: Tämä ilmoitus kuuluu kohteeseen, joka ei ole enää käytettävissä. Voit merkitä sen luetuksi. notifications_digest_mailer: header: daily: Päivittäinen ilmoitusyhteenveto @@ -1292,6 +1305,7 @@ fi: hello: Hei %{name}, intro: daily: 'Tässä on lista edellisen päivän ilmoituksista niistä asioista, joita seuraat:' + real_time: 'Tämä on ilmoitus seuraamistasi alustalla tapahtuneista asioista:' weekly: 'Tässä on lista edellisen viikon ilmoituksista niistä asioista, joita seuraat:' outro: Tämä ilmoitus on lähetetty sinulle, koska seuraat ilmoitukseen liittyviä sisältöjä tai niiden tekijöitä. Voit lopettaa seuraamisen näiden sisältöjen sivuilta. see_more: Näytä lisää ilmoituksia @@ -1434,7 +1448,9 @@ fi: title: Miten nämä tiedostot avataan ja miten niiden kanssa työskennellään? license: body_1_html: '%{organization_name} -palvelun tietokanta on lisensoitu %{link_database} -lisenssillä. Kaikki tietokannan sisällöt on lisensoitu %{link_contents} -lisenssillä.' + license_contents_link: https://opendatacommons.org/licenses/dbcl/1.0/ license_contents_name: Tietokannan sisällön lisenssi + license_database_link: https://opendatacommons.org/licenses/odbl/1.0/ license_database_name: Avoimien tietojen lisenssi title: Lisenssi title: Avoin data @@ -1457,6 +1473,7 @@ fi: footer_sub_hero: footer_sub_hero_body_html: Rakennetaan entistä avoimempi, läpinäkyvämpi ja yhteistyökykyisempi yhteiskunta.
Liity, osallistu ja päätä. footer_sub_hero_headline: Tervetuloa yhteisölliseen osallistumispalveluun %{organization}. + register: Luo tili hero: participate: Osallistu participate_title: Osallistu prosesseihin alustalla @@ -1464,6 +1481,7 @@ fi: statistics: headline: Tilastot sub_hero: + register: Luo tili register_title: Luo tili index: standalone_pages: Sivut @@ -2116,6 +2134,8 @@ fi: expire_time_html: Istuntosi vanhenee %{minutes} minuutin kuluttua. language_chooser: choose_language: Valitse kieli + navigation: + aria_label: 'Navigaatiovalikko: %{title}' notifications_dashboard: mark_all_as_read: Merkitse kaikki luetuiksi mark_as_read: Merkitse luetuiksi diff --git a/decidim-core/config/locales/fr-CA.yml b/decidim-core/config/locales/fr-CA.yml index 1009ef1600cec..cc9a41afa6466 100644 --- a/decidim-core/config/locales/fr-CA.yml +++ b/decidim-core/config/locales/fr-CA.yml @@ -1169,8 +1169,10 @@ fr-CA: action_error: Un problème est survenu lors de la mise à jour des paramètres de notification. no_notifications: Il n'y a pas encore de notifications. show: + deleted: Le contenu a été supprimé par l'auteur. missing_event: Oups, cette notification est liée à un élément qui n'est plus disponible. Vous pouvez la supprimer. moderated: Le contenu a été masqué par modération. + not_available: Oups, cette notification est liée à un élément qui n'est plus disponible. Vous pouvez la supprimer. notifications_digest_mailer: header: daily: Résumé quotidien des notifications diff --git a/decidim-core/config/locales/fr.yml b/decidim-core/config/locales/fr.yml index 50759fa79a744..e6539e3c563d4 100644 --- a/decidim-core/config/locales/fr.yml +++ b/decidim-core/config/locales/fr.yml @@ -1169,8 +1169,10 @@ fr: action_error: Un problème est survenu lors de la mise à jour des paramètres de notification. no_notifications: Il n'y a pas encore de notifications. show: + deleted: Le contenu a été supprimé par l'auteur. missing_event: Oups, cette notification est liée à un élément qui n'est plus disponible. Vous pouvez la supprimer. moderated: Le contenu a été masqué par modération. + not_available: Oups, cette notification est liée à un élément qui n'est plus disponible. Vous pouvez la supprimer. notifications_digest_mailer: header: daily: Résumé quotidien des notifications diff --git a/decidim-core/config/locales/ja.yml b/decidim-core/config/locales/ja.yml index 958033364a424..0daeb2cdb32dd 100644 --- a/decidim-core/config/locales/ja.yml +++ b/decidim-core/config/locales/ja.yml @@ -742,6 +742,7 @@ ja: created_at: この会話の作成日時 id: この会話の固有ID messages: この会話のメッセージ + updated_at: この会話の最終更新日時 notifications: created_at: 通知の作成日時 event_class: 通知をトリガーしたイベントのクラス @@ -766,6 +767,7 @@ ja: reason: レポートの理由 updated_at: レポートの最終更新日時 users: + about: この参加者のプロフィール説明 accepted_tos_version: プラットフォームの利用規約がユーザーに承諾された日時 admin: このユーザーが管理者であるかどうか confirmation_sent_at: 確認の送信日時 @@ -776,6 +778,7 @@ ja: delete_reason: このユーザーの削除理由 deleted_at: このユーザーの削除日時 email: このユーザーのメールアドレス + followers_count: このユーザーをフォローしている参加者数 following_count: このユーザーがフォローしている参加者数 id: ユーザーの固有ID invitation_accepted_at: 招待の承認日時 @@ -783,12 +786,16 @@ ja: invitation_sent_at: 招待の送信日時 invitations_count: このユーザーに送信された招待の数 invited_by: このユーザーを招待したユーザー + last_sign_in_at: 前回のログイン日時 + last_sign_in_ip: 前回ログイン時のIPアドレス locale: このユーザーが選択したロケール(言語) managed: このユーザーが別のユーザーによって管理されているかどうか name: ユーザー名 + newsletter_notifications_at: この参加者のニュースレター購読登録日時 nickname: ユーザーのニックネーム notification_settings: このユーザーが選択した通知設定 notifications_sending_frequency: このユーザーが受け取る通知の頻度 + officialized_as: 役職の説明(市長、局長など) officialized_at: このユーザーの公式化日時 organization: このユーザーが所属する組織 password_updated_at: パスワードの最終更新日時 @@ -826,6 +833,7 @@ ja: files: file_cannot_be_processed: ファイルを処理できません file_resolution_too_large: ファイルの解像度が大きすぎます + not_inside_organization: ファイルはどの組織にも紐ついていません。 internal_server_error: copied: テキストをコピーしました copy_error_message_clarification: エラーメッセージをクリップボードにコピー @@ -1022,6 +1030,7 @@ ja: file: explanation: 'ファイルのガイダンス:' message_1: 画像またはドキュメントである必要があります。 + message_2: 画像を使用する場合は、テキストが含まれない横長の画像が最適です。プラットフォーム側で画像をトリミングします。 icon: explanation: 'アイコンのガイド:' message_1: 正方形の画像でなければなりません。 @@ -1263,9 +1272,11 @@ ja: same_language: コンテンツはあなたの設定された言語(%{language}) で投稿されました。このため、このメールには自動翻訳は表示されません。 translated_text: '自動翻訳されたテキスト:' notifications: + action_error: 通知設定の更新に問題が発生しました。 no_notifications: まだ通知はありません。 show: missing_event: この通知は利用できなくなった要素に属しています。破棄してください。 + moderated: コンテンツはモデレーションにより非表示になっています。 notifications_digest_mailer: header: daily: 日次の通知のダイジェスト @@ -1274,6 +1285,7 @@ ja: hello: こんにちは %{name} さん、 intro: daily: 'あなたがフォローしているアクティビティに基づいた最終日以降の通知です:' + real_time: 'これはあなたがフォローしている活動に関する通知です:' weekly: 'あなたがフォローしているアクティビティに基づいた先週以降の通知です:' outro: このコンテンツまたは著者をフォローしているため、これらの通知を受け取りました。それぞれのページからフォローを解除できます。 see_more: 他の通知を見る @@ -1415,7 +1427,9 @@ ja: title: これらのファイルを開いて操作する方法 license: body_1_html: この %{organization_name} データベースは、 %{link_database} の下で利用可能になります。データベースの個々のコンテンツの権利は、 %{link_contents} の下でライセンスされます。 + license_contents_link: https://opendatacommons.org/licenses/dbcl/1.0/ license_contents_name: Database Contents License + license_database_link: https://opendatacommons.org/licenses/odbl/1.0/ license_database_name: Open Database License title: ライセンス title: オープンデータ @@ -1438,6 +1452,7 @@ ja: footer_sub_hero: footer_sub_hero_body_html: より開かれた透明性のあるコラボレーティブな社会を構築しましょう。
一緒に参加し、決定しましょう。 footer_sub_hero_headline: '%{organization} 参加型プラットフォームへようこそ。' + register: アカウントを作成 hero: participate: 参加 participate_title: プラットフォームのプロセスに参加する @@ -1445,6 +1460,7 @@ ja: statistics: headline: 統計情報 sub_hero: + register: アカウントを作成 register_title: アカウントを作成 index: standalone_pages: ページ @@ -2092,6 +2108,8 @@ ja: expire_time_html: このログインセッションは %{minutes} 分後に無効になります。。 language_chooser: choose_language: 言語を選択 + navigation: + aria_label: 'ナビゲーションメニュー: %{title}' notifications_dashboard: mark_all_as_read: すべての通知を削除する mark_as_read: 既読にする diff --git a/decidim-core/config/locales/ko.yml b/decidim-core/config/locales/ko.yml index 8a7b3b861deda..867fae663ef9a 100644 --- a/decidim-core/config/locales/ko.yml +++ b/decidim-core/config/locales/ko.yml @@ -1 +1,3 @@ ko: + locale: + name: 한국어 diff --git a/decidim-core/config/locales/mt.yml b/decidim-core/config/locales/mt.yml index f7aabc7149a9b..b5b1e3cbc583f 100644 --- a/decidim-core/config/locales/mt.yml +++ b/decidim-core/config/locales/mt.yml @@ -1 +1,3 @@ mt: + locale: + name: Malti diff --git a/decidim-core/config/locales/sv.yml b/decidim-core/config/locales/sv.yml index 5491a98940707..9ce3516a8d050 100644 --- a/decidim-core/config/locales/sv.yml +++ b/decidim-core/config/locales/sv.yml @@ -1212,8 +1212,10 @@ sv: action_error: Det gick inte att uppdatera inställningen för meddelanden. no_notifications: Inga meddelanden ännu. show: + deleted: Innehållet har tagits bort av författaren. missing_event: Hoppsan, det här meddelandet tillhör ett objekt som inte längre är tillgängligt. Du kan kasta det. moderated: Innehållet har dolts av moderator. + not_available: Hoppsan, det här meddelandet tillhör ett objekt som inte längre är tillgängligt. Du kan kasta det. notifications_digest_mailer: header: daily: Dagligt sammandrag diff --git a/decidim-core/config/locales/vi.yml b/decidim-core/config/locales/vi.yml index 326506f0b1a36..8ddf65e6d1d49 100644 --- a/decidim-core/config/locales/vi.yml +++ b/decidim-core/config/locales/vi.yml @@ -1 +1,3 @@ vi: + locale: + name: tiếng Việt diff --git a/decidim-core/db/data/20251125144141_add_short_name_to_organizations.rb b/decidim-core/db/data/20251125144141_add_short_name_to_organizations.rb new file mode 100644 index 0000000000000..44bf99471b8e4 --- /dev/null +++ b/decidim-core/db/data/20251125144141_add_short_name_to_organizations.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class AddShortNameToOrganizations < ActiveRecord::Migration[7.2] + class Organization < ApplicationRecord + self.table_name = "decidim_organizations" + end + + def up + Organization.find_each do |organization| + # Skip if short_name is already populated + next if organization.short_name.present? && organization.short_name != {} + + next if organization.name.blank? + + short_name_hash = {} + organization.name.each do |locale, name_value| + # Skip machine_translations and other nested hashes + next if name_value.is_a?(Hash) + next if name_value.blank? + + generated_short_name = name_value.gsub(/\s+/, "")[0, 12] + next if generated_short_name.length < 3 + + short_name_hash[locale] = generated_short_name + end + + # Only update if we have a valid short_name to set, otherwise leave as empty hash + organization.update_column(:short_name, short_name_hash) unless short_name_hash.empty? # rubocop:disable Rails/SkipsModelValidations + end + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/decidim-core/db/data/20251213075429_rename_members_in_action_log.rb b/decidim-core/db/data/20251213075429_rename_members_in_action_log.rb new file mode 100644 index 0000000000000..58481f44df6df --- /dev/null +++ b/decidim-core/db/data/20251213075429_rename_members_in_action_log.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class RenameMembersInActionLog < ActiveRecord::Migration[7.2] + class ActionLog < ApplicationRecord + self.table_name = :decidim_action_logs + end + + def up + old_resource_type = "Decidim::ParticipatorySpacePrivateUser" + new_resource_type = "Decidim::ParticipatorySpace::Member" + + # rubocop:disable Rails/SkipsModelValidations + updated_count = ActionLog.where(resource_type: old_resource_type).update_all(resource_type: new_resource_type) + # rubocop:enable Rails/SkipsModelValidations + + Rails.logger.info "Updated #{updated_count} ActionLog records from #{old_resource_type} to #{new_resource_type}" + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/decidim-core/db/migrate/20250523104311_move_cta_to_hero_content_block.rb b/decidim-core/db/migrate/20250523104311_move_cta_to_hero_content_block.rb index b89ddbcf5c202..fb569cc5d8898 100644 --- a/decidim-core/db/migrate/20250523104311_move_cta_to_hero_content_block.rb +++ b/decidim-core/db/migrate/20250523104311_move_cta_to_hero_content_block.rb @@ -8,7 +8,7 @@ class Organization < ApplicationRecord def up Decidim::ContentBlock.reset_column_information Organization.find_each do |organization| - content_block = Decidim::ContentBlock.find_by(organization: organization, scope_name: :homepage, manifest_name: :hero) + content_block = Decidim::ContentBlock.find_by(organization:, scope_name: :homepage, manifest_name: :hero) settings = {} cta_button_text = organization.cta_button_text || {} settings = cta_button_text.inject(settings) { |acc, (k, v)| acc.update("cta_button_text_#{k}" => v) } diff --git a/decidim-core/db/migrate/20250527122040_move_highlighted_content_banner_settings_to_content_block.rb b/decidim-core/db/migrate/20250527122040_move_highlighted_content_banner_settings_to_content_block.rb index 0d2e1796b84f2..59b29fb108365 100644 --- a/decidim-core/db/migrate/20250527122040_move_highlighted_content_banner_settings_to_content_block.rb +++ b/decidim-core/db/migrate/20250527122040_move_highlighted_content_banner_settings_to_content_block.rb @@ -11,7 +11,7 @@ def up Decidim::ContentBlock.reset_column_information Organization.find_each do |organization| - content_block = Decidim::ContentBlock.find_by(organization: organization, scope_name: :homepage, manifest_name: :highlighted_content_banner) + content_block = Decidim::ContentBlock.find_by(organization:, scope_name: :homepage, manifest_name: :highlighted_content_banner) settings = extract_settings(organization) # We need to do a workaround for getting the image, as ActiveStorage is polymorphic and expects that the `record_type` is the class name of the model diff --git a/decidim-core/db/migrate/20251031150928_add_short_name_to_organization.rb b/decidim-core/db/migrate/20251031150928_add_short_name_to_organization.rb new file mode 100644 index 0000000000000..3511b8bf74ebe --- /dev/null +++ b/decidim-core/db/migrate/20251031150928_add_short_name_to_organization.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddShortNameToOrganization < ActiveRecord::Migration[7.2] + def change + add_column :decidim_organizations, :short_name, :jsonb, null: false, default: {} + end +end diff --git a/decidim-core/db/migrate/20251205122428_rename_participatory_space_private_users_to_members.rb b/decidim-core/db/migrate/20251205122428_rename_participatory_space_private_users_to_members.rb new file mode 100644 index 0000000000000..40ff31c4f8260 --- /dev/null +++ b/decidim-core/db/migrate/20251205122428_rename_participatory_space_private_users_to_members.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class RenameParticipatorySpacePrivateUsersToMembers < ActiveRecord::Migration[7.0] + def change + rename_table :decidim_participatory_space_private_users, :decidim_members + + rename_index :decidim_members, :index_decidim_spaces_users_on_private_user_id, :index_decidim_members_on_user_id + end +end diff --git a/decidim-core/lib/decidim/api/functions/component_finder_base.rb b/decidim-core/lib/decidim/api/functions/component_finder_base.rb index 7d15191e5773d..f855a207fc129 100644 --- a/decidim-core/lib/decidim/api/functions/component_finder_base.rb +++ b/decidim-core/lib/decidim/api/functions/component_finder_base.rb @@ -26,7 +26,7 @@ def call(component, args, _ctx) args.keys.each do |key| @query[key] = args[key] end - query_scope.find_by(@query) + query_scope.find_by!(@query) end # By default, any model in its default scope diff --git a/decidim-core/lib/decidim/api/functions/component_list.rb b/decidim-core/lib/decidim/api/functions/component_list.rb index a7fbf3f3691bb..23b9f298ca656 100644 --- a/decidim-core/lib/decidim/api/functions/component_list.rb +++ b/decidim-core/lib/decidim/api/functions/component_list.rb @@ -23,11 +23,12 @@ def initialize @model_class = Component end - def call(participatory_space, args, _ctx) + def call(participatory_space, args, ctx) @query = Decidim::Component # remove default ordering if custom order required @query = @query.unscoped if args[:order] @query = @query.where(participatory_space:) + @query = @query.published unless ctx[:current_user]&.admin? add_filter_keys(args[:filter]) add_order_keys(args[:order].to_h) add_default_order diff --git a/decidim-core/lib/decidim/api/functions/participatory_space_finder_base.rb b/decidim-core/lib/decidim/api/functions/participatory_space_finder_base.rb index 192d76de1b262..1c56e87dc864a 100644 --- a/decidim-core/lib/decidim/api/functions/participatory_space_finder_base.rb +++ b/decidim-core/lib/decidim/api/functions/participatory_space_finder_base.rb @@ -32,7 +32,7 @@ def call(_obj, args, ctx) model_class.public_spaces end - @query.find_by(query) + @query.find_by!(query) end end end diff --git a/decidim-core/lib/decidim/api/functions/user_entity_list.rb b/decidim-core/lib/decidim/api/functions/user_entity_list.rb index b125206d8a502..c38623ec99015 100644 --- a/decidim-core/lib/decidim/api/functions/user_entity_list.rb +++ b/decidim-core/lib/decidim/api/functions/user_entity_list.rb @@ -21,7 +21,6 @@ def call(_obj, args, ctx) .where(organization: ctx[:current_organization]) .confirmed .not_blocked - .includes(avatar_attachment: :blob) add_filter_keys(args[:filter]) add_order_keys(args[:order].to_h) @query diff --git a/decidim-core/lib/decidim/core.rb b/decidim-core/lib/decidim/core.rb index f194a6cdfbf30..e59f148306552 100644 --- a/decidim-core/lib/decidim/core.rb +++ b/decidim-core/lib/decidim/core.rb @@ -74,9 +74,7 @@ module Decidim autoload :Searchable, "decidim/searchable" autoload :FilterableResource, "decidim/filterable_resource" autoload :SearchResourceFieldsMapper, "decidim/search_resource_fields_mapper" - autoload :QueryExtensions, "decidim/query_extensions" autoload :ParticipatorySpaceResourceable, "decidim/participatory_space_resourceable" - autoload :HasPrivateUsers, "decidim/has_private_users" autoload :ViewModel, "decidim/view_model" autoload :FingerprintCalculator, "decidim/fingerprint_calculator" autoload :Fingerprintable, "decidim/fingerprintable" @@ -134,6 +132,11 @@ module Decidim autoload :ApiResponseHelper, "decidim/api_response_helper" autoload :ResourceHelper, "decidim/resource_helper" autoload :TooltipHelper, "decidim/tooltip_helper" + autoload :FormFactory, "decidim/form_factory" + + module ParticipatorySpace + autoload :HasMembers, "decidim/participatory_space/has_members" + end module Commands autoload :CreateResource, "decidim/commands/create_resource" diff --git a/decidim-core/lib/decidim/core/engine.rb b/decidim-core/lib/decidim/core/engine.rb index 07d6a4536f9a8..ffa5b3726c457 100644 --- a/decidim-core/lib/decidim/core/engine.rb +++ b/decidim-core/lib/decidim/core/engine.rb @@ -365,8 +365,6 @@ def named_variants end initializer "decidim_core.graphql_api" do - Decidim::Api::QueryType.include Decidim::QueryExtensions - Decidim::Api.add_orphan_type Decidim::Core::UserType end diff --git a/decidim-core/lib/decidim/core/test.rb b/decidim-core/lib/decidim/core/test.rb index fe81ecfc107b8..786154fe3c299 100644 --- a/decidim-core/lib/decidim/core/test.rb +++ b/decidim-core/lib/decidim/core/test.rb @@ -53,7 +53,7 @@ require "decidim/core/test/shared_examples/uncommentable_component_examples" require "decidim/core/test/shared_examples/searchable_resources_shared_context" require "decidim/core/test/shared_examples/searchable_participatory_space_examples" -require "decidim/core/test/shared_examples/has_private_users" +require "decidim/core/test/shared_examples/has_members" require "decidim/core/test/shared_examples/with_likeable_permissions_examples" require "decidim/core/test/shared_examples/system_like_resource_examples" require "decidim/core/test/shared_examples/rich_text_editor_examples" diff --git a/decidim-core/lib/decidim/core/test/factories.rb b/decidim-core/lib/decidim/core/test/factories.rb index 5bafdefd7f4ce..20ce1007c170c 100644 --- a/decidim-core/lib/decidim/core/test/factories.rb +++ b/decidim-core/lib/decidim/core/test/factories.rb @@ -117,6 +117,11 @@ def generate_title(field = nil, skip_injection:) Decidim.available_locales.index_with { |_locale| Faker::Company.unique.name } end + # we do not want machine translation here + short_name do + Decidim.available_locales.index_with { |_locale| Faker::Company.unique.name.gsub(/\s+/, "")[0, 12] } + end + reference_prefix { Faker::Name.suffix } time_zone { "UTC" } twitter_handler { Faker::Hipster.word } @@ -277,7 +282,7 @@ def generate_title(field = nil, skip_injection:) value { 1 } end - factory :participatory_space_private_user, class: "Decidim::ParticipatorySpacePrivateUser" do + factory :member, class: "Decidim::ParticipatorySpace::Member" do transient do skip_injection { false } end @@ -295,7 +300,7 @@ def generate_title(field = nil, skip_injection:) end end - factory :assembly_private_user, class: "Decidim::ParticipatorySpacePrivateUser" do + factory :assembly_member, class: "Decidim::ParticipatorySpace::Member" do transient do skip_injection { false } end diff --git a/decidim-core/lib/decidim/core/test/shared_examples/has_private_users.rb b/decidim-core/lib/decidim/core/test/shared_examples/has_members.rb similarity index 64% rename from decidim-core/lib/decidim/core/test/shared_examples/has_private_users.rb rename to decidim-core/lib/decidim/core/test/shared_examples/has_members.rb index 24bf9077fa8cb..4e6fb0df8f8a5 100644 --- a/decidim-core/lib/decidim/core/test/shared_examples/has_private_users.rb +++ b/decidim-core/lib/decidim/core/test/shared_examples/has_members.rb @@ -2,7 +2,7 @@ require "spec_helper" -shared_examples_for "has private users" do +shared_examples_for "has members" do let(:factory_name) { described_class.name.demodulize.underscore.to_sym } let!(:public_space) do @@ -13,8 +13,8 @@ create(factory_name, private_space: true, published_at: Time.current) end - def create_space_private_user(space, user = create(:user, organization: space.organization)) - Decidim::ParticipatorySpacePrivateUser.create(privatable_to: space, user:) + def create_space_member(space, user = create(:user, organization: space.organization)) + Decidim::ParticipatorySpace::Member.create(privatable_to: space, user:) end describe ".public_spaces" do @@ -26,7 +26,7 @@ def create_space_private_user(space, user = create(:user, organization: space.or describe ".visible_for" do let(:scope) { described_class.send(:visible_for, user) } - before { create_space_private_user(private_space) } + before { create_space_member(private_space) } context "without user" do let(:user) { nil } @@ -34,26 +34,26 @@ def create_space_private_user(space, user = create(:user, organization: space.or it { expect(scope).to contain_exactly(public_space) } end - context "with non-private user" do + context "with non-member" do let(:user) { create(:user) } it { expect(scope).to contain_exactly(public_space) } end - context "with private user" do + context "with member" do let(:user) { private_space.users.first } it { expect(scope).to contain_exactly(public_space, private_space) } end - context "when the space is both public and has private users" do - # Visible spaces for non-private user. + context "when the space is both public and has members" do + # Visible spaces for non-member. let(:user) { create(:user) } before do - # Public space has multiple private users. - create_space_private_user(public_space) - create_space_private_user(public_space) + # Public space has multiple members. + create_space_member(public_space) + create_space_member(public_space) end # Expect no duplicate results. diff --git a/decidim-core/lib/decidim/core/test/shared_examples/participatory_space_members_page_examples.rb b/decidim-core/lib/decidim/core/test/shared_examples/participatory_space_members_page_examples.rb index d48ef63eb7d7f..a7d84f10b6a19 100644 --- a/decidim-core/lib/decidim/core/test/shared_examples/participatory_space_members_page_examples.rb +++ b/decidim-core/lib/decidim/core/test/shared_examples/participatory_space_members_page_examples.rb @@ -18,9 +18,9 @@ end context "when participatory space has members" do - let!(:member1) { create(:participatory_space_private_user, privatable_to:, user: user1, published: true) } - let!(:member2) { create(:participatory_space_private_user, privatable_to:, user: user2, published: true) } - let!(:non_published) { create(:participatory_space_private_user, privatable_to:, user: user3, published: false) } + let!(:member1) { create(:member, privatable_to:, user: user1, published: true) } + let!(:member2) { create(:member, privatable_to:, user: user2, published: true) } + let!(:non_published) { create(:member, privatable_to:, user: user3, published: false) } context "when user has permissions" do it "displays list of members" do diff --git a/decidim-core/lib/decidim/download_your_data_serializers.rb b/decidim-core/lib/decidim/download_your_data_serializers.rb index 83216146ce224..0af8b9cde17c2 100644 --- a/decidim-core/lib/decidim/download_your_data_serializers.rb +++ b/decidim-core/lib/decidim/download_your_data_serializers.rb @@ -8,12 +8,12 @@ module DownloadYourDataSerializers autoload :DownloadYourDataFollowSerializer, "decidim/download_your_data_serializers/download_your_data_follow_serializer" autoload :DownloadYourDataNotificationSerializer, "decidim/download_your_data_serializers/download_your_data_notification_serializer" autoload :DownloadYourDataIdentitySerializer, "decidim/download_your_data_serializers/download_your_data_identity_serializer" - autoload :DownloadYourDataParticipatorySpacePrivateUserSerializer, "decidim/download_your_data_serializers/download_your_data_participatory_space_private_user_serializer" + autoload :DownloadYourDataMemberSerializer, "decidim/download_your_data_serializers/download_your_data_member_serializer" def self.data_entities ["Decidim::Follow", "Decidim::Identity", "Decidim::Messaging::Conversation", "Decidim::Notification", - "Decidim::ParticipatorySpacePrivateUser", "Decidim::Report", "Decidim::User"] | + "Decidim::ParticipatorySpace::Member", "Decidim::Report", "Decidim::User"] | Decidim.component_manifests.map(&:data_portable_entities).flatten | Decidim.participatory_space_manifests.map(&:data_portable_entities).flatten | (Decidim::Comments.data_portable_entities.flatten if defined?(Decidim::Comments)) diff --git a/decidim-core/lib/decidim/download_your_data_serializers/download_your_data_participatory_space_private_user_serializer.rb b/decidim-core/lib/decidim/download_your_data_serializers/download_your_data_member_serializer.rb similarity index 88% rename from decidim-core/lib/decidim/download_your_data_serializers/download_your_data_participatory_space_private_user_serializer.rb rename to decidim-core/lib/decidim/download_your_data_serializers/download_your_data_member_serializer.rb index 0e2b619882412..9b87e467ca69a 100644 --- a/decidim-core/lib/decidim/download_your_data_serializers/download_your_data_participatory_space_private_user_serializer.rb +++ b/decidim-core/lib/decidim/download_your_data_serializers/download_your_data_member_serializer.rb @@ -3,7 +3,7 @@ module Decidim # This class serializes a User so can be exported to CSV module DownloadYourDataSerializers - class DownloadYourDataParticipatorySpacePrivateUserSerializer < Decidim::Exporters::Serializer + class DownloadYourDataMemberSerializer < Decidim::Exporters::Serializer include Decidim::ResourceHelper # Public: Exports a hash with the serialized data for this user. diff --git a/decidim-core/app/controllers/concerns/decidim/form_factory.rb b/decidim-core/lib/decidim/form_factory.rb similarity index 100% rename from decidim-core/app/controllers/concerns/decidim/form_factory.rb rename to decidim-core/lib/decidim/form_factory.rb diff --git a/decidim-core/lib/decidim/has_private_users.rb b/decidim-core/lib/decidim/has_private_users.rb deleted file mode 100644 index 90dd3fba6a160..0000000000000 --- a/decidim-core/lib/decidim/has_private_users.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -require "active_support/concern" - -module Decidim - # A concern with the features needed when you want a model to be able to create - # private users - module HasPrivateUsers - extend ActiveSupport::Concern - - included do - has_many :participatory_space_private_users, - class_name: "Decidim::ParticipatorySpacePrivateUser", - as: :privatable_to, - dependent: :destroy - has_many :users, - through: :participatory_space_private_users, - class_name: "Decidim::User", - foreign_key: "private_user_to_id" - - def self.visible_for(user) - if user - return all if user.admin? - - where( - id: public_spaces + - private_spaces - .joins(:participatory_space_private_users) - .where(decidim_participatory_space_private_users: { decidim_user_id: user.id }) - ) - else - public_spaces - end - end - - def members_public_page? - private_space && participatory_space_private_users.published.any? - end - - def can_participate?(user) - return false unless published? - return true unless private_space? - return false unless user - - participatory_space_private_users.exists?(decidim_user_id: user.id) - end - - def self.public_spaces - where(private_space: false).published - end - - def self.private_spaces - where(private_space: true) - end - end - end -end diff --git a/decidim-core/lib/decidim/participatory_space/has_members.rb b/decidim-core/lib/decidim/participatory_space/has_members.rb new file mode 100644 index 0000000000000..3b721a100d6a3 --- /dev/null +++ b/decidim-core/lib/decidim/participatory_space/has_members.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "active_support/concern" + +module Decidim + module ParticipatorySpace + # A concern with the features needed when you want a model to be able to create + # members + module HasMembers + extend ActiveSupport::Concern + + included do + has_many :members, + class_name: "Decidim::ParticipatorySpace::Member", + as: :privatable_to, + dependent: :destroy + has_many :users, + through: :members, + class_name: "Decidim::User", + foreign_key: "member_to_id" + + def self.visible_for(user) + if user + return all if user.admin? + + where( + id: public_spaces + + private_spaces + .joins(:members) + .where(decidim_members: { decidim_user_id: user.id }) + ) + else + public_spaces + end + end + + def members_public_page? + private_space && members.published.any? + end + + def can_participate?(user) + return false unless published? + return true unless private_space? + return false unless user + + members.exists?(decidim_user_id: user.id) + end + + def self.public_spaces + where(private_space: false).published + end + + def self.private_spaces + where(private_space: true) + end + end + end + end +end diff --git a/decidim-core/lib/decidim/query_extensions.rb b/decidim-core/lib/decidim/query_extensions.rb deleted file mode 100644 index 323eb3a02c605..0000000000000 --- a/decidim-core/lib/decidim/query_extensions.rb +++ /dev/null @@ -1,105 +0,0 @@ -# frozen_string_literal: true - -module Decidim - # This module's job is to extend the API with custom fields related to - # decidim-core. - module QueryExtensions - # Public: Extends a type with `decidim-core`'s fields. - # - # type - A GraphQL::BaseType to extend. - # - # Returns nothing. - def self.included(type) - type.field :component, Decidim::Core::ComponentInterface, null: true do - description "Lists the components this space contains." - argument :id, GraphQL::Types::ID, required: true, description: "The ID of the component to be found" - end - type.field :session, Core::SessionType, description: "Return's information about the logged in user", null: true - type.field :decidim, Core::DecidimType, "Decidim's framework properties.", null: true - type.field :organization, Core::OrganizationType, "The current organization", null: true - type.field :user, - type: Core::UserType, null: true, - description: "A participant (user or group) in the current organization" do - argument :id, GraphQL::Types::ID, "The ID of the participant", required: false - argument :nickname, GraphQL::Types::String, "The @nickname of the participant", required: false - end - type.field :users, - type: [Core::UserType], null: true, - description: "The participants (users or groups) for the current organization" do - argument :order, Decidim::Core::UserEntityInputSort, "Provides several methods to order the results", required: false - argument :filter, Decidim::Core::UserEntityInputFilter, "Provides several methods to filter the results", required: false - end - type.field :participant_details, type: Decidim::Core::ParticipantDetailsType, null: true do - description "Participant details visible to admin users only" - argument :id, GraphQL::Types::ID, "The ID of the participant", required: true - argument :nickname, GraphQL::Types::String, "The @nickname of the participant", required: false - end - type.field :static_pages, type: [Decidim::Core::StaticPageType], null: true, - description: "The static pages for the current organization" - type.field :static_page_topics, type: [Decidim::Core::StaticPageTopicType], null: true, - description: "The static page topics for the current organization" - type.field :moderated_users, type: [Decidim::Core::UserModerationType], null: true, - description: "The moderated users for the current organization" - type.field :moderations, type: [Decidim::Core::ModerationType], null: true, - description: "The moderation for the current organization" - end - - def component(id: {}) - component = Decidim::Component.published.find_by(id:) - component&.organization == context[:current_organization] ? component : nil - end - - def session - context[:current_user] - end - - def decidim - Decidim - end - - def organization - context[:current_organization] - end - - def user(id: nil, nickname: nil) - Core::UserEntityFinder.new.call(object, { id:, nickname: }, context) - end - - def users(filter: {}, order: {}) - Core::UserEntityList.new.call(object, { filter:, order: }, context) - end - - def participant_details(id: nil, nickname: nil) - participant = Decidim::Core::UserEntityFinder.new.call(object, { id: id, nickname: nickname }, context) - return nil unless participant - - return nil unless Decidim::Core::ParticipantDetailsType.authorized?(participant, context) - - Decidim::ActionLogger.log( - "read", - context[:current_user], - participant, - nil, - {} - ) - - participant - end - - def static_pages - Decidim::StaticPage.accessible_for(context[:current_organization], context[:current_user]) - end - - def static_page_topics - static_pages.collect(&:topic).uniq.compact_blank - end - - def moderated_users - Decidim::UserModeration.joins(:user).where(decidim_users: { decidim_organization_id: context[:current_organization]&.id }).where.not(decidim_users: { blocked_at: nil }) - end - - def moderations - Decidim::Moderation.where(participatory_space: context[:current_organization].participatory_spaces).includes(:reports).hidden - end - end -end diff --git a/decidim-core/lib/tasks/decidim_procfile.rake b/decidim-core/lib/tasks/decidim_procfile.rake index fd043af677932..a72f31066cdab 100644 --- a/decidim-core/lib/tasks/decidim_procfile.rake +++ b/decidim-core/lib/tasks/decidim_procfile.rake @@ -23,7 +23,7 @@ set -e bundle check || bundle install --jobs 20 --retry 5 -bin/rails decidim:upgrade db:migrate +bin/rails decidim:upgrade db:migrate data:migrate if ! gem list foreman -i --silent; then echo "Installing foreman..." diff --git a/decidim-core/lib/tasks/upgrade/decidim_remove_deleted_users_left_data_tasks.rake b/decidim-core/lib/tasks/upgrade/decidim_remove_deleted_users_left_data_tasks.rake index d1e5a1ca7864e..858fba83bda24 100644 --- a/decidim-core/lib/tasks/upgrade/decidim_remove_deleted_users_left_data_tasks.rake +++ b/decidim-core/lib/tasks/upgrade/decidim_remove_deleted_users_left_data_tasks.rake @@ -8,7 +8,7 @@ namespace :decidim do Decidim::User.where.not(deleted_at: nil).find_each do |deleted_user| Decidim::Follow.where(followable: deleted_user).find_each(&:destroy) Decidim::Follow.where(user: deleted_user).find_each(&:destroy) - Decidim::ParticipatorySpacePrivateUser.where(user: deleted_user).find_each(&:destroy) + Decidim::ParticipatorySpace::Member.where(user: deleted_user).find_each(&:destroy) Decidim::Gamification::BadgeScore.where(user: deleted_user).find_each(&:destroy) Decidim::UserModeration.where(user: deleted_user).find_each(&:destroy) Decidim::Like.where(author: deleted_user).find_each(&:destroy) diff --git a/decidim-core/lib/tasks/upgrade/fix_deleted_private_follows.rake b/decidim-core/lib/tasks/upgrade/fix_deleted_private_follows.rake index 9a77bc41bec85..21277ed55d17b 100644 --- a/decidim-core/lib/tasks/upgrade/fix_deleted_private_follows.rake +++ b/decidim-core/lib/tasks/upgrade/fix_deleted_private_follows.rake @@ -17,7 +17,7 @@ namespace :decidim do next unless user.following_follows.count.positive? spaces.each do |space| - Decidim::Admin::DestroyPrivateUsersFollowsJob.perform_later(user, space) + Decidim::Admin::ParticipatorySpace::DestroyMembersFollowsJob.perform_later(user, space) end end end diff --git a/decidim-core/spec/commands/decidim/destroy_account_spec.rb b/decidim-core/spec/commands/decidim/destroy_account_spec.rb index a5ef541383240..27870cc580ae4 100644 --- a/decidim-core/spec/commands/decidim/destroy_account_spec.rb +++ b/decidim-core/spec/commands/decidim/destroy_account_spec.rb @@ -133,10 +133,10 @@ module Decidim expect { command.call }.not_to change(Decidim::Authorization, :count) end - it "deletes participatory space private user" do - create(:participatory_space_private_user, user:) + it "deletes member" do + create(:member, user:) - expect { command.call }.to change(ParticipatorySpacePrivateUser, :count).by(-1) + expect { command.call }.to change(Decidim::ParticipatorySpace::Member, :count).by(-1) end it "deletes user likes" do diff --git a/decidim-core/spec/db/data/add_short_name_to_organizations_spec.rb b/decidim-core/spec/db/data/add_short_name_to_organizations_spec.rb new file mode 100644 index 0000000000000..58128e1b450d3 --- /dev/null +++ b/decidim-core/spec/db/data/add_short_name_to_organizations_spec.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require "spec_helper" +require "./db/data/20251125144141_add_short_name_to_organizations" + +describe AddShortNameToOrganizations do + let(:migrator) do + described_class.new.tap do |m| + m.verbose = false + end + end + + describe "#up" do + shared_examples_for "adds the short name" do |describe_title, example_title, name_hash, short_name_hash| + describe describe_title do + let!(:organization) do + org = create(:organization, name: name_hash) + org.update_column(:short_name, {}) # rubocop:disable Rails/SkipsModelValidations + org + end + + it example_title do + expect(organization.reload.short_name).to eq({}) + migrator.migrate(:up) + expect(organization.reload.short_name).to eq(short_name_hash) + end + end + end + + it_behaves_like "adds the short name", + "with a normal name", + "generates short_name from name", + { en: "MyOrganization" }, + { "en" => "MyOrganizati" } + + it_behaves_like "adds the short name", + "with a name containing spaces", + "removes spaces and generates short_name", + { en: "My Organization" }, + { "en" => "MyOrganizati" } + + it_behaves_like "adds the short name", + "with a short name without spaces", + "uses the name as short_name", + { en: "Test" }, + { "en" => "Test" } + + it_behaves_like "adds the short name", + "with a name that results in less than 3 characters", + "does not set short_name", + { en: "A B" }, + {} + + it_behaves_like "adds the short name", + "with a long name", + "truncates to 12 characters", + { en: "Very Long Organization Name" }, + { "en" => "VeryLongOrga" } + + it_behaves_like "adds the short name", + "with a blank name", + "does not set short_name", + { en: "" }, + {} + + it_behaves_like "adds the short name", + "with a Spanish name", + "generates short_name from Spanish name", + { es: "MiOrganización", en: "MyOrganization" }, + { "es" => "MiOrganizaci", "en" => "MyOrganizati" } + + it_behaves_like "adds the short name", + "with a French name containing accents", + "removes spaces and generates short_name from French", + { fr: "Mon Organisation", en: "My Organization" }, + { "fr" => "MonOrganisat", "en" => "MyOrganizati" } + + it_behaves_like "adds the short name", + "with a Catalan name", + "generates short_name from Catalan name", + { ca: "LaMevaOrganització", en: "MyOrganization" }, + { "ca" => "LaMevaOrgani", "en" => "MyOrganizati" } + + it_behaves_like "adds the short name", + "with multiple locales and spaces", + "generates short_name for all locales", + { en: "My Organization", es: "Mi Organización", ca: "La Meva Organització" }, + { "en" => "MyOrganizati", "es" => "MiOrganizaci", "ca" => "LaMevaOrgani" } + + it_behaves_like "adds the short name", + "with German name", + "generates short_name from German name", + { de: "Meine Organisation", en: "My Organization" }, + { "de" => "MeineOrganis", "en" => "MyOrganizati" } + + it_behaves_like "adds the short name", + "with faker multilingual organization", + "generates short_name for all valid locales", + { + "ar" => "سعيد شركة", + "bg" => "Schultz-Mosciski", + "ca" => "Garau, Miquel and Pitart", + "cs" => "Rogahn, Farrell and Spinka", + "da" => "DuBuque, Gislason and Buckridge", + "de" => "Oeser KG", + "el" => "Wunsch, Adams and Simonis", + "en" => "Emmerich Inc", + "eo" => "Gusikowski Inc", + "es" => "Fonseca, Robles y Rivera Asociados", + "et" => "McClure, Leffler and Zboncak", + "eu" => "Corwin and Sons", + "fa" => "ندوشن Group", + "fi" => "Zemlak, Swift and Lemke", + "fr" => "Duval EURL", + "ga" => "Rippin Group", + "gl" => "Jacobson, Schinner and Feil", + "hr" => "Hamill Group", + "hu" => "Lueilwitz-Schaden", + "id" => "Haryanto, Waluyo and Idris", + "is" => "Romaguera and Sons", + "it" => "Amato, Negri e Barone SPA", + "ja" => "加藤運輸株式会社", + "ko" => "한국 민준", + "lb" => "Mante-Emard", + "lt" => "Pfeffer, Nienow and Schaefer", + "lv" => "Irbe AS", + "mt" => "Ebert, Zulauf and Grady", + "nl" => "Tegelaar V.O.F.", + "no" => "Bartell-Corwin", + "pl" => "Matusiak-Flis", + "pt" => "Moreira-Araújo", + "ro" => "Kautzer and Sons", + "ru" => "ИП Эдуард", + "sk" => "Labudová s.r.o.", + "sl" => "Sanford-Leannon", + "sr" => "Jakubowski, Ullrich and Reynolds", + "sv" => "Andersson Group", + "tr" => "Özkanlı, Zengel and Davut", + "uk" => "ФОП Яцьків", + "vi" => "Cửa hàng Triệu", + "es-MX" => "Rivas y Lozano S.R.L", + "es-PY" => "Ceballos S.A.", + "fi-pl" => "Leannon-Stroman", + "fr-CA" => "Aubért et Rémy", + "pt-BR" => "Marcondes, Ferraço e Solimões", + "zh-CN" => "杜-雷", + "machine_translations" => { "zh-TW" => "章 Inc" } + }, + { + "ar" => "سعيدشركة", + "bg" => "Schultz-Mosc", + "ca" => "Garau,Miquel", + "cs" => "Rogahn,Farre", + "da" => "DuBuque,Gisl", + "de" => "OeserKG", + "el" => "Wunsch,Adams", + "en" => "EmmerichInc", + "eo" => "GusikowskiIn", + "es" => "Fonseca,Robl", + "et" => "McClure,Leff", + "eu" => "CorwinandSon", + "fa" => "ندوشنGroup", + "fi" => "Zemlak,Swift", + "fr" => "DuvalEURL", + "ga" => "RippinGroup", + "gl" => "Jacobson,Sch", + "hr" => "HamillGroup", + "hu" => "Lueilwitz-Sc", + "id" => "Haryanto,Wal", + "is" => "Romagueraand", + "it" => "Amato,Negrie", + "ja" => "加藤運輸株式会社", + "ko" => "한국민준", + "lb" => "Mante-Emard", + "lt" => "Pfeffer,Nien", + "lv" => "IrbeAS", + "mt" => "Ebert,Zulauf", + "nl" => "TegelaarV.O.", + "no" => "Bartell-Corw", + "pl" => "Matusiak-Fli", + "pt" => "Moreira-Araú", + "ro" => "KautzerandSo", + "ru" => "ИПЭдуард", + "sk" => "Labudovás.r.", + "sl" => "Sanford-Lean", + "sr" => "Jakubowski,U", + "sv" => "AnderssonGro", + "tr" => "Özkanlı,Zeng", + "uk" => "ФОПЯцьків", + "vi" => "CửahàngTriệu", + "es-MX" => "RivasyLozano", + "es-PY" => "CeballosS.A.", + "fi-pl" => "Leannon-Stro", + "fr-CA" => "AubértetRémy", + "pt-BR" => "Marcondes,Fe", + "zh-CN" => "杜-雷" + } + end + + describe "#down" do + it "raises IrreversibleMigration exception" do + expect { migrator.migrate(:down) }.to raise_error(ActiveRecord::IrreversibleMigration) + end + end +end diff --git a/decidim-core/spec/db/data/rename_members_in_action_log_spec.rb b/decidim-core/spec/db/data/rename_members_in_action_log_spec.rb new file mode 100644 index 0000000000000..2459e5772ab5d --- /dev/null +++ b/decidim-core/spec/db/data/rename_members_in_action_log_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require "spec_helper" + +require "./db/data/20251213075429_rename_members_in_action_log" + +describe RenameMembersInActionLog do + let(:migrator) do + described_class.new.tap do |m| + m.verbose = false + end + end + + describe "#up" do + let(:old_resource_type) { "Decidim::ParticipatorySpacePrivateUser" } + let(:new_resource_type) { "Decidim::ParticipatorySpace::Member" } + let(:organization) { create(:organization) } + + context "when there are records with the old resource type" do + let!(:action_logs_with_old_type) do + user = create(:user, organization:) + # rubocop:disable Rails/SkipsModelValidations + Decidim::ActionLog.insert_all([ + { + decidim_organization_id: organization.id, + user_id: user.id, + user_type: user.class.name, + resource_type: old_resource_type, + resource_id: 1, + action: "create", + visibility: "admin-only", + extra: {}, + created_at: Time.current, + updated_at: Time.current + }, + { + decidim_organization_id: organization.id, + user_id: user.id, + user_type: user.class.name, + resource_type: old_resource_type, + resource_id: 2, + action: "create", + visibility: "admin-only", + extra: {}, + created_at: Time.current, + updated_at: Time.current + }, + { + decidim_organization_id: organization.id, + user_id: user.id, + user_type: user.class.name, + resource_type: old_resource_type, + resource_id: 3, + action: "create", + visibility: "admin-only", + extra: {}, + created_at: Time.current, + updated_at: Time.current + } + ]) + end + # rubocop:enable Rails/SkipsModelValidations + let!(:action_logs_with_other_types) do + create_list(:action_log, 2, resource: create(:dummy_resource, organization:)) + end + + it "updates all records with the old resource type to the new one" do + expect { migrator.migrate(:up) }.to change { + Decidim::ActionLog.where(resource_type: old_resource_type).count + }.from(3).to(0) + + expect(Decidim::ActionLog.where(resource_type: new_resource_type).count).to eq(3) + end + + it "does not affect records with other resource types" do + expect { migrator.migrate(:up) }.not_to(change do + Decidim::ActionLog.where(resource_type: "Decidim::SomeOtherModel").count + end) + end + + it "logs the number of updated records" do + allow(Rails.logger).to receive(:info) + + migrator.migrate(:up) + + expect(Rails.logger).to have_received(:info).with("Updated 3 ActionLog records from Decidim::ParticipatorySpacePrivateUser to Decidim::ParticipatorySpace::Member") + end + end + + context "when there are no records with the old resource type" do + let!(:action_logs_with_other_types) do + create_list(:action_log, 2, resource: create(:dummy_resource, organization:)) + end + + it "does not raise an error" do + expect { migrator.migrate(:up) }.not_to raise_error + end + + it "logs that zero records were updated" do + allow(Rails.logger).to receive(:info) + + migrator.migrate(:up) + + expect(Rails.logger).to have_received(:info).with("Updated 0 ActionLog records from Decidim::ParticipatorySpacePrivateUser to Decidim::ParticipatorySpace::Member") + end + end + + context "when the table is empty" do + it "does not raise an error" do + expect { migrator.migrate(:up) }.not_to raise_error + end + + it "logs that zero records were updated" do + allow(Rails.logger).to receive(:info) + + migrator.migrate(:up) + + expect(Rails.logger).to have_received(:info).with("Updated 0 ActionLog records from Decidim::ParticipatorySpacePrivateUser to Decidim::ParticipatorySpace::Member") + end + end + end + + describe "#down" do + it "raises ActiveRecord::IrreversibleMigration" do + expect { migrator.migrate(:down) }.to raise_error(ActiveRecord::IrreversibleMigration) + end + end +end diff --git a/decidim-core/spec/helpers/decidim/menu_helper_spec.rb b/decidim-core/spec/helpers/decidim/menu_helper_spec.rb index e0c510763ea23..3e3089a185201 100644 --- a/decidim-core/spec/helpers/decidim/menu_helper_spec.rb +++ b/decidim-core/spec/helpers/decidim/menu_helper_spec.rb @@ -5,7 +5,7 @@ module Decidim describe MenuHelper do let(:organization) { create(:organization) } - let(:user) { create(:user, organization: organization) } + let(:user) { create(:user, organization:) } let!(:process) { create(:participatory_process, :active, weight: 1, organization:) } let!(:process_two) { create(:participatory_process, :active, weight: 2, organization:) } let!(:process_three) { create(:participatory_process, :active, :promoted, weight: 3, organization:) } @@ -70,15 +70,15 @@ module Decidim process_two.update!(promoted: true, private_space: true) end - context "and current_user is private user of that process" do - let!(:participatory_space_private_user) { create(:participatory_space_private_user, privatable_to: process_two, user:) } + context "and current_user is member of that process" do + let!(:member) { create(:member, privatable_to: process_two, user:) } it "returns the private process" do expect(helper.menu_highlighted_participatory_process).to eq(process_two) end end - context "and current_user is not private user of that process" do + context "and current_user is not member of that process" do it "returns the other published promoted process" do expect(helper.menu_highlighted_participatory_process).to eq(process_three) end diff --git a/decidim-core/spec/lib/api/functions/participatory_space_list_base_spec.rb b/decidim-core/spec/lib/api/functions/participatory_space_list_base_spec.rb index c813bcdc1a1ea..db026a4a177f4 100644 --- a/decidim-core/spec/lib/api/functions/participatory_space_list_base_spec.rb +++ b/decidim-core/spec/lib/api/functions/participatory_space_list_base_spec.rb @@ -30,7 +30,7 @@ module Decidim::Core context "with a private space participant" do let(:user) { create(:user, :confirmed, organization:) } - let!(:private_user) { create(:participatory_space_private_user, privatable_to: private_process, user:) } + let!(:member) { create(:member, privatable_to: private_process, user:) } it "returns all spaces including the private space" do expect(subject).to include(process1, process2, process3, private_process) diff --git a/decidim-core/spec/lib/download_your_data_serializers/download_your_data_participatory_space_private_user_serializer_spec.rb b/decidim-core/spec/lib/download_your_data_serializers/download_your_data_member_serializer_spec.rb similarity index 88% rename from decidim-core/spec/lib/download_your_data_serializers/download_your_data_participatory_space_private_user_serializer_spec.rb rename to decidim-core/spec/lib/download_your_data_serializers/download_your_data_member_serializer_spec.rb index 8ec4077b380c8..a0686717cf021 100644 --- a/decidim-core/spec/lib/download_your_data_serializers/download_your_data_participatory_space_private_user_serializer_spec.rb +++ b/decidim-core/spec/lib/download_your_data_serializers/download_your_data_member_serializer_spec.rb @@ -3,9 +3,9 @@ require "spec_helper" module Decidim - describe DownloadYourDataSerializers::DownloadYourDataParticipatorySpacePrivateUserSerializer do + describe DownloadYourDataSerializers::DownloadYourDataMemberSerializer do subject { described_class.new(resource) } - let(:resource) { build(:participatory_space_private_user) } + let(:resource) { build(:member) } let(:serialized) { subject.serialize } diff --git a/decidim-core/spec/controllers/concerns/form_factory_spec.rb b/decidim-core/spec/lib/form_factory_spec.rb similarity index 100% rename from decidim-core/spec/controllers/concerns/form_factory_spec.rb rename to decidim-core/spec/lib/form_factory_spec.rb diff --git a/decidim-core/spec/lib/oauth/token_generator_spec.rb b/decidim-core/spec/lib/oauth/token_generator_spec.rb index 63c14acaba422..35032bdd2e985 100644 --- a/decidim-core/spec/lib/oauth/token_generator_spec.rb +++ b/decidim-core/spec/lib/oauth/token_generator_spec.rb @@ -13,7 +13,7 @@ module Decidim let(:scopes) { "profile" } let(:options) do { - application: application, + application:, resource_owner_id: user.id, scopes: ::Doorkeeper::OAuth::Scopes.from_string(scopes) } diff --git a/decidim-core/spec/mailers/notification_digest_mailer_spec.rb b/decidim-core/spec/mailers/notification_digest_mailer_spec.rb index 3d49276636b4b..e0016c94efd06 100644 --- a/decidim-core/spec/mailers/notification_digest_mailer_spec.rb +++ b/decidim-core/spec/mailers/notification_digest_mailer_spec.rb @@ -81,7 +81,7 @@ module Decidim context "when the space is private and user has access" do let!(:participatory_space) { create(:participatory_process, :private, organization:) } let(:component) { create(:component, :published, manifest_name: "dummy", participatory_space:) } - let!(:participatory_space_private_user) { create(:participatory_space_private_user, privatable_to: participatory_space, user: user) } + let!(:member) { create(:member, privatable_to: participatory_space, user:) } it "displays the notification" do expect(subject.body).to include(test_content) @@ -92,7 +92,7 @@ module Decidim context "when the space is transparent and user has access" do let!(:participatory_space) { create(:assembly, :transparent, :private, organization:) } let(:component) { create(:component, :published, manifest_name: "dummy", participatory_space:) } - let!(:participatory_space_private_user) { create(:participatory_space_private_user, privatable_to: participatory_space, user: user) } + let!(:member) { create(:member, privatable_to: participatory_space, user:) } it "displays the notification" do expect(subject.body).to include(test_content) diff --git a/decidim-core/spec/models/decidim/participatory_space_private_user_spec.rb b/decidim-core/spec/models/decidim/participatory_space/member_spec.rb similarity index 70% rename from decidim-core/spec/models/decidim/participatory_space_private_user_spec.rb rename to decidim-core/spec/models/decidim/participatory_space/member_spec.rb index e570af135e9df..ec9d084f3bafd 100644 --- a/decidim-core/spec/models/decidim/participatory_space_private_user_spec.rb +++ b/decidim-core/spec/models/decidim/participatory_space/member_spec.rb @@ -2,11 +2,11 @@ require "spec_helper" -module Decidim - describe ParticipatorySpacePrivateUser do - subject { participatory_space_private_user } +module Decidim::ParticipatorySpace + describe Member do + subject { member } - let(:participatory_space_private_user) { build(:participatory_space_private_user) } + let(:member) { build(:member) } it { is_expected.to be_valid } @@ -23,9 +23,9 @@ module Decidim let(:user) { create(:user, organization: user_organization) } - let(:participatory_space_private_user) do + let(:member) do build( - :participatory_space_private_user, + :member, user:, privatable_to: participatory_process ) diff --git a/decidim-core/spec/presenters/decidim/organization_presenter_spec.rb b/decidim-core/spec/presenters/decidim/organization_presenter_spec.rb index e49025934bb7d..cd51f08913762 100644 --- a/decidim-core/spec/presenters/decidim/organization_presenter_spec.rb +++ b/decidim-core/spec/presenters/decidim/organization_presenter_spec.rb @@ -5,7 +5,7 @@ module Decidim describe OrganizationPresenter, type: :helper do let(:description) { { "en" => "

A necessitatibus quo. 1

" } } - let(:organization) { create(:organization, name: { en: "Organization's name" }, description:) } + let(:organization) { create(:organization, name: { en: "Organization's name" }, short_name: { en: "OrgName" }, description:) } subject { described_class.new(organization) } @@ -16,6 +16,12 @@ module Decidim end end + describe "#html_short_name" do + it "returns the short_name translated and without any html tag" do + expect(subject.html_short_name).to eq("OrgName") + end + end + describe "#translated_description" do it "returns the description translated and without any html tag" do expect(subject.translated_description).to eq("A necessitatibus quo. 1") diff --git a/decidim-core/spec/presenters/decidim/stats_presenter_spec.rb b/decidim-core/spec/presenters/decidim/stats_presenter_spec.rb index 00e560398595a..23a364fbde8bc 100644 --- a/decidim-core/spec/presenters/decidim/stats_presenter_spec.rb +++ b/decidim-core/spec/presenters/decidim/stats_presenter_spec.rb @@ -8,14 +8,14 @@ describe "#collection" do let(:priority) { Decidim::StatsRegistry::MEDIUM_PRIORITY } - let(:conditions) { { priority: priority } } + let(:conditions) { { priority: } } before do - allow(presenter).to receive(:all_stats).with(priority: priority).and_return([ - { name: "stat_1", data: [1, 23] }, - { name: "stat_2", data: [0] }, - { name: "stat_3", data: [45] } - ]) + allow(presenter).to receive(:all_stats).with(priority:).and_return([ + { name: "stat_1", data: [1, 23] }, + { name: "stat_2", data: [0] }, + { name: "stat_3", data: [45] } + ]) end it "returns stats with non-empty data" do @@ -31,10 +31,10 @@ end it "sums the data of stats with the same name" do - allow(presenter).to receive(:all_stats).with(priority: priority).and_return([ - { name: "stat_1", data: [12, 3] }, - { name: "stat_1", data: [4] } - ]) + allow(presenter).to receive(:all_stats).with(priority:).and_return([ + { name: "stat_1", data: [12, 3] }, + { name: "stat_1", data: [4] } + ]) result = presenter.collection(priority:) diff --git a/decidim-core/spec/queries/decidim/inactive_users_query_spec.rb b/decidim-core/spec/queries/decidim/inactive_users_query_spec.rb index a0e437559452a..f1665a8a87870 100644 --- a/decidim-core/spec/queries/decidim/inactive_users_query_spec.rb +++ b/decidim-core/spec/queries/decidim/inactive_users_query_spec.rb @@ -15,21 +15,21 @@ let!(:inactive_recent_sign_in) { create(:user, organization:, current_sign_in_at: 400.days.ago, created_at: 400.days.ago, extended_data: {}) } let!(:active_recent_sign_in) { create(:user, organization:, current_sign_in_at: 200.days.ago, created_at: 200.days.ago, extended_data: {}) } let!(:user_reminder_due) do - create(:user, organization: organization, + create(:user, organization:, current_sign_in_at: 294.days.ago, created_at: 400.days.ago, extended_data: { "inactivity_notification" => { "notification_type" => "first", "sent_at" => 23.days.ago } }) end let!(:user_ready_for_removal) do - create(:user, organization: organization, + create(:user, organization:, current_sign_in_at: 400.days.ago, created_at: 400.days.ago, extended_data: { "inactivity_notification" => { "notification_type" => "second", "sent_at" => 40.days.ago } }) end let!(:user_logged_in_after_notification) do - create(:user, organization: organization, + create(:user, organization:, current_sign_in_at: 1.day.ago, created_at: 400.days.ago, extended_data: { "inactivity_notification" => { "notification_type" => "second", "sent_at" => 7.days.ago } }) diff --git a/decidim-core/spec/queries/decidim/public_activities_spec.rb b/decidim-core/spec/queries/decidim/public_activities_spec.rb index 16f690fe87181..aa91e645fbb4d 100644 --- a/decidim-core/spec/queries/decidim/public_activities_spec.rb +++ b/decidim-core/spec/queries/decidim/public_activities_spec.rb @@ -15,15 +15,15 @@ let(:private_assembly) { create(:assembly, :private, organization:) } before do - # Note that it is possible to add private users also to public processes + # Note that it is possible to add members also to public processes # and assemblies, there is no programming logic forbidding that to happen. [process, assembly, private_process, private_assembly].each do |space| - 10.times { create(:participatory_space_private_user, user: build(:user, :confirmed, organization:), privatable_to: space) } + 10.times { create(:member, user: build(:user, :confirmed, organization:), privatable_to: space) } end # Add the user to both private spaces - create(:participatory_space_private_user, user:, privatable_to: private_process) - create(:participatory_space_private_user, user:, privatable_to: private_assembly) + create(:member, user:, privatable_to: private_process) + create(:member, user:, privatable_to: private_assembly) end describe "#query" do @@ -43,7 +43,7 @@ context "when the current user has access to the private space" do before do - create(:participatory_space_private_user, user: current_user, privatable_to: private_process) + create(:member, user: current_user, privatable_to: private_process) end it "returns also the private comment without duplicates" do diff --git a/decidim-core/spec/services/decidim/download_your_data_exporter_spec.rb b/decidim-core/spec/services/decidim/download_your_data_exporter_spec.rb index 74c4ff091faa8..26c437d9d6ce5 100644 --- a/decidim-core/spec/services/decidim/download_your_data_exporter_spec.rb +++ b/decidim-core/spec/services/decidim/download_your_data_exporter_spec.rb @@ -16,7 +16,7 @@ module Decidim decidim-identities- decidim-messaging-conversations- decidim-notifications- - decidim-participatoryspaceprivateusers- + decidim-participatoryspace-members- decidim-reports- decidim-users- decidim-meetings-registrations- @@ -98,9 +98,9 @@ module Decidim it_behaves_like "a download your data entity" end - context "when the user has participatory_space_private_user" do - let!(:participatory_space_private_user) { create(:participatory_space_private_user, user:) } - let(:help_definition_string) { "The role that this private user has" } + context "when the user has member" do + let!(:member) { create(:member, user:) } + let(:help_definition_string) { "The role that this member has" } it_behaves_like "a download your data entity" end diff --git a/decidim-core/spec/system/editor_spec.rb b/decidim-core/spec/system/editor_spec.rb index 82d0b3ba3b7c6..9f76c6dcec7de 100644 --- a/decidim-core/spec/system/editor_spec.rb +++ b/decidim-core/spec/system/editor_spec.rb @@ -1274,7 +1274,7 @@ def protect_against_forgery? it "allows selecting resource mentions with a slash" do allow(Decidim::SearchableResource).to receive(:where).with( resource_type: %w(Decidim::Proposals::Proposal), - organization: organization, + organization:, decidim_participatory_space: participatory_space, locale: I18n.locale ).and_return(double( diff --git a/decidim-core/spec/system/link_target_spec.rb b/decidim-core/spec/system/link_target_spec.rb index 7afcad6a162e6..1528cf098407f 100644 --- a/decidim-core/spec/system/link_target_spec.rb +++ b/decidim-core/spec/system/link_target_spec.rb @@ -4,8 +4,8 @@ describe "Admin editor link target remains" do let(:organization) { create(:organization) } - let(:admin) { create(:user, :admin, :confirmed, organization: organization) } - let(:participatory_process) { create(:participatory_process, organization: organization) } + let(:admin) { create(:user, :admin, :confirmed, organization:) } + let(:participatory_process) { create(:participatory_process, organization:) } let(:component) { create(:component, manifest_name: "pages", participatory_space: participatory_process) } before do diff --git a/decidim-core/spec/tasks/upgrade/decidim_remove_deleted_users_left_data_spec.rb b/decidim-core/spec/tasks/upgrade/decidim_remove_deleted_users_left_data_spec.rb index 0bc4b466a40e8..77c83050339e6 100644 --- a/decidim-core/spec/tasks/upgrade/decidim_remove_deleted_users_left_data_spec.rb +++ b/decidim-core/spec/tasks/upgrade/decidim_remove_deleted_users_left_data_spec.rb @@ -15,9 +15,9 @@ create(:reminder, user: deleted_user) create(:private_export, attached_to: deleted_user) create(:identity, user: deleted_user) - create(:follow, followable: deleted_user, user: user) + create(:follow, followable: deleted_user, user:) create(:follow, followable: user, user: deleted_user) - create(:participatory_space_private_user, user: deleted_user) + create(:member, user: deleted_user) create(:oauth_access_token, resource_owner_id: user.id) create(:identity, user:) @@ -64,8 +64,8 @@ expect { task.execute }.to change(Decidim::Identity, :count).by(-1) end - it "deletes the participatory space private user of deleted user" do - expect { task.execute }.to change(Decidim::ParticipatorySpacePrivateUser, :count).by(-1) + it "deletes the member of deleted user" do + expect { task.execute }.to change(Decidim::ParticipatorySpace::Member, :count).by(-1) end it "deletes the badges of deleted user" do diff --git a/decidim-core/spec/tasks/upgrade/fix_deleted_private_follows_spec.rb b/decidim-core/spec/tasks/upgrade/fix_deleted_private_follows_spec.rb index a0c4cd056668e..98a84e8b34067 100644 --- a/decidim-core/spec/tasks/upgrade/fix_deleted_private_follows_spec.rb +++ b/decidim-core/spec/tasks/upgrade/fix_deleted_private_follows_spec.rb @@ -8,12 +8,12 @@ let(:user) { create(:user, :admin, :confirmed, organization:) } let(:second_user) { create(:user, :confirmed, organization:) } let(:component) { create(:dummy_component, :published, participatory_space:) } - let!(:followable) { create(:dummy_resource, component: component, author: user) } + let!(:followable) { create(:dummy_resource, component:, author: user) } let!(:follow) { create(:follow, user:, followable: participatory_space) } let!(:unwanted_follow) { create(:follow, user: second_user, followable: participatory_space) } let!(:resource_follow) { create(:follow, followable:, user:) } let!(:resource_unwanted_follow) { create(:follow, followable:, user: second_user) } - let!(:participatory_space_private_user) { create(:participatory_space_private_user, user:, privatable_to: participatory_space) } + let!(:member) { create(:member, user:, privatable_to: participatory_space) } let(:participatory_space) { create(:participatory_process, :published, organization: user.organization) } around do |example| @@ -27,7 +27,7 @@ context "when assembly is private and non transparent" do let(:participatory_space) { create(:assembly, :private, :published, :opaque, organization: user.organization) } - it "deletes follows of non private users" do + it "deletes follows of non members" do # we have 2 follows, one for assembly, and one for a "child" resource expect { task.execute }.to change(Decidim::Follow, :count).by(-2) end @@ -36,7 +36,7 @@ context "when assembly is private but transparent" do let(:participatory_space) { create(:assembly, :private, :published, organization: user.organization) } - it "preserves follows of non private users" do + it "preserves follows of non members" do # we have 2 follows, one for assembly, and one for a "child" resource expect { task.execute }.not_to change(Decidim::Follow, :count) end @@ -45,7 +45,7 @@ context "when assembly is public" do let(:participatory_space) { create(:assembly, :published, organization: user.organization) } - it "preserves follows of non private users" do + it "preserves follows of non members" do # we have 2 follows, one for assembly, and one for a "child" resource expect { task.execute }.not_to change(Decidim::Follow, :count) end @@ -54,7 +54,7 @@ context "when process is private" do let(:participatory_space) { create(:participatory_process, :private, :published, organization: user.organization) } - it "deletes follows of non private users" do + it "deletes follows of non members" do # we have 2 follows, one for process, and one for a "child" resource expect { task.execute }.to change(Decidim::Follow, :count).by(-2) end @@ -63,7 +63,7 @@ context "when process is public" do let(:participatory_space) { create(:participatory_process, :published, organization: user.organization) } - it "preserves follows of non private users" do + it "preserves follows of non members" do expect { task.execute }.not_to change(Decidim::Follow, :count) end end diff --git a/decidim-core/spec/types/reportable_type_spec.rb b/decidim-core/spec/types/reportable_type_spec.rb index 55944375888fa..ef7ec7f5c15d2 100644 --- a/decidim-core/spec/types/reportable_type_spec.rb +++ b/decidim-core/spec/types/reportable_type_spec.rb @@ -11,6 +11,12 @@ module Core include_examples "timestamps interface" + shared_examples "unauthorized User object" do + it "throws Decidim::Api::Errors::UnauthorizedObjectError" do + expect { response }.to raise_error(Decidim::Api::Errors::UnauthorizedObjectError, "You cannot view or edit this User because you do not have permissions") + end + end + describe "id" do let(:query) { "{ id }" } @@ -56,17 +62,13 @@ module Core context "when user reporting deleted his account" do let!(:model) { create(:report, moderation:, user: create(:user, :confirmed, :deleted, organization: moderation.reportable.organization), details: "Testing reason", locale: "en") } - it "returns nil" do - expect(response["user"]).to be_nil - end + it_behaves_like "unauthorized User object" end context "when user reporting got blocked" do let!(:model) { create(:report, moderation:, user: create(:user, :confirmed, :blocked, organization: moderation.reportable.organization), details: "Testing reason", locale: "en") } - it "returns nil" do - expect(response["user"]).to be_nil - end + it_behaves_like "unauthorized User object" end end end diff --git a/decidim-core/spec/types/reportable_user_type_spec.rb b/decidim-core/spec/types/reportable_user_type_spec.rb index 343e647a172c6..6cc9cce746cbf 100644 --- a/decidim-core/spec/types/reportable_user_type_spec.rb +++ b/decidim-core/spec/types/reportable_user_type_spec.rb @@ -11,6 +11,12 @@ module Core include_examples "timestamps interface" + shared_examples "unauthorized User object" do + it "throws Decidim::Api::Errors::UnauthorizedObjectError" do + expect { response }.to raise_error(Decidim::Api::Errors::UnauthorizedObjectError, "You cannot view or edit this User because you do not have permissions") + end + end + describe "id" do let(:query) { "{ id }" } @@ -47,24 +53,18 @@ module Core let!(:model) { create(:user_report, moderation:, user: moderation.user, details: "Testing reason") } - it "returns nil" do - expect(response["user"]).to be_nil - end + it_behaves_like "unauthorized User object" context "when the user that made the report deleted their account" do let(:moderation) { create(:user_moderation, user: create(:user, :confirmed, :deleted)) } - it "returns nil" do - expect(response["user"]).to be_nil - end + it_behaves_like "unauthorized User object" end context "when the user that made the reporting got blocked" do let(:moderation) { create(:user_moderation, user: create(:user, :confirmed, :blocked)) } - it "returns nil" do - expect(response["user"]).to be_nil - end + it_behaves_like "unauthorized User object" end end end diff --git a/decidim-core/spec/types/user_type_spec.rb b/decidim-core/spec/types/user_type_spec.rb index 78e206d2e8f3c..a66e251dc6dc0 100644 --- a/decidim-core/spec/types/user_type_spec.rb +++ b/decidim-core/spec/types/user_type_spec.rb @@ -13,31 +13,31 @@ module Core include_examples "timestamps interface" include_examples "followable interface" + shared_examples "unauthorized User object" do + it "throws Decidim::Api::Errors::UnauthorizedObjectError" do + expect { response }.to raise_error(Decidim::Api::Errors::UnauthorizedObjectError, "You cannot view or edit this User because you do not have permissions") + end + end + describe "unconfirmed user" do let(:model) { create(:user) } let(:query) { "{ id }" } - it "returns nothing" do - expect(response).to be_nil - end + it_behaves_like "unauthorized User object" end describe "deleted user" do let(:model) { create(:user, :deleted) } let(:query) { "{ id }" } - it "returns nothing" do - expect(response).to be_nil - end + it_behaves_like "unauthorized User object" end describe "moderated user" do let(:model) { create(:user, :blocked) } let(:query) { "{ id }" } - it "returns nothing" do - expect(response).to be_nil - end + it_behaves_like "unauthorized User object" end describe "name" do @@ -124,9 +124,7 @@ module Core context "when user is deleted" do let(:model) { create(:user, :deleted) } - it "returns empty" do - expect(response).to be_nil - end + it_behaves_like "unauthorized User object" end end diff --git a/decidim-debates/app/views/decidim/debates/debates/index.html.erb b/decidim-debates/app/views/decidim/debates/debates/index.html.erb index 230ae2850bdb6..7286167642cdd 100644 --- a/decidim-debates/app/views/decidim/debates/debates/index.html.erb +++ b/decidim-debates/app/views/decidim/debates/debates/index.html.erb @@ -1,7 +1,7 @@ <% add_decidim_meta_tags( description: translated_attribute(current_participatory_space.short_description), title: t("decidim.components.pagination.page_title", - component_name: component_name, + component_name:, current_page: paginated_debates.current_page, total_pages: paginated_debates.total_pages ), url: debates_url, diff --git a/decidim-debates/config/locales/ja.yml b/decidim-debates/config/locales/ja.yml index 7da9070acb9ed..377904e1bd22d 100644 --- a/decidim-debates/config/locales/ja.yml +++ b/decidim-debates/config/locales/ja.yml @@ -32,6 +32,7 @@ ja: comment: コメント create: ディベートを作成 like: いいね + vote_comment: コメントに投票 name: ディベート settings: global: diff --git a/decidim-debates/lib/decidim/api/debates_type.rb b/decidim-debates/lib/decidim/api/debates_type.rb index af65b858ec16c..c2d8991076d57 100644 --- a/decidim-debates/lib/decidim/api/debates_type.rb +++ b/decidim-debates/lib/decidim/api/debates_type.rb @@ -15,8 +15,8 @@ def debates Debate.where(component: object).includes(:component) end - def debate(**args) - Debate.where(component: object).find_by(id: args[:id]) + def debate(id:) + Decidim::Core::ComponentFinderBase.new(model_class: Debate).call(object, { id: }, context) end end end diff --git a/decidim-debates/spec/system/debates_breadcrumbs_spec.rb b/decidim-debates/spec/system/debates_breadcrumbs_spec.rb new file mode 100644 index 0000000000000..2c7132a76255a --- /dev/null +++ b/decidim-debates/spec/system/debates_breadcrumbs_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Debates Breadcrumb" do + include_context "with a component" + let(:manifest_name) { "debates" } + + let!(:debate) { create(:debate, component:) } + + before do + visit_component + end + + describe "index" do + it "shows the correct information in breadcrumb (space, component)" do + within(".menu-bar") do + expect(page).to have_content(translated(component.participatory_space.title)) + expect(page).to have_content(translated(component.name)) + end + end + end + + describe "show" do + it "shows the correct information in breadcrumb (space, component, debate)" do + click_on translated(debate.title), class: "card__list" + + within(".menu-bar") do + expect(page).to have_content(translated(component.participatory_space.title)) + expect(page).to have_content(translated(component.name)) + expect(page).to have_content(translated(debate.title)) + end + end + end + + describe "versions", versioning: true do + let(:additional_description) { generate_localized_description(:debate_description) } + + before do + Decidim.traceability.update!( + debate, + "Dummy author", + description: additional_description + ) + click_on translated(debate.title), class: "card__list" + click_on "see other versions" + end + + it "shows the correct information in breadcrumb (space, component, debate)" do + within(".menu-bar") do + expect(page).to have_content(translated(component.participatory_space.title)) + expect(page).to have_content(translated(component.name)) + expect(page).to have_content(translated(debate.title)) + end + end + end +end diff --git a/decidim-debates/spec/system/debates_versions_spec.rb b/decidim-debates/spec/system/debates_versions_spec.rb index 0cf4735a66503..f75e6b2c21be8 100644 --- a/decidim-debates/spec/system/debates_versions_spec.rb +++ b/decidim-debates/spec/system/debates_versions_spec.rb @@ -14,10 +14,6 @@ end it "has only one version" do - within(".menu-bar") do - expect(page).to have_content(translated(component.name)) - expect(page).to have_content(translated(debate.title)) - end expect(page).to have_content("Version number 1 (of 1)") end diff --git a/decidim-debates/spec/system/private_space_debate_spec.rb b/decidim-debates/spec/system/private_space_debate_spec.rb index 716cccde301ba..c009bc94f07eb 100644 --- a/decidim-debates/spec/system/private_space_debate_spec.rb +++ b/decidim-debates/spec/system/private_space_debate_spec.rb @@ -10,7 +10,7 @@ let(:user) { create(:user, :confirmed, organization:) } let!(:other_user) { create(:user, :confirmed, organization:) } - let!(:participatory_space_private_user) { create(:participatory_space_private_user, user: other_user, privatable_to: participatory_space_private) } + let!(:member) { create(:member, user: other_user, privatable_to: participatory_space_private) } let!(:participatory_space) { participatory_space_private } @@ -39,7 +39,7 @@ def visit_component end context "when the user is logged in" do - context "and is private user space" do + context "and is member space" do before do login_as other_user, scope: :user end @@ -51,7 +51,7 @@ def visit_component end end - context "and is not private user space" do + context "and is not member space" do before do login_as user, scope: :user end @@ -83,7 +83,7 @@ def visit_component end context "when the user is logged in" do - context "and is private user space" do + context "and is member space" do before do login_as other_user, scope: :user end @@ -104,7 +104,7 @@ def visit_component end end - context "and is not private user space" do + context "and is not member space" do let(:target_path) { main_component_path(component) } before do diff --git a/decidim-debates/spec/system/show_spec.rb b/decidim-debates/spec/system/show_spec.rb index 579d5f892591c..0dfda11ee6cf5 100644 --- a/decidim-debates/spec/system/show_spec.rb +++ b/decidim-debates/spec/system/show_spec.rb @@ -66,10 +66,6 @@ context "when shows the debate component" do it "shows the debate title" do - within(".menu-bar") do - expect(page).to have_content(translated(component.name)) - expect(page).to have_content(translated(debate.title)) - end expect(page).to have_content(translated(debate.title)) end end diff --git a/decidim-debates/spec/types/debate_type_spec.rb b/decidim-debates/spec/types/debate_type_spec.rb index bf11613707e2e..ee604b48146e3 100644 --- a/decidim-debates/spec/types/debate_type_spec.rb +++ b/decidim-debates/spec/types/debate_type_spec.rb @@ -23,6 +23,12 @@ module Debates let(:model) { create(:debate, :ongoing_ama, :with_likes) } end + shared_examples "unauthorized Debate" do + it "throws Decidim::Api::Errors::UnauthorizedObjectError" do + expect { response }.to raise_error(Decidim::Api::Errors::UnauthorizedObjectError, "You cannot view or edit this Debate because you do not have permissions") + end + end + describe "id" do let(:query) { "{ id }" } @@ -140,9 +146,7 @@ module Debates let(:model) { create(:debate, :ongoing_ama, component: current_component) } let(:query) { "{ id }" } - it "returns nothing" do - expect(response).to be_nil - end + it_behaves_like "unauthorized Debate" end context "when participatory space is private but transparent" do @@ -162,9 +166,7 @@ module Debates let(:model) { create(:debate, :ongoing_ama, component: current_component) } let(:query) { "{ id }" } - it "returns nothing" do - expect(response).to be_nil - end + it_behaves_like "unauthorized Debate" end context "when component is not published" do @@ -172,9 +174,7 @@ module Debates let(:model) { create(:debate, :ongoing_ama, component: current_component) } let(:query) { "{ id }" } - it "returns nothing" do - expect(response).to be_nil - end + it_behaves_like "unauthorized Debate" end context "when debate is moderated" do @@ -182,9 +182,7 @@ module Debates let(:query) { "{ id }" } let(:root_value) { model.reload } - it "returns nothing" do - expect(response).to be_nil - end + it_behaves_like "unauthorized Debate" end end end diff --git a/decidim-debates/spec/types/debates_type_spec.rb b/decidim-debates/spec/types/debates_type_spec.rb index 5b6fe5ae19d36..761a5ee5b748f 100644 --- a/decidim-debates/spec/types/debates_type_spec.rb +++ b/decidim-debates/spec/types/debates_type_spec.rb @@ -39,8 +39,8 @@ module Debates context "when the debate does not belong to the component" do let(:debate) { create(:debate, component: create(:debates_component)) } - it "does not find the debate" do - expect(response["debate"]).to be_nil + it "raises error" do + expect { response }.to raise_error(Decidim::Api::Errors::NotFoundError, "Debate not found") end end end diff --git a/decidim-dev/config/rubocop/graphql/configuration.yml b/decidim-dev/config/rubocop/graphql/configuration.yml index 113e2a9bb2e6b..11b7860cf4a37 100644 --- a/decidim-dev/config/rubocop/graphql/configuration.yml +++ b/decidim-dev/config/rubocop/graphql/configuration.yml @@ -17,6 +17,7 @@ GraphQL/ObjectDescription: - "**/lib/decidim/api/graphiql/config.rb" - "**/lib/decidim/api/graphql_permissions.rb" - "**/lib/decidim/api/functions/*" + - "**/lib/decidim/api/errors/*" - "spec/**/*" - "test/**/*" diff --git a/decidim-dev/config/rubocop/ruby/configuration.yml b/decidim-dev/config/rubocop/ruby/configuration.yml index f02aa6e69ba2f..b35906036929c 100644 --- a/decidim-dev/config/rubocop/ruby/configuration.yml +++ b/decidim-dev/config/rubocop/ruby/configuration.yml @@ -521,6 +521,7 @@ Style/GuardClause: MinBodyLength: 6 Style/HashSyntax: + EnforcedShorthandSyntax: always EnforcedStyle: no_mixed_keys SupportedStyles: # checks for 1.9 syntax (e.g. {a: 1}) for all symbol keys diff --git a/decidim-dev/lib/decidim/dev/assets/import_participatory_space_private_users.csv b/decidim-dev/lib/decidim/dev/assets/import_members.csv similarity index 100% rename from decidim-dev/lib/decidim/dev/assets/import_participatory_space_private_users.csv rename to decidim-dev/lib/decidim/dev/assets/import_members.csv diff --git a/decidim-dev/lib/decidim/dev/assets/import_participatory_space_private_users_invalid_col_sep.csv b/decidim-dev/lib/decidim/dev/assets/import_members_invalid_col_sep.csv similarity index 100% rename from decidim-dev/lib/decidim/dev/assets/import_participatory_space_private_users_invalid_col_sep.csv rename to decidim-dev/lib/decidim/dev/assets/import_members_invalid_col_sep.csv diff --git a/decidim-dev/lib/decidim/dev/assets/import_participatory_space_private_users_iso8859-1.csv b/decidim-dev/lib/decidim/dev/assets/import_members_iso8859-1.csv similarity index 100% rename from decidim-dev/lib/decidim/dev/assets/import_participatory_space_private_users_iso8859-1.csv rename to decidim-dev/lib/decidim/dev/assets/import_members_iso8859-1.csv diff --git a/decidim-dev/lib/decidim/dev/assets/import_participatory_space_private_users_nok.csv b/decidim-dev/lib/decidim/dev/assets/import_members_nok.csv similarity index 100% rename from decidim-dev/lib/decidim/dev/assets/import_participatory_space_private_users_nok.csv rename to decidim-dev/lib/decidim/dev/assets/import_members_nok.csv diff --git a/decidim-dev/lib/decidim/dev/assets/import_participatory_space_private_users_with_bom.csv b/decidim-dev/lib/decidim/dev/assets/import_members_with_bom.csv similarity index 100% rename from decidim-dev/lib/decidim/dev/assets/import_participatory_space_private_users_with_bom.csv rename to decidim-dev/lib/decidim/dev/assets/import_members_with_bom.csv diff --git a/decidim-dev/spec/types/errors_spec.rb b/decidim-dev/spec/types/errors_spec.rb new file mode 100644 index 0000000000000..170615943a778 --- /dev/null +++ b/decidim-dev/spec/types/errors_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require "spec_helper" +require "decidim/api/test" + +describe "Decidim::Api::Errors" do + include_context "with a graphql class type" + + let(:query) do + %( + query { + testedError + } + ) + end + + let(:schema) do + klass = type_class + Class.new(Decidim::Api::Schema) do + query klass + end + end + + before do + I18n.backend.reload! + I18n.backend.store_translations( + :en, + decidim: { + test: "Test %{name}" + } + ) + end + + context "when Decidim::Api::Errors::PermissionNotSetError is raised" do + let(:type_class) do + Class.new(Decidim::Api::Types::BaseObject) do + graphql_name "ErrorTypeTest" + field :tested_error, String, null: false + + def tested_error + raise Decidim::PermissionAction::PermissionNotSetError, "Exemplifying permission not set error" + end + end + end + + it "throws exception" do + expect { response }.to raise_error(Decidim::Api::Errors::PermissionNotSetError, /Permission has not been set for this/) + end + end + + context "when Decidim::Api::Errors::LocaleError is raised because of I18n::MissingInterpolationArgument is raised" do + let(:type_class) do + Class.new(Decidim::Api::Types::BaseObject) do + graphql_name "ErrorTypeTest" + field :tested_error, String, null: false + + def tested_error + I18n.translate!("decidim.test", invalid_interpolation: "Testing Missing interpolation argument") + end + end + end + + it "throws exception" do + expect { response }.to raise_error(Decidim::Api::Errors::LocaleError, "There was an error while internally handling i18n data") + end + end + + context "when Decidim::Api::Errors::LocaleError is raised because of I18n::MissingTranslationData is raised" do + let(:type_class) do + Class.new(Decidim::Api::Types::BaseObject) do + graphql_name "ErrorTypeTest" + field :tested_error, String, null: false + + def tested_error + I18n.translate!("decidim.invalid_translation_key") + end + end + end + + it "throws exception" do + expect { response }.to raise_error(Decidim::Api::Errors::LocaleError, "There was an error while internally handling i18n data") + end + end + + context "when Decidim::Api::Errors::InvalidLocaleError is raised because of I18n::InvalidLocaleError is raised" do + let(:type_class) do + Class.new(Decidim::Api::Types::BaseObject) do + graphql_name "ErrorTypeTest" + field :tested_error, String, null: false + + def tested_error + I18n.with_locale("invalid_locale") do + I18n.translate!("decidim.test", name: "test") + end + end + end + end + + it "throws exception" do + expect { response }.to raise_error(Decidim::Api::Errors::InvalidLocaleError, "Invalid locale provided") + end + end +end diff --git a/decidim-elections/app/commands/decidim/elections/admin/process_census.rb b/decidim-elections/app/commands/decidim/elections/admin/process_census.rb index f56e06415240e..9bbf4c67ee5a9 100644 --- a/decidim-elections/app/commands/decidim/elections/admin/process_census.rb +++ b/decidim-elections/app/commands/decidim/elections/admin/process_census.rb @@ -9,7 +9,7 @@ class ProcessCensus < Decidim::Commands::UpdateResource def attributes { census_manifest: resource.census.name, - census_settings: census_settings + census_settings: } end diff --git a/decidim-elections/app/commands/decidim/elections/cast_votes.rb b/decidim-elections/app/commands/decidim/elections/cast_votes.rb index 0f5a93655d823..d3842a8c186c2 100644 --- a/decidim-elections/app/commands/decidim/elections/cast_votes.rb +++ b/decidim-elections/app/commands/decidim/elections/cast_votes.rb @@ -40,11 +40,11 @@ def save_votes! voted_questions.each do |question, responses| raise StandardError, "No responses for question #{question.id}" if responses.blank? - question.votes.where(voter_uid: voter_uid).destroy_all + question.votes.where(voter_uid:).destroy_all responses.each do |response_option| question.votes.create!( - voter_uid: voter_uid, - response_option: response_option + voter_uid:, + response_option: ) end end diff --git a/decidim-elections/app/controllers/concerns/decidim/elections/uses_votes_booth.rb b/decidim-elections/app/controllers/concerns/decidim/elections/uses_votes_booth.rb index 08cf7b7c030c8..4561b6e6bfbeb 100644 --- a/decidim-elections/app/controllers/concerns/decidim/elections/uses_votes_booth.rb +++ b/decidim-elections/app/controllers/concerns/decidim/elections/uses_votes_booth.rb @@ -49,10 +49,16 @@ def create # Shows the receipt page def receipt + if params[:exit].present? + votes_buffer.clear + session_attributes.clear + return redirect_to(exit_path) + end + enforce_permission_to(:create, :vote, election:) - votes_buffer.clear - session_attributes.clear + votes_buffer.clear unless election.per_question? + return redirect_to(exit_path) unless election.votes.exists?(voter_uid: session[:voter_uid]) render "decidim/elections/votes/receipt" @@ -85,7 +91,7 @@ def response_chosen?(response_option) def previous_responses @previous_responses ||= election.questions.to_h do |question| - [question.id.to_s, question.votes.where(voter_uid: voter_uid).pluck(:response_option_id).map(&:to_s)] + [question.id.to_s, question.votes.where(voter_uid:).pluck(:response_option_id).map(&:to_s)] end end end diff --git a/decidim-elections/app/controllers/decidim/elections/admin/census_controller.rb b/decidim-elections/app/controllers/decidim/elections/admin/census_controller.rb index d22ef580641ad..833b58c7ccecb 100644 --- a/decidim-elections/app/controllers/decidim/elections/admin/census_controller.rb +++ b/decidim-elections/app/controllers/decidim/elections/admin/census_controller.rb @@ -11,15 +11,15 @@ class CensusController < Admin::ApplicationController before_action :set_census_manifest, only: [:edit, :update] def edit - enforce_permission_to :update, :census, election: election + enforce_permission_to(:update, :census, election:) - @form = form(election.census.admin_form.constantize).from_params(election.census_settings, election: election) if election.census && election.census.admin_form.present? + @form = form(election.census.admin_form.constantize).from_params(election.census_settings, election:) if election.census && election.census.admin_form.present? end def update - enforce_permission_to :update, :census, election: election + enforce_permission_to(:update, :census, election:) - @form = form(election.census.admin_form.constantize).from_params(params, election: election) if election.census.admin_form.present? + @form = form(election.census.admin_form.constantize).from_params(params, election:) if election.census.admin_form.present? ProcessCensus.call(@form, election) do on(:ok) do diff --git a/decidim-elections/app/controllers/decidim/elections/admin/elections_controller.rb b/decidim-elections/app/controllers/decidim/elections/admin/elections_controller.rb index bddf6d142ed39..2b7da14469687 100644 --- a/decidim-elections/app/controllers/decidim/elections/admin/elections_controller.rb +++ b/decidim-elections/app/controllers/decidim/elections/admin/elections_controller.rb @@ -46,7 +46,7 @@ def edit end def update - enforce_permission_to :update, :election, election: election + enforce_permission_to(:update, :election, election:) @form = form(Decidim::Elections::Admin::ElectionForm).from_params(params, current_component:, election:) @@ -64,7 +64,7 @@ def update end def publish - enforce_permission_to :publish, :election, election: election + enforce_permission_to(:publish, :election, election:) PublishElection.call(election, current_user) do on(:ok) do @@ -80,7 +80,7 @@ def publish end def unpublish - enforce_permission_to :unpublish, :election, election: election + enforce_permission_to(:unpublish, :election, election:) Decidim::Elections::Admin::UnpublishElection.call(election, current_user) do on(:ok) do @@ -96,7 +96,7 @@ def unpublish end def dashboard - enforce_permission_to :dashboard, :election, election: election + enforce_permission_to(:dashboard, :election, election:) respond_to do |format| format.html { render :dashboard } @@ -107,7 +107,7 @@ def dashboard end def update_status - enforce_permission_to :update, :election, election: election + enforce_permission_to(:update, :election, election:) status_action = params[:status_action] UpdateElectionStatus.call(status_action, election) do @@ -123,7 +123,7 @@ def update_status end def toggle_census_check - enforce_permission_to :update, :election, election: election + enforce_permission_to(:update, :election, election:) value = ActiveModel::Type::Boolean.new.cast(params[:allow_census_check_before_start]) election.update!(allow_census_check_before_start: value) diff --git a/decidim-elections/app/controllers/decidim/elections/per_question_votes_controller.rb b/decidim-elections/app/controllers/decidim/elections/per_question_votes_controller.rb index cceaddb0fc58d..ebd2bb2d7681f 100644 --- a/decidim-elections/app/controllers/decidim/elections/per_question_votes_controller.rb +++ b/decidim-elections/app/controllers/decidim/elections/per_question_votes_controller.rb @@ -29,6 +29,7 @@ def update enforce_permission_to(:create, :vote, election:) response_ids = params.dig(:response, question.id.to_s) + requeue_following_questions votes_buffer[question.id.to_s] = response_ids CastVotes.call(election, { question.id.to_s => response_ids }, voter_uid) do on(:ok) do @@ -84,6 +85,13 @@ def next_vote_step_action { action: :show, id: next_question } end + + def requeue_following_questions + election.questions + .where("position > ?", question.position) + .pluck(:id) + .each { |id| votes_buffer.delete(id.to_s) } + end end end end diff --git a/decidim-elections/app/forms/decidim/elections/censuses/internal_users_form.rb b/decidim-elections/app/forms/decidim/elections/censuses/internal_users_form.rb index 9f4f67e2695e7..58e1d02cc8941 100644 --- a/decidim-elections/app/forms/decidim/elections/censuses/internal_users_form.rb +++ b/decidim-elections/app/forms/decidim/elections/censuses/internal_users_form.rb @@ -29,7 +29,7 @@ def authorizations [ adapter, Decidim::Verifications::Authorizations.new( - organization: organization, + organization:, user: current_user, name: adapter.name ).first diff --git a/decidim-elections/app/models/decidim/elections/vote.rb b/decidim-elections/app/models/decidim/elections/vote.rb index 1af4fdf945dd5..0c161cfdd34ec 100644 --- a/decidim-elections/app/models/decidim/elections/vote.rb +++ b/decidim-elections/app/models/decidim/elections/vote.rb @@ -31,7 +31,7 @@ def response_belong_to_question def max_votable_options return unless question && response_option - return if question.votes.where.not(id: id).where(voter_uid: voter_uid).count < question.max_votable_options + return if question.votes.where.not(id:).where(voter_uid:).count < question.max_votable_options errors.add(:response_option, :invalid) end diff --git a/decidim-elections/app/packs/src/decidim/elections/live_results_update.js b/decidim-elections/app/packs/src/decidim/elections/live_results_update.js index 335a7ad29154e..fa97538eac739 100644 --- a/decidim-elections/app/packs/src/decidim/elections/live_results_update.js +++ b/decidim-elections/app/packs/src/decidim/elections/live_results_update.js @@ -12,6 +12,7 @@ document.addEventListener("DOMContentLoaded", () => { const optionVoteCountTexts = () => document.querySelectorAll("[data-option-votes-count-text]"); const optionVotePercentTexts = () => document.querySelectorAll("[data-option-votes-percent-text]"); const optionVoteWidths = () => document.querySelectorAll("[data-option-votes-width]"); + const questionTotalVotesTexts = () => document.querySelectorAll("[data-question-total-votes-text]"); const animateText = (element, value) => { if (element.textContent === value) { @@ -24,6 +25,15 @@ document.addEventListener("DOMContentLoaded", () => { }, 1000); }; + const digQuestionValue = (questionId, data, key) => { + const questions = data.questions || []; + const question = questions.find((item) => item.id === parseInt(questionId, 10)); + if (!question || !(key in question)) { + return null; + } + return question[key]; + }; + const digOptionValue = (questionId, optionId, data, key) => { data.questions = data.questions || []; const question = data.questions.find((item) => item.id === parseInt(questionId, 10)); @@ -55,6 +65,11 @@ document.addEventListener("DOMContentLoaded", () => { questionElement.id = `question-${question.id}`; questionElement.classList.remove("hidden"); questionElement.querySelector("[data-question-body]").textContent = question.body; + const totalVotesElement = questionElement.querySelector("[data-question-total-votes-text]"); + if (totalVotesElement) { + totalVotesElement.dataset.questionTotalVotesText = question.id; + totalVotesElement.textContent = question.total_votes_text || ""; + } const optionsContainer = questionElement.querySelector("[data-options-container]"); question.response_options.forEach((option) => { const optionElement = optionTemplate.cloneNode(true); @@ -106,6 +121,13 @@ document.addEventListener("DOMContentLoaded", () => { el.style.width = `${val}%`; } }); + questionTotalVotesTexts().forEach((el) => { + const questionId = el.dataset.questionTotalVotesText; + const val = digQuestionValue(questionId, data, "total_votes_text"); + if (val) { + animateText(el, val); + } + }); // repeat for ongoing elections only if (data.ongoing) { setTimeout(fetchResults, 4000); diff --git a/decidim-elections/app/presenters/decidim/elections/election_presenter.rb b/decidim-elections/app/presenters/decidim/elections/election_presenter.rb index 0d002734754c6..1e129078b5f50 100644 --- a/decidim-elections/app/presenters/decidim/elections/election_presenter.rb +++ b/decidim-elections/app/presenters/decidim/elections/election_presenter.rb @@ -42,7 +42,13 @@ def to_json(admin: false) body: translated_attribute(question.body), position: question.position, voting_enabled: question.voting_enabled?, - published_results: question.published_results?, + published_results: question.published_results? + }.tap do |hash| + next unless admin || result_published_questions.include?(question) + + hash[:total_votes] = question.total_votes + hash[:total_votes_text] = I18n.t("total_votes", scope: "decidim.elections.elections.vote_results", count: question.total_votes) + end.merge( response_options: question.response_options.map do |option| { id: option.id, @@ -56,7 +62,7 @@ def to_json(admin: false) hash[:votes_percent] = option.votes_percent end end - } + ) end } end diff --git a/decidim-elections/app/views/decidim/elections/admin/censuses/_internal_users_form.html.erb b/decidim-elections/app/views/decidim/elections/admin/censuses/_internal_users_form.html.erb index 3457f04c1fcd0..86b35414e858a 100644 --- a/decidim-elections/app/views/decidim/elections/admin/censuses/_internal_users_form.html.erb +++ b/decidim-elections/app/views/decidim/elections/admin/censuses/_internal_users_form.html.erb @@ -10,7 +10,7 @@
<%= builder.label { builder.check_box + builder.text } %>
;"> - <%= render "decidim/elections/admin/censuses/internal_users_options_form", form: form, handler_name: builder.value %> + <%= render "decidim/elections/admin/censuses/internal_users_options_form", form:, handler_name: builder.value %>
<% end %> diff --git a/decidim-elections/app/views/decidim/elections/admin/dashboard/_questions_with_results.html.erb b/decidim-elections/app/views/decidim/elections/admin/dashboard/_questions_with_results.html.erb index 2e3c5642e5ce7..b6018efd93181 100644 --- a/decidim-elections/app/views/decidim/elections/admin/dashboard/_questions_with_results.html.erb +++ b/decidim-elections/app/views/decidim/elections/admin/dashboard/_questions_with_results.html.erb @@ -35,6 +35,11 @@ <%= number_to_percentage(option.votes_percent, precision: 1) %> <% end %> + + <%= t("decidim.elections.admin.dashboard.questions_table.total") %> + + <%= t("votes_count", scope: "decidim.elections.admin.dashboard.questions_table", count: question.total_votes) %> +
diff --git a/decidim-elections/app/views/decidim/elections/admin/elections/_election-tr.html.erb b/decidim-elections/app/views/decidim/elections/admin/elections/_election-tr.html.erb index 282943921e40e..21b3255c510be 100644 --- a/decidim-elections/app/views/decidim/elections/admin/elections/_election-tr.html.erb +++ b/decidim-elections/app/views/decidim/elections/admin/elections/_election-tr.html.erb @@ -25,6 +25,6 @@ <%= election.census&.label %> " class="table-list__actions"> - <%= render partial: "decidim/elections/admin/elections/actions", locals: { election: election, view: view } %> + <%= render partial: "decidim/elections/admin/elections/actions", locals: { election:, view: } %> diff --git a/decidim-elections/app/views/decidim/elections/censuses/_internal_users_form.html.erb b/decidim-elections/app/views/decidim/elections/censuses/_internal_users_form.html.erb index 7a286593fc830..2e1c535d2a928 100644 --- a/decidim-elections/app/views/decidim/elections/censuses/_internal_users_form.html.erb +++ b/decidim-elections/app/views/decidim/elections/censuses/_internal_users_form.html.erb @@ -17,7 +17,7 @@
<% end %> <%= link_to t(".exit_button"), exit_path, class: "button button__secondary button__lg w-full mt-12" %> - <%= render "decidim/elections/censuses/submit_button", form: form, disabled: true %> + <%= render "decidim/elections/censuses/submit_button", form:, disabled: true %> <% else %> <% redirect_url = new_election_vote_path(election) %>
@@ -54,7 +54,7 @@ <% end %> <% end %>
- <%= render "decidim/elections/censuses/submit_button", form: form %> + <%= render "decidim/elections/censuses/submit_button", form: %> <% end %> <% else %> <%= render partial: "decidim/devise/shared/login_boxes", locals: { scope: "decidim.elections.censuses.internal_users" } %> diff --git a/decidim-elections/app/views/decidim/elections/censuses/_token_csv_form.html.erb b/decidim-elections/app/views/decidim/elections/censuses/_token_csv_form.html.erb index 7912c3774865a..c3eb7616cbb84 100644 --- a/decidim-elections/app/views/decidim/elections/censuses/_token_csv_form.html.erb +++ b/decidim-elections/app/views/decidim/elections/censuses/_token_csv_form.html.erb @@ -1,4 +1,4 @@ <%= form.text_field :email, label: t(".email"), placeholder: t(".email_placeholder"), autofocus: true, required: true %> <%= form.text_field :token, label: t(".token"), placeholder: t(".token_placeholder"), required: true %> -<%= render "decidim/elections/censuses/submit_button", form: form %> +<%= render "decidim/elections/censuses/submit_button", form: %> diff --git a/decidim-elections/app/views/decidim/elections/elections/_election_aside.html.erb b/decidim-elections/app/views/decidim/elections/elections/_election_aside.html.erb index 0826ccd0bb05c..91442a6932dc0 100644 --- a/decidim-elections/app/views/decidim/elections/elections/_election_aside.html.erb +++ b/decidim-elections/app/views/decidim/elections/elections/_election_aside.html.erb @@ -1,7 +1,8 @@
<% if election.ongoing? %> - <%= link_to t("vote_button", scope: "decidim.elections.elections.show"), new_election_vote_path(election), class: "button button__secondary button__lg" %> + <% button_key = voted_by_current_user?(election) ? "edit_vote_button" : "vote_button" %> + <%= link_to t(button_key, scope: "decidim.elections.elections.show"), new_election_vote_path(election), class: "button button__secondary button__lg" %> <% if voted_by_current_user?(election) %>
diff --git a/decidim-elections/app/views/decidim/elections/elections/index.html.erb b/decidim-elections/app/views/decidim/elections/elections/index.html.erb index 80f249f8b1743..b9e16b4babfd6 100644 --- a/decidim-elections/app/views/decidim/elections/elections/index.html.erb +++ b/decidim-elections/app/views/decidim/elections/elections/index.html.erb @@ -1,7 +1,7 @@ <% add_decidim_meta_tags( description: translated_attribute(current_participatory_space.short_description), title: t("decidim.components.pagination.page_title", - component_name: component_name, + component_name:, current_page: paginated_elections.current_page, total_pages: paginated_elections.total_pages ), url: elections_url, diff --git a/decidim-elections/app/views/decidim/elections/elections/show.html.erb b/decidim-elections/app/views/decidim/elections/elections/show.html.erb index b0d243dba2187..72ffbc413b70a 100644 --- a/decidim-elections/app/views/decidim/elections/elections/show.html.erb +++ b/decidim-elections/app/views/decidim/elections/elections/show.html.erb @@ -9,7 +9,7 @@ resource_locator(election).edit, :update, :election, - election: election + election: ) %> diff --git a/decidim-elections/app/views/decidim/elections/votes/receipt.html.erb b/decidim-elections/app/views/decidim/elections/votes/receipt.html.erb index 7bdeb45e33781..9d240b5609fa5 100644 --- a/decidim-elections/app/views/decidim/elections/votes/receipt.html.erb +++ b/decidim-elections/app/views/decidim/elections/votes/receipt.html.erb @@ -4,5 +4,22 @@

<%= t(".title") %>

<%= t(".description") %>

- <%= link_to t(".exit_button"), exit_path, class: "button button__secondary button__lg w-full mt-12" %> + + <% editable_question = election.per_question? ? election.questions.enabled.unpublished_results.last : nil %> + <% exit_button_path = election.per_question? ? url_for(action: :receipt, exit: true) : exit_path %> + <% if editable_question.present? %> +
+ <%= link_to election_per_question_vote_path(election_id: election, id: editable_question), + class: "button button__lg button__transparent-secondary" do %> + <%= icon "edit-line", class: "fill-current mr-2" %> + <%= t(".edit_vote") %> + <% end %> + +
+ <%= link_to t(".exit_button"), exit_button_path, class: "button button__lg button__secondary" %> +
+
+ <% else %> + <%= link_to t(".exit_button"), exit_button_path, class: "button button__secondary button__lg w-full mt-12" %> + <% end %>
diff --git a/decidim-elections/config/locales/en.yml b/decidim-elections/config/locales/en.yml index a6d488e49d941..489ddf952d99a 100644 --- a/decidim-elections/config/locales/en.yml +++ b/decidim-elections/config/locales/en.yml @@ -82,6 +82,7 @@ en: questions_table: answer: Answers percentage: Percentage + total: Total votes: Votes votes_count: one: 1 vote @@ -257,6 +258,7 @@ en: active_voting_until: 'Active voting until: %{end_date}' check_census_button: Check if I can vote check_census_explanation: This election has not started yet, but you can check if you are included in the census. + edit_vote_button: Edit vote vote_button: Vote voted: You have already voted. You can vote again, only your last vote will be counted. votes_count: @@ -269,6 +271,10 @@ en: per_question: Results are available per question. You can see the results for each question after voting is enabled and results are published. real_time: Results are available in real time. You can see the results while voting is in progress. title: Results + total: 'TOTAL:' + total_votes: + one: 1 vote + other: "%{count} votes" models: election: fields: @@ -316,7 +322,8 @@ en: max_choices_exceeded: You cannot select more than %{max} options. Please go back and adjust your selection. next: Next receipt: - description: Yo can vote again at any time while the voting period is open. Your previous vote will be overwritten by the new one. + description: You can vote again at any time while the voting period is open. Your previous vote will be overwritten by the new one. + edit_vote: Edit your vote exit_button: Exit the voting booth title: Your votes have been cast successfully metadata: diff --git a/decidim-elections/config/locales/eu.yml b/decidim-elections/config/locales/eu.yml index aceb1772ed9d5..f07c6557a04b6 100644 --- a/decidim-elections/config/locales/eu.yml +++ b/decidim-elections/config/locales/eu.yml @@ -96,6 +96,7 @@ eu: start_question_button: Botoa eman daiteke title: Emaitzak status: + allow_census_check_before_start: Parte hartzaileek botoa ematen duten ala ez egiaztatu ahal izango dute aukeraketa hasi aurretik census: 'Errolda:' results_availability: after_end: Emaitzak eskuragarri bozketak amaitu ondoren @@ -140,6 +141,8 @@ eu: publish: invalid: Arazo bat egon da hauteskundea argitaratzean. success: Hauteskundea zuzen argitaratua. + toggle_census_check: + error: Akats bat gertatu da errolda egiaztatzean. unpublish: invalid: Arazo bat egon da hauteskundea despublikatzean. success: Hauteskundea zuzen despublikatua. @@ -182,6 +185,11 @@ eu: update: "%{user_name} parte-hartzaileak %{resource_name} hauteskundea eguneratu du %{space_name} espazioan" question: update: "%{user_name} parte-hartzaileak eguneratu ditu %{resource_name} aukerako galderak" + census_checks: + show: + description: Behin hauteskundeak hasita, bertan bozkatu ahal izango da. + exit_button: Exit the census check + title: Behar bezala egiaztatu da censuses: census_ready_html: Erroldako datuak kargatuta daude eta prest daude % %{election_title}{@eleccion_title} bozketan erabiltzeko. census_size_html: @@ -246,6 +254,8 @@ eu: title: Aukeraketaren galderak show: active_voting_until: 'Bozketa aktibo %{end_date} arte' + check_census_button: Begiratu ea botoa eman dezakedan + check_census_explanation: Hauteskunde hauek oraindik ez dira hasi, baina erroldan sartuta zauden egiazta dezakezu. vote_button: Eman babesa voted: Bozkatu duzu. Berriz bozkatu dezakezu, zure azken botoa baino ez da kontuan hartuko. votes_count: diff --git a/decidim-elections/config/locales/fi-plain.yml b/decidim-elections/config/locales/fi-plain.yml index e905e60076635..5305224a6e71c 100644 --- a/decidim-elections/config/locales/fi-plain.yml +++ b/decidim-elections/config/locales/fi-plain.yml @@ -96,6 +96,7 @@ fi-pl: start_question_button: Ota äänestys käyttöön title: Tulokset status: + allow_census_check_before_start: Salli käyttäjien tarkistaa, voivatko he äänestää ennen vaalin alkamista census: 'Henkilötietorekisteri:' results_availability: after_end: Tulokset ovat saatavilla vaalin päätyttyä @@ -140,6 +141,8 @@ fi-pl: publish: invalid: Vaalin julkaisu epäonnistui. success: Vaalin julkaisu onnistui. + toggle_census_check: + error: Henkilötietorekisterin tarkastuksen käyttöönotto epäonnistui. unpublish: invalid: Vaalin julkaisun peruminen epäonnistui. success: Vaalin julkaisun peruminen onnistui. @@ -182,6 +185,11 @@ fi-pl: update: "%{user_name} päivitti vaalia %{resource_name} tilassa %{space_name}" question: update: "%{user_name} päivitti kysymyksiä vaalille %{resource_name}" + census_checks: + show: + description: Tämä tarkoittaa sitä, että kun vaalit alkavat, voit äänestää niissä. + exit_button: Poistu väestölaskennan tarkastuksesta + title: Tilisi vahvistaminen onnistui censuses: census_ready_html: Henkilötietorekisterin tietoja ladataan ja valmistellaan käytettäväksi vaalissa %{election_title}. census_size_html: @@ -246,6 +254,8 @@ fi-pl: title: Vaaliin liittyvät kysymykset show: active_voting_until: 'Äänestysaika päättyy: %{end_date}' + check_census_button: Tarkasta, voitko äänestää + check_census_explanation: Nämä vaalit eivät ole vielä alkaneet, mutta voit tarkistaa, onko sinut merkitty näiden vaalien henkilötietorekisteriin. vote_button: Äänestä voted: Olet jo äänestänyt. Voit äänestää uudestaan, ainoastaan viimeinen äänesi otetaan huomioon ääntenlaskennassa. votes_count: diff --git a/decidim-elections/config/locales/fi.yml b/decidim-elections/config/locales/fi.yml index 9319fa8e6959c..2f564b450bd6d 100644 --- a/decidim-elections/config/locales/fi.yml +++ b/decidim-elections/config/locales/fi.yml @@ -96,6 +96,7 @@ fi: start_question_button: Ota äänestys käyttöön title: Tulokset status: + allow_census_check_before_start: Salli käyttäjien tarkistaa, voivatko he äänestää ennen vaalin alkamista census: 'Henkilötietorekisteri:' results_availability: after_end: Tulokset ovat saatavilla vaalin päätyttyä @@ -140,6 +141,8 @@ fi: publish: invalid: Vaalin julkaisu epäonnistui. success: Vaalin julkaisu onnistui. + toggle_census_check: + error: Henkilötietorekisterin tarkastuksen käyttöönotto epäonnistui. unpublish: invalid: Vaalin julkaisun peruminen epäonnistui. success: Vaalin julkaisun peruminen onnistui. @@ -182,6 +185,11 @@ fi: update: "%{user_name} päivitti vaalia %{resource_name} tilassa %{space_name}" question: update: "%{user_name} päivitti kysymyksiä vaalille %{resource_name}" + census_checks: + show: + description: Tämä tarkoittaa sitä, että kun vaalit alkavat, voit äänestää niissä. + exit_button: Poistu väestölaskennan tarkastuksesta + title: Tilisi vahvistaminen onnistui censuses: census_ready_html: Henkilötietorekisterin tietoja ladataan ja valmistellaan käytettäväksi vaalissa %{election_title}. census_size_html: @@ -246,6 +254,8 @@ fi: title: Vaaliin liittyvät kysymykset show: active_voting_until: 'Äänestysaika päättyy: %{end_date}' + check_census_button: Tarkasta, voitko äänestää + check_census_explanation: Nämä vaalit eivät ole vielä alkaneet, mutta voit tarkistaa, onko sinut merkitty näiden vaalien henkilötietorekisteriin. vote_button: Äänestä voted: Olet jo äänestänyt. Voit äänestää uudestaan, ainoastaan viimeinen äänesi otetaan huomioon ääntenlaskennassa. votes_count: diff --git a/decidim-elections/config/locales/ja.yml b/decidim-elections/config/locales/ja.yml index c9098848640e4..cb9fa21fa3754 100644 --- a/decidim-elections/config/locales/ja.yml +++ b/decidim-elections/config/locales/ja.yml @@ -1,6 +1,8 @@ ja: activemodel: attributes: + elections_question: + max_choices: 選択肢の最大数 token_csv: file: ファイル remove_all: 現在のセンサスデータをすべて削除する @@ -92,6 +94,7 @@ ja: start_question_button: 投票を有効化 title: 結果 status: + allow_census_check_before_start: 選挙開始前にユーザーが投票可能かどうかを確認できるようにする census: 'センサス:' results_availability: after_end: 選挙終了後に利用可能な結果 @@ -136,6 +139,8 @@ ja: publish: invalid: 選挙の公表中に問題が発生しました。 success: 選挙を公開しました。 + toggle_census_check: + error: センサスチェックを有効にする際にエラーが発生しました。 unpublish: invalid: 選挙の非公表中に問題が発生しました。 success: 選挙を非公開にしました。 @@ -178,6 +183,11 @@ ja: update: "%{user_name} が %{space_name} の %{resource_name} 選挙を更新しました" question: update: "%{user_name} が %{resource_name} 選挙の質問を更新しました" + census_checks: + show: + description: これは、選挙が始まったらあなたはその選挙に投票できるということを意味します。 + exit_button: センサスチェックを終了 + title: 正常に検証されました censuses: census_ready_html: センサスデータはアップロードされ、 %{election_title} 選挙での使用に備えられています。 census_size_html: @@ -240,6 +250,8 @@ ja: title: 選挙の質問 show: active_voting_until: '有効な投票まで: %{end_date}' + check_census_button: 投票できるかどうかチェック + check_census_explanation: この選挙はまだ始まっていませんが、センサスの対象に含まれているかどうか確認できます。 vote_button: 投票 voted: すでに投票済みです。再度投票することは可能ですが、最後に投票した内容のみが集計対象となります。 votes_count: @@ -293,6 +305,9 @@ ja: question: back: 戻る cast_vote: 投票 + max_choices: '最大選択数: %{count}' + max_choices_alert: 選択したオプションが多すぎます。続行するには選択をいくつか解除してください。 + max_choices_exceeded: 選択できるオプションは最大 %{max} 個までです。一度戻って選択を調整してください。 next: 次へ receipt: description: 投票期間中はいつでも再投票が可能です。以前の投票結果は新しい投票結果で上書きされます。 diff --git a/decidim-elections/config/locales/sv.yml b/decidim-elections/config/locales/sv.yml index 75992528f3265..99a5702cb946f 100644 --- a/decidim-elections/config/locales/sv.yml +++ b/decidim-elections/config/locales/sv.yml @@ -67,6 +67,7 @@ sv: publish_button: Publicera resultat title: Resultat status: + allow_census_check_before_start: Tillåt användare att kontrollera om de kan rösta innan valet börjar census: 'Folkräkning:' results_availability: after_end: Resultat tillgängliga efter valets slut @@ -105,6 +106,8 @@ sv: publish: invalid: Det gick inte att publicera valet. success: Valet publicerades. + toggle_census_check: + error: Det gick inte att aktivera kontrollen av folkräkningen. unpublish: invalid: Det gick inte att ta bort valet. success: Valet togs bort. @@ -123,6 +126,11 @@ sv: soft_delete: "%{user_name} Flyttade valet %{resource_name} i %{space_name} till papperskorgen" unpublish: "%{user_name} Tog bort valet %{resource_name} i %{space_name}" update: "%{user_name} Uppdaterade valet %{resource_name} i %{space_name}" + census_checks: + show: + description: Detta innebär att direkt när valet har startat kan ni rösta i det. + exit_button: Avsluta folkräkningskontrollen + title: Du har blivit auktoriserad censuses: internal_users: already_have_an_account?: Har du redan ett konto? @@ -181,6 +189,8 @@ sv: subtitle: 'Detta är frågorna för denna omröstningsprocess:' show: active_voting_until: 'Aktiv röstning till: %{end_date}' + check_census_button: Kontrollera om jag kan rösta + check_census_explanation: Detta val har ännu inte börjat, men du kan kontrollera om du är med i folkräkningen. vote_button: Rösta voted: Du har redan röstat. Om du röstar igen kommer bara den senaste rösten att räknas. votes_count: diff --git a/decidim-elections/lib/decidim/elections/engine.rb b/decidim-elections/lib/decidim/elections/engine.rb index a9497ed99a9e5..39d80c0041b72 100644 --- a/decidim-elections/lib/decidim/elections/engine.rb +++ b/decidim-elections/lib/decidim/elections/engine.rb @@ -47,7 +47,7 @@ class Engine < ::Rails::Engine manifest.voter_form_partial = "decidim/elections/censuses/token_csv_form" manifest.after_update_command = "Decidim::Elections::Admin::Censuses::TokenCsv" manifest.user_query do |election| - Decidim::Elections::Voter.where(election: election) + Decidim::Elections::Voter.where(election:) end end diff --git a/decidim-elections/lib/decidim/elections/seeds.rb b/decidim-elections/lib/decidim/elections/seeds.rb index 97e3ac0984c39..5e75f632b4d19 100644 --- a/decidim-elections/lib/decidim/elections/seeds.rb +++ b/decidim-elections/lib/decidim/elections/seeds.rb @@ -89,7 +89,7 @@ def create_questions_for!(election) def create_voters_for!(election) number_of_records.times do |i| Decidim::Elections::Voter.create!( - election: election, + election:, data: { "email" => "user#{i + 1}@example.org", "token" => SecureRandom.hex(6).upcase diff --git a/decidim-elections/lib/decidim/elections/test/per_question_vote_examples.rb b/decidim-elections/lib/decidim/elections/test/per_question_vote_examples.rb index 4972a1da1469c..02c98244eb80d 100644 --- a/decidim-elections/lib/decidim/elections/test/per_question_vote_examples.rb +++ b/decidim-elections/lib/decidim/elections/test/per_question_vote_examples.rb @@ -31,7 +31,7 @@ expect(page).to have_current_path(election_path) expect(page).to have_content("You have already voted.") expect(election.votes.where(voter_uid:).size).to eq(3) - click_on "Vote" + click_on "Edit vote" expect(page).to have_current_path(new_election_vote_path) fill_in "Email", with: election.voters.first.data["email"] fill_in "Token", with: election.voters.first.data["token"] @@ -73,7 +73,7 @@ expect(page).to have_current_path(election_path) expect(page).to have_content("You have already voted.") expect(election.votes.where(voter_uid:).size).to eq(2) - click_on "Vote" + click_on "Edit vote" expect(find("input[value='#{question1.response_options.first.id}']")).to be_checked choose translated_attribute(question1.response_options.second.body) click_on "Cast vote" @@ -114,7 +114,7 @@ expect(page).to have_current_path(election_path) expect(page).to have_content("You have already voted.") expect(election.votes.where(voter_uid:).size).to eq(1) - click_on "Vote" + click_on "Edit vote" expect(page).to have_current_path(election_vote_path(question2)) expect(find("input[value='#{question2.response_options.first.id}']")).to be_checked expect(find("input[value='#{question2.response_options.second.id}']")).not_to be_checked @@ -171,3 +171,72 @@ expect(page).to have_no_content("Edit your vote") end end + +shared_examples "a per question votable election with edit from receipt" do + it "allows editing votes from receipt page" do + click_on "Vote" + choose translated_attribute(question1.response_options.first.body) + click_on "Cast vote" + check translated_attribute(question2.response_options.first.body) + click_on "Cast vote" + expect(page).to have_current_path(receipt_election_votes_path) + expect(page).to have_link("Edit your vote") + expect(page).to have_link("Exit the voting booth") + + click_on "Edit your vote" + expect(page).to have_current_path(election_vote_path(question2)) + expect(find("input[value='#{question2.response_options.first.id}']")).to be_checked + + click_on "Back" + expect(page).to have_current_path(election_vote_path(question1)) + expect(find("input[value='#{question1.response_options.first.id}']")).to be_checked + + choose translated_attribute(question1.response_options.second.body) + click_on "Cast vote" + expect(page).to have_current_path(election_vote_path(question2)) + + check translated_attribute(question2.response_options.second.body) + click_on "Cast vote" + expect(page).to have_current_path(receipt_election_votes_path) + expect(election.votes.where(voter_uid:).size).to eq(3) + + click_on "Exit the voting booth" + expect(page).to have_current_path(election_path) + expect(page).to have_content("You have already voted.") + end +end + +shared_examples "a per question votable election with edit from receipt when all questions enabled" do + it "allows editing any question from receipt page" do + click_on "Vote" + choose translated_attribute(question1.response_options.first.body) + click_on "Cast vote" + check translated_attribute(question2.response_options.first.body) + click_on "Cast vote" + expect(page).to have_current_path(receipt_election_votes_path) + + click_on "Edit your vote" + expect(page).to have_current_path(election_vote_path(question2)) + + click_on "Back" + expect(page).to have_current_path(election_vote_path(question1)) + + choose translated_attribute(question1.response_options.second.body) + click_on "Cast vote" + expect(page).to have_current_path(election_vote_path(question2)) + expect(find("input[value='#{question2.response_options.first.id}']")).to be_checked + + click_on "Cast vote" + expect(page).to have_current_path(receipt_election_votes_path) + + click_on "Edit your vote" + click_on "Back" + choose translated_attribute(question1.response_options.first.body) + click_on "Cast vote" + uncheck translated_attribute(question2.response_options.first.body) + check translated_attribute(question2.response_options.second.body) + click_on "Cast vote" + expect(page).to have_current_path(receipt_election_votes_path) + expect(election.votes.where(voter_uid:).size).to eq(2) + end +end diff --git a/decidim-elections/lib/decidim/elections/test/vote_controller_examples.rb b/decidim-elections/lib/decidim/elections/test/vote_controller_examples.rb index 00d3f8692b2f1..fa86c9781fe97 100644 --- a/decidim-elections/lib/decidim/elections/test/vote_controller_examples.rb +++ b/decidim-elections/lib/decidim/elections/test/vote_controller_examples.rb @@ -2,9 +2,9 @@ def do_action(action) if [:show, :confirm, :waiting, :receipt].include?(action) - get action, params: params + get(action, params:) else - patch action, params: params + patch action, params: end end @@ -39,7 +39,7 @@ def do_action(action) shared_examples "an authenticated vote controller" do describe "GET new" do it "renders the new vote form" do - get :new, params: params + get(:new, params:) expect(response).to have_http_status(:ok) expect(assigns(:form)).to be_a(Decidim::Elections::Censuses::InternalUsersForm) expect(subject).to render_template("decidim/elections/votes/new") @@ -52,7 +52,7 @@ def do_action(action) it "redirects to the question path" do expect(controller).to receive(:redirect_to).with(action: :show, id: question) - get :new, params: params + get(:new, params:) expect(controller.send(:session_authenticated?)).to be true expect(response).to render_template("decidim/elections/votes/new") @@ -63,7 +63,7 @@ def do_action(action) describe "POST create" do it "renders the new form with errors when the form is invalid" do expect(controller).to receive(:redirect_to).with(action: :new) - post :create, params: params + post(:create, params:) expect(controller.send(:session_authenticated?)).to be false expect(controller.send(:voter_uid)).to be_nil @@ -77,7 +77,7 @@ def do_action(action) it "creates the session credentials and redirects to form again" do expect(controller).to receive(:redirect_to).with(action: :show, id: question) - post :create, params: params + post(:create, params:) expect(session[:session_attributes]).to be_present expect(controller.send(:session_authenticated?)).to be true diff --git a/decidim-elections/lib/decidim/elections/test/vote_examples.rb b/decidim-elections/lib/decidim/elections/test/vote_examples.rb index 6c7f5326444a1..dcf717d30b8d3 100644 --- a/decidim-elections/lib/decidim/elections/test/vote_examples.rb +++ b/decidim-elections/lib/decidim/elections/test/vote_examples.rb @@ -44,7 +44,7 @@ def fill_in_votes click_on "Exit the voting booth" expect(page).to have_current_path(election_path) expect(page).to have_content("You have already voted.") - expect(election.votes.where(voter_uid: voter_uid).size).to eq(3) + expect(election.votes.where(voter_uid:).size).to eq(3) end shared_examples "a votable election" do @@ -52,7 +52,7 @@ def fill_in_votes click_on "Vote" fill_in_votes - click_on "Vote" + click_on "Edit vote" expect(page).to have_current_path(election_vote_path(election.questions.first)) end end @@ -131,7 +131,7 @@ def fill_in_votes fill_in "Token", with: election.voters.first.data["token"] click_on "Access" fill_in_votes - click_on "Vote" + click_on "Edit vote" expect(page).to have_current_path(new_election_vote_path) fill_in "Email", with: election.voters.first.data["email"] fill_in "Token", with: election.voters.first.data["token"] @@ -163,13 +163,14 @@ def fill_in_votes click_on "Exit the voting booth" expect(page).to have_current_path(election_path) expect(page).to have_content("You have already voted.") - expect(election.votes.where(voter_uid: voter_uid).size).to eq(2) + expect(election.votes.where(voter_uid:).size).to eq(2) end end shared_examples "an editable votable election" do it "Allows the user to edit the vote" do - click_on "Vote" + expect(page).to have_link("Edit vote") + click_on "Edit vote" expect(page).to have_current_path(election_vote_path(election.questions.first)) expect(find("input[value='#{election.questions.first.response_options.first.id}']")).to be_checked expect(find("input[value='#{election.questions.first.response_options.second.id}']")).not_to be_checked @@ -185,12 +186,13 @@ def fill_in_votes click_on "Exit the voting booth" expect(page).to have_current_path(election_path) expect(page).to have_content("You have already voted.") - expect(election.votes.where(voter_uid: voter_uid).size).to eq(3) + expect(election.votes.where(voter_uid:).size).to eq(3) end end shared_examples "a csv token editable votable election" do it "Allows the user to edit the vote" do + expect(page).to have_link("Vote") click_on "Vote" expect(page).to have_current_path(new_election_vote_path) expect(page).to have_content("Verify your identity") @@ -212,6 +214,6 @@ def fill_in_votes click_on "Exit the voting booth" expect(page).to have_current_path(election_path) expect(page).to have_content("You have already voted.") - expect(election.votes.where(voter_uid: voter_uid).size).to eq(3) + expect(election.votes.where(voter_uid:).size).to eq(3) end end diff --git a/decidim-elections/spec/commands/decidim/elections/admin/create_election_spec.rb b/decidim-elections/spec/commands/decidim/elections/admin/create_election_spec.rb index 50fcb122e611b..a50d602443b99 100644 --- a/decidim-elections/spec/commands/decidim/elections/admin/create_election_spec.rb +++ b/decidim-elections/spec/commands/decidim/elections/admin/create_election_spec.rb @@ -84,8 +84,8 @@ module Admin Decidim::Elections::Election, current_user, hash_including( - title: title, - description: description, + title:, + description:, start_at:, end_at:, results_availability: "after_end", diff --git a/decidim-elections/spec/commands/decidim/elections/admin/update_questions_spec.rb b/decidim-elections/spec/commands/decidim/elections/admin/update_questions_spec.rb index b1d35796b0490..a4b1b1ccd6734 100644 --- a/decidim-elections/spec/commands/decidim/elections/admin/update_questions_spec.rb +++ b/decidim-elections/spec/commands/decidim/elections/admin/update_questions_spec.rb @@ -25,7 +25,7 @@ module Admin let(:second_question_second_option) { second_question.response_options.second } let(:context_params) do - { current_organization: organization, current_user: current_user } + { current_organization: organization, current_user: } end context "when updating an existing question" do @@ -180,7 +180,7 @@ module Admin context "when the form is invalid" do let(:form) do - double("Form", invalid?: true, current_user: current_user, current_organization: organization, questions: []) + double("Form", invalid?: true, current_user:, current_organization: organization, questions: []) end let(:command) { described_class.new(form, election) } diff --git a/decidim-elections/spec/controllers/decidim/elections/admin/census_controller_spec.rb b/decidim-elections/spec/controllers/decidim/elections/admin/census_controller_spec.rb index 3b5c7b6480f5c..634a57445fe22 100644 --- a/decidim-elections/spec/controllers/decidim/elections/admin/census_controller_spec.rb +++ b/decidim-elections/spec/controllers/decidim/elections/admin/census_controller_spec.rb @@ -38,7 +38,7 @@ module Admin let(:params) { { id: election.id, manifest: :token_csv, file: valid_file } } it "processes the census and redirects with a success message" do - patch :update, params: params + patch(:update, params:) expect(flash[:notice]).to eq(I18n.t("decidim.elections.admin.census.update.success")) expect(response).to redirect_to(dashboard_election_path) @@ -49,7 +49,7 @@ module Admin let(:params) { { id: election.id, manifest: :token_csv, file: invalid_file } } it "renders the edit view with an error message" do - patch :update, params: params + patch(:update, params:) expect(flash[:alert]).to eq(I18n.t("decidim.elections.admin.census.update.error")) expect(response).to render_template(:edit) diff --git a/decidim-elections/spec/controllers/decidim/elections/admin/elections_controller_spec.rb b/decidim-elections/spec/controllers/decidim/elections/admin/elections_controller_spec.rb index 61289d07ec63a..eadcfcb5697c1 100644 --- a/decidim-elections/spec/controllers/decidim/elections/admin/elections_controller_spec.rb +++ b/decidim-elections/spec/controllers/decidim/elections/admin/elections_controller_spec.rb @@ -117,6 +117,8 @@ def dashboard_path(election) "position" => election_question.position, "voting_enabled" => false, "published_results" => false, + "total_votes" => 0, + "total_votes_text" => "0 votes", "response_options" => election_question.response_options.map do |ro| { "id" => ro.id, diff --git a/decidim-elections/spec/controllers/decidim/elections/census_checks_controller_spec.rb b/decidim-elections/spec/controllers/decidim/elections/census_checks_controller_spec.rb index 531918bf2bda7..34569c44bf153 100644 --- a/decidim-elections/spec/controllers/decidim/elections/census_checks_controller_spec.rb +++ b/decidim-elections/spec/controllers/decidim/elections/census_checks_controller_spec.rb @@ -28,7 +28,7 @@ module Elections describe "GET new" do it "renders the census check form" do - get :new, params: params + get(:new, params:) expect(response).to have_http_status(:ok) expect(assigns(:form)).to be_present @@ -40,7 +40,7 @@ module Elections end it "redirects to the success page" do - get :new, params: params + get(:new, params:) expect(response).to redirect_to(census_check_path) end @@ -66,7 +66,7 @@ module Elections describe "GET show" do it "redirects to the form when the session is not authenticated" do - get :show, params: params + get(:show, params:) expect(response).to redirect_to(new_census_check_path) expect(flash[:alert]).to eq(I18n.t("decidim.elections.votes.check_census.failed")) @@ -78,7 +78,7 @@ module Elections end it "renders the success page" do - get :show, params: params + get(:show, params:) expect(response).to have_http_status(:ok) expect(subject).to render_template(:show) diff --git a/decidim-elections/spec/controllers/decidim/elections/per_question_votes_controller_spec.rb b/decidim-elections/spec/controllers/decidim/elections/per_question_votes_controller_spec.rb index a16c780fd1a8c..4ff53c96512f8 100644 --- a/decidim-elections/spec/controllers/decidim/elections/per_question_votes_controller_spec.rb +++ b/decidim-elections/spec/controllers/decidim/elections/per_question_votes_controller_spec.rb @@ -9,7 +9,7 @@ module Elections let(:user) { create(:user, :confirmed, organization: component.organization) } let(:component) { create(:elections_component) } let(:election) { create(:election, :published, :with_internal_users_census, :per_question, :ongoing, component:) } - let!(:existing_vote) { create(:election_vote, question: question, response_option: question.response_options.first, voter_uid: "some-id") } + let!(:existing_vote) { create(:election_vote, question:, response_option: question.response_options.first, voter_uid: "some-id") } let!(:question) { create(:election_question, :with_response_options, :voting_enabled, election:) } let!(:second_question) { create(:election_question, :with_response_options, :voting_enabled, election:) } @@ -29,6 +29,7 @@ module Elections allow(controller).to receive(:current_participatory_space).and_return(component.participatory_space) allow(controller).to receive(:current_component).and_return(component) allow(controller).to receive(:election_vote_path).and_return(election_vote_path) + allow(controller).to receive(:second_election_vote_path).and_return(second_election_vote_path) allow(controller).to receive(:new_election_vote_path).and_return(new_election_vote_path) allow(controller).to receive(:election_path).and_return(election_path) allow(controller).to receive(:waiting_election_votes_path).and_return(waiting_election_votes_path) @@ -48,7 +49,7 @@ module Elections it_behaves_like "a redirect to the waiting room", :show it "renders the voting form" do - get :show, params: params + get(:show, params:) expect(response).to have_http_status(:ok) expect(controller.helpers.question).to eq(question) expect(subject).to render_template(:show) @@ -57,14 +58,14 @@ module Elections it "redirects to the next question if the current question is not enabled" do question.update(voting_enabled_at: nil) expect(controller).to receive(:redirect_to).with(action: :show, id: second_question) - get :show, params: params + get(:show, params:) expect(response).to have_http_status(:ok) end it "redirects to the next question if the current question has published results" do question.update(published_results_at: Time.current) expect(controller).to receive(:redirect_to).with(action: :show, id: second_question) - get :show, params: params + get(:show, params:) expect(response).to have_http_status(:ok) end end @@ -118,14 +119,23 @@ module Elections end it "casts the votes and redirects to the receipt page if successful" do - # ensure there are no pending votes - allow(controller).to receive(:votes_buffer).and_return({ question.id.to_s => [question.response_options.first.id], second_question.id.to_s => [second_question.response_options.first.id] }) + session[:votes_buffer] = { question.id.to_s => [question.response_options.first.id.to_s], second_question.id.to_s => [second_question.response_options.first.id.to_s] } expect(controller).to receive(:redirect_to).with(action: :receipt) - patch :update, params: params.merge(id: question.id, response: { question.id.to_s => [question.response_options.first.id] }) + patch :update, params: params.merge(id: second_question.id, response: { second_question.id.to_s => [second_question.response_options.first.id] }) expect(session[:voter_uid]).to eq(user.to_global_id.to_s) expect(flash[:notice]).to eq(I18n.t("votes.cast.success", scope: "decidim.elections")) end + + it "clears subsequent questions from buffer when updating a previous question" do + session[:votes_buffer] = { question.id.to_s => [question.response_options.first.id.to_s], second_question.id.to_s => [second_question.response_options.first.id.to_s] } + + expect(controller).to receive(:redirect_to).with(action: :show, id: second_question) + patch :update, params: params.merge(id: question.id, response: { question.id.to_s => [question.response_options.second.id] }) + + expect(session[:votes_buffer]).not_to have_key(second_question.id.to_s) + expect(session[:votes_buffer][question.id.to_s]).to eq([question.response_options.second.id.to_s]) + end end end @@ -142,7 +152,7 @@ module Elections allow(controller).to receive(:votes_buffer).and_return({ question.id.to_s => [question.response_options.first.id] }) expect(controller).to receive(:redirect_to).with(action: :show, id: second_question) - get :waiting, params: params + get(:waiting, params:) expect(response).to have_http_status(:ok) end @@ -153,7 +163,7 @@ module Elections it "redirects to the non voted question" do expect(controller).to receive(:redirect_to).with(action: :show, id: question) - get :waiting, params: params + get(:waiting, params:) expect(response).to have_http_status(:ok) end @@ -162,13 +172,13 @@ module Elections it "redirects to the non voted question" do expect(controller).to receive(:redirect_to).with(action: :show, id: question) - get :waiting, params: params + get(:waiting, params:) expect(response).to have_http_status(:ok) end it "renders the waiting page if votes_buffer exist" do allow(controller).to receive(:votes_buffer).and_return({ question.id.to_s => [question.response_options.first.id] }) - get :waiting, params: params + get(:waiting, params:) expect(response).to have_http_status(:ok) expect(subject).to render_template(:waiting) end @@ -181,7 +191,7 @@ module Elections allow(controller).to receive(:votes_buffer).and_return({ question.id.to_s => [question.response_options.first.id] }) expect(controller).to receive(:url_for).with(action: :show, id: second_question) - get :waiting, params: params, format: :json + get :waiting, params:, format: :json expect(response).to have_http_status(:ok) expect(JSON.parse(response.body)).to have_key("url") end @@ -205,25 +215,32 @@ module Elections end it "redirects to the election path" do - get :receipt, params: params + get(:receipt, params:) expect(response).to redirect_to(election_path) end context "when the election has votes for the voter UID" do before do - create(:election_vote, voter_uid: session[:voter_uid], question: question, response_option: question.response_options.first) + create(:election_vote, voter_uid: session[:voter_uid], question:, response_option: question.response_options.first) create(:election_vote, voter_uid: session[:voter_uid], question: second_question, response_option: second_question.response_options.first) end it_behaves_like "a redirect to the waiting room", :receipt - it "renders the receipt page" do - expect(controller.send(:votes_buffer)).to receive(:clear) - expect(controller.send(:session_attributes)).to receive(:clear) - get :receipt, params: params + it "renders the receipt page without clearing session" do + expect(controller.send(:votes_buffer)).not_to receive(:clear) + expect(controller.send(:session_attributes)).not_to receive(:clear) + get(:receipt, params:) expect(response).to have_http_status(:ok) expect(subject).to render_template(:receipt) end + + it "clears session when exit param is present" do + expect(controller.send(:votes_buffer)).to receive(:clear) + expect(controller.send(:session_attributes)).to receive(:clear) + get :receipt, params: params.merge(exit: true) + expect(response).to redirect_to(election_path) + end end end end diff --git a/decidim-elections/spec/controllers/decidim/elections/votes_controller_spec.rb b/decidim-elections/spec/controllers/decidim/elections/votes_controller_spec.rb index 7cab91d3c4936..1da30d6e346b4 100644 --- a/decidim-elections/spec/controllers/decidim/elections/votes_controller_spec.rb +++ b/decidim-elections/spec/controllers/decidim/elections/votes_controller_spec.rb @@ -9,7 +9,7 @@ module Elections let(:user) { create(:user, :confirmed, organization: component.organization) } let(:component) { create(:elections_component) } let(:election) { create(:election, :published, :with_internal_users_census, :ongoing, component:) } - let!(:existing_vote) { create(:election_vote, question: question, response_option: question.response_options.first, voter_uid: "some-id") } + let!(:existing_vote) { create(:election_vote, question:, response_option: question.response_options.first, voter_uid: "some-id") } let!(:question) { create(:election_question, :with_response_options, :voting_enabled, election:) } let!(:second_question) { create(:election_question, :with_response_options, :voting_enabled, election:) } @@ -52,7 +52,7 @@ module Elections end it "renders the voting form" do - get :show, params: params + get(:show, params:) expect(response).to have_http_status(:ok) expect(controller.helpers.question).to eq(question) expect(subject).to render_template(:show) @@ -158,7 +158,7 @@ module Elections end it "renders the confirmation page" do - get :confirm, params: params + get(:confirm, params:) expect(response).to have_http_status(:ok) expect(subject).to render_template(:confirm) end @@ -184,7 +184,7 @@ module Elections it "casts the votes and redirects to the receipt page" do expect(controller.send(:votes_buffer)).to receive(:clear) expect(controller.send(:session_attributes)).to receive(:clear) - post :cast, params: params + post(:cast, params:) expect(session[:voter_uid]).to eq(user.to_global_id.to_s) expect(response).to redirect_to(receipt_election_votes_path) expect(flash[:notice]).to eq(I18n.t("votes.cast.success", scope: "decidim.elections")) @@ -198,7 +198,7 @@ module Elections end it "redirects to the confirm page if votes are incomplete" do - post :cast, params: params + post(:cast, params:) expect(response).to redirect_to(confirm_election_votes_path) expect(flash[:alert]).to eq(I18n.t("votes.cast.invalid", scope: "decidim.elections")) end @@ -208,7 +208,7 @@ module Elections describe "GET receipt" do it "redirects to the election path" do - get :receipt, params: params + get(:receipt, params:) expect(response).to redirect_to(election_path) end @@ -218,7 +218,7 @@ module Elections end it "redirects to the election path" do - get :receipt, params: params + get(:receipt, params:) expect(response).to redirect_to(election_path) end end @@ -232,16 +232,23 @@ module Elections context "when the election has votes for the voter UID" do before do - create(:election_vote, voter_uid: session[:voter_uid], question: question, response_option: question.response_options.first) + create(:election_vote, voter_uid: session[:voter_uid], question:, response_option: question.response_options.first) end - it "renders the receipt page" do + it "renders the receipt page and clears votes buffer" do expect(controller.send(:votes_buffer)).to receive(:clear) - expect(controller.send(:session_attributes)).to receive(:clear) - get :receipt, params: params + expect(controller.send(:session_attributes)).not_to receive(:clear) + get(:receipt, params:) expect(response).to have_http_status(:ok) expect(subject).to render_template(:receipt) end + + it "clears session when exit param is present" do + expect(controller.send(:votes_buffer)).to receive(:clear) + expect(controller.send(:session_attributes)).to receive(:clear) + get :receipt, params: params.merge(exit: true) + expect(response).to redirect_to(election_path) + end end end end diff --git a/decidim-elections/spec/forms/decidim/elections/admin/question_form_spec.rb b/decidim-elections/spec/forms/decidim/elections/admin/question_form_spec.rb index 75e3f42235347..ab0d67abc50a7 100644 --- a/decidim-elections/spec/forms/decidim/elections/admin/question_form_spec.rb +++ b/decidim-elections/spec/forms/decidim/elections/admin/question_form_spec.rb @@ -20,10 +20,10 @@ module Admin let(:attributes) do { - body_en: body_en, - description_en: description_en, - question_type: question_type, - response_options: response_options + body_en:, + description_en:, + question_type:, + response_options: } end @@ -58,11 +58,11 @@ module Admin describe "max_choices validation" do let(:attributes) do { - body_en: body_en, - description_en: description_en, - question_type: question_type, - response_options: response_options, - max_choices: max_choices + body_en:, + description_en:, + question_type:, + response_options:, + max_choices: } end diff --git a/decidim-elections/spec/forms/decidim/elections/admin/questions_form_spec.rb b/decidim-elections/spec/forms/decidim/elections/admin/questions_form_spec.rb index 3df91827c8233..98dc848d85b86 100644 --- a/decidim-elections/spec/forms/decidim/elections/admin/questions_form_spec.rb +++ b/decidim-elections/spec/forms/decidim/elections/admin/questions_form_spec.rb @@ -35,7 +35,7 @@ module Admin subject(:form) do described_class.from_params(attributes).with_context( - current_organization: current_organization + current_organization: ) end diff --git a/decidim-elections/spec/forms/decidim/elections/admin/token_csv_form_spec.rb b/decidim-elections/spec/forms/decidim/elections/admin/token_csv_form_spec.rb index fcadf003b446c..e0275029f51eb 100644 --- a/decidim-elections/spec/forms/decidim/elections/admin/token_csv_form_spec.rb +++ b/decidim-elections/spec/forms/decidim/elections/admin/token_csv_form_spec.rb @@ -8,7 +8,7 @@ module Admin module Censuses describe TokenCsvForm do let(:organization) { create(:organization) } - let(:attributes) { { file: file, remove_all: remove_all } } + let(:attributes) { { file:, remove_all: } } let(:remove_all) { false } subject { described_class.new(attributes).with_context(current_organization: organization) } diff --git a/decidim-elections/spec/permissions/decidim/elections/admin/permissions_spec.rb b/decidim-elections/spec/permissions/decidim/elections/admin/permissions_spec.rb index 4175e951d3510..b9a8dbee9c78c 100644 --- a/decidim-elections/spec/permissions/decidim/elections/admin/permissions_spec.rb +++ b/decidim-elections/spec/permissions/decidim/elections/admin/permissions_spec.rb @@ -14,7 +14,7 @@ election: } end - let(:permission_action) { Decidim::PermissionAction.new(scope: scope, action: action_name, subject: action_subject) } + let(:permission_action) { Decidim::PermissionAction.new(scope:, action: action_name, subject: action_subject) } let(:scope) { :admin } let(:action_name) { :foo } let(:action_subject) { :foo } diff --git a/decidim-elections/spec/presenters/decidim/elections/election_presenter_spec.rb b/decidim-elections/spec/presenters/decidim/elections/election_presenter_spec.rb index eb2e85c5f39e8..60b72f7b81015 100644 --- a/decidim-elections/spec/presenters/decidim/elections/election_presenter_spec.rb +++ b/decidim-elections/spec/presenters/decidim/elections/election_presenter_spec.rb @@ -13,7 +13,7 @@ module Elections let(:election) do create(:election, - component: component, + component:, title: { en: "Test election" }) end @@ -43,6 +43,45 @@ module Elections it { expect(presenter.title).to be_nil } it { expect(presenter.election_path).to be_nil } end + + describe "#to_json" do + let(:election) { create(:election, :published, :real_time, :ongoing, component:) } + let!(:question) { create(:election_question, :with_response_options, election:) } + let!(:vote) { create(:election_vote, question:, response_option: question.response_options.first, voter_uid: "voter1") } + + context "when admin is true" do + subject(:json) { presenter.to_json(admin: true) } + + it "includes total_votes for each question" do + question_json = json[:questions].find { |q| q[:id] == question.id } + expect(question_json[:total_votes]).to eq(1) + expect(question_json[:total_votes_text]).to eq("1 vote") + end + end + + context "when admin is false and results are published" do + subject(:json) { presenter.to_json(admin: false) } + + it "includes total_votes for questions with published results" do + question_json = json[:questions].find { |q| q[:id] == question.id } + expect(question_json[:total_votes]).to eq(1) + expect(question_json[:total_votes_text]).to eq("1 vote") + end + end + + context "when admin is false and results are not published" do + let(:election) { create(:election, :published, :per_question, :ongoing, component:) } + let!(:question) { create(:election_question, :with_response_options, election:, voting_enabled_at: Time.current, published_results_at: nil) } + + subject(:json) { presenter.to_json(admin: false) } + + it "does not include total_votes for questions without published results" do + question_json = json[:questions].find { |q| q[:id] == question.id } + expect(question_json).not_to have_key(:total_votes) + expect(question_json).not_to have_key(:total_votes_text) + end + end + end end end end diff --git a/decidim-elections/spec/system/admin/admin_manages_election_census_spec.rb b/decidim-elections/spec/system/admin/admin_manages_election_census_spec.rb index e3518d6cbd15c..94ca9124e8c44 100644 --- a/decidim-elections/spec/system/admin/admin_manages_election_census_spec.rb +++ b/decidim-elections/spec/system/admin/admin_manages_election_census_spec.rb @@ -99,7 +99,7 @@ let(:available_authorizations) { %w(dummy_authorization_handler another_dummy_authorization_handler) } before do - organization.update!(available_authorizations: available_authorizations) + organization.update!(available_authorizations:) end context "when no verification handlers are selected" do diff --git a/decidim-elections/spec/system/admin/dashboard_spec.rb b/decidim-elections/spec/system/admin/dashboard_spec.rb index 8cebc33cd646e..067380a52a7c3 100644 --- a/decidim-elections/spec/system/admin/dashboard_spec.rb +++ b/decidim-elections/spec/system/admin/dashboard_spec.rb @@ -133,6 +133,27 @@ expect(page).to have_no_content("Election has not started yet.") expect(page).to have_no_content("Publish results") end + + it "shows total votes for each question" do + questions.each do |question| + within("#question_#{question.id}") do + expect(page).to have_content("Total") + expect(page).to have_css("[data-question-total-votes-text='#{question.id}']", text: "0 votes") + end + end + end + + context "when there are votes" do + let!(:questions) { create_list(:election_question, 3, :with_response_options, election:) } + let!(:vote) { create(:election_vote, question: questions.first, response_option: questions.first.response_options.first, voter_uid: "voter1") } + + it "shows the correct total votes count" do + visit election_dashboard_path + within("#question_#{questions.first.id}") do + expect(page).to have_css("[data-question-total-votes-text='#{questions.first.id}']", text: "1 vote") + end + end + end end end @@ -176,6 +197,15 @@ expect(page).to have_button("Enable voting", count: 3, disabled: false) end + it "shows total votes for each question" do + questions.each do |question| + within("#question_#{question.id}") do + expect(page).to have_content("Total") + expect(page).to have_css("[data-question-total-votes-text='#{question.id}']", text: "0 votes") + end + end + end + context "when a question is enabled" do before do within("#question_#{first_question.id}") do @@ -262,6 +292,15 @@ expect(page).to have_no_content("Election has not started yet.") expect(page).to have_button("Publish results") end + + it "shows total votes for each question" do + questions.each do |question| + within("#question_#{question.id}") do + expect(page).to have_content("Total") + expect(page).to have_css("[data-question-total-votes-text='#{question.id}']", text: "0 votes") + end + end + end end end @@ -272,6 +311,15 @@ expect(page).to have_content("Finished") expect(page).to have_no_button("Results published at") end + + it "shows total votes for each question" do + questions.each do |question| + within("#question_#{question.id}") do + expect(page).to have_content("Total") + expect(page).to have_css("[data-question-total-votes-text='#{question.id}']", text: "0 votes") + end + end + end end private diff --git a/decidim-elections/spec/system/election_results_spec.rb b/decidim-elections/spec/system/election_results_spec.rb index 0653401acf4d2..db988ea0edb09 100644 --- a/decidim-elections/spec/system/election_results_spec.rb +++ b/decidim-elections/spec/system/election_results_spec.rb @@ -32,6 +32,11 @@ def expect_vote_count(question, option, count) expect(div).to have_content("#{count} vote") end + def expect_total_votes(question, count) + div = find("[data-question-total-votes-text='#{question.id}']") + expect(div).to have_content("#{count} vote") + end + it "shows the results" do expect(page).to have_content("Results") within "#question-#{question1.id}" do @@ -53,6 +58,8 @@ def expect_vote_count(question, option, count) expect_vote_count(question2, option21, "0") expect_vote_percent(question2, option22, "0.0%") expect_vote_count(question2, option22, "0") + expect_total_votes(question1, "0") + expect_total_votes(question2, "0") create(:election_vote, question: question1, voter_uid: "voter1", response_option: option11) create(:election_vote, question: question2, voter_uid: "voter1", response_option: option22) @@ -66,6 +73,8 @@ def expect_vote_count(question, option, count) expect_vote_count(question2, option21, "0") expect_vote_percent(question2, option22, "100.0%") expect_vote_count(question2, option22, "1") + expect_total_votes(question1, "1") + expect_total_votes(question2, "1") end context "when the election is per question" do @@ -100,6 +109,7 @@ def expect_vote_count(question, option, count) expect_vote_count(question1, option11, "0") expect_vote_percent(question1, option12, "0.0%") expect_vote_count(question1, option12, "0") + expect_total_votes(question1, "0") question1.update(published_results_at: Time.current) question2.update(voting_enabled_at: Time.current) @@ -112,6 +122,7 @@ def expect_vote_count(question, option, count) expect_vote_count(question1, option11, "1") expect_vote_percent(question1, option12, "0.0%") expect_vote_count(question1, option12, "0") + expect_total_votes(question1, "1") expect(page).to have_no_selector("#question-#{question2.id}") question2.update(published_results_at: Time.current) @@ -125,6 +136,7 @@ def expect_vote_count(question, option, count) expect_vote_count(question2, option21, "1") expect_vote_percent(question2, option22, "0.0%") expect_vote_count(question2, option22, "0") + expect_total_votes(question2, "1") end end end diff --git a/decidim-elections/spec/system/elections_breadcrumbs_spec.rb b/decidim-elections/spec/system/elections_breadcrumbs_spec.rb new file mode 100644 index 0000000000000..6b7f17285060f --- /dev/null +++ b/decidim-elections/spec/system/elections_breadcrumbs_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Elections Breadcrumb" do + include_context "with a component" + let(:manifest_name) { "elections" } + + let!(:election) { create(:election, :published, :finished, :with_internal_users_census, component:) } + + before do + visit_component + end + + describe "index" do + it "shows the correct information in breadcrumb (space, component)" do + within(".menu-bar") do + expect(page).to have_content(translated(component.participatory_space.title)) + expect(page).to have_content(translated(component.name)) + end + end + end + + describe "show" do + it "shows the correct information in breadcrumb (space, component, election)" do + click_on translated(election.title) + within(".menu-bar") do + expect(page).to have_content(translated(component.participatory_space.title)) + expect(page).to have_content(translated(component.name)) + expect(page).to have_content(translated(election.title)) + end + end + end +end diff --git a/decidim-elections/spec/system/user_votes_in_an_election_spec.rb b/decidim-elections/spec/system/user_votes_in_an_election_spec.rb index 54ec42daacb07..f0262fe5b9047 100644 --- a/decidim-elections/spec/system/user_votes_in_an_election_spec.rb +++ b/decidim-elections/spec/system/user_votes_in_an_election_spec.rb @@ -95,10 +95,6 @@ def election_vote_path(question) let(:election) { create(:election, :published, :finished, :with_internal_users_census) } it "does not allow to vote" do - within(".menu-bar") do - expect(page).to have_content(translated(election.component.name)) - expect(page).to have_content(translated(election.title)) - end expect(page).to have_no_link("Vote") expect(page).to have_no_content("You have already voted.") visit new_election_vote_path diff --git a/decidim-elections/spec/system/user_votes_in_an_per_question_election_spec.rb b/decidim-elections/spec/system/user_votes_in_an_per_question_election_spec.rb index 7be60f6e774b7..3b88aed74897a 100644 --- a/decidim-elections/spec/system/user_votes_in_an_per_question_election_spec.rb +++ b/decidim-elections/spec/system/user_votes_in_an_per_question_election_spec.rb @@ -73,6 +73,19 @@ def election_vote_path(question) it_behaves_like "a per question votable election with already voted questions" end + context "when editing votes from receipt page" do + let!(:question1) { create(:election_question, :with_response_options, :voting_enabled, skip_injection: true, question_type: "single_option", election:, position: 1) } + let!(:question2) { create(:election_question, :with_response_options, :voting_enabled, election:, skip_injection: true, position: 2) } + + before do + login_as user, scope: :user + visit election_path + end + + it_behaves_like "a per question votable election with edit from receipt" + it_behaves_like "a per question votable election with edit from receipt when all questions enabled" + end + context "when the election is real time" do let(:election) { create(:election, :published, :ongoing, :with_internal_users_census, :real_time, :with_questions) } diff --git a/decidim-forms/app/commands/decidim/forms/response_questionnaire.rb b/decidim-forms/app/commands/decidim/forms/response_questionnaire.rb index 6f0046c414fe2..3ae31237c4890 100644 --- a/decidim-forms/app/commands/decidim/forms/response_questionnaire.rb +++ b/decidim-forms/app/commands/decidim/forms/response_questionnaire.rb @@ -79,7 +79,7 @@ def build_choices(response, form_response) end def clear_responses! - Response.where(questionnaire: questionnaire, user: current_user, session_token: form.context.session_token, ip_hash: form.context.ip_hash).destroy_all + Response.where(questionnaire:, user: current_user, session_token: form.context.session_token, ip_hash: form.context.ip_hash).destroy_all end def response_questionnaire diff --git a/decidim-forms/app/views/decidim/forms/questionnaires/_questionnaire.html.erb b/decidim-forms/app/views/decidim/forms/questionnaires/_questionnaire.html.erb index 8fd55f4036f09..986483dfc89ae 100644 --- a/decidim-forms/app/views/decidim/forms/questionnaires/_questionnaire.html.erb +++ b/decidim-forms/app/views/decidim/forms/questionnaires/_questionnaire.html.erb @@ -3,7 +3,7 @@ <% unless current_participatory_space.can_participate?(current_user) %> - <%= cell("decidim/announcement", { title: t("decidim.forms.questionnaires.show.questionnaire_for_private_users.title"), body: t("decidim.forms.questionnaires.show.questionnaire_for_private_users.body") }, callout_class: "alert") %> + <%= cell("decidim/announcement", { title: t("decidim.forms.questionnaires.show.questionnaire_for_members.title"), body: t("decidim.forms.questionnaires.show.questionnaire_for_members.body") }, callout_class: "alert") %> <% end %> <% if questionnaire_for.respond_to?(:announcement) and questionnaire_for.announcement.present? %> diff --git a/decidim-forms/app/views/decidim/forms/questionnaires/show.html.erb b/decidim-forms/app/views/decidim/forms/questionnaires/show.html.erb index c2fc3f24d5d01..4da5e4778d96b 100644 --- a/decidim-forms/app/views/decidim/forms/questionnaires/show.html.erb +++ b/decidim-forms/app/views/decidim/forms/questionnaires/show.html.erb @@ -39,7 +39,7 @@ <% else %> <% body = t("decidim.forms.questionnaires.show.questionnaire_responded.body") %> <% end %> - <%= cell("decidim/announcement", { title: t("decidim.forms.questionnaires.show.questionnaire_responded.title"), body: body }) %> + <%= cell("decidim/announcement", { title: t("decidim.forms.questionnaires.show.questionnaire_responded.title"), body: }) %> <% else %> <% if @form.responses_by_step.flatten.empty? %> <%= cell("decidim/announcement", t("decidim.forms.questionnaires.show.empty")) %> diff --git a/decidim-forms/config/locales/en.yml b/decidim-forms/config/locales/en.yml index e054af6244b60..6ff6602004b6d 100644 --- a/decidim-forms/config/locales/en.yml +++ b/decidim-forms/config/locales/en.yml @@ -189,8 +189,8 @@ en: questionnaire_closed: body: The form is closed and cannot be responded. title: Form closed - questionnaire_for_private_users: - body: The form is available only for private users + questionnaire_for_members: + body: The form is available only for members title: Form closed questionnaire_js_disabled: body: Some of this form's features will be disabled. To improve your experience, please enable JavaScript in your browser. diff --git a/decidim-forms/spec/forms/decidim/forms/admin/display_condition_form_spec.rb b/decidim-forms/spec/forms/decidim/forms/admin/display_condition_form_spec.rb index 96eb8fcddecd0..972183d127152 100644 --- a/decidim-forms/spec/forms/decidim/forms/admin/display_condition_form_spec.rb +++ b/decidim-forms/spec/forms/decidim/forms/admin/display_condition_form_spec.rb @@ -112,7 +112,7 @@ module Admin describe "#questions_for_select" do let(:questions_for_select) { subject.questions_for_select(questionnaire, question.id) } - let!(:separator_question) { create(:questionnaire_question, questionnaire: questionnaire, question_type: "separator") } + let!(:separator_question) { create(:questionnaire_question, questionnaire:, question_type: "separator") } it "returns an array of arrays containing translated body, id, and a hash" do expect(questions_for_select.first.first).to eq( diff --git a/decidim-forms/spec/types/questionnaire_type_spec.rb b/decidim-forms/spec/types/questionnaire_type_spec.rb index 7d84f0821f489..f120e944a6601 100644 --- a/decidim-forms/spec/types/questionnaire_type_spec.rb +++ b/decidim-forms/spec/types/questionnaire_type_spec.rb @@ -85,11 +85,11 @@ module Forms end end - context "when meeting is no published" do + context "when meeting is not published" do let(:meeting) { create(:meeting) } - it "returns the questionnaire's entity corresponding to questionnaire_for_id" do - expect(response["forEntity"]).to be_nil + it "throws Decidim::Api::Errors::UnauthorizedObjectError" do + expect { response }.to raise_error(Decidim::Api::Errors::UnauthorizedObjectError, "You cannot view or edit this Meeting because you do not have permissions") end end end diff --git a/decidim-initiatives/app/models/decidim/initiative.rb b/decidim-initiatives/app/models/decidim/initiative.rb index df2118eb55a85..d9a58a9444e43 100644 --- a/decidim-initiatives/app/models/decidim/initiative.rb +++ b/decidim-initiatives/app/models/decidim/initiative.rb @@ -173,7 +173,7 @@ def self.ransackable_attributes(auth_object = nil) return base unless auth_object&.admin? - base + %w(published_at state decidim_area_id type_id) + base + %w(published_at created_at state decidim_area_id type_id) end def self.ransackable_associations(_auth_object = nil) diff --git a/decidim-initiatives/app/views/decidim/initiatives/admin/exports/_dropdown.html.erb b/decidim-initiatives/app/views/decidim/initiatives/admin/exports/_dropdown.html.erb index 1d27d0b782ea1..91c292cca779c 100644 --- a/decidim-initiatives/app/views/decidim/initiatives/admin/exports/_dropdown.html.erb +++ b/decidim-initiatives/app/views/decidim/initiatives/admin/exports/_dropdown.html.erb @@ -13,7 +13,7 @@
+
+ <%= f.translated :text_field, :short_name, help_text: t(".short_name_hint") %> +
+ <%= f.text_field :host %> <%= f.text_area :secondary_hosts, help_text: t(".secondary_hosts_hint") %> diff --git a/decidim-system/app/views/decidim/system/organizations/new.html.erb b/decidim-system/app/views/decidim/system/organizations/new.html.erb index 25f19ccf0b457..9ce3a24d0b66e 100644 --- a/decidim-system/app/views/decidim/system/organizations/new.html.erb +++ b/decidim-system/app/views/decidim/system/organizations/new.html.erb @@ -8,6 +8,8 @@
<%= f.text_field :name, autofocus: true %> + <%= f.text_field :short_name, help_text: t(".short_name_hint") %> + <%= f.text_field :reference_prefix, help_text: t(".reference_prefix_hint") %> <%= f.text_field :host %> diff --git a/decidim-system/config/locales/en.yml b/decidim-system/config/locales/en.yml index 83a2cebf65400..f474807dc447d 100644 --- a/decidim-system/config/locales/en.yml +++ b/decidim-system/config/locales/en.yml @@ -270,6 +270,7 @@ en: confirm_resend_invitation: Are you sure you want to resend the invitation? resend_invitation: Resend invitation secondary_hosts_hint: Enter each one of them in a new line + short_name_hint: Short name used for the Progressive Web Application. It must have 12 characters maximum. title: Edit organization file_upload_settings: content_types: @@ -299,6 +300,7 @@ en: organization_admin_email_hint: We will send an email to this address so you can confirm it and set up your password. reference_prefix_hint: The reference prefix is used to uniquely identify resources across all organization. secondary_hosts_hint: Enter each one of them in a new line. + short_name_hint: Short name used for the Progressive Web Application. It must have 12 characters maximum. title: New organization omniauth_settings: decidim: diff --git a/decidim-system/spec/commands/decidim/system/create_api_user_spec.rb b/decidim-system/spec/commands/decidim/system/create_api_user_spec.rb index 86dd61280a96d..85e959b260612 100644 --- a/decidim-system/spec/commands/decidim/system/create_api_user_spec.rb +++ b/decidim-system/spec/commands/decidim/system/create_api_user_spec.rb @@ -18,7 +18,7 @@ module System double( valid?: valid, organization: organization.id, - name: name + name: ) end diff --git a/decidim-system/spec/commands/decidim/system/create_organization_spec.rb b/decidim-system/spec/commands/decidim/system/create_organization_spec.rb index 89380cbe5b0f2..9fa8ee532a141 100644 --- a/decidim-system/spec/commands/decidim/system/create_organization_spec.rb +++ b/decidim-system/spec/commands/decidim/system/create_organization_spec.rb @@ -17,6 +17,7 @@ module System let(:params) do { name: "Gotham City", + short_name: "GothamCity", host: "decide.example.org", secondary_hosts: "foo.example.org\r\n\r\nbar.example.org", reference_prefix: "JKR", @@ -55,6 +56,7 @@ module System expect { command.call }.to change(Organization, :count).by(1) organization = Organization.last expect(translated(organization.name)).to eq("Gotham City") + expect(translated(organization.short_name)).to eq("GothamCity") expect(organization.host).to eq("decide.example.org") expect(organization.secondary_hosts).to contain_exactly("foo.example.org", "bar.example.org") expect(organization.external_domain_allowlist).to contain_exactly("decidim.org", "github.com") diff --git a/decidim-system/spec/commands/decidim/system/refresh_api_user_secret_spec.rb b/decidim-system/spec/commands/decidim/system/refresh_api_user_secret_spec.rb index 0b4c6abb71657..e2c68700d96e7 100644 --- a/decidim-system/spec/commands/decidim/system/refresh_api_user_secret_spec.rb +++ b/decidim-system/spec/commands/decidim/system/refresh_api_user_secret_spec.rb @@ -9,7 +9,7 @@ module System let(:command) { subject.call } let!(:organization) { create(:organization) } - let(:api_user) { create(:api_user, organization: organization) } + let(:api_user) { create(:api_user, organization:) } let(:admin) { create(:admin) } let(:valid) { true } let(:name) { "Dummy name" } diff --git a/decidim-system/spec/commands/decidim/system/update_organization_spec.rb b/decidim-system/spec/commands/decidim/system/update_organization_spec.rb index 8106da38f5fe1..29fd5487b6376 100644 --- a/decidim-system/spec/commands/decidim/system/update_organization_spec.rb +++ b/decidim-system/spec/commands/decidim/system/update_organization_spec.rb @@ -18,6 +18,7 @@ module System let(:params) do { name: { en: "Gotham City" }, + short_name: { en: "GothamCity" }, host: "decide.example.org", secondary_hosts: "foo.example.org\r\n\r\nbar.example.org", force_users_to_authenticate_before_access_organization: false, @@ -130,6 +131,7 @@ module System let(:params) do { name: { en: "Gotham City" }, + short_name: { en: "GothamCity" }, host: "decide.example.org", users_registration_mode: "existing", file_upload_settings: params_for_uploads(upload_settings), diff --git a/decidim-system/spec/forms/decidim/system/api_user_form_spec.rb b/decidim-system/spec/forms/decidim/system/api_user_form_spec.rb index d1f81f5ec051a..6a2452812e217 100644 --- a/decidim-system/spec/forms/decidim/system/api_user_form_spec.rb +++ b/decidim-system/spec/forms/decidim/system/api_user_form_spec.rb @@ -13,7 +13,7 @@ module System let(:attributes) do { - name: name, + name:, organization: organization_id } end @@ -29,7 +29,7 @@ module System end context "when name is already exist" do - let!(:api_user) { create(:api_user, organization: organization, name: name) } + let!(:api_user) { create(:api_user, organization:, name:) } it { is_expected.not_to be_valid } end diff --git a/decidim-system/spec/forms/decidim/system/register_organization_form_spec.rb b/decidim-system/spec/forms/decidim/system/register_organization_form_spec.rb new file mode 100644 index 0000000000000..8149a01078c16 --- /dev/null +++ b/decidim-system/spec/forms/decidim/system/register_organization_form_spec.rb @@ -0,0 +1,459 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::System + describe RegisterOrganizationForm do + subject do + described_class.new( + name: "Gotham City", + short_name: "GothamCity", + host: "decide.example.org", + secondary_hosts: "foo.example.org\r\n\r\nbar.example.org", + reference_prefix: "JKR", + organization_admin_name: "Fiorello Henry La Guardia", + organization_admin_email: "f.laguardia@example.org", + available_locales: ["en"], + default_locale: "en", + users_registration_mode: "enabled", + force_users_to_authenticate_before_access_organization: "false", + **smtp_settings, + **omniauth_settings + ) + end + let(:omniauth_settings) do + { + "omniauth_settings_facebook_enabled" => true, + "omniauth_settings_facebook_app_id" => facebook_app_id, + "omniauth_settings_facebook_app_secret" => facebook_app_secret + } + end + let(:smtp_settings) do + { + "address" => "mail.example.org", + "port" => 25, + "user_name" => "f.laguardia", + "password" => password, + "from_email" => "decide@example.org", + "from_label" => from_label + } + end + let(:password) { "secret_password" } + let(:from_label) { "Decide Gotham" } + let(:facebook_app_id) { "plain-text-facebook-app-id" } + let(:facebook_app_secret) { "plain-text-facebook-app-secret" } + + context "when everything is OK" do + it { is_expected.to be_valid } + + describe "omniauth_settings" do + it "contains attributes as plain text" do + expect(subject.omniauth_settings_facebook_enabled).to be(true) + expect(subject.omniauth_settings_facebook_app_id).to eq(facebook_app_id) + expect(subject.omniauth_settings_facebook_app_secret).to eq(facebook_app_secret) + end + + context "when all values are blank" do + let(:omniauth_settings) do + { + "omniauth_settings_facebook_enabled" => nil, + "omniauth_settings_facebook_app_id" => nil, + "omniauth_settings_facebook_app_secret" => nil + } + end + + it "returns nil" do + expect(subject.encrypted_omniauth_settings).to be_nil + end + end + end + + describe "encrypted_omniauth_settings" do + it "encrypts sensible attributes" do + encrypted_settings = subject.encrypted_omniauth_settings + + expect(encrypted_settings["omniauth_settings_facebook_enabled"]).to be(true) + expect( + Decidim::AttributeEncryptor.decrypt(encrypted_settings["omniauth_settings_facebook_app_id"]) + ).to eq(facebook_app_id) + expect( + Decidim::AttributeEncryptor.decrypt(encrypted_settings["omniauth_settings_facebook_app_secret"]) + ).to eq(facebook_app_secret) + end + end + + describe "#set_from" do + it "concatenates from_label and from_email" do + from = subject.set_from + + expect(from).to eq("Decide Gotham ") + end + + context "when from_label is empty" do + let(:from_label) { "" } + + it "returns the email" do + from = subject.set_from + + expect(from).to eq("decide@example.org") + end + end + end + + describe "smtp_settings" do + it "handles SMTP password properly" do + expect(subject.smtp_settings).to eq(smtp_settings.except("password")) + expect(Decidim::AttributeEncryptor.decrypt(subject.encrypted_smtp_settings[:encrypted_password])).to eq(password) + end + + context "when all values are blank" do + let(:smtp_settings) do + { + "address" => "", + "port" => "", + "user_name" => "", + "password" => "", + "from_email" => "", + "from_label" => "" + } + end + + it "returns nil" do + expect(subject.encrypted_smtp_settings).to be_nil + end + end + end + end + + describe "validations" do + describe "organization_admin_email" do + context "when organization_admin_email is blank" do + before { subject.organization_admin_email = "" } + + it { is_expected.not_to be_valid } + + it "adds an error" do + subject.valid? + expect(subject.errors[:organization_admin_email]).to include("cannot be blank") + end + end + + context "when organization_admin_email is nil" do + before { subject.organization_admin_email = nil } + + it { is_expected.not_to be_valid } + end + end + + describe "organization_admin_name" do + context "when organization_admin_name is blank" do + before { subject.organization_admin_name = "" } + + it { is_expected.not_to be_valid } + + it "adds an error" do + subject.valid? + expect(subject.errors[:organization_admin_name]).to include("cannot be blank") + end + end + + context "when organization_admin_name is nil" do + before { subject.organization_admin_name = nil } + + it { is_expected.not_to be_valid } + end + end + + describe "name" do + context "when name is blank" do + before { subject.name = "" } + + it { is_expected.not_to be_valid } + + it "adds an error" do + subject.valid? + expect(subject.errors[:name]).to include("cannot be blank") + end + end + + context "when name is nil" do + before { subject.name = nil } + + it { is_expected.not_to be_valid } + end + end + + describe "short_name" do + context "when short_name is blank" do + before { subject.short_name = "" } + + it { is_expected.not_to be_valid } + + it "adds an error" do + subject.valid? + expect(subject.errors[:short_name]).to include("cannot be blank") + end + end + + context "when short_name is nil" do + before { subject.short_name = nil } + + it { is_expected.not_to be_valid } + end + + context "when short_name is too short" do + before { subject.short_name = "AB" } + + it { is_expected.not_to be_valid } + + it "adds an error" do + subject.valid? + expect(subject.errors[:short_name]).to include("is too short (under 3 characters)") + end + end + + context "when short_name is too long" do + before { subject.short_name = "A" * 13 } + + it { is_expected.not_to be_valid } + + it "adds an error" do + subject.valid? + expect(subject.errors[:short_name]).to include("is too long (maximum is 12 characters)") + end + end + + context "when short_name has minimum valid length" do + before { subject.short_name = "ABC" } + + it { is_expected.to be_valid } + end + + context "when short_name has maximum valid length" do + before { subject.short_name = "A" * 12 } + + it { is_expected.to be_valid } + end + end + + describe "reference_prefix" do + context "when reference_prefix is blank" do + before { subject.reference_prefix = "" } + + it { is_expected.not_to be_valid } + + it "adds an error" do + subject.valid? + expect(subject.errors[:reference_prefix]).to include("cannot be blank") + end + end + + context "when reference_prefix is nil" do + before { subject.reference_prefix = nil } + + it { is_expected.not_to be_valid } + end + end + + describe "available_locales" do + context "when available_locales is blank" do + before { subject.available_locales = [] } + + it { is_expected.not_to be_valid } + + it "adds an error" do + subject.valid? + expect(subject.errors[:available_locales]).to include("cannot be blank") + end + end + + context "when available_locales is nil" do + before { subject.available_locales = nil } + + it { is_expected.not_to be_valid } + end + end + + describe "default_locale" do + context "when default_locale is blank" do + before { subject.default_locale = "" } + + it { is_expected.not_to be_valid } + + it "adds an error" do + subject.valid? + expect(subject.errors[:default_locale]).to include("cannot be blank") + end + end + + context "when default_locale is nil" do + before { subject.default_locale = nil } + + it { is_expected.not_to be_valid } + end + + context "when default_locale is not included in available_locales" do + before do + subject.available_locales = %w(en es) + subject.default_locale = "fr" + end + + it { is_expected.not_to be_valid } + + it "adds an error" do + subject.valid? + expect(subject.errors[:default_locale]).to include("is not included in the list") + end + end + + context "when default_locale is included in available_locales" do + before do + subject.available_locales = %w(en es fr) + subject.default_locale = "fr" + end + + it { is_expected.to be_valid } + end + end + + describe "organization uniqueness" do + let!(:existing_organization) do + create( + :organization, + name: { en: "Existing City", es: "Ciudad Existente" }, + host: "existing.example.org" + ) + end + + context "when organization name already exists (case-insensitive)" do + before { subject.name = "EXISTING CITY" } + + it { is_expected.not_to be_valid } + + it "adds an error" do + subject.valid? + expect(subject.errors[:name]).to include("has already been taken") + end + end + + context "when organization name already exists in different locale" do + before { subject.name = "Ciudad Existente" } + + it { is_expected.not_to be_valid } + + it "adds an error" do + subject.valid? + expect(subject.errors[:name]).to include("has already been taken") + end + end + + context "when host already exists" do + before { subject.host = "existing.example.org" } + + it { is_expected.not_to be_valid } + + it "adds an error" do + subject.valid? + expect(subject.errors[:host]).to include("has already been taken") + end + end + + context "when organization name is unique" do + before { subject.name = "Unique City" } + + it { is_expected.to be_valid } + end + + context "when host is unique" do + before { subject.host = "unique.example.org" } + + it { is_expected.to be_valid } + end + end + + describe "short_name uniqueness" do + let!(:existing_organization) do + create( + :organization, + short_name: { en: "ExistingCity", es: "CiudadExistente" } + ) + end + + context "when organization short_name already exists (case-insensitive)" do + before { subject.short_name = "EXISTINGCITY" } + + it { is_expected.not_to be_valid } + + it "adds an error" do + subject.valid? + expect(subject.errors[:short_name]).to include("has already been taken") + end + end + + context "when organization short_name already exists in different locale" do + before { subject.short_name = "CiudadExistente" } + + it { is_expected.not_to be_valid } + + it "adds an error" do + subject.valid? + expect(subject.errors[:short_name]).to include("has already been taken") + end + end + + context "when organization short_name is unique" do + before { subject.short_name = "UniqueCity" } + + it { is_expected.to be_valid } + end + end + end + + describe "#map_model" do + subject { described_class.from_model(organization) } + + let(:organization) do + create( + :organization, + secondary_hosts: ["foobar.example.org", "foobaz.example.org"], + omniauth_settings: { + omniauth_settings_facebook_enabled: Decidim::AttributeEncryptor.encrypt(true), + omniauth_settings_facebook_app_id: Decidim::AttributeEncryptor.encrypt("foo") + }, + file_upload_settings: { + allowed_file_extensions: { + default: %w(jpg jpeg), + admin: %w(jpg jpeg png), + image: %w(jpg jpeg png) + }, + allowed_content_types: { + default: %w(image/*), + admin: %w(image/*) + }, + maximum_file_size: { + default: 7.2, + avatar: 2.4 + } + } + ) + end + + it "maps the organization attributes correctly" do + expect(subject.secondary_hosts).to eq(organization.secondary_hosts.join("\n")) + expect(subject.omniauth_settings).to eq( + { + "omniauth_settings_facebook_app_id" => "foo", + "omniauth_settings_facebook_enabled" => true + } + ) + expect(subject.file_upload_settings.final).to eq( + { + allowed_content_types: { "admin" => %w(image/*), "default" => %w(image/*) }, + allowed_file_extensions: { "admin" => %w(jpg jpeg png), "default" => %w(jpg jpeg), "image" => %w(jpg jpeg png) }, + maximum_file_size: { "avatar" => 2.4, "default" => 7.2 } + } + ) + end + end + end +end diff --git a/decidim-system/spec/forms/decidim/system/update_organization_form_spec.rb b/decidim-system/spec/forms/decidim/system/update_organization_form_spec.rb index b4eebaa4b9d70..dd7084e2792f5 100644 --- a/decidim-system/spec/forms/decidim/system/update_organization_form_spec.rb +++ b/decidim-system/spec/forms/decidim/system/update_organization_form_spec.rb @@ -7,6 +7,7 @@ module Decidim::System subject do described_class.new( name: { ca: "", en: "Gotham City", es: "" }, + short_name: { ca: "", en: "GothamCity", es: "" }, host: "decide.example.org", secondary_hosts: "foo.example.org\r\n\r\nbar.example.org", reference_prefix: "JKR", @@ -124,6 +125,470 @@ module Decidim::System end end + describe "validations" do + describe "organization name presence" do + let(:organization) { create(:organization, default_locale: "en") } + + before do + subject.id = organization.id + allow(subject).to receive(:current_organization).and_return(organization) + end + + context "when name in default locale is present" do + before { subject.name = { en: "Gotham City" } } + + it { is_expected.to be_valid } + end + + context "when name in default locale is blank" do + before { subject.name = { en: "" } } + + it { is_expected.not_to be_valid } + + it "adds an error to the default locale name attribute" do + subject.valid? + expect(subject.errors[:name_en]).to include("cannot be blank") + end + end + + context "when name in default locale is nil" do + before { subject.name = { en: nil } } + + it { is_expected.not_to be_valid } + + it "adds an error to the default locale name attribute" do + subject.valid? + expect(subject.errors[:name_en]).to include("cannot be blank") + end + end + + context "when organization has different default locale" do + let(:organization) { create(:organization, default_locale: "es") } + + before do + subject.default_locale = "es" + subject.name = { es: "" } + end + + it { is_expected.not_to be_valid } + + it "adds an error to the correct locale name attribute" do + subject.valid? + expect(subject.errors[:name_es]).to include("cannot be blank") + end + end + + context "when current_organization is not set" do + before do + allow(subject).to receive(:current_organization).and_return(nil) + subject.send(:"name_#{Decidim.default_locale}=", "") + end + + it { is_expected.not_to be_valid } + + it "uses Decidim default locale" do + subject.valid? + expect(subject.errors[:"name_#{Decidim.default_locale}"]).to include("cannot be blank") + end + end + end + + describe "organization short_name presence" do + let(:organization) { create(:organization, default_locale: "en") } + + before do + subject.id = organization.id + allow(subject).to receive(:current_organization).and_return(organization) + end + + context "when short_name in default locale is present" do + before { subject.short_name = { en: "GothamCity" } } + + it { is_expected.to be_valid } + end + + context "when short_name in default locale is blank" do + before { subject.short_name = { en: "" } } + + it { is_expected.not_to be_valid } + + it "adds an error to the default locale short_name attribute" do + subject.valid? + expect(subject.errors[:short_name_en]).to include("cannot be blank") + end + end + + context "when short_name in default locale is nil" do + before { subject.short_name = { en: nil } } + + it { is_expected.not_to be_valid } + + it "adds an error to the default locale short_name attribute" do + subject.valid? + expect(subject.errors[:short_name_en]).to include("cannot be blank") + end + end + + context "when organization has different default locale" do + let(:organization) { create(:organization, default_locale: "es") } + + before do + subject.default_locale = "es" + subject.short_name = { es: "" } + end + + it { is_expected.not_to be_valid } + + it "adds an error to the correct locale short_name attribute" do + subject.valid? + expect(subject.errors[:short_name_es]).to include("cannot be blank") + end + end + end + + describe "short_name format" do + context "when short_name is too short in one locale" do + before { subject.short_name = { en: "AB", es: "ValidName" } } + + it { is_expected.not_to be_valid } + + it "adds an error to the locale with invalid format" do + subject.valid? + expect(subject.errors[:short_name_en]).to include("is too short (under 3 characters)") + end + end + + context "when short_name is too long in one locale" do + before { subject.short_name = { en: "A" * 13, es: "ValidName" } } + + it { is_expected.not_to be_valid } + + it "adds an error to the locale with invalid format" do + subject.valid? + expect(subject.errors[:short_name_en]).to include("is too long (maximum is 12 characters)") + end + end + + context "when short_name is invalid in multiple locales" do + before { subject.short_name = { en: "AB", es: "A" * 13 } } + + it { is_expected.not_to be_valid } + + it "adds errors to all locales with invalid format" do + subject.valid? + expect(subject.errors[:short_name_en]).to include("is too short (under 3 characters)") + expect(subject.errors[:short_name_es]).to include("is too long (maximum is 12 characters)") + end + end + + context "when short_name has minimum valid length" do + before { subject.short_name = { en: "ABC" } } + + it { is_expected.to be_valid } + end + + context "when short_name has maximum valid length" do + before { subject.short_name = { en: "A" * 12 } } + + it { is_expected.to be_valid } + end + + context "when short_name is blank in a locale" do + before { subject.short_name = { en: "ValidName", es: "" } } + + it "does not add format validation errors for blank values" do + subject.valid? + expect(subject.errors[:short_name_es]).not_to include("is too short (under 3 characters)") + end + end + end + + describe "organization uniqueness" do + let!(:existing_organization) do + create( + :organization, + name: { en: "Existing City", es: "Ciudad Existente" }, + host: "existing.example.org" + ) + end + + context "when creating a new organization" do + context "when organization name already exists (case-insensitive)" do + before { subject.name_en = "EXISTING CITY" } + + it { is_expected.not_to be_valid } + + it "adds an error to the name attribute" do + subject.valid? + expect(subject.errors[:name_en]).to include("has already been taken") + end + end + + context "when organization name already exists in different locale" do + before { subject.name_en = "Ciudad Existente" } + + it { is_expected.not_to be_valid } + + it "adds an error" do + subject.valid? + expect(subject.errors[:name_en]).to include("has already been taken") + end + end + + context "when multiple locale names conflict" do + before do + subject.name_en = "Existing City" + subject.name_es = "Ciudad Existente" + end + + it { is_expected.not_to be_valid } + + it "adds errors to both locale attributes" do + subject.valid? + expect(subject.errors[:name_en]).to include("has already been taken") + expect(subject.errors[:name_es]).to include("has already been taken") + end + end + + context "when host already exists" do + before { subject.host = "existing.example.org" } + + it { is_expected.not_to be_valid } + + it "adds an error" do + subject.valid? + expect(subject.errors[:host]).to include("has already been taken") + end + end + + context "when organization name is unique" do + before { subject.name_en = "Unique City" } + + it { is_expected.to be_valid } + end + + context "when host is unique" do + before { subject.host = "unique.example.org" } + + it { is_expected.to be_valid } + end + end + + context "when updating an existing organization" do + let(:organization_to_update) do + create( + :organization, + name: { en: "My City", es: "Mi Ciudad" }, + host: "mycity.example.org" + ) + end + + before do + subject.id = organization_to_update.id + end + + context "when keeping the same name" do + before { subject.name_en = "My City" } + + it { is_expected.to be_valid } + end + + context "when keeping the same host" do + before { subject.host = "mycity.example.org" } + + it { is_expected.to be_valid } + end + + context "when changing name to an existing one" do + before { subject.name_en = "Existing City" } + + it { is_expected.not_to be_valid } + + it "adds an error" do + subject.valid? + expect(subject.errors[:name_en]).to include("has already been taken") + end + end + + context "when changing host to an existing one" do + before { subject.host = "existing.example.org" } + + it { is_expected.not_to be_valid } + + it "adds an error" do + subject.valid? + expect(subject.errors[:host]).to include("has already been taken") + end + end + + context "when changing name to a unique one" do + before { subject.name_en = "Brand New City" } + + it { is_expected.to be_valid } + end + + context "when changing host to a unique one" do + before { subject.host = "other.example.org" } + + it { is_expected.to be_valid } + end + end + + context "when name contains machine_translations" do + let!(:org_with_translations) do + create( + :organization, + name: { + :en => "City", + "machine_translations" => { fr: "Ville" } + } + ) + end + + context "when new name conflicts with machine translation" do + before { subject.name_en = "Ville" } + + it { is_expected.not_to be_valid } + + it "adds an error" do + subject.valid? + expect(subject.errors[:name_en]).to include("has already been taken") + end + end + end + + context "when name value is a Hash (nested structure)" do + before do + allow(subject).to receive(:name).and_return({ en: { nested: "value" }, es: "Valid Name" }) + end + + it "skips Hash values during validation" do + expect { subject.valid? }.not_to raise_error + end + end + end + + describe "short_name uniqueness" do + let!(:existing_organization) do + create( + :organization, + short_name: { en: "ExistingCity", es: "CiudadExistente" } + ) + end + + context "when creating a new organization" do + context "when organization short_name already exists (case-insensitive)" do + before { subject.short_name = { en: "EXISTINGCITY" } } + + it { is_expected.not_to be_valid } + + it "adds an error to the short_name attribute" do + subject.valid? + expect(subject.errors[:short_name_en]).to include("has already been taken") + end + end + + context "when organization short_name already exists in different locale" do + before { subject.short_name = { en: "CiudadExistente" } } + + it { is_expected.not_to be_valid } + + it "adds an error" do + subject.valid? + expect(subject.errors[:short_name_en]).to include("has already been taken") + end + end + + context "when multiple locale short_names conflict" do + before { subject.short_name = { en: "ExistingCity", es: "CiudadExistente" } } + + it { is_expected.not_to be_valid } + + it "adds errors to both locale attributes" do + subject.valid? + expect(subject.errors[:short_name_en]).to include("has already been taken") + expect(subject.errors[:short_name_es]).to include("has already been taken") + end + end + + context "when organization short_name is unique" do + before { subject.short_name = { en: "UniqueCity" } } + + it { is_expected.to be_valid } + end + end + + context "when updating an existing organization" do + let(:organization_to_update) do + create( + :organization, + short_name: { en: "MyCity", es: "MiCiudad" } + ) + end + + before do + subject.id = organization_to_update.id + end + + context "when keeping the same short_name" do + before { subject.short_name = { en: "My City" } } + + it { is_expected.to be_valid } + end + + context "when changing short_name to an existing one" do + before { subject.short_name = { en: "ExistingCity" } } + + it { is_expected.not_to be_valid } + + it "adds an error" do + subject.valid? + expect(subject.errors[:short_name_en]).to include("has already been taken") + end + end + + context "when changing short_name to a unique one" do + before { subject.short_name = { en: "BrandNewCity" } } + + it { is_expected.to be_valid } + end + end + + context "when short_name contains machine_translations" do + let!(:org_with_translations) do + create( + :organization, + short_name: { + :en => "City", + "machine_translations" => { fr: "Ville" } + } + ) + end + + context "when new short_name conflicts with machine translation" do + before { subject.short_name = { en: "Ville" } } + + it { is_expected.not_to be_valid } + + it "adds an error" do + subject.valid? + expect(subject.errors[:short_name_en]).to include("has already been taken") + end + end + end + + context "when short_name value is a Hash (nested structure)" do + before do + allow(subject).to receive(:short_name).and_return({ en: { nested: "value" }, es: "ValidShortName" }) + end + + it "skips Hash values during validation" do + expect { subject.valid? }.not_to raise_error + end + end + end + end + describe "#map_model" do subject { described_class.from_model(organization) } diff --git a/decidim-system/spec/system/explore_api_credentials_spec.rb b/decidim-system/spec/system/explore_api_credentials_spec.rb index 2550d5cc96db4..e41a6cad3729e 100644 --- a/decidim-system/spec/system/explore_api_credentials_spec.rb +++ b/decidim-system/spec/system/explore_api_credentials_spec.rb @@ -36,7 +36,7 @@ end context "with API users" do - let!(:set) { create_list(:api_user, 3, organization: organization) } + let!(:set) { create_list(:api_user, 3, organization:) } let!(:set1) { create_list(:api_user, 4, organization: organization1) } before do diff --git a/decidim-system/spec/system/organizations_spec.rb b/decidim-system/spec/system/organizations_spec.rb index 38797e01ef510..c3e7c77b4d7da 100644 --- a/decidim-system/spec/system/organizations_spec.rb +++ b/decidim-system/spec/system/organizations_spec.rb @@ -42,6 +42,7 @@ it "creates a new organization" do fill_in "Name", with: "Citizen Corp" + fill_in "Short name", with: "CitizenCorp" fill_in "Host", with: "www.example.org" fill_in "Secondary hosts", with: "foo.example.org\n\rbar.example.org" fill_in "Reference prefix", with: "CCORP" @@ -78,6 +79,7 @@ it "does not create an organization" do fill_in "Name", with: "Citizen Corp" + fill_in "Short name", with: "CitizenCorp" fill_in "Host", with: "www.example.org" fill_in "Reference prefix", with: "CCORP" click_on "Create organization & invite admin" @@ -95,6 +97,7 @@ it "does not create an organization" do fill_in "Name", with: "Citizen Corp 2" + fill_in "Short name", with: "CitizenCorp2" fill_in "Reference prefix", with: "CCORP" fill_in "Organization admin name", with: "system@example.org" diff --git a/decidim-templates/app/views/decidim/templates/admin/proposal_answer_templates/index.html.erb b/decidim-templates/app/views/decidim/templates/admin/proposal_answer_templates/index.html.erb index ec507f7f87220..5ff8ea068a958 100644 --- a/decidim-templates/app/views/decidim/templates/admin/proposal_answer_templates/index.html.erb +++ b/decidim-templates/app/views/decidim/templates/admin/proposal_answer_templates/index.html.erb @@ -25,7 +25,7 @@ <% @templates.each do |template| %> "> - <%= link_to_if allowed_to?(:update, :template, template: template), translated_attribute(template.name), edit_proposal_answer_template_path(template) %> + <%= link_to_if allowed_to?(:update, :template, template:), translated_attribute(template.name), edit_proposal_answer_template_path(template) %> "> <%= proposal_state(template) %> diff --git a/decidim-verifications/config/locales/fi-plain.yml b/decidim-verifications/config/locales/fi-plain.yml index ab4fc7f4411a6..5dc66a492bb15 100644 --- a/decidim-verifications/config/locales/fi-plain.yml +++ b/decidim-verifications/config/locales/fi-plain.yml @@ -178,7 +178,7 @@ fi-pl: no_user: Käyttäjää ei löytynyt success: '%{count} tietueen tuonti onnistui. Tietoja käsitellään parhaillaan. Päivitä tämä sivu muutaman minuutin kuluttua nähdäksesi muutokset.' destroy: - success: Henkilötietorekisteriin tietue poistettiin. + success: Henkilötietorekisterin tietue poistettiin. index: empty: Henkilötietorekisterissä ei ole tietoja. Käytä %{import_csv} -toimintoa tuodaksesi tiedot järjestelmään CSV-tiedostosta. no_sign_in: Ei ole koskaan kirjautunut sisään diff --git a/decidim-verifications/config/locales/fi.yml b/decidim-verifications/config/locales/fi.yml index 34655354df782..c5593d8702391 100644 --- a/decidim-verifications/config/locales/fi.yml +++ b/decidim-verifications/config/locales/fi.yml @@ -178,7 +178,7 @@ fi: no_user: Käyttäjää ei löytynyt success: '%{count} tietueen tuonti onnistui. Tietoja käsitellään parhaillaan. Päivitä tämä sivu muutaman minuutin kuluttua nähdäksesi muutokset.' destroy: - success: Henkilötietorekisteriin tietue poistettiin. + success: Henkilötietorekisterin tietue poistettiin. index: empty: Henkilötietorekisterissä ei ole tietoja. Käytä %{import_csv} -toimintoa tuodaksesi tiedot järjestelmään CSV-tiedostosta. no_sign_in: Ei ole koskaan kirjautunut sisään diff --git a/decidim-verifications/spec/forms/decidim/verifications/csv_census/admin/census_data_form_spec.rb b/decidim-verifications/spec/forms/decidim/verifications/csv_census/admin/census_data_form_spec.rb index 9b57f5d711ef0..8baa07d367753 100644 --- a/decidim-verifications/spec/forms/decidim/verifications/csv_census/admin/census_data_form_spec.rb +++ b/decidim-verifications/spec/forms/decidim/verifications/csv_census/admin/census_data_form_spec.rb @@ -7,7 +7,7 @@ module Decidim::Verifications::CsvCensus::Admin subject { described_class.from_params(file:) } context "when the file is in invalid format" do - let(:file) { Decidim::Dev.test_file("import_participatory_space_private_users_iso8859-1.csv", "text/csv") } + let(:file) { Decidim::Dev.test_file("import_members_iso8859-1.csv", "text/csv") } it { is_expected.not_to be_nil } end diff --git a/docs/modules/develop/pages/api/authentication.adoc b/docs/modules/develop/pages/api/authentication.adoc new file mode 100644 index 0000000000000..21235ebd5aebf --- /dev/null +++ b/docs/modules/develop/pages/api/authentication.adoc @@ -0,0 +1,95 @@ += Authentication with the API + +By default, the GraphQL API in Decidim is publically available for read-only operations and it can also be used by external applications to read data from Decidim. If you want to write data over the API, i.e. perform GraphQL mutations, you need first need to authenticate the API user to perform these operations. Otherwise, it is not possible to perform such operations as most such operations are performed as an actual user in Decidim. + +More information regarding implementing the API authentication is available in the API documentation of your Decidim instance. + +== Signing in to the API + +In case you want to use the API as a sign in user to perform mutations representing a user in Decidim, you have two available options for such integrations through the system administration panel: + +1. Creating an OAuth application and implementing the OAuth authentication flow for the users of your application. Use this option for participant-facing applications where the participants represent themselves in Decidim through the API. +2. Creating API credentials and signing in to the API with these credentials to perform the operations as a signed in machine user. Use this option for machine-to-machine automations where there is no real end user interacting with Decidim. + +If you only want to test the GraphQL queries as a signed in user, you can use the normal Decidim authentication functionality to sign in and then use the GraphiQL IDE to perform these queries as a signed in user. + +== OAuth flow for participant-facing applications + +Participant-facing applications where the participants need to interact with Decidim through GraphQL mutations can be integrated using OAuth applications. In order to configure such integration capability from the system administration panel, create a new OAuth application and provide the necessary details for your integration. Note that the "application type" for such applications would typically be "Public". For more information regarding the application types, refer to https://datatracker.ietf.org/doc/html/rfc6749#section-2.1[RFC 6749 Section 2.1. (OAuth client types)]. + +In order to use the OAuth access tokens to represent the user through the API, please select the following scopes as "Available" scopes for the application: + +* `user` - Authenticated users have the ability to represent a logged in user in Decidim +* `api:read` - Authenticated users have the ability to read data from the API +* `api:write` - Authenticated users have the ability to write data through the API (in case your external application needs to perform mutations over the API on behalf of the user) + +Once configured, you can now use any OAuth authentication library to perform the OAuth authentication flow with your application users and receive an access token to utilize the Decidim API representing the signed in user. Please note that with public OAuth clients especially (and recommended also for confidential clients), you have to use https://datatracker.ietf.org/doc/html/rfc7636[PKCE] with the authorization flow. + +Once the OAuth application is created, you can authenticate against it with the following steps: + +1. Send the user to perform an OAuth authorization request at Decidim with the required API scopes (`user`, `api:read` and `api:write` if you want to perform mutations over the API). Along with the authorization request, also send the additional parameters required by PKCE (`code_challenge` and `code_challenge_method`). +2. Receive an OAuth authorization code back to your application's configured redirect URI. +3. Utilizing the received authorization code, request an OAuth access token from the OAuth token endpoint. Along with the token request, also send the additional parameter required by PKCE `code_verifier`. +4. The issued token is a JSON Web Token (JWT) when the authorization request contains the defined scopes. This token can be now used to represent the user in further calls to the API by passing the token with its type (`Bearer`) within the HTTP Authorization header with the request to the API. + +When doing the requests to the API, you also need to pass the OAuth client ID within the `X-Jwt-Aud` header of the requests in order for the token to be recognized as a valid token for the issued client. Passing the bearer token to the `Authorization` header and the OAuth client ID to the `X-Jwt-Aud` header, you can send the following HTTP request to the API to validate that the token works and the user is recognized as signed in: + +```http +POST /api HTTP/1.1 +Accept: application/json +Authorization: Bearer token +Content-Length: 53 +Content-Type: application/json +Host: DOMAIN +X-Jwt-Aud: OAUTH_CLIENT_ID + +{"query":"{ session { user { id name nickname } } }"} +``` + +You should see the user details in the response in case the token is valid and you have configured the API correctly. If the response does not contain the user details, please refer to the Decidim configuration documentation. + +Once the interaction with the API is completed, it is recommended to revoke the tokens, which is similar to the user signing out of the application. This can be done utilizing the OAuth revocation endpoint provided by Decidim. After the token is revoked, it is no longer valid and the user has to perform a re-authorization the next time they want to utilize the API. + +In case you need tokens with a longer life span, you can either look into the Decidim documentation to extend the validity period of the access tokens or enable refresh tokens for the OAuth application when configuring it. However, note that tokens with longer lifespan can weaken the security of your system and make your application users vulnerable to security threats. Such use cases should be carefully planned and the security concerns should be addressed seriously. + +== API credentials flow for machine-to-machine automations + +The API credentials represent an administrative user in Decidim that performs administrative tasks on behalf of the end users. This type of integration flows should never live on devices that the participants have access to. These types of integrations are meant for different types of automations, such as transferring proposal answers or meeting reports back to Decidim from an external system automatically, e.g. once a day. + +Note that these credentials are highly sensitive and have elevated permissions, so take good care of the system security where you are planning to store these credentials. If these credentials end up in participants' hands, the whole system is compromised and no longer secure. You should always primarily create OAuth integrations where the end users will manually perform the authorization for the application to perform actions on behalf of them. + +Once you have validated that this is the correct way for your integration to operate, you can create the API credentials from the system administration panel. You will receive an API key and API secret after creating the credentials. These credentials should be also manually rotated on a regular basis to prevent unauthorized access to the system with these credentials in case they are leaked. The credentials have to be manually rotated in order to prevent external applications breaking because they cannot rotate the credentials themselves and they are typically statically configured for these applications. + +Given you have issued the API key and API secret, you can now send a sign in request to the API using these credentials as follows: + +```bash +curl -s -i -H "Content-type: application/x-www-form-urlencoded" \ + -d "api_user[key]=PASTE_API_KEY_HERE" \ + -d "api_user[secret]=PASTE_API_SECRET_HERE" \ + -X POST https://DOMAIN/api/sign_in | grep 'Authorization' | cut -d ' ' -f2- +``` + +After running this command, you should see the following string in the console, where `token` is replaced with the access token: + +```bash +Bearer token +``` + +This string is passed to the following requests within the HTTP `Authorization` header to represent the user during API calls. You can use the following example query to test it out and confirm that signing in works as expected: + +```bash +curl -w "\n" -H "Content-Type: application/json" \ + -H "Authorization: Bearer token" \ + -d '{"query":"{ session { user { id name nickname } } }"}' \ + -X POST https://DOMAIN/api +``` + +You should see the user details in the response in case the token is valid and you have configured the API correctly. If the response does not contain the user details, please refer to the Decidim configuration documentation. + +Once the API interaction is done, you should always make an HTTP DELETE request to `/api/sign_out` with the same token in order to revoke the token from further access as follows: + +```bash +curl -s -o /dev/null -w "HTTP %{http_code}\n" \ + -H "Authorization: Bearer token" \ + -X DELETE http://DOMAIN/api/sign_out +``` diff --git a/docs/modules/develop/pages/api/core-concepts.adoc b/docs/modules/develop/pages/api/core-concepts.adoc new file mode 100644 index 0000000000000..524cd362c84d4 --- /dev/null +++ b/docs/modules/develop/pages/api/core-concepts.adoc @@ -0,0 +1,535 @@ += Core concepts in the API: Decidim structure, Types, collections and Polymorphism + +There are no endpoints in the GraphQL specification, instead objects are organized according to their "Type". + +These objects can be grouped in a single, complex query. Also, objects may accept parameters, which are "Types" as well. + +Each "Type" is just a pre-defined structure with fields, or just an Scalar (Strings, Integers, Booleans, ...). + +For instance, to obtain *all the participatory processes in a Decidim installation published since January 2018* and order them by published date, we could execute the next query: + +```graphql +{ + participatoryProcesses(filter: {publishedSince: "2018-01-01"}, order: {publishedAt: "asc"}) { + slug + title { + translation(locale: "en") + } + } +} +``` + +Response should look like: + +```json +{ + "data": { + "participatoryProcesses": [ + { + "slug": "consectetur-at", + "title": { + "translation": "Soluta consectetur quos fugit aut." + } + }, + { + "slug": "nostrum-earum", + "title": { + "translation": "Porro hic ipsam cupiditate reiciendis." + } + } + ] + } +} +``` + +== What happened? + +In the former query, each keyword represents a type, the words `publishedSince`, `publishedAt`, `slug`, `locale` are scalars, all of them Strings. + +The other keywords however, are objects representing certain entities: + +* `participatoryProcesses` is a type that represents a collection of participatory spaces. It accepts arguments (`filter` and `order`), which are other object types as well. `slug` and `title` are the fields of the participatory process we are interested in, there are "Types" too. +* `filter` is a https://nightly.decidim.org/api/docs/input_object/participatoryprocessfilter/[ParticipatoryProcessFilter] input type, it has several properties that allows us to refine our search. One of them is the `publishedSince` property with the initial date from which to list entries. +* `order` is a https://nightly.decidim.org/api/docs/input_object/participatoryprocesssort/[ParticipatoryProcessSort] type, works the same way as the filter but with the goal of ordering the results. +* `title` is a https://nightly.decidim.org/api/docs/object/translatedfield/[TranslatedField] type, which allows us to deal with multi-language fields. + +Finally, note that the returned object is an array, each item of which is a representation of the object we requested. + +> \***About how filters and sorting are organized** +> +> There are two types of objects to filter and ordering collections in Decidim, they all work in a similar fashion. The type involved in filtering always have the suffix "Filter", for ordering it has the suffix "Sort". +> +> The types used to filter participatory spaces are: https://nightly.decidim.org/api/docs/input_object/participatoryprocessfilter/[ParticipatoryProcessFilter], [AssemblyFilter](#AssemblyFilter), and so on. +> +> Other collections (or connections) may have their own filters (i.e. https://nightly.decidim.org/api/docs/input_object/componentfilter/[ComponentFilter]). +> +> Each filter has its own properties, you should check any object in particular for details. The way they work with multi-languages fields, however, is the same: +> +> We can say we have some searchable object with a multi-language field called *title*, and we have a filter that allows us to search through this field. How should it work? Should we look up content for every language in the field? or should we stick to a specific language? +> +> In our case, we have decided to search only one particular language of a multi-language field but we let you choose which language to search. +> If no language is specified, the configured as default in the organization will be used. The keyword to specify the language is `locale`, and it should be provided in the 2 letters ISO 639-1 format (en = English, es = Spanish, ...). +> +> Example (this is not a real Decidim query): +> +> ```graphql +> some_collection(filter: { locale: "en", title: "ideas"}) { +> id +> } +> ``` +> +> The same applies to sorting (https://nightly.decidim.org/api/docs/input_object/participatoryprocesssort/[ParticipatoryProcessSort], [AssemblySort](#AssemblySort), etc.) +> +> In this case, the content of the field (*title*) only allows 2 values: *ASC* and *DESC*. +> +> Example of ordering alphabetically by the title content in French language: +> +> ```graphql +> some_collection(order: { locale: "en", title: "asc"}) { +> id +> } +> ``` +> +> Of course, you can combine both filter and order. Also remember to check availability of this type of behaviour for any particular filter/sort. + +== Decidim main types + +Decidim has 2 main types of objects through which content is provided. These are Participatory Spaces and Components. + +A participatory space is the first level, currently there are 5 officially supported: *Participatory Processes*, *Assemblies*, *Conferences* and *Initiatives*. For each participatory process there will correspond a collection type and a "single item" type. + +The previous example uses the collection type for participatory processes. You can try `assemblies`, `conferences`, or `initiatives` for the others. Note that each collection can implement their own filter and order types with different properties. + +As an example for a single item query, you can run: + +```graphql +{ + participatoryProcess(slug: "consectetur-at") { + slug + title { + translation(locale: "en") + } + } +} +``` + +And the response will be: + +```json +{ + "data": { + "participatoryProcess": { + "slug": "consectetur-at", + "title": { + "translation": "Soluta consectetur quos fugit aut." + } + } + } +} +``` + +== What is different? + +First, note that we are querying, in singular, the type `participatoryProcess`, with a different parameter, `slug`\*, (a String). We can use the `id` instead if we know it. + +Second, the response is not an Array, it is just the object we requested. We can expect to return `null` if the object is not found. + +> \* The `slug` is a convenient way to find a participatory space as is (usually) in the URL. +> +> For instance, consider this real case from Barcelona: +> +> https://www.decidim.barcelona/processes/patrimonigracia +> +> The word `patrimonigracia` indicates the "slug". + +== Components + +Every participatory space may (and should) have some components. There are 9 official components, these are `Proposals`, `Page`, `Meetings`, `Budgets`, `Surveys`, `Accountability`, `Debates` and `Blog`. Plugins may add their own components. + +If you know the `id`\* of a specific component you can obtain it by querying it directly: + +```graphql +{ + component(id:2) { + id + name { + translation(locale:"en") + } + __typename + participatorySpace { + id + type + } + } +} +``` + +Response: + +```json +{ + "data": { + "component": { + "id": "2", + "name": { + "translation": "Meetings" + }, + "__typename": "Meetings", + "participatorySpace": { + "id": "1", + "type": "Decidim::ParticipatoryProcess" + } + } + } +} +``` + +The process is analogue as what has been explained in the case of searching for one specific participatory process. + +> \*Note that the `id` of a component is present also in the URL after the letter "f": +> +> https://www.decidim.barcelona/processes/patrimonigracia/f/3257/ +> +> In this case, 3257. + +== What about component's collections? + +Glad you asked, component's collections cannot be retrieved directly, the are available *in the context* of a participatory space. + +For instance, we can query all the components in an particular Assembly as follows: + +```graphql +{ + assembly(id: 3) { + components { + id + name { + translation(locale: "en") + } + __typename + } + } +} +``` + +The response will be similar to: + +```json +{ + "data": { + "assembly": { + "components": [ + { + "id": "42", + "name": { + "translation": "Accountability" + }, + "__typename": "Component" + }, + { + "id": "38", + "name": { + "translation": "Meetings" + }, + "__typename": "Meetings" + }, + { + "id": "37", + "name": { + "translation": "Page" + }, + "__typename": "Pages" + }, + { + "id": "39", + "name": { + "translation": "Proposals" + }, + "__typename": "Proposals" + } + ] + } + } +} +``` + +We can also apply some filters by using the [ComponentFilter](#ComponentFilter) type. In the next query we would like to *find all the components with geolocation enabled in the assembly with id=2*: + +```graphql +{ + assembly(id: 2) { + components(filter: {withGeolocationEnabled: true}) { + id + name { + translation(locale: "en") + } + __typename + } + } +} +``` + +The response: + +```json +{ + "data": { + "assembly": { + "components": [ + { + "id": "39", + "name": { + "translation": "Meetings" + }, + "__typename": "Meetings" + } + ] + } + } +} +``` + +Note that, in this case, there is only one component returned, "Meetings". In some cases Proposals can be geolocated too therefore would be returned in this query. + +== Polymorphism and connections + +Many relationships between tables in Decidim are polymorphic, this means that the related object can belong to different classes and share just a few properties in common. + +For instance, components in a participatory space are polymorphic, while the concept of component is generic and all of them share properties like *published date*, *name* or *weight*, they differ in the rest. *Proposals* have the *status* field while *Meetings* have an *agenda*. + +Another example are the case of linked resources, these are properties that may link objects of different nature between components or participatory spaces. + +In a very simplified way (to know more please refer to the official guide), GraphQL polymorphism is handled through the operator `... on`. You will know when a field is polymorphic because the property `__typename`, which tells you the type of that particular object, will change accordingly. + +In the previous examples we have queried for this property: + +Response fragment: + +```json + "components": [ + { + "id": "38", + "name": { + "translation": "Meetings" + }, + "__typename": "Meetings" + } +``` + +So, if we want to access the rest of the properties in a polymorphic object, we should do it through the `... on` operator as follows: + +```graphql +{ + assembly(id: 2) { + components { + id + ... on Proposals { + + } + } + } +} +``` + +Consider this query: + +```graphql +{ + assembly(id: 3) { + components(filter: {type: "Proposals"}) { + id + name { + translation(locale: "en") + } + ... on Proposals { + proposals(order: {likeCount: "desc"}, first: 2) { + edges { + node { + id + likes { + name + } + } + } + } + } + } + } +} +``` + +The response: + +```json +{ + "data": { + "assembly": { + "components": [ + { + "id": "39", + "name": { + "translation": "Proposals" + }, + "proposals": { + "edges": [ + { + "node": { + "id": "35", + "likes": [ + { + "name": "Ms. Johnathon Schaefer" + }, + { + "name": "Linwood Lakin PhD 3 4 endr1" + }, + { + "name": "Gracie Emmerich" + }, + { + "name": "Randall Rath 3 4 endr3" + }, + { + "name": "Jolene Schmitt MD" + }, + { + "name": "Clarence Hammes IV 3 4 endr5" + }, + { + "name": "Omar Mayer" + }, + { + "name": "Raymundo Jaskolski 3 4 endr7" + } + ] + } + }, + { + "node": { + "id": "33", + "likes": [ + { + "name": "Spring Brakus" + }, + { + "name": "Reiko Simonis IV 3 2 endr1" + }, + { + "name": "Dr. Jim Denesik" + }, + { + "name": "Dr. Mack Schoen 3 2 endr3" + } + ] + } + } + ] + } + } + ] + } + } +} +``` + +== What is going on? + +Until the `... on Proposals` line, there is nothing new. We are requesting the *Assembly* participatory space identified by the `id=3`, then listing all its components with the type "Proposals". All the components share the *id* and *name* properties, so we can just add them at the query. + +After that, we want content specific from the *Proposals* type. In order to do that we must tell the server that the content we will request shall only be executed if the types matches *Proposals*. We do that by wrapping the rest of the query in the `... on Proposals` clause. + +The next line is just a property of the type *Proposals* which is a type of collection called a "connection". A connection works similar as normal collection (such as *components*) but it can handle more complex cases. + +Typically, a connection is used to paginate long results, for this purpose the results are not directly available but encapsulated inside the list *edges* in several *node* results. Also there are more arguments available in order to navigate between pages. This are the arguments: + +* `first`: Returns the first *n* elements from the list +* `after`: Returns the elements in the list that come after the specified *cursor* +* `last`: Returns the last *n* elements from the list +* `before`: Returns the elements in the list that come before the specified *cursor* + +Example: + +```graphql +{ + assembly(id: 3) { + components(filter: {type: "Proposals"}) { + id + name { + translation(locale: "en") + } + ... on Proposals { + proposals(first:2,after:"Mg") { + pageInfo { + endCursor + startCursor + hasPreviousPage + hasNextPage + } + edges { + node { + id + likes { + name + } + } + } + } + } + } + } +} +``` + +Being the response: + +```json +{ + "data": { + "assembly": { + "components": [ + { + "id": "39", + "name": { + "translation": "Proposals" + }, + "proposals": { + "pageInfo": { + "endCursor": "NA", + "startCursor": "Mw", + "hasPreviousPage": false, + "hasNextPage": true + }, + "edges": [ + { + "node": { + "id": "32", + "likes": [] + } + }, + { + "node": { + "id": "31", + "likes": [ + { + "name": "Mr. Nicolas Raynor" + }, + { + "name": "Gerry Fritsch PhD 3 1 endr1" + } + ] + } + } + ] + } + } + ] + } + } +} +``` + +As you can see, a part from the *edges* list, you can access to the object *pageInfo* which gives you the information needed to navigate through the different pages. + +For more info on how connections work, you can check the official guide: + +https://graphql.org/learn/pagination/ + diff --git a/docs/modules/develop/pages/api/index.adoc b/docs/modules/develop/pages/api/index.adoc index e485acde910f0..be86a699988d0 100644 --- a/docs/modules/develop/pages/api/index.adoc +++ b/docs/modules/develop/pages/api/index.adoc @@ -8,14 +8,76 @@ Documentation for this API is auto-generated in each instance of Decidim, usuall Accessing that URL will give a full details of all the objects that can be requested and how. It includes the full field types reference. Note that the API can be slightly different for different Decidim instances and versions. -For example, you can see the Decidim demo application API documentation under the following URL: +== Using the GraphQL API -`https://try.decidim.org/api/docs` +The GraphQL format is a JSON formatted text that is specified in a query. Response is a JSON object as well. For details about specification check the official https://graphql.org/learn/[GraphQL site]. -As an alternative, if you cannot access that URL on your instance you can read about how this work https://github.com/decidim/decidim/blob/develop/decidim-api/docs/usage.md[Using the Decidim GraphQL API]. +Exercise caution when utilizing the output of this API, as it may include HTML that has not been escaped. Take particular care in handling this data, specially if you intend to render it on a webpage. -== Integrating external applications with the API +For instance, you can check the version of a Decidim installation by using different technologies and languages: -By default, the GraphQL API in Decidim is publically available for read-only operations and it can also be used by external applications to read data from Decidim. If you want to write data over the API, i.e. perform GraphQL mutations, you need first need to authenticate the API user to perform these operations. Otherwise, it is not possible to perform such operations as most such operations are performed as an actual user in Decidim. +include::develop:partial$api/decidim_version.adoc[] -More information regarding implementing the API authentication is available in the API documentation of your Decidim instance. +Note that `Content-Type` needs to be specified. + +The query can also be used in GraphiQL, in that case you can skip the `"query"` text: + +```graphql +{ + decidim { + version + } +} +``` + +Response (formatted) should look something like this: + +```json +{ + "data": { + "decidim": { + "version": "0.18.1" + } + } +} +``` + +The most practical way to experiment with GraphQL, however, is just to use the in-browser IDE GraphiQL. It provides access to the documentation and auto-complete (use CTRL-Space) for writing queries. + +From now on, we will skip the "query" keyword for the purpose of readability. You can skip it too if you are using GraphiQL, if you are querying directly (by using CURL for instance) you will need to include it. + +** xref:develop:api/authentication.adoc[Authentication] +** xref:develop:api/core-concepts.adoc[Core concepts] +** xref:develop:api/reference/errors.adoc[Error Handling] +*** xref:develop:api/reference/errors/permission_not_set.adoc[Permission Not Set Error - PERMISSION_NOT_SET_ERROR] +*** xref:develop:api/reference/errors/unauthorized_field.adoc[Unauthorized Field Error - UNAUTHORIZED_FIELD_ERROR] +*** xref:develop:api/reference/errors/unauthorized_mutation.adoc[Unauthorized Mutation Error - MUTATION_NOT_AUTHORIZED_ERROR] +*** xref:develop:api/reference/errors/not_found.adoc[Not Found Error - NOT_FOUND_ERROR] +*** xref:develop:api/reference/errors/validation_error.adoc[Validation Error - VALIDATION_ERROR] +*** xref:develop:api/reference/errors/attribute_validation_error.adoc[Attribute Validation Error - ATTRIBUTE_VALIDATION_ERROR] +*** xref:develop:api/reference/errors/locale_error.adoc[Locale Error - LOCALE_ERROR] +*** xref:develop:api/reference/errors/invalid_locale_error.adoc[Invalid Locale Error - INVALID_LOCALE_ERROR] +** API reference by module +*** Spaces +*** Components +**** xref:develop:api/reference/components/debates.adoc[Debates] +***** xref:develop:api/reference/components/debates/create.adoc[Create Debate] +***** xref:develop:api/reference/components/debates/update.adoc[Update Debate] +***** xref:develop:api/reference/components/debates/close.adoc[Close Debate] +**** xref:develop:api/reference/components/meetings.adoc[Meetings] +***** xref:develop:api/reference/components/meetings/create.adoc[Create Meeting] +***** xref:develop:api/reference/components/meetings/update.adoc[Update Meeting] +***** xref:develop:api/reference/components/meetings/withdraw.adoc[Withdraw Meeting] +***** xref:develop:api/reference/components/meetings/close.adoc[Close Meeting] +**** xref:develop:api/reference/components/proposals.adoc[Proposals] +***** xref:develop:api/reference/components/proposals/create.adoc[Create Proposal] +***** xref:develop:api/reference/components/proposals/update.adoc[Update Proposal] +***** xref:develop:api/reference/components/proposals/withdraw.adoc[Withdraw Proposal] +***** xref:develop:api/reference/components/proposals/vote.adoc[Vote Proposal] +***** xref:develop:api/reference/components/proposals/answer.adoc[Answer Proposal] + +== Usage limits + +Decidim is just a Rails application, meaning that any particular installation may implement custom limits in order to access the API (and the application in general). + +By default (particular installations may change that), API uses the same limitations as the whole Decidim website, provided by the Gem https://github.com/kickstarter/rack-attack[Rack::Attack]. These are 100 maximum requests per minute per IP to prevent DoS attacks diff --git a/docs/modules/develop/pages/api/reference/components/debates.adoc b/docs/modules/develop/pages/api/reference/components/debates.adoc new file mode 100644 index 0000000000000..5bfb0957be4fb --- /dev/null +++ b/docs/modules/develop/pages/api/reference/components/debates.adoc @@ -0,0 +1,9 @@ += Debates + +In the debates module we are providing a few mutations that we find useful for day-to-day management of Decidim instance outside the admin. + +== For user/participants roles + +* xref:develop:api/reference/components/debates/create.adoc[Create Debate] +* xref:develop:api/reference/components/debates/update.adoc[Update Debate] +* xref:develop:api/reference/components/debates/close.adoc[Close Debate] diff --git a/docs/modules/develop/pages/api/reference/components/debates/close.adoc b/docs/modules/develop/pages/api/reference/components/debates/close.adoc new file mode 100644 index 0000000000000..4077bf21e1ef1 --- /dev/null +++ b/docs/modules/develop/pages/api/reference/components/debates/close.adoc @@ -0,0 +1,3 @@ += Close Debate + +include::admin:partial$under-construction.adoc[] diff --git a/docs/modules/develop/pages/api/reference/components/debates/create.adoc b/docs/modules/develop/pages/api/reference/components/debates/create.adoc new file mode 100644 index 0000000000000..765d6f92e8817 --- /dev/null +++ b/docs/modules/develop/pages/api/reference/components/debates/create.adoc @@ -0,0 +1,3 @@ += Create Debate + +include::admin:partial$under-construction.adoc[] diff --git a/docs/modules/develop/pages/api/reference/components/debates/update.adoc b/docs/modules/develop/pages/api/reference/components/debates/update.adoc new file mode 100644 index 0000000000000..5e03f3a2c52ac --- /dev/null +++ b/docs/modules/develop/pages/api/reference/components/debates/update.adoc @@ -0,0 +1,3 @@ += Update Debate + +include::admin:partial$under-construction.adoc[] diff --git a/docs/modules/develop/pages/api/reference/components/meetings.adoc b/docs/modules/develop/pages/api/reference/components/meetings.adoc new file mode 100644 index 0000000000000..eb9f1079bfd4f --- /dev/null +++ b/docs/modules/develop/pages/api/reference/components/meetings.adoc @@ -0,0 +1,10 @@ += Meetings + +In the meetings module we are providing a few mutations that we find useful for day-to-day management of Decidim instance outside the admin. + +== For user/participants roles + +* xref:develop:api/reference/components/meetings/create.adoc[Create Meeting] +* xref:develop:api/reference/components/meetings/update.adoc[Update Meeting] +* xref:develop:api/reference/components/meetings/withdraw.adoc[Withdraw Meeting] +* xref:develop:api/reference/components/meetings/close.adoc[Close Meeting] diff --git a/docs/modules/develop/pages/api/reference/components/meetings/close.adoc b/docs/modules/develop/pages/api/reference/components/meetings/close.adoc new file mode 100644 index 0000000000000..aa9e1e9a12452 --- /dev/null +++ b/docs/modules/develop/pages/api/reference/components/meetings/close.adoc @@ -0,0 +1,3 @@ += Close Meeting + +include::admin:partial$under-construction.adoc[] diff --git a/docs/modules/develop/pages/api/reference/components/meetings/create.adoc b/docs/modules/develop/pages/api/reference/components/meetings/create.adoc new file mode 100644 index 0000000000000..da498b07c613a --- /dev/null +++ b/docs/modules/develop/pages/api/reference/components/meetings/create.adoc @@ -0,0 +1,3 @@ += Create Meeting + +include::admin:partial$under-construction.adoc[] diff --git a/docs/modules/develop/pages/api/reference/components/meetings/update.adoc b/docs/modules/develop/pages/api/reference/components/meetings/update.adoc new file mode 100644 index 0000000000000..ccad2ce3cd8d1 --- /dev/null +++ b/docs/modules/develop/pages/api/reference/components/meetings/update.adoc @@ -0,0 +1,3 @@ += Update Meeting + +include::admin:partial$under-construction.adoc[] diff --git a/docs/modules/develop/pages/api/reference/components/meetings/withdraw.adoc b/docs/modules/develop/pages/api/reference/components/meetings/withdraw.adoc new file mode 100644 index 0000000000000..492704d61d0e5 --- /dev/null +++ b/docs/modules/develop/pages/api/reference/components/meetings/withdraw.adoc @@ -0,0 +1,3 @@ += Withdraw Meeting + +include::admin:partial$under-construction.adoc[] diff --git a/docs/modules/develop/pages/api/reference/components/proposals.adoc b/docs/modules/develop/pages/api/reference/components/proposals.adoc new file mode 100644 index 0000000000000..4435663dd0a9d --- /dev/null +++ b/docs/modules/develop/pages/api/reference/components/proposals.adoc @@ -0,0 +1,15 @@ += Proposals + +In the proposals module we are providing a few mutations that we find useful for day-to-day management of Decidim instance outside the admin. + +== For user/participants roles + +* xref:develop:api/reference/components/proposals/create.adoc[Create Proposal] +* xref:develop:api/reference/components/proposals/update.adoc[Update Proposal] +* xref:develop:api/reference/components/proposals/withdraw.adoc[Withdraw Proposal] +* xref:develop:api/reference/components/proposals/vote.adoc[Vote Proposal] + +== For admin roles + +* xref:develop:api/reference/components/proposals/answer.adoc[Answer Proposal] + diff --git a/docs/modules/develop/pages/api/proposals/mutations.adoc b/docs/modules/develop/pages/api/reference/components/proposals/answer.adoc similarity index 70% rename from docs/modules/develop/pages/api/proposals/mutations.adoc rename to docs/modules/develop/pages/api/reference/components/proposals/answer.adoc index 60d14806c4c0c..0ef6d67174f8f 100644 --- a/docs/modules/develop/pages/api/proposals/mutations.adoc +++ b/docs/modules/develop/pages/api/reference/components/proposals/answer.adoc @@ -1,10 +1,8 @@ -= Proposals - -== Answering Proposals += Answer Proposal Allows administrators to answer proposals with a status (accepted, rejected, or evaluating). -This is an example on how to use the Answer Proposal mutation. Please refer to the GraphQL documentation for the available fields: +This is an example on how to use the `addProposalAnswer` mutation. Please refer to the GraphQL documentation for the available fields: - https://nightly.decidim.org/api/docs/input_object/answerinput/[Answer Input] @@ -36,8 +34,8 @@ Example of submitted variables "state": "accepted", "cost": 10.0, "answerContent": { - "en" => "Some answer in english", - "ca" => "Some answer in Catalan" + "en": "Some answer in English", + "ca": "Some answer in Catalan" } } } @@ -64,8 +62,8 @@ curl -X POST https://your-decidim-instance.org/api \ "state": "accepted", "cost": 10.0, "answerContent": { - "en" => "Some answer in english", - "ca" => "Some answer in Catalan" + "en": "Some answer in English", + "ca": "Some answer in Catalan" } } } @@ -76,10 +74,9 @@ curl -X POST https://your-decidim-instance.org/api \ === Error Handling -When you are not authorized, or when you are not authenticated, the server will respond with a simple: -```graphql -{ - "data": null -} -``` +The most frequent errors that can be generated by this mutation are as follows: + +* xref:develop:api/reference/errors/unauthorized_object.adoc[Unauthorized Object Error - UNAUTHORIZED_OBJECT_ERROR] +* xref:develop:api/reference/errors/not_found.adoc[Not Found Error - NOT_FOUND_ERROR] +* xref:develop:api/reference/errors/attribute_validation_error.adoc[Attribute Validation Error - ATTRIBUTE_VALIDATION_ERROR] diff --git a/docs/modules/develop/pages/api/reference/components/proposals/create.adoc b/docs/modules/develop/pages/api/reference/components/proposals/create.adoc new file mode 100644 index 0000000000000..d2998e60afc87 --- /dev/null +++ b/docs/modules/develop/pages/api/reference/components/proposals/create.adoc @@ -0,0 +1,3 @@ += Create Proposal + +include::admin:partial$under-construction.adoc[] diff --git a/docs/modules/develop/pages/api/reference/components/proposals/update.adoc b/docs/modules/develop/pages/api/reference/components/proposals/update.adoc new file mode 100644 index 0000000000000..733e1987aa225 --- /dev/null +++ b/docs/modules/develop/pages/api/reference/components/proposals/update.adoc @@ -0,0 +1,3 @@ += Update Proposal + +include::admin:partial$under-construction.adoc[] diff --git a/docs/modules/develop/pages/api/reference/components/proposals/vote.adoc b/docs/modules/develop/pages/api/reference/components/proposals/vote.adoc new file mode 100644 index 0000000000000..a0c1c6c834c90 --- /dev/null +++ b/docs/modules/develop/pages/api/reference/components/proposals/vote.adoc @@ -0,0 +1,73 @@ += Vote / Unvote Proposal + +== Vote Proposal + +To use the API to vote any proposal that is configured to receive votes, you will need to use the following API request. + +[source,graphql] +---- +mutation voteProposal($componentId: ID!, $proposalId: ID!){ + component(id: $componentId) { + ...on ProposalsMutation{ + proposal(id: $proposalId) { + vote(input: {}) { + } + } + } + } +} +---- + +Example of submitted variables + +[source,json] +---- +{ + "componentId": "9", + "proposalId": "2" +} +---- + +=== Error Handling + +The most frequent errors that can be generated by this mutation are as follows: + +* xref:develop:api/reference/errors/unauthorized_mutation.adoc[Unauthorized Mutation Error - MUTATION_NOT_AUTHORIZED_ERROR] +* xref:develop:api/reference/errors/validation_error.adoc[Validation Error - VALIDATION_ERROR] +* xref:develop:api/reference/errors/unauthorized_object.adoc[Unauthorized Object Error - UNAUTHORIZED_OBJECT_ERROR] + +== Unvote Proposal + +To use the API to remove a vote on any proposal that you have already voted, you will need to use the following API request. + +[source,graphql] +---- +mutation unvoteProposal($componentId: ID!, $proposalId: ID!){ + component(id: $componentId) { + ...on ProposalsMutation{ + proposal(id: $proposalId) { + unvote(input: {}) { + } + } + } + } +} +---- + +Example of submitted variables + +[source,json] +---- +{ + "componentId": "9", + "proposalId": "2" +} +---- + +=== Error Handling + +The most frequent errors that can be generated by this mutation are as follows: + +* xref:develop:api/reference/errors/unauthorized_mutation.adoc[Unauthorized Mutation Error - MUTATION_NOT_AUTHORIZED_ERROR] +* xref:develop:api/reference/errors/validation_error.adoc[Validation Error - VALIDATION_ERROR] +* xref:develop:api/reference/errors/unauthorized_object.adoc[Unauthorized Object Error - UNAUTHORIZED_OBJECT_ERROR] diff --git a/docs/modules/develop/pages/api/reference/components/proposals/withdraw.adoc b/docs/modules/develop/pages/api/reference/components/proposals/withdraw.adoc new file mode 100644 index 0000000000000..8b7aab440e85b --- /dev/null +++ b/docs/modules/develop/pages/api/reference/components/proposals/withdraw.adoc @@ -0,0 +1,3 @@ += Withdraw Proposal + +include::admin:partial$under-construction.adoc[] diff --git a/docs/modules/develop/pages/api/reference/errors.adoc b/docs/modules/develop/pages/api/reference/errors.adoc new file mode 100644 index 0000000000000..0acfd284f582f --- /dev/null +++ b/docs/modules/develop/pages/api/reference/errors.adoc @@ -0,0 +1,22 @@ += Error Handling for the API + +Currently, in Decidim we are using some exceptions to handle API responses, aiming to serve both input (receiving data through API) and output (sending data through API). + +At the moment there are 3 categories of exceptions that are being handled: + +== Authorization errors + +* xref:develop:api/reference/errors/permission_not_set.adoc[Permission Not Set Error - PERMISSION_NOT_SET_ERROR], is triggered when there is a problem with the authorization layer. +* xref:develop:api/reference/errors/unauthorized_object.adoc[Unauthorized Object Error - UNAUTHORIZED_OBJECT_ERROR], is triggered when the requesting user does not have access to a certain resource. +* xref:develop:api/reference/errors/unauthorized_field.adoc[Unauthorized Field Error - UNAUTHORIZED_FIELD_ERROR], is triggered when the requesting user does not have access to a certain field or resource. +* xref:develop:api/reference/errors/unauthorized_mutation.adoc[Unauthorized Mutation Error - MUTATION_NOT_AUTHORIZED_ERROR], is triggered when the requesting user tries to access a mutation that cannot be accessed. + +== Validation errors +* xref:develop:api/reference/errors/not_found.adoc[Not Found Error - NOT_FOUND_ERROR], is triggered when the requested record does not exist in database, or is in a state where you should not have access to it (moderated, belongs to an unpublished component, belongs to a private process where the requesting user does not have access) +* xref:develop:api/reference/errors/validation_error.adoc[Validation Error - VALIDATION_ERROR], is triggered when the requesting user performs an action that contains an error. This is equivalent to the flash messages displayed on the Website. +* xref:develop:api/reference/errors/attribute_validation_error.adoc[Attribute Validation Error - ATTRIBUTE_VALIDATION_ERROR], is triggered when the requesting user submits a request to alter a resource that has an error. This is equivalent to the validation errors in the HTTP forms. + +== Internationalization errors + +* xref:develop:api/reference/errors/locale_error.adoc[Locale Error - LOCALE_ERROR], is triggered when there is a problem with the internationalization, for example, a missing translation, a missing interpolation to the output string +* xref:develop:api/reference/errors/invalid_locale_error.adoc[Invalid Locale Error - INVALID_LOCALE_ERROR], is triggered when the provided locale is invalid or not supported by the system, for example when an unknown locale code is used or a locale that is not enabled for the current organization diff --git a/docs/modules/develop/pages/api/reference/errors/attribute_validation_error.adoc b/docs/modules/develop/pages/api/reference/errors/attribute_validation_error.adoc new file mode 100644 index 0000000000000..01d2f5121e41e --- /dev/null +++ b/docs/modules/develop/pages/api/reference/errors/attribute_validation_error.adoc @@ -0,0 +1,38 @@ += Attribute Validation Error - ATTRIBUTE_VALIDATION_ERROR + +This error is triggered when a user submits invalid data into a mutation form. This error is the equivalent of the form validation errors seen in the HTTP version + +The JSON response structure is: +[source, json] +---- +{ + "errors": [ + { + "message": [ + { + "path": ["attributes", "title"], + "message": "cannot be blank" + }, + { + "path": ["attributes", "title"], + "message": "is too short (under 15 characters)"} + ], + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "createProposal" + ], + "extensions": { + "code": "ATTRIBUTE_VALIDATION_ERROR" + } + } + ], + "data": { + "createProposal": null + } +} +---- diff --git a/docs/modules/develop/pages/api/reference/errors/invalid_locale_error.adoc b/docs/modules/develop/pages/api/reference/errors/invalid_locale_error.adoc new file mode 100644 index 0000000000000..bba907120f4ec --- /dev/null +++ b/docs/modules/develop/pages/api/reference/errors/invalid_locale_error.adoc @@ -0,0 +1,30 @@ += Invalid Locale Error - INVALID_LOCALE_ERROR + +This error is issued when the user submits or requests content in a language that is not supported by the platform. + +The JSON response structure is: +[source, json] +---- +{ + "errors": [ + { + "message": "Invalid locale provided", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "createProposal" + ], + "extensions": { + "code": "INVALID_LOCALE_ERROR" + } + } + ], + "data": { + "createProposal": null + } +} +---- diff --git a/docs/modules/develop/pages/api/reference/errors/locale_error.adoc b/docs/modules/develop/pages/api/reference/errors/locale_error.adoc new file mode 100644 index 0000000000000..3ce14da998c19 --- /dev/null +++ b/docs/modules/develop/pages/api/reference/errors/locale_error.adoc @@ -0,0 +1,30 @@ += Locale Error - LOCALE_ERROR + +This error is triggered when the requested language has any internationalization errors, like missing translation string, bad variable replacement (interpolation), or any other I18n errors. + +The JSON response structure is: +[source, json] +---- +{ + "errors": [ + { + "message": "There was an error while internally handling i18n data", + "locations": [ + { + "line": 2, + "column": 39 + } + ], + "path": [ + "createProposal" + ], + "extensions": { + "code": "LOCALE_ERROR" + } + } + ], + "data": { + "createProposal": null + } +} +---- diff --git a/docs/modules/develop/pages/api/reference/errors/not_found.adoc b/docs/modules/develop/pages/api/reference/errors/not_found.adoc new file mode 100644 index 0000000000000..84f64a4017fc0 --- /dev/null +++ b/docs/modules/develop/pages/api/reference/errors/not_found.adoc @@ -0,0 +1,30 @@ += Not Found Error - NOT_FOUND_ERROR + +This error is triggered when the requested record does not exist in the database, or is in a state where you should not have access to it (moderated, belongs to an unpublished component, belongs to a private process where the requesting user does not have access). This is equivalent to 404 HTTP Status code. + +The JSON response structure is: +[source, json] +---- +{ + "errors": [ + { + "message": "ParticipatoryProcess not found", + "extensions": { + "code": "NOT_FOUND_ERROR" + }, + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "participatoryProcess" + ] + } + ], + "data": { + "participatoryProcess": null + } +} +---- diff --git a/docs/modules/develop/pages/api/reference/errors/permission_not_set.adoc b/docs/modules/develop/pages/api/reference/errors/permission_not_set.adoc new file mode 100644 index 0000000000000..aa1845178b38e --- /dev/null +++ b/docs/modules/develop/pages/api/reference/errors/permission_not_set.adoc @@ -0,0 +1,46 @@ += Permission Not Set Error - PERMISSION_NOT_SET_ERROR + +This is an error that should not be seen by end users, as this error is being fired when a request passes through a managed permission handler. + +The JSON response structure is: +[source, json] +---- +{ + "errors": [ + { + "message": "Permission has not been set for this Project", + "locations": [ + { + "line": 3, + "column": 5 + } + ], + "path": [ + "participatoryProcess", + "components", + 0, + "budget", + "projects", + 0 + ], + "extensions": { + "code": "PERMISSION_NOT_SET_ERROR" + } + } + ], + "data": { + "participatoryProcess": { + "components": [ + { + "id": "3", + "budget": { + "projects": [ + null + ] + } + } + ] + } + } +} +---- diff --git a/docs/modules/develop/pages/api/reference/errors/unauthorized_field.adoc b/docs/modules/develop/pages/api/reference/errors/unauthorized_field.adoc new file mode 100644 index 0000000000000..d159a227e94d2 --- /dev/null +++ b/docs/modules/develop/pages/api/reference/errors/unauthorized_field.adoc @@ -0,0 +1,30 @@ += Unauthorized Field Error - UNAUTHORIZED_FIELD_ERROR + +This error is triggered when the requesting user does not have access to a certain field, or a certain resource. + +The JSON response structure is: +[source, json] +---- +{ + "errors": [ + { + "message": "You cannot view or edit answer field on Answer because you do not have permission", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "answer" + ], + "extensions": { + "code": "UNAUTHORIZED_FIELD_ERROR" + } + } + ], + "data": { + "answer": null + } +} +---- diff --git a/docs/modules/develop/pages/api/reference/errors/unauthorized_mutation.adoc b/docs/modules/develop/pages/api/reference/errors/unauthorized_mutation.adoc new file mode 100644 index 0000000000000..26ab12e6cda9b --- /dev/null +++ b/docs/modules/develop/pages/api/reference/errors/unauthorized_mutation.adoc @@ -0,0 +1,30 @@ += Unauthorized Mutation Error - MUTATION_NOT_AUTHORIZED_ERROR + +This error is triggered when a user submits a mutation request to a resource they do not have access to. + +The JSON response structure is: +[source, json] +---- +{ + "errors": [ + { + "message": "You do not have permission to perform this mutation", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "createProposal" + ], + "extensions": { + "code": "MUTATION_NOT_AUTHORIZED_ERROR" + } + } + ], + "data": { + "createProposal": null + } +} +---- diff --git a/docs/modules/develop/pages/api/reference/errors/unauthorized_object.adoc b/docs/modules/develop/pages/api/reference/errors/unauthorized_object.adoc new file mode 100644 index 0000000000000..c63be015ad51e --- /dev/null +++ b/docs/modules/develop/pages/api/reference/errors/unauthorized_object.adoc @@ -0,0 +1,30 @@ += Unauthorized Object Error - UNAUTHORIZED_OBJECT_ERROR + +This error is triggered when the requesting user does not have access to a certain resource. + +The JSON response structure is: +[source, json] +---- +{ + "errors": [ + { + "message": "You cannot view or edit this Result because you do not have permissions", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "vote" + ], + "extensions": { + "code": "UNAUTHORIZED_OBJECT_ERROR" + } + } + ], + "data": { + "result": null + } +} +---- diff --git a/docs/modules/develop/pages/api/reference/errors/validation_error.adoc b/docs/modules/develop/pages/api/reference/errors/validation_error.adoc new file mode 100644 index 0000000000000..d93511c4c60b1 --- /dev/null +++ b/docs/modules/develop/pages/api/reference/errors/validation_error.adoc @@ -0,0 +1,30 @@ += Validation Error - VALIDATION_ERROR + +This error is triggered when the requesting user performs an action that contains an error, for example, when the user tries to publish a resource that is already published. This is equivalent to the flash messages displayed on the Website. + +The JSON response structure is: +[source, json] +---- +{ + "errors": [ + { + "message": "There was a problem voting the proposal.", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "vote" + ], + "extensions": { + "code": "VALIDATION_ERROR" + } + } + ], + "data": { + "vote": null + } +} +---- diff --git a/docs/modules/develop/partials/api/decidim_version.adoc b/docs/modules/develop/partials/api/decidim_version.adoc new file mode 100644 index 0000000000000..7c3286b3570a5 --- /dev/null +++ b/docs/modules/develop/partials/api/decidim_version.adoc @@ -0,0 +1,102 @@ + +++++ +
+
+ + + + +
+
+
+++++ + +[source,bash] +---- +curl -sSH "Content-Type: application/json" \ + -d '{"query": "{ decidim { version } }"}' \ + https://www.decidim.barcelona/api/ +---- + +++++ +
+
+++++ + +[source,javascript] +---- +const url = "https://www.decidim.barcelona/api/"; +const query = "{ decidim { version } }"; + +fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ query }), +}) + .then((response) => response.json()) + .then((data) => { + console.log(data); + }) + .catch((error) => { + console.error(error); + }); +---- + +++++ +
+
+++++ + +[source,python] +---- +import requests + +url = "https://www.decidim.barcelona/api/" +query = "{ decidim { version } }" + +headers = { + "Content-Type": "application/json" +} + +response = requests.post( + url, + json={"query": query}, + headers=headers +) + +print(response.json()) +---- + +++++ +
+
+++++ + +[source,ruby] +---- +require "net/http" +require "uri" +require "json" + +uri = URI.parse("https://www.decidim.barcelona/api/") + +http = Net::HTTP.new(uri.host, uri.port) +http.use_ssl = true + +request = Net::HTTP::Post.new(uri.request_uri) +request["Content-Type"] = "application/json" +request.body = { + query: "{ decidim { version } }" +}.to_json + +response = http.request(request) +puts JSON.parse(response.body) +---- + +++++ +
+
+
+++++ diff --git a/docs/modules/services/pages/activestorage.adoc b/docs/modules/services/pages/activestorage.adoc index 6b75c6099d3c8..abc4f36388742 100644 --- a/docs/modules/services/pages/activestorage.adoc +++ b/docs/modules/services/pages/activestorage.adoc @@ -47,6 +47,46 @@ Locate the bucket, go into the "Permissions" tab and find the section titled "CO Read more at https://docs.aws.amazon.com/AmazonS3/latest/userguide/cors.html[Amazon S3 CORS documentation]. +==== Public assets + +To have public assets in your application, so that you do not rely on the ActiveStorage redirect system, you need to configure your bucket as follows: + +1. Go to your AWS S3 console +2. Select the bucket you are using for uploads +3. Open the *Permissions* tab +4. In the *Block public access* section, click *Edit* +5. Disable all blocking options by *unchecking* every box: + - "Block all public access" + - "Block public access to buckets and objects granted through new access control lists (ACLs)" + - "Block public access to buckets and objects granted through any access control lists (ACLs)" + - "Block public access to buckets and objects granted through new public bucket or access point policies" + - "Block public and cross-account access to buckets and objects through any public bucket or access point policies" +6. Click *Save changes* +7. Still in the *Permissions tab*, locate the *Bucket policy section* and click *Edit*. +8. Add a bucket policy similar to the example below. + - If you are unsure of your bucket’s ARN, you can find it in the Properties tab. For this example, we use `arn:aws:s3:::your-bucket-name` +9. Click *Save changes* +[source,json] +---- +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "Statement1", + "Effect": "Allow", + "Principal": "*", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::your-bucket-name/*" + } + ] +} +---- + +[NOTE] +==== +If you use any other provider than the default (`local`) you will need to also configure the xref:customize:content_security_policy.adoc[Content security policy]. For the directives "img-src", "media-src", and "connect-src" adding some additional content like https://$YOUR-BUCKET-NAME.s3.$YOUR-AWS-REGION.amazonaws.com/* (should look like: https://your-bucket-name.s3.eu-west-1.amazonaws.com) +==== + === Google Cloud Storage Google Cloud Storage requires you to use the `gsutil` command line tool to set the CORS policy on your bucket. First you need to know the name of your bucket and then use the following command (replace `your-bucket-name` with the actual name of the bucket):