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: | + + 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: | + + 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