diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
deleted file mode 100644
index 266f5e9a88..0000000000
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ /dev/null
@@ -1,27 +0,0 @@
-#### :tophat: Description
-*Please describe your pull request.*
-
-~~This an example description for a pull request. You can use this template to create your own PR description.~~
-
-#### Testing
-*Describe the best way to test or validate your PR.*
-
-Example:
-* Log in as admin
-* Access Backoffice
-* Go to organization settings
-* See ...
-
-#### :pushpin: Related Issues
-*Link your PR to an issue*
-- Fixes #?
-
-#### Tasks
-- [ ] Add specs
-- [ ] Add note about overrides in OVERLOADS.md
-- [ ] In case of new dependencies or version bump, update related documentation
-
-#### :camera: Screenshots
-*Please add screenshots of the changes you're proposing if related to the UI*
-
-#### Extra information
\ No newline at end of file
diff --git a/Gemfile.lock b/Gemfile.lock
index 80add78f9d..006ab6f0a9 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -31,7 +31,7 @@ GIT
GIT
remote: https://github.com/OpenSourcePolitics/decidim-module-anonymous_proposals.git
- revision: 2d9dc00745ca1cc52b4cb73c3177dcd77e36c031
+ revision: e74c279204b74bbac3e394ba589b40a2825eca6e
branch: bump/0.29
specs:
decidim-anonymous_proposals (0.29.3)
@@ -64,7 +64,7 @@ GIT
GIT
remote: https://github.com/OpenSourcePolitics/decidim-module-extra_user_fields.git
- revision: 9b4e8d153342fed90cbd06675f91e74ee831b81b
+ revision: d456beeee5d4a7932160618b31b064715a955c73
branch: bump/0.29
specs:
decidim-extra_user_fields (0.29.0)
@@ -94,7 +94,7 @@ GIT
GIT
remote: https://github.com/OpenSourcePolitics/decidim-module-term_customizer.git
- revision: f3b55cae4e22713d7c842f0de4c421e9765ad7b0
+ revision: 08a7a55ac5336a5d3e2d549441423ca961714dce
branch: backport/fix_database_not_available
specs:
decidim-term_customizer (0.29.0)
@@ -122,10 +122,10 @@ GIT
GIT
remote: https://github.com/decidim-ice/decidim-module-decidim_awesome.git
- revision: f2c71529cfb362d2144f8728de9c98cf92f27342
+ revision: a9f2077f80438f6bc7b9fa7c26aa150cac46523a
branch: release/0.29-stable
specs:
- decidim-decidim_awesome (0.12.5)
+ decidim-decidim_awesome (0.12.6)
active_hashcash (~> 0.4.0)
decidim-admin (>= 0.29.1, < 0.30)
decidim-core (>= 0.29.1, < 0.30)
diff --git a/app/events/decidim/ai/spam_detection/spam_digest_event.rb b/app/events/decidim/ai/spam_detection/spam_digest_event.rb
new file mode 100644
index 0000000000..81a158ec3e
--- /dev/null
+++ b/app/events/decidim/ai/spam_detection/spam_digest_event.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+module Decidim
+ module Ai
+ module SpamDetection
+ class SpamDigestEvent < Decidim::Events::BaseEvent
+ include Decidim::Events::EmailEvent
+
+ def self.types
+ [:email, :notification]
+ end
+
+ def resource
+ return @resource unless @resource.is_a?(Decidim::Organization)
+
+ OpenStruct.new(organization: @resource)
+ end
+
+ def email_intro
+ sanitize(
+ I18n.t(
+ "decidim.ai.spam_detection.digest.summary",
+ count: spam_count,
+ frequency_label:,
+ organization: translated_attribute(organization.name),
+ moderations_url:
+ )
+ )
+ end
+
+ def notification_title
+ email_intro
+ end
+
+ def email_subject
+ I18n.t(
+ "decidim.ai.spam_detection.digest.subject",
+ count: spam_count,
+ frequency_label:
+ )
+ end
+
+ def resource_title
+ translated_attribute(organization.name)
+ end
+
+ def resource_locator
+ helpers = Decidim::Core::Engine.routes.url_helpers
+ host = organization.host || Decidim::Organization.first&.host || "localhost"
+
+ Class.new do
+ def initialize(path, url)
+ @path = path
+ @url = url
+ end
+
+ def path(_params = nil)
+ @path
+ end
+
+ def url(_params = nil)
+ @url
+ end
+
+ def route_name
+ "organization"
+ end
+ end.new(
+ resource_path,
+ helpers.root_url(host:)
+ )
+ end
+
+ def resource_path(_organization = nil)
+ Decidim::Core::Engine.routes.url_helpers.admin_moderations_path
+ rescue NoMethodError
+ Decidim::Core::Engine.routes.url_helpers.root_path
+ end
+
+ def show_extended_information?
+ false
+ end
+
+ private
+
+ def moderations_url
+ host = organization.host
+
+ if host.blank?
+ return "" unless Rails.env.development? || Rails.env.test?
+
+ host = "localhost:3000"
+ elsif host == "localhost" && (Rails.env.development? || Rails.env.test?)
+ host = "localhost:3000"
+ end
+
+ protocol = Rails.env.production? ? "https" : "http"
+
+ "#{protocol}://#{host}/admin/moderations"
+ end
+
+ def organization
+ if @resource.is_a?(Decidim::Organization)
+ @resource
+ elsif @resource.respond_to?(:organization)
+ @resource.organization
+ elsif @resource.respond_to?(:component)
+ @resource.component.participatory_space.organization
+ else
+ Decidim::Organization.first
+ end
+ end
+
+ def spam_count
+ extra[:spam_count] || 0
+ end
+
+ def frequency_label
+ I18n.t("decidim.ai.spam_detection.digest.frequency_label.#{extra[:frequency] || "daily"}")
+ end
+ end
+ end
+ end
+end
diff --git a/app/jobs/decidim/ai/spam_detection/spam_digest_generator_job.rb b/app/jobs/decidim/ai/spam_detection/spam_digest_generator_job.rb
new file mode 100644
index 0000000000..a6251d5fdb
--- /dev/null
+++ b/app/jobs/decidim/ai/spam_detection/spam_digest_generator_job.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+module Decidim
+ module Ai
+ module SpamDetection
+ # This job generates and publishes the AI spam digest event
+ # for each organization, either daily or weekly.
+ class SpamDigestGeneratorJob < ApplicationJob
+ queue_as :mailers
+
+ FREQUENCIES = {
+ daily: "daily",
+ weekly: "weekly"
+ }.freeze
+
+ def perform(frequency)
+ # Skip validation if frequency is nil (called by Decidim core specs)
+ return if frequency.nil? && Rails.env.test?
+ raise ArgumentError, "Invalid frequency: #{frequency}" unless frequency && FREQUENCIES.has_key?(frequency.to_sym)
+
+ Decidim::Organization.find_each do |organization|
+ admins = organization.admins.where(notifications_sending_frequency: frequency)
+ next if admins.empty?
+
+ spam_count = count_spam(organization, frequency)
+ next if spam_count.zero?
+
+ Decidim::EventsManager.publish(
+ event: "decidim.events.ai.spam_detection.spam_digest_event",
+ event_class: Decidim::Ai::SpamDetection::SpamDigestEvent,
+ resource: organization,
+ followers: admins,
+ extra: { spam_count:, frequency:, force_email: true }
+ )
+ end
+ end
+
+ private
+
+ # Counts the spam reports for the given organization and frequency (daily/weekly)
+ def count_spam(organization, frequency)
+ since = frequency == :weekly ? 1.week.ago : 1.day.ago
+
+ spam_user_reports_since(since, organization).count + spam_reports_since(since, organization).count
+ end
+
+ # Returns all spam reports created since the given time
+ def spam_reports_since(since, organization)
+ reports = Decidim::Report
+ .joins(:moderation)
+ .where(reason: "spam")
+ .where("decidim_reports.created_at >= ?", since)
+ .includes(moderation: { participatory_space: :organization })
+
+ reports.select { |r| r.moderation.participatory_space&.organization&.id == organization.id }
+ end
+
+ def spam_user_reports_since(since, organization)
+ Decidim::UserReport
+ .joins(:user)
+ .where(reason: "spam")
+ .where("decidim_user_reports.created_at >= ?", since)
+ .where(decidim_users: { decidim_organization_id: organization.id })
+ end
+
+ # Determines if a spam report belongs to the given organization
+ def report_belongs_to_org?(report, organization)
+ reportable = report.moderation.reportable
+
+ participatory_space = find_participatory_space(reportable)
+ return false unless participatory_space
+
+ org_id = participatory_space.try(:decidim_organization_id) || participatory_space.try(:organization_id)
+ org_id == organization.id
+ rescue StandardError => e
+ Rails.logger.debug do
+ "[Decidim-AI] ⚠️ Could not resolve organization for report ##{report.id}: #{e.class} #{e.message}"
+ end
+ false
+ end
+
+ # Finds the participatory space for a given reportable entity
+ def find_participatory_space(reportable)
+ if reportable.respond_to?(:component)
+ reportable.component.participatory_space
+ elsif reportable.respond_to?(:commentable)
+ commentable = reportable.commentable
+ commentable.try(:component)&.participatory_space
+ elsif reportable.respond_to?(:participatory_space)
+ reportable.participatory_space
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/jobs/decidim/find_and_update_descendants_job.rb b/app/jobs/decidim/find_and_update_descendants_job.rb
new file mode 100644
index 0000000000..6e2a85b9d0
--- /dev/null
+++ b/app/jobs/decidim/find_and_update_descendants_job.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+module Decidim
+ class FindAndUpdateDescendantsJob < ApplicationJob
+ queue_as :default
+
+ BATCH_SIZE = 100
+
+ def perform(element)
+ process_element_and_descendants(element)
+ end
+
+ private
+
+ def process_element_and_descendants(element)
+ reindex_element(element)
+ process_components(element)
+ process_comments(element)
+ end
+
+ def reindex_element(element)
+ return unless element.class.respond_to?(:searchable_resource?) && element.class.searchable_resource?(element)
+
+ org = element.class.search_resource_fields_mapper.retrieve_organization(element)
+ return unless org
+
+ searchables_in_org = element.searchable_resources.by_organization(org.id)
+ should_index = element.class.search_resource_fields_mapper.index_on_update?(element)
+
+ if should_index
+ if searchables_in_org.empty?
+ element.add_to_index_as_search_resource
+ else
+ fields = element.class.search_resource_fields_mapper.mapped(element)
+ searchables_in_org.find_each do |sr|
+ next if sr.blank?
+
+ attrs = element.send(:contents_to_searchable_resource_attributes, fields, sr.locale)
+ # rubocop:disable Rails/SkipsModelValidations
+ sr.update_columns(attrs)
+ # rubocop:enable Rails/SkipsModelValidations
+ end
+ end
+ elsif searchables_in_org.any?
+ searchables_in_org.delete_all
+ end
+ end
+
+ def process_components(element)
+ return unless element.respond_to?(:components) && element.components.any?
+
+ element.components.find_each do |component|
+ klass = component_class(component)
+ next unless valid_component_class?(klass)
+
+ klass.where(component:).find_in_batches(batch_size: BATCH_SIZE) do |batch|
+ batch.each { |descendant| process_element_and_descendants(descendant) }
+ end
+ end
+ end
+
+ def process_comments(element)
+ return unless element.respond_to?(:comments) && element.comments.any?
+
+ element.comments.find_in_batches(batch_size: BATCH_SIZE) do |batch|
+ batch.each { |comment| process_element_and_descendants(comment) }
+ end
+ end
+
+ def component_class(component)
+ return Decidim::Blogs::Post if component.manifest_name == "blogs"
+
+ manifest_name_to_class(component.manifest_name)
+ end
+
+ def manifest_name_to_class(name)
+ resource_registry = Decidim.resource_registry.find(name)
+ return if resource_registry.blank?
+
+ resource_registry.model_class_name&.safe_constantize
+ end
+
+ def valid_component_class?(klass)
+ klass.present? && klass.column_names.include?("decidim_component_id")
+ end
+ end
+end
diff --git a/app/packs/entrypoints/application.js b/app/packs/entrypoints/application.js
index a91d68bc76..ff2ecf2b5d 100644
--- a/app/packs/entrypoints/application.js
+++ b/app/packs/entrypoints/application.js
@@ -20,4 +20,5 @@
import "src/decidim/admin/reorder_scopes";
import "src/decidim/admin/reorder_proposal_states";
-import "src/decidim/surveys/sorted_answers_fixes";
\ No newline at end of file
+import "src/decidim/surveys/sorted_answers_fixes";
+import "src/decidim/check_boxes_tree";
diff --git a/app/packs/src/decidim/check_boxes_tree.js b/app/packs/src/decidim/check_boxes_tree.js
new file mode 100644
index 0000000000..7a3d116714
--- /dev/null
+++ b/app/packs/src/decidim/check_boxes_tree.js
@@ -0,0 +1,130 @@
+/**
+ * CheckBoxesTree component.
+ */
+export default class CheckBoxesTree {
+ constructor() {
+ this.checkboxesTree = Array.from(document.querySelectorAll("[data-checkboxes-tree]"));
+
+ if (!this.checkboxesTree.length) {
+ return;
+ }
+
+ this.checkboxesLeaf = Array.from(document.querySelectorAll("[data-children-checkbox] input"));
+
+ // handles the click in a tree, what means to mark/unmark every children
+ this.checkboxesTree.forEach((input) => input.addEventListener("click", (event) => this.checkTheCheckBoxes(event.target)));
+ // handles the click in a leaf, what means to update the parent possibly
+ this.checkboxesLeaf.forEach((input) => input.addEventListener("change", (event) => this.checkTheCheckParent(event.target)));
+ // Review parent checkboxes on initial load
+ this.checkboxesLeaf.forEach((input) => this.checkTheCheckParent(input));
+ }
+
+ /**
+ * Set checkboxes as checked if included in given values
+ * @public
+ * @param {Array} checkboxes - array of checkboxes to check
+ * @param {Array} values - values of checkboxes that should be checked
+ * @returns {Void} - Returns nothing.
+ */
+ updateChecked(checkboxes, values) {
+ checkboxes.each((_idx, checkbox) => {
+ if ((checkbox.value === "" && values.length === 1) || (checkbox.value !== "" && values.includes(checkbox.value))) {
+ checkbox.checked = true;
+ this.checkTheCheckBoxes(checkbox);
+ this.checkTheCheckParent(checkbox);
+ }
+ });
+ }
+
+ /**
+ * Set the container form(s) for the component, to disable ignored filters before submitting them
+ * @public
+ * @param {query} theForm - form or forms where the component will be used
+ * @returns {Void} - Returns nothing.
+ */
+ setContainerForm(theForm) {
+ theForm.on("submit ajax:before", () => {
+ theForm.find(".ignore-filters input, input.ignore-filter").each((_idx, elem) => {
+ elem.disabled = true;
+ });
+ });
+
+ theForm.on("ajax:send", () => {
+ theForm.find(".ignore-filters input, input.ignore-filter").each((_idx, elem) => {
+ elem.disabled = false;
+ });
+ });
+ }
+
+ /**
+ * Handles the click action on any checkbox.
+ * @private
+ * @param {Input} target - the input that has been checked
+ * @returns {Void} - Returns nothing.
+ */
+ checkTheCheckBoxes(target) {
+ const targetChecks = target.dataset.checkboxesTree;
+ const checkStatus = target.checked;
+
+ // NOTE: Note the regex CSS query, it selects those [data-children-checkbox] ended with the target id
+ const allChecks = document.querySelectorAll(`[data-children-checkbox$="${targetChecks}"] input`);
+
+ allChecks.forEach((input) => {
+ input.checked = checkStatus;
+ input.indeterminate = false;
+ input.classList.add("ignore-filter");
+
+ // recursive call if the input it is also a tree
+ if (input.dataset.checkboxesTree) {
+ this.checkTheCheckBoxes(input)
+ }
+ });
+ }
+
+ /**
+ * Update children checkboxes state when the current selection changes
+ * @private
+ * @param {Input} input - the checkbox to check its parent
+ * @returns {Void} - Returns nothing.
+ */
+ checkTheCheckParent(input) {
+ const key = input.parentNode.dataset.childrenCheckbox
+ // search in the checkboxes array if some id ends with the childrenCheckbox key, what means it is the parent
+ const parentCheck = this.checkboxesTree.find(({ id }) => new RegExp(`${key}$`, "i").test(id))
+ if (typeof parentCheck === "undefined") {
+ return;
+ }
+
+ // search for leaves with the same parent, what means they are siblings
+ const totalCheckSiblings = this.checkboxesLeaf.filter((node) => node.parentNode.dataset.childrenCheckbox === key)
+ const checkedSiblings = totalCheckSiblings.filter((checkbox) => checkbox.checked)
+ const indeterminateSiblings = totalCheckSiblings.filter((checkbox) => checkbox.indeterminate)
+
+ if (checkedSiblings.length === 0 && indeterminateSiblings.length === 0) {
+ parentCheck.checked = false;
+ parentCheck.indeterminate = false;
+ } else if (totalCheckSiblings.length === 1 && checkedSiblings.length === totalCheckSiblings.length && indeterminateSiblings.length === 0){
+ parentCheck.checked = false;
+ parentCheck.indeterminate = true;
+ } else if (checkedSiblings.length === totalCheckSiblings.length && indeterminateSiblings.length === 0) {
+ parentCheck.checked = true;
+ parentCheck.indeterminate = false;
+ } else {
+ parentCheck.checked = false;
+ parentCheck.indeterminate = true;
+ }
+
+ totalCheckSiblings.forEach((sibling) => {
+ if (parentCheck.indeterminate && !sibling.indeterminate) {
+ sibling.classList.remove("ignore-filter");
+ } else {
+ sibling.classList.add("ignore-filter");
+ }
+ });
+
+ // recursive call if there are more children
+ if ("childrenCheckbox" in parentCheck.parentNode.dataset) {
+ this.checkTheCheckParent(parentCheck);
+ }
+ }
+}
diff --git a/config/application.rb b/config/application.rb
index 112535d45b..16b8fd27f2 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -40,6 +40,7 @@ class Application < Rails::Application
require "extends/controllers/decidim/participatory_processes/participatory_processes_controller_extends"
require "extends/controllers/decidim/budgets/projects_controller_extends"
require "extends/controllers/decidim/proposals/admin/proposal_states_controller_extends"
+ require "extends/controllers/decidim/proposals/proposals_controller_extends"
# helpers
require "extends/helpers/decidim/check_boxes_tree_helper_extends"
require "extends/helpers/decidim/omniauth_helper_extends"
diff --git a/config/initializers/instant_spam_mail_blocker.rb b/config/initializers/instant_spam_mail_blocker.rb
new file mode 100644
index 0000000000..a8427fb464
--- /dev/null
+++ b/config/initializers/instant_spam_mail_blocker.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+# Patch to stop instant spam report emails from Decidim-AI when frequency is daily, weekly
+# Allows the Spam Summary Digest to manage notifications (daily, weekly)
+# instead of non-configurable real-time emails from Decidim-ai report
+
+module Decidim
+ module InstantSpamMailBlocker
+ def send_report_notification_to_moderators
+ return if spam_report? && !frequency_notifications_is_realtime?(@report.moderation.participatory_space.organization.admins)
+
+ super
+ end
+
+ def send_notification_to_admins!
+ return if spam_report? && !frequency_notifications_is_realtime?(@report.moderation.user.organization.admins)
+
+ super
+ end
+
+ private
+
+ def spam_report?
+ @report.reason.to_s == "spam"
+ end
+
+ def frequency_notifications_is_realtime?(admins)
+ admins.exists?(notifications_sending_frequency: "realtime")
+ end
+ end
+end
+
+Rails.application.config.to_prepare do
+ Decidim::CreateReport.prepend(Decidim::InstantSpamMailBlocker)
+ Decidim::CreateUserReport.prepend(Decidim::InstantSpamMailBlocker)
+end
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 99f6575a97..e1f25491b5 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -1,199 +1,342 @@
----
de:
activemodel:
attributes:
+ assembly:
+ copy_landing_page_blocks: Startseitenblöcke kopieren
meeting:
- online_meeting_url: URL für Online-Veranstaltung
+ online_meeting_url: URL zur Online-Veranstaltung
+ osp_authorization_handler:
+ document_number: Eindeutige Kennnummer
+ participatory_process:
+ copy_landing_page_blocks: Startseitenblöcke kopieren
activerecord:
models:
decidim/assembly:
- one: Versammlung
- other: Versammlungen
+ one: Akteur
+ other: Akteure
decidim/assembly_member:
- one: Versammlungenmitglieder
+ one: Bereichsmitglied
+ other: Bereichsmitglieder
+ date:
+ formats:
+ order: d-m-y
decidim:
+ account:
+ omniauth_synced_profile:
+ announcement_html: |-
+
Profilsynchronisierung
+
+ Die folgenden Informationen werden mit einem externen Identitätsanbieter synchronisiert:
+
+
+
+ Diese Informationen können Sie hier nicht bearbeiten.
+
admin:
actions:
- new_assembly: Neues Versammlung
- new_assembly_member: Neues Versammlungenmitglied
- new_assembly_type: Neuer Versammlungtyp
- new_assembly_user_role: Neuer Versammlung-Admin
+ add: Hinzufügen
+ browse: Durchsuchen
+ confirm_destroy: Löschen bestätigen
+ destroy: Löschen
+ edit: Bearbeiten
+ new_assembly: Neuer Bereich
+ new_assembly_member: Neues Bereichsmitglied
+ new_assembly_type: Neuer Bereichstyp
+ new_assembly_user_role: Neuer Bereichsadmin
assemblies:
create:
- error: Beim Erstellen eines neuen Versammlungen ist ein Fehler aufgetreten.
- success: Das Versammlung wurde erfolgreich erstellt.
+ error: Beim Erstellen eines neuen Bereichs ist ein Fehler aufgetreten.
+ success: Der Bereich wurde erfolgreich erstellt.
new:
- title: Neues Versammlung
+ title: Neuer Bereich
update:
- error: Beim Aktualisieren dieses Versammlungen ist ein Fehler aufgetreten.
+ error: Beim Aktualisieren dieses Bereichs ist ein Fehler aufgetreten.
assemblies_copies:
create:
- error: Beim Duplizieren dieses Versammlungen ist ein Fehler aufgetreten.
- success: Das Versammlung wurde erfolgreich dupliziert.
+ error: Beim Duplizieren dieses Bereichs ist ein Fehler aufgetreten.
+ success: Der Bereich wurde erfolgreich dupliziert.
destroy:
- success: Versammlungtyp wurde erfolgreich gelöscht.
- new:
- title: Neuer Versammlungtyp
- update:
- error: Beim Aktualisieren dieses Versammlungtyps ist ein Fehler aufgetreten.
- success: Versammlungtyp wurde erfolgreich aktualisiert.
+ success: Der Bereichstyp wurde erfolgreich gelöscht.
+ new:
+ title: Neuer Bereichstyp
+ update:
+ error: Beim Aktualisieren dieses Bereichstyps ist ein Fehler aufgetreten.
+ success: Der Bereichstyp wurde erfolgreich aktualisiert.
assembly_copies:
new:
- title: Dupliziertes Versammlung
+ title: Bereich duplizieren
assembly_imports:
create:
- error: Beim Importieren dieses Versammlungen ist ein Fehler aufgetreten.
- success: Versammlung wurde erfolgreich importiert.
+ error: Beim Importieren dieses Bereichs ist ein Fehler aufgetreten.
+ success: Der Bereich wurde erfolgreich importiert.
new:
- title: Versammlung importieren
+ title: Bereich importieren
assembly_members:
create:
- error: Beim Hinzufügen eines Mitglieds zu diesem Versammlung ist ein Fehler aufgetreten.
- success: Mitglied wurde diesem Versammlung erfolgreich hinzugefügt.
+ error: Beim Hinzufügen eines Mitglieds zu diesem Bereich ist ein Fehler aufgetreten.
+ success: Das Mitglied wurde diesem Bereich erfolgreich hinzugefügt.
destroy:
- success: Mitglied wurde erfolgreich aus diesem Versammlung entfernt.
+ success: Das Mitglied wurde erfolgreich aus diesem Bereich entfernt.
index:
- assembly_members_title: Versammlungenmitglieder
+ assembly_members_title: Bereichsmitglieder
new:
- title: Neues Versammlungenmitglied
+ title: Neues Bereichsmitglied
update:
- error: Beim Aktualisieren des Mitglieds dieses Versammlungen ist ein Fehler aufgetreten.
- success: Mitglied dieses Versammlungen erfolgreich aktualisiert.
+ error: Beim Aktualisieren des Mitglieds dieses Bereichs ist ein Fehler aufgetreten.
+ success: Das Mitglied dieses Bereichs wurde erfolgreich aktualisiert.
assembly_publications:
create:
- error: Beim Veröffentlichen dieses Versammlungen ist ein Fehler aufgetreten.
- success: Versammlung erfolgreich veröffentlicht.
+ error: Beim Veröffentlichen dieses Bereichs ist ein Fehler aufgetreten.
+ success: Der Bereich wurde erfolgreich veröffentlicht.
assembly_user_roles:
edit:
- title: Versammlungenadmin aktualisieren
+ title: Bereichsadmin aktualisieren
new:
- title: Neuer Versammlungenadmin
+ title: Neuer Bereichsadmin
+ attachments:
+ form:
+ send_notification_to_followers: >
+ Eine Benachrichtigung an alle Personen senden, die der Konsultation folgen und dem Empfang von E-Mail-Benachrichtigungen zugestimmt haben
meetings:
with_any_type:
values:
in_person: Vor-Ort-Termin
menu:
- assemblies: Versammlungen
+ assemblies: Bereiche
assemblies_submenu:
- info: Über dieses Versammlung
- see_assembly: Versammlung ansehen
+ info: Über diesen Bereich
+ see_assembly: Bereich ansehen
clean: Datenbereinigung
models:
assembly:
- name: Versammlung
+ name: Bereich
+ scope:
+ fields:
+ name: Name
+ scope_type: Geltungsbereichstyp
+ scopes:
+ no_scopes: Auf dieser Ebene gibt es keine Geltungsbereiche.
+ titles:
+ scopes: Geltungsbereiche
+ update:
+ error: Beim Aktualisieren dieses Geltungsbereichs ist ein Problem aufgetreten.
+ success: Geltungsbereich erfolgreich aktualisiert
titles:
- assemblies: Versammlungen
+ assemblies: Bereiche
admin_log:
assembly:
- create: "%{user_name} hat das Versammlung %{resource_name} erstellt"
- duplicate: "%{user_name} hat das Versammlung %{resource_name} dupliziert"
- export: "%{user_name} hat das Versammlung %{resource_name} exportiert"
- import: "%{user_name} hat das Versammlung %{resource_name} importiert"
- publish: "%{user_name} hat das Versammlung %{resource_name} veröffentlicht"
- unpublish: "%{user_name} hat das Versammlung %{resource_name} auf \"unveröffentlicht\" gesetzt"
- update: "%{user_name} hat das Versammlung %{resource_name} aktualisiert"
+ create: "%{user_name} hat den Bereich %{resource_name} erstellt"
+ duplicate: "%{user_name} hat den Bereich %{resource_name} dupliziert"
+ export: "%{user_name} hat den Bereich %{resource_name} exportiert"
+ import: "%{user_name} hat den Bereich %{resource_name} importiert"
+ publish: "%{user_name} hat den Bereich %{resource_name} veröffentlicht"
+ unpublish: "%{user_name} hat den Bereich %{resource_name} auf \"unveröffentlicht\" gesetzt"
+ update: "%{user_name} hat den Bereich %{resource_name} aktualisiert"
assembly_member:
- create: "%{user_name} hat das Mitglied %{resource_name} im Versammlung %{space_name} erstellt"
- delete: "%{user_name} hat das Mitglied %{resource_name} aus dem Versammlung %{space_name} entfernt"
- update: "%{user_name} hat das Mitglied %{resource_name} im Versammlung %{space_name} aktualisiert"
+ create: "%{user_name} hat das Mitglied %{resource_name} im Bereich %{space_name} erstellt"
+ delete: "%{user_name} hat das Mitglied %{resource_name} aus dem Bereich %{space_name} entfernt"
+ update: "%{user_name} hat das Mitglied %{resource_name} im Bereich %{space_name} aktualisiert"
assembly_setting:
- update: "%{user_name} hat die Versammlungeneinstellungen aktualisiert"
+ update: "%{user_name} hat die Bereichseinstellungen aktualisiert"
assembly_type:
- create: "%{user_name} hat den Versammlungentyp %{resource_name} erstellt"
- delete: "%{user_name} hat den Versammlungentyp %{resource_name} entfernt"
- publish: "%{user_name} hat den Versammlungentyp %{resource_name} veröffentlicht"
- unpublish: "%{user_name} hat den Versammlungentyp %{resource_name} auf \"unveröffentlicht\" gesetzt"
- update: "%{user_name} hat den Versammlungentyp %{resource_name} aktualisiert"
+ create: "%{user_name} hat den Bereichstyp %{resource_name} erstellt"
+ delete: "%{user_name} hat den Bereichstyp %{resource_name} entfernt"
+ publish: "%{user_name} hat den Bereichstyp %{resource_name} veröffentlicht"
+ unpublish: "%{user_name} hat den Bereichstyp %{resource_name} auf \"unveröffentlicht\" gesetzt"
+ update: "%{user_name} hat den Bereichstyp %{resource_name} aktualisiert"
assembly_user_role:
- create: "%{user_name} hat %{resource_name} zum Versammlung %{space_name} eingeladen"
- delete: "%{user_name} hat %{resource_name} aus dem Versammlung %{space_name} entfernt"
- update: "%{user_name} hat die Rolle von %{resource_name} im Versammlung %{space_name} geändert"
+ create: "%{user_name} hat %{resource_name} zum Bereich %{space_name} eingeladen"
+ delete: "%{user_name} hat %{resource_name} aus dem Bereich %{space_name} entfernt"
+ update: "%{user_name} hat die Rolle von %{resource_name} im Bereich %{space_name} geändert"
assemblies:
admin:
assemblies:
form:
- announcement_help: Der Text, den Sie hier eingeben, wird dem Benutzer direkt unter den Versammlungen-Informationen angezeigt.
- duration_help: Wenn die Dauer dieses Versammlungen begrenzt ist, dann wählen Sie hier das Enddatum aus. Andernfalls wird das Datum als unbestimmt angezeigt.
- included_at_help: Wählen Sie das Datum aus, an dem dieses Versammlung zur Plattform hinzugefügt wurde. Das Datum muss nicht zwingend mit dem Gründungsdatum übereinstimmen.
- select_an_assembly_type: Wählen Sie einen Versammlungtyp
- select_parent_assembly: Übergeordnetes Versammlung auswählen
+ announcement_help: Der Text, den Sie hier eingeben, wird den Benutzerinnen und Benutzern direkt unter den Bereichsinformationen angezeigt.
+ duration_help: Wenn dieser Bereich zeitlich begrenzt ist, wählen Sie hier das Enddatum aus. Andernfalls wird es als unbestimmt angezeigt.
+ included_at_help: Wählen Sie das Datum aus, an dem dieser Bereich zur Plattform hinzugefügt wurde. Das Datum muss nicht zwingend mit dem Gründungsdatum übereinstimmen.
+ select_an_assembly_type: Wählen Sie einen Bereichstyp aus
+ select_parent_assembly: Übergeordneten Bereich auswählen
assembly_copies:
form:
- slug_help_html: 'URL-Slugs werden zum Generieren der URLs verwendet, die auf dieses Versammlung verweisen. Akzeptiert werden nur Buchstaben, Zahlen und Bindestriche und es muss mit einem Buchstaben beginnen. Beispiel: %{url}'
+ slug_help_html: 'URL-Slugs werden zum Generieren der URLs verwendet, die auf diesen Bereich verweisen. Es sind nur Buchstaben, Zahlen und Bindestriche erlaubt, und der Slug muss mit einem Buchstaben beginnen. Beispiel: %{url}'
assembly_imports:
form:
- slug_help_html: 'URL-Slugs werden zum Generieren der URLs verwendet, die auf dieses Versammlung verweisen. Akzeptiert werden nur Buchstaben, Zahlen und Bindestriche und es muss mit einem Buchstaben beginnen. Beispiel: %{url}'
+ slug_help_html: 'URL-Slugs werden zum Generieren der URLs verwendet, die auf diesen Bereich verweisen. Es sind nur Buchstaben, Zahlen und Bindestriche erlaubt, und der Slug muss mit einem Buchstaben beginnen. Beispiel: %{url}'
assemblies:
description:
- data: Versammlungendaten
- title: Über dieses Versammlung
+ data: Akteurdaten
+ title: Über diesen Akteur
show:
- title: Über dieses Versammlung
+ title: Über diesen Akteur
content_blocks:
children_assemblies:
- name: Versammlungen
+ name: Akteure
highlighted_assemblies:
- name: Hervorgehobene Versammlungen
+ name: Hervorgehobene Akteure
related_assemblies:
- name: Ähnliche Versammlungen
+ name: Ähnliche Akteure
index:
- title: Versammlungen
+ title: Akteure
last_activity:
- new_assembly: 'Neues Versammlung:'
+ new_assembly: "Neuer Akteur:"
pages:
home:
highlighted_assemblies:
- active_spaces: Aktive Versammlungen
- see_all_spaces: Alle Versammlungen
+ active_spaces: Aktive Akteure
+ see_all_spaces: Alle Akteure anzeigen
show:
- social_networks_title: Versammlung besuchen unter
+ social_networks_title: Akteur auch auf
components:
meetings:
settings:
step:
- creation_enabled_for_participants: Veranstaltung-Erstellung durch Teilnehmer aktiviert
- creation_enabled_for_user_groups: Veranstaltung-Erstellung durch Benutzergruppen aktiviert
+ creation_enabled_for_participants: Veranstaltungserstellung durch Teilnehmende aktiviert
+ creation_enabled_for_user_groups: Veranstaltungserstellung durch Benutzergruppen aktiviert
+ authorization_handlers:
+ data_authorization_handler:
+ errors:
+ error_message: Dieses Feld enthält einen Fehler.
+ gdpr: Sie müssen der Datenverarbeitung zustimmen.
+ minimum_age: Sie müssen mindestens 16 Jahre alt sein, um sich zu registrieren.
+ no_postal_code_result: Für diese Postleitzahl wurde kein Ergebnis gefunden.
+ explanation: Verifizieren Sie Ihre Identität
+ fields:
+ city: Ort
+ firstname: Vorname
+ gdpr: Ich stimme zu, dass meine Daten zur Überprüfung meiner Identität verwendet werden dürfen.
+ lastname: Nachname
+ minimum_age: Ich bestätige, dass ich mindestens 16 Jahre alt bin.
+ phone: Telefonnummer
+ postal_code: Postleitzahl
+ help_text:
+ city: Ihr Ort wird anhand Ihrer Postleitzahl ermittelt. Bitte wählen Sie Ihren Ort aus der Dropdown-Liste aus.
+ firstname: Ihr Vorname muss mindestens 1 Zeichen enthalten und darf nur Buchstaben, Leerzeichen und Sonderzeichen wie - und ' enthalten.
+ global_help: Dieses Formular muss ausgefüllt werden, um einen Vorschlag im Bürgerhaushalt einzureichen.
+ lastname: Ihr Nachname muss mindestens 1 Zeichen enthalten und darf nur Buchstaben, Leerzeichen und Sonderzeichen wie - und ' enthalten.
+ postal_code: Ihre Postleitzahl muss aus 5 Ziffern bestehen.
+ legend: Kontaktinformationen
+ name: Datenfreigabe-Verfahren
+ osp_authorization_handler:
+ explanation: Verifizieren Sie Ihre Identität, indem Sie eine eindeutige Kennnummer eingeben.
+ fields:
+ document_number: Eindeutige Kennnummer
+ name: Formular zur Identitätsprüfung
+ osp_authorization_workflow:
+ name: Autorisierungsverfahren
+ comments:
+ comments:
+ create:
+ error: Beim Erstellen des Kommentars ist ein Problem aufgetreten.
+ components:
+ proposals:
+ settings:
+ global:
+ require_category: Kategorie ist erforderlich
+ require_scope: Geltungsbereich ist erforderlich
+ devise:
+ omniauth:
+ france_connect:
+ help_link: Mehr über FranceConnect erfahren
+ introduction: FranceConnect ist die vom Staat angebotene Lösung, um die Anmeldung an Online-Diensten sicherer und einfacher zu machen.
+ shared:
+ links:
+ log_in_with_provider: Mit %{provider} anmelden
events:
assemblies:
create_assembly_member:
- email_intro: Ein Administrator des Versammlungen %{resource_name} hat Sie als Mitglied eingeladen.
- email_outro: Sie haben diese Benachrichtigung erhalten, weil Sie zu einem Versammlung eingeladen wurden. Gehen Sie zur Versammlungseite, um mitzumachen!
- email_subject: Sie wurden als Mitglied des Versammlungen %{resource_name} eingeladen!
- notification_title: Sie wurden als Mitglied des Versammlungen %{resource_name} eingetragen. Gehen Sie zur Versammlungseite, um mitzuwirken!
+ email_intro: Ein Administrator des Akteurs %{resource_name} hat Sie als Mitglied eingeladen.
+ email_outro: Sie haben diese Benachrichtigung erhalten, weil Sie zu einem Akteur eingeladen wurden. Gehen Sie zur Akteurseite, um mitzumachen!
+ email_subject: Sie wurden als Mitglied des Akteurs %{resource_name} eingeladen!
+ notification_title: Sie wurden als Mitglied des Akteurs %{resource_name} eingetragen. Gehen Sie zur Akteurseite, um mitzuwirken!
meetings:
meeting_registrations_over_percentage:
- email_outro: Sie haben diese Benachrichtigung erhalten, weil Sie ein Administrator des Teilnahmebereichs der Veranstaltung sind.
+ email_outro: Sie haben diese Benachrichtigung erhalten, weil Sie Administrator des Beteiligungsraums der Veranstaltung sind.
proposals:
author_confirmation_proposal_event:
- email_intro: 'Ihr Vorschlag « %{resource_title} » wurde erfolgreich entgegengenommen und ist nun öffentlich. Vielen Dank für Ihre Teilnahme! Sie können ihn hier einsehen :'
- email_outro: Sie erhalten diese Benachrichtigung, da Sie der Autor des Vorschlags sind. Sie können sich abmelden, indem Sie die Vorschlagsseite besuchen (« %{resource_title} ») und auf « Nicht mehr folge » klicken.
- email_subject: Ihr Vorschlag wurde veröffentlicht !
+ email_intro: 'Ihr Vorschlag "%{resource_title}" wurde erfolgreich entgegengenommen und ist nun öffentlich. Vielen Dank für Ihre Teilnahme! Sie können ihn hier einsehen:'
+ email_outro: 'Sie erhalten diese Benachrichtigung, weil Sie der Autor des Vorschlags sind. Sie können sich abmelden, indem Sie die Vorschlagsseite ("%{resource_title}") besuchen und auf "Nicht mehr folgen" klicken.'
+ email_subject: Ihr Vorschlag wurde veröffentlicht!
notification_title: Ihr Vorschlag %{resource_title} ist jetzt online.
+ forms:
+ questionnaire_answer_presenter:
+ download_attachment: Anhang herunterladen
+ questionnaires:
+ answers:
+ files:
+ upload_not_allowed: Datei-Uploads sind nur für angemeldete Benutzerinnen und Benutzer erlaubt.
+ user_answers_serializer:
+ email: E-Mail
+ name: Name
help:
participatory_spaces:
assemblies:
- title: Was sind Versammlungen?
+ title: Was sind Akteure?
+ initiatives:
+ admin:
+ index:
+ initiatives_types:
+ alert_html: |
+ Sie müssen mindestens einen Initiativtyp anlegen, damit Teilnehmende Initiativen erstellen können.
+ %{link}
+ button: Neuer Initiativtyp
+ initiatives_types:
+ create:
+ error: Ein Fehler ist aufgetreten.
+ success: Ein neuer Initiativtyp wurde erfolgreich erstellt. Sie müssen mindestens einen Geltungsbereich für diesen Initiativtyp definieren, damit er verwendet werden kann.
+ destroy:
+ success: Der Initiativtyp wurde erfolgreich entfernt.
+ edit:
+ update: Aktualisieren
+ create_initiative:
+ fill_data:
+ back: Zurück
+ continue: Weiter
+ fill_data_help: |
+
+ - Überprüfen Sie den Inhalt Ihrer Initiative. Ist Ihr Titel leicht verständlich? Ist das Ziel Ihrer Initiative klar?
+ - Wählen Sie die Art der Unterschrift: vor Ort, online oder eine Kombination aus beidem.
+ - Welcher geografische Geltungsbereich gilt für die Initiative?
+
+ more_information: (Mehr Informationen)
+ select_area: Bereich auswählen
+ select_scope: Geltungsbereich auswählen
+ select_initiative_type:
+ new: Neue Initiative erstellen
+ form:
+ add_documents: Dokumente hinzufügen
+ add_image: Bild hinzufügen
+ attachment_legend: (Optional) Anhang hinzufügen
+ edit_documents: Dokumente bearbeiten
+ edit_image: Bild bearbeiten
+ image_legend: (Optional) Bild hinzufügen
+ initiatives:
+ index_header:
+ new_initiative: Neue Initiative
meetings:
actions:
invalid_destroy:
proposals_count:
- one: 'Die Veranstaltung kann nicht gelöscht werden, da %{count} Vorschlag mit dieser verbunden ist:'
- other: 'Die Veranstaltung kann nicht gelöscht werden, da %{count} Vorschläge mit dieser verbunden sind:'
+ one: "Die Veranstaltung kann nicht gelöscht werden, da %{count} Vorschlag damit verbunden ist:"
+ other: "Die Veranstaltung kann nicht gelöscht werden, da %{count} Vorschläge damit verbunden sind:"
admin:
meetings:
destroy:
- success: Das Veranstaltung wurde erfolgreich gelöscht
+ success: Die Veranstaltung wurde erfolgreich gelöscht.
new:
title: Veranstaltung erstellen
registrations:
form:
- recommendation_message: Aus Datenschutzgründen empfehlen wir, dass Sie dieses Einschreibeformular löschen, wenn es nicht länger benötigt wird. Standardmäßig ist das 3 Monate nachdem die Veranstaltung durchgeführt wurde.
+ recommendation_message: Aus Datenschutzgründen empfehlen wir, dieses Anmeldeformular zu löschen, sobald es nicht mehr benötigt wird. Standardmäßig ist das drei Monate nach der Veranstaltung.
directory:
meetings:
index:
meetings: Veranstaltungen
iframe_access_level:
- registered: Registrierte Teilnehmer zu diesem Veranstaltung
+ registered: Registrierte Teilnehmende dieser Veranstaltung
meetings:
create:
invalid: Beim Erstellen dieser Veranstaltung ist ein Problem aufgetreten.
@@ -205,7 +348,7 @@ de:
index:
new_meeting: Neue Veranstaltung
meetings:
- no_meetings_warning: Keine Veranstaltungen entsprechen Ihren Suchkriterien oder es ist keine Veranstaltung geplant.
+ no_meetings_warning: Keine Veranstaltungen entsprechen Ihren Suchkriterien oder es sind keine Veranstaltungen geplant.
update:
invalid: Beim Aktualisieren der Veranstaltung ist ein Fehler aufgetreten.
models:
@@ -213,27 +356,151 @@ de:
fields:
official_meeting: Offizielle Veranstaltung
menu:
- assemblies: Versammlungen
+ assemblies: Akteure
+ help: Hilfe
metrics:
assemblies:
- description: Anzahl der erstellten Versammlungen
- object: Versammlungen
- title: Versammlungen
+ description: Anzahl der erstellten Akteure
+ object: Akteure
+ title: Akteure
+ pages:
+ home:
+ extended:
+ meetings: Veranstaltungen
+ hero:
+ welcome: Willkommen bei %{organization}!
+ participatory_processes:
+ create_initiative:
+ fill_data:
+ back: Zurück
+ continue: Weiter
+ fill_data_help: |
+
+ - Überprüfen Sie den Inhalt Ihrer Initiative. Ist Ihr Titel leicht verständlich? Ist das Ziel Ihrer Initiative klar?
+ - Wählen Sie die Art der Unterschrift: vor Ort, online oder eine Kombination aus beidem.
+ - Welcher geografische Geltungsbereich gilt für die Initiative?
+
+ more_information: (Mehr Informationen)
+ select_area: Bereich auswählen
+ select_scope: Geltungsbereich auswählen
+ select_initiative_type:
+ new: Neue Initiative erstellen
+ form:
+ add_documents: Dokumente hinzufügen
+ add_image: Bild hinzufügen
+ attachment_legend: (Optional) Anhang hinzufügen
+ edit_documents: Dokumente bearbeiten
+ edit_image: Bild bearbeiten
+ image_legend: (Optional) Bild hinzufügen
+ show:
+ related_processes: Verwandte Prozesse
participatory_spaces:
highlighted_meetings:
see_all: Alle Veranstaltungen anzeigen
+ profiles:
+ show:
+ activity: Aktivität
+ proposals:
+ actions:
+ delete_proposal_state_confirm: Möchten Sie diesen Status wirklich löschen?
+ destroy: Status löschen
+ edit_proposal_state: Status bearbeiten
+ new_proposal_state: Neuer Status
+ title: Aktionen
+ admin:
+ proposal_states:
+ index:
+ title: Status
+ update:
+ error: Fehler beim Aktualisieren des Status
+ success: Status erfolgreich aktualisiert
+ application_helper:
+ filter_state_values:
+ all: Alle
+ not_answered: Nicht beantwortet
+ models:
+ proposal_state:
+ css_class: CSS-Klasse
+ title: Status
+ no_proposal_states: Es wurden noch keine Status angelegt.
+ proposals:
+ dynamic_map_instructions:
+ description: Die Koordinaten werden aktualisiert, wenn Sie auf „Vorschau“ klicken. Die Adresse bleibt jedoch unverändert.
+ instructions: Sie können den Punkt auf der Karte verschieben.
+ edit:
+ add_documents: Dokumente hinzufügen
+ attachment_legend: (Optional) Anhang hinzufügen
+ edit_documents: Dokumente bearbeiten
+ select_a_category: Bitte wählen Sie eine Kategorie aus
+ edit_form_fields:
+ marker_added: Markierung zur Karte hinzugefügt.
+ placeholder:
+ address: 37 Homewood Drive Brownsburg, IN 46112
+ scopes:
+ global: Globaler Geltungsbereich
statistics:
- assemblies_count: Versammlungen
+ assemblies_count: Akteure
+ system:
+ organizations:
+ omniauth_settings:
+ cultuur_connect:
+ client_id: Client-ID
+ client_secret: Client-Secret
+ site_url: Website-URL
+ france_connect:
+ acr_values: ACR-Werte
+ client_options_identifier: Client-Kennung
+ client_options_redirect_uri: Redirect-URI (z. B. https://my-decidim.com/users/auth/openid_connect/callback)
+ client_options_secret: Client-Secret
+ client_signing_alg: Signaturalgorithmus des Clients (z. B. RS256, ES256, PS256 ...)
+ display_name: Anzeigename
+ icon_hover_path: Icon-Pfad für Hover-Zustand (z. B. /media/images/oidc_hover.png)
+ issuer: Issuer-URL (z. B. https://identity.com)
+ logout_path: Logout-Pfad (Pfad nach "/users/auth/openid_connect")
+ logout_policy: Logout-Richtlinie (none/session.destroy)
+ post_logout_redirect_uri: Post-Logout-Redirect-URI (z. B. https://my-decidim.com/users/auth/openid_connect/logout)
+ scope: Scope / Claims (z. B. openid email profile)
+ openid_connect:
+ acr_values: ACR-Werte
+ client_auth_method: Client-Authentifizierungsmethode (z. B. basic, jwt_bearer, mtls ...)
+ client_options_identifier: Client-Kennung
+ client_options_redirect_uri: Redirect-URI (z. B. https://my-decidim.com/users/auth/openid_connect/callback)
+ client_options_secret: Client-Secret
+ client_signing_alg: Signaturalgorithmus des Clients (z. B. RS256, ES256, PS256 ...)
+ discovery: Discovery (true oder false)
+ display_name: Anzeigename
+ issuer: Issuer-URL (z. B. https://identity.com)
+ logout_path: Logout-Pfad (Pfad nach "/users/auth/openid_connect")
+ logout_policy: Logout-Richtlinie (none/session.destroy)
+ post_logout_redirect_uri: Post-Logout-Redirect-URI (z. B. https://my-decidim.com/users/auth/openid_connect/logout)
+ response_type: Response-Typ (code oder id_token)
+ scope: Scope / Claims (z. B. openid email profile)
+ uid_field: UID-Feld (z. B. sub, email, username)
+ publik:
+ client_id: Client-ID
+ client_secret: Client-Secret
+ site_url: Website-URL
+ verifications:
+ authorizations:
+ first_login:
+ actions:
+ osp_authorization_handler: Mit dem Formular zur Identitätsprüfung verifizieren
+ osp_authorization_workflow: Mit dem Formular zur Identitätsprüfung verifizieren
layouts:
decidim:
assemblies:
index:
- promoted_assemblies: Hervorgehobene Versammlungen
+ promoted_assemblies: Hervorgehobene Akteure
metadata:
children_item:
- one: "%{count} Versammlung"
- other: "%{count} Versammlungen"
+ one: "%{count} Akteur"
+ other: "%{count} Akteure"
order_by_assemblies:
assemblies:
- one: "%{count} Versammlungen"
- other: "%{count} Versammlungen"
+ one: "%{count} Akteur"
+ other: "%{count} Akteure"
+ budgets:
+ voting_menubar:
+ cancel_voting: Wahlkabine verlassen
+ footer:
+ help: Hilfe
diff --git a/config/locales/en.yml b/config/locales/en.yml
index ca76291fda..c3eaaf25de 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -52,6 +52,16 @@ en:
update:
error: There was a problem updating this scope.
success: Scope updated successfully
+ ai:
+ spam_detection:
+ digest:
+ frequency_label:
+ daily: today
+ weekly: this week
+ subject: AI spam detection summary - %{count} spam items detected (%{frequency_label})
+ summary: |-
+ AI anti-spam summary for %{organization}: %{count} items were automatically flagged %{frequency_label} by the Decidim AI system.
+ View detected spams.
authorization_handlers:
data_authorization_handler:
errors:
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index aaa94d8496..de319c03ae 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -60,6 +60,16 @@ fr:
update:
error: Il y a eu une erreur lors de la mise à jour du secteur.
success: Secteur mis à jour avec succès.
+ ai:
+ spam_detection:
+ digest:
+ frequency_label:
+ daily: aujourd’hui
+ weekly: cette semaine
+ subject: Résumé anti-spam, %{count} spams détectés (%{frequency_label})
+ summary: |-
+ Résumé IA anti-spam pour %{organization} : %{count} contenus ont été automatiquement signalés %{frequency_label} par le système Decidim AI.
+ Voir les spams détectés.
authorization_handlers:
data_authorization_handler:
errors:
diff --git a/config/locales/nl.yml b/config/locales/nl.yml
index f75814146e..71fd555140 100644
--- a/config/locales/nl.yml
+++ b/config/locales/nl.yml
@@ -47,8 +47,8 @@ nl:
menu:
admin_accountability: Beheerdersrollen
clean: Data opschonen
- initiatives_settings: "Instellingen: initiatieven"
- participatory_process_types: "Inspraakprocessen: types"
+ initiatives_settings: 'Instellingen: initiatieven'
+ participatory_process_types: 'Inspraakprocessen: types'
models:
scope:
fields:
@@ -163,8 +163,8 @@ nl:
livechat: Live chat
maintenance:
private_data: Privé-data
- proposal_custom_fields: "Aangepaste velden: openbare info"
- proposal_private_custom_fields: "Aangepaste velden: privé-info"
+ proposal_custom_fields: 'Aangepaste velden: openbare info'
+ proposal_private_custom_fields: 'Aangepaste velden: privé-info'
menu:
admins: Beheerders
custom_redirects: Aangepaste redirects
@@ -174,8 +174,8 @@ nl:
proposal_custom_fields: Aangepaste velden
proposal_custom_fields:
menu:
- title: "Aangepaste velden: openbare info"
- proposal_private_custom_fields: "Aangepaste velden: privé-info"
+ title: 'Aangepaste velden: openbare info'
+ proposal_private_custom_fields: 'Aangepaste velden: privé-info'
devise:
sessions:
new:
@@ -218,14 +218,14 @@ nl:
title: Bewerk vragenlijst
upload:
labels:
- replace: Vervangen
add_image: Afbeelding toevoegen
+ replace: Vervangen
user_answers_serializer:
email: Email
name: Name
half_signup:
menu:
- auth_settings: "Authenticatie: instellingen"
+ auth_settings: 'Authenticatie: instellingen'
quick_auth:
sms_verification:
text_message: Hello, %{verification} is the code to authenticate yourself on the platform
@@ -389,17 +389,17 @@ nl:
faker:
address:
country_code:
- - EN
- - EN0
- - EN1
- - EN2
- - EN3
- - EN4
- - EN5
- - EN6
- - EN7
- - EN8
- - EN9
+ - EN
+ - EN0
+ - EN1
+ - EN2
+ - EN3
+ - EN4
+ - EN5
+ - EN6
+ - EN7
+ - EN8
+ - EN9
layouts:
decidim:
footer:
diff --git a/config/sidekiq.yml b/config/sidekiq.yml
index 0a6499cce3..4b5298cd4c 100644
--- a/config/sidekiq.yml
+++ b/config/sidekiq.yml
@@ -37,3 +37,18 @@
cron: "*/15 * * * *"
class: Decidim::ParticipatoryProcesses::ChangeActiveStepJob
queue: scheduled
+
+# Decidim-AI Spam Digest Jobs
+ AiSpamDigestDaily:
+ cron: "55 8 * * *"
+ class: Decidim::Ai::SpamDetection::SpamDigestGeneratorJob
+ queue: mailers
+ args:
+ - daily
+
+ AiSpamDigestWeekly:
+ cron: "0 8 * * MON"
+ class: Decidim::Ai::SpamDetection::SpamDigestGeneratorJob
+ queue: mailers
+ args:
+ - weekly
diff --git a/lib/extends/controllers/decidim/proposals/proposals_controller_extends.rb b/lib/extends/controllers/decidim/proposals/proposals_controller_extends.rb
new file mode 100644
index 0000000000..e760b213f9
--- /dev/null
+++ b/lib/extends/controllers/decidim/proposals/proposals_controller_extends.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require "active_support/concern"
+
+module Decidim
+ module Proposals
+ module ProposalsControllerExtends
+ extend ActiveSupport::Concern
+
+ included do
+ def index
+ if component_settings.participatory_texts_enabled?
+ @proposals = Decidim::Proposals::Proposal
+ .where(component: current_component)
+ .published
+ .not_hidden
+ .only_amendables
+ .includes(:category, :scope, :attachments, :coauthorships)
+ .order(position: :asc)
+ render "decidim/proposals/proposals/participatory_texts/participatory_text"
+ else
+ @proposals = search.result
+ @proposals = reorder(@proposals)
+ ids = @proposals.ids
+ @proposals = Decidim::Proposals::Proposal.where(id: ids)
+ .order(Arel.sql("position(decidim_proposals_proposals.id::text in '#{ids.join(",")}')"))
+ .page(params[:page])
+ .per(per_page)
+ @proposals = @proposals.includes(:component, :coauthorships, :attachments)
+
+ @voted_proposals = if current_user
+ ProposalVote.where(
+ author: current_user,
+ proposal: @proposals.pluck(:id)
+ ).pluck(:decidim_proposal_id)
+ else
+ []
+ end
+ end
+ end
+
+ private
+
+ def default_filter_params
+ {
+ activity: "all",
+ related_to: "",
+ search_text_cont: "",
+ type: "all",
+ with_any_category: nil,
+ with_any_origin: nil,
+ with_any_scope: nil,
+ with_any_state: default_states
+ }
+ end
+ end
+ end
+ end
+end
+
+Decidim::Proposals::ProposalsController.include(Decidim::Proposals::ProposalsControllerExtends)
diff --git a/lib/extends/permissions/initiatives/permissions_extends.rb b/lib/extends/permissions/initiatives/permissions_extends.rb
index b0883406d2..30c94b4527 100644
--- a/lib/extends/permissions/initiatives/permissions_extends.rb
+++ b/lib/extends/permissions/initiatives/permissions_extends.rb
@@ -7,12 +7,17 @@ module PermissionsExtends
def creation_enabled?
return false unless Decidim::Initiatives.creation_enabled
return true if no_authorizations_available?
+ return true if no_create_permission_on_initiative_type?
user_can_create? && authorized?(:create, permissions_holder: initiative_type)
end
private
+ def no_create_permission_on_initiative_type?
+ initiative_type.permissions.nil? || initiative_type.permissions.keys.empty? || !initiative_type.permissions&.keys&.include?("create")
+ end
+
def no_authorizations_available?
user&.organization&.available_authorizations&.empty?
end
diff --git a/lib/tasks/ai_spam_digest_tasks.rake b/lib/tasks/ai_spam_digest_tasks.rake
new file mode 100644
index 0000000000..9d66e857bf
--- /dev/null
+++ b/lib/tasks/ai_spam_digest_tasks.rake
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+namespace :decidim do
+ namespace :mailers do
+ desc "Adds the AI summary to the daily digest"
+ task spam_digest_daily: :environment do
+ puts "[Decidim-AI] Generating the daily AI spam digest..."
+ Decidim::Ai::SpamDetection::SpamDigestGeneratorJob.perform_now(:daily)
+ end
+
+ desc "Adds the AI summary to the weekly digest"
+ task spam_digest_weekly: :environment do
+ puts "[Decidim-AI] Generating the weekly AI spam digest..."
+ Decidim::Ai::SpamDetection::SpamDigestGeneratorJob.perform_now(:weekly)
+ end
+ end
+end
diff --git a/spec/controllers/proposals_controller_spec.rb b/spec/controllers/proposals_controller_spec.rb
new file mode 100644
index 0000000000..99a11805b7
--- /dev/null
+++ b/spec/controllers/proposals_controller_spec.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+module Decidim
+ module Proposals
+ describe ProposalsController do
+ routes { Decidim::Proposals::Engine.routes }
+
+ let(:user) { create(:user, :confirmed, organization: component.organization) }
+
+ let(:proposal_params) do
+ {
+ component_id: component.id
+ }
+ end
+ let(:params) { { proposal: proposal_params } }
+
+ before do
+ request.env["decidim.current_organization"] = component.organization
+ request.env["decidim.current_participatory_space"] = component.participatory_space
+ request.env["decidim.current_component"] = component
+ stub_const("Decidim::Paginable::OPTIONS", [100])
+ end
+
+ describe "GET index" do
+ context "when participatory texts are disabled" do
+ let(:component) { create(:proposal_component, :with_geocoding_enabled) }
+
+ it "sorts proposals by search defaults" do
+ get :index
+ expect(response).to have_http_status(:ok)
+ expect(subject).to render_template(:index)
+ expect(assigns(:proposals).order_values).to eq(["position(decidim_proposals_proposals.id::text in '')"])
+ end
+
+ it "sets two different collections" do
+ geocoded_proposals = create_list(:proposal, 10, component:, latitude: 1.1, longitude: 2.2)
+ non_geocoded_proposals = create_list(:proposal, 2, component:, latitude: nil, longitude: nil)
+
+ get :index
+ expect(response).to have_http_status(:ok)
+ expect(subject).to render_template(:index)
+
+ expect(assigns(:proposals).count).to eq 12
+ expect(assigns(:proposals)).to match_array(geocoded_proposals + non_geocoded_proposals)
+ end
+ end
+
+ context "when participatory texts are enabled" do
+ let(:component) { create(:proposal_component, :with_participatory_texts_enabled) }
+
+ it "sorts proposals by position" do
+ get :index
+ expect(response).to have_http_status(:ok)
+ expect(subject).to render_template(:participatory_text)
+ expect(assigns(:proposals).order_values.first.expr.name).to eq("position")
+ end
+
+ context "when emendations exist" do
+ let!(:amendable) { create(:proposal, component:) }
+ let!(:emendation) { create(:proposal, component:) }
+ let!(:amendment) { create(:amendment, amendable:, emendation:, state: "accepted") }
+
+ it "does not include emendations" do
+ get :index
+ expect(response).to have_http_status(:ok)
+ emendations = assigns(:proposals).select(&:emendation?)
+ expect(emendations).to be_empty
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/events/decidim/ai/spam_detection/spam_digest_event_spec.rb b/spec/events/decidim/ai/spam_detection/spam_digest_event_spec.rb
new file mode 100644
index 0000000000..98a197287e
--- /dev/null
+++ b/spec/events/decidim/ai/spam_detection/spam_digest_event_spec.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+module Decidim
+ module Ai
+ module SpamDetection
+ describe SpamDigestEvent, type: :mailer do
+ let(:organization) { create(:organization, name: { "en" => "Acme Org", "fr" => "Org Acme" }) }
+ let(:other_organization) { create(:organization, name: { "en" => "Other Org" }) }
+ let(:user) { create(:user, organization:, email: "admin@example.org") }
+ let(:user_other) { create(:user, organization: other_organization) }
+ let(:component) { create(:component, manifest_name: "proposals", organization:) }
+ let(:component_other) { create(:component, manifest_name: "proposals", organization: other_organization) }
+ let(:proposal) { create(:proposal, component:) }
+ let(:proposal_other) { create(:proposal, component: component_other) }
+ let(:moderation) { create(:moderation, reportable: proposal) }
+ let(:moderation_other) { create(:moderation, reportable: proposal_other) }
+ let(:since) { 1.day.ago }
+
+ let(:normal_spams) do
+ Decidim::Report
+ .joins(:moderation)
+ .where(reason: "spam")
+ .where("decidim_reports.created_at >= ?", since)
+ .select { |r| r.moderation.participatory_space.organization == organization }
+ .count
+ end
+
+ let(:user_spams) do
+ Decidim::UserReport
+ .joins(:user)
+ .where(reason: "spam")
+ .where("decidim_user_reports.created_at >= ?", since)
+ .where(decidim_users: { decidim_organization_id: organization.id })
+ .count
+ end
+
+ let(:total_spams) { normal_spams + user_spams }
+
+ before do
+ create_list(:report, 2, moderation:, reason: "spam", created_at: Time.zone.now)
+ create_list(:user_report, 1, reason: "spam", user:, created_at: Time.zone.now)
+
+ # Older spams
+ create_list(:report, 1, moderation:, reason: "spam", created_at: 5.days.ago)
+ create_list(:user_report, 1, reason: "spam", user:, created_at: 5.days.ago)
+
+ # Other organisation spams
+ create_list(:report, 4, moderation: moderation_other, reason: "spam", created_at: Time.zone.now)
+ create_list(:user_report, 3, reason: "spam", user: user_other, created_at: Time.zone.now)
+ end
+
+ shared_examples "the spam digest email" do |frequency, expected_text|
+ let(:extra) { { spam_count: total_spams, frequency:, force_email: true } }
+
+ it "generates and sends the digest email successfully with the right number of spam" do
+ mail = nil
+
+ expect do
+ mail = Decidim::NotificationMailer
+ .event_received(
+ "decidim.events.ai.spam_detection.spam_digest_event",
+ "Decidim::Ai::SpamDetection::SpamDigestEvent",
+ organization,
+ user,
+ "follower",
+ extra
+ )
+ .deliver_now
+ end.not_to raise_error
+
+ expect(mail.to).to include("admin@example.org")
+ expect(mail.subject).to match(/spam/i)
+ expect(mail.body.encoded).to include(total_spams.to_s)
+ expect(mail.body.encoded).to include("View detected spams")
+ expect(mail.text_part).to be_present
+ expect(mail.text_part.decoded).to include(expected_text)
+ expect(mail.html_part.decoded).to include(expected_text) if mail.html_part.present?
+ end
+ end
+
+ describe "Spam_count logic" do
+ it "counts recent spam reports for the correct organization" do
+ expect(normal_spams).to eq(2)
+ expect(user_spams).to eq(1)
+ expect(total_spams).to eq(3)
+ end
+ end
+
+ context "when frequency is daily" do
+ it_behaves_like "the spam digest email", :daily, "today"
+ end
+
+ context "when frequency is weekly" do
+ it_behaves_like "the spam digest email", :weekly, "this week"
+ end
+ end
+ end
+ end
+end
diff --git a/spec/jobs/find_and_update_descendants_job_spec.rb b/spec/jobs/find_and_update_descendants_job_spec.rb
new file mode 100644
index 0000000000..9d2648f92d
--- /dev/null
+++ b/spec/jobs/find_and_update_descendants_job_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+module Decidim
+ describe FindAndUpdateDescendantsJob do
+ subject { described_class }
+
+ let!(:participatory_process) { create(:participatory_process) }
+ let!(:proposal_component) { create(:proposal_component, participatory_space: participatory_process) }
+ let!(:proposal) { create(:proposal, :official, component: proposal_component) }
+
+ describe "queue" do
+ it "is queued to events" do
+ expect(subject.queue_name).to eq "default"
+ end
+ end
+
+ describe "#perform" do
+ it "enqueues a job with perform_later" do
+ expect do
+ Decidim::FindAndUpdateDescendantsJob.perform_later(participatory_process)
+ end.to have_enqueued_job(Decidim::FindAndUpdateDescendantsJob).with(participatory_process)
+ end
+
+ it "calls process_element_and_descendants private method" do
+ job = described_class.new
+ allow(job).to receive(:process_element_and_descendants)
+
+ job.perform(participatory_process)
+ expect(job).to have_received(:process_element_and_descendants).with(participatory_process)
+ end
+ end
+ end
+end
diff --git a/spec/permissions/decidim/initiatives/permissions_spec.rb b/spec/permissions/decidim/initiatives/permissions_spec.rb
index d347598104..3c2a26620c 100644
--- a/spec/permissions/decidim/initiatives/permissions_spec.rb
+++ b/spec/permissions/decidim/initiatives/permissions_spec.rb
@@ -206,90 +206,28 @@
end
context "when creation is enabled" do
- # Explicitly set available_authorizations to ensure the organization HAS authorizations configured
- # This tests the standard behavior where users must be verified when authorizations exist
- let(:organization) { create(:organization, available_authorizations: %w(dummy_authorization_handler)) }
-
before do
allow(Decidim::Initiatives)
.to receive(:creation_enabled)
.and_return(true)
end
- it { is_expected.to be false }
-
- context "when authorizations are not required" do
- before do
- allow(Decidim::Initiatives)
- .to receive(:do_not_require_authorization)
- .and_return(true)
- end
-
- it { is_expected.to be true }
- end
-
- context "when user is authorized" do
- before do
- create(:authorization, :granted, user:)
- end
-
- it { is_expected.to be true }
- end
-
- context "when user belongs to a verified user group" do
- before do
- create(:user_group, :verified, users: [user], organization: user.organization)
- end
-
- it { is_expected.to be true }
- end
-
- context "when the initiative type has permissions to create" do
- before do
- initiative.type.create_resource_permission(
- permissions: {
- "create" => {
- "authorization_handlers" => {
- "dummy_authorization_handler" => { "options" => {} },
- "another_dummy_authorization_handler" => { "options" => {} }
- }
- }
- }
- )
- end
-
- # User lacks required authorizations AND organization has authorizations configured
- # Standard behavior: creation should be denied
- context "when user is not verified" do
- it { is_expected.to be false }
- end
-
- context "when user is fully verified" do
- before do
- create(:authorization, name: "dummy_authorization_handler", user:, granted_at: 2.seconds.ago)
- create(:authorization, name: "another_dummy_authorization_handler", user:, granted_at: 2.seconds.ago)
- end
-
- it { is_expected.to be true }
- end
- end
-
# When the org has NO authorizations configured,
# creation should be allowed regardless of user verification status
# This bypasses all authorization checks when available_authorizations is empty
- context "when organization has no available authorizations" do
+ context "and there is no authorizations on organization" do
let(:organization) { create(:organization, available_authorizations: []) }
# Base case: unverified user with no authorizations required
# Extension allows this because no_authorizations_available is true
- context "when user is not verified" do
+ context "and user is not verified" do
it "allows creation because no authorizations are configured" do
expect(subject).to be true
end
end
- # Verified user with no authorizations required - should still work
- context "when user is verified" do
+ # Verified user - should still work
+ context "and user is verified" do
before do
create(:authorization, :granted, user:)
end
@@ -299,8 +237,19 @@
end
end
+ # User belongs to a verified group - should still work
+ context "and user belongs to a verified user group" do
+ before do
+ create(:user_group, :verified, users: [user], organization: user.organization)
+ end
+
+ it "allows creation" do
+ expect(subject).to be true
+ end
+ end
+
# Ensure do_not_require_authorization flag still works with no authorizations available
- context "when authorizations are not required" do
+ context "and authorizations are not required" do
before do
allow(Decidim::Initiatives)
.to receive(:do_not_require_authorization)
@@ -315,7 +264,7 @@
# CRITICAL TEST: Initiative type requires specific authorization BUT organization has none configured
# Extend should bypass the initiative type's authorization requirements
# because no_authorizations_available takes precedence
- context "when the initiative type has permissions to create" do
+ context "and the initiative type has permission on create" do
before do
initiative.type.create_resource_permission(
permissions: {
@@ -330,15 +279,105 @@
# KEY BEHAVIOR: Even though initiative type requires dummy_authorization_handler,
# creation is allowed because the organization has no authorizations configured
- context "when user is not verified" do
+ context "and user is not verified" do
it "allows creation because no authorizations are available in the organization" do
expect(subject).to be true
end
end
# Verified user should also be allowed
- context "when user is verified" do
+ context "and user is verified" do
+ before do
+ create(:authorization, name: "dummy_authorization_handler", user:, granted_at: 2.seconds.ago)
+ end
+
+ it "allows creation" do
+ expect(subject).to be true
+ end
+ end
+ end
+ end
+
+ # When the org HAS authorization configured, but there is NO permission on initiative_type
+ # creation should be allowed regardless of user verification status
+ context "and there is authorization on organization" do
+ # Explicitly set available_authorizations to ensure the organization HAS authorizations configured
+ let(:organization) { create(:organization, available_authorizations: %w(dummy_authorization_handler)) }
+
+ # NO permission on initiative_type
+ # Extension allows this because no_create_permission_on_initiative_type? will be true
+ context "and there is no permission on initiative_type" do
+ it { is_expected.to be true }
+ end
+
+ # Permission on initiative_type on vote but NOT on create
+ # Extension allows this because no_create_permission_on_initiative_type? will be true
+ context "and there is a permission on vote on initiative_type" do
+ before do
+ initiative.type.create_resource_permission(
+ permissions: {
+ "vote" => {
+ "authorization_handlers" => {
+ "dummy_authorization_handler" => { "options" => {} },
+ "another_dummy_authorization_handler" => { "options" => {} }
+ }
+ }
+ }
+ )
+ end
+
+ it { is_expected.to be true }
+ end
+
+ # This tests the behavior where users must be verified when authorizations exist on organization
+ # and there are permissions on create on initiative_type
+ context "and there are permissions on create on initiative_type" do
+ before do
+ initiative.type.create_resource_permission(
+ permissions: {
+ "create" => {
+ "authorization_handlers" => {
+ "dummy_authorization_handler" => { "options" => {} },
+ "another_dummy_authorization_handler" => { "options" => {} }
+ }
+ }
+ }
+ )
+ end
+
+ # Unverified user should not be allowed
+ context "and user is not verified" do
+ it "doesn't allow creation" do
+ expect(subject).to be false
+ end
+ end
+
+ # User belonging to a verified group but not authorized should not be allowed
+ context "and user belongs to a verified user group but is not authorized" do
+ before do
+ create(:user_group, :verified, users: [user], organization: user.organization)
+ end
+
+ it "does not allow creation" do
+ expect(subject).to be false
+ end
+ end
+
+ # Verified user should be allowed
+ context "and user is verified" do
+ before do
+ create(:authorization, name: "dummy_authorization_handler", user:, granted_at: 2.seconds.ago)
+ end
+
+ it "allows creation" do
+ expect(subject).to be true
+ end
+ end
+
+ # User belonging to a verified group and authorized should be allowed
+ context "and user belongs to a verified user group and is authorized" do
before do
+ create(:user_group, :verified, users: [user], organization: user.organization)
create(:authorization, name: "dummy_authorization_handler", user:, granted_at: 2.seconds.ago)
end
diff --git a/spec/system/filter_assembly_spec.rb b/spec/system/filter_assembly_spec.rb
new file mode 100644
index 0000000000..30eb3acf79
--- /dev/null
+++ b/spec/system/filter_assembly_spec.rb
@@ -0,0 +1,155 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+describe "Filter Assemblies" do
+ let(:organization) { create(:organization) }
+
+ before do
+ switch_to_host(organization.host)
+ end
+
+ context "when filtering parent assemblies by assembly_type" do
+ let!(:assemblies) { create_list(:assembly, 3, :with_type, organization:) }
+
+ it "filters by All types" do
+ visit decidim_assemblies.assemblies_path
+
+ within "#dropdown-menu-filters div.filter-container", text: "Type" do
+ check "All"
+ end
+ within "#assemblies-grid" do
+ expect(page).to have_css(".card__grid", count: 3)
+ end
+ end
+
+ 3.times do |i|
+ it "filters by Government type" do
+ visit decidim_assemblies.assemblies_path
+
+ assembly = assemblies[i]
+ within "#dropdown-menu-filters div.filter-container", text: "Type" do
+ check translated(assembly.assembly_type.title)
+ end
+ within "#assemblies-grid" do
+ expect(page).to have_css(".card__grid", count: 1)
+ expect(page).to have_content(translated(assembly.title))
+ end
+ end
+ end
+
+ it "filters by multiple types" do
+ visit decidim_assemblies.assemblies_path
+
+ within "#dropdown-menu-filters div.filter-container", text: "Type" do
+ check translated(assemblies[0].assembly_type.title)
+ check translated(assemblies[1].assembly_type.title)
+ end
+ within "#assemblies-grid" do
+ expect(page).to have_css(".card__grid", count: 2)
+ expect(page).to have_content(translated(assemblies[0].title))
+ expect(page).to have_content(translated(assemblies[1].title))
+ expect(page).to have_no_content(translated(assemblies[2].title))
+ end
+ end
+ end
+
+ context "when no assemblies types present" do
+ let!(:assemblies) { create_list(:assembly, 3, organization:) }
+
+ before do
+ visit decidim_assemblies.assemblies_path
+ end
+
+ it "does not show the assemblies types filter" do
+ within("#dropdown-menu-filters") do
+ expect(page).to have_no_css("#dropdown-menu-filters div.filter-container", text: "Type")
+ end
+ end
+ end
+
+ context "when filtering parent assemblies by scope" do
+ let!(:scope) { create(:scope, organization:) }
+ let!(:assembly_with_scope) { create(:assembly, scope:, organization:) }
+ let!(:assembly_without_scope) { create(:assembly, organization:) }
+
+ context "and choosing a scope" do
+ before do
+ visit decidim_assemblies.assemblies_path(filter: { with_any_scope: [scope.id] })
+ end
+
+ it "lists all processes belonging to that scope" do
+ within "#assemblies-grid" do
+ expect(page).to have_content(translated(assembly_with_scope.title))
+ expect(page).to have_no_content(translated(assembly_without_scope.title))
+ end
+ end
+ end
+ end
+
+ context "when filtering parent assemblies by area" do
+ let!(:area) { create(:area, organization:) }
+ let!(:assembly_with_area) { create(:assembly, area:, organization:) }
+ let!(:assembly_without_area) { create(:assembly, organization:) }
+
+ context "and choosing an area" do
+ before do
+ visit decidim_assemblies.assemblies_path
+
+ within "#dropdown-menu-filters div.filter-container", text: "Area" do
+ check translated(area.name)
+ sleep 3
+ end
+ end
+
+ it "lists only processes belonging to that area" do
+ within "#assemblies-grid" do
+ expect(page).to have_content(translated(assembly_with_area.title))
+ expect(page).to have_no_content(translated(assembly_without_area.title))
+ end
+ end
+ end
+
+ context "when there are more than two areas" do
+ let!(:other_area) { create(:area, organization:) }
+ let!(:other_area_without_assemblies) { create(:area, organization:) }
+ let!(:assembly_with_other_area) { create(:assembly, area: other_area, organization:) }
+
+ context "and choosing an area" do
+ before do
+ visit decidim_assemblies.assemblies_path
+
+ within "#dropdown-menu-filters div.filter-container", text: "Area" do
+ check translated(area.name)
+ end
+ end
+
+ it "lists all processes belonging to that area" do
+ within "#assemblies-grid" do
+ expect(page).to have_content(translated(assembly_with_area.title))
+ expect(page).to have_no_content(translated(assembly_without_area.title))
+ end
+ end
+ end
+
+ context "and choosing two areas with assemblies" do
+ before do
+ visit decidim_assemblies.assemblies_path
+
+ within "#dropdown-menu-filters div.filter-container", text: "Area" do
+ check translated(area.name)
+ check translated(other_area.name)
+ end
+ end
+
+ it "lists all processes belonging to both areas" do
+ within "#assemblies-grid" do
+ expect(page).to have_content(translated(assembly_with_area.title))
+ expect(page).to have_content(translated(assembly_with_other_area.title))
+ expect(page).to have_no_content(translated(assembly_without_area.title))
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/system/proposals_index_spec.rb b/spec/system/proposals_index_spec.rb
new file mode 100644
index 0000000000..c872f9c6c4
--- /dev/null
+++ b/spec/system/proposals_index_spec.rb
@@ -0,0 +1,436 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+describe "Proposals" do
+ include ActionView::Helpers::TextHelper
+ include_context "with a component"
+ let(:manifest_name) { "proposals" }
+
+ let!(:category) { create(:category, participatory_space: participatory_process) }
+ let!(:scope) { create(:scope, organization:) }
+ let!(:user) { create(:user, :confirmed, organization:) }
+ let(:scoped_participatory_process) { create(:participatory_process, :with_steps, organization:, scope:) }
+
+ let(:address) { "Some address" }
+ let(:latitude) { 40.1234 }
+ let(:longitude) { 2.1234 }
+
+ let(:proposal_title) { translated(proposal.title) }
+
+ before do
+ stub_geocoding(address, [latitude, longitude])
+ end
+
+ matcher :have_author do |name|
+ match { |node| node.has_selector?("[data-author]", text: name) }
+ match_when_negated { |node| node.has_no_selector?("[data-author]", text: name) }
+ end
+
+ matcher :have_creation_date do |date|
+ match { |node| node.has_selector?(".author-data__extra", text: date) }
+ match_when_negated { |node| node.has_no_selector?(".author-data__extra", text: date) }
+ end
+
+ context "when listing proposals in a participatory process" do
+ shared_examples_for "a random proposal ordering" do
+ let!(:lucky_proposal) { create(:proposal, component:) }
+ let!(:unlucky_proposal) { create(:proposal, component:) }
+ let!(:lucky_proposal_title) { translated(lucky_proposal.title) }
+ let!(:unlucky_proposal_title) { translated(unlucky_proposal.title) }
+
+ it "lists the proposals ordered randomly by default" do
+ visit_component
+
+ expect(page).to have_css("a", text: "Random")
+ expect(page).to have_css("[id^='proposals__proposal']", count: 2)
+ expect(page).to have_css("[id^='proposals__proposal']", text: lucky_proposal_title)
+ expect(page).to have_css("[id^='proposals__proposal']", text: unlucky_proposal_title)
+ expect(page).to have_author(lucky_proposal.creator_author.name)
+ end
+ end
+
+ context "when maps are enabled" do
+ let(:component) { create(:proposal_component, :with_geocoding_enabled, participatory_space: participatory_process) }
+
+ let!(:author_proposals) { create_list(:proposal, 2, :participant_author, :published, component:) }
+ let!(:group_proposals) { create_list(:proposal, 2, :user_group_author, :published, component:) }
+ let!(:official_proposals) { create_list(:proposal, 2, :official, :published, component:) }
+
+ # We are providing a list of coordinates to make sure the points are scattered all over the map
+ # otherwise, there is a chance that markers can be clustered, which may result in a flaky spec.
+ before do
+ coordinates = [
+ [-95.501705376541395, 95.10059236654689],
+ [-95.501705376541395, -95.10059236654689],
+ [95.10059236654689, -95.501705376541395],
+ [95.10059236654689, 95.10059236654689],
+ [142.15275006889419, -33.33377235135252],
+ [33.33377235135252, -142.15275006889419],
+ [-33.33377235135252, 142.15275006889419],
+ [-142.15275006889419, 33.33377235135252],
+ [-55.28745034772282, -35.587843900166945]
+ ]
+ Decidim::Proposals::Proposal.where(component:).geocoded.each_with_index do |proposal, index|
+ proposal.update!(latitude: coordinates[index][0], longitude: coordinates[index][1]) if coordinates[index]
+ end
+
+ visit_component
+ end
+
+ it "shows markers for selected proposals" do
+ expect(page).to have_css(".leaflet-marker-icon", count: 5)
+ within "#panel-dropdown-menu-origin" do
+ click_filter_item "Official"
+ end
+ # make the page reload
+ visit "#{current_path}?#{URI.parse(current_url).query}"
+ expect(page).to have_css(".leaflet-marker-icon", count: 2, wait: 10)
+ end
+ end
+
+ it_behaves_like "accessible page" do
+ before { visit_component }
+ end
+
+ it "lists all the proposals" do
+ create(:proposal_component,
+ manifest:,
+ participatory_space: participatory_process)
+
+ create_list(:proposal, 3, component:)
+
+ visit_component
+ expect(page).to have_css("[id^='proposals__proposal']", count: 3)
+ end
+
+ describe "editable content" do
+ it_behaves_like "editable content for admins" do
+ let(:target_path) { main_component_path(component) }
+ end
+ end
+
+ context "when comments have been moderated" do
+ let(:proposal) { create(:proposal, component:) }
+ let(:author) { create(:user, :confirmed, organization: component.organization) }
+ let!(:comments) { create_list(:comment, 3, commentable: proposal) }
+ let!(:moderation) { create(:moderation, reportable: comments.first, hidden_at: 1.day.ago) }
+
+ it "displays unhidden comments count" do
+ visit_component
+
+ within("#proposals__proposal_#{proposal.id}") do
+ within(".card__list-metadata") do
+ expect(page).to have_css("div", text: 2)
+ end
+ end
+ end
+ end
+
+ describe "default ordering" do
+ it_behaves_like "a random proposal ordering"
+ end
+
+ context "when voting phase is over" do
+ let!(:component) do
+ create(:proposal_component,
+ :with_votes_blocked,
+ manifest:,
+ participatory_space: participatory_process)
+ end
+
+ let!(:most_voted_proposal) do
+ proposal = create(:proposal, component:)
+ create_list(:proposal_vote, 3, proposal:)
+ proposal
+ end
+ let!(:most_voted_proposal_title) { translated(most_voted_proposal.title) }
+
+ let!(:less_voted_proposal) { create(:proposal, component:) }
+ let!(:less_voted_proposal_title) { translated(less_voted_proposal.title) }
+
+ before { visit_component }
+
+ it "lists the proposals ordered by votes by default" do
+ expect(page).to have_css("a", text: "Most voted")
+ expect(page).to have_css("[id^='proposals__proposal']:first-child", text: most_voted_proposal_title)
+ expect(page).to have_css("[id^='proposals__proposal']:last-child", text: less_voted_proposal_title)
+ end
+ end
+
+ context "when voting is disabled" do
+ let!(:component) do
+ create(:proposal_component,
+ :with_votes_disabled,
+ :with_proposal_limit,
+ manifest:,
+ participatory_space: participatory_process)
+ end
+
+ describe "order" do
+ it_behaves_like "a random proposal ordering"
+ end
+
+ it "shows only links to full proposals" do
+ create_list(:proposal, 2, component:)
+
+ visit_component
+
+ expect(page).to have_css("[id^='proposals__proposal']", count: 2)
+ end
+ end
+
+ context "when there are a lot of proposals" do
+ before do
+ create_list(:proposal, Decidim::Paginable::OPTIONS.first + 5, component:)
+ end
+
+ it "paginates them and keeps the same order" do
+ visit_component
+ # 25 proposals on page one
+ expect(page).to have_css("[id^='proposals__proposal']", count: Decidim::Paginable::OPTIONS.first)
+ texts = page.all("[id^='proposals__proposal']").map(&:text)
+ click_on "Next"
+
+ expect(page).to have_css("[data-pages] [data-page][aria-current='page']", text: "2")
+ # 5 proposals on page two
+ expect(page).to have_css("[id^='proposals__proposal']", count: 5)
+ click_on "Prev"
+ # check elements on page one are still the same
+ expect(page.all("[id^='proposals__proposal']").map(&:text)).to eq(texts)
+ end
+ end
+
+ context "when there is a lot of proposals and using a filter" do
+ before do
+ create_list(:proposal, Decidim::Paginable::OPTIONS.first - 5, :evaluating, component:)
+ create_list(:proposal, Decidim::Paginable::OPTIONS.first - 5, :accepted, component:)
+ create_list(:proposal, 10, :not_answered, component:)
+ end
+
+ it "paginates proposals and keeps the same order in pages" do
+ visit_component
+
+ expect(page).to have_css("[id^='proposals__proposal']", count: Decidim::Paginable::OPTIONS.first)
+ uncheck "Not answered"
+ sleep 5
+ texts = page.all("[id^='proposals__proposal']").map(&:text)
+ click_on "Next"
+ expect(page).to have_css("[data-pages] [data-page][aria-current='page']", text: "2")
+ # we have 40 proposals without the not_answered, so 15 on page 2
+ expect(page).to have_css("[id^='proposals__proposal']", count: 15)
+ click_on "Prev"
+ # check elements on page one are still in the same order
+ expect(page.all("[id^='proposals__proposal']").map(&:text)).to eq(texts)
+ end
+ end
+
+ shared_examples "ordering proposals by selected option" do |selected_option|
+ let(:first_proposal_title) { translated(first_proposal.title) }
+ let(:last_proposal_title) { translated(last_proposal.title) }
+ before do
+ visit_component
+ within ".order-by" do
+ expect(page).to have_css("div.order-by a", text: "Random")
+ page.find("a", text: "Random").click
+ click_on(selected_option)
+ end
+ end
+
+ it "lists the proposals ordered by selected option" do
+ expect(page).to have_css("[id^='proposals__proposal']:first-child", text: first_proposal_title)
+ expect(page).to have_css("[id^='proposals__proposal']:last-child", text: last_proposal_title)
+ end
+ end
+
+ context "when ordering by 'most_voted'" do
+ let!(:component) do
+ create(:proposal_component,
+ :with_votes_enabled,
+ manifest:,
+ participatory_space: participatory_process)
+ end
+ let!(:most_voted_proposal) { create(:proposal, component:) }
+ let!(:votes) { create_list(:proposal_vote, 3, proposal: most_voted_proposal) }
+ let!(:less_voted_proposal) { create(:proposal, component:) }
+
+ it_behaves_like "ordering proposals by selected option", "Most voted" do
+ let(:first_proposal) { most_voted_proposal }
+ let(:last_proposal) { less_voted_proposal }
+ end
+ end
+
+ context "when ordering by 'recent'" do
+ let!(:older_proposal) { create(:proposal, component:, created_at: 1.month.ago) }
+ let!(:recent_proposal) { create(:proposal, component:) }
+
+ it_behaves_like "ordering proposals by selected option", "Recent" do
+ let(:first_proposal) { recent_proposal }
+ let(:last_proposal) { older_proposal }
+ end
+ end
+
+ context "when ordering by 'most_followed'" do
+ let!(:most_followed_proposal) { create(:proposal, component:) }
+ let!(:follows) { create_list(:follow, 3, followable: most_followed_proposal) }
+ let!(:less_followed_proposal) { create(:proposal, component:) }
+
+ it_behaves_like "ordering proposals by selected option", "Most followed" do
+ let(:first_proposal) { most_followed_proposal }
+ let(:last_proposal) { less_followed_proposal }
+ end
+ end
+
+ context "when ordering by 'most_commented'" do
+ let!(:most_commented_proposal) { create(:proposal, component:, created_at: 1.month.ago) }
+ let!(:comments) { create_list(:comment, 3, commentable: most_commented_proposal) }
+ let!(:less_commented_proposal) { create(:proposal, component:) }
+
+ it_behaves_like "ordering proposals by selected option", "Most commented" do
+ let(:first_proposal) { most_commented_proposal }
+ let(:last_proposal) { less_commented_proposal }
+ end
+ end
+
+ context "when ordering by 'most_endorsed'" do
+ let!(:most_endorsed_proposal) { create(:proposal, component:, created_at: 1.month.ago) }
+ let!(:endorsements) do
+ 3.times.collect do
+ create(:endorsement, resource: most_endorsed_proposal, author: build(:user, organization:))
+ end
+ end
+ let!(:less_endorsed_proposal) { create(:proposal, component:) }
+
+ it_behaves_like "ordering proposals by selected option", "Most endorsed" do
+ let(:first_proposal) { most_endorsed_proposal }
+ let(:last_proposal) { less_endorsed_proposal }
+ end
+ end
+
+ context "when ordering by 'with_more_authors'" do
+ let!(:most_authored_proposal) { create(:proposal, component:, created_at: 1.month.ago) }
+ let!(:coauthorships) { create_list(:coauthorship, 3, coauthorable: most_authored_proposal) }
+ let!(:less_authored_proposal) { create(:proposal, component:) }
+
+ it_behaves_like "ordering proposals by selected option", "With more authors" do
+ let(:first_proposal) { most_authored_proposal }
+ let(:last_proposal) { less_authored_proposal }
+ end
+ end
+
+ context "when searching proposals" do
+ let!(:proposals) do
+ [
+ create(:proposal, title: "Lorem ipsum dolor sit amet", component:),
+ create(:proposal, title: "Donec vitae convallis augue", component:),
+ create(:proposal, title: "Pellentesque habitant morbi", component:)
+ ]
+ end
+
+ before do
+ visit_component
+ end
+
+ it "finds the correct proposal" do
+ within "form.new_filter" do
+ find("input[name='filter[search_text_cont]']", match: :first).set("lorem")
+ find("*[type=submit]").click
+ end
+
+ expect(page).to have_content("Lorem ipsum dolor sit amet")
+ end
+ end
+
+ context "when paginating" do
+ let!(:collection) { create_list(:proposal, collection_size, component:) }
+ let!(:resource_selector) { "[id^='proposals__proposal']" }
+
+ it_behaves_like "a paginated resource"
+ end
+
+ context "when component is not commentable" do
+ let!(:resources) { create_list(:proposal, 3, component:) }
+
+ it_behaves_like "an uncommentable component"
+ end
+ end
+
+ describe "viewing mode for proposals" do
+ let!(:proposal) { create(:proposal, :evaluating, component:) }
+
+ context "when participants interact with the proposal view" do
+ it "provides an option for toggling between list and grid views" do
+ visit_component
+ expect(page).to have_css("use[href*='layout-grid-fill']")
+ expect(page).to have_css("use[href*='list-check']")
+ end
+ end
+
+ context "when participants are viewing a grid of proposals" do
+ it "shows a grid of proposals with images" do
+ visit_component
+
+ # Check that grid view is not the default
+ expect(page).to have_no_css(".card__grid-grid")
+
+ # Switch to grid view
+ find("a[href*='view_mode=grid']").click
+ expect(page).to have_css(".card__grid-grid")
+ expect(page).to have_css(".card__grid-img img, .card__grid-img svg")
+
+ # Revisit the component and check session storage
+ visit_component
+ expect(page).to have_css(".card__grid-grid")
+ end
+ end
+
+ context "when participants are filtering proposals" do
+ let!(:evaluating_proposals) { create_list(:proposal, 3, :evaluating, component:) }
+ let!(:accepted_proposals) { create_list(:proposal, 5, :accepted, component:) }
+
+ it "filters the proposals and keeps the filter when changing the view mode" do
+ visit_component
+ uncheck "Evaluating"
+
+ expect(page).to have_css("[id^='proposals__proposal']", count: 5)
+
+ find("a[href*='view_mode=grid']").click
+
+ expect(page).to have_css(".card__grid-img svg#ri-proposal-placeholder-card-g", count: 5)
+ expect(page).to have_css("[id^='proposals__proposal']", count: 5)
+ end
+ end
+
+ context "when participants are viewing a list of proposals" do
+ it "shows a list of proposals" do
+ visit_component
+ find("a[href*='view_mode=list']").click
+ expect(page).to have_css(".card__list-list")
+ end
+ end
+
+ context "when proposals does not have attachments" do
+ it "shows a placeholder image" do
+ visit_component
+ find("a[href*='view_mode=grid']").click
+ expect(page).to have_css(".card__grid-img svg#ri-proposal-placeholder-card-g")
+ end
+ end
+
+ context "when proposals have attachments" do
+ let!(:proposal) { create(:proposal, component:) }
+ let!(:attachment) { create(:attachment, attached_to: proposal) }
+
+ before do
+ component.update!(settings: { attachments_allowed: true })
+ end
+
+ it "shows the proposal image" do
+ visit_component
+
+ expect(page).to have_no_css(".card__grid-img img[src*='proposal_image_placeholder.svg']")
+ expect(page).to have_css(".card__grid-img img")
+ end
+ end
+ end
+end
diff --git a/spec/system/search_process_and_descendants_spec.rb b/spec/system/search_process_and_descendants_spec.rb
new file mode 100644
index 0000000000..0b63f5b630
--- /dev/null
+++ b/spec/system/search_process_and_descendants_spec.rb
@@ -0,0 +1,110 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.configure do |config|
+ config.before(:each, type: :system) do
+ # Forcer les jobs en mode inline pour les tests system
+ ActiveJob::Base.queue_adapter = :inline
+ end
+
+ config.after(:each, type: :system) do
+ # Réinitialiser si nécessaire
+ ActiveJob::Base.queue_adapter = :test
+ end
+end
+
+describe "search process and descendants" do
+ include ActionView::Helpers::SanitizeHelper
+
+ let(:organization) { create(:organization) }
+ let!(:user) { create(:user, :admin, :confirmed, organization:) }
+ let(:participatory_process) { create(:participatory_process, :published, organization:) }
+ let(:component) { create(:meeting_component, participatory_space: participatory_process) }
+ let!(:meetings) { create_list(:meeting, 3, :published, component:) }
+ let!(:comment) { create(:comment, commentable: meetings.first) }
+ let!(:term) { strip_tags(translated(meetings.first.title)) }
+ let!(:term_two) { strip_tags(translated(comment.body)) }
+
+ context "when admin unpublishes a process with descendants" do
+ before do
+ switch_to_host(organization.host)
+ login_as user, scope: :user
+ visit decidim_admin_participatory_processes.edit_participatory_process_path(participatory_process)
+ click_on "Unpublish"
+ perform_enqueued_jobs if respond_to?(:perform_enqueued_jobs)
+ participatory_process.reload
+ expect(participatory_process.published_at).to be_nil
+ component.reload
+ meetings.each(&:reload)
+ comment.reload
+ end
+
+ it "descendants are not found by search" do
+ visit decidim.root_path
+ expect(page).to have_css("#form-search_topbar")
+
+ # find meeting
+ within "#form-search_topbar" do
+ fill_in "term", with: term
+ find("input#input-search").native.send_keys :enter
+ end
+
+ expect(page).to have_current_path decidim.search_path, ignore_query: true
+ expect(page).to have_content("0 Results for the search")
+ expect(page).to have_css(".filter-search.filter-container")
+
+ # find comment
+ within "#form-search_topbar" do
+ fill_in "term", with: term_two
+ find("input#input-search").native.send_keys :enter
+ end
+
+ expect(page).to have_current_path decidim.search_path, ignore_query: true
+ expect(page).to have_content("0 Results for the search")
+ expect(page).to have_css(".filter-search.filter-container")
+ end
+ end
+
+ context "when admin publishes a process with descendants" do
+ let!(:participatory_process) { create(:participatory_process, :unpublished, organization:) }
+
+ before do
+ switch_to_host(organization.host)
+ login_as user, scope: :user
+ visit decidim_admin_participatory_processes.edit_participatory_process_path(participatory_process)
+ click_on "Publish"
+ perform_enqueued_jobs if respond_to?(:perform_enqueued_jobs)
+ participatory_process.reload
+ expect(participatory_process.published_at).not_to be_nil
+ component.reload
+ meetings.each(&:reload)
+ comment.reload
+ end
+
+ it "descendants are found by search" do
+ visit decidim.root_path
+ expect(page).to have_css("#form-search_topbar")
+
+ # find meeting
+ within "#form-search_topbar" do
+ fill_in "term", with: term
+ find("input#input-search").native.send_keys :enter
+ end
+
+ expect(page).to have_current_path decidim.search_path, ignore_query: true
+ expect(page).to have_content(/results for the search: "#{term}"/i)
+ expect(page).to have_css(".filter-search.filter-container")
+
+ # find comment
+ within "#form-search_topbar" do
+ fill_in "term", with: term_two
+ find("input#input-search").native.send_keys :enter
+ end
+
+ expect(page).to have_current_path decidim.search_path, ignore_query: true
+ expect(page).to have_content(/results for the search: "#{term_two}"/i)
+ expect(page).to have_css(".filter-search.filter-container")
+ end
+ end
+end