Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 0 additions & 27 deletions .github/PULL_REQUEST_TEMPLATE.md

This file was deleted.

10 changes: 5 additions & 5 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
124 changes: 124 additions & 0 deletions app/events/decidim/ai/spam_detection/spam_digest_event.rb
Original file line number Diff line number Diff line change
@@ -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
96 changes: 96 additions & 0 deletions app/jobs/decidim/ai/spam_detection/spam_digest_generator_job.rb
Original file line number Diff line number Diff line change
@@ -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
87 changes: 87 additions & 0 deletions app/jobs/decidim/find_and_update_descendants_job.rb
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion app/packs/entrypoints/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@

import "src/decidim/admin/reorder_scopes";
import "src/decidim/admin/reorder_proposal_states";
import "src/decidim/surveys/sorted_answers_fixes";
import "src/decidim/surveys/sorted_answers_fixes";
import "src/decidim/check_boxes_tree";
Loading
Loading