diff --git a/.rubocop_rails.yml b/.rubocop_rails.yml
index 68f4c40d64..e3067271ee 100644
--- a/.rubocop_rails.yml
+++ b/.rubocop_rails.yml
@@ -89,6 +89,8 @@ Rails/SkipsModelValidations:
Enabled: true
Exclude:
- db/migrate/*.rb
+ - lib/extends/models/decidim/decidim_awesome/proposal_extra_field_extends.rb
+ - spec/lib/tasks/decidim_app/set_decrypted_private_body_task_spec.rb
Rails/Validation:
Include:
diff --git a/Gemfile b/Gemfile
index b066a14083..4eb3c8f52b 100644
--- a/Gemfile
+++ b/Gemfile
@@ -39,7 +39,7 @@ gem "decidim-spam_detection", git: "https://github.com/OpenSourcePolitics/decidi
gem "decidim-survey_multiple_answers", git: "https://github.com/OpenSourcePolitics/decidim-module-survey_multiple_answers"
gem "decidim-term_customizer", git: "https://github.com/OpenSourcePolitics/decidim-module-term_customizer.git", branch: "fix/email_with_precompile"
-gem "decidim-guest_meeting_registration", git: "https://github.com/alecslupu-pfa/guest-meeting-registration.git", branch: DECIDIM_BRANCH, ref: "532de9e"
+gem "decidim-guest_meeting_registration", git: "https://github.com/alecslupu-pfa/guest-meeting-registration.git", branch: DECIDIM_BRANCH
# Omniauth gems
gem "omniauth-france_connect", git: "https://github.com/OpenSourcePolitics/omniauth-france_connect"
gem "omniauth_openid_connect"
diff --git a/Gemfile.lock b/Gemfile.lock
index c49bd80729..ed7ec8559f 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -153,8 +153,7 @@ GIT
GIT
remote: https://github.com/alecslupu-pfa/guest-meeting-registration.git
- revision: 532de9e87ecb8b2959c0013c6408d4c092abce87
- ref: 532de9e
+ revision: 7b3af0d34d053cc430080e483cd6d1e48dcc0f32
branch: release/0.27-stable
specs:
decidim-guest_meeting_registration (0.27.7)
diff --git a/app/jobs/active_storage_clear_orphans_job.rb b/app/jobs/active_storage_clear_orphans_job.rb
new file mode 100644
index 0000000000..efa7554111
--- /dev/null
+++ b/app/jobs/active_storage_clear_orphans_job.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+class ActiveStorageClearOrphansJob < ApplicationJob
+ include ActionView::Helpers::NumberHelper
+ queue_as :default
+
+ def perform(**args)
+ limit = args[:limit] || 10_000
+ Rails.logger.info "Looking for orphan blobs in S3... (limit: #{limit})"
+ objects = ActiveStorage::Blob.service.bucket.objects
+ Rails.logger.info "Total files: #{objects.size}"
+
+ current_iteration = 0
+ sum = 0
+ orphans_count = 0
+ objects.each do |obj|
+ break if current_iteration >= limit
+
+ current_iteration += 1
+ next if ActiveStorage::Blob.exists?(key: obj.key)
+
+ sum += delete_object(obj)
+ orphans_count += 1
+ end
+
+ Rails.logger.info "Size: #{number_to_human_size(sum)} in #{orphans_count} files"
+ Rails.logger.info "Configuration limit is #{limit} files"
+ Rails.logger.info "Terminated task... "
+ end
+
+ private
+
+ def delete_object(obj)
+ Rails.logger.info "Removing orphan: #{obj.key}"
+ size = obj.size
+ obj.delete
+ size
+ end
+end
diff --git a/app/jobs/private_body_decrypt_job.rb b/app/jobs/private_body_decrypt_job.rb
new file mode 100644
index 0000000000..275f8bf62f
--- /dev/null
+++ b/app/jobs/private_body_decrypt_job.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class PrivateBodyDecryptJob < ApplicationJob
+ queue_as :default
+
+ def perform
+ extra_fields = Decidim::DecidimAwesome::ProposalExtraField.where(decrypted_private_body: nil).where.not(private_body: nil)
+ return unless extra_fields.any?
+
+ Rails.logger.info "Extra fields to update: #{extra_fields.size}"
+ count = 0
+ extra_fields.find_each do |extra_field|
+ extra_field.update(decrypted_private_body: extra_field.private_body.to_s)
+ count += 1 if extra_field.decrypted_private_body_previous_change.present?
+ end
+ Rails.logger.info "Extra fields updated: #{count}"
+ end
+end
diff --git a/app/packs/src/decidim/decidim_awesome/admin/custom_fields_builder.js b/app/packs/src/decidim/decidim_awesome/admin/custom_fields_builder.js
new file mode 100644
index 0000000000..c426e9645a
--- /dev/null
+++ b/app/packs/src/decidim/decidim_awesome/admin/custom_fields_builder.js
@@ -0,0 +1,79 @@
+require("formBuilder/dist/form-builder.min.js")
+import "src/decidim/decidim_awesome/forms/rich_text_plugin"
+
+window.CustomFieldsBuilders = window.CustomFieldsBuilders || [];
+
+$(() => {
+ $(".awesome-edit-config .proposal_custom_fields_editor").each((_idx, el) => {
+ const key = $(el).closest(".proposal_custom_fields_container").data("key");
+ const configVar = $(el).closest(".proposal_custom_fields_container").data("var");
+ // DOCS: https://formbuilder.online/docs
+ window.CustomFieldsBuilders.push({
+ el: el,
+ key: key,
+ var: configVar,
+ config: {
+ i18n: {
+ locale: "fr-FR",
+ location: "https://decidim.storage.opensourcepolitics.eu/osp-cdn/form_builder/1.1.0"
+ },
+ formData: $(`input[name="config[${configVar}][${key}]"]`).val(),
+ disableFields: ["button", "file"],
+ disabledActionButtons: ["save", "data", "clear"],
+ disabledAttrs: [
+ "access",
+ "inline",
+ "className"
+ ],
+ controlOrder: [
+ "text",
+ "textarea",
+ "number",
+ "date",
+ "checkbox-group",
+ "radio-group",
+ "select",
+ "autocomplete",
+ "header",
+ "paragraph"
+ ],
+ disabledSubtypes: {
+ // default color as it generate hashtags in decidim (TODO: fix hashtag generator with this)
+ text: ["color"],
+ // disable default wysiwyg editors as they present problems
+ textarea: ["tinymce", "quill"]
+ }
+ },
+ instance: null
+ });
+ });
+
+ $(document).on("formBuilder.create", (_event, idx, list) => {
+ if (!list[idx]) {
+ return;
+ }
+
+ $(list[idx].el).formBuilder(list[idx].config).promise.then(function(res) {
+ list[idx].instance = res;
+ // Attach to DOM
+ list[idx].el.FormBuilder = res;
+ // remove spinner
+ $(list[idx].el).find(".loading-spinner").remove();
+ // for external use
+ $(document).trigger("formBuilder.created", [list[idx]]);
+ if (idx < list.length) {
+ $(document).trigger("formBuilder.create", [idx + 1, list]);
+ }
+ });
+ });
+
+ if (window.CustomFieldsBuilders.length) {
+ $(document).trigger("formBuilder.create", [0, window.CustomFieldsBuilders]);
+ }
+
+ $("form.awesome-edit-config").on("submit", () => {
+ window.CustomFieldsBuilders.forEach((builder) => {
+ $(`input[name="config[${builder.var}][${builder.key}]"]`).val(builder.instance.actions.getData("json"));
+ });
+ });
+});
diff --git a/app/packs/src/decidim/decidim_awesome/editors/editor.js b/app/packs/src/decidim/decidim_awesome/editors/editor.js
new file mode 100644
index 0000000000..7bec3c9396
--- /dev/null
+++ b/app/packs/src/decidim/decidim_awesome/editors/editor.js
@@ -0,0 +1,214 @@
+/* eslint-disable require-jsdoc, func-style */
+
+/*
+* Since version 0.25 we follow a different strategy and opt to destroy and override completely the original editor
+* That's because editors are instantiated directly instead of creating a global function to instantiate them
+*/
+
+import lineBreakButtonHandler from "src/decidim/editor/linebreak_module"
+import InscrybMDE from "inscrybmde"
+import Europa from "europa"
+import "inline-attachment/src/inline-attachment";
+import "inline-attachment/src/codemirror-4.inline-attachment";
+import "inline-attachment/src/jquery.inline-attachment";
+import hljs from "highlight.js";
+import "highlight.js/styles/github.css";
+import "src/decidim/editor/clipboard_override"
+import "src/decidim/vendor/image-resize.min"
+import "src/decidim/vendor/image-upload.min"
+import { marked } from "marked";
+
+const DecidimAwesome = window.DecidimAwesome || {};
+const quillFormats = ["bold", "italic", "link", "underline", "header", "list", "video", "image", "alt", "break", "width", "style", "code", "blockquote", "indent"];
+
+// A tricky way to destroy the quill editor
+export function destroyQuillEditor(container) {
+ if (container) {
+ const content = $(container).find(".ql-editor").html();
+ $(container).html(content);
+ $(container).siblings(".ql-toolbar").remove();
+ $(container).find("*[class*='ql-']").removeClass((index, className) => (className.match(/(^|\s)ql-\S+/g) || []).join(" "));
+ $(container).removeClass((index, className) => (className.match(/(^|\s)ql-\S+/g) || []).join(" "));
+ if ($(container).next().is("p.help-text")) {
+ $(container).next().remove();
+ }
+ }
+ else {
+ console.error(`editor [${container}] not exists`);
+ }
+}
+
+export function createQuillEditor(container) {
+ const toolbar = $(container).data("toolbar");
+ const disabled = $(container).data("disabled");
+ const allowedEmptyContentSelector = "iframe,img";
+
+ let quillToolbar = [
+ ["bold", "italic", "underline", "linebreak"],
+ [{ list: "ordered" }, { list: "bullet" }],
+ ["link", "clean"],
+ ["code", "blockquote"],
+ [{ "indent": "-1"}, { "indent": "+1" }]
+ ];
+
+ let addImage = false;
+
+ if (toolbar === "full") {
+ quillToolbar = [
+ [{ header: [2, 3, 4, 5, 6, false] }],
+ ...quillToolbar
+ ];
+ if (DecidimAwesome.allow_images_in_full_editor) {
+ quillToolbar.push(["video", "image"]);
+ addImage = true;
+ } else {
+ quillToolbar.push(["video"]);
+ }
+ } else if (toolbar === "basic") {
+ if (DecidimAwesome.allow_images_in_small_editor) {
+ quillToolbar.push(["video", "image"]);
+ addImage = true;
+ } else {
+ quillToolbar.push(["video"]);
+ }
+ } else if (DecidimAwesome.allow_images_in_small_editor) {
+ quillToolbar.push(["image"]);
+ addImage = true;
+ }
+
+ let modules = {
+ linebreak: {},
+ toolbar: {
+ container: quillToolbar,
+ handlers: {
+ "linebreak": lineBreakButtonHandler
+ }
+ }
+ };
+
+ const $input = $(container).siblings('input[type="hidden"]');
+ container.innerHTML = $input.val() || "";
+ const token = $('meta[name="csrf-token"]').attr("content");
+ if (addImage) {
+ modules.imageResize = {
+ modules: ["Resize", "DisplaySize"]
+ }
+ modules.imageUpload = {
+ url: DecidimAwesome.editor_uploader_path,
+ method: "POST",
+ name: "image",
+ withCredentials: false,
+ headers: { "X-CSRF-Token": token },
+ callbackOK: (serverResponse, next) => {
+ $("div.ql-toolbar").last().removeClass("editor-loading")
+ next(serverResponse.url);
+ },
+ callbackKO: (serverError) => {
+ $("div.ql-toolbar").last().removeClass("editor-loading")
+ let msg = serverError && serverError.body;
+ try {
+ msg = JSON.parse(msg).message;
+ } catch (evt) { console.error("Parsing error", evt); }
+ console.error(`Image upload error: ${msg}`);
+ let $p = $(`
${msg}
`);
+ $(container).after($p)
+ setTimeout(() => {
+ $p.fadeOut(1000, () => {
+ $p.destroy();
+ });
+ }, 3000);
+ },
+ checkBeforeSend: (file, next) => {
+ $("div.ql-toolbar").last().addClass("editor-loading")
+ next(file);
+ }
+ }
+ }
+ const quill = new Quill(container, {
+ modules: modules,
+ formats: quillFormats,
+ theme: "snow"
+ });
+
+ if (disabled) {
+ quill.disable();
+ }
+
+ quill.on("text-change", () => {
+ const text = quill.getText();
+
+ // Triggers CustomEvent with the cursor position
+ // It is required in input_mentions.js
+ let event = new CustomEvent("quill-position", {
+ detail: quill.getSelection()
+ });
+ container.dispatchEvent(event);
+
+ if ((text === "\n" || text === "\n\n") && quill.root.querySelectorAll(allowedEmptyContentSelector).length === 0
+ && !$input.val().match(/img/)) {
+ $input.val("");
+ } else {
+ const emptyParagraph = "
";
+ const cleanHTML = quill.root.innerHTML.replace(
+ new RegExp(`^${emptyParagraph}|${emptyParagraph}$`, "g"),
+ ""
+ );
+ $input.val(cleanHTML);
+ }
+ });
+ // After editor is ready, linebreak_module deletes two extraneous new lines
+ quill.emitter.emit("editor-ready");
+
+ if (addImage) {
+ const text = $(container).data("dragAndDropHelpText") || DecidimAwesome.texts.drag_and_drop_image;
+ $(container).after(`${text}
`);
+ }
+
+ // After editor is ready, linebreak_module deletes two extraneous new lines
+ quill.emitter.emit("editor-ready");
+
+ return quill;
+}
+
+export function createMarkdownEditor(container) {
+ const text = DecidimAwesome.texts.drag_and_drop_image;
+ const token = $('meta[name="csrf-token"]').attr("content");
+ const $input = $(container).siblings('input[type="hidden"]');
+ const $faker = $('');
+ const $form = $(container).closest("form");
+ const europa = new Europa();
+ $faker.val(europa.convert($input.val()));
+ $faker.insertBefore($(container));
+ $(container).hide();
+ const inscrybmde = new InscrybMDE({
+ element: $faker[0],
+ spellChecker: false,
+ renderingConfig: {
+ codeSyntaxHighlighting: true,
+ hljs: hljs
+ }
+ });
+ $faker[0].InscrybMDE = inscrybmde;
+
+ // Allow image upload
+ if (DecidimAwesome.allow_images_in_markdown_editor) {
+ $(inscrybmde.gui.statusbar).prepend(`${text}`);
+ window.inlineAttachment.editors.codemirror4.attach(inscrybmde.codemirror, {
+ uploadUrl: DecidimAwesome.editor_uploader_path,
+ uploadFieldName: "image",
+ jsonFieldName: "url",
+ extraHeaders: { "X-CSRF-Token": token }
+ });
+ }
+
+ // Allow linebreaks
+ marked.setOptions({
+ breaks: true
+ });
+
+ // convert to html on submit
+ $form.on("submit", () => {
+ // e.preventDefault();
+ $input.val(marked(inscrybmde.value()));
+ });
+}
diff --git a/app/packs/src/decidim/decidim_awesome/forms/custom_fields_renderer.js b/app/packs/src/decidim/decidim_awesome/forms/custom_fields_renderer.js
new file mode 100644
index 0000000000..41e7b97843
--- /dev/null
+++ b/app/packs/src/decidim/decidim_awesome/forms/custom_fields_renderer.js
@@ -0,0 +1,225 @@
+import "formBuilder/dist/form-render.min.js";
+import "src/decidim/decidim_awesome/forms/rich_text_plugin"
+
+export default class CustomFieldsRenderer { // eslint-disable-line no-unused-vars
+ constructor(containerSelector) {
+ this.containerSelector = containerSelector || ".proposal_custom_field:last";
+ this.lang = this.getLang(window.DecidimAwesome.currentLocale);
+ }
+
+ getLang(lang) {
+ const langs = {
+ // ar: 'ar-SA', // Not in decidim yet
+ "ar": "ar-TN",
+ "ca": "ca-ES",
+ "cs": "cs-CZ",
+ "da": "da-DK",
+ "de": "de-DE",
+ "el": "el-GR",
+ "en": "en-US",
+ "es": "es-ES",
+ "fa": "fa-IR",
+ "fi": "fi-FI",
+ "fr": "fr-FR",
+ "he": "he-IL",
+ "hu": "hu-HU",
+ "it": "it-IT",
+ "ja": "ja-JP",
+ "my": "my-MM",
+ "nb": "nb-NO",
+ "nl": "nl-NL",
+ "pl": "pl-PL",
+ "pt": "pt-BR",
+ "qz": "qz-MM",
+ "ro": "ro-RO",
+ "ru": "ru-RU",
+ "sl": "sl-SI",
+ "th": "th-TH",
+ "tr": "tr-TR",
+ "uk": "uk-UA",
+ "vi": "vi-VN",
+ "zh-TW": "zh-TW",
+ "zh": "zh-CN"
+ };
+ if (langs[lang]) {
+ return langs[lang];
+ }
+ if (langs[lang.substr(0, 2)]) {
+ return langs[lang.substr(0, 2)];
+ }
+ return "en-US";
+ }
+
+ /*
+ * Creates an XML document with a subset of html-compatible dl/dd/dt elements
+ * to store the custom fields answers
+ */
+ dataToXML(data) {
+ const $dl = $("
");
+ let $dd = null,
+ $div = null,
+ $dt = null,
+ datum = null,
+ key = null,
+ label = null,
+ text = null,
+ val = null;
+ $dl.attr("class", "decidim_awesome-custom_fields");
+ $dl.attr("data-generator", "decidim_awesome");
+ $dl.attr("data-version", window.DecidimAwesome.version);
+ for (key in data) { // eslint-disable-line guard-for-in
+ // console.log("get the data!", key, data[key]);
+ // Richtext plugin does not saves userdata, so we get it from the hidden input
+ if (data[key].type === "textarea" && data[key].subtype === "richtext") {
+ data[key].userData = [$(`#${data[key].name}-input`).val()];
+ }
+ if (data[key].userData && data[key].userData.length) {
+ $dt = $("");
+ $dt.text(data[key].label);
+ $dt.attr("name", data[key].name);
+ $dd = $("");
+ // console.log("data for", key, data[key].name, data[key])
+ for (val in data[key].userData) { // eslint-disable-line guard-for-in
+ $div = $("");
+ label = data[key].userData[val];
+ text = null;
+ if (data[key].values) {
+ datum = data[key].values.find((obj) => obj.value === label); // eslint-disable-line no-loop-func
+ if (datum) { // eslint-disable-line max-depth
+ text = label;
+ label = datum.label;
+ }
+ } else if (data[key].type === "date" && label) {
+ datum = new Date(label).toLocaleDateString();
+ if (datum) { // eslint-disable-line max-depth
+ text = label;
+ label = datum;
+ }
+ }
+ // console.log("userData", text, "label", label, 'key', key, 'data', data)
+ if (data[key].type === "textarea" && data[key].subtype === "richtext") {
+ $div.html(label);
+ } else {
+ $div.text(label);
+ }
+ if (text) {
+ $div.attr("alt", text);
+ }
+ $dd.append($div);
+ }
+ $dd.attr("id", data[key].name);
+ $dd.attr("name", data[key].type);
+ $dl.append($dt);
+ $dl.append($dd);
+ }
+ }
+ return `${$dl[0].outerHTML}`;
+ }
+
+ fixBuggyFields() {
+ if (!this.$element) {
+ return false;
+ }
+
+ /**
+ * Hack to fix required checkboxes being reset
+ * Issue: https://github.com/decidim-ice/decidim-module-decidim_awesome/issues/82
+ */
+ this.$element.find(".formbuilder-checkbox-group").each((_key, group) => {
+ const inputs = $(".formbuilder-checkbox input", group);
+ const $label = $(group).find("label");
+ const data = this.spec.find((obj) => obj.type === "checkbox-group" && obj.name === $label.attr("for"));
+ let values = data.userData;
+ if (!inputs.length || !data || !values) {
+ return;
+ }
+
+ inputs.each((_idx, input) => {
+ let index = values.indexOf(input.value);
+ if (index >= 0) {
+ values.splice(index, 1)
+ // setting checked=true do not makes the browser aware that the form is valid if the field is required
+ if (!input.checked)
+ {$(input).click();}
+ } else if (input.checked)
+ {$(input).click();}
+ });
+
+ // Fill "other" option
+ const otherOption = $(".other-option", inputs.parent())[0];
+ const otherVal = $(".other-val", inputs.parent())[0];
+ const otherText = values.join(" ");
+
+ if (otherOption) {
+ if (otherText) {
+ otherOption.checked = true;
+ otherOption.value = otherText;
+ otherVal.value = otherText;
+ } else {
+ otherOption.checked = false;
+ otherOption.value = "";
+ otherVal.value = "";
+ }
+ }
+ });
+
+ /**
+ * Hack to fix required radio buttons "other" value
+ * Issue: https://github.com/decidim-ice/decidim-module-decidim_awesome/issues/133
+ */
+ this.$element.find(".formbuilder-radio input.other-val").on("input", (input) => {
+ const $input = $(input.currentTarget);
+ const $group = $input.closest(".formbuilder-radio-group");
+ $group.find("input").each((_key, radio) => {
+ const name = $(radio).attr("name");
+ if (name && name.endsWith("[]")) {
+ $(radio).attr("name", name.slice(0, -2));
+ }
+ });
+ });
+ return this;
+ }
+
+ // Saves xml to the hidden input
+ storeData() {
+ if (!this.$element) {
+ return false;
+ }
+ const $form = this.$element.closest("form");
+ const $body = $form.find(`input[name="${this.$element.data("name")}"]`);
+ if ($body.length && this.instance) {
+ this.spec = this.instance.userData;
+ console.log("Spec data:", this.spec);
+ $body.val(this.dataToXML(this.spec));
+ this.$element.data("spec", this.spec);
+ }
+ // console.log("storeData spec", this.spec, "$body", $body,"$form",$form,"this",this);
+ return this;
+ }
+
+ init($element) {
+ console.log("CustomFieldsRenderer init");
+ this.$element = $element;
+ console.log("Element for rendering:", $element);
+
+ this.spec = $element.data("spec");
+ console.log("Spec data:", this.spec);
+ // console.log("init", $element, "this", this)
+ // in case of multilang tabs we only render one form due a limitation in the library for handling several instances
+ this.instance = $element.formRender({
+ i18n: {
+ locale: this.lang,
+ location: "https://decidim.storage.opensourcepolitics.eu/osp-cdn/form_builder/1.1.0"
+ },
+ formData: this.spec,
+ render: true,
+ disableInjectedStyle: true,
+ controlConfig: {
+ "textarea.richtext": {
+ editorOptions: $element.data("editorOptions")
+ }
+ }
+ });
+ this.fixBuggyFields();
+ }
+}
diff --git a/app/packs/src/decidim/editor.js b/app/packs/src/decidim/editor.js
new file mode 100644
index 0000000000..09c47e47ee
--- /dev/null
+++ b/app/packs/src/decidim/editor.js
@@ -0,0 +1,152 @@
+/* eslint-disable require-jsdoc */
+
+import lineBreakButtonHandler from "src/decidim/editor/linebreak_module";
+import "src/decidim/editor/clipboard_override";
+import "src/decidim/vendor/image-resize.min";
+import "src/decidim/vendor/image-upload.min";
+
+const quillFormats = [
+ "bold",
+ "italic",
+ "link",
+ "underline",
+ "header",
+ "list",
+ "alt",
+ "break",
+ "width",
+ "style",
+ "code",
+ "blockquote",
+ "indent"
+];
+
+export default function createQuillEditor(container) {
+ const toolbar = $(container).data("toolbar");
+ const disabled = $(container).data("disabled");
+
+ const allowedEmptyContentSelector = "iframe";
+ let quillToolbar = [
+ ["bold", "italic", "underline", "linebreak"],
+ [{ list: "ordered" }, { list: "bullet" }],
+ ["link", "clean"],
+ ["code", "blockquote"],
+ [{ indent: "-1" }, { indent: "+1" }]
+ ];
+
+ let addImage = false;
+ let addVideo = false;
+
+ /**
+ * - basic = only basic controls without titles
+ * - content = basic + headings
+ * - full = basic + headings + image + video
+ */
+ if (toolbar === "content") {
+ quillToolbar = [[{ header: [2, 3, 4, 5, 6, false] }], ...quillToolbar];
+ } else if (toolbar === "full") {
+ addImage = true;
+ addVideo = true;
+ quillToolbar = [
+ [{ header: [2, 3, 4, 5, 6, false] }],
+ ...quillToolbar,
+ ["video"],
+ ["image"]
+ ];
+ }
+
+ let modules = {
+ linebreak: {},
+ toolbar: {
+ container: quillToolbar,
+ handlers: {
+ linebreak: lineBreakButtonHandler
+ }
+ }
+ };
+
+ const $input = $(container).siblings('input[type="hidden"]');
+ container.innerHTML = $input.val() || "";
+ const token = $('meta[name="csrf-token"]').attr("content");
+
+ if (addVideo) {
+ quillFormats.push("video");
+ }
+
+ if (addImage) {
+ // Attempt to allow images only if the image support is enabled at editor support.
+ // see: https://github.com/quilljs/quill/issues/1108
+ quillFormats.push("image");
+
+ modules.imageResize = {
+ modules: ["Resize", "DisplaySize"]
+ };
+ modules.imageUpload = {
+ url: $(container).data("uploadImagesPath"),
+ method: "POST",
+ name: "image",
+ withCredentials: false,
+ headers: { "X-CSRF-Token": token },
+ callbackOK: (serverResponse, next) => {
+ $("div.ql-toolbar").last().removeClass("editor-loading");
+ next(serverResponse.url);
+ },
+ callbackKO: (serverError) => {
+ $("div.ql-toolbar").last().removeClass("editor-loading");
+ console.log(`Image upload error: ${serverError.message}`);
+ },
+ checkBeforeSend: (file, next) => {
+ $("div.ql-toolbar").last().addClass("editor-loading");
+ next(file);
+ }
+ };
+
+ const text = $(container).data("dragAndDropHelpText");
+ $(container).after(
+ `${text}
`
+ );
+ }
+ const quill = new Quill(container, {
+ modules: modules,
+ formats: quillFormats,
+ theme: "snow"
+ });
+
+ if (addImage === false) {
+ // Firefox natively implements image drop in contenteditable which is why we need to disable that
+ quill.root.addEventListener("drop", (ev) => ev.preventDefault());
+ }
+
+ if (disabled) {
+ quill.disable();
+ }
+
+ quill.on("text-change", () => {
+
+ const text = quill.getText();
+ // Triggers CustomEvent with the cursor position
+ // It is required in input_mentions.js
+ let event = new CustomEvent("quill-position", {
+ detail: quill.getSelection()
+ });
+ container.dispatchEvent(event);
+
+ if (
+ (text === "\n" || text === "\n\n") &&
+ quill.root.querySelectorAll(allowedEmptyContentSelector).length === 0 && !$input.val().match(/img/)
+ ) {
+ $input.val("");
+ } else {
+ const emptyParagraph = "
";
+ const cleanHTML = quill.root.innerHTML.replace(
+ new RegExp(`^${emptyParagraph}|${emptyParagraph}$`, "g"),
+ ""
+ );
+ $input.val(cleanHTML);
+ }
+ });
+ // After editor is ready, linebreak_module deletes two extraneous new lines
+ quill.emitter.emit("editor-ready");
+
+ return quill;
+}
diff --git a/config/application.rb b/config/application.rb
index 94a47232c3..489401f660 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -55,6 +55,7 @@ class Application < Rails::Application
# Models
require "extends/models/decidim/budgets/project_extends"
require "extends/models/decidim/authorization_extends"
+ require "extends/models/decidim/decidim_awesome/proposal_extra_field_extends"
# Services
require "extends/services/decidim/iframe_disabler_extends"
# Helpers
diff --git a/config/initializers/awesome_defaults.rb b/config/initializers/awesome_defaults.rb
new file mode 100644
index 0000000000..97e20a45ec
--- /dev/null
+++ b/config/initializers/awesome_defaults.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+# A URL where to obtain the translations for the FormBuilder component
+# you can a custom place if you are worried about the CDN geolocation
+# Download them from https://github.com/kevinchappell/formBuilder-languages
+
+# For instance, copy them to your /public/fb_locales/ directory and set the path here:
+Decidim::DecidimAwesome.configure do |config|
+ config.form_builder_langs_location = "https://decidim.storage.opensourcepolitics.eu/osp-cdn/form_builder/1.1.0/"
+end
diff --git a/config/sidekiq.yml b/config/sidekiq.yml
index 0d311832d8..06a488dcc0 100644
--- a/config/sidekiq.yml
+++ b/config/sidekiq.yml
@@ -63,6 +63,10 @@
cron: '0 1 * * *'
class: NotifyProgressInitiatives
queue: initiatives
+ ActiveStorageClearOrphans:
+ cron: '30 6 1 9 0' # Run at 06:30AM on 1st September
+ class: ActiveStorageClearOrphansJob
+ queue: default
CleanAdminLogs:
cron: "0 9 0 * * *"
class: Decidim::Cleaner::CleanAdminLogsJob
diff --git a/config/storage.yml b/config/storage.yml
index 54e2f21c1d..552b5fbcb6 100644
--- a/config/storage.yml
+++ b/config/storage.yml
@@ -8,7 +8,7 @@ local:
scaleway:
service: S3
- endpoint: https://<%= Rails.application.secrets.dig(:scaleway, :endpoint) %>
+ endpoint: <%= Rails.application.secrets.dig(:scaleway, :bucket_name) == "localhost" ? "http" : "https" %>://<%= Rails.application.secrets.dig(:scaleway, :endpoint) %>
access_key_id: <%= Rails.application.secrets.dig(:scaleway, :id) %>
secret_access_key: <%= Rails.application.secrets.dig(:scaleway, :token) %>
region: fr-par
diff --git a/db/migrate/20241011092707_add_decrypted_private_body_to_proposal_extra_field.rb b/db/migrate/20241011092707_add_decrypted_private_body_to_proposal_extra_field.rb
new file mode 100644
index 0000000000..0b7f1d20f4
--- /dev/null
+++ b/db/migrate/20241011092707_add_decrypted_private_body_to_proposal_extra_field.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class AddDecryptedPrivateBodyToProposalExtraField < ActiveRecord::Migration[6.1]
+ class ProposalExtraField < ApplicationRecord
+ self.table_name = :decidim_awesome_proposal_extra_fields
+ end
+
+ def change
+ add_column :decidim_awesome_proposal_extra_fields, :decrypted_private_body, :string
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 60ae0dfbea..b817eee201 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -332,6 +332,7 @@
t.string "private_body"
t.string "decidim_proposal_type", null: false
t.datetime "private_body_updated_at"
+ t.string "decrypted_private_body"
t.index ["decidim_proposal_id", "decidim_proposal_type"], name: "index_decidim_awesome_proposal_extra_fields_on_decidim_proposal"
end
diff --git a/docker-compose.local.yml b/docker-compose.local.yml
index c1e5ab0fcd..0a5cb54a6f 100644
--- a/docker-compose.local.yml
+++ b/docker-compose.local.yml
@@ -1,20 +1,41 @@
services:
+ minio:
+ container_name: minio
+ image: "bitnami/minio:latest"
+ ports:
+ - "9000:9000"
+ - "9001:9001"
+ environment:
+ - MINIO_DEFAULT_BUCKETS=localhost
+ - MINIO_ROOT_USER=minioadmin
+ - MINIO_ROOT_PASSWORD=minioadmin
+ networks:
+ - minio_network
+ volumes:
+ - 'minio:/bitnami/minio/data'
+
database:
image: postgres
volumes:
- pg-data:/var/lib/postgresql/data
environment:
- POSTGRES_HOST_AUTH_METHOD=trust
+ networks:
+ - minio_network
memcached:
image: memcached
ports:
- "11211:11211"
+ networks:
+ - minio_network
redis:
image: redis
ports:
- "6379:6379"
volumes:
- redis-data:/var/lib/redis/data
+ networks:
+ - minio_network
sidekiq:
image: decidim-app:latest
command: [ "bundle", "exec", "sidekiq", "-C", "config/sidekiq.yml" ]
@@ -26,6 +47,7 @@ services:
- MEMCACHE_SERVERS=memcached:11211
- RAILS_SERVE_STATIC_FILES=true
- RAILS_LOG_TO_STDOUT=true
+ - RAILS_LOG_LEVEL=debug
- ASSET_HOST=localhost:3000
- FORCE_SSL=1
- ENABLE_LETTER_OPENER=1
@@ -41,6 +63,10 @@ services:
- GEOCODER_LOOKUP_API_KEY=${GEOCODER_LOOKUP_API_KEY}
- DEFAULT_LOCALE=${DEFAULT_LOCALE}
- AVAILABLE_LOCALES=${AVAILABLE_LOCALES}
+ - OBJECTSTORE_S3_HOST=minio:9000
+ - SCALEWAY_BUCKET_NAME=localhost
+ - SCALEWAY_ID=minioadmin
+ - SCALEWAY_TOKEN=minioadmin
depends_on:
- app
volumes:
@@ -48,6 +74,8 @@ services:
links:
- database
- redis
+ networks:
+ - minio_network
app:
image: decidim-app:latest
environment:
@@ -58,6 +86,7 @@ services:
- MEMCACHE_SERVERS=memcached:11211
- RAILS_SERVE_STATIC_FILES=true
- RAILS_LOG_TO_STDOUT=true
+ - RAILS_LOG_LEVEL=debug
- ASSET_HOST=localhost:3000
- FORCE_SSL=1
- ENABLE_LETTER_OPENER=1
@@ -73,6 +102,10 @@ services:
- GEOCODER_LOOKUP_API_KEY=${GEOCODER_LOOKUP_API_KEY}
- DEFAULT_LOCALE=${DEFAULT_LOCALE}
- AVAILABLE_LOCALES=${AVAILABLE_LOCALES}
+ - OBJECTSTORE_S3_HOST=minio:9000
+ - SCALEWAY_BUCKET_NAME=localhost
+ - SCALEWAY_ID=minioadmin
+ - SCALEWAY_TOKEN=minioadmin
volumes:
- shared-volume:/app
ports:
@@ -81,8 +114,16 @@ services:
- database
- redis
- memcached
+ networks:
+ - minio_network
+
+networks:
+ minio_network:
+ driver: bridge
volumes:
shared-volume: { }
pg-data: { }
- redis-data: { }
\ No newline at end of file
+ redis-data: { }
+ minio:
+ driver: local
diff --git a/lib/extends/lib/decidim/forms/user_answers_serializer_extend.rb b/lib/extends/lib/decidim/forms/user_answers_serializer_extend.rb
index 57345d30c2..31a0d7efc5 100644
--- a/lib/extends/lib/decidim/forms/user_answers_serializer_extend.rb
+++ b/lib/extends/lib/decidim/forms/user_answers_serializer_extend.rb
@@ -6,6 +6,20 @@ module UserAnswersSerializerExtends
included do
private
+ def questions_hash
+ questionnaire_id = @answers.first&.decidim_questionnaire_id
+ return {} unless questionnaire_id
+
+ questions = Decidim::Forms::Question.where(decidim_questionnaire_id: questionnaire_id).order(:position)
+ return {} if questions.none?
+
+ questions.each.inject({}) do |serialized, question|
+ serialized.update(
+ translated_question_key(question.position, question.body) => ""
+ )
+ end
+ end
+
def hash_for(answer)
{
answer_translated_attribute_name(:id) => answer&.session_token,
diff --git a/lib/extends/models/decidim/decidim_awesome/proposal_extra_field_extends.rb b/lib/extends/models/decidim/decidim_awesome/proposal_extra_field_extends.rb
new file mode 100644
index 0000000000..68bc571585
--- /dev/null
+++ b/lib/extends/models/decidim/decidim_awesome/proposal_extra_field_extends.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require "active_support/concern"
+module ProposalExtraFieldExtends
+ extend ActiveSupport::Concern
+
+ included do
+ after_save :update_decrypted_body
+
+ private
+
+ def update_decrypted_body
+ update_columns(decrypted_private_body: private_body.to_s) if private_body.present?
+ end
+ end
+end
+
+Decidim::DecidimAwesome::ProposalExtraField.include(ProposalExtraFieldExtends)
diff --git a/lib/tasks/active_storage.rake b/lib/tasks/active_storage.rake
new file mode 100644
index 0000000000..ef696ccb31
--- /dev/null
+++ b/lib/tasks/active_storage.rake
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+namespace :active_storage do
+ namespace :purge do
+ desc "Purge orphan blobs in databa"
+ task blobs: :environment do
+ Rails.logger.info "Looking for blobs without attachments in database..."
+ blobs = ActiveStorage::Blob.where.not(id: ActiveStorage::Attachment.select(:blob_id))
+
+ if blobs.count.zero?
+ Rails.logger.info "Database is clean !"
+ Rails.logger.info "Terminating task..."
+ else
+ Rails.logger.info "Found #{blobs.count} orphan blobs !"
+ blobs.each(&:purge)
+ Rails.logger.info "Task terminated !"
+ end
+ end
+
+ desc "Purge orphan blobs in S3"
+ task s3: :environment do
+ limit = ENV.fetch("S3_LIMIT", "10000").to_i
+ ActiveStorageClearOrphansJob.perform_later(limit: limit)
+ end
+ end
+end
diff --git a/lib/tasks/set_decrypted_private_body.rake b/lib/tasks/set_decrypted_private_body.rake
new file mode 100644
index 0000000000..e232be733c
--- /dev/null
+++ b/lib/tasks/set_decrypted_private_body.rake
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+namespace :decidim do
+ desc "Set decrypted_private_body to existing extra fields"
+ task set_decrypted_private_body: :environment do
+ if Rails.env.development?
+ PrivateBodyDecryptJob.perform_now
+ else
+ PrivateBodyDecryptJob.perform_later
+ end
+ end
+end
diff --git a/spec/jobs/private_body_decrypt_job_spec.rb b/spec/jobs/private_body_decrypt_job_spec.rb
new file mode 100644
index 0000000000..dd0a59f0fc
--- /dev/null
+++ b/spec/jobs/private_body_decrypt_job_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+describe PrivateBodyDecryptJob, type: :job do
+ let!(:proposal_with_private_body) { create(:awesome_proposal_extra_fields, proposal: create(:extended_proposal), private_body: '- Something
', decrypted_private_body: nil) }
+ let!(:proposal_without_private_body) { create(:awesome_proposal_extra_fields, proposal: create(:extended_proposal), private_body: nil, decrypted_private_body: nil) }
+ let!(:proposal_with_both_bodies_defined) { create(:awesome_proposal_extra_fields, proposal: create(:extended_proposal), private_body: '- Something
', decrypted_private_body: '- Something
') }
+ let!(:proposal_without_extra_fields) { create(:awesome_proposal_extra_fields, proposal: create(:extended_proposal)) }
+
+ before do
+ allow(Rails.logger).to receive(:info)
+ end
+
+ context "when extra fields are present" do
+ context "with private body defined and decrypted_private_body nil" do
+ it "logs and updates the decrypted body" do
+ # rubocop:disable Rails/SkipsModelValidations
+ proposal_with_private_body.update_columns(decrypted_private_body: nil)
+ # rubocop:enable Rails/SkipsModelValidations
+ expect(proposal_with_private_body.decrypted_private_body).to be_nil
+ described_class.perform_now
+ expect(proposal_with_private_body.reload.decrypted_private_body).to eq('- Something
')
+ end
+ end
+
+ context "with private body nil, and decrypted_private_body nil" do
+ it "does not log or update anything" do
+ expect(proposal_without_private_body.decrypted_private_body).to be_nil
+ expect(proposal_without_private_body.private_body).to be_nil
+ expect(Rails.logger).not_to receive(:info).with("Extra fields to update: 1")
+ expect(Rails.logger).not_to receive(:info).with("Extra fields updated: 1")
+ described_class.perform_now
+ expect(proposal_without_private_body.reload.decrypted_private_body).to be_nil
+ end
+ end
+
+ context "with private body and decrypted_private_body defined" do
+ it "does not log or update anything" do
+ expect(proposal_with_both_bodies_defined.decrypted_private_body).not_to be_nil
+ expect(proposal_with_both_bodies_defined.private_body).not_to be_nil
+ expect(Rails.logger).not_to receive(:info).with("Extra fields to update: 1")
+ expect(Rails.logger).not_to receive(:info).with("Extra fields updated: 1")
+ described_class.perform_now
+ expect(proposal_with_both_bodies_defined.reload.decrypted_private_body).to eq('- Something
')
+ end
+ end
+ end
+
+ context "when extra fields are missing" do
+ it "does not log or update" do
+ expect(Rails.logger).not_to receive(:info).with("Extra fields to update: 1")
+ expect(Rails.logger).not_to receive(:info).with("Extra fields updated: 1")
+ described_class.perform_now
+ expect(proposal_without_extra_fields.reload.decrypted_private_body).to be_nil
+ end
+ end
+end
diff --git a/spec/lib/tasks/decidim_app/set_decrypted_private_body_task_spec.rb b/spec/lib/tasks/decidim_app/set_decrypted_private_body_task_spec.rb
new file mode 100644
index 0000000000..41dbc58206
--- /dev/null
+++ b/spec/lib/tasks/decidim_app/set_decrypted_private_body_task_spec.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+describe "rake decidim:set_decrypted_private_body", type: :task do
+ let(:task) { Rake::Task["decidim:set_decrypted_private_body"] }
+ let!(:extra_fields) { create(:awesome_proposal_extra_fields, proposal: create(:extended_proposal)) }
+
+ before do
+ extra_fields.private_body = { "en" => '- Something
' }
+ extra_fields.save!
+ end
+
+ it "preloads the Rails environment" do
+ expect(task.prerequisites).to include "environment"
+ end
+
+ context "when the environment is development" do
+ before do
+ allow(Rails.env).to receive(:development?).and_return(true)
+ end
+
+ it "executes the job immediately" do
+ expect(PrivateBodyDecryptJob).to receive(:perform_now)
+ task.execute
+ end
+ end
+
+ context "when the environment is not development" do
+ before do
+ allow(Rails.env).to receive(:development?).and_return(false)
+ end
+
+ it "enqueues the job to perform later" do
+ expect(PrivateBodyDecryptJob).to receive(:perform_later)
+ task.execute
+ end
+ end
+end
diff --git a/spec/models/decidim/decidim_awesome/proposal_extra_field_spec.rb b/spec/models/decidim/decidim_awesome/proposal_extra_field_spec.rb
new file mode 100644
index 0000000000..1223979e07
--- /dev/null
+++ b/spec/models/decidim/decidim_awesome/proposal_extra_field_spec.rb
@@ -0,0 +1,298 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+module Decidim::DecidimAwesome
+ describe ProposalExtraField do
+ subject { extra_fields }
+
+ let(:extra_fields) { create(:awesome_proposal_extra_fields, proposal: create(:extended_proposal)) }
+ let(:proposal) { create(:extended_proposal) }
+
+ it { is_expected.to be_valid }
+
+ it "has a proposal associated" do
+ expect(extra_fields.proposal).to be_a(Decidim::Proposals::Proposal)
+ end
+
+ it "cannot associate more than one extra field to a proposal" do
+ extra_fields
+ expect do
+ another_proposal = create(:proposal, component: extra_fields.proposal.component)
+ create(:awesome_proposal_extra_fields, proposal: another_proposal)
+ end.to change(Decidim::DecidimAwesome::ProposalExtraField, :count).by(1)
+ expect { create(:awesome_proposal_extra_fields, proposal: extra_fields.proposal) }.to raise_error(ActiveRecord::RecordInvalid)
+ end
+
+ it "the associated proposal has the same extra_fields" do
+ expect(extra_fields.proposal.reload.extra_fields).to eq(extra_fields)
+ end
+
+ describe "weight_count" do
+ let!(:extra_fields) { create(:awesome_proposal_extra_fields, proposal: proposal) }
+ let!(:vote_weights) do
+ proposal
+ [
+ create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 1),
+ create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 2),
+ create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 3)
+ ]
+ end
+
+ it "returns the weight count for a weight" do
+ expect(proposal.reload.weight_count(1)).to eq(1)
+ expect(proposal.weight_count(2)).to eq(1)
+ expect(proposal.weight_count(3)).to eq(1)
+ end
+
+ context "when a vote is added" do
+ before do
+ create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 5)
+ create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 3)
+ end
+
+ it "returns the weight count for a weight" do
+ expect(proposal.reload.weight_count(1)).to eq(1)
+ expect(proposal.weight_count(2)).to eq(1)
+ expect(proposal.weight_count(3)).to eq(2)
+ expect(proposal.weight_count(4)).to eq(0)
+ expect(proposal.weight_count(5)).to eq(1)
+ end
+ end
+
+ context "when extra_fields does not exist" do
+ let(:extra_fields) { nil }
+ let(:vote_weights) { nil }
+
+ it "returns 0" do
+ expect(proposal.reload.weight_count(1)).to eq(0)
+ expect(proposal.weight_count(2)).to eq(0)
+ expect(proposal.weight_count(3)).to eq(0)
+ expect(proposal.weight_count(100)).to eq(0)
+ end
+
+ context "when a vote is added" do
+ before do
+ create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 5)
+ create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 3)
+ end
+
+ it "returns the weight count for a weight" do
+ expect(proposal.reload.weight_count(1)).to eq(0)
+ expect(proposal.weight_count(2)).to eq(0)
+ expect(proposal.weight_count(3)).to eq(1)
+ expect(proposal.weight_count(4)).to eq(0)
+ expect(proposal.weight_count(5)).to eq(1)
+ end
+ end
+ end
+ end
+
+ context "when proposal is destroyed" do
+ let!(:extra_fields) { create(:awesome_proposal_extra_fields, proposal: proposal) }
+
+ it "destroys the proposal weight" do
+ expect { proposal.destroy }.to change(Decidim::DecidimAwesome::ProposalExtraField, :count).by(-1)
+ end
+ end
+
+ context "when proposal weight is destroyed" do
+ let!(:extra_fields) { create(:awesome_proposal_extra_fields, proposal: proposal) }
+
+ it "does not destroy the proposal" do
+ expect { extra_fields.destroy }.not_to change(Decidim::Proposals::ProposalVote, :count)
+ end
+ end
+
+ context "when vote weight is" do
+ describe "created" do
+ it "increments the weight cache" do
+ expect { create(:proposal_vote, proposal: proposal) }.to change { proposal.votes.count }.by(1)
+ expect { create(:awesome_vote_weight, vote: proposal.votes.first, weight: 3) }.to change(Decidim::DecidimAwesome::ProposalExtraField, :count).by(1)
+ expect(proposal.reload.extra_fields.vote_weight_totals).to eq({ "3" => 1 })
+ expect(proposal.extra_fields.weight_total).to eq(3)
+ end
+
+ context "when cache already exists" do
+ let(:another_proposal) { create(:proposal, component: proposal.component) }
+ let!(:extra_fields) { create(:awesome_proposal_extra_fields, :with_votes, proposal: proposal) }
+ let!(:another_extra_fields) { create(:awesome_proposal_extra_fields, :with_votes, proposal: another_proposal) }
+
+ it "has weights and votes" do
+ expect(extra_fields.reload.vote_weight_totals).to eq({ "1" => 1, "2" => 1, "3" => 1, "4" => 1, "5" => 1 })
+ expect(extra_fields.weight_total).to eq(15)
+ end
+
+ it "increments the weight cache" do
+ create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 1)
+ create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 3)
+ create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 3)
+ expect(extra_fields.reload.vote_weight_totals).to eq({ "1" => 2, "2" => 1, "3" => 3, "4" => 1, "5" => 1 })
+ expect(extra_fields.weight_total).to eq(22)
+ end
+ end
+
+ context "when cache does not exist yet" do
+ let(:extra_fields) { proposal.reload.extra_fields }
+
+ it "has no weights and votes" do
+ expect(extra_fields).to be_nil
+ end
+
+ it "increments the weight cache" do
+ create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 1)
+ create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 3)
+ create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 3)
+ expect(extra_fields.vote_weight_totals).to eq({ "1" => 1, "3" => 2 })
+ expect(extra_fields.weight_total).to eq(7)
+ end
+ end
+ end
+
+ # this is an unlikely scenario where voting removes and creates new vote weights, just in case...
+ describe "updated" do
+ let!(:vote_weight1) { create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 1) }
+ let!(:vote_weight2) { create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 2) }
+ let(:extra_fields) { proposal.reload.extra_fields }
+
+ it "increments the weight cache" do
+ vote_weight1.weight = 3
+ vote_weight1.save
+ expect(extra_fields.vote_weight_totals).to eq({ "2" => 1, "3" => 1 })
+ expect(extra_fields.weight_total).to eq(5)
+ end
+
+ it "decreases the weight cache" do
+ vote_weight2.weight = 1
+ vote_weight2.save
+ expect(extra_fields.vote_weight_totals).to eq({ "1" => 2 })
+ expect(extra_fields.weight_total).to eq(2)
+ end
+ end
+
+ describe "destroyed" do
+ let!(:vote_weight1) { create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 1) }
+ let!(:vote_weight2) { create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 2) }
+ let(:extra_fields) { proposal.reload.extra_fields }
+
+ it "decreases the weight cache" do
+ vote_weight1.destroy
+ expect(extra_fields.vote_weight_totals).to eq({ "2" => 1 })
+ expect(extra_fields.weight_total).to eq(2)
+ end
+ end
+ end
+
+ describe "all_vote_weights" do
+ let!(:extra_fields) { create(:awesome_proposal_extra_fields, proposal: proposal) }
+ let!(:another_extra_fields) { create(:awesome_proposal_extra_fields, proposal: another_proposal) }
+ let!(:unrelated_another_extra_fields) { create(:awesome_proposal_extra_fields, :with_votes, proposal: create(:extended_proposal)) }
+ let(:another_proposal) { create(:proposal, component: proposal.component) }
+ let!(:votes) do
+ vote = create(:proposal_vote, proposal: proposal, author: create(:user, organization: proposal.organization))
+ create(:awesome_vote_weight, vote: vote, weight: 1)
+ end
+ let!(:other_votes) do
+ vote = create(:proposal_vote, proposal: another_proposal, author: create(:user, organization: proposal.organization))
+ create(:awesome_vote_weight, vote: vote, weight: 2)
+ end
+
+ it "returns all vote weights for a component" do
+ expect(proposal.reload.all_vote_weights).to contain_exactly(1, 2)
+ expect(another_proposal.reload.all_vote_weights).to contain_exactly(1, 2)
+ expect(proposal.vote_weights).to eq({ "1" => 1, "2" => 0 })
+ expect(another_proposal.vote_weights).to eq({ "1" => 0, "2" => 1 })
+ end
+
+ context "when wrong cache exists" do
+ before do
+ # rubocop:disable Rails/SkipsModelValidations:
+ # we don't want to trigger the active record hooks
+ extra_fields.update_columns(vote_weight_totals: { "3" => 1, "4" => 1 })
+ # rubocop:enable Rails/SkipsModelValidations:
+ end
+
+ it "returns all vote weights for a component" do
+ expect(proposal.reload.extra_fields.vote_weight_totals).to eq({ "3" => 1, "4" => 1 })
+ expect(proposal.vote_weights).to eq({ "1" => 0, "2" => 0 })
+ proposal.update_vote_weights!
+ expect(proposal.vote_weights).to eq({ "1" => 1, "2" => 0 })
+ expect(another_proposal.reload.vote_weights).to eq({ "1" => 0, "2" => 1 })
+ expect(proposal.extra_fields.vote_weight_totals).to eq({ "1" => 1 })
+ expect(another_proposal.extra_fields.vote_weight_totals).to eq({ "2" => 1 })
+ end
+ end
+ end
+
+ describe "private_body" do
+ it "returns nil if no private_body" do
+ expect(extra_fields.private_body).to be_nil
+ expect(extra_fields.private_body_updated_at).to be_nil
+ expect(extra_fields.attributes["private_body"]).to be_nil
+ end
+
+ it "the associated proposal has a private_body" do
+ expect(extra_fields.proposal.private_body).to be_nil
+ end
+
+ context "when private body is set" do
+ before do
+ extra_fields.private_body = { "en" => '- Something
' }
+ extra_fields.save!
+ end
+
+ it "sets the private body" do
+ expect(extra_fields.private_body["en"]).to eq('- Something
')
+ expect(extra_fields.attributes["private_body"]["en"]).not_to start_with("- '
- Something else
' }
+ extra_fields.save!
+ expect(extra_fields.private_body_updated_at).not_to eq(initial_date)
+ end
+
+ it "the associated proposal has a private_body" do
+ expect(extra_fields.proposal.reload.private_body["en"]).to eq('- Something
')
+ expect(extra_fields.proposal.private_body).to eq(extra_fields.private_body)
+ end
+
+ it "sets the decrypted private body" do
+ expect(extra_fields.decrypted_private_body).to eq('{"en"=>"- Something
"}')
+ end
+ end
+
+ context "when setting the private body from the proposal" do
+ before do
+ proposal.private_body = { "en" => '- Something
' }
+ end
+
+ it "sets the private body" do
+ expect(proposal.private_body["en"]).to eq('- Something
')
+ end
+ end
+
+ context "when saving the private body from the proposal" do
+ before do
+ proposal.private_body = { "en" => '- Something
' }
+ proposal.save!
+ end
+
+ it "sets the private body" do
+ expect(proposal.extra_fields.private_body["en"]).to eq('- Something
')
+ expect(proposal.extra_fields.attributes["private_body"]["en"]).not_to start_with("- "
- Something
"}')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/serializers/decidim/forms/user_answers_serializer_spec.rb b/spec/serializers/decidim/forms/user_answers_serializer_spec.rb
index 581372dbc9..6badfad42e 100644
--- a/spec/serializers/decidim/forms/user_answers_serializer_spec.rb
+++ b/spec/serializers/decidim/forms/user_answers_serializer_spec.rb
@@ -209,6 +209,13 @@ def memory_usage
end
end
end
+
+ describe "questions_hash" do
+ it "generates a hash of questions ordered by position" do
+ questions.shuffle!
+ expect(subject.instance_eval { questions_hash }.keys.map { |key| key[0].to_i }.uniq).to eq(questions.sort_by(&:position).map { |question| question.position + 1 })
+ end
+ end
end
end
end
diff --git a/spec/system/admin/admin_manages_survey_question_with_image_spec.rb b/spec/system/admin/admin_manages_survey_question_with_image_spec.rb
new file mode 100644
index 0000000000..8acf44d0ab
--- /dev/null
+++ b/spec/system/admin/admin_manages_survey_question_with_image_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+describe "Admin manages survey question with image", type: :system do
+ let(:manifest_name) { "surveys" }
+ let!(:component) do
+ create(:component,
+ manifest: manifest,
+ participatory_space: participatory_space,
+ published_at: nil)
+ end
+ let!(:questionnaire) { create(:questionnaire) }
+ let!(:survey) { create :survey, component: component, questionnaire: questionnaire }
+
+ include_context "when managing a component as an admin"
+
+ context "when survey is not published" do
+ before do
+ component.unpublish!
+ end
+
+ let(:image_url) { "https://unsplash.com/fr/photos/une-trainee-detoiles-est-vue-dans-le-ciel-au-dessus-de-locean-pjHseB_JLpg" }
+ let(:router) { Decidim::EngineRouter.main_proxy(component) }
+ let(:description_with_image) do
+ {
+ "en" => "",
+ "ca" => "
",
+ "es" => "
"
+ }
+ end
+ let!(:question) { create(:questionnaire_question, description: description_with_image, questionnaire: questionnaire) }
+
+ it "after save, it renders description with hidden input value filled" do
+ Capybara.ignore_hidden_elements = false
+ visit questionnaire_edit_path
+ click_button "Expand all"
+ expect(page).to have_selector("img[src='#{image_url}']")
+ click_button "Save"
+ click_button "Expand all"
+ expect(page).to have_selector("img[src='#{image_url}']")
+ within "#questionnaire_question_#{question.id}-field" do
+ within "#questionnaire_question_#{question.id}-description-panel-0" do
+ input = page.find("#questionnaire_questions_#{question.id}_description_en")
+ expect(input.value).to eq("
")
+ end
+ end
+ end
+ end
+
+ def questionnaire_edit_path
+ manage_component_path(component)
+ end
+end
diff --git a/spec/system/admin/admin_updates_question_in_questionnaire_templates_spec.rb b/spec/system/admin/admin_updates_question_in_questionnaire_templates_spec.rb
index b6d207ae0e..d574bb286d 100644
--- a/spec/system/admin/admin_updates_question_in_questionnaire_templates_spec.rb
+++ b/spec/system/admin/admin_updates_question_in_questionnaire_templates_spec.rb
@@ -2,7 +2,7 @@
require "spec_helper"
-describe "Admin adds display condition to template's questionniaire question", type: :system do
+describe "Admin adds display condition to template's questionnaire question", type: :system do
let!(:organization) { create(:organization) }
let!(:user) { create(:user, :admin, :confirmed, organization: organization) }
let(:template) { create(:questionnaire_template, organization: organization) }
@@ -23,14 +23,16 @@
# expand question two
find("[data-toggle$=button--expand-question-questionnaire_question_#{question_two.id}]").click
# add display condition
- find(".add-display-condition").click
- # select question
- select translated(question_one.body), from: "questionnaire[questions][#{question_two.id}][display_conditions][questionnaire-display-condition-id][decidim_condition_question_id]"
- # select equal
- select "Equal", from: "questionnaire[questions][#{question_two.id}][display_conditions][questionnaire-display-condition-id][condition_type]"
- # validate we have the 2 answer options from question one in the select
- select = find("#questionnaire_questions_#{question_two.id}_display_conditions_questionnaire-display-condition-id_decidim_answer_option_id")
- expect(select).to have_content(translated(question_one.answer_options.first.body))
- expect(select).to have_content(translated(question_one.answer_options.last.body))
+ within "#questionnaire_question_#{question_two.id}-question-card" do
+ find(".add-display-condition").click
+ # select question
+ select translated(question_one.body), from: "questionnaire[questions][#{question_two.id}][display_conditions][questionnaire-display-condition-id][decidim_condition_question_id]"
+ # select equal
+ select "Equal", from: "questionnaire[questions][#{question_two.id}][display_conditions][questionnaire-display-condition-id][condition_type]"
+ # validate we have the 2 answer options from question one in the select
+ select = find("#questionnaire_questions_#{question_two.id}_display_conditions_questionnaire-display-condition-id_decidim_answer_option_id")
+ expect(select).to have_content(translated(question_one.answer_options.first.body))
+ expect(select).to have_content(translated(question_one.answer_options.last.body))
+ end
end
end