From 2763d395e76e942913c250e00d2caaa07e6c7606 Mon Sep 17 00:00:00 2001 From: moustachu Date: Wed, 6 May 2026 12:59:07 +0200 Subject: [PATCH 01/24] [WIP] feat: reversibility tools --- Gemfile | 1 + Gemfile.lock | 3 + .../decidim/admin/content/tree_controller.rb | 72 ++++ .../entrypoints/decidim_admin_content_tree.js | 1 + .../decidim_admin_content_tree.scss | 1 + .../decidim_admin_content_treemap.js | 1 + .../src/decidim/admin/content/tree/index.js | 0 .../admin/content/tree/treemap/index.js | 135 ++++++++ .../content/tree/treemap/zoomable_treemap.js | 148 +++++++++ .../decidim/admin/content/_table.scss | 15 + .../decidim/content/metadata_generator.rb | 98 ++++++ .../decidim/content/tree_generator.rb | 309 ++++++++++++++++++ .../decidim/admin/content/tree/index.html.erb | 41 +++ .../tree/table/_component_name.html.erb | 12 + .../admin/content/tree/table/_row.html.erb | 52 +++ .../admin/content/tree/treemap.html.erb | 2 + .../admin/content/tree/treeview.html.erb | 6 + .../decidim/admin/content/tree.html.erb | 5 + config/initializers/icons.rb | 5 + config/locales/admin_content_tree/en.yml | 11 + config/routes.rb | 11 + package-lock.json | 8 +- package.json | 4 +- tsconfig.json | 7 +- 24 files changed, 941 insertions(+), 7 deletions(-) create mode 100644 app/controllers/decidim/admin/content/tree_controller.rb create mode 100644 app/packs/entrypoints/decidim_admin_content_tree.js create mode 100644 app/packs/entrypoints/decidim_admin_content_tree.scss create mode 100644 app/packs/entrypoints/decidim_admin_content_treemap.js create mode 100644 app/packs/src/decidim/admin/content/tree/index.js create mode 100644 app/packs/src/decidim/admin/content/tree/treemap/index.js create mode 100644 app/packs/src/decidim/admin/content/tree/treemap/zoomable_treemap.js create mode 100644 app/packs/stylesheets/decidim/admin/content/_table.scss create mode 100644 app/services/concerns/decidim/content/metadata_generator.rb create mode 100644 app/services/decidim/content/tree_generator.rb create mode 100644 app/views/decidim/admin/content/tree/index.html.erb create mode 100644 app/views/decidim/admin/content/tree/table/_component_name.html.erb create mode 100644 app/views/decidim/admin/content/tree/table/_row.html.erb create mode 100644 app/views/decidim/admin/content/tree/treemap.html.erb create mode 100644 app/views/decidim/admin/content/tree/treeview.html.erb create mode 100644 app/views/layouts/decidim/admin/content/tree.html.erb create mode 100644 config/initializers/icons.rb create mode 100644 config/locales/admin_content_tree/en.yml diff --git a/Gemfile b/Gemfile index f8ce437958..76ff283d8c 100644 --- a/Gemfile +++ b/Gemfile @@ -7,6 +7,7 @@ DECIDIM_TAG = "v0.29.5" gem "decidim", github: "decidim/decidim", tag: DECIDIM_TAG gem "decidim-conferences", github: "decidim/decidim", tag: DECIDIM_TAG +gem "decidim-design", github: "decidim/decidim", tag: DECIDIM_TAG gem "decidim-initiatives", github: "decidim/decidim", tag: DECIDIM_TAG gem "decidim-templates", github: "decidim/decidim", tag: DECIDIM_TAG diff --git a/Gemfile.lock b/Gemfile.lock index 516fc39470..ec379bddc2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -248,6 +248,8 @@ GIT decidim-debates (0.29.5) decidim-comments (= 0.29.5) decidim-core (= 0.29.5) + decidim-design (0.29.5) + decidim-core (= 0.29.5) decidim-dev (0.29.5) bullet (~> 7.1.6) byebug (~> 11.0) @@ -1143,6 +1145,7 @@ DEPENDENCIES decidim-conferences! decidim-dataspace! decidim-decidim_awesome! + decidim-design! decidim-dev! decidim-emitter! decidim-extra_user_fields! diff --git a/app/controllers/decidim/admin/content/tree_controller.rb b/app/controllers/decidim/admin/content/tree_controller.rb new file mode 100644 index 0000000000..0433e93672 --- /dev/null +++ b/app/controllers/decidim/admin/content/tree_controller.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module Decidim + module Admin + module Content + class TreeController < Decidim::Admin::ApplicationController + helper Decidim::IconHelper + + IDENT_TOKEN = " " + + before_action :default_permissions + before_action :relative_view_path + helper_method :content_tree + layout "decidim/admin/content/tree" + + def index; end + + def export + generator = Decidim::Content::TreeGenerator.new( + organization: current_organization, + include_object: false, + include_metadata: true, + include_score: true, + components_as_children: true + ) + csv_data = generator.to_csv + + respond_to do |format| + format.csv do + send_data csv_data.string, + filename: "content_tree_#{Time.zone.now.strftime("%Y%m%d_%H%M%S")}.csv", + type: "text/csv" + end + end + end + + def treemap + respond_to do |format| + format.html + format.json { render json: content_treemap } + end + end + + def treeview; end + + private + + def default_permissions + enforce_permission_to :update, :organization, organization: current_organization + end + + def relative_view_path + prepend_view_path "app/views/decidim/admin/content/tree" + end + + def content_tree + @content_tree ||= Decidim::Content::TreeGenerator.new(organization: current_organization).hash + end + + def content_treemap + Decidim::Content::TreeGenerator.new( + organization: current_organization, + include_object: false, + include_metadata: false, + include_score: true, + components_as_children: true + ).hash + end + end + end + end +end diff --git a/app/packs/entrypoints/decidim_admin_content_tree.js b/app/packs/entrypoints/decidim_admin_content_tree.js new file mode 100644 index 0000000000..8d51ed38f2 --- /dev/null +++ b/app/packs/entrypoints/decidim_admin_content_tree.js @@ -0,0 +1 @@ +import "src/decidim/admin/content/tree"; \ No newline at end of file diff --git a/app/packs/entrypoints/decidim_admin_content_tree.scss b/app/packs/entrypoints/decidim_admin_content_tree.scss new file mode 100644 index 0000000000..541aea7b76 --- /dev/null +++ b/app/packs/entrypoints/decidim_admin_content_tree.scss @@ -0,0 +1 @@ +@import "stylesheets/decidim/admin/content/table.scss"; \ No newline at end of file diff --git a/app/packs/entrypoints/decidim_admin_content_treemap.js b/app/packs/entrypoints/decidim_admin_content_treemap.js new file mode 100644 index 0000000000..b43918b65c --- /dev/null +++ b/app/packs/entrypoints/decidim_admin_content_treemap.js @@ -0,0 +1 @@ +import "src/decidim/admin/content/tree/treemap"; \ No newline at end of file diff --git a/app/packs/src/decidim/admin/content/tree/index.js b/app/packs/src/decidim/admin/content/tree/index.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/packs/src/decidim/admin/content/tree/treemap/index.js b/app/packs/src/decidim/admin/content/tree/treemap/index.js new file mode 100644 index 0000000000..b52738d92e --- /dev/null +++ b/app/packs/src/decidim/admin/content/tree/treemap/index.js @@ -0,0 +1,135 @@ +import * as d3 from "d3"; + +const container = document.getElementById("treemap-container"); + +if (container) { + d3.json(container.dataset.src).then(data => { + + // Specify the chart’s dimensions. + const width = 1280; + const height = 924; + + // This custom tiling function adapts the built-in binary tiling function + // for the appropriate aspect ratio when the treemap is zoomed-in. + function tile(node, x0, y0, x1, y1) { + d3.treemapBinary(node, 0, 0, width, height); + for (const child of node.children) { + child.x0 = x0 + child.x0 / width * (x1 - x0); + child.x1 = x0 + child.x1 / width * (x1 - x0); + child.y0 = y0 + child.y0 / height * (y1 - y0); + child.y1 = y0 + child.y1 / height * (y1 - y0); + } + } + + // Create the scales. + const x = d3.scaleLinear().rangeRound([0, width]); + const y = d3.scaleLinear().rangeRound([0, height]); + + // Formatting utilities. + const format = d3.format(",d"); + const name = d => d.ancestors().reverse().map(d => d.data.name).join("/"); + + // Create the SVG container. + const svg = d3.create("svg") + .attr("viewBox", [0.5, -30.5, width, height + 30]) + .attr("width", width) + .attr("height", height + 30) + .attr("style", "max-width: 100%; height: auto;") + .style("font", "10px sans-serif"); + + function render(group, root) { + const node = group + .selectAll("g") + .data(root.children.concat(root)) + .join("g"); + + node.filter(d => d === root ? d.parent : d.children) + .attr("cursor", "pointer") + .on("click", (event, d) => d === root ? zoomout(root) : zoomin(d)); + + node.append("title") + .text(d => `${name(d)}\n${format(d.value)}`); + + node.append("rect") + .attr("id", d => (d.leafUid = crypto.randomUUID("leaf")).id) + .attr("fill", d => d === root ? "#fff" : d.children ? "#ccc" : "#ddd") + .attr("stroke", "#fff"); + + node.append("clipPath") + .attr("id", d => (d.clipUid = crypto.randomUUID("clip")).id) + .append("use") + .attr("xlink:href", d => d.leafUid.href); + + node.append("text") + .attr("clip-path", d => d.clipUid) + .attr("font-weight", d => d === root ? "bold" : null) + .selectAll("tspan") + .data(d => (d === root ? name(d) : d.data.name).split(/(?=[A-Z][^A-Z])/g).concat(format(d.value))) + .join("tspan") + .attr("x", 3) + .attr("y", (d, i, nodes) => `${(i === nodes.length - 1) * 0.3 + 1.1 + i * 0.9}em`) + .attr("fill-opacity", (d, i, nodes) => i === nodes.length - 1 ? 0.7 : null) + .attr("font-weight", (d, i, nodes) => i === nodes.length - 1 ? "normal" : null) + .text(d => d); + + group.call(position, root); + } + + function position(group, root) { + group.selectAll("g") + .attr("transform", d => d === root ? `translate(0,-30)` : `translate(${x(d.x0)},${y(d.y0)})`) + .select("rect") + .attr("width", d => d === root ? width : x(d.x1) - x(d.x0)) + .attr("height", d => d === root ? 30 : y(d.y1) - y(d.y0)); + } + + // When zooming in, draw the new nodes on top, and fade them in. + function zoomin(d) { + const group0 = group.attr("pointer-events", "none"); + const group1 = group = svg.append("g").call(render, d); + + x.domain([d.x0, d.x1]); + y.domain([d.y0, d.y1]); + + svg.transition() + .duration(750) + .call(t => group0.transition(t).remove() + .call(position, d.parent)) + .call(t => group1.transition(t) + .attrTween("opacity", () => d3.interpolate(0, 1)) + .call(position, d)); + } + + // When zooming out, draw the old nodes on top, and fade them out. + function zoomout(d) { + const group0 = group.attr("pointer-events", "none"); + const group1 = group = svg.insert("g", "*").call(render, d.parent); + + x.domain([d.parent.x0, d.parent.x1]); + y.domain([d.parent.y0, d.parent.y1]); + + svg.transition() + .duration(750) + .call(t => group0.transition(t).remove() + .attrTween("opacity", () => d3.interpolate(1, 0)) + .call(position, d)) + .call(t => group1.transition(t) + .call(position, d.parent)); + } + + // Compute the layout. + const hierarchy = d3.hierarchy(data) + .sum(d => d.value) + .sort((a, b) => b.value - a.value); + const root = d3.treemap().tile(tile).size([width, height])(hierarchy); + + // Display the root. + let group = svg.append("g") + .call(render, root); + + // Append the SVG element to the container. + document.getElementById("treemap-container").appendChild(svg.node()); + }); +} + + diff --git a/app/packs/src/decidim/admin/content/tree/treemap/zoomable_treemap.js b/app/packs/src/decidim/admin/content/tree/treemap/zoomable_treemap.js new file mode 100644 index 0000000000..e3e0fc7a6e --- /dev/null +++ b/app/packs/src/decidim/admin/content/tree/treemap/zoomable_treemap.js @@ -0,0 +1,148 @@ +/* + +import ZoomableTreemap from "./zoomable_treemap"; + +document.addEventListener("DOMContentLoaded", () => { + const container = document.getElementById("treemap-container"); + if (container) { + new ZoomableTreemap(container.dataset.src, container); + } +}); + +*/ + + +import * as d3 from "d3"; + +// Specify the chart’s dimensions. +const width = 928; +const height = 924; + +// Create the scales. +const x = d3.scaleLinear().rangeRound([0, width]); +const y = d3.scaleLinear().rangeRound([0, height]); + +const format = d3.format(",d"); +const name = d => d.ancestors().reverse().map(d => d.data.name).join("/"); + +function position(group, root) { + group.selectAll("g") + .attr("transform", d => d === root ? `translate(0,-30)` : `translate(${x(d.x0)},${y(d.y0)})`) + .select("rect") + .attr("width", d => d === root ? width : x(d.x1) - x(d.x0)) + .attr("height", d => d === root ? 30 : y(d.y1) - y(d.y0)); +} + +export default class ZoomableTreemap { + constructor(src, container) { + this.src = src; + this.container = container; + + // Create the SVG container. + this.svg = d3.create("svg") + .attr("viewBox", [0.5, -30.5, width, height + 30]) + .attr("width", width) + .attr("height", height + 30) + .attr("style", "max-width: 100%; height: auto;") + .style("font", "10px sans-serif"); + + this.data = d3.json(this.src).then(data => { + // Compute the layout. + const hierarchy = d3.hierarchy(data) + .sum(d => d.value) + .sort((a, b) => b.value - a.value); + const root = d3.treemap().tile(this.tile).size([width, height])(hierarchy); + + // Display the root. + let group = this.svg.append("g") + .call(this.render, root); + + // Append the SVG element to the container. + this.container.appendChild(this.svg.node()); + }); + } + + // This custom tiling function adapts the built-in binary tiling function + // for the appropriate aspect ratio when the treemap is zoomed-in. + tile(node, x0, y0, x1, y1) { + d3.treemapBinary(node, 0, 0, width, height); + for (const child of node.children) { + child.x0 = x0 + child.x0 / width * (x1 - x0); + child.x1 = x0 + child.x1 / width * (x1 - x0); + child.y0 = y0 + child.y0 / height * (y1 - y0); + child.y1 = y0 + child.y1 / height * (y1 - y0); + } + } + + render(group, root) { + const node = group + .selectAll("g") + .data(root.children.concat(root)) + .join("g"); + + node.filter(d => d === root ? d.parent : d.children) + .attr("cursor", "pointer") + .on("click", (event, d) => d === root ? this.zoomout(root) : this.zoomin(d)); + + node.append("title") + .text(d => `${name(d)}\n${format(d.value)}`); + + node.append("rect") + .attr("id", d => (d.leafUid = crypto.randomUUID("leaf")).id) + .attr("fill", d => d === root ? "#fff" : d.children ? "#ccc" : "#ddd") + .attr("stroke", "#fff"); + + node.append("clipPath") + .attr("id", d => (d.clipUid = crypto.randomUUID("clip")).id) + .append("use") + .attr("xlink:href", d => d.leafUid.href); + + node.append("text") + .attr("clip-path", d => d.clipUid) + .attr("font-weight", d => d === root ? "bold" : null) + .selectAll("tspan") + .data(d => (d === root ? name(d) : d.data.name).split(/(?=[A-Z][^A-Z])/g).concat(format(d.value))) + .join("tspan") + .attr("x", 3) + .attr("y", (d, i, nodes) => `${(i === nodes.length - 1) * 0.3 + 1.1 + i * 0.9}em`) + .attr("fill-opacity", (d, i, nodes) => i === nodes.length - 1 ? 0.7 : null) + .attr("font-weight", (d, i, nodes) => i === nodes.length - 1 ? "normal" : null) + .text(d => d); + + group.call(position, root); + } + + // When zooming in, draw the new nodes on top, and fade them in. + zoomin(d) { + const group0 = this.group.attr("pointer-events", "none"); + const group1 = this.group = this.svg.append("g").call(this.render, d); + + x.domain([d.x0, d.x1]); + y.domain([d.y0, d.y1]); + + this.svg.transition() + .duration(750) + .call(t => group0.transition(t).remove() + .call(position, d.parent)) + .call(t => group1.transition(t) + .attrTween("opacity", () => d3.interpolate(0, 1)) + .call(position, d)); + } + + // When zooming out, draw the old nodes on top, and fade them out. + zoomout(d) { + const group0 = this.group.attr("pointer-events", "none"); + const group1 = this.group = this.svg.insert("g", "*").call(this.render, d.parent); + + x.domain([d.parent.x0, d.parent.x1]); + y.domain([d.parent.y0, d.parent.y1]); + + this.svg.transition() + .duration(750) + .call(t => group0.transition(t).remove() + .attrTween("opacity", () => d3.interpolate(1, 0)) + .call(position, d)) + .call(t => group1.transition(t) + .call(position, d.parent)); + } +} \ No newline at end of file diff --git a/app/packs/stylesheets/decidim/admin/content/_table.scss b/app/packs/stylesheets/decidim/admin/content/_table.scss new file mode 100644 index 0000000000..b08d1425b3 --- /dev/null +++ b/app/packs/stylesheets/decidim/admin/content/_table.scss @@ -0,0 +1,15 @@ +.indent-level-0 { + @apply pl-0; +} +.indent-level-1 { + @apply pl-4; +} +.indent-level-2 { + @apply pl-8; +} +.indent-level-3 { + @apply pl-12; +} +.indent-level-4 { + @apply pl-16; +} \ No newline at end of file diff --git a/app/services/concerns/decidim/content/metadata_generator.rb b/app/services/concerns/decidim/content/metadata_generator.rb new file mode 100644 index 0000000000..38145fe6ed --- /dev/null +++ b/app/services/concerns/decidim/content/metadata_generator.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module Decidim + module Content + module MetadataGenerator + extend ActiveSupport::Concern + included do + def metadata_for(instance) + { + labels: labels_for(instance), + stats: stats_for(instance) + } + end + + def labels_for(instance) + labels = {} + labels.merge!(component_type(instance)) if instance.is_a?(Decidim::Component) + labels.merge!(private_space(instance)) if instance.is_a?(Decidim::HasPrivateUsers) + labels.merge!(published(instance)) if instance.is_a?(Decidim::Publicable) + labels + end + + def stats_for(instance) + stats = {} + stats.merge!(component_stats(instance)) if instance.is_a?(Decidim::Component) + stats + end + + private + + def published(instance) + if instance.published? + value = "published" + icon = "check-line" + level = "success" + else + icon = "close-line" + if instance.previously_published? + value = "previously_published" + level = "warning" + else + value = "unpublished" + level = "alert" + end + end + { + published: { + value:, + text: I18n.t("decidim.admin.content.tree.label.#{value}"), + icon:, + level: + } + } + end + + def private_space(instance) + { + private: { + value: instance.private_space ? "private" : "public", + text: instance.private_space ? I18n.t("decidim.admin.content.tree.label.private") : I18n.t("decidim.admin.content.tree.label.public"), + icon: instance.private_space ? "eye-off-line" : "eye-line", + level: instance.private_space ? "warning" : "success" + } + } + end + + def component_type(instance) + { + component_type: { + value: instance.manifest_name, + text: I18n.t("decidim.components.#{instance.manifest_name}.name", default: instance.manifest_name.humanize), + icon: "tools-line", + level: "info" + } + } + end + + def component_stats(component) + rejected_stats = [] + case component.manifest_name + when "proposals" + rejected_stats = [:proposals_accepted] + end + component.manifest.stats.except(rejected_stats).with_context(component).map do |stat_title, stat_number| + [ + stat_title, + { + value: stat_number, + text: stat_title.to_s.chomp("_count").humanize, + level: "info" + } + ] + end.to_h + end + end + end + end +end diff --git a/app/services/decidim/content/tree_generator.rb b/app/services/decidim/content/tree_generator.rb new file mode 100644 index 0000000000..1c45c877e9 --- /dev/null +++ b/app/services/decidim/content/tree_generator.rb @@ -0,0 +1,309 @@ +# frozen_string_literal: true + +require "csv" +require "stringio" + +module Decidim + module Content + class TreeGenerator + include Decidim::TranslatableAttributes + include Decidim::Content::MetadataGenerator + + SUPPORTED_PARTICIPATORY_SPACES = [ + { + space_class: Decidim::ParticipatoryProcess, + group_class: Decidim::ParticipatoryProcessGroup, + group_attribute: :decidim_participatory_process_group_id + }, + { + space_class: Decidim::Assembly, + group_class: Decidim::Assembly, + group_attribute: :parent_id, + sub_group_class: Decidim::Assembly, + sub_group_attribute: :parent_id + }, + { + space_class: Decidim::Initiative, + group_class: Decidim::InitiativesType, + group_attribute: :decidim_initiatives_types_id, + sub_group_class: Decidim::InitiativesTypeScope, + sub_group_attribute: :scoped_type_id + }, + { + space_class: Decidim::Conference + } + ].freeze + + SUPPPORTED_NAME_ATTRIBUTES = %w( + name + title + scope.name + type.name + ).freeze + + DEFAULT_KIND_ICONS = { + root: "home-2-line", + type: "price-tag-3-line", + group: "stack-line", + sub_group: "stack-line", + space: "pages-line", + component: "apps-2-line" + } + + DEFAULT_OPTIONS = { + include_components: true, + include_object: true, + include_metadata: true, + include_score: false, + components_as_children: false + }.freeze + + attr_reader :organization, :hash, :options + + def initialize(organization:, **options) + @organization = organization + @options = DEFAULT_OPTIONS.merge(options) + @hash = build_tree + end + + def build_tree + shared_hash(instance: organization, kind: "root").merge( + children: SUPPORTED_PARTICIPATORY_SPACES.map do |manifest| + { + kind: "type", + name: manifest[:space_class].model_name.human(count: :other), + manifest:, + children: build_participatory_spaces_array(manifest) + } + end + ) { |_key, old_value, new_value| old_value + new_value } + end + + def to_csv + rows = flatten_hash_for_csv(hash) + + forced_headers = %i(kind group sub_group space class component_type name private published item_count stats) + headers_row_hash = rows.each_with_object([]) do |row, keys| + row.keys.each { |key| keys << key unless keys.include?(key) } + end + + headers = forced_headers.union(headers_row_hash) + + csv_data = ::CSV.generate(headers:, write_headers: true, col_sep: Decidim.default_csv_col_sep) do |csv| + rows.each do |row| + csv << headers.map { |header| sanitize_csv_value(row[header]) } + end + end + + ::StringIO.new(csv_data) + end + + private + + def participatory_spaces_base_query(manifest) + manifest[:space_class].where(organization:) + end + + def build_participatory_spaces_array(manifest) + result = [] + space_query = participatory_spaces_base_query(manifest) + if manifest[:group_class].present? + result += build_groups_array(manifest) + + # Get spaces that are not in any group + if manifest[:space_class] != manifest[:group_class] && manifest[:space_class].has_attribute?(manifest[:group_attribute]) + result += space_query.where(manifest[:group_attribute] => nil).map do |space| + shared_hash(instance: space, kind: "space") + end + end + else + space_query.each do |space| + space_hash = shared_hash(instance: space, kind: "space") + result << space_hash + end + end + result + end + + def build_groups_array(manifest) + group_query = manifest[:group_class].where(organization:) + + # If groups and spaces are the same class like Assemblies, + # we only want to get top level groups (group_attribute not set) in this query + group_query = group_query.where(manifest[:group_attribute] => nil) if manifest[:space_class] == manifest[:group_class] + + group_query.map do |group| + build_group_hash(manifest, group) + end + end + + def build_group_hash(manifest, group) + group_hash = shared_hash(instance: group, kind: "group") + + group_hash[:children] = [] if group_hash[:children].nil? + + # Get sub groups if applicable + group_hash[:children] += build_sub_groups_array(manifest, group) if manifest[:sub_group_class].present? + + # Get group spaces that are not in any sub-group + if manifest[:space_class] != manifest[:group_class] && manifest[:space_class].has_attribute?(manifest[:group_attribute]) + group_hash[:children] += participatory_spaces_base_query(manifest).where(manifest[:group_attribute] => group.id).map do |space| + shared_hash(instance: space, kind: "space") + end + end + group_hash + end + + def build_sub_groups_array(manifest, group) + # If sub-groups and spaces are the same class like Assemblies, we treat spaces like sub-groups. + # Both are directly part of the parent group and will be fetched by the same query + manifest[:sub_group_class].where(manifest[:group_attribute] => group.id).map do |sub_group| + build_sub_group_hash(manifest, sub_group) + end + end + + def build_sub_group_hash(manifest, sub_group) + shared_hash(instance: sub_group, kind: "sub_group").merge( + { + children: manifest[:space_class].where(manifest[:sub_group_attribute] => sub_group.id).map do |space| + shared_hash(instance: space, kind: "space") + end + } + ) { |_key, old_value, new_value| old_value + new_value } + end + + def build_components_array(instance) + return {} unless instance.respond_to?(:components) && instance.components.any? + + components_attribute = options[:components_as_children] ? :children : :components + { + components_attribute.to_sym => instance.components.map do |component| + shared_hash(instance: component, kind: "component").merge( + { + item_count: component.primary_stat, + value: component.primary_stat || 1 + } + ) + end + } + end + + def shared_hash(instance:, kind:, icon: nil) + result_hash = { + kind:, + class: instance.class.name, + name: name_attribute(instance) || + "-- #{instance.class.name}(ID:#{instance.id}) --" + } + if icon.present? + result_hash[:icon] = icon + elsif DEFAULT_KIND_ICONS[kind.to_sym].present? + result_hash[:icon] = DEFAULT_KIND_ICONS[kind.to_sym] + end + result_hash[:object] = instance if options[:include_object] + result_hash[:metadata] = metadata_for(instance) if options[:include_metadata] + result_hash.merge!(build_components_array(instance)) if options[:include_components] + result_hash + end + + def name_attribute(instance) + SUPPPORTED_NAME_ATTRIBUTES.each do |attribute| + path = attribute.split(".") + o = instance + while !path.empty? && o.respond_to?(path.first) && o.send(path.first).present? + o = o.send(path.first) + return translated_attribute(o) if path.size == 1 + + path = path.drop(1) + end + end + end + + def current_locale + I18n.locale.to_s.presence || organization&.default_locale + end + + def flatten_hash_for_csv(tree, row_prefix_values = {}) + return unless tree.is_a?(Hash) + + case tree[:kind] + when "root" + tree[:children].map do |space_type| + space_type[:children].map do |space| + flatten_hash_for_csv(space, row_prefix_values) + end.flatten + end.flatten + when "group", "sub_group" + [flatten_row_hash_for_csv(tree).merge(row_prefix_values)].concat( + tree[:children].map do |children| + flatten_hash_for_csv( + children, + row_prefix_values.merge(tree[:kind].to_sym => format_path_name_for_csv(tree)) + ) + end.flatten + ) + when "space" + result = [flatten_row_hash_for_csv(tree).merge(row_prefix_values)] + if options[:include_components] + component_property = options[:components_as_children] ? :children : :components + if tree[component_property].present? + result.concat( + tree[component_property].map do |component| + flatten_hash_for_csv( + component, + row_prefix_values.merge(space: format_path_name_for_csv(tree)) + ) + end.flatten + ) + end + end + result + when "component" + [flatten_row_hash_for_csv(tree).merge(row_prefix_values)] + end + end + + def flatten_row_hash_for_csv(row_hash) + result = row_hash.except(:object, :children, :icon, :metadata, :value) + result[:class] = row_hash[:class].demodulize if row_hash[:class].present? + if options[:include_metadata] + result.merge!(flatten_metadata_labels(row_hash[:metadata] || {})) + result.merge!(flatten_metadata_stats(row_hash[:metadata] || {})) + end + result + end + + def flatten_metadata_labels(metadata) + metadata[:labels].each_with_object({}) do |(key, value), result| + if value.is_a?(Hash) && value[:text].present? + result[key] = value[:text] + else + result[key] = value + end + end + end + + def flatten_metadata_stats(metadata, separator: "\n") + { + stats: metadata[:stats].values.map do |stat| + if stat.is_a?(Hash) && stat[:text].present? && stat[:value].present? + "#{stat[:value]} #{stat[:text]}" + else + stat.to_s + end + end.join(separator) + } + end + + def format_path_name_for_csv(tree) + "[ #{tree[:class].demodulize} ] #{tree[:name]}" + end + + def sanitize_csv_value(value) + return value unless value.instance_of?(String) && %w(= + - @).include?(value.first) + + value.dup.prepend("'") + end + end + end +end diff --git a/app/views/decidim/admin/content/tree/index.html.erb b/app/views/decidim/admin/content/tree/index.html.erb new file mode 100644 index 0000000000..edc990ae21 --- /dev/null +++ b/app/views/decidim/admin/content/tree/index.html.erb @@ -0,0 +1,41 @@ +
+

Content Tree

+
+ +
+ + <%# + + + + + + %> + + <% content_tree[:children].each do |participatory_spaces| %> + <% participatory_space_type_prefix = participatory_spaces[:manifest][:item_class].to_s.demodulize.underscore.pluralize %> + + + + <% participatory_spaces[:children].each do |participatory_space| %> + <%= render partial: "table/row", locals: { item: participatory_space } %> + <% end %> + <% end %> + +
+ name + + status + + actions +
+
"> +

+ <%= participatory_spaces[:name] %> +

+
+
+
+
+
<%= JSON.pretty_generate(content_tree) %>
+
\ No newline at end of file diff --git a/app/views/decidim/admin/content/tree/table/_component_name.html.erb b/app/views/decidim/admin/content/tree/table/_component_name.html.erb new file mode 100644 index 0000000000..1254798a1b --- /dev/null +++ b/app/views/decidim/admin/content/tree/table/_component_name.html.erb @@ -0,0 +1,12 @@ + + <%= icon("tools-line")%> + <%= component[:object].manifest_name.humanize %> + + + <%= component[:name] %> + +<% if component[:item_count].present? %> + + <%= component[:item_count] %> + +<% end %> \ No newline at end of file diff --git a/app/views/decidim/admin/content/tree/table/_row.html.erb b/app/views/decidim/admin/content/tree/table/_row.html.erb new file mode 100644 index 0000000000..2783db676f --- /dev/null +++ b/app/views/decidim/admin/content/tree/table/_row.html.erb @@ -0,0 +1,52 @@ +<% ident_level = local_assigns[:ident_level] || 0 %> + + +
+ <% if item[:icon].present? %> + <%= icon(item[:icon], class: "!text-lg") %> + <% end %> + + <%= item[:name] %> + + <% if item[:item_count].present? %> + + <%= item[:item_count] %> + + <% end %> + <% if item.dig(:metadata, :labels).present? %> +
+ <% item[:metadata][:labels].each do |key, label| %> + + <%= icon(label[:icon]) if label[:icon].present? %> + <%= label[:text] || label[:value] %> + + <% end %> +
+ <% end %> + <% if item.dig(:metadata, :stats).present? %> +
+ <% item[:metadata][:stats].each do |key, label| %> + + <%= label[:value] %> + <%= label[:text] %> + + <% end %> +
+ <% end %> +
+ + + -- status -- + + + A + B + C + + +<% item[:components]&.each do |component| %> + <%= render partial: "table/row", locals: { item: component, ident_level: ident_level + 1 } %> +<% end %> +<% if item[:children]&.any? %> + <%= render partial: "table/row", collection: item[:children], as: :item, locals: { ident_level: ident_level + 1 } %> +<% end %> \ No newline at end of file diff --git a/app/views/decidim/admin/content/tree/treemap.html.erb b/app/views/decidim/admin/content/tree/treemap.html.erb new file mode 100644 index 0000000000..844e5befd6 --- /dev/null +++ b/app/views/decidim/admin/content/tree/treemap.html.erb @@ -0,0 +1,2 @@ +<% append_javascript_pack_tag "decidim_admin_content_treemap", defer: true %> +
\ No newline at end of file diff --git a/app/views/decidim/admin/content/tree/treeview.html.erb b/app/views/decidim/admin/content/tree/treeview.html.erb new file mode 100644 index 0000000000..31db510318 --- /dev/null +++ b/app/views/decidim/admin/content/tree/treeview.html.erb @@ -0,0 +1,6 @@ + +
+<%= mermaid_treeview %>
+
\ No newline at end of file diff --git a/app/views/layouts/decidim/admin/content/tree.html.erb b/app/views/layouts/decidim/admin/content/tree.html.erb new file mode 100644 index 0000000000..e824615893 --- /dev/null +++ b/app/views/layouts/decidim/admin/content/tree.html.erb @@ -0,0 +1,5 @@ +<% append_javascript_pack_tag "decidim_admin_content_tree", defer: true %> +<% append_stylesheet_pack_tag "decidim_admin_content_tree" %> +<%= render "layouts/decidim/admin/application" do %> + <%= yield %> +<% end %> diff --git a/config/initializers/icons.rb b/config/initializers/icons.rb new file mode 100644 index 0000000000..96acef7557 --- /dev/null +++ b/config/initializers/icons.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +Decidim.icons.register(name: "pages-line", icon: "pages-line", category: "system", description: "", engine: :core) +Decidim.icons.register(name: "stack-line", icon: "stack-line", category: "system", description: "", engine: :core) +Decidim.icons.register(name: "price-tag-3-line", icon: "price-tag-3-line", category: "system", description: "", engine: :core) diff --git a/config/locales/admin_content_tree/en.yml b/config/locales/admin_content_tree/en.yml new file mode 100644 index 0000000000..da02d8a24f --- /dev/null +++ b/config/locales/admin_content_tree/en.yml @@ -0,0 +1,11 @@ +en: + decidim: + admin: + content: + tree: + label: + previously_published: "Previously published" + private: "Private" + published: "Published" + public: "Public" + unpublished: "Unpublished" \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 2b0c4761c6..50f1dc9269 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,6 +3,17 @@ require "sidekiq/web" require "sidekiq-scheduler/web" +Decidim::Admin::Engine.routes.draw do + constraints(->(request) { Decidim::Admin::OrganizationDashboardConstraint.new(request).matches? }) do + namespace :content do + root to: "tree#index" + get "export", to: "tree#export" + get "treemap", to: "tree#treemap" + get "treeview", to: "tree#treeview" + end + end +end + Rails.application.routes.draw do if Rails.application.secrets.puma[:health_check][:enabled] get "/stats", to: redirect { |_params, request| "http://#{request.host}:#{Rails.application.secrets.puma[:health_check][:port]}/stats?#{request.params.to_query}" } diff --git a/package-lock.json b/package-lock.json index 9d121e08b7..b5633d3eaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "decidim-lite", - "version": "0.1.0", + "name": "decidim-app", + "version": "3.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "decidim-lite", - "version": "0.1.0", + "name": "decidim-app", + "version": "3.10.0", "dependencies": { "@decidim/browserslist-config": "file:packages/browserslist-config", "@decidim/core": "file:packages/core", diff --git a/package.json b/package.json index e1011b6401..b476a29a1b 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "decidim-lite", + "name": "decidim-app", "private": true, "dependencies": { "@decidim/browserslist-config": "file:packages/browserslist-config", @@ -24,7 +24,7 @@ "zustand": "^5.0.5", "regenerator-runtime": "0.14.1" }, - "version": "0.1.0", + "version": "3.10.0", "devDependencies": { "@decidim/dev": "file:packages/dev", "@decidim/eslint-config": "file:packages/eslint-config", diff --git a/tsconfig.json b/tsconfig.json index e1e0eaa710..7d30e39154 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,5 +7,10 @@ "incremental": true, /* Enable incremental compilation */ "target": "es6", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ "skipLibCheck": true, /* Skip type checking all .d.ts files. */ - } + }, + "exclude": [ + "node_modules", + "public", + "**/*.erb" + ] } From deb656b0183d086bbafdc486f9aba8d8d1705508 Mon Sep 17 00:00:00 2001 From: moustachu Date: Tue, 26 May 2026 11:29:27 +0200 Subject: [PATCH 02/24] [WIP] feat: reversibility content tree --- .../decidim/admin/content/tree_controller.rb | 13 +- .../decidim_admin_content_tree.scss | 4 +- .../iframe_settings_proposal_component.js | 7 +- .../decidim/admin/content/_table.scss | 6 + .../decidim/admin/content/_tree.scss | 49 +++++ .../decidim/admin/content/_utilities.scss | 41 ++++ .../decidim/content/location_generator.rb | 108 ++++++++++ .../decidim/content/metadata_generator.rb | 130 +++++++++++- .../decidim/content/tree_generator.rb | 199 ++++++++++++------ .../content/tree/_accordion_trigger.html.erb | 4 + .../admin/content/tree/_article.html.erb | 90 ++++++++ .../decidim/admin/content/tree/index.html.erb | 59 +++--- .../decidim/admin/content/tree/table.html.erb | 36 ++++ .../admin/content/tree/table/_row.html.erb | 15 +- .../admin/content/tree/treeview.html.erb | 6 - config/initializers/icons.rb | 20 +- config/initializers/reversibility.rb | 11 + config/locales/admin_content_tree/en.yml | 21 +- config/locales/admin_content_tree/fr.yml | 30 +++ config/routes.rb | 2 +- 20 files changed, 721 insertions(+), 130 deletions(-) create mode 100644 app/packs/stylesheets/decidim/admin/content/_tree.scss create mode 100644 app/packs/stylesheets/decidim/admin/content/_utilities.scss create mode 100644 app/services/concerns/decidim/content/location_generator.rb create mode 100644 app/views/decidim/admin/content/tree/_accordion_trigger.html.erb create mode 100644 app/views/decidim/admin/content/tree/_article.html.erb create mode 100644 app/views/decidim/admin/content/tree/table.html.erb delete mode 100644 app/views/decidim/admin/content/tree/treeview.html.erb create mode 100644 config/initializers/reversibility.rb create mode 100644 config/locales/admin_content_tree/fr.yml diff --git a/app/controllers/decidim/admin/content/tree_controller.rb b/app/controllers/decidim/admin/content/tree_controller.rb index 0433e93672..a975ba772c 100644 --- a/app/controllers/decidim/admin/content/tree_controller.rb +++ b/app/controllers/decidim/admin/content/tree_controller.rb @@ -6,11 +6,9 @@ module Content class TreeController < Decidim::Admin::ApplicationController helper Decidim::IconHelper - IDENT_TOKEN = " " - before_action :default_permissions before_action :relative_view_path - helper_method :content_tree + helper_method :content_tree, :any_grand_children? layout "decidim/admin/content/tree" def index; end @@ -19,9 +17,10 @@ def export generator = Decidim::Content::TreeGenerator.new( organization: current_organization, include_object: false, + location_type: :url, include_metadata: true, include_score: true, - components_as_children: true + components_as_children: false ) csv_data = generator.to_csv @@ -41,7 +40,7 @@ def treemap end end - def treeview; end + def table; end private @@ -66,6 +65,10 @@ def content_treemap components_as_children: true ).hash end + + def any_grand_children?(node) + node[:children]&.any? { |child| child[:children]&.any? } + end end end end diff --git a/app/packs/entrypoints/decidim_admin_content_tree.scss b/app/packs/entrypoints/decidim_admin_content_tree.scss index 541aea7b76..f80b0cfb53 100644 --- a/app/packs/entrypoints/decidim_admin_content_tree.scss +++ b/app/packs/entrypoints/decidim_admin_content_tree.scss @@ -1 +1,3 @@ -@import "stylesheets/decidim/admin/content/table.scss"; \ No newline at end of file +@import "stylesheets/decidim/admin/content/utilities.scss"; +@import "stylesheets/decidim/admin/content/tree.scss"; +@import "stylesheets/decidim/admin/content/table.scss"; diff --git a/app/packs/src/decidim/iframe_settings_proposal_component.js b/app/packs/src/decidim/iframe_settings_proposal_component.js index d8c89ba918..403746650f 100644 --- a/app/packs/src/decidim/iframe_settings_proposal_component.js +++ b/app/packs/src/decidim/iframe_settings_proposal_component.js @@ -52,9 +52,10 @@ document.addEventListener("DOMContentLoaded", function(){ } }) } - // check validity of urls when input looses focus - inputUrl.addEventListener("keyup", checkUrl) - + if(inputUrl){ + // check validity of urls when input looses focus + inputUrl.addEventListener("keyup", checkUrl) + } function checkUrl(event){ const values = event.target.value; const errors = []; diff --git a/app/packs/stylesheets/decidim/admin/content/_table.scss b/app/packs/stylesheets/decidim/admin/content/_table.scss index b08d1425b3..c9451d4820 100644 --- a/app/packs/stylesheets/decidim/admin/content/_table.scss +++ b/app/packs/stylesheets/decidim/admin/content/_table.scss @@ -12,4 +12,10 @@ } .indent-level-4 { @apply pl-16; +} + +table.content-tree { + thead tr th:first-child { + @apply pl-3; + } } \ No newline at end of file diff --git a/app/packs/stylesheets/decidim/admin/content/_tree.scss b/app/packs/stylesheets/decidim/admin/content/_tree.scss new file mode 100644 index 0000000000..3c316d82d4 --- /dev/null +++ b/app/packs/stylesheets/decidim/admin/content/_tree.scss @@ -0,0 +1,49 @@ +.depth-ident-0 { + @apply pl-0; +} +.depth-ident-1 { + @apply pl-4; +} +.depth-ident-2 { + @apply pl-8; +} +.depth-ident-3 { + @apply pl-12; +} +.depth-ident-4 { + @apply pl-16; +} + +.content-tree.tree-view { + @apply flex flex-col text-md rounded-lg bg-background-2 border border-background-4; + + &, :has(> *.tree-row) { + @apply divide-y divide-background-4 !important; + } + + .accordion-group > *.tree-row:first-of-type { + @apply border-t border-background-4; + } + + .tree-row-content { + @apply p-2; + } + + .item-name { + @apply font-semibold align-middle; + } + + .item-stats { + @apply pl-6; + } + + section.components .item-stats { + @apply pl-2; + } + + .actions { + a:not(.button) { + @apply underline text-md font-semibold text-secondary; + } + } +} diff --git a/app/packs/stylesheets/decidim/admin/content/_utilities.scss b/app/packs/stylesheets/decidim/admin/content/_utilities.scss new file mode 100644 index 0000000000..e56b4b1f4a --- /dev/null +++ b/app/packs/stylesheets/decidim/admin/content/_utilities.scss @@ -0,0 +1,41 @@ +.whitespace-collapse { + white-space: collapse; +} + +.icon-button-lg { + // TODO: find how to retrieve var(--text-lg--line-height) + height: 1.55rem; + vertical-align: bottom; +} + +.item-count { + @apply ml-1 inline-flex items-center justify-center w-5 h-5 text-xs font-semibold rounded-full bg-gray-3 border-secondary; + line-height: unset; +} + +.content-tree { + .accordion-group[aria-hidden="true"] { + @apply hidden; + } + + .accordion--trigger { + @apply leading-[0]; + + &[aria-expanded="false"] { + svg.accordion--trigger-collapsed { + @apply inline-block; + } + svg.accordion--trigger-expanded { + @apply hidden; + } + } + &[aria-expanded="true"] { + svg.accordion--trigger-collapsed { + @apply hidden; + } + svg.accordion--trigger-expanded { + @apply inline-block; + } + } + } +} \ No newline at end of file diff --git a/app/services/concerns/decidim/content/location_generator.rb b/app/services/concerns/decidim/content/location_generator.rb new file mode 100644 index 0000000000..2d34c7a415 --- /dev/null +++ b/app/services/concerns/decidim/content/location_generator.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +module Decidim + module Content + module LocationGenerator + extend ActiveSupport::Concern + included do + def location_for(instance, location_type = :path) + # TODO : create options for path versus url + + raise "Unsupported location type #{location_type}" unless [:path, :url].include?(location_type) + raise "Organization attribute is required to generate data for :url location type" if location_type == :url && try(:organization).nil? + + front_key = location_type + admin_key = "admin_#{location_type}".to_sym + + case instance.class.name + when "Decidim::Organization" + { + front_key => send("base_#{location_type}"), + admin_key => send("admin_base_#{location_type}") + } + when "Decidim::Component" + { + front_key => Decidim::EngineRouter.main_proxy(instance).send("root_#{location_type}"), + admin_key => Decidim::EngineRouter.admin_proxy(instance).send("root_#{location_type}") + } + when "Decidim::ParticipatoryProcessGroup" + { + front_key => compute_location( + location: Decidim::ParticipatoryProcesses::Engine.routes.url_helpers.participatory_process_group_path(instance), + location_type: + ), + admin_key => compute_location( + location: Decidim::ParticipatoryProcesses::AdminEngine.routes.url_helpers.edit_participatory_process_group_path(instance), + location_type: + ) + } + when "Decidim::InitiativesType" + { + admin_key => compute_location( + location: Decidim::Initiatives::AdminEngine.routes.url_helpers.edit_initiatives_type_path(instance), + location_type: + ) + } + when "Decidim::InitiativesTypeScope" + { + admin_key => compute_location( + location: Decidim::Initiatives::AdminEngine.routes.url_helpers.edit_initiatives_type_initiatives_type_scope_path(instance, id: instance.id), + location_type: + ) + } + else + # TODO: add a rescue for missing route or missing manifest : + # - advise to add another case in this method with the correct routes + # - return nil or empty hash while it's not implemented, instead of raising an error + + resource_locator_presenter = Decidim::ResourceLocatorPresenter.new(instance) + { + front_key => compute_location( + location: resource_locator_presenter.path, + location_type: + ), + admin_key => compute_location( + location: resource_locator_presenter.edit, + location_type: + ) + } + end + end + + def compute_location(location:, location_type: :path, admin: false) + # use the admin argument if you need to add "/admin" before the location argument + joiner_class = location_type == :url ? URI : File + prefix_method = admin ? "admin_" : "" + base_location = send("#{prefix_method}base_#{location_type}") + joiner_class.join(base_location, location).to_s + end + + def base_path + @base_path ||= Decidim::Core::Engine.routes.url_helpers.root_path + end + + def base_url + @base_url ||= switch_url_port(Decidim::Core::Engine.routes.url_helpers.root_url(host: organization.host)) + end + + def admin_base_path + @admin_base_path ||= Decidim::Admin::Engine.routes.url_helpers.root_path + end + + def admin_base_url + @admin_base_url ||= switch_url_port(Decidim::Admin::Engine.routes.url_helpers.root_url(host: organization.host)) + end + + def switch_url_port(url) + if Rails.env.development? || Rails.env.test? + url_with_port = URI(url) + url_with_port.port = Rails::Server::Options.new.parse!(ARGV)[:Port] + url_with_port.to_s + else + url + end + end + end + end + end +end diff --git a/app/services/concerns/decidim/content/metadata_generator.rb b/app/services/concerns/decidim/content/metadata_generator.rb index 38145fe6ed..c26a5623e0 100644 --- a/app/services/concerns/decidim/content/metadata_generator.rb +++ b/app/services/concerns/decidim/content/metadata_generator.rb @@ -5,6 +5,24 @@ module Content module MetadataGenerator extend ActiveSupport::Concern included do + INITIATIVE_STATE_ICON_MAP = { + created: "draft-line", + validating: "time-line", + published: "pen-nib-line", + discarded: "close-line", + accepted: "thumb-up-line", + rejected: "thumb-down-line" + }.freeze + + INITIATIVE_STATE_LEVEL_MAP = { + created: "info", + validating: "warning", + published: "success", + discarded: "alert", + accepted: "success", + rejected: "alert" + }.freeze + def metadata_for(instance) { labels: labels_for(instance), @@ -14,21 +32,29 @@ def metadata_for(instance) def labels_for(instance) labels = {} + labels.merge!(hashtag(instance)) if instance.respond_to?(:hashtag) && instance.hashtag.present? labels.merge!(component_type(instance)) if instance.is_a?(Decidim::Component) labels.merge!(private_space(instance)) if instance.is_a?(Decidim::HasPrivateUsers) labels.merge!(published(instance)) if instance.is_a?(Decidim::Publicable) labels end - def stats_for(instance) + def stats_for(instance, empty_values: false) stats = {} stats.merge!(component_stats(instance)) if instance.is_a?(Decidim::Component) + stats.merge!(initiative_stats(instance)) if instance.is_a?(Decidim::Initiative) + stats.merge!(participatory_space_stats(instance)) if instance.is_a?(Decidim::Participable) + stats.merge!(followers_stats(instance)) if instance.is_a?(Decidim::Followable) + stats.merge!(participatory_space_moderations_stats(instance)) if instance.is_a?(Decidim::Participable) + stats.reject! { |_, stat| stat[:value].to_i.zero? } unless empty_values stats end private def published(instance) + return initiative_state(instance) if instance.is_a?(Decidim::Initiative) + if instance.published? value = "published" icon = "check-line" @@ -75,22 +101,118 @@ def component_type(instance) } end + def hashtag(instance) + { + hashtag: { + value: instance.hashtag, + text: "##{instance.hashtag}", + level: "info" + } + } + end + def component_stats(component) rejected_stats = [] case component.manifest_name when "proposals" rejected_stats = [:proposals_accepted] end - component.manifest.stats.except(rejected_stats).with_context(component).map do |stat_title, stat_number| + component.manifest.stats.except(rejected_stats).with_context(component).to_h do |stat_title, stat_number| [ - stat_title, + stat_title, { value: stat_number, text: stat_title.to_s.chomp("_count").humanize, level: "info" } ] - end.to_h + end + end + + def participatory_space_stats(space) + { + categories_count: { + value: space.categories&.size || 0, + text: I18n.t("decidim.admin.content.tree.stats.categories"), + icon: "tags-line", + level: "info" + }, + components_count: { + value: space.components&.size || 0, + text: I18n.t("decidim.admin.content.tree.stats.components"), + icon: "apps-2-line", + level: "info" + }, + attachments_count: { + value: space.attachments&.size || 0, + text: I18n.t("decidim.admin.content.tree.stats.attachments"), + icon: "paperclip-line", + level: "info" + } + } + end + + def participatory_space_moderations_stats(space) + moderations = Decidim::Moderation.where(participatory_space: space) + { + moderations_count: { + value: moderations.size, + text: I18n.t("decidim.admin.content.tree.stats.moderations"), + icon: "flag-line", + level: "info" + }, + hidden_moderations_count: { + value: moderations.hidden.size, + text: I18n.t("decidim.admin.content.tree.stats.hidden_moderations"), + icon: "eye-off-line", + level: "warning" + } + } + end + + def initiative_state(instance) + { + initiative_state: { + value: instance.state, + text: I18n.t("decidim.initiatives.admin_states.#{instance.state}"), + icon: INITIATIVE_STATE_ICON_MAP[instance.state.to_sym], + level: INITIATIVE_STATE_LEVEL_MAP[instance.state.to_sym] || "info" + } + } + end + + def initiative_stats(initiative) + { + signatures_count: { + value: initiative.supports_count, + text: I18n.t("decidim.admin.content.tree.stats.signatures"), + icon: "thumb-up-line", + level: "info" + }, + authors_count: { + value: initiative.author_users.size > 1 ? initiative.author_users.size : nil, + text: I18n.t("decidim.admin.content.tree.stats.authors"), + icon: "user-line", + level: "info" + }, + comments_count: { + value: initiative.comments_count, + text: I18n.t("decidim.admin.content.tree.stats.comments"), + icon: "message-line", + level: "info" + } + } + end + + def followers_stats(instance) + { + followers_count: { + value: instance.follows_count, + text: I18n.t("decidim.admin.content.tree.stats.followers"), + icon: "group-line", + level: "info" + } + } end end end diff --git a/app/services/decidim/content/tree_generator.rb b/app/services/decidim/content/tree_generator.rb index 1c45c877e9..b6aaf28315 100644 --- a/app/services/decidim/content/tree_generator.rb +++ b/app/services/decidim/content/tree_generator.rb @@ -8,33 +8,49 @@ module Content class TreeGenerator include Decidim::TranslatableAttributes include Decidim::Content::MetadataGenerator + include Decidim::Content::LocationGenerator SUPPORTED_PARTICIPATORY_SPACES = [ { space_class: Decidim::ParticipatoryProcess, + space_eager_load: [:organization, :components], + space_icon: "treasure-map-line", group_class: Decidim::ParticipatoryProcessGroup, group_attribute: :decidim_participatory_process_group_id }, { space_class: Decidim::Assembly, + space_eager_load: [:organization, :categories, :attachments, :components], + space_icon: "government-line", group_class: Decidim::Assembly, group_attribute: :parent_id, + group_eager_load: [:organization, :categories, :attachments, :components], + group_icon: "government-line", sub_group_class: Decidim::Assembly, - sub_group_attribute: :parent_id + sub_group_attribute: :parent_id, + sub_group_eager_load: [:organization, :categories, :attachments, :components], + sub_group_icon: "government-line" }, { space_class: Decidim::Initiative, + space_eager_load: [:organization, :components], + space_icon: "lightbulb-flash-line", group_class: Decidim::InitiativesType, group_attribute: :decidim_initiatives_types_id, + group_icon: "layout-masonry-line", sub_group_class: Decidim::InitiativesTypeScope, - sub_group_attribute: :scoped_type_id + sub_group_attribute: :scoped_type_id, + sub_group_eager_load: [:scope], + sub_group_icon: "price-tag-3-line" }, { - space_class: Decidim::Conference + space_class: Decidim::Conference, + space_eager_load: [:organization, :components], + space_icon: "live-line" } ].freeze - SUPPPORTED_NAME_ATTRIBUTES = %w( + SUPPORTED_NAME_ATTRIBUTES = %w( name title scope.name @@ -44,15 +60,17 @@ class TreeGenerator DEFAULT_KIND_ICONS = { root: "home-2-line", type: "price-tag-3-line", - group: "stack-line", - sub_group: "stack-line", + group: "folder-line", + sub_group: "folder-line", space: "pages-line", - component: "apps-2-line" + component: "focus-line" } DEFAULT_OPTIONS = { include_components: true, include_object: true, + include_location: true, + location_type: :path, include_metadata: true, include_score: false, components_as_children: false @@ -67,27 +85,31 @@ def initialize(organization:, **options) end def build_tree - shared_hash(instance: organization, kind: "root").merge( + shared_hash(instance: organization, kind: "root", manifest: {}).merge( children: SUPPORTED_PARTICIPATORY_SPACES.map do |manifest| { kind: "type", name: manifest[:space_class].model_name.human(count: :other), manifest:, - children: build_participatory_spaces_array(manifest) - } + children: children = build_participatory_spaces_array(manifest) + }.merge( + item_count: count_space_children(children) + ) end ) { |_key, old_value, new_value| old_value + new_value } end def to_csv rows = flatten_hash_for_csv(hash) - - forced_headers = %i(kind group sub_group space class component_type name private published item_count stats) + + forced_headers = [:kind, :group, :sub_group, :space, :class, :component_type, :name, :private, :published, :item_count, :stats, :url, :admin_url, :gid] + rejected_headers = [:manifest] + headers_row_hash = rows.each_with_object([]) do |row, keys| row.keys.each { |key| keys << key unless keys.include?(key) } end - headers = forced_headers.union(headers_row_hash) + headers = forced_headers.union(headers_row_hash).difference(rejected_headers) csv_data = ::CSV.generate(headers:, write_headers: true, col_sep: Decidim.default_csv_col_sep) do |csv| rows.each do |row| @@ -101,7 +123,7 @@ def to_csv private def participatory_spaces_base_query(manifest) - manifest[:space_class].where(organization:) + manifest[:space_class].includes(manifest[:space_eager_load] || []).where(organization:) end def build_participatory_spaces_array(manifest) @@ -113,12 +135,12 @@ def build_participatory_spaces_array(manifest) # Get spaces that are not in any group if manifest[:space_class] != manifest[:group_class] && manifest[:space_class].has_attribute?(manifest[:group_attribute]) result += space_query.where(manifest[:group_attribute] => nil).map do |space| - shared_hash(instance: space, kind: "space") + shared_hash(instance: space, kind: "space", manifest:) end end else space_query.each do |space| - space_hash = shared_hash(instance: space, kind: "space") + space_hash = shared_hash(instance: space, kind: "space", manifest:) result << space_hash end end @@ -126,7 +148,7 @@ def build_participatory_spaces_array(manifest) end def build_groups_array(manifest) - group_query = manifest[:group_class].where(organization:) + group_query = manifest[:group_class].includes(manifest[:group_eager_load] || []).where(organization:) # If groups and spaces are the same class like Assemblies, # we only want to get top level groups (group_attribute not set) in this query @@ -138,7 +160,7 @@ def build_groups_array(manifest) end def build_group_hash(manifest, group) - group_hash = shared_hash(instance: group, kind: "group") + group_hash = shared_hash(instance: group, kind: "group", manifest:) group_hash[:children] = [] if group_hash[:children].nil? @@ -148,66 +170,75 @@ def build_group_hash(manifest, group) # Get group spaces that are not in any sub-group if manifest[:space_class] != manifest[:group_class] && manifest[:space_class].has_attribute?(manifest[:group_attribute]) group_hash[:children] += participatory_spaces_base_query(manifest).where(manifest[:group_attribute] => group.id).map do |space| - shared_hash(instance: space, kind: "space") + shared_hash(instance: space, kind: "space", manifest:) end end + group_hash[:item_count] = count_space_children(group_hash[:children]) if group_hash[:children].present? group_hash end def build_sub_groups_array(manifest, group) # If sub-groups and spaces are the same class like Assemblies, we treat spaces like sub-groups. # Both are directly part of the parent group and will be fetched by the same query - manifest[:sub_group_class].where(manifest[:group_attribute] => group.id).map do |sub_group| + manifest[:sub_group_class].includes(manifest[:sub_group_eager_load] || []).where(manifest[:group_attribute] => group.id).map do |sub_group| build_sub_group_hash(manifest, sub_group) end end def build_sub_group_hash(manifest, sub_group) - shared_hash(instance: sub_group, kind: "sub_group").merge( + shared_hash(instance: sub_group, kind: "sub_group", manifest:).merge( { - children: manifest[:space_class].where(manifest[:sub_group_attribute] => sub_group.id).map do |space| - shared_hash(instance: space, kind: "space") + children: children = manifest[:space_class].includes(manifest[:sub_group_eager_load] || []).where(manifest[:sub_group_attribute] => sub_group.id).map do |space| + shared_hash(instance: space, kind: "space", manifest:) end - } + }.merge( + item_count: count_space_children(children) + ) ) { |_key, old_value, new_value| old_value + new_value } end def build_components_array(instance) return {} unless instance.respond_to?(:components) && instance.components.any? - components_attribute = options[:components_as_children] ? :children : :components + if options[:components_as_children] + components_attribute = :children + count_attribute = :item_count + else + components_attribute = :components + count_attribute = :component_count + end { - components_attribute.to_sym => instance.components.map do |component| - shared_hash(instance: component, kind: "component").merge( + components_attribute.to_sym => children = instance.components.map do |component| + shared_hash(instance: component, kind: "component", manifest: instance.manifest.to_h, icon: component.manifest.icon_key).merge( { item_count: component.primary_stat, - value: component.primary_stat || 1 + value: component.primary_stat || 1 # TODO : remove if unnecessary } ) end - } + }.merge(count_attribute => children.size) end - def shared_hash(instance:, kind:, icon: nil) + def shared_hash(instance:, kind:, manifest:, icon: nil) result_hash = { kind:, + manifest:, class: instance.class.name, + gid: instance.to_global_id.to_s, name: name_attribute(instance) || "-- #{instance.class.name}(ID:#{instance.id}) --" } - if icon.present? - result_hash[:icon] = icon - elsif DEFAULT_KIND_ICONS[kind.to_sym].present? - result_hash[:icon] = DEFAULT_KIND_ICONS[kind.to_sym] - end + icon = icon.presence || manifest["#{kind}_icon".to_sym].presence || DEFAULT_KIND_ICONS[kind.to_sym] + result_hash[:icon] = icon if icon.present? result_hash[:object] = instance if options[:include_object] + result_hash.merge!(location_for(instance, options[:location_type])) if options[:include_location] result_hash[:metadata] = metadata_for(instance) if options[:include_metadata] result_hash.merge!(build_components_array(instance)) if options[:include_components] result_hash end def name_attribute(instance) - SUPPPORTED_NAME_ATTRIBUTES.each do |attribute| + SUPPORTED_NAME_ATTRIBUTES.each do |attribute| path = attribute.split(".") o = instance while !path.empty? && o.respond_to?(path.first) && o.send(path.first).present? @@ -228,41 +259,52 @@ def flatten_hash_for_csv(tree, row_prefix_values = {}) case tree[:kind] when "root" - tree[:children].map do |space_type| - space_type[:children].map do |space| - flatten_hash_for_csv(space, row_prefix_values) - end.flatten - end.flatten + flatten_root_for_csv(tree, row_prefix_values) when "group", "sub_group" - [flatten_row_hash_for_csv(tree).merge(row_prefix_values)].concat( - tree[:children].map do |children| - flatten_hash_for_csv( - children, - row_prefix_values.merge(tree[:kind].to_sym => format_path_name_for_csv(tree)) - ) - end.flatten - ) + flatten_group_for_csv(tree, row_prefix_values) when "space" - result = [flatten_row_hash_for_csv(tree).merge(row_prefix_values)] - if options[:include_components] - component_property = options[:components_as_children] ? :children : :components - if tree[component_property].present? - result.concat( - tree[component_property].map do |component| - flatten_hash_for_csv( - component, - row_prefix_values.merge(space: format_path_name_for_csv(tree)) - ) - end.flatten - ) - end - end - result + flatten_space_for_csv(tree, row_prefix_values) when "component" [flatten_row_hash_for_csv(tree).merge(row_prefix_values)] end end + def flatten_root_for_csv(tree, row_prefix_values) + tree[:children].map do |space_type| + space_type[:children].map do |space| + flatten_hash_for_csv(space, row_prefix_values) + end.flatten + end.flatten + end + + def flatten_group_for_csv(tree, row_prefix_values) + [flatten_row_hash_for_csv(tree).merge(row_prefix_values)].concat( + tree[:children].map do |children| + flatten_hash_for_csv( + children, + row_prefix_values.merge(tree[:kind].to_sym => format_path_name_for_csv(tree)) + ) + end.flatten + ) + end + + def flatten_space_for_csv(tree, row_prefix_values) + result = [flatten_row_hash_for_csv(tree).merge(row_prefix_values)] + return result unless options[:include_components] + + component_property = options[:components_as_children] ? :children : :components + return result if tree[component_property].blank? + + result.concat( + tree[component_property].map do |component| + flatten_hash_for_csv( + component, + row_prefix_values.merge(space: format_path_name_for_csv(tree)) + ) + end.flatten + ) + end + def flatten_row_hash_for_csv(row_hash) result = row_hash.except(:object, :children, :icon, :metadata, :value) result[:class] = row_hash[:class].demodulize if row_hash[:class].present? @@ -274,11 +316,11 @@ def flatten_row_hash_for_csv(row_hash) end def flatten_metadata_labels(metadata) - metadata[:labels].each_with_object({}) do |(key, value), result| - if value.is_a?(Hash) && value[:text].present? - result[key] = value[:text] + metadata[:labels].transform_values do |value| + if value.is_a?(Hash) && value[:value].present? + value[:value] else - result[key] = value + value end end end @@ -304,6 +346,27 @@ def sanitize_csv_value(value) value.dup.prepend("'") end + + def count_space_children(children_array) + return 0 if children_array.blank? + + children_array.reduce(0) do |sum, child| + item_count = if child[:item_count].to_i.positive? + child[:item_count].to_i + (spaceable_group?(child) ? 1 : 0) + elsif child[:kind] == "space" || spaceable_group?(child) + 1 + else + 0 + end + sum + item_count + end + end + + def spaceable_group?(node) + %w(group sub_group).include?(node[:kind]) && + node[:manifest].present? && + node[:manifest]["#{node[:kind]}_class".to_sym] == node[:manifest][:space_class] + end end end end diff --git a/app/views/decidim/admin/content/tree/_accordion_trigger.html.erb b/app/views/decidim/admin/content/tree/_accordion_trigger.html.erb new file mode 100644 index 0000000000..9f415ef4e0 --- /dev/null +++ b/app/views/decidim/admin/content/tree/_accordion_trigger.html.erb @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/app/views/decidim/admin/content/tree/_article.html.erb b/app/views/decidim/admin/content/tree/_article.html.erb new file mode 100644 index 0000000000..b9fe335bcd --- /dev/null +++ b/app/views/decidim/admin/content/tree/_article.html.erb @@ -0,0 +1,90 @@ +<% depth = local_assigns[:depth] || 0 %> +<% item_prefix = local_assigns[:item_prefix] || SecureRandom.uuid.underscore %> +<% siblings_with_children = local_assigns[:siblings_with_children] || false %> +<%# depth += 1 if item[:children].blank? && siblings_with_children %> +
+
+
+
+
+
+ + <% if item[:children]&.any? %> + <%= render partial: "accordion_trigger", locals: { target: item_prefix + "__children" } %> + <% elsif item[:kind] != "component" %> + + <% end %> + <% if item[:icon].present? %> + <%= icon(item[:icon], class: "inline-block") %> + <% end %> + + + <%= item[:name] %> + <% if item[:item_count].to_i.positive? %> + <%= item[:item_count] %> + <% end %> + +
+ <% if item.dig(:metadata, :labels).present? %> +
+ <% item[:metadata][:labels].each do |key, label| %> + + <%= icon(label[:icon]) if label[:icon].present? %> + <%= label[:text] || label[:value] %> + + <% end %> +
+ <% end %> +
+ <% if item.dig(:metadata, :stats).present? %> +
+ <% item[:metadata][:stats].except(:components_count).each do |key, label| %> + + <%= label[:value] %> + <%= label[:text] %> + + <% end %> +
+ <% end %> +
+
+ <% button_cell_attributes = { button_classes: "button button__xs button__transparent-secondary", html_options: { target: "_blank", data: { "external-link": false } } } %> + <% if item[:path].present? %> + <%= cell("decidim/button", { text: "View", icon: "eye-line", path: item[:path] }, button_cell_attributes) %> + <% end %> + <% if item[:admin_path].present? %> + <%= cell("decidim/button", { text: "Admin", icon: "settings-4-line", path: item[:admin_path] }, button_cell_attributes) %> + <% end %> +
+
+ <% if item[:components]&.any? %> +
+
+
+
+ + <%= render partial: "accordion_trigger", locals: { target: item_prefix + "__components" } %> + <%= icon("apps-2-line", class: "inline-block") %> + + + Components + <%= item[:components].size %> + +
+
+ <%= cell("decidim/button", { text: "Admin", icon: "settings-4-line", path: Decidim::EngineRouter.admin_proxy(item[:object]).send("components_path") }, button_cell_attributes) %> +
+
+
" aria-hidden="true" class="accordion-group"> + <%= render partial: "article", collection: item[:components], as: :item, locals: { depth: depth + 2 } %> +
+
+
+ <% end %> + <% if item[:children]&.any? %> +
" aria-hidden="true" class="children accordion-group"> + <%= render partial: "article", collection: item[:children], as: :item, locals: { depth: depth + 1, siblings_with_children: any_grand_children?(item) } %> +
+ <% end %> +
+
diff --git a/app/views/decidim/admin/content/tree/index.html.erb b/app/views/decidim/admin/content/tree/index.html.erb index edc990ae21..2adf4fa172 100644 --- a/app/views/decidim/admin/content/tree/index.html.erb +++ b/app/views/decidim/admin/content/tree/index.html.erb @@ -1,41 +1,30 @@
-

Content Tree

+

+
<%= I18n.t("decidim.admin.content.tree.views.index.title") %>
+
+ <%= link_to I18n.t("decidim.admin.content.tree.views.index.actions.export_to_csv"), decidim_admin.content_export_path(format: :csv), class: "button button__sm button__secondary" %> +
+

-
- - <%# - - - - - - %> - - <% content_tree[:children].each do |participatory_spaces| %> - <% participatory_space_type_prefix = participatory_spaces[:manifest][:item_class].to_s.demodulize.underscore.pluralize %> - - - +
+ <% content_tree[:children].each do |participatory_spaces| %> + <% participatory_space_type_prefix = participatory_spaces[:manifest][:space_class].to_s.demodulize.underscore.pluralize %> +
+
+ <%= render partial: "accordion_trigger", locals: { target: participatory_space_type_prefix + "__group" } %> +

+ <%= participatory_spaces[:name] %> + <% if participatory_spaces[:item_count].present? %> + <%= participatory_spaces[:item_count] %> + <% end %> +

+
+
" aria-hidden="true" class="accordion-group"> <% participatory_spaces[:children].each do |participatory_space| %> - <%= render partial: "table/row", locals: { item: participatory_space } %> + <%= render partial: "article", locals: { item: participatory_space, depth: 1, siblings_with_children: any_grand_children?(participatory_spaces) } %> <% end %> - <% end %> -
-
- name - - status - - actions -
-
"> -

- <%= participatory_spaces[:name] %> -

-
-
-
-
-
<%= JSON.pretty_generate(content_tree) %>
+
+ + <% end %> \ No newline at end of file diff --git a/app/views/decidim/admin/content/tree/table.html.erb b/app/views/decidim/admin/content/tree/table.html.erb new file mode 100644 index 0000000000..668ba7251d --- /dev/null +++ b/app/views/decidim/admin/content/tree/table.html.erb @@ -0,0 +1,36 @@ +
+

<%= I18n.t("decidim.admin.content.tree.views.table.title") %>

+
+ +
+ + <% content_tree[:children].each do |participatory_spaces| %> + <% participatory_space_type_prefix = participatory_spaces[:manifest][:space_class].to_s.demodulize.underscore.pluralize %> + + + + + + "> + <% participatory_spaces[:children].each do |participatory_space| %> + <%= render partial: "table/row", locals: { item: participatory_space, group_prefix: participatory_space_type_prefix + "__group" } %> + <% end %> + + <% end %> +
"> +

+ <%= participatory_spaces[:name] %> + <% if participatory_spaces[:item_count].present? %> + + <%= participatory_spaces[:item_count] %> + + <% end %> +

+
+
+<% if Rails.env.development? %> +
+
+
<%= JSON.pretty_generate(content_tree) %>
+
+<% end %> \ No newline at end of file diff --git a/app/views/decidim/admin/content/tree/table/_row.html.erb b/app/views/decidim/admin/content/tree/table/_row.html.erb index 2783db676f..7988085e95 100644 --- a/app/views/decidim/admin/content/tree/table/_row.html.erb +++ b/app/views/decidim/admin/content/tree/table/_row.html.erb @@ -36,7 +36,16 @@ - -- status -- + <% if item[:path].present? %> +
+ <%= link_to "View", item[:path], target: "_blank", data: { "external-link": false } %> +
+ <% end %> + <% if item[:admin_path].present? %> +
+ <%= link_to "Admin", item[:admin_path], target: "_blank", data: { "external-link": false } %> +
+ <% end %> A @@ -45,8 +54,8 @@ <% item[:components]&.each do |component| %> - <%= render partial: "table/row", locals: { item: component, ident_level: ident_level + 1 } %> +<%= render partial: "table/row", locals: { item: component, ident_level: ident_level + 1 } %> <% end %> <% if item[:children]&.any? %> - <%= render partial: "table/row", collection: item[:children], as: :item, locals: { ident_level: ident_level + 1 } %> +<%= render partial: "table/row", collection: item[:children], as: :item, locals: { ident_level: ident_level + 1 } %> <% end %> \ No newline at end of file diff --git a/app/views/decidim/admin/content/tree/treeview.html.erb b/app/views/decidim/admin/content/tree/treeview.html.erb deleted file mode 100644 index 31db510318..0000000000 --- a/app/views/decidim/admin/content/tree/treeview.html.erb +++ /dev/null @@ -1,6 +0,0 @@ - -
-<%= mermaid_treeview %>
-
\ No newline at end of file diff --git a/config/initializers/icons.rb b/config/initializers/icons.rb index 96acef7557..dfdd2ad883 100644 --- a/config/initializers/icons.rb +++ b/config/initializers/icons.rb @@ -1,5 +1,19 @@ # frozen_string_literal: true -Decidim.icons.register(name: "pages-line", icon: "pages-line", category: "system", description: "", engine: :core) -Decidim.icons.register(name: "stack-line", icon: "stack-line", category: "system", description: "", engine: :core) -Decidim.icons.register(name: "price-tag-3-line", icon: "price-tag-3-line", category: "system", description: "", engine: :core) +%w( + pages-line + stack-line + price-tag-3-line + bar-chart-line + pen-nib-line + coin-line + discuss-line + map-pin-line + pages-line + chat-new-line + billiards-line + survey-line + node-tree +).each do |icon_name| + Decidim.icons.register(name: icon_name, icon: icon_name, category: "system", description: "", engine: :core) +end diff --git a/config/initializers/reversibility.rb b/config/initializers/reversibility.rb new file mode 100644 index 0000000000..34f4756687 --- /dev/null +++ b/config/initializers/reversibility.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +Decidim.menu :admin_menu do |menu| + menu.add_item :content, + I18n.t("menu.reversibility", scope: "decidim.admin", default: "Content Tree"), + decidim_admin.content_root_path, + icon_name: "node-tree", + position: 15, + active: is_active_link?(decidim_admin.content_root_path), + if: allowed_to?(:update, :organization, organization: current_organization) +end diff --git a/config/locales/admin_content_tree/en.yml b/config/locales/admin_content_tree/en.yml index da02d8a24f..90e30dff6c 100644 --- a/config/locales/admin_content_tree/en.yml +++ b/config/locales/admin_content_tree/en.yml @@ -8,4 +8,23 @@ en: private: "Private" published: "Published" public: "Public" - unpublished: "Unpublished" \ No newline at end of file + unpublished: "Not published" + stats: + attachments: "Attachments" + authors: "Authors" + categories: "Categories" + comments: "Comments" + components: "Components" + followers: "Followers" + hidden_moderations: "Hidden content" + moderations: "Moderations" + signatures: "Signatures" + views: + index: + title: "Content Tree" + actions: + export_to_csv: "Export tree to CSV" + table: + title: "Content Table" + menu: + reversibility: "Content Tree" \ No newline at end of file diff --git a/config/locales/admin_content_tree/fr.yml b/config/locales/admin_content_tree/fr.yml new file mode 100644 index 0000000000..aafea76557 --- /dev/null +++ b/config/locales/admin_content_tree/fr.yml @@ -0,0 +1,30 @@ +fr: + decidim: + admin: + content: + tree: + label: + previously_published: "Publié précédemment" + private: "Privé" + published: "Publié" + public: "Public" + unpublished: "Non publié" + stats: + attachments: "Pièces jointes" + authors: "Auteurs" + categories: "Catégories" + comments: "Commentaires" + components: "Fonctionnalités" + followers: "Abonnés" + hidden_moderations: "Contenus cachés" + moderations: "Modérations" + signatures: "Signatures" + views: + index: + title: "Arbre de contenu" + actions: + export_to_csv: "Exporter l'arbre en CSV" + table: + title: "Tableau de contenu" + menu: + reversibility: "Arbre de contenu" \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 50f1dc9269..de697bd567 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -8,8 +8,8 @@ namespace :content do root to: "tree#index" get "export", to: "tree#export" + get "table", to: "tree#table" get "treemap", to: "tree#treemap" - get "treeview", to: "tree#treeview" end end end From de187a2cdeb5e0a020452a3d0d8044efc690768b Mon Sep 17 00:00:00 2001 From: moustachu Date: Thu, 28 May 2026 02:12:24 +0200 Subject: [PATCH 03/24] [WIP] fix: lint code --- app/services/decidim/content/tree_generator.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/decidim/content/tree_generator.rb b/app/services/decidim/content/tree_generator.rb index b6aaf28315..005baea828 100644 --- a/app/services/decidim/content/tree_generator.rb +++ b/app/services/decidim/content/tree_generator.rb @@ -64,7 +64,7 @@ class TreeGenerator sub_group: "folder-line", space: "pages-line", component: "focus-line" - } + }.freeze DEFAULT_OPTIONS = { include_components: true, From 2697c0cb79d57d05de53622634c2a931bd1e58a4 Mon Sep 17 00:00:00 2001 From: moustachu Date: Wed, 3 Jun 2026 10:43:32 +0200 Subject: [PATCH 04/24] [WIP] feat: reversibility content bundle export --- .../admin/content/bundle_controller.rb | 29 +++ .../decidim/admin/content/tree_controller.rb | 2 +- .../decidim/content/_blank_serializer.rb | 15 ++ .../decidim/content/area_serializer.rb | 20 ++ .../attachment_collection_serializer.rb | 18 ++ .../decidim/content/attachment_serializer.rb | 20 ++ .../decidim/content/category_serializer.rb | 27 ++ .../decidim/content/component_serializer.rb | 21 ++ .../content/organization_serializer.rb | 78 ++++++ .../participatory_process_group_serializer.rb | 31 +++ .../participatory_process_serializer.rb | 43 ++++ .../participatory_process_step_serializer.rb | 23 ++ .../participatory_space_user_serializer.rb | 16 ++ .../decidim/content/scope_serializer.rb | 30 +++ .../decidim/content/user_serializer.rb | 48 ++++ .../decidim/content/location_generator.rb | 12 +- app/services/decidim/content/csv_bundler.rb | 238 ++++++++++++++++++ app/services/decidim/content/csv_exporter.rb | 50 ++++ .../decidim/content/tree_generator.rb | 6 +- .../decidim/admin/content/tree/index.html.erb | 2 +- config/application.rb | 5 + config/routes.rb | 3 +- .../decidim/content/serializer_tools.rb | 25 ++ lib/concerns/decidim/content/uid_tools.rb | 23 ++ lib/concerns/decidim/content/url_tools.rb | 33 +++ 25 files changed, 801 insertions(+), 17 deletions(-) create mode 100644 app/controllers/decidim/admin/content/bundle_controller.rb create mode 100644 app/serializers/decidim/content/_blank_serializer.rb create mode 100644 app/serializers/decidim/content/area_serializer.rb create mode 100644 app/serializers/decidim/content/attachment_collection_serializer.rb create mode 100644 app/serializers/decidim/content/attachment_serializer.rb create mode 100644 app/serializers/decidim/content/category_serializer.rb create mode 100644 app/serializers/decidim/content/component_serializer.rb create mode 100644 app/serializers/decidim/content/organization_serializer.rb create mode 100644 app/serializers/decidim/content/participatory_process_group_serializer.rb create mode 100644 app/serializers/decidim/content/participatory_process_serializer.rb create mode 100644 app/serializers/decidim/content/participatory_process_step_serializer.rb create mode 100644 app/serializers/decidim/content/participatory_space_user_serializer.rb create mode 100644 app/serializers/decidim/content/scope_serializer.rb create mode 100644 app/serializers/decidim/content/user_serializer.rb create mode 100644 app/services/decidim/content/csv_bundler.rb create mode 100644 app/services/decidim/content/csv_exporter.rb create mode 100644 lib/concerns/decidim/content/serializer_tools.rb create mode 100644 lib/concerns/decidim/content/uid_tools.rb create mode 100644 lib/concerns/decidim/content/url_tools.rb diff --git a/app/controllers/decidim/admin/content/bundle_controller.rb b/app/controllers/decidim/admin/content/bundle_controller.rb new file mode 100644 index 0000000000..d3fb22e605 --- /dev/null +++ b/app/controllers/decidim/admin/content/bundle_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Decidim + module Admin + module Content + class BundleController < Decidim::Admin::ApplicationController + before_action :default_permissions + + def export + zip_data = Decidim::Content::CsvBundler.new(organization: current_organization).export_to_archive + + respond_to do |format| + format.zip do + send_data zip_data.read, + filename: "content-bundle--#{Time.zone.now.strftime("%Y%m%d-%H%M%S")}.zip", + type: "application/zip" + end + end + end + + private + + def default_permissions + enforce_permission_to :update, :organization, organization: current_organization + end + end + end + end +end diff --git a/app/controllers/decidim/admin/content/tree_controller.rb b/app/controllers/decidim/admin/content/tree_controller.rb index a975ba772c..7f2d97bfa9 100644 --- a/app/controllers/decidim/admin/content/tree_controller.rb +++ b/app/controllers/decidim/admin/content/tree_controller.rb @@ -27,7 +27,7 @@ def export respond_to do |format| format.csv do send_data csv_data.string, - filename: "content_tree_#{Time.zone.now.strftime("%Y%m%d_%H%M%S")}.csv", + filename: "content-tree--#{Time.zone.now.strftime("%Y%m%d-%H%M%S")}.csv", type: "text/csv" end end diff --git a/app/serializers/decidim/content/_blank_serializer.rb b/app/serializers/decidim/content/_blank_serializer.rb new file mode 100644 index 0000000000..bced904915 --- /dev/null +++ b/app/serializers/decidim/content/_blank_serializer.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Decidim + module Content + class BlankSerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(resource) + } + end + end + end +end diff --git a/app/serializers/decidim/content/area_serializer.rb b/app/serializers/decidim/content/area_serializer.rb new file mode 100644 index 0000000000..d801fc41d2 --- /dev/null +++ b/app/serializers/decidim/content/area_serializer.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Decidim + module Content + class AreaSerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(resource), + # decidim_organization_id: resource.decidim_organization_id, + name: normalize_translated_attribute(resource.name), + area_type: normalize_translated_attribute(resource.area_type&.name), + created_at: resource.created_at, + updated_at: resource.updated_at + } + end + end + end +end diff --git a/app/serializers/decidim/content/attachment_collection_serializer.rb b/app/serializers/decidim/content/attachment_collection_serializer.rb new file mode 100644 index 0000000000..9d8b93677d --- /dev/null +++ b/app/serializers/decidim/content/attachment_collection_serializer.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Decidim + module Content + class AttachmentCollectionSerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(resource), + name: normalize_translated_attribute(resource.name), + weight: resource.try(:weight), + description: normalize_translated_attribute(resource.description) + } + end + end + end +end diff --git a/app/serializers/decidim/content/attachment_serializer.rb b/app/serializers/decidim/content/attachment_serializer.rb new file mode 100644 index 0000000000..6aeadc9da7 --- /dev/null +++ b/app/serializers/decidim/content/attachment_serializer.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Decidim + module Content + class AttachmentSerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(resource), + title: normalize_translated_attribute(resource.title), + description: normalize_translated_attribute(resource.description), + weight: resource.try(:weight), + # file: Decidim::AttachmentPresenter.new(resource).attachment_file_url + file: blob_url(resource.file, resource.organization) + } + end + end + end +end diff --git a/app/serializers/decidim/content/category_serializer.rb b/app/serializers/decidim/content/category_serializer.rb new file mode 100644 index 0000000000..9318d08551 --- /dev/null +++ b/app/serializers/decidim/content/category_serializer.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Decidim + module Content + class CategorySerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(resource), + parent: parent_uid, + name: normalize_translated_attribute(resource.name), + description: normalize_translated_attribute(resource.description), + weight: resource.try(:weight), + text_color: resource.try(:text_color), + background_color: resource.try(:background_color) + } + end + + private + + def parent_uid + uid(Decidim::Category.new(id: resource.parent_id)) if resource.parent_id.present? + end + end + end +end diff --git a/app/serializers/decidim/content/component_serializer.rb b/app/serializers/decidim/content/component_serializer.rb new file mode 100644 index 0000000000..a8a5edb3fb --- /dev/null +++ b/app/serializers/decidim/content/component_serializer.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Decidim + module Content + class ComponentSerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(resource), + manifest_name: resource.manifest_name, + name: normalize_translated_attribute(resource.name), + settings: resource[:settings], + weight: resource.try(:weight), + permissions: resource.try(:permissions), + published_at: resource.try(:published_at) + }.merge(specific_data: resource.manifest.specific_data_serializer_class&.new(resource)&.run) + end + end + end +end diff --git a/app/serializers/decidim/content/organization_serializer.rb b/app/serializers/decidim/content/organization_serializer.rb new file mode 100644 index 0000000000..756b6b53cb --- /dev/null +++ b/app/serializers/decidim/content/organization_serializer.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Decidim + module Content + class OrganizationSerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(resource), + host: resource.host, + reference_prefix: resource.reference_prefix, + created_at: resource.created_at, + updated_at: resource.updated_at, + default_locale: resource.default_locale, + available_locales: resource.available_locales, + time_zone: resource.time_zone, + name: normalize_translated_attribute(resource.name), + description: normalize_translated_attribute(resource.description), + logo: blob_url(resource.logo, resource), + favicon: blob_url(resource.favicon, resource), + official_url: resource.official_url, + official_img_footer: blob_url(resource.official_img_footer, resource), + twitter_handler: resource.twitter_handler, + instagram_handler: resource.instagram_handler, + facebook_handler: resource.facebook_handler, + youtube_handler: resource.youtube_handler, + github_handler: resource.github_handler, + # cta_button_text: resource.cta_button_text, + # cta_button_path: resource.cta_button_path, + # enable_omnipresent_banner: resource.enable_omnipresent_banner, + # omnipresent_banner_title: resource.omnipresent_banner_title, + # omnipresent_banner_short_description: resource.omnipresent_banner_short_description, + # omnipresent_banner_url: resource.omnipresent_banner_url, + # highlighted_content_banner_enabled: resource.highlighted_content_banner_enabled, + # highlighted_content_banner_title: resource.highlighted_content_banner_title, + # highlighted_content_banner_short_description: resource.highlighted_content_banner_short_description, + # highlighted_content_banner_action_title: resource.highlighted_content_banner_action_title, + # highlighted_content_banner_action_subtitle: resource.highlighted_content_banner_action_subtitle, + # highlighted_content_banner_action_url: resource.highlighted_content_banner_action_url, + # highlighted_content_banner_image: blob_url(resource.highlighted_content_banner_image, resource), + # tos_version: resource.tos_version, + # badges_enabled: resource.badges_enabled, + # send_welcome_notification: resource.send_welcome_notification, + # welcome_notification_subject: resource.welcome_notification_subject, + # welcome_notification_body: resource.welcome_notification_body, + # id_documents_methods: resource.id_documents_methods, + # id_documents_explanation_text: resource.id_documents_explanation_text, + # user_groups_enabled: resource.user_groups_enabled, + # deepl_api_key: resource.deepl_api_key, + # comments_max_length: resource.comments_max_length, + # rich_text_editor_in_public_views: resource.rich_text_editor_in_public_views, + # admin_terms_of_service_body: resource.admin_terms_of_service_body, + # enable_machine_translations: resource.enable_machine_translations, + # machine_translation_display_priority: resource.machine_translation_display_priority, + # enable_participatory_space_filters: resource.enable_participatory_space_filters, + # delete_admin_logs: resource.delete_admin_logs, + # delete_admin_logs_after: resource.delete_admin_logs_after, + # delete_inactive_users: resource.delete_inactive_users, + # delete_inactive_users_email_after: resource.delete_inactive_users_email_after, + # delete_inactive_users_after: resource.delete_inactive_users_after, + # secondary_hosts: resource.secondary_hosts, + content_security_policy: resource.content_security_policy, + # external_domain_allowlist: resource.external_domain_allowlist, + header_snippets: resource.header_snippets, + # colors: resource.colors, + file_upload_settings: resource.file_upload_settings, + smtp_settings: resource.smtp_settings.slice("from", "from_email", "from_label"), + available_authorizations: resource.available_authorizations, + users_registration_mode: resource.users_registration_mode, + force_users_to_authenticate_before_access_organization: resource.force_users_to_authenticate_before_access_organization, + extra_user_fields: resource.extra_user_fields, + omniauth_settings: resource.omniauth_settings + } + end + end + end +end diff --git a/app/serializers/decidim/content/participatory_process_group_serializer.rb b/app/serializers/decidim/content/participatory_process_group_serializer.rb new file mode 100644 index 0000000000..1d1f28aa25 --- /dev/null +++ b/app/serializers/decidim/content/participatory_process_group_serializer.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Decidim + module Content + class ParticipatoryProcessGroupSerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(resource), + title: normalize_translated_attribute(resource.title), + description: normalize_translated_attribute(resource.description), + hero_image: blob_url(resource.hero_image, resource.organization), + promoted: resource.promoted, + metadata: { + hashtag: resource.hashtag, + group_url: resource.group_url, + developer_group: resource.developer_group, + local_area: resource.local_area, + meta_scope: resource.meta_scope, + target: resource.target, + participatory_scope: resource.participatory_scope, + participatory_structure: resource.participatory_structure + }, + created_at: resource.created_at, + updated_at: resource.updated_at + } + end + end + end +end diff --git a/app/serializers/decidim/content/participatory_process_serializer.rb b/app/serializers/decidim/content/participatory_process_serializer.rb new file mode 100644 index 0000000000..e56a3892ec --- /dev/null +++ b/app/serializers/decidim/content/participatory_process_serializer.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Decidim + module Content + class ParticipatoryProcessSerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(resource), + title: normalize_translated_attribute(resource.title), + subtitle: normalize_translated_attribute(resource.subtitle), + slug: resource.slug, + short_description: normalize_translated_attribute(resource.short_description), + description: normalize_translated_attribute(resource.description), + announcement: normalize_translated_attribute(resource.announcement), + start_date: resource.start_date, + end_date: resource.end_date, + hero_image: blob_url(resource.hero_image, resource.organization), + participatory_process_group: uid(resource.try(:participatory_process_group)), + participatory_process_type: normalize_translated_attribute(resource.participatory_process_type.try(:title)), + area: uid(resource.try(:area)), + scope: uid(resource.try(:scope)), + private_space: resource.private_space, + promoted: resource.promoted, + scopes_enabled: resource.scopes_enabled, + metadata: { + hashtag: resource.hashtag, + developer_group: resource.developer_group, + local_area: resource.local_area, + meta_scope: resource.meta_scope, + target: resource.target, + participatory_scope: resource.participatory_scope, + participatory_structure: resource.participatory_structure + }, + created_at: resource.created_at, + updated_at: resource.updated_at, + published_at: resource.try(:published_at) + } + end + end + end +end diff --git a/app/serializers/decidim/content/participatory_process_step_serializer.rb b/app/serializers/decidim/content/participatory_process_step_serializer.rb new file mode 100644 index 0000000000..bd2e1ca385 --- /dev/null +++ b/app/serializers/decidim/content/participatory_process_step_serializer.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Decidim + module Content + class ParticipatoryProcessStepSerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(resource), + title: normalize_translated_attribute(resource.title), + description: normalize_translated_attribute(resource.description), + start_date: resource.try(:start_date), + end_date: resource.try(:end_date), + active: resource.try(:active), + cta_path: resource.try(:cta_path), + cta_text: normalize_translated_attribute(resource.cta_text), + position: resource.try(:position) + } + end + end + end +end diff --git a/app/serializers/decidim/content/participatory_space_user_serializer.rb b/app/serializers/decidim/content/participatory_space_user_serializer.rb new file mode 100644 index 0000000000..c05a14f101 --- /dev/null +++ b/app/serializers/decidim/content/participatory_space_user_serializer.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Decidim + module Content + class ParticipatorySpaceUserSerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(Decidim::User.new(id: resource[:decidim_user_id])), + role: resource[:role] + } + end + end + end +end diff --git a/app/serializers/decidim/content/scope_serializer.rb b/app/serializers/decidim/content/scope_serializer.rb new file mode 100644 index 0000000000..e7a2859c2f --- /dev/null +++ b/app/serializers/decidim/content/scope_serializer.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Decidim + module Content + class ScopeSerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(resource), + parent: parent_uid, + code: resource.code, + name: normalize_translated_attribute(resource.name), + scope_type: normalize_translated_attribute(resource.scope_type&.name), + # part_of: resource.part_of.map { |id| uid(Decidim::Scope.new(id:)) }, + # geojson: resource.geojson, + weight: resource.weight, + created_at: resource.created_at, + updated_at: resource.updated_at + } + end + + private + + def parent_uid + uid(Decidim::Scope.new(id: resource.parent_id)) if resource.parent_id.present? + end + end + end +end diff --git a/app/serializers/decidim/content/user_serializer.rb b/app/serializers/decidim/content/user_serializer.rb new file mode 100644 index 0000000000..3309cb1bee --- /dev/null +++ b/app/serializers/decidim/content/user_serializer.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Decidim + module Content + class UserSerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(resource), + type: resource.type, + blocked: resource.blocked, + email: resource.email, + name: resource.name, + nickname: resource.nickname, + locale: resource.locale, + admin: resource.admin, + roles: resource.roles, + personal_url: resource.personal_url, + about: resource.about, + avatar: blob_url(resource.avatar, resource.organization), + extended_data: serialize_extended_data, + notification_settings: resource.notification_settings, + direct_message_types: resource.direct_message_types, + created_at: resource.created_at, + updated_at: resource.updated_at, + confirmed_at: resource.confirmed_at, + unconfirmed_email: resource.unconfirmed_email, + deleted_at: resource.deleted_at, + delete_reason: resource.delete_reason + } + end + + def serialize_extended_data + data = resource.extended_data || {} + data["phone_number"] = format_phone_number(resource.phone_number, resource.phone_country) if resource.phone_number.present? + data + end + + private + + def format_phone_number(phone_number, phone_country) + prefix = "+#{ISO3166::Country.find_country_by_alpha2(phone_country)&.country_code}" if phone_country.present? + "#{prefix}#{phone_number}" + end + end + end +end diff --git a/app/services/concerns/decidim/content/location_generator.rb b/app/services/concerns/decidim/content/location_generator.rb index 2d34c7a415..d589c3811f 100644 --- a/app/services/concerns/decidim/content/location_generator.rb +++ b/app/services/concerns/decidim/content/location_generator.rb @@ -5,6 +5,8 @@ module Content module LocationGenerator extend ActiveSupport::Concern included do + include Decidim::Content::UrlTools + def location_for(instance, location_type = :path) # TODO : create options for path versus url @@ -92,16 +94,6 @@ def admin_base_path def admin_base_url @admin_base_url ||= switch_url_port(Decidim::Admin::Engine.routes.url_helpers.root_url(host: organization.host)) end - - def switch_url_port(url) - if Rails.env.development? || Rails.env.test? - url_with_port = URI(url) - url_with_port.port = Rails::Server::Options.new.parse!(ARGV)[:Port] - url_with_port.to_s - else - url - end - end end end end diff --git a/app/services/decidim/content/csv_bundler.rb b/app/services/decidim/content/csv_bundler.rb new file mode 100644 index 0000000000..fc778fb4c9 --- /dev/null +++ b/app/services/decidim/content/csv_bundler.rb @@ -0,0 +1,238 @@ +# frozen_string_literal: true + +# organization = Decidim::Organization.first +# bundler = Decidim::Content::CsvBundler.new(organization: organization) +# bundler.export_to_directory +# +# reload!; Decidim::Content::CsvBundler.new(organization: Decidim::Organization.first).export_to_directory + +module Decidim + module Content + class CsvBundler + include Decidim::Content::UidTools + + DEFAULT_OPTIONS = { + flatten_json: false, + export_mode: :archive + }.freeze + + attr_reader :organization, :options + + def initialize(organization:, **options) + @organization = organization + @options = DEFAULT_OPTIONS.merge(options) + end + + def bundle + @bundle ||= parse_bundle_manifests( + [ + { + path: "organization", + serializer: Decidim::Content::OrganizationSerializer, + collection: [organization] + }, + { + path: "users", + serializer: Decidim::Content::UserSerializer, + collection: organization.users.reorder("id ASC") + }, + { + path: "scopes", + serializer: Decidim::Content::ScopeSerializer, + collection: organization.scopes.includes(:scope_type).reorder("id ASC") + }, + { + path: "areas", + serializer: Decidim::Content::AreaSerializer, + collection: organization.areas.includes(:area_type).reorder("id ASC") + }, + { + path: "participatory-processes", + children: [ + { + path: "participatory-process-groups", + serializer: Decidim::Content::ParticipatoryProcessGroupSerializer, + collection: Decidim::ParticipatoryProcessGroup.where(organization:).reorder("id ASC") + }, + { + path: ->(resource) { uid(resource) }, + collection: Decidim::ParticipatoryProcess.where(organization:).reorder("id ASC"), + children: [ + { + path: "participatory-process", + serializer: Decidim::Content::ParticipatoryProcessSerializer, + collection: ->(parent) { [parent] } + }, + { + path: "steps", + serializer: Decidim::Content::ParticipatoryProcessStepSerializer, + collection: ->(parent) { parent.steps.reorder("start_date ASC") } + }, + { + path: "categories", + serializer: Decidim::Content::CategorySerializer, + collection: ->(parent) { parent.categories } + }, + { + path: "attachment_collections", + serializer: Decidim::Content::AttachmentCollectionSerializer, + collection: ->(parent) { parent.attachment_collections } + }, + { + path: "attachments", + serializer: Decidim::Content::AttachmentSerializer, + collection: ->(parent) { parent.attachments } + }, + { + path: "components", + serializer: Decidim::Content::ComponentSerializer, + collection: ->(parent) { parent.components } + }, + { + path: "users", + serializer: Decidim::Content::ParticipatorySpaceUserSerializer, + collection: ->(parent) { participatory_process_users(parent) } + } + ] + } + ] + } + ] + ) + end + + def export_to_directory + bundle.each do |exportable| + exportable_path = File.join(local_export_path, "#{exportable[:path]}.csv") + FileUtils.mkdir_p(File.dirname(exportable_path)) + File.write(exportable_path, exportable[:data]) + end.count + end + + def export_to_archive + buffer = Zip::OutputStream.write_buffer do |output| + bundle.each do |exportable| + output.put_next_entry("#{exportable[:path]}.csv") + output.write(exportable[:data]) + end + end + buffer.rewind + buffer + # File.binwrite("#{local_export_path}.zip", buffer.read) + + # blob = ActiveStorage::Blob.create_and_upload!(io: buffer, filename: "#{archive_name}.zip", content_type: "application/zip") + # puts "Download link is : #{root_url}#{Rails.application.routes.url_helpers.rails_blob_url(blob, only_path: true)}" + end + + private + + # rubocop:disable Metrics/PerceivedComplexity + def parse_bundle_manifests(bundle_manifests, object: nil) + bundle_manifests.each.with_index.inject([]) do |bundle_results, (manifest, index)| + results = [] + + begin + validate_manifest!(manifest, object:) + + manifest[:collection] = manifest[:collection].call(object) if manifest[:collection].respond_to?(:call) + + if manifest[:serializer].present? + if manifest[:collection].present? + results << manifest.merge( + path: compute_path(manifest[:path], object:, index:), + data: generate_csv_for(manifest) + ) + end + elsif manifest[:children].present? + if manifest[:collection].present? + results.concat( + manifest[:collection].each.with_index.inject([]) do |collection_results, (collection_item, collection_index)| + collection_item_results = parse_bundle_manifests(manifest[:children], object: collection_item) + add_prefix_path_to_children(collection_item_results, prefix: compute_path(manifest[:path], object: collection_item, index: collection_index + index)) + collection_results.concat(collection_item_results) + end + ) + else + manifest[:path] = compute_path(manifest[:path], object:, index:) + results.concat(parse_bundle_manifests(manifest[:children], object:)) + add_prefix_path_to_children(results, prefix: manifest[:path]) + end + end + bundle_results.concat(results) + rescue ArgumentError => e + Rails.logger.error "#{e.message}. Manifest will be ignored." + Rails.logger.error "Manifest : #{inspect_manifest(manifest)}" + next bundle_results + end + end + end + # rubocop:enable Metrics/PerceivedComplexity + + # rubocop:disable Metrics/CyclomaticComplexity + # rubocop:disable Metrics/PerceivedComplexity + def validate_manifest!(manifest, object: nil) + raise ArgumentError, "Manifest must have a path" if manifest[:path].nil? + + raise ArgumentError, "Manifest with serializer must have a collection" if manifest[:serializer].present? && manifest[:collection].blank? + + raise ArgumentError, "Manifest with both children and serializer are not yet supported" if manifest[:children].present? && manifest[:serializer].present? + + if manifest[:path].respond_to?(:call) && object.nil? && manifest[:collection].nil? + raise ArgumentError, "Manifest path is a lambda but no object or collection were provided to compute it" + end + + raise ArgumentError, "Manifest collection is a lambda but no object was provided to compute it" if manifest[:collection].respond_to?(:call) && object.nil? + end + # rubocop:enable Metrics/CyclomaticComplexity + # rubocop:enable Metrics/PerceivedComplexity + + def compute_path(path, object: nil, index: nil) + path = path.call(object) if path.respond_to?(:call) && !object.nil? + path = "#{format("%02d", index + 1)}---#{path}" if index.present? + path + end + + def add_prefix_path_to_children(manifests, prefix: "") + manifests.each do |manifest| + manifest[:path] = File.join(prefix, manifest[:path]) + end + end + + def inspect_manifest(manifest) + manifest.merge( + children_count: manifest[:children]&.size, + collection: manifest[:collection]&.class&.name + ).except(:children).to_s + end + + def bundle_prefix + "#{organization.host.parameterize}--#{Time.zone.now.strftime("%Y%m%d%H%M%S")}" + end + + def local_export_path + @local_export_path ||= Rails.root.join("tmp/exports/content", bundle_prefix) + end + + def csv_options + @csv_options ||= { + col_sep: "," + } + end + + def generate_csv_for(exportable) + Decidim::Content::CsvExporter.new( + collection: exportable[:collection], + serializer: exportable[:serializer], + flatten: options[:flatten_json], + csv_options: + ).export.read + end + + def participatory_process_users(participatory_process) + users_with_roles = participatory_process.user_roles.select(:decidim_user_id, :role).reorder("decidim_user_id").to_a + private_users = participatory_process.users.select(:decidim_user_id).reorder("decidim_user_id").to_a + (users_with_roles + private_users).uniq(&:decidim_user_id).map { |o| o.attributes.compact.symbolize_keys.merge(role: o.role || "private_user") } + end + end + end +end diff --git a/app/services/decidim/content/csv_exporter.rb b/app/services/decidim/content/csv_exporter.rb new file mode 100644 index 0000000000..385ff53dc7 --- /dev/null +++ b/app/services/decidim/content/csv_exporter.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Decidim + module Content + class CsvExporter < Decidim::Exporters::CSV + DEFAULT_OPTIONS = { + flatten: false, + csv_options: { + col_sep: Decidim.default_csv_col_sep + } + }.freeze + + def initialize(collection:, serializer: Serializer, **options) + super(collection, serializer) + @options = DEFAULT_OPTIONS.merge(options) + end + + def export(col_sep = options.dig(:csv_options, :col_sep)) + data = ::CSV.generate(headers:, write_headers: true, col_sep:) do |csv| + processed_collection.each do |resource| + csv << headers.map { |header| custom_sanitize(resource[header]) } + end + end + Decidim::Exporters::ExportData.new(data, "csv") + end + + private + + attr_reader :options + + def processed_collection + @processed_collection ||= collection.map do |resource| + serialized_data = serializer.new(resource).run + serialized_data = options[:flatten] ? flatten(serialized_data) : convert_jsonable_atribute(serialized_data) + serialized_data.deep_dup + end + end + + def convert_jsonable_atribute(resource) + resource.transform_values do |value| + if value.is_a?(Hash) || value.is_a?(Array) + JSON.generate(value.compact_blank) + else + value + end + end + end + end + end +end diff --git a/app/services/decidim/content/tree_generator.rb b/app/services/decidim/content/tree_generator.rb index 005baea828..520744bb5f 100644 --- a/app/services/decidim/content/tree_generator.rb +++ b/app/services/decidim/content/tree_generator.rb @@ -1,8 +1,5 @@ # frozen_string_literal: true -require "csv" -require "stringio" - module Decidim module Content class TreeGenerator @@ -97,13 +94,14 @@ def build_tree ) end ) { |_key, old_value, new_value| old_value + new_value } + # TODO: remove spaces type without any records (eg: 0 conference) to avoid showing empty sections in the tree end def to_csv rows = flatten_hash_for_csv(hash) forced_headers = [:kind, :group, :sub_group, :space, :class, :component_type, :name, :private, :published, :item_count, :stats, :url, :admin_url, :gid] - rejected_headers = [:manifest] + rejected_headers = [:manifest, :components, :component_count, :hashtag] headers_row_hash = rows.each_with_object([]) do |row, keys| row.keys.each { |key| keys << key unless keys.include?(key) } diff --git a/app/views/decidim/admin/content/tree/index.html.erb b/app/views/decidim/admin/content/tree/index.html.erb index 2adf4fa172..ed6f7dddac 100644 --- a/app/views/decidim/admin/content/tree/index.html.erb +++ b/app/views/decidim/admin/content/tree/index.html.erb @@ -2,7 +2,7 @@

<%= I18n.t("decidim.admin.content.tree.views.index.title") %>
- <%= link_to I18n.t("decidim.admin.content.tree.views.index.actions.export_to_csv"), decidim_admin.content_export_path(format: :csv), class: "button button__sm button__secondary" %> + <%= link_to I18n.t("decidim.admin.content.tree.views.index.actions.export_to_csv"), decidim_admin.content_tree_export_path(format: :csv), class: "button button__sm button__secondary" %>

diff --git a/config/application.rb b/config/application.rb index 46188ef83d..c2488e1ffa 100644 --- a/config/application.rb +++ b/config/application.rb @@ -11,6 +11,11 @@ module DecidimApp class Application < Rails::Application config.load_defaults 7.0 + config.autoload_paths.push( + "#{root}/app/serializers", + "#{root}/lib/concerns" + ) + require "decidim_app/omniauth/configurator" config.after_initialize do diff --git a/config/routes.rb b/config/routes.rb index de697bd567..fa23cdbaab 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -7,9 +7,10 @@ constraints(->(request) { Decidim::Admin::OrganizationDashboardConstraint.new(request).matches? }) do namespace :content do root to: "tree#index" - get "export", to: "tree#export" get "table", to: "tree#table" get "treemap", to: "tree#treemap" + get "tree/export", to: "tree#export" + get "bundle/export", to: "bundle#export" end end end diff --git a/lib/concerns/decidim/content/serializer_tools.rb b/lib/concerns/decidim/content/serializer_tools.rb new file mode 100644 index 0000000000..940e77baf5 --- /dev/null +++ b/lib/concerns/decidim/content/serializer_tools.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "active_support/concern" + +module Decidim + module Content + module SerializerTools + extend ActiveSupport::Concern + included do + # include Decidim::ResourceHelper + include Decidim::TranslationsHelper + include Decidim::Content::UrlTools + include Decidim::Content::UidTools + + def normalize_translated_attribute(attribute) + # NOTES : if returning nil creates issues then return empty_translatable + return nil if attribute.blank? + return attribute unless attribute.is_a?(Hash) && attribute["machine_translations"].present? + + attribute.merge(attribute.delete("machine_translations")) + end + end + end + end +end diff --git a/lib/concerns/decidim/content/uid_tools.rb b/lib/concerns/decidim/content/uid_tools.rb new file mode 100644 index 0000000000..94d3102e18 --- /dev/null +++ b/lib/concerns/decidim/content/uid_tools.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "active_support/concern" + +module Decidim + module Content + module UidTools + extend ActiveSupport::Concern + included do + def uid(resource) + resource.to_gid.uri.path.underscore.parameterize(separator: "--").dasherize if resource.respond_to?(:to_gid) + end + + def locate_resource_by_uid(uid) + exploded = uid.split("--") + id = exploded.pop.to_i + model_name = exploded.map(&:underscore).map(&:camelize).join("::") + model_name.constantize.find(id) + end + end + end + end +end diff --git a/lib/concerns/decidim/content/url_tools.rb b/lib/concerns/decidim/content/url_tools.rb new file mode 100644 index 0000000000..ee8503cdad --- /dev/null +++ b/lib/concerns/decidim/content/url_tools.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "active_support/concern" + +module Decidim + module Content + module UrlTools + extend ActiveSupport::Concern + included do + def switch_url_port(url) + if (Rails.env.development? || Rails.env.test?) && !defined?(Rails::Console) + url_with_port = URI(url) + url_with_port.port = Rails::Server::Options.new.parse!(ARGV)[:Port] + url_with_port.to_s + else + url + end + end + + def blob_url(attachment, organization) + return unless attachment.respond_to?(:blob) && attachment.blob.present? + + # TODO : Optimize Decidim::Organization , ActiveStorage::Attachment and ActiveStorage::Blob eager load on each call + + # Another method is : resource..attached_uploader(:attachment_name).url + + # this gives the redirect url + switch_url_port(Rails.application.routes.url_helpers.rails_blob_url(attachment.blob, host: organization.host)) + end + end + end + end +end From d6408cae9fc832d8ab13c3c3bd958e7ac1040dd8 Mon Sep 17 00:00:00 2001 From: moustachu Date: Wed, 3 Jun 2026 16:32:11 +0200 Subject: [PATCH 05/24] [WIP] fix: export bundle manifest needs dup --- app/services/decidim/content/csv_bundler.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/services/decidim/content/csv_bundler.rb b/app/services/decidim/content/csv_bundler.rb index fc778fb4c9..bae0a10bc7 100644 --- a/app/services/decidim/content/csv_bundler.rb +++ b/app/services/decidim/content/csv_bundler.rb @@ -128,7 +128,8 @@ def export_to_archive # rubocop:disable Metrics/PerceivedComplexity def parse_bundle_manifests(bundle_manifests, object: nil) - bundle_manifests.each.with_index.inject([]) do |bundle_results, (manifest, index)| + bundle_manifests.each.with_index.inject([]) do |bundle_results, (bundle_manifest, index)| + manifest = bundle_manifest.dup results = [] begin From 7d0e7055b3a3a2d5600b3d293fd7b898c4fe8655 Mon Sep 17 00:00:00 2001 From: moustachu Date: Tue, 9 Jun 2026 11:30:18 +0200 Subject: [PATCH 06/24] [WIP] feat(reversibility): add to bundle debates and proposals (+ comments) --- app/jobs/dummy_long_job.rb | 16 +++++ .../decidim/content/comment_serializer.rb | 29 ++++++++ .../decidim/content/component_serializer.rb | 12 +++- .../decidim/content/debate_serializer.rb | 32 +++++++++ .../decidim/content/proposal_serializer.rb | 41 +++++++++++ app/services/decidim/content/csv_bundler.rb | 69 ++++++++++++++++--- .../decidim/content/authorable_tools.rb | 22 ++++++ .../decidim/content/component_tools.rb | 66 ++++++++++++++++++ .../decidim/content/serializer_tools.rb | 1 + 9 files changed, 277 insertions(+), 11 deletions(-) create mode 100644 app/jobs/dummy_long_job.rb create mode 100644 app/serializers/decidim/content/comment_serializer.rb create mode 100644 app/serializers/decidim/content/debate_serializer.rb create mode 100644 app/serializers/decidim/content/proposal_serializer.rb create mode 100644 lib/concerns/decidim/content/authorable_tools.rb create mode 100644 lib/concerns/decidim/content/component_tools.rb diff --git a/app/jobs/dummy_long_job.rb b/app/jobs/dummy_long_job.rb new file mode 100644 index 0000000000..4ccafb8335 --- /dev/null +++ b/app/jobs/dummy_long_job.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class DummyLongJob < ApplicationJob + def perform(duration: 60.seconds, step: 5.seconds) + elapsed = 0 + total_steps = (duration / step).to_i + Rails.logger.info "Starting DummyLongJob for #{duration} seconds with #{total_steps} steps of #{step} seconds each." + step_index = 1 + while elapsed < duration + Rails.logger.info "DummyLongJob progress: Step #{step_index}/#{total_steps} (#{elapsed}/#{duration} seconds)" + sleep(step) + elapsed += step.seconds + step_index += 1 + end + end +end diff --git a/app/serializers/decidim/content/comment_serializer.rb b/app/serializers/decidim/content/comment_serializer.rb new file mode 100644 index 0000000000..8e206d2ba7 --- /dev/null +++ b/app/serializers/decidim/content/comment_serializer.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Decidim + module Content + class CommentSerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(resource), + body: normalize_translated_attribute(resource.body), + locale: resource.body.keys.first, + author: uid(identity(resource)), + alignment: resource.alignment, + up_votes_count: resource.up_votes&.count, + down_votes_count: resource.down_votes&.count, + depth: resource.depth, + comments_count: resource.comments_count, + commentable_id: resource.decidim_commentable_id, + commentable_type: resource.decidim_commentable_type, + root_commentable_id: resource.decidim_commentable_id, + root_commentable_type: resource.decidim_commentable_type, + created_at: resource.created_at, + updated_at: resource.updated_at + } + end + end + end +end diff --git a/app/serializers/decidim/content/component_serializer.rb b/app/serializers/decidim/content/component_serializer.rb index a8a5edb3fb..20981129ac 100644 --- a/app/serializers/decidim/content/component_serializer.rb +++ b/app/serializers/decidim/content/component_serializer.rb @@ -10,12 +10,20 @@ def serialize uid: uid(resource), manifest_name: resource.manifest_name, name: normalize_translated_attribute(resource.name), - settings: resource[:settings], + settings: convert_settings_to_uid(resource[:settings]), weight: resource.try(:weight), permissions: resource.try(:permissions), - published_at: resource.try(:published_at) + published_at: resource.try(:published_at), + previously_published: resource.try(:previously_published?) }.merge(specific_data: resource.manifest.specific_data_serializer_class&.new(resource)&.run) end + + def convert_settings_to_uid(settings) + return unless settings + + settings["steps"].transform_keys! { |key| uid(Decidim::ParticipatoryProcessStep.new(id: key.to_i)) } if settings["steps"] + settings["global"]["scope_id"] = uid(Decidim::Scope.new(id: settings["global"]["scope_id"].to_i)) if settings.dig("global", "scope_id") + end end end end diff --git a/app/serializers/decidim/content/debate_serializer.rb b/app/serializers/decidim/content/debate_serializer.rb new file mode 100644 index 0000000000..c49690ece9 --- /dev/null +++ b/app/serializers/decidim/content/debate_serializer.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Decidim + module Content + class DebateSerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(resource), + author: uid(identity(resource)), + category: uid(resource.try(:category)), + title: normalize_translated_attribute(resource.try(:title)), + description: normalize_translated_attribute(resource.try(:description)), + instructions: normalize_translated_attribute(resource.try(:instructions)), + information_updates: normalize_translated_attribute(resource.try(:information_updates)), + conclusions: normalize_translated_attribute(resource.try(:conclusions)), + start_time: resource.try(:start_time), + end_time: resource.try(:end_time), + created_at: resource.try(:created_at), + updated_at: resource.try(:updated_at), + closed_at: resource.try(:closed_at), + reference: resource.try(:reference), + comments_enabled: resource.try(:comments_enabled), + comments_count: resource.try(:comments_count), + endorsements_count: resource.try(:endorsements).try(:size), + followers_count: resource.try(:follows).try(:size) + } + end + end + end +end diff --git a/app/serializers/decidim/content/proposal_serializer.rb b/app/serializers/decidim/content/proposal_serializer.rb new file mode 100644 index 0000000000..d60a9bbf57 --- /dev/null +++ b/app/serializers/decidim/content/proposal_serializer.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Decidim + module Content + # see : Decidim::Proposals::ProposalSerializer + class ProposalSerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(resource), + authors: coauthors(resource).map { |author| uid(author) }, + category: uid(resource.try(:category)), + scope: uid(resource.try(:scope)), + title: normalize_translated_attribute(resource.try(:title)), + body: normalize_translated_attribute(resource.try(:body)), + address: resource.try(:address), + latitude: resource.try(:latitude), + longitude: resource.try(:longitude), + state: uid(resource.try(:proposal_state)), + state_token: resource.try(:internal_state), + reference: resource.try(:reference), + answer: normalize_translated_attribute(resource.try(:answer)), + answered_at: resource.try(:answered_at), + votes_count: resource.try(:proposal_votes_count), + endorsements_count: resource.try(:endorsements).try(:size), + comments_count: resource.try(:comments_count), + attachments_count: resource.try(:attachments).try(:size), + followers_count: resource.try(:follows).try(:size), + published_at: resource.try(:published_at), + related_proposals: resource.linked_resources(:proposals, "copied_from_component").map { |proposal| uid(proposal) }, + related_meetings: resource.linked_resources(:meetings, "proposals_from_meeting").map { |meeting| uid(meeting) }, + is_amend: resource.try(:emendation?), + original_proposal: uid(resource.try(:amendable)), + withdrawn: resource.try(:withdrawn?), + withdrawn_at: resource.try(:withdrawn_at) + } # TODO : add custom fields (public & private) + end + end + end +end diff --git a/app/services/decidim/content/csv_bundler.rb b/app/services/decidim/content/csv_bundler.rb index bae0a10bc7..11afe483ba 100644 --- a/app/services/decidim/content/csv_bundler.rb +++ b/app/services/decidim/content/csv_bundler.rb @@ -10,6 +10,7 @@ module Decidim module Content class CsvBundler include Decidim::Content::UidTools + include Decidim::Content::ComponentTools DEFAULT_OPTIONS = { flatten_json: false, @@ -34,7 +35,7 @@ def bundle { path: "users", serializer: Decidim::Content::UserSerializer, - collection: organization.users.reorder("id ASC") + collection: organization.user_entities.reorder("id ASC") }, { path: "scopes", @@ -83,15 +84,58 @@ def bundle serializer: Decidim::Content::AttachmentSerializer, collection: ->(parent) { parent.attachments } }, - { - path: "components", - serializer: Decidim::Content::ComponentSerializer, - collection: ->(parent) { parent.components } - }, { path: "users", serializer: Decidim::Content::ParticipatorySpaceUserSerializer, collection: ->(parent) { participatory_process_users(parent) } + }, + # TODO : followers + { + path: "components", + children: [ + { + path: ->(resource) { "#{uid(resource)}---#{resource.try(:manifest_name)}" }, + collection: ->(parent) { parent.components }, + children: [ + { + path: "component", + serializer: Decidim::Content::ComponentSerializer, + collection: ->(parent) { [parent] } + }, + # TODO : before proposals -> attachments, states + { + path: "proposals", + include_if: ->(parent) { parent&.manifest_name == "proposals" }, + serializer: Decidim::Content::ProposalSerializer, + collection: lambda { |parent| + Decidim::Proposals::Proposal + .published + .not_hidden + .where(component: parent) + .includes(:scope, :category) + } + }, + # TODO : after proposals -> comments, votes, endorsements, followers, notes + { + path: "debates", + include_if: ->(parent) { parent&.manifest_name == "debates" }, + serializer: Decidim::Content::DebateSerializer, + collection: lambda { |parent| + Decidim::Debates::Debate + .not_hidden + .where(component: parent) + .includes(:category) + } + }, + { + path: "comments", + include_if: ->(parent) { commentable_component?(parent&.manifest_name) && %w(proposals debates).include?(parent&.manifest_name) }, + serializer: Decidim::Content::CommentSerializer, + collection: ->(parent) { comments_for_component(parent) } + } + ] + } + ] } ] } @@ -126,15 +170,21 @@ def export_to_archive private + # rubocop:disable Metrics/CyclomaticComplexity # rubocop:disable Metrics/PerceivedComplexity def parse_bundle_manifests(bundle_manifests, object: nil) - bundle_manifests.each.with_index.inject([]) do |bundle_results, (bundle_manifest, index)| + index = 0 + bundle_manifests.each.inject([]) do |bundle_results, bundle_manifest| manifest = bundle_manifest.dup results = [] begin validate_manifest!(manifest, object:) + next bundle_results if manifest[:include_if].present? && !manifest[:include_if].call(object) + + index += 1 + manifest[:collection] = manifest[:collection].call(object) if manifest[:collection].respond_to?(:call) if manifest[:serializer].present? @@ -168,6 +218,7 @@ def parse_bundle_manifests(bundle_manifests, object: nil) end end # rubocop:enable Metrics/PerceivedComplexity + # rubocop:enable Metrics/CyclomaticComplexity # rubocop:disable Metrics/CyclomaticComplexity # rubocop:disable Metrics/PerceivedComplexity @@ -189,7 +240,7 @@ def validate_manifest!(manifest, object: nil) def compute_path(path, object: nil, index: nil) path = path.call(object) if path.respond_to?(:call) && !object.nil? - path = "#{format("%02d", index + 1)}---#{path}" if index.present? + path = "#{format("%02d", index)}---#{path}" if index.present? path end @@ -232,7 +283,7 @@ def generate_csv_for(exportable) def participatory_process_users(participatory_process) users_with_roles = participatory_process.user_roles.select(:decidim_user_id, :role).reorder("decidim_user_id").to_a private_users = participatory_process.users.select(:decidim_user_id).reorder("decidim_user_id").to_a - (users_with_roles + private_users).uniq(&:decidim_user_id).map { |o| o.attributes.compact.symbolize_keys.merge(role: o.role || "private_user") } + (users_with_roles + private_users).uniq(&:decidim_user_id).map { |o| o.attributes.compact.symbolize_keys.merge(role: o[:role] || "private_user") } end end end diff --git a/lib/concerns/decidim/content/authorable_tools.rb b/lib/concerns/decidim/content/authorable_tools.rb new file mode 100644 index 0000000000..7590c8ca51 --- /dev/null +++ b/lib/concerns/decidim/content/authorable_tools.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "active_support/concern" + +module Decidim + module Content + module AuthorableTools + extend ActiveSupport::Concern + included do + def identity(resource) + resource.try(:user_group) || resource.try(:author) + end + + def coauthors(resource) + return [] unless resource.respond_to?(:coauthorships) + + resource.coauthorships.map { |c| identity(c) } + end + end + end + end +end diff --git a/lib/concerns/decidim/content/component_tools.rb b/lib/concerns/decidim/content/component_tools.rb new file mode 100644 index 0000000000..f48f7e2385 --- /dev/null +++ b/lib/concerns/decidim/content/component_tools.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "active_support/concern" + +module Decidim + module Content + module ComponentTools + extend ActiveSupport::Concern + included do + def component_resource_manifests(manifest_name) + return [] if manifest_name.blank? + + Decidim.resource_registry.manifests.each.with_object([]) do |manifest, resources| + resources.push(manifest) if manifest.component_manifest&.name == manifest_name.to_sym + end + end + + def component_commentable_resource_manifests(manifest_name) + component_resource_manifests(manifest_name).select { |manifest| manifest.model_class_name&.constantize&.include?(Decidim::Comments::Commentable) } + end + + def commentable_component?(manifest_name) + component_commentable_resource_manifests(manifest_name).any? + end + + def comments_for_component(component) + component_commentable_resource_manifests(component&.manifest_name).each.with_object([]) do |manifest, results| + results.concat(comments_for_resource(manifest.model_class_name.constantize, component)) if manifest.model_class_name.present? + end + end + + # rubocop:disable Metrics/CyclomaticComplexity + # rubocop:disable Metrics/PerceivedComplexity + # Fix for Decidim::Comments::Export.comments_for_resource which only works for :belong_to associations + def comments_for_resource(resource_class, component) + reflection = resource_class.reflections["component"] + + if reflection.present? + if reflection.belongs_to? + # Meaning that the resource has something like : + # belongs_to :component, foreign_key: "decidim_component_id", class_name: "Decidim::Component" ... + root_commentable = resource_class.where(component:) + elsif reflection.has_one? && reflection.try(:options)&.[](:through).present? + # Meaning that the resource has something like : + # has_one :component, through: < belongs_to association > ... + through = reflection.options[:through] + gateway_reflection = resource_class.reflections[through.to_s] + gateway_resources = gateway_reflection.try(:options)&.[](:class_name)&.constantize&.where(component:) + root_commentable = gateway_resources.present? ? resource_class.where(through => gateway_resources) : resource_class.none + end + if root_commentable.present? + return Decidim::Comments::Comment + .not_deleted + .not_hidden + .where(root_commentable:) + end + end + Rails.logger.warn "Decidim::Content::ComponentTools.comments_for_resource (concerns) : Unable to fetch comments for #{resource_class} with component association." + Decidim::Comments::Comment.none + end + # rubocop:enable Metrics/CyclomaticComplexity + # rubocop:enable Metrics/PerceivedComplexity + end + end + end +end diff --git a/lib/concerns/decidim/content/serializer_tools.rb b/lib/concerns/decidim/content/serializer_tools.rb index 940e77baf5..f2853f79e0 100644 --- a/lib/concerns/decidim/content/serializer_tools.rb +++ b/lib/concerns/decidim/content/serializer_tools.rb @@ -11,6 +11,7 @@ module SerializerTools include Decidim::TranslationsHelper include Decidim::Content::UrlTools include Decidim::Content::UidTools + include Decidim::Content::AuthorableTools def normalize_translated_attribute(attribute) # NOTES : if returning nil creates issues then return empty_translatable From b33cd2bee2cc540a93e966039c14e6d6042ac6c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Jun 2026 10:06:21 +0000 Subject: [PATCH 07/24] Fix i18n-tasks locale glob for nested locale files --- config/i18n-tasks.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 4bf887805b..f0a8042c50 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -18,6 +18,7 @@ data: # Locale files or `File.find` patterns where translations are read from: read: - config/locales/%{locale}.yml + - config/locales/**/%{locale}.yml # Locale files to write new keys to, based on a list of key pattern => file rules. Matched from top to bottom: # `i18n-tasks normalize -p` will force move the keys according to these rules From cfe209495209668b705f1243ba7cc9cf8a2aa09c Mon Sep 17 00:00:00 2001 From: moustachu Date: Tue, 9 Jun 2026 13:01:55 +0200 Subject: [PATCH 08/24] [WIP] fix(reversibility): bundle loop error when component list is empty --- app/services/decidim/content/csv_bundler.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/decidim/content/csv_bundler.rb b/app/services/decidim/content/csv_bundler.rb index 11afe483ba..b8bf1e4442 100644 --- a/app/services/decidim/content/csv_bundler.rb +++ b/app/services/decidim/content/csv_bundler.rb @@ -195,7 +195,7 @@ def parse_bundle_manifests(bundle_manifests, object: nil) ) end elsif manifest[:children].present? - if manifest[:collection].present? + if manifest[:collection] # .present? doesn't fit in this case because [] is a valid candidate results.concat( manifest[:collection].each.with_index.inject([]) do |collection_results, (collection_item, collection_index)| collection_item_results = parse_bundle_manifests(manifest[:children], object: collection_item) From c34cace4be019e90b5a3c4c28705ea8a75aa04db Mon Sep 17 00:00:00 2001 From: moustachu Date: Tue, 9 Jun 2026 13:45:32 +0200 Subject: [PATCH 09/24] [WIP] fix(reversibility): commentable UID --- app/serializers/decidim/content/comment_serializer.rb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/serializers/decidim/content/comment_serializer.rb b/app/serializers/decidim/content/comment_serializer.rb index 8e206d2ba7..6367a79b68 100644 --- a/app/serializers/decidim/content/comment_serializer.rb +++ b/app/serializers/decidim/content/comment_serializer.rb @@ -16,10 +16,8 @@ def serialize down_votes_count: resource.down_votes&.count, depth: resource.depth, comments_count: resource.comments_count, - commentable_id: resource.decidim_commentable_id, - commentable_type: resource.decidim_commentable_type, - root_commentable_id: resource.decidim_commentable_id, - root_commentable_type: resource.decidim_commentable_type, + commentable: uid(resource.commentable), + root_commentable: uid(resource.root_commentable), created_at: resource.created_at, updated_at: resource.updated_at } From 5bc4f49411e3fbfff7bab679c7cfd10cb1707d94 Mon Sep 17 00:00:00 2001 From: moustachu Date: Tue, 23 Jun 2026 01:06:33 +0200 Subject: [PATCH 10/24] [WIP] feat(reversibility): export url with processes, groups, comments, debates, proposals and comments --- .../decidim/content/comment_serializer.rb | 15 ++++++++++++++- .../decidim/content/component_serializer.rb | 6 ++++-- .../decidim/content/debate_serializer.rb | 3 ++- .../participatory_process_group_serializer.rb | 5 ++++- .../content/participatory_process_serializer.rb | 3 ++- .../decidim/content/proposal_serializer.rb | 3 ++- 6 files changed, 28 insertions(+), 7 deletions(-) diff --git a/app/serializers/decidim/content/comment_serializer.rb b/app/serializers/decidim/content/comment_serializer.rb index 6367a79b68..802cb2b819 100644 --- a/app/serializers/decidim/content/comment_serializer.rb +++ b/app/serializers/decidim/content/comment_serializer.rb @@ -19,9 +19,22 @@ def serialize commentable: uid(resource.commentable), root_commentable: uid(resource.root_commentable), created_at: resource.created_at, - updated_at: resource.updated_at + updated_at: resource.updated_at, + url: single_comment_url } end + + def root_commentable + if defined?(Decidim::Budgets) && resource.root_commentable.is_a?(Decidim::Budgets::Project) + [resource.root_commentable.budget, resource.root_commentable] + else + resource.root_commentable + end + end + + def single_comment_url + "#{Decidim::ResourceLocatorPresenter.new(root_commentable).url(commentId: resource.id)}#comment_#{resource.id}" + end end end end diff --git a/app/serializers/decidim/content/component_serializer.rb b/app/serializers/decidim/content/component_serializer.rb index 20981129ac..f511a03d1b 100644 --- a/app/serializers/decidim/content/component_serializer.rb +++ b/app/serializers/decidim/content/component_serializer.rb @@ -14,8 +14,10 @@ def serialize weight: resource.try(:weight), permissions: resource.try(:permissions), published_at: resource.try(:published_at), - previously_published: resource.try(:previously_published?) - }.merge(specific_data: resource.manifest.specific_data_serializer_class&.new(resource)&.run) + previously_published: resource.try(:previously_published?), + specific_data: resource.manifest.specific_data_serializer_class&.new(resource)&.run, + url: Decidim::EngineRouter.main_proxy(resource)&.root_url + } end def convert_settings_to_uid(settings) diff --git a/app/serializers/decidim/content/debate_serializer.rb b/app/serializers/decidim/content/debate_serializer.rb index c49690ece9..72973834e6 100644 --- a/app/serializers/decidim/content/debate_serializer.rb +++ b/app/serializers/decidim/content/debate_serializer.rb @@ -24,7 +24,8 @@ def serialize comments_enabled: resource.try(:comments_enabled), comments_count: resource.try(:comments_count), endorsements_count: resource.try(:endorsements).try(:size), - followers_count: resource.try(:follows).try(:size) + followers_count: resource.try(:follows).try(:size), + url: Decidim::ResourceLocatorPresenter.new(resource).url } end end diff --git a/app/serializers/decidim/content/participatory_process_group_serializer.rb b/app/serializers/decidim/content/participatory_process_group_serializer.rb index 1d1f28aa25..e55848e072 100644 --- a/app/serializers/decidim/content/participatory_process_group_serializer.rb +++ b/app/serializers/decidim/content/participatory_process_group_serializer.rb @@ -5,6 +5,8 @@ module Content class ParticipatoryProcessGroupSerializer < Decidim::Exporters::Serializer include Decidim::Content::SerializerTools + delegate :organization, to: :resource + def serialize { uid: uid(resource), @@ -23,7 +25,8 @@ def serialize participatory_structure: resource.participatory_structure }, created_at: resource.created_at, - updated_at: resource.updated_at + updated_at: resource.updated_at, + url: switch_url_port(Decidim::ParticipatoryProcesses::Engine.routes.url_helpers.participatory_process_group_url(resource, host: resource.organization.host)) } end end diff --git a/app/serializers/decidim/content/participatory_process_serializer.rb b/app/serializers/decidim/content/participatory_process_serializer.rb index e56a3892ec..33f682c474 100644 --- a/app/serializers/decidim/content/participatory_process_serializer.rb +++ b/app/serializers/decidim/content/participatory_process_serializer.rb @@ -35,7 +35,8 @@ def serialize }, created_at: resource.created_at, updated_at: resource.updated_at, - published_at: resource.try(:published_at) + published_at: resource.try(:published_at), + url: Decidim::ResourceLocatorPresenter.new(resource).url } end end diff --git a/app/serializers/decidim/content/proposal_serializer.rb b/app/serializers/decidim/content/proposal_serializer.rb index d60a9bbf57..50196302b7 100644 --- a/app/serializers/decidim/content/proposal_serializer.rb +++ b/app/serializers/decidim/content/proposal_serializer.rb @@ -33,7 +33,8 @@ def serialize is_amend: resource.try(:emendation?), original_proposal: uid(resource.try(:amendable)), withdrawn: resource.try(:withdrawn?), - withdrawn_at: resource.try(:withdrawn_at) + withdrawn_at: resource.try(:withdrawn_at), + url: Decidim::ResourceLocatorPresenter.new(resource).url } # TODO : add custom fields (public & private) end end From fc7e93acf5bb646baec5b9569ed1bfe953f4fa14 Mon Sep 17 00:00:00 2001 From: moustachu Date: Tue, 23 Jun 2026 01:41:01 +0200 Subject: [PATCH 11/24] [WIP] fix(reversibility): export content tree with components as children options --- app/controllers/decidim/admin/content/tree_controller.rb | 2 +- app/services/decidim/content/tree_generator.rb | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/controllers/decidim/admin/content/tree_controller.rb b/app/controllers/decidim/admin/content/tree_controller.rb index 7f2d97bfa9..bf6200f940 100644 --- a/app/controllers/decidim/admin/content/tree_controller.rb +++ b/app/controllers/decidim/admin/content/tree_controller.rb @@ -20,7 +20,7 @@ def export location_type: :url, include_metadata: true, include_score: true, - components_as_children: false + components_as_children: true ) csv_data = generator.to_csv diff --git a/app/services/decidim/content/tree_generator.rb b/app/services/decidim/content/tree_generator.rb index 520744bb5f..b82968502d 100644 --- a/app/services/decidim/content/tree_generator.rb +++ b/app/services/decidim/content/tree_generator.rb @@ -205,6 +205,7 @@ def build_components_array(instance) components_attribute = :components count_attribute = :component_count end + { components_attribute.to_sym => children = instance.components.map do |component| shared_hash(instance: component, kind: "component", manifest: instance.manifest.to_h, icon: component.manifest.icon_key).merge( From b4b7a836544f36e886469744d582ddc1df016328 Mon Sep 17 00:00:00 2001 From: moustachu Date: Wed, 24 Jun 2026 12:19:07 +0200 Subject: [PATCH 12/24] [WIP] feat(reversibility): add surveys answers to export bundle --- .../decidim/content/component_serializer.rb | 39 +++++++- .../content/survey_answer_serializer.rb | 97 +++++++++++++++++++ app/services/decidim/content/csv_bundler.rb | 9 ++ config/application.rb | 1 + .../surveys/data_serializer_extends.rb | 26 +++++ 5 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 app/serializers/decidim/content/survey_answer_serializer.rb create mode 100644 lib/extends/serializers/decidim/surveys/data_serializer_extends.rb diff --git a/app/serializers/decidim/content/component_serializer.rb b/app/serializers/decidim/content/component_serializer.rb index f511a03d1b..f504bd21dc 100644 --- a/app/serializers/decidim/content/component_serializer.rb +++ b/app/serializers/decidim/content/component_serializer.rb @@ -15,7 +15,7 @@ def serialize permissions: resource.try(:permissions), published_at: resource.try(:published_at), previously_published: resource.try(:previously_published?), - specific_data: resource.manifest.specific_data_serializer_class&.new(resource)&.run, + specific_data: convert_specific_data_to_uid(resource.manifest.specific_data_serializer_class&.new(resource)&.run), url: Decidim::EngineRouter.main_proxy(resource)&.root_url } end @@ -26,6 +26,43 @@ def convert_settings_to_uid(settings) settings["steps"].transform_keys! { |key| uid(Decidim::ParticipatoryProcessStep.new(id: key.to_i)) } if settings["steps"] settings["global"]["scope_id"] = uid(Decidim::Scope.new(id: settings["global"]["scope_id"].to_i)) if settings.dig("global", "scope_id") end + + def convert_specific_data_to_uid(specific_data) + return unless specific_data + return specific_data.map { |data| convert_specific_data_to_uid(data) } if specific_data.is_a?(Array) + + if specific_data.is_a?(Hash) && resource.manifest_name == "surveys" + convert_surveys_specific_data_to_uid(specific_data) + else + specific_data + end + end + + def convert_surveys_specific_data_to_uid(specific_data) + return unless specific_data + + # TODO: duplicated data (like id references) should be removed + + specific_data["id"] = uid(Decidim::Surveys::Survey.new(id: specific_data["id"].to_i)) + specific_data["decidim_component_id"] = uid(resource) + + return unless specific_data["questionnaire"] + + specific_data["questionnaire"]["id"] = uid(Decidim::Forms::Questionnaire.new(id: specific_data["questionnaire"]["id"].to_i)) + specific_data["questionnaire"]["questionnaire_for"] = specific_data["id"] + specific_data["questionnaire"]["questions"]&.each do |question| + question["id"] = uid(Decidim::Forms::Question.new(id: question["id"].to_i)) + question["decidim_questionnaire_id"] = specific_data["questionnaire"]["id"] + question["answer_options"]&.each do |answer_option| + answer_option["id"] = uid(Decidim::Forms::AnswerOption.new(id: answer_option["id"].to_i)) + answer_option["decidim_question_id"] = question["id"] + end + question["matrix_rows"]&.each do |matrix_row| + matrix_row["id"] = uid(Decidim::Forms::MatrixRow.new(id: matrix_row["id"].to_i)) + matrix_row["decidim_question_id"] = question["id"] + end + end + end end end end diff --git a/app/serializers/decidim/content/survey_answer_serializer.rb b/app/serializers/decidim/content/survey_answer_serializer.rb new file mode 100644 index 0000000000..8868ef33a7 --- /dev/null +++ b/app/serializers/decidim/content/survey_answer_serializer.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +module Decidim + module Content + class SurveyAnswerSerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + EXCLUDED_QUESTION_TYPES = [ + Decidim::Forms::Question::SEPARATOR_TYPE, + Decidim::Forms::Question::TITLE_AND_DESCRIPTION_TYPE + ].freeze + + def initialize(answers) + @answers = answers + end + + attr_reader :answers + alias resource answers + + def serialize + { + **user_data, + created_at: answers&.first&.created_at, + **questions_hash + } + end + + def user_data + if answers&.first&.decidim_user_id.present? + { + author: uid(Decidim::User.new(id: answers&.first&.decidim_user_id)), + author_status: "registered" + } + else + { + author: nil, + author_status: "unregistered" + } + end + end + + def questions_hash + questionnaire_id = answers&.first&.decidim_questionnaire_id + return {} unless questionnaire_id + + questions = Decidim::Forms::Question.where(decidim_questionnaire_id: questionnaire_id).where.not(question_type: EXCLUDED_QUESTION_TYPES).order(:position) + return {} if questions.none? + + answers_hash = answers.each.inject({}) do |result, answer| + result.update( + answer.question.id => answer + ) + end + + questions.each.inject({}) do |serialized, question| + serialized.update( + uid(question) => normalize_body(question, answers_hash[question.id]) + ) + end + end + + def normalize_body(question, answer) + case question.question_type + when "single_option", "multiple_option", "sorting" + normalize_choices(answer&.choices) + when "matrix_single", "matrix_multiple" + normalize_matrix(question, answer) + when "files" + answer&.attachments&.map(&:url) + else + answer&.body + end + end + + def normalize_matrix(question, answer) + question.matrix_rows&.map do |matrix_row| + { + uid(matrix_row) => normalize_choices(answer&.choices&.where(matrix_row:)) + } + end + end + + def normalize_choices(choices) + choices&.order(:position)&.map { |choice| normalize_single_choice(choice) } + end + + def normalize_single_choice(choice) + { + answer_option: uid(Decidim::Forms::AnswerOption.new(id: choice.decidim_answer_option_id)), + position: choice.try(:position), + body: choice.try(:body), + custom_body: choice.try(:custom_body) + } + end + end + end +end diff --git a/app/services/decidim/content/csv_bundler.rb b/app/services/decidim/content/csv_bundler.rb index b8bf1e4442..78e0decd31 100644 --- a/app/services/decidim/content/csv_bundler.rb +++ b/app/services/decidim/content/csv_bundler.rb @@ -132,6 +132,15 @@ def bundle include_if: ->(parent) { commentable_component?(parent&.manifest_name) && %w(proposals debates).include?(parent&.manifest_name) }, serializer: Decidim::Content::CommentSerializer, collection: ->(parent) { comments_for_component(parent) } + }, + { + path: "answers", + include_if: ->(parent) { parent&.manifest_name == "surveys" }, + serializer: Decidim::Content::SurveyAnswerSerializer, + collection: lambda { |parent| + survey = Decidim::Surveys::Survey.find_by(component: parent) + Decidim::Forms::QuestionnaireUserAnswers.for(survey.questionnaire) + } } ] } diff --git a/config/application.rb b/config/application.rb index c2488e1ffa..a8e63b2a82 100644 --- a/config/application.rb +++ b/config/application.rb @@ -69,6 +69,7 @@ class Application < Rails::Application require "extends/presenters/decidim/menu_item_presenter_extends" # serializers require "extends/serializers/decidim/proposals/proposal_serializer_extends" + require "extends/serializers/decidim/surveys/data_serializer_extends" end config.to_prepare do diff --git a/lib/extends/serializers/decidim/surveys/data_serializer_extends.rb b/lib/extends/serializers/decidim/surveys/data_serializer_extends.rb new file mode 100644 index 0000000000..5332ef626b --- /dev/null +++ b/lib/extends/serializers/decidim/surveys/data_serializer_extends.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module SurveysDataSerializerExtends + extend ActiveSupport::Concern + + included do + def serialize_questions(questions) + questions.collect do |question| + json = question.attributes.as_json + json[:matrix_rows] = serialize_matrix_rows(question.matrix_rows) + json[:answer_options] = serialize_answer_options(question.answer_options) + json + end + end + + def serialize_matrix_rows(matrix_rows) + matrix_rows.collect do |row| + row.attributes.as_json + end + end + end +end + +Decidim::Surveys::DataSerializer.class_eval do + include(SurveysDataSerializerExtends) +end From 2d1db746a71047507b7e7393a5b11aaba6bda462 Mon Sep 17 00:00:00 2001 From: moustachu Date: Wed, 24 Jun 2026 21:55:07 +0200 Subject: [PATCH 13/24] [WIP] fix(reversibility): convert surveys specific_data to uid --- app/serializers/decidim/content/component_serializer.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/serializers/decidim/content/component_serializer.rb b/app/serializers/decidim/content/component_serializer.rb index f504bd21dc..a1e55ec115 100644 --- a/app/serializers/decidim/content/component_serializer.rb +++ b/app/serializers/decidim/content/component_serializer.rb @@ -58,10 +58,11 @@ def convert_surveys_specific_data_to_uid(specific_data) answer_option["decidim_question_id"] = question["id"] end question["matrix_rows"]&.each do |matrix_row| - matrix_row["id"] = uid(Decidim::Forms::MatrixRow.new(id: matrix_row["id"].to_i)) + matrix_row["id"] = uid(Decidim::Forms::QuestionMatrixRow.new(id: matrix_row["id"].to_i)) matrix_row["decidim_question_id"] = question["id"] end end + specific_data end end end From 196c8417ee9871e49d36abeb7655415383894c2d Mon Sep 17 00:00:00 2001 From: moustachu Date: Thu, 25 Jun 2026 10:51:22 +0200 Subject: [PATCH 14/24] [WIP] feat(reversibility): add proposals votes / notes / endrosements to bundle --- .../attachment_collection_serializer.rb | 3 +- .../decidim/content/attachment_serializer.rb | 4 +- .../decidim/content/comment_serializer.rb | 6 ++- .../decidim/content/component_serializer.rb | 1 + .../decidim/content/debate_serializer.rb | 1 + .../decidim/content/endorsement_serializer.rb | 19 ++++++++++ .../decidim/content/follower_serializer.rb | 19 ++++++++++ .../content/proposal_note_serializer.rb | 21 +++++++++++ .../decidim/content/proposal_serializer.rb | 4 +- .../content/proposal_vote_serializer.rb | 22 +++++++++++ app/services/decidim/content/csv_bundler.rb | 37 ++++++++++++++++--- .../decidim/content/component_tools.rb | 34 +++++++++++++++-- lib/concerns/decidim/content/uid_tools.rb | 6 +++ 13 files changed, 162 insertions(+), 15 deletions(-) create mode 100644 app/serializers/decidim/content/endorsement_serializer.rb create mode 100644 app/serializers/decidim/content/follower_serializer.rb create mode 100644 app/serializers/decidim/content/proposal_note_serializer.rb create mode 100644 app/serializers/decidim/content/proposal_vote_serializer.rb diff --git a/app/serializers/decidim/content/attachment_collection_serializer.rb b/app/serializers/decidim/content/attachment_collection_serializer.rb index 9d8b93677d..1d8880af89 100644 --- a/app/serializers/decidim/content/attachment_collection_serializer.rb +++ b/app/serializers/decidim/content/attachment_collection_serializer.rb @@ -10,7 +10,8 @@ def serialize uid: uid(resource), name: normalize_translated_attribute(resource.name), weight: resource.try(:weight), - description: normalize_translated_attribute(resource.description) + description: normalize_translated_attribute(resource.description), + collection_for: polymorphic_uid(resource, :collection_for) } end end diff --git a/app/serializers/decidim/content/attachment_serializer.rb b/app/serializers/decidim/content/attachment_serializer.rb index 6aeadc9da7..fad1fe8f3a 100644 --- a/app/serializers/decidim/content/attachment_serializer.rb +++ b/app/serializers/decidim/content/attachment_serializer.rb @@ -12,7 +12,9 @@ def serialize description: normalize_translated_attribute(resource.description), weight: resource.try(:weight), # file: Decidim::AttachmentPresenter.new(resource).attachment_file_url - file: blob_url(resource.file, resource.organization) + file: blob_url(resource.file, resource.organization), + attached_to: polymorphic_uid(resource, :attached_to), + collection: uid(Decidim::AttachmentCollection.new(id: resource.decidim_attachment_collection_id)) } end end diff --git a/app/serializers/decidim/content/comment_serializer.rb b/app/serializers/decidim/content/comment_serializer.rb index 802cb2b819..d1bfc24bc4 100644 --- a/app/serializers/decidim/content/comment_serializer.rb +++ b/app/serializers/decidim/content/comment_serializer.rb @@ -16,8 +16,10 @@ def serialize down_votes_count: resource.down_votes&.count, depth: resource.depth, comments_count: resource.comments_count, - commentable: uid(resource.commentable), - root_commentable: uid(resource.root_commentable), + # commentable: uid(resource.commentable), + # root_commentable: uid(resource.root_commentable), + commentable: polymorphic_uid(resource, :decidim_commentable), + root_commentable: polymorphic_uid(resource, :decidim_root_commentable), created_at: resource.created_at, updated_at: resource.updated_at, url: single_comment_url diff --git a/app/serializers/decidim/content/component_serializer.rb b/app/serializers/decidim/content/component_serializer.rb index a1e55ec115..1944e4e97b 100644 --- a/app/serializers/decidim/content/component_serializer.rb +++ b/app/serializers/decidim/content/component_serializer.rb @@ -16,6 +16,7 @@ def serialize published_at: resource.try(:published_at), previously_published: resource.try(:previously_published?), specific_data: convert_specific_data_to_uid(resource.manifest.specific_data_serializer_class&.new(resource)&.run), + participatory_space: polymorphic_uid(resource, :participatory_space), url: Decidim::EngineRouter.main_proxy(resource)&.root_url } end diff --git a/app/serializers/decidim/content/debate_serializer.rb b/app/serializers/decidim/content/debate_serializer.rb index 72973834e6..0bee8be42d 100644 --- a/app/serializers/decidim/content/debate_serializer.rb +++ b/app/serializers/decidim/content/debate_serializer.rb @@ -25,6 +25,7 @@ def serialize comments_count: resource.try(:comments_count), endorsements_count: resource.try(:endorsements).try(:size), followers_count: resource.try(:follows).try(:size), + component: uid(resource.try(:component)), url: Decidim::ResourceLocatorPresenter.new(resource).url } end diff --git a/app/serializers/decidim/content/endorsement_serializer.rb b/app/serializers/decidim/content/endorsement_serializer.rb new file mode 100644 index 0000000000..d60596a9c1 --- /dev/null +++ b/app/serializers/decidim/content/endorsement_serializer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Decidim + module Content + class EndorsementSerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(resource), + author: uid(identity(resource)), + created_at: resource.try(:created_at), + updated_at: resource.try(:updated_at), + endorsement_for: polymorphic_uid(resource, :resource) + } + end + end + end +end diff --git a/app/serializers/decidim/content/follower_serializer.rb b/app/serializers/decidim/content/follower_serializer.rb new file mode 100644 index 0000000000..b41f534d89 --- /dev/null +++ b/app/serializers/decidim/content/follower_serializer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Decidim + module Content + class FollowerSerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(resource), + user: uid(Decidim::User.new(id: resource.decidim_user_id)), + followable: polymorphic_uid(resource, :decidim_followable), + created_at: resource.try(:created_at), + updated_at: resource.try(:updated_at) + } + end + end + end +end diff --git a/app/serializers/decidim/content/proposal_note_serializer.rb b/app/serializers/decidim/content/proposal_note_serializer.rb new file mode 100644 index 0000000000..5b2f3244f4 --- /dev/null +++ b/app/serializers/decidim/content/proposal_note_serializer.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Decidim + module Content + class ProposalNoteSerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(resource), + proposal: uid(Decidim::Proposals::Proposal.new(id: resource.decidim_proposal_id)), + # author: uid(identity(resource)), # if there is user groups involved + author: uid(Decidim::User.new(id: resource.decidim_author_id)), + body: normalize_translated_attribute(resource.try(:body)), + created_at: resource.try(:created_at), + updated_at: resource.try(:updated_at) + } + end + end + end +end diff --git a/app/serializers/decidim/content/proposal_serializer.rb b/app/serializers/decidim/content/proposal_serializer.rb index 50196302b7..482f8392fe 100644 --- a/app/serializers/decidim/content/proposal_serializer.rb +++ b/app/serializers/decidim/content/proposal_serializer.rb @@ -11,7 +11,7 @@ def serialize uid: uid(resource), authors: coauthors(resource).map { |author| uid(author) }, category: uid(resource.try(:category)), - scope: uid(resource.try(:scope)), + scope: uid(Decidim::Scope.new(id: resource.try(:decidim_scope_id))), title: normalize_translated_attribute(resource.try(:title)), body: normalize_translated_attribute(resource.try(:body)), address: resource.try(:address), @@ -27,6 +27,7 @@ def serialize comments_count: resource.try(:comments_count), attachments_count: resource.try(:attachments).try(:size), followers_count: resource.try(:follows).try(:size), + notes_count: resource.try(:proposal_notes_count), published_at: resource.try(:published_at), related_proposals: resource.linked_resources(:proposals, "copied_from_component").map { |proposal| uid(proposal) }, related_meetings: resource.linked_resources(:meetings, "proposals_from_meeting").map { |meeting| uid(meeting) }, @@ -34,6 +35,7 @@ def serialize original_proposal: uid(resource.try(:amendable)), withdrawn: resource.try(:withdrawn?), withdrawn_at: resource.try(:withdrawn_at), + component: uid(resource.try(:component)), url: Decidim::ResourceLocatorPresenter.new(resource).url } # TODO : add custom fields (public & private) end diff --git a/app/serializers/decidim/content/proposal_vote_serializer.rb b/app/serializers/decidim/content/proposal_vote_serializer.rb new file mode 100644 index 0000000000..39ba0701ec --- /dev/null +++ b/app/serializers/decidim/content/proposal_vote_serializer.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Decidim + module Content + class ProposalVoteSerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(resource), + proposal: uid(Decidim::Proposals::Proposal.new(id: resource.decidim_proposal_id)), + # author: uid(identity(resource)), # if there is user groups involved + author: uid(Decidim::User.new(id: resource.decidim_author_id)), + created_at: resource.try(:created_at), + updated_at: resource.try(:updated_at), + weight: resource.try(:weight), + temporary: resource.try(:temporary) + } + end + end + end +end diff --git a/app/services/decidim/content/csv_bundler.rb b/app/services/decidim/content/csv_bundler.rb index 78e0decd31..30a5b422eb 100644 --- a/app/services/decidim/content/csv_bundler.rb +++ b/app/services/decidim/content/csv_bundler.rb @@ -108,14 +108,10 @@ def bundle include_if: ->(parent) { parent&.manifest_name == "proposals" }, serializer: Decidim::Content::ProposalSerializer, collection: lambda { |parent| - Decidim::Proposals::Proposal - .published - .not_hidden - .where(component: parent) - .includes(:scope, :category) + proposals_for_component(parent).includes(:category) } }, - # TODO : after proposals -> comments, votes, endorsements, followers, notes + # TODO : after proposals -> endorsements, followers { path: "debates", include_if: ->(parent) { parent&.manifest_name == "debates" }, @@ -141,6 +137,28 @@ def bundle survey = Decidim::Surveys::Survey.find_by(component: parent) Decidim::Forms::QuestionnaireUserAnswers.for(survey.questionnaire) } + }, + { + path: "proposal-votes", + include_if: ->(parent) { parent&.manifest_name == "proposals" }, + serializer: Decidim::Content::ProposalVoteSerializer, + collection: lambda { |parent| + Decidim::Proposals::ProposalVote.where(decidim_proposal_id: proposals_for_component(parent).pluck(:id)) + } + }, + { + path: "proposal-notes", + include_if: ->(parent) { parent&.manifest_name == "proposals" }, + serializer: Decidim::Content::ProposalNoteSerializer, + collection: lambda { |parent| + Decidim::Proposals::ProposalNote.where(decidim_proposal_id: proposals_for_component(parent).pluck(:id)) + } + }, + { + path: "endorsements", + include_if: ->(parent) { endorsable_component?(parent&.manifest_name) && parent&.manifest_name == "proposals" }, + serializer: Decidim::Content::EndorsementSerializer, + collection: ->(parent) { endorsements_for_component(parent) } } ] } @@ -294,6 +312,13 @@ def participatory_process_users(participatory_process) private_users = participatory_process.users.select(:decidim_user_id).reorder("decidim_user_id").to_a (users_with_roles + private_users).uniq(&:decidim_user_id).map { |o| o.attributes.compact.symbolize_keys.merge(role: o[:role] || "private_user") } end + + def proposals_for_component(component) + Decidim::Proposals::Proposal + .published + .not_hidden + .where(component:) + end end end end diff --git a/lib/concerns/decidim/content/component_tools.rb b/lib/concerns/decidim/content/component_tools.rb index f48f7e2385..7b7db0233c 100644 --- a/lib/concerns/decidim/content/component_tools.rb +++ b/lib/concerns/decidim/content/component_tools.rb @@ -15,16 +15,28 @@ def component_resource_manifests(manifest_name) end end - def component_commentable_resource_manifests(manifest_name) - component_resource_manifests(manifest_name).select { |manifest| manifest.model_class_name&.constantize&.include?(Decidim::Comments::Commentable) } + def component_resource_manifests_including_trait(manifest_name, trait_module) + component_resource_manifests(manifest_name).select { |manifest| manifest.model_class_name&.constantize&.include?(trait_module) } + end + + def endorsable_component?(manifest_name) + component_resource_manifests_including_trait(manifest_name, Decidim::Endorsable).any? + end + + def followable_component?(manifest_name) + component_resource_manifests_including_trait(manifest_name, Decidim::Followable).any? + end + + def component_has_attachments?(manifest_name) + component_resource_manifests_including_trait(manifest_name, Decidim::HasAttachments).any? end def commentable_component?(manifest_name) - component_commentable_resource_manifests(manifest_name).any? + component_resource_manifests_including_trait(manifest_name, Decidim::Comments::Commentable).any? end def comments_for_component(component) - component_commentable_resource_manifests(component&.manifest_name).each.with_object([]) do |manifest, results| + component_resource_manifests_including_trait(component&.manifest_name, Decidim::Comments::Commentable).each.with_object([]) do |manifest, results| results.concat(comments_for_resource(manifest.model_class_name.constantize, component)) if manifest.model_class_name.present? end end @@ -60,6 +72,20 @@ def comments_for_resource(resource_class, component) end # rubocop:enable Metrics/CyclomaticComplexity # rubocop:enable Metrics/PerceivedComplexity + + def endorsements_for_component(component) + component_resource_manifests_including_trait(component&.manifest_name, Decidim::Endorsable).each.with_object([]) do |manifest, results| + results.concat(endorsements_for_resource(manifest.model_class_name.constantize, component)) if manifest.model_class_name.present? + end + end + + def endorsements_for_resource(resource_class, component) + endorsable_resources = resource_class.where(component:) + return Decidim::Endorsement.where(resource: endorsable_resources) if endorsable_resources.present? + + Rails.logger.warn "Decidim::Content::ComponentTools.endorsements_for_resource (concerns) : Unable to fetch endorsements for #{resource_class} with component association." + Decidim::Endorsement.none + end end end end diff --git a/lib/concerns/decidim/content/uid_tools.rb b/lib/concerns/decidim/content/uid_tools.rb index 94d3102e18..a10d9c5aff 100644 --- a/lib/concerns/decidim/content/uid_tools.rb +++ b/lib/concerns/decidim/content/uid_tools.rb @@ -17,6 +17,12 @@ def locate_resource_by_uid(uid) model_name = exploded.map(&:underscore).map(&:camelize).join("::") model_name.constantize.find(id) end + + def polymorphic_uid(resource, key) + return unless key.present? && resource.respond_to?(key.to_sym) + + uid(resource.try("#{key}_type".to_sym)&.safe_constantize&.new(id: resource.try("#{key}_id".to_sym))) + end end end end From 432c3737a0aa3907f94e3813bc2bb72f0b83ea18 Mon Sep 17 00:00:00 2001 From: moustachu Date: Thu, 25 Jun 2026 12:11:12 +0200 Subject: [PATCH 15/24] [WIP] fix(reversibility): misspelled column name --- app/serializers/decidim/content/attachment_serializer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/serializers/decidim/content/attachment_serializer.rb b/app/serializers/decidim/content/attachment_serializer.rb index fad1fe8f3a..f4557aba0f 100644 --- a/app/serializers/decidim/content/attachment_serializer.rb +++ b/app/serializers/decidim/content/attachment_serializer.rb @@ -14,7 +14,7 @@ def serialize # file: Decidim::AttachmentPresenter.new(resource).attachment_file_url file: blob_url(resource.file, resource.organization), attached_to: polymorphic_uid(resource, :attached_to), - collection: uid(Decidim::AttachmentCollection.new(id: resource.decidim_attachment_collection_id)) + collection: uid(Decidim::AttachmentCollection.new(id: resource.try(:attachment_collection_id))) } end end From 3d18e4eed6cad5a70fdc47406875f0fd3336b2c4 Mon Sep 17 00:00:00 2001 From: moustachu Date: Thu, 25 Jun 2026 12:30:50 +0200 Subject: [PATCH 16/24] [WIP] fix(reversibility): safeguard condition for missing id on uid method --- lib/concerns/decidim/content/uid_tools.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/concerns/decidim/content/uid_tools.rb b/lib/concerns/decidim/content/uid_tools.rb index a10d9c5aff..3bf7a0d8d7 100644 --- a/lib/concerns/decidim/content/uid_tools.rb +++ b/lib/concerns/decidim/content/uid_tools.rb @@ -8,7 +8,7 @@ module UidTools extend ActiveSupport::Concern included do def uid(resource) - resource.to_gid.uri.path.underscore.parameterize(separator: "--").dasherize if resource.respond_to?(:to_gid) + resource.to_gid.uri.path.underscore.parameterize(separator: "--").dasherize if resource.respond_to?(:to_gid) && resource.try(:id).present? end def locate_resource_by_uid(uid) From 9a97004ad5680ef3a7f97839d21d46bce6745da9 Mon Sep 17 00:00:00 2001 From: moustachu Date: Wed, 1 Jul 2026 00:29:00 +0200 Subject: [PATCH 17/24] [WIP] feat(reversibility): add component resource cache for filtering resource side records + add followers to bundle --- app/services/decidim/content/csv_bundler.rb | 20 ++--- .../decidim/content/component_tools.rb | 76 ++++++++++++++++++- lib/concerns/decidim/content/uid_tools.rb | 9 ++- 3 files changed, 89 insertions(+), 16 deletions(-) diff --git a/app/services/decidim/content/csv_bundler.rb b/app/services/decidim/content/csv_bundler.rb index 30a5b422eb..683eab04c8 100644 --- a/app/services/decidim/content/csv_bundler.rb +++ b/app/services/decidim/content/csv_bundler.rb @@ -117,10 +117,7 @@ def bundle include_if: ->(parent) { parent&.manifest_name == "debates" }, serializer: Decidim::Content::DebateSerializer, collection: lambda { |parent| - Decidim::Debates::Debate - .not_hidden - .where(component: parent) - .includes(:category) + debates_for_component(parent).includes(:category) } }, { @@ -156,9 +153,15 @@ def bundle }, { path: "endorsements", - include_if: ->(parent) { endorsable_component?(parent&.manifest_name) && parent&.manifest_name == "proposals" }, + include_if: ->(parent) { endorsable_component?(parent&.manifest_name) && %w(proposals debates).include?(parent&.manifest_name) }, serializer: Decidim::Content::EndorsementSerializer, collection: ->(parent) { endorsements_for_component(parent) } + }, + { + path: "followers", + include_if: ->(parent) { followable_component?(parent&.manifest_name) && %w(proposals debates).include?(parent&.manifest_name) }, + serializer: Decidim::Content::FollowerSerializer, + collection: ->(parent) { followers_for_component(parent) } } ] } @@ -312,13 +315,6 @@ def participatory_process_users(participatory_process) private_users = participatory_process.users.select(:decidim_user_id).reorder("decidim_user_id").to_a (users_with_roles + private_users).uniq(&:decidim_user_id).map { |o| o.attributes.compact.symbolize_keys.merge(role: o[:role] || "private_user") } end - - def proposals_for_component(component) - Decidim::Proposals::Proposal - .published - .not_hidden - .where(component:) - end end end end diff --git a/lib/concerns/decidim/content/component_tools.rb b/lib/concerns/decidim/content/component_tools.rb index 7b7db0233c..d81a6f15af 100644 --- a/lib/concerns/decidim/content/component_tools.rb +++ b/lib/concerns/decidim/content/component_tools.rb @@ -7,6 +7,26 @@ module Content module ComponentTools extend ActiveSupport::Concern included do + def component_resource_cache + @component_resource_cache ||= Hash.new { |h, k| h[k] = h.dup.clear } + end + + def component_resource_cache_set(component:, resource_class:, query:, force: false) + if force || component_resource_cache[component.id][resource_class.name].blank? + component_resource_cache[component.id][resource_class.name] = query + else + resource_class.none + end + end + + def component_resource_cache_get(component:, resource_class:) + component_resource_cache[component.id][resource_class.name] + end + + def component_resource_cache_exists?(component:, resource_class:) + component_resource_cache[component.id][resource_class.name].present? + end + def component_resource_manifests(manifest_name) return [] if manifest_name.blank? @@ -60,6 +80,12 @@ def comments_for_resource(resource_class, component) gateway_resources = gateway_reflection.try(:options)&.[](:class_name)&.constantize&.where(component:) root_commentable = gateway_resources.present? ? resource_class.where(through => gateway_resources) : resource_class.none end + + if component_resource_cache_exists?(component:, resource_class:) + cached_ids = component_resource_cache_get(component:, resource_class:).pluck(:id) + root_commentable = root_commentable.where(id: cached_ids) + end + if root_commentable.present? return Decidim::Comments::Comment .not_deleted @@ -67,7 +93,8 @@ def comments_for_resource(resource_class, component) .where(root_commentable:) end end - Rails.logger.warn "Decidim::Content::ComponentTools.comments_for_resource (concerns) : Unable to fetch comments for #{resource_class} with component association." + Rails.logger.warn "Decidim::Content::ComponentTools.comments_for_resource (concerns) : No comments found for #{resource_class} with component association." + Rails.logger.warn "-- cached query was involved with #{cached_ids.size} records" if component_resource_cache_exists?(component:, resource_class:) Decidim::Comments::Comment.none end # rubocop:enable Metrics/CyclomaticComplexity @@ -81,11 +108,56 @@ def endorsements_for_component(component) def endorsements_for_resource(resource_class, component) endorsable_resources = resource_class.where(component:) + if component_resource_cache_exists?(component:, resource_class:) + cached_ids = component_resource_cache_get(component:, resource_class:).pluck(:id) + endorsable_resources = endorsable_resources.where(id: cached_ids) + end return Decidim::Endorsement.where(resource: endorsable_resources) if endorsable_resources.present? - Rails.logger.warn "Decidim::Content::ComponentTools.endorsements_for_resource (concerns) : Unable to fetch endorsements for #{resource_class} with component association." + Rails.logger.warn "Decidim::Content::ComponentTools.endorsements_for_resource (concerns) : No endorsements found for #{resource_class} with component association." + Rails.logger.warn "-- cached query was involved with #{cached_ids.size} records" if component_resource_cache_exists?(component:, resource_class:) Decidim::Endorsement.none end + + def followers_for_component(component) + component_resource_manifests_including_trait(component&.manifest_name, Decidim::Followable).each.with_object([]) do |manifest, results| + results.concat(followers_for_resource(manifest.model_class_name.constantize, component)) if manifest.model_class_name.present? + end + end + + def followers_for_resource(resource_class, component) + followable_resources = resource_class.where(component:) + if component_resource_cache_exists?(component:, resource_class:) + cached_ids = component_resource_cache_get(component:, resource_class:).pluck(:id) + followable_resources = followable_resources.where(id: cached_ids) + end + return Decidim::Follow.where(followable: followable_resources) if followable_resources.present? + + Rails.logger.warn "Decidim::Content::ComponentTools.followers_for_resource (concerns) : No followers found for #{resource_class} with component association." + Rails.logger.warn "-- cached query was involved with #{cached_ids.size} records" if component_resource_cache_exists?(component:, resource_class:) + Decidim::Follow.none + end + + def proposals_for_component(component) + component_resource_cache_set( + component:, + resource_class: Decidim::Proposals::Proposal, + query: Decidim::Proposals::Proposal + .published + .not_hidden + .where(component:) + ) + end + + def debates_for_component(component) + component_resource_cache_set( + component:, + resource_class: Decidim::Debates::Debate, + query: Decidim::Debates::Debate + .not_hidden + .where(component:) + ) + end end end end diff --git a/lib/concerns/decidim/content/uid_tools.rb b/lib/concerns/decidim/content/uid_tools.rb index 3bf7a0d8d7..6ff756c3df 100644 --- a/lib/concerns/decidim/content/uid_tools.rb +++ b/lib/concerns/decidim/content/uid_tools.rb @@ -19,9 +19,14 @@ def locate_resource_by_uid(uid) end def polymorphic_uid(resource, key) - return unless key.present? && resource.respond_to?(key.to_sym) + return if key.blank? - uid(resource.try("#{key}_type".to_sym)&.safe_constantize&.new(id: resource.try("#{key}_id".to_sym))) + type_attribute = "#{key}_type".to_sym + id_attribute = "#{key}_id".to_sym + return unless resource.respond_to?(type_attribute) && resource.respond_to?(id_attribute) + + # Rails.logger.debug { "polymorphic_uid: resource=#{resource.class.name} key=#{key} type=#{resource.try(type_attribute)} id=#{resource.try(id_attribute)}" } + uid(resource.try(type_attribute)&.safe_constantize&.new(id: resource.try(id_attribute)&.to_i)) end end end From 1f62d26f5031394f0f790064800f26c5eb7c48a5 Mon Sep 17 00:00:00 2001 From: moustachu Date: Wed, 1 Jul 2026 10:36:38 +0200 Subject: [PATCH 18/24] [WIP] feat(reversibility): add accountability to bundle + component resources attachments --- .../accountability_result_serializer.rb | 47 ++++++++++++++++++ .../accountability_status_serializer.rb | 22 +++++++++ app/services/decidim/content/csv_bundler.rb | 28 +++++++++-- .../decidim/content/component_tools.rb | 48 +++++++++++++++++++ 4 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 app/serializers/decidim/content/accountability_result_serializer.rb create mode 100644 app/serializers/decidim/content/accountability_status_serializer.rb diff --git a/app/serializers/decidim/content/accountability_result_serializer.rb b/app/serializers/decidim/content/accountability_result_serializer.rb new file mode 100644 index 0000000000..eaffcf3b9b --- /dev/null +++ b/app/serializers/decidim/content/accountability_result_serializer.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Decidim + module Content + class AccountabilityResultSerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(resource), + reference: resource.try(:reference), + category: uid(resource.try(:category)), + scope: uid(Decidim::Scope.new(id: resource.try(:decidim_scope_id))), + parent: uid(Decidim::Accountability::Result.new(id: resource.try(:parent_id))), + title: normalize_translated_attribute(resource.title), + description: normalize_translated_attribute(resource.description), + start_date: resource.try(:start_date), + end_date: resource.try(:end_date), + status: uid(Decidim::Accountability::Status.new(id: resource.try(:decidim_accountability_status_id))), + progress: resource.try(:progress), + timeline: timeline_entries, + children_count: resource.try(:children_count), + external_id: resource.try(:external_id), + comment_count: resource.try(:comment_count), + linked_proposals: resource.linked_resources(:proposals, "included_proposals").map { |proposal| uid(Decidim::Proposals::Proposal.new(id: proposal.id)) }, + linked_projects: resource.linked_resources(:projects, "included_projects").map { |project| uid(Decidim::Budgets::Project.new(id: project.id)) }, + created_at: resource.try(:created_at), + updated_at: resource.try(:updated_at), + component: uid(resource.try(:component)), + url: Decidim::ResourceLocatorPresenter.new(resource).url + } + end + + def timeline_entries + resource.timeline_entries.collect do |entry| + { + date: entry.try(:date), + title: normalize_translated_attribute(entry.try(:title)), + description: normalize_translated_attribute(entry.try(:description)) + # created_at: entry.try(:created_at), + # updated_at: entry.try(:updated_at) + } + end + end + end + end +end diff --git a/app/serializers/decidim/content/accountability_status_serializer.rb b/app/serializers/decidim/content/accountability_status_serializer.rb new file mode 100644 index 0000000000..79ece47d5c --- /dev/null +++ b/app/serializers/decidim/content/accountability_status_serializer.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Decidim + module Content + class AccountabilityStatusSerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(resource), + key: resource.try(:key), + name: normalize_translated_attribute(resource.try(:name)), + description: normalize_translated_attribute(resource.try(:description)), + progress: resource.try(:progress), + created_at: resource.try(:created_at), + updated_at: resource.try(:updated_at), + component: uid(resource.try(:component)) + } + end + end + end +end diff --git a/app/services/decidim/content/csv_bundler.rb b/app/services/decidim/content/csv_bundler.rb index 683eab04c8..151bb34db4 100644 --- a/app/services/decidim/content/csv_bundler.rb +++ b/app/services/decidim/content/csv_bundler.rb @@ -102,7 +102,17 @@ def bundle serializer: Decidim::Content::ComponentSerializer, collection: ->(parent) { [parent] } }, - # TODO : before proposals -> attachments, states + { + path: "statuses", + serializer: Decidim::Content::AccountabilityStatusSerializer, + collection: ->(parent) { Decidim::Accountability::Status.where(component: parent) } + }, + { + path: "results", + serializer: Decidim::Content::AccountabilityResultSerializer, + collection: ->(parent) { accountability_results_for_component(parent).includes(:category) } + }, + # TODO : before proposals -> states { path: "proposals", include_if: ->(parent) { parent&.manifest_name == "proposals" }, @@ -120,9 +130,21 @@ def bundle debates_for_component(parent).includes(:category) } }, + { + path: "attachment_collections", + include_if: ->(parent) { component_has_attachments?(parent&.manifest_name) && %w(proposals accountability).include?(parent&.manifest_name) }, + serializer: Decidim::Content::AttachmentCollectionSerializer, + collection: ->(parent) { attachment_collections_for_component(parent) } + }, + { + path: "attachments", + include_if: ->(parent) { component_has_attachments?(parent&.manifest_name) && %w(proposals accountability).include?(parent&.manifest_name) }, + serializer: Decidim::Content::AttachmentSerializer, + collection: ->(parent) { attachments_for_component(parent) } + }, { path: "comments", - include_if: ->(parent) { commentable_component?(parent&.manifest_name) && %w(proposals debates).include?(parent&.manifest_name) }, + include_if: ->(parent) { commentable_component?(parent&.manifest_name) && %w(proposals debates accountability).include?(parent&.manifest_name) }, serializer: Decidim::Content::CommentSerializer, collection: ->(parent) { comments_for_component(parent) } }, @@ -159,7 +181,7 @@ def bundle }, { path: "followers", - include_if: ->(parent) { followable_component?(parent&.manifest_name) && %w(proposals debates).include?(parent&.manifest_name) }, + include_if: ->(parent) { followable_component?(parent&.manifest_name) && %w(proposals debates accountability).include?(parent&.manifest_name) }, serializer: Decidim::Content::FollowerSerializer, collection: ->(parent) { followers_for_component(parent) } } diff --git a/lib/concerns/decidim/content/component_tools.rb b/lib/concerns/decidim/content/component_tools.rb index d81a6f15af..66d86bc4e6 100644 --- a/lib/concerns/decidim/content/component_tools.rb +++ b/lib/concerns/decidim/content/component_tools.rb @@ -138,6 +138,46 @@ def followers_for_resource(resource_class, component) Decidim::Follow.none end + def attachment_collections_for_component(component) + component_resource_manifests_including_trait(component&.manifest_name, Decidim::HasAttachments).each.with_object([]) do |manifest, results| + results.concat(attachment_collections_for_resource(manifest.model_class_name.constantize, component)) if manifest.model_class_name.present? + end + end + + def attachment_collections_for_resource(resource_class, component) + attachable_resources = resource_class.where(component:) + if component_resource_cache_exists?(component:, resource_class:) + cached_ids = component_resource_cache_get(component:, resource_class:).pluck(:id) + attachable_resources = attachable_resources.where(id: cached_ids) + end + return Decidim::AttachmentCollection.where(collection_for: attachable_resources) if attachable_resources.present? + + Rails.logger.warn do + "Decidim::Content::ComponentTools.attachment_collections_for_resource (concerns) : No attachment collections found for #{resource_class} with component association." + end + Rails.logger.warn "-- cached query was involved with #{cached_ids.size} records" if component_resource_cache_exists?(component:, resource_class:) + Decidim::AttachmentCollection.none + end + + def attachments_for_component(component) + component_resource_manifests_including_trait(component&.manifest_name, Decidim::HasAttachments).each.with_object([]) do |manifest, results| + results.concat(attachments_for_resource(manifest.model_class_name.constantize, component)) if manifest.model_class_name.present? + end + end + + def attachments_for_resource(resource_class, component) + attachable_resources = resource_class.where(component:) + if component_resource_cache_exists?(component:, resource_class:) + cached_ids = component_resource_cache_get(component:, resource_class:).pluck(:id) + attachable_resources = attachable_resources.where(id: cached_ids) + end + return Decidim::Attachment.where(attached_to: attachable_resources) if attachable_resources.present? + + Rails.logger.warn "Decidim::Content::ComponentTools.attachments_for_resource (concerns) : No attachments found for #{resource_class} with component association." + Rails.logger.warn "-- cached query was involved with #{cached_ids.size} records" if component_resource_cache_exists?(component:, resource_class:) + Decidim::Attachment.none + end + def proposals_for_component(component) component_resource_cache_set( component:, @@ -158,6 +198,14 @@ def debates_for_component(component) .where(component:) ) end + + def accountability_results_for_component(component) + component_resource_cache_set( + component:, + resource_class: Decidim::Accountability::Result, + query: Decidim::Accountability::Result.where(component:).order("children_count DESC, parent_id ASC, id ASC") + ) + end end end end From a473f4760b361f51a3395688b53948e54b833abf Mon Sep 17 00:00:00 2001 From: moustachu Date: Wed, 1 Jul 2026 11:11:53 +0200 Subject: [PATCH 19/24] [WIP] fix(reversibility): docker build warning on node 20 --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index fbea4e0a32..35902921b6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,7 @@ ENV RAILS_ENV=production \ BUNDLE_JOBS=8 \ MAKEFLAGS="-j8" \ NODE_OPTIONS="--max-old-space-size=4096" \ + ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true \ EXECJS_RUNTIME=Node WORKDIR /opt/decidim From 2885073b3691a6bd12414d6443b77e02dc6eb6e3 Mon Sep 17 00:00:00 2001 From: moustachu Date: Thu, 2 Jul 2026 13:25:01 +0200 Subject: [PATCH 20/24] [WIP] feat(reversibility): add assemblies (participatory spaces) to bundle (reusing component bundle already available) --- .../admin/content/bundle_controller.rb | 2 +- .../content/assemblies_type_serializer.rb | 18 ++ .../content/assembly_member_serializer.rb | 28 ++ .../decidim/content/assembly_serializer.rb | 66 +++++ .../participatory_process_serializer.rb | 16 +- app/services/decidim/content/csv_bundler.rb | 276 ++++++++++-------- lib/concerns/decidim/content/url_tools.rb | 2 +- 7 files changed, 280 insertions(+), 128 deletions(-) create mode 100644 app/serializers/decidim/content/assemblies_type_serializer.rb create mode 100644 app/serializers/decidim/content/assembly_member_serializer.rb create mode 100644 app/serializers/decidim/content/assembly_serializer.rb diff --git a/app/controllers/decidim/admin/content/bundle_controller.rb b/app/controllers/decidim/admin/content/bundle_controller.rb index d3fb22e605..ac0b71d9b9 100644 --- a/app/controllers/decidim/admin/content/bundle_controller.rb +++ b/app/controllers/decidim/admin/content/bundle_controller.rb @@ -12,7 +12,7 @@ def export respond_to do |format| format.zip do send_data zip_data.read, - filename: "content-bundle--#{Time.zone.now.strftime("%Y%m%d-%H%M%S")}.zip", + filename: "#{current_organization.host}--content-bundle--#{Time.zone.now.strftime("%Y%m%d-%H%M%S")}.zip", type: "application/zip" end end diff --git a/app/serializers/decidim/content/assemblies_type_serializer.rb b/app/serializers/decidim/content/assemblies_type_serializer.rb new file mode 100644 index 0000000000..a6f86395d8 --- /dev/null +++ b/app/serializers/decidim/content/assemblies_type_serializer.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Decidim + module Content + class AssembliesTypeSerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(resource), + title: normalize_translated_attribute(resource.title), + created_at: resource.created_at, + updated_at: resource.updated_at + } + end + end + end +end diff --git a/app/serializers/decidim/content/assembly_member_serializer.rb b/app/serializers/decidim/content/assembly_member_serializer.rb new file mode 100644 index 0000000000..a6e8ce17f4 --- /dev/null +++ b/app/serializers/decidim/content/assembly_member_serializer.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Decidim + module Content + class AssemblyMemberSerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(resource), + user: resource.try(:decidim_user_id).present? ? uid(Decidim::User.new(id: resource.decidim_user_id)) : nil, + full_name: normalize_translated_attribute(resource.full_name), + position: resource.try(:position), + position_other: normalize_translated_attribute(resource.try(:position_other)), + designation_date: resource.try(:designation_date), + ceased_date: resource.try(:ceased_date), + gender: resource.try(:gender), + birthday: resource.try(:birthday), + birthplace: resource.try(:birthplace), + non_user_avatar: blob_url(resource.try(:non_user_avatar), resource.organization), + weight: resource.try(:weight), + created_at: resource.created_at, + updated_at: resource.updated_at + } + end + end + end +end diff --git a/app/serializers/decidim/content/assembly_serializer.rb b/app/serializers/decidim/content/assembly_serializer.rb new file mode 100644 index 0000000000..84af21cc2b --- /dev/null +++ b/app/serializers/decidim/content/assembly_serializer.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Decidim + module Content + class AssemblySerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(resource), + parent: resource.try(:parent_id).present? ? uid(Decidim::Assembly.new(id: resource.parent_id)) : nil, + assembly_path: resource.parents_path.split(".").map { |parent| uid(Decidim::Assembly.new(id: parent.to_i)) }, + children_count: resource.children_count, + assembly_type: resource.try(:decidim_assemblies_type_id).present? ? uid(Decidim::AssembliesType.new(id: resource.decidim_assemblies_type_id)) : nil, + slug: resource.slug, + reference: resource.try(:reference), + title: normalize_translated_attribute(resource.title), + subtitle: normalize_translated_attribute(resource.subtitle), + short_description: normalize_translated_attribute(resource.short_description), + description: normalize_translated_attribute(resource.description), + purpose_of_action: normalize_translated_attribute(resource.try(:purpose_of_action)), + composition: normalize_translated_attribute(resource.try(:composition)), + internal_organisation: normalize_translated_attribute(resource.try(:internal_organisation)), + announcement: normalize_translated_attribute(resource.try(:announcement)), + creation_date: resource.try(:creation_date), + included_at: resource.try(:included_at), + duration: resource.try(:duration), + closing_date: resource.try(:closing_date), + closing_date_reason: normalize_translated_attribute(resource.try(:closing_date_reason)), + created_by: resource.try(:created_by), + created_by_other: normalize_translated_attribute(resource.try(:created_by_other)), + hero_image: blob_url(resource.hero_image, resource.organization), + banner_image: blob_url(resource.banner_image, resource.organization), + area: uid(resource.try(:area)), + scopes_enabled: resource.scopes_enabled, + scope: uid(resource.try(:scope)), + private_space: resource.private_space, + is_transparent: resource.try(:is_transparent), + promoted: resource.promoted, + metadata: { + hashtag: resource.hashtag, + developer_group: normalize_translated_attribute(resource.try(:developer_group)), + local_area: normalize_translated_attribute(resource.try(:local_area)), + meta_scope: normalize_translated_attribute(resource.try(:meta_scope)), + target: normalize_translated_attribute(resource.try(:target)), + participatory_scope: normalize_translated_attribute(resource.try(:participatory_scope)), + participatory_structure: normalize_translated_attribute(resource.try(:participatory_structure)) + }, + social_media: { + twitter_handler: resource.try(:twitter_handler), + instagram_handler: resource.try(:instagram_handler), + facebook_handler: resource.try(:facebook_handler), + youtube_handler: resource.try(:youtube_handler), + github_handler: resource.try(:github_handler) + }, + special_features: normalize_translated_attribute(resource.try(:special_features)), + weight: resource.weight, + created_at: resource.created_at, + updated_at: resource.updated_at, + published_at: resource.try(:published_at), + url: Decidim::ResourceLocatorPresenter.new(resource).url + } + end + end + end +end diff --git a/app/serializers/decidim/content/participatory_process_serializer.rb b/app/serializers/decidim/content/participatory_process_serializer.rb index 33f682c474..92d346b787 100644 --- a/app/serializers/decidim/content/participatory_process_serializer.rb +++ b/app/serializers/decidim/content/participatory_process_serializer.rb @@ -11,6 +11,7 @@ def serialize title: normalize_translated_attribute(resource.title), subtitle: normalize_translated_attribute(resource.subtitle), slug: resource.slug, + reference: resource.try(:reference), short_description: normalize_translated_attribute(resource.short_description), description: normalize_translated_attribute(resource.description), announcement: normalize_translated_attribute(resource.announcement), @@ -20,19 +21,20 @@ def serialize participatory_process_group: uid(resource.try(:participatory_process_group)), participatory_process_type: normalize_translated_attribute(resource.participatory_process_type.try(:title)), area: uid(resource.try(:area)), + scopes_enabled: resource.scopes_enabled, scope: uid(resource.try(:scope)), private_space: resource.private_space, promoted: resource.promoted, - scopes_enabled: resource.scopes_enabled, metadata: { hashtag: resource.hashtag, - developer_group: resource.developer_group, - local_area: resource.local_area, - meta_scope: resource.meta_scope, - target: resource.target, - participatory_scope: resource.participatory_scope, - participatory_structure: resource.participatory_structure + developer_group: normalize_translated_attribute(resource.try(:developer_group)), + local_area: normalize_translated_attribute(resource.try(:local_area)), + meta_scope: normalize_translated_attribute(resource.try(:meta_scope)), + target: normalize_translated_attribute(resource.try(:target)), + participatory_scope: normalize_translated_attribute(resource.try(:participatory_scope)), + participatory_structure: normalize_translated_attribute(resource.try(:participatory_structure)) }, + weight: resource.weight, created_at: resource.created_at, updated_at: resource.updated_at, published_at: resource.try(:published_at), diff --git a/app/services/decidim/content/csv_bundler.rb b/app/services/decidim/content/csv_bundler.rb index 151bb34db4..7223e9884a 100644 --- a/app/services/decidim/content/csv_bundler.rb +++ b/app/services/decidim/content/csv_bundler.rb @@ -69,126 +69,36 @@ def bundle serializer: Decidim::Content::ParticipatoryProcessStepSerializer, collection: ->(parent) { parent.steps.reorder("start_date ASC") } }, + *participatory_space_shared_bundle_array, + **components_bundle_hash + ] + } + ] + }, + { + path: "assemblies", + children: [ + { + path: "assembly-types", + serializer: Decidim::Content::AssembliesTypeSerializer, + collection: Decidim::AssembliesType.where(organization:).reorder("id ASC") + }, + { + path: ->(resource) { uid(resource) }, + collection: Decidim::Assembly.where(organization:).reorder("id ASC"), + children: [ { - path: "categories", - serializer: Decidim::Content::CategorySerializer, - collection: ->(parent) { parent.categories } - }, - { - path: "attachment_collections", - serializer: Decidim::Content::AttachmentCollectionSerializer, - collection: ->(parent) { parent.attachment_collections } - }, - { - path: "attachments", - serializer: Decidim::Content::AttachmentSerializer, - collection: ->(parent) { parent.attachments } + path: "assembly", + serializer: Decidim::Content::AssemblySerializer, + collection: ->(parent) { [parent] } }, + *participatory_space_shared_bundle_array, { - path: "users", - serializer: Decidim::Content::ParticipatorySpaceUserSerializer, - collection: ->(parent) { participatory_process_users(parent) } + path: "members", + serializer: Decidim::Content::AssemblyMemberSerializer, + collection: ->(parent) { parent.members.includes(:user).reorder("id ASC") } }, - # TODO : followers - { - path: "components", - children: [ - { - path: ->(resource) { "#{uid(resource)}---#{resource.try(:manifest_name)}" }, - collection: ->(parent) { parent.components }, - children: [ - { - path: "component", - serializer: Decidim::Content::ComponentSerializer, - collection: ->(parent) { [parent] } - }, - { - path: "statuses", - serializer: Decidim::Content::AccountabilityStatusSerializer, - collection: ->(parent) { Decidim::Accountability::Status.where(component: parent) } - }, - { - path: "results", - serializer: Decidim::Content::AccountabilityResultSerializer, - collection: ->(parent) { accountability_results_for_component(parent).includes(:category) } - }, - # TODO : before proposals -> states - { - path: "proposals", - include_if: ->(parent) { parent&.manifest_name == "proposals" }, - serializer: Decidim::Content::ProposalSerializer, - collection: lambda { |parent| - proposals_for_component(parent).includes(:category) - } - }, - # TODO : after proposals -> endorsements, followers - { - path: "debates", - include_if: ->(parent) { parent&.manifest_name == "debates" }, - serializer: Decidim::Content::DebateSerializer, - collection: lambda { |parent| - debates_for_component(parent).includes(:category) - } - }, - { - path: "attachment_collections", - include_if: ->(parent) { component_has_attachments?(parent&.manifest_name) && %w(proposals accountability).include?(parent&.manifest_name) }, - serializer: Decidim::Content::AttachmentCollectionSerializer, - collection: ->(parent) { attachment_collections_for_component(parent) } - }, - { - path: "attachments", - include_if: ->(parent) { component_has_attachments?(parent&.manifest_name) && %w(proposals accountability).include?(parent&.manifest_name) }, - serializer: Decidim::Content::AttachmentSerializer, - collection: ->(parent) { attachments_for_component(parent) } - }, - { - path: "comments", - include_if: ->(parent) { commentable_component?(parent&.manifest_name) && %w(proposals debates accountability).include?(parent&.manifest_name) }, - serializer: Decidim::Content::CommentSerializer, - collection: ->(parent) { comments_for_component(parent) } - }, - { - path: "answers", - include_if: ->(parent) { parent&.manifest_name == "surveys" }, - serializer: Decidim::Content::SurveyAnswerSerializer, - collection: lambda { |parent| - survey = Decidim::Surveys::Survey.find_by(component: parent) - Decidim::Forms::QuestionnaireUserAnswers.for(survey.questionnaire) - } - }, - { - path: "proposal-votes", - include_if: ->(parent) { parent&.manifest_name == "proposals" }, - serializer: Decidim::Content::ProposalVoteSerializer, - collection: lambda { |parent| - Decidim::Proposals::ProposalVote.where(decidim_proposal_id: proposals_for_component(parent).pluck(:id)) - } - }, - { - path: "proposal-notes", - include_if: ->(parent) { parent&.manifest_name == "proposals" }, - serializer: Decidim::Content::ProposalNoteSerializer, - collection: lambda { |parent| - Decidim::Proposals::ProposalNote.where(decidim_proposal_id: proposals_for_component(parent).pluck(:id)) - } - }, - { - path: "endorsements", - include_if: ->(parent) { endorsable_component?(parent&.manifest_name) && %w(proposals debates).include?(parent&.manifest_name) }, - serializer: Decidim::Content::EndorsementSerializer, - collection: ->(parent) { endorsements_for_component(parent) } - }, - { - path: "followers", - include_if: ->(parent) { followable_component?(parent&.manifest_name) && %w(proposals debates accountability).include?(parent&.manifest_name) }, - serializer: Decidim::Content::FollowerSerializer, - collection: ->(parent) { followers_for_component(parent) } - } - ] - } - ] - } + **components_bundle_hash ] } ] @@ -197,6 +107,134 @@ def bundle ) end + def participatory_space_shared_bundle_array + @participatory_space_shared_bundle_array ||= [ + { + path: "categories", + serializer: Decidim::Content::CategorySerializer, + collection: ->(parent) { parent.categories } + }, + { + path: "attachment_collections", + serializer: Decidim::Content::AttachmentCollectionSerializer, + collection: ->(parent) { parent.attachment_collections } + }, + { + path: "attachments", + serializer: Decidim::Content::AttachmentSerializer, + collection: ->(parent) { parent.attachments } + }, + { + path: "users", + serializer: Decidim::Content::ParticipatorySpaceUserSerializer, + collection: ->(parent) { participatory_space_users(parent) } + } + # TODO : followers + ] + end + + def components_bundle_hash + @components_bundle_hash ||= { + path: "components", + children: [ + { + path: ->(resource) { "#{uid(resource)}---#{resource.try(:manifest_name)}" }, + collection: ->(parent) { parent.components }, + children: [ + { + path: "component", + serializer: Decidim::Content::ComponentSerializer, + collection: ->(parent) { [parent] } + }, + { + path: "statuses", + serializer: Decidim::Content::AccountabilityStatusSerializer, + collection: ->(parent) { Decidim::Accountability::Status.where(component: parent) } + }, + { + path: "results", + serializer: Decidim::Content::AccountabilityResultSerializer, + collection: ->(parent) { accountability_results_for_component(parent).includes(:category) } + }, + # TODO : before proposals -> states + { + path: "proposals", + include_if: ->(parent) { parent&.manifest_name == "proposals" }, + serializer: Decidim::Content::ProposalSerializer, + collection: lambda { |parent| + proposals_for_component(parent).includes(:category) + } + }, + # TODO : after proposals -> endorsements, followers + { + path: "debates", + include_if: ->(parent) { parent&.manifest_name == "debates" }, + serializer: Decidim::Content::DebateSerializer, + collection: lambda { |parent| + debates_for_component(parent).includes(:category) + } + }, + { + path: "attachment_collections", + include_if: ->(parent) { component_has_attachments?(parent&.manifest_name) && %w(proposals accountability).include?(parent&.manifest_name) }, + serializer: Decidim::Content::AttachmentCollectionSerializer, + collection: ->(parent) { attachment_collections_for_component(parent) } + }, + { + path: "attachments", + include_if: ->(parent) { component_has_attachments?(parent&.manifest_name) && %w(proposals accountability).include?(parent&.manifest_name) }, + serializer: Decidim::Content::AttachmentSerializer, + collection: ->(parent) { attachments_for_component(parent) } + }, + { + path: "comments", + include_if: ->(parent) { commentable_component?(parent&.manifest_name) && %w(proposals debates accountability).include?(parent&.manifest_name) }, + serializer: Decidim::Content::CommentSerializer, + collection: ->(parent) { comments_for_component(parent) } + }, + { + path: "answers", + include_if: ->(parent) { parent&.manifest_name == "surveys" }, + serializer: Decidim::Content::SurveyAnswerSerializer, + collection: lambda { |parent| + survey = Decidim::Surveys::Survey.find_by(component: parent) + Decidim::Forms::QuestionnaireUserAnswers.for(survey.questionnaire) + } + }, + { + path: "proposal-votes", + include_if: ->(parent) { parent&.manifest_name == "proposals" }, + serializer: Decidim::Content::ProposalVoteSerializer, + collection: lambda { |parent| + Decidim::Proposals::ProposalVote.where(decidim_proposal_id: proposals_for_component(parent).pluck(:id)) + } + }, + { + path: "proposal-notes", + include_if: ->(parent) { parent&.manifest_name == "proposals" }, + serializer: Decidim::Content::ProposalNoteSerializer, + collection: lambda { |parent| + Decidim::Proposals::ProposalNote.where(decidim_proposal_id: proposals_for_component(parent).pluck(:id)) + } + }, + { + path: "endorsements", + include_if: ->(parent) { endorsable_component?(parent&.manifest_name) && %w(proposals debates).include?(parent&.manifest_name) }, + serializer: Decidim::Content::EndorsementSerializer, + collection: ->(parent) { endorsements_for_component(parent) } + }, + { + path: "followers", + include_if: ->(parent) { followable_component?(parent&.manifest_name) && %w(proposals debates accountability).include?(parent&.manifest_name) }, + serializer: Decidim::Content::FollowerSerializer, + collection: ->(parent) { followers_for_component(parent) } + } + ] + } + ] + } + end + def export_to_directory bundle.each do |exportable| exportable_path = File.join(local_export_path, "#{exportable[:path]}.csv") @@ -332,9 +370,9 @@ def generate_csv_for(exportable) ).export.read end - def participatory_process_users(participatory_process) - users_with_roles = participatory_process.user_roles.select(:decidim_user_id, :role).reorder("decidim_user_id").to_a - private_users = participatory_process.users.select(:decidim_user_id).reorder("decidim_user_id").to_a + def participatory_space_users(participatory_space) + users_with_roles = participatory_space.user_roles.select(:decidim_user_id, :role).reorder("decidim_user_id").to_a + private_users = participatory_space.users.select(:decidim_user_id).reorder("decidim_user_id").to_a (users_with_roles + private_users).uniq(&:decidim_user_id).map { |o| o.attributes.compact.symbolize_keys.merge(role: o[:role] || "private_user") } end end diff --git a/lib/concerns/decidim/content/url_tools.rb b/lib/concerns/decidim/content/url_tools.rb index ee8503cdad..068b398d09 100644 --- a/lib/concerns/decidim/content/url_tools.rb +++ b/lib/concerns/decidim/content/url_tools.rb @@ -18,7 +18,7 @@ def switch_url_port(url) end def blob_url(attachment, organization) - return unless attachment.respond_to?(:blob) && attachment.blob.present? + return unless attachment.present? && attachment.respond_to?(:blob) && attachment.blob.present? # TODO : Optimize Decidim::Organization , ActiveStorage::Attachment and ActiveStorage::Blob eager load on each call From f6d570c71beac5ca6c11d07e86a93abf4a8a603f Mon Sep 17 00:00:00 2001 From: moustachu Date: Sun, 5 Jul 2026 21:00:04 +0200 Subject: [PATCH 21/24] [WIP] feat(reversibility): add budget component to bundle --- .../content/budget_order_serializer.rb | 24 ++++++++ .../content/budget_project_serializer.rb | 33 +++++++++++ .../decidim/content/budget_serializer.rb | 25 ++++++++ app/services/decidim/content/csv_bundler.rb | 59 ++++++++++++++++--- .../decidim/content/component_tools.rb | 38 +++++++----- 5 files changed, 158 insertions(+), 21 deletions(-) create mode 100644 app/serializers/decidim/content/budget_order_serializer.rb create mode 100644 app/serializers/decidim/content/budget_project_serializer.rb create mode 100644 app/serializers/decidim/content/budget_serializer.rb diff --git a/app/serializers/decidim/content/budget_order_serializer.rb b/app/serializers/decidim/content/budget_order_serializer.rb new file mode 100644 index 0000000000..063444aebb --- /dev/null +++ b/app/serializers/decidim/content/budget_order_serializer.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Decidim + module Content + class BudgetOrderSerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(resource), + user: uid(Decidim::User.new(id: resource.try(:decidim_user_id))), + status: resource.try(:checked_out_at).present? ? "finished" : "pending", + # budget: uid(Decidim::Budgets::Budget.new(id: resource.try(:decidim_budget_id))), + projects: resource.try(:line_items).map { |line| uid(Decidim::Budgets::Project.new(id: line.try(:decidim_project_id))) }, + # total_budget: resource.try(:total_budget), + # total_projects: resource.try(:total_projects), + checked_out_at: resource.try(:checked_out_at), + created_at: resource.try(:created_at), + updated_at: resource.try(:updated_at) + } + end + end + end +end diff --git a/app/serializers/decidim/content/budget_project_serializer.rb b/app/serializers/decidim/content/budget_project_serializer.rb new file mode 100644 index 0000000000..7017489083 --- /dev/null +++ b/app/serializers/decidim/content/budget_project_serializer.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Decidim + module Content + class BudgetProjectSerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(resource), + reference: resource.try(:reference), + title: normalize_translated_attribute(resource.title), + description: normalize_translated_attribute(resource.description), + budget_amount: resource.try(:budget_amount), + # confirmed_votes: resource.try(:confirmed_orders_count), + category: uid(resource.try(:category)), + scope: uid(Decidim::Scope.new(id: resource.try(:decidim_scope_id))), + address: resource.try(:address), + latitude: resource.try(:latitude), + longitude: resource.try(:longitude), + comments_count: resource.try(:comments_count), + follows_count: resource.try(:follows_count), + created_at: resource.try(:created_at), + updated_at: resource.try(:updated_at), + selected_at: resource.try(:selected_at), + budget: uid(resource.try(:budget)), + component: uid(resource.try(:component)), + url: Decidim::ResourceLocatorPresenter.new([resource.try(:budget), resource]).url + } + end + end + end +end diff --git a/app/serializers/decidim/content/budget_serializer.rb b/app/serializers/decidim/content/budget_serializer.rb new file mode 100644 index 0000000000..db5fff5c33 --- /dev/null +++ b/app/serializers/decidim/content/budget_serializer.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Decidim + module Content + class BudgetSerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(resource), + title: normalize_translated_attribute(resource.title), + description: normalize_translated_attribute(resource.description), + total_budget: resource.try(:total_budget), + scope: uid(Decidim::Scope.new(id: resource.try(:decidim_scope_id))), + category_budget_rules: resource.try(:category_budget_rules), + weight: resource.try(:weight), + created_at: resource.try(:created_at), + updated_at: resource.try(:updated_at), + component: uid(resource.try(:component)), + url: Decidim::ResourceLocatorPresenter.new(resource).url + } + end + end + end +end diff --git a/app/services/decidim/content/csv_bundler.rb b/app/services/decidim/content/csv_bundler.rb index 7223e9884a..f7359d5e88 100644 --- a/app/services/decidim/content/csv_bundler.rb +++ b/app/services/decidim/content/csv_bundler.rb @@ -9,7 +9,6 @@ module Decidim module Content class CsvBundler - include Decidim::Content::UidTools include Decidim::Content::ComponentTools DEFAULT_OPTIONS = { @@ -161,19 +160,16 @@ def components_bundle_hash path: "proposals", include_if: ->(parent) { parent&.manifest_name == "proposals" }, serializer: Decidim::Content::ProposalSerializer, - collection: lambda { |parent| - proposals_for_component(parent).includes(:category) - } + collection: ->(parent) { proposals_for_component(parent).includes(:category) } }, # TODO : after proposals -> endorsements, followers { path: "debates", include_if: ->(parent) { parent&.manifest_name == "debates" }, serializer: Decidim::Content::DebateSerializer, - collection: lambda { |parent| - debates_for_component(parent).includes(:category) - } + collection: ->(parent) { debates_for_component(parent).includes(:category) } }, + *components_bundle_for_budgets_array, { path: "attachment_collections", include_if: ->(parent) { component_has_attachments?(parent&.manifest_name) && %w(proposals accountability).include?(parent&.manifest_name) }, @@ -235,6 +231,55 @@ def components_bundle_hash } end + def components_bundle_for_budgets_array + # A budget component can have multiple budgets, and each budget can have multiple projects. + # So we create a folder for each budget, and inside that folder we create a folder for each project. + [ + { + path: ->(resource) { uid(resource) }, + include_if: ->(parent) { parent&.manifest_name == "budgets" }, + collection: ->(parent) { Decidim::Budgets::Budget.where(component: parent).reorder(:weight, :id) }, + children: [ + { + path: "budget", + serializer: Decidim::Content::BudgetSerializer, + collection: ->(parent) { [parent] } + }, + { + path: "projects", + serializer: Decidim::Content::BudgetProjectSerializer, + collection: ->(parent) { projects_for_budget(parent).includes(:category, :budget) } + }, + { + path: "orders", + serializer: Decidim::Content::BudgetOrderSerializer, + collection: ->(parent) { Decidim::Budgets::Order.where(budget: parent).includes(:line_items) } + }, + { + path: "attachment_collections", + serializer: Decidim::Content::AttachmentCollectionSerializer, + collection: ->(parent) { Decidim::AttachmentCollection.where(collection_for: projects_for_budget(parent)) } + }, + { + path: "attachments", + serializer: Decidim::Content::AttachmentSerializer, + collection: ->(parent) { Decidim::Attachment.where(attached_to: projects_for_budget(parent)) } + }, + { + path: "comments", + serializer: Decidim::Content::CommentSerializer, + collection: ->(parent) { Decidim::Comments::Comment.where(root_commentable: projects_for_budget(parent)) } + }, + { + path: "followers", + serializer: Decidim::Content::FollowerSerializer, + collection: ->(parent) { Decidim::Follow.where(followable: projects_for_budget(parent)) } + } + ] + } + ] + end + def export_to_directory bundle.each do |exportable| exportable_path = File.join(local_export_path, "#{exportable[:path]}.csv") diff --git a/lib/concerns/decidim/content/component_tools.rb b/lib/concerns/decidim/content/component_tools.rb index 66d86bc4e6..c25f7b4e1e 100644 --- a/lib/concerns/decidim/content/component_tools.rb +++ b/lib/concerns/decidim/content/component_tools.rb @@ -7,24 +7,26 @@ module Content module ComponentTools extend ActiveSupport::Concern included do + include Decidim::Content::UidTools + def component_resource_cache @component_resource_cache ||= Hash.new { |h, k| h[k] = h.dup.clear } end - def component_resource_cache_set(component:, resource_class:, query:, force: false) - if force || component_resource_cache[component.id][resource_class.name].blank? - component_resource_cache[component.id][resource_class.name] = query + def component_resource_cache_set(container:, resource_class:, query:, force: false) + if force || component_resource_cache[uid(container)][resource_class.name].blank? + component_resource_cache[uid(container)][resource_class.name] = query else - resource_class.none + component_resource_cache[uid(container)][resource_class.name] end end - def component_resource_cache_get(component:, resource_class:) - component_resource_cache[component.id][resource_class.name] + def component_resource_cache_get(container:, resource_class:) + (component_resource_cache[uid(container)][resource_class.name].presence || resource_class.none) end - def component_resource_cache_exists?(component:, resource_class:) - component_resource_cache[component.id][resource_class.name].present? + def component_resource_cache_exists?(container:, resource_class:) + component_resource_cache[uid(container)][resource_class.name].present? end def component_resource_manifests(manifest_name) @@ -81,8 +83,8 @@ def comments_for_resource(resource_class, component) root_commentable = gateway_resources.present? ? resource_class.where(through => gateway_resources) : resource_class.none end - if component_resource_cache_exists?(component:, resource_class:) - cached_ids = component_resource_cache_get(component:, resource_class:).pluck(:id) + if component_resource_cache_exists?(container: component, resource_class:) + cached_ids = component_resource_cache_get(container: component, resource_class:).pluck(:id) root_commentable = root_commentable.where(id: cached_ids) end @@ -94,7 +96,7 @@ def comments_for_resource(resource_class, component) end end Rails.logger.warn "Decidim::Content::ComponentTools.comments_for_resource (concerns) : No comments found for #{resource_class} with component association." - Rails.logger.warn "-- cached query was involved with #{cached_ids.size} records" if component_resource_cache_exists?(component:, resource_class:) + Rails.logger.warn "-- cached query was involved with #{cached_ids.size} records" if component_resource_cache_exists?(container: component, resource_class:) Decidim::Comments::Comment.none end # rubocop:enable Metrics/CyclomaticComplexity @@ -180,7 +182,7 @@ def attachments_for_resource(resource_class, component) def proposals_for_component(component) component_resource_cache_set( - component:, + container: component, resource_class: Decidim::Proposals::Proposal, query: Decidim::Proposals::Proposal .published @@ -191,7 +193,7 @@ def proposals_for_component(component) def debates_for_component(component) component_resource_cache_set( - component:, + container: component, resource_class: Decidim::Debates::Debate, query: Decidim::Debates::Debate .not_hidden @@ -201,11 +203,19 @@ def debates_for_component(component) def accountability_results_for_component(component) component_resource_cache_set( - component:, + container: component, resource_class: Decidim::Accountability::Result, query: Decidim::Accountability::Result.where(component:).order("children_count DESC, parent_id ASC, id ASC") ) end + + def projects_for_budget(budget) + component_resource_cache_set( + container: budget, + resource_class: Decidim::Budgets::Project, + query: Decidim::Budgets::Project.where(budget:) + ) + end end end end From 561ae97aaebdad98852e16b99886018f4ccc72dc Mon Sep 17 00:00:00 2001 From: moustachu Date: Sun, 5 Jul 2026 21:50:37 +0200 Subject: [PATCH 22/24] [WIP] feat(reversibility): add comment votes (up/down) to bundle --- .../decidim/content/comment_serializer.rb | 5 ++-- .../content/comment_vote_serializer.rb | 30 +++++++++++++++++++ app/services/decidim/content/csv_bundler.rb | 13 +++++++- config/initializers/icons.rb | 1 + .../decidim/content/component_tools.rb | 30 +++++++++++++++++-- 5 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 app/serializers/decidim/content/comment_vote_serializer.rb diff --git a/app/serializers/decidim/content/comment_serializer.rb b/app/serializers/decidim/content/comment_serializer.rb index d1bfc24bc4..97e637f9cb 100644 --- a/app/serializers/decidim/content/comment_serializer.rb +++ b/app/serializers/decidim/content/comment_serializer.rb @@ -15,9 +15,8 @@ def serialize up_votes_count: resource.up_votes&.count, down_votes_count: resource.down_votes&.count, depth: resource.depth, - comments_count: resource.comments_count, - # commentable: uid(resource.commentable), - # root_commentable: uid(resource.root_commentable), + # Aparently, this is only used for root_commentable, but Comments can't be root_commentable, so this is always 0. + # comments_count: resource.comments_count, commentable: polymorphic_uid(resource, :decidim_commentable), root_commentable: polymorphic_uid(resource, :decidim_root_commentable), created_at: resource.created_at, diff --git a/app/serializers/decidim/content/comment_vote_serializer.rb b/app/serializers/decidim/content/comment_vote_serializer.rb new file mode 100644 index 0000000000..bf9192089f --- /dev/null +++ b/app/serializers/decidim/content/comment_vote_serializer.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Decidim + module Content + class CommentVoteSerializer < Decidim::Exporters::Serializer + include Decidim::Content::SerializerTools + + def serialize + { + uid: uid(resource), + comment: uid(Decidim::Comments::Comment.new(id: resource.try(:decidim_comment_id))), + author: uid(identity(resource)), + value: comment_vote_value, + weight: resource.try(:weight), + created_at: resource.try(:created_at), + updated_at: resource.try(:updated_at) + } + end + + def comment_vote_value + case resource.try(:weight) + when 1 + "up" + when -1 + "down" + end + end + end + end +end diff --git a/app/services/decidim/content/csv_bundler.rb b/app/services/decidim/content/csv_bundler.rb index f7359d5e88..ccd80accd1 100644 --- a/app/services/decidim/content/csv_bundler.rb +++ b/app/services/decidim/content/csv_bundler.rb @@ -188,6 +188,12 @@ def components_bundle_hash serializer: Decidim::Content::CommentSerializer, collection: ->(parent) { comments_for_component(parent) } }, + { + path: "comments-votes", + include_if: ->(parent) { commentable_component?(parent&.manifest_name) && %w(proposals debates accountability).include?(parent&.manifest_name) }, + serializer: Decidim::Content::CommentVoteSerializer, + collection: ->(parent) { comment_votes_for_component(parent) } + }, { path: "answers", include_if: ->(parent) { parent&.manifest_name == "surveys" }, @@ -268,7 +274,12 @@ def components_bundle_for_budgets_array { path: "comments", serializer: Decidim::Content::CommentSerializer, - collection: ->(parent) { Decidim::Comments::Comment.where(root_commentable: projects_for_budget(parent)) } + collection: ->(parent) { comments_for_budget(parent) } + }, + { + path: "comment_votes", + serializer: Decidim::Content::CommentVoteSerializer, + collection: ->(parent) { comment_votes_for_budget(parent) } }, { path: "followers", diff --git a/config/initializers/icons.rb b/config/initializers/icons.rb index dfdd2ad883..c8e109f36d 100644 --- a/config/initializers/icons.rb +++ b/config/initializers/icons.rb @@ -14,6 +14,7 @@ billiards-line survey-line node-tree + eye-off-line ).each do |icon_name| Decidim.icons.register(name: icon_name, icon: icon_name, category: "system", description: "", engine: :core) end diff --git a/lib/concerns/decidim/content/component_tools.rb b/lib/concerns/decidim/content/component_tools.rb index c25f7b4e1e..794434d0e5 100644 --- a/lib/concerns/decidim/content/component_tools.rb +++ b/lib/concerns/decidim/content/component_tools.rb @@ -58,9 +58,17 @@ def commentable_component?(manifest_name) end def comments_for_component(component) - component_resource_manifests_including_trait(component&.manifest_name, Decidim::Comments::Commentable).each.with_object([]) do |manifest, results| - results.concat(comments_for_resource(manifest.model_class_name.constantize, component)) if manifest.model_class_name.present? - end + # !! WARNING !! We are caching an array instead of an ActiveRecord::Relation here, + # so we can't use the cached results to chain further queries. + # This is a limitation of the current implementation. + # TODO : fix this the remove the confusion between caching an array and caching an ActiveRecord::Relation. + component_resource_cache_set( + container: component, + resource_class: Decidim::Comments::Comment, + query: component_resource_manifests_including_trait(component&.manifest_name, Decidim::Comments::Commentable).each.with_object([]) do |manifest, results| + results.concat(comments_for_resource(manifest.model_class_name.constantize, component)) if manifest.model_class_name.present? + end + ) end # rubocop:disable Metrics/CyclomaticComplexity @@ -102,6 +110,10 @@ def comments_for_resource(resource_class, component) # rubocop:enable Metrics/CyclomaticComplexity # rubocop:enable Metrics/PerceivedComplexity + def comment_votes_for_component(component) + Decidim::Comments::CommentVote.where(decidim_comment_id: comments_for_component(component)&.pluck(:id)) + end + def endorsements_for_component(component) component_resource_manifests_including_trait(component&.manifest_name, Decidim::Endorsable).each.with_object([]) do |manifest, results| results.concat(endorsements_for_resource(manifest.model_class_name.constantize, component)) if manifest.model_class_name.present? @@ -216,6 +228,18 @@ def projects_for_budget(budget) query: Decidim::Budgets::Project.where(budget:) ) end + + def comments_for_budget(budget) + component_resource_cache_set( + container: budget, + resource_class: Decidim::Comments::Comment, + query: Decidim::Comments::Comment.where(root_commentable: projects_for_budget(budget)) + ) + end + + def comment_votes_for_budget(budget) + Decidim::Comments::CommentVote.where(decidim_comment_id: comments_for_budget(budget)&.pluck(:id)) + end end end end From 8eed683ee5f3df0445d1e5e7638066f75524eafb Mon Sep 17 00:00:00 2001 From: moustachu Date: Thu, 9 Jul 2026 14:09:31 +0200 Subject: [PATCH 23/24] [WIP] feat(reversibility): add organization stats to content tree header --- .../decidim/admin/content/tree/_labels.html.erb | 6 ++++++ .../admin/content/tree/_organization.html.erb | 17 +++++++++++++++++ .../decidim/admin/content/tree/_stats.html.erb | 6 ++++++ 3 files changed, 29 insertions(+) create mode 100644 app/views/decidim/admin/content/tree/_labels.html.erb create mode 100644 app/views/decidim/admin/content/tree/_organization.html.erb create mode 100644 app/views/decidim/admin/content/tree/_stats.html.erb diff --git a/app/views/decidim/admin/content/tree/_labels.html.erb b/app/views/decidim/admin/content/tree/_labels.html.erb new file mode 100644 index 0000000000..7f41f69a0f --- /dev/null +++ b/app/views/decidim/admin/content/tree/_labels.html.erb @@ -0,0 +1,6 @@ +<% labels.each do |key, label| %> + + <%= icon(label[:icon]) if label[:icon].present? %> + <%= label[:text] || label[:value] %> + +<% end %> \ No newline at end of file diff --git a/app/views/decidim/admin/content/tree/_organization.html.erb b/app/views/decidim/admin/content/tree/_organization.html.erb new file mode 100644 index 0000000000..dbee0dd828 --- /dev/null +++ b/app/views/decidim/admin/content/tree/_organization.html.erb @@ -0,0 +1,17 @@ +
+
+

+ <%= I18n.t("decidim.admin.content.tree.views.index.organization.header") %> +

+ <% if organization.dig(:metadata, :labels).present? %> +
+ <%= render partial: "labels", locals: { labels: organization[:metadata][:labels] } %> +
+ <% end %> +
+ <% if organization.dig(:metadata, :stats).present? %> +
+ <%= render partial: "stats", locals: { stats: organization[:metadata][:stats] } %> +
+ <% end %> +
\ No newline at end of file diff --git a/app/views/decidim/admin/content/tree/_stats.html.erb b/app/views/decidim/admin/content/tree/_stats.html.erb new file mode 100644 index 0000000000..e6d5c9d34e --- /dev/null +++ b/app/views/decidim/admin/content/tree/_stats.html.erb @@ -0,0 +1,6 @@ +<% stats.except(:components_count).each do |key, label| %> + + <%= label[:value] %> + <%= label[:text] %> + +<% end %> \ No newline at end of file From 50a5f692c6dbf57cca97cd307071b150127986e9 Mon Sep 17 00:00:00 2001 From: moustachu Date: Thu, 9 Jul 2026 14:55:27 +0200 Subject: [PATCH 24/24] [WIP] feat(reversibility): add organization stats to content tree header --- .../decidim/content/metadata_generator.rb | 100 +++++++++++++++++- .../admin/content/tree/_article.html.erb | 14 +-- .../decidim/admin/content/tree/index.html.erb | 2 + config/locales/admin_content_tree/en.yml | 37 ++++++- config/locales/admin_content_tree/fr.yml | 37 ++++++- 5 files changed, 174 insertions(+), 16 deletions(-) diff --git a/app/services/concerns/decidim/content/metadata_generator.rb b/app/services/concerns/decidim/content/metadata_generator.rb index c26a5623e0..0d23550716 100644 --- a/app/services/concerns/decidim/content/metadata_generator.rb +++ b/app/services/concerns/decidim/content/metadata_generator.rb @@ -5,6 +5,8 @@ module Content module MetadataGenerator extend ActiveSupport::Concern included do + include ActionView::Helpers::DateHelper + INITIATIVE_STATE_ICON_MAP = { created: "draft-line", validating: "time-line", @@ -32,6 +34,7 @@ def metadata_for(instance) def labels_for(instance) labels = {} + labels.merge!(organization_labels(instance)) if instance.is_a?(Decidim::Organization) labels.merge!(hashtag(instance)) if instance.respond_to?(:hashtag) && instance.hashtag.present? labels.merge!(component_type(instance)) if instance.is_a?(Decidim::Component) labels.merge!(private_space(instance)) if instance.is_a?(Decidim::HasPrivateUsers) @@ -39,14 +42,15 @@ def labels_for(instance) labels end - def stats_for(instance, empty_values: false) + def stats_for(instance, empty_values: false) # rubocop:disable Metrics/CyclomaticComplexity stats = {} + stats.merge!(organization_stats(instance)) if instance.is_a?(Decidim::Organization) stats.merge!(component_stats(instance)) if instance.is_a?(Decidim::Component) stats.merge!(initiative_stats(instance)) if instance.is_a?(Decidim::Initiative) stats.merge!(participatory_space_stats(instance)) if instance.is_a?(Decidim::Participable) stats.merge!(followers_stats(instance)) if instance.is_a?(Decidim::Followable) stats.merge!(participatory_space_moderations_stats(instance)) if instance.is_a?(Decidim::Participable) - stats.reject! { |_, stat| stat[:value].to_i.zero? } unless empty_values + stats.reject! { |_, stat| stat[:value].to_i.zero? && !stat[:force_display] } unless empty_values stats end @@ -214,6 +218,98 @@ def followers_stats(instance) } } end + + def organization_labels(organization) + { + created_at: { + text: I18n.t("decidim.admin.content.tree.stats.organization.created_at", time_ago: time_ago_in_words(organization.created_at)), + icon: "time-line", + level: "info" + } + } + end + + def organization_stats(organization) + { + **organization_users_stats(organization), + **organization_system_stats(organization), + **organization_transversal_content_stats(organization) + } + end + + def organization_users_stats(organization) + { + **organization.user_entities.group(:type).count.sort.to_h.each.inject({}) do |stats, (type, count)| + key = type.to_s.demodulize.underscore.pluralize + stats.merge( + key.to_sym => { + value: count || 0, + text: I18n.t("decidim.admin.content.tree.stats.organization.users.#{key}", count:, default: key.humanize), + level: "info" + } + ) + end, + admins: { + value: admin_count = organization.admins.count, + text: I18n.t("decidim.admin.content.tree.stats.organization.users.admins", count: admin_count), + level: admin_count.positive? ? "info" : "warning", + force_display: true + }, + **organization.users_with_any_role.group(:roles).count.each_with_object({}) do |(roles, count), stats| + roles.each do |role| + key = role.to_s.pluralize + stats.merge!( + key.to_sym => { + value: count || 0, + text: I18n.t("decidim.admin.content.tree.stats.organization.users.#{key}", count:, default: key.humanize), + level: "info" + } + ) { |_key, old_value, new_value| old_value + new_value } + end + stats + end + } + end + + def organization_system_stats(organization) + { + authorizations: { + value: authorizations_count = organization.available_authorizations.size, + text: I18n.t("decidim.admin.content.tree.stats.organization.authorizations", count: authorizations_count), + level: "info" + }, + omniauth_providers: { + value: omniauth_providers_count = organization.enabled_omniauth_providers.size, + text: I18n.t("decidim.admin.content.tree.stats.organization.omniauth_providers", count: omniauth_providers_count), + level: "info" + } + } + end + + def organization_transversal_content_stats(organization) + { + scopes: { + value: scopes_count = organization.scopes.count, + text: I18n.t("decidim.admin.content.tree.stats.organization.scopes", count: scopes_count), + level: "info" + }, + areas: { + value: areas_count = organization.areas.count, + text: I18n.t("decidim.admin.content.tree.stats.organization.areas", count: areas_count), + level: "info" + }, + static_pages: { + value: static_pages_count = organization.static_pages.count, + text: I18n.t("decidim.admin.content.tree.stats.organization.static_pages", count: static_pages_count), + level: "info" + }, + static_page_topics: { + value: static_page_topics_count = organization.static_page_topics.count, + text: I18n.t("decidim.admin.content.tree.stats.organization.static_page_topics", count: static_page_topics_count), + level: "info" + } + } + end end end end diff --git a/app/views/decidim/admin/content/tree/_article.html.erb b/app/views/decidim/admin/content/tree/_article.html.erb index b9fe335bcd..831cf32dae 100644 --- a/app/views/decidim/admin/content/tree/_article.html.erb +++ b/app/views/decidim/admin/content/tree/_article.html.erb @@ -27,23 +27,13 @@ <% if item.dig(:metadata, :labels).present? %>
- <% item[:metadata][:labels].each do |key, label| %> - - <%= icon(label[:icon]) if label[:icon].present? %> - <%= label[:text] || label[:value] %> - - <% end %> + <%= render partial: "labels", locals: { labels: item[:metadata][:labels] } %>
<% end %> <% if item.dig(:metadata, :stats).present? %>
- <% item[:metadata][:stats].except(:components_count).each do |key, label| %> - - <%= label[:value] %> - <%= label[:text] %> - - <% end %> + <%= render partial: "stats", locals: { stats: item[:metadata][:stats] } %>
<% end %> diff --git a/app/views/decidim/admin/content/tree/index.html.erb b/app/views/decidim/admin/content/tree/index.html.erb index ed6f7dddac..9c21789cc3 100644 --- a/app/views/decidim/admin/content/tree/index.html.erb +++ b/app/views/decidim/admin/content/tree/index.html.erb @@ -7,6 +7,8 @@ +<%= render partial: "organization", locals: { organization: content_tree.except(:children) } %> +
<% content_tree[:children].each do |participatory_spaces| %> <% participatory_space_type_prefix = participatory_spaces[:manifest][:space_class].to_s.demodulize.underscore.pluralize %> diff --git a/config/locales/admin_content_tree/en.yml b/config/locales/admin_content_tree/en.yml index 90e30dff6c..bcf5cec233 100644 --- a/config/locales/admin_content_tree/en.yml +++ b/config/locales/admin_content_tree/en.yml @@ -18,12 +18,47 @@ en: followers: "Followers" hidden_moderations: "Hidden content" moderations: "Moderations" + organization: + areas: + one: "Area" + other: "Areas" + authorizations: + one: "Authorization" + other: "Authorizations" + created_at: "Created %{time_ago} ago" + omniauth_providers: + one: "External identity provider" + other: "External identity providers" + scopes: + one: "Scope" + other: "Scopes" + static_pages: + one: "Static page" + other: "Static pages" + static_page_topics: + one: "Static page topic" + other: "Static page topics" + users: + users: + one: "User" + other: "Users" + user_groups: + one: "User Group" + other: "User Groups" + admins: + one: "Admin" + other: "Admins" + user_managers: + one: "Participant Manager" + other: "Participant Managers" signatures: "Signatures" views: index: - title: "Content Tree" actions: export_to_csv: "Export tree to CSV" + organization: + header: "Organization" + title: "Content Tree" table: title: "Content Table" menu: diff --git a/config/locales/admin_content_tree/fr.yml b/config/locales/admin_content_tree/fr.yml index aafea76557..11b6472f35 100644 --- a/config/locales/admin_content_tree/fr.yml +++ b/config/locales/admin_content_tree/fr.yml @@ -18,12 +18,47 @@ fr: followers: "Abonnés" hidden_moderations: "Contenus cachés" moderations: "Modérations" + organization: + areas: + one: "Périmètre d'assemblée" + other: "Périmètres d'assemblée" + authorizations: + one: "Autorisation" + other: "Autorisations" + created_at: "Créée il y a %{time_ago}" + omniauth_providers: + one: "Fournisseur d'identité externe" + other: "Fournisseurs d'identité externe" + scopes: + one: "Secteur" + other: "Secteurs" + static_pages: + one: "Page" + other: "Pages" + static_page_topics: + one: "Sujet" + other: "Sujets" + users: + users: + one: "Utilisateur" + other: "Utilisateurs" + user_groups: + one: "Groupe d'utilisateurs" + other: "Groupes d'utilisateurs" + admins: + one: "Administrateur" + other: "Administrateurs" + user_managers: + one: "Représentant de l'utilisateur" + other: "Représentants de l'utilisateur" signatures: "Signatures" views: index: - title: "Arbre de contenu" actions: export_to_csv: "Exporter l'arbre en CSV" + organization: + header: "Organisation" + title: "Arbre de contenu" table: title: "Tableau de contenu" menu: