diff --git a/app/admin/observation.rb b/app/admin/observation.rb index c4c6314b2..ce0b7c4c3 100644 --- a/app/admin/observation.rb +++ b/app/admin/observation.rb @@ -108,6 +108,9 @@ def scoped_collection scope -> { I18n.t("shared.created") }, :created scope -> { I18n.t("active_admin.observations_page.scope_hidden") }, :hidden scope -> { I18n.t("active_admin.observations_page.visible") }, :visible + scope -> { I18n.t("activerecord.models.quality_control") }, :quality_control, if: -> { current_user.reviewable_observer_ids.any? } do |observations| + observations.ready_for_or_in_qc.where(id: current_user.quality_controlable_observations) + end # region filters filter :id, as: :numeric_range_filter diff --git a/app/admin/quality_control.rb b/app/admin/quality_control.rb index 547cdff3e..805ebb625 100644 --- a/app/admin/quality_control.rb +++ b/app/admin/quality_control.rb @@ -5,7 +5,7 @@ actions :new, :create - permit_params :reviewer_id, :reviewable_id, :reviewable_type, :passed, :comment + permit_params :reviewer_id, :reviewable_id, :reviewable_type, :decision, :comment controller do def new @@ -16,7 +16,11 @@ def new def create super do |format| - redirect_to reviewable_path and return + if resource.errors.empty? + redirect_to params[:return_to] || reviewable_path, notice: I18n.t("active_admin.quality_control_page.performed_qc", decision: resource.decision) and return + else + resource.reviewable.reload + end end end @@ -32,9 +36,21 @@ def reviewable_path f.hidden_field :reviewer_id, value: current_user.id f.hidden_field :reviewable_id, value: resource.reviewable_id f.hidden_field :reviewable_type, value: resource.reviewable_type + f.hidden_field :rejectable_decisions, value: resource.reviewable.qc_rejectable_decisions.join(","), disabled: true f.inputs do - f.input :passed, as: :radio, collection: resource.reviewable.qc_available_decisions, label: I18n.t("operator_documents.qc_form.decision") + f.input :decision, as: :radio, collection: resource.reviewable.qc_available_decisions, label: I18n.t("operator_documents.qc_form.decision") + + resource.reviewable.qc_available_decisions.each do |decision| + next unless resource.reviewable.qc_decisions_hints[decision].present? + + li class: "input qc-decision-hint", style: "display: none;", "data-hint": decision do + div class: "flash flash_warning" do + resource.reviewable.qc_decisions_hints[decision] + end + end + end + f.input :comment, as: :text end diff --git a/app/assets/javascripts/quality_controls.js b/app/assets/javascripts/quality_controls.js index d38f8f73b..f768c98bc 100644 --- a/app/assets/javascripts/quality_controls.js +++ b/app/assets/javascripts/quality_controls.js @@ -1,15 +1,20 @@ $(document).ready(function() { updateQCFields(); - $('input[name="quality_control[passed]"]').on('change', function(){ + $('input[name="quality_control[decision]"]').on('change', function(){ updateQCFields(); }) }) function updateQCFields() { - const selectedValue = $('input[name="quality_control[passed]"]:checked').val(); + const selectedValue = $('input[name="quality_control[decision]"]:checked').val(); + const rejectableDecisions = $('#quality_control_rejectable_decisions').val().split(','); const comment = $('#quality_control_comment_input'); + const hint = $('.qc-decision-hint[data-hint="' + selectedValue + '"]'); - if (selectedValue === 'false') { + $('.qc-decision-hint').hide(); + hint.show(); + + if (rejectableDecisions.includes(selectedValue)) { comment.find('textarea').prop('disabled', false); comment.show(); } else { diff --git a/app/models/observation.rb b/app/models/observation.rb index 57a200a9f..ac3d947c9 100644 --- a/app/models/observation.rb +++ b/app/models/observation.rb @@ -56,7 +56,7 @@ class Observation < ApplicationRecord translates :details, :concern_opinion, :litigation_status, touch: true, versioning: :paper_trail, paranoia: true active_admin_translates :details, :concern_opinion, :litigation_status - WrongStateError = Class.new(StandardError) + QCError = Class.new(StandardError) enum :evidence_type, {"No evidence" => 0, "Uploaded documents" => 1, "Evidence presented in the report" => 2}, validate: {allow_nil: true} enum :observation_type, {"operator" => 0, "government" => 1}, validate: true @@ -94,18 +94,7 @@ class Observation < ApplicationRecord "Ready for QC1" => ["QC1 in progress"], "QC1 in progress" => ["Rejected", "Ready for QC2"], "Ready for QC2" => ["QC2 in progress"], - "QC2 in progress" => ["Needs revision", "Ready for publication"] - } - }.freeze - - QC_APPROVAL_STATUS_TRANSITIONS = { - "QC1 in progress" => { - false => "Rejected", - true => "Ready for QC2" - }, - "QC2 in progress" => { - false => "Needs revision", - true => "Ready for publication" + "QC2 in progress" => ["Needs revision", "Rejected", "Ready for publication"] } }.freeze @@ -212,6 +201,7 @@ class Observation < ApplicationRecord scope :by_government, ->(government_id) { joins(:governments).where(governments: {id: government_id}) } scope :pending, -> { where(validation_status: ["Created", "QC2 in progress"]) } scope :created, -> { where(validation_status: ["Created", "Ready for QC2"]) } + scope :ready_for_or_in_qc, -> { where(validation_status: ["Ready for QC1", "Ready for QC2", "QC1 in progress", "QC2 in progress"]) } scope :published, -> { where(validation_status: PUBLISHED_STATES) } scope :hidden, -> { where(hidden: true) } scope :visible, -> { where(hidden: [false, nil]) } @@ -279,27 +269,36 @@ def all_responsible_for_qc responsible_for_qc1.or(responsible_for_qc2) end - def update_qc_status!(qc_passed:) - raise WrongStateError, "QC not in progress" unless qc_in_progress? - + def update_qc_status!(qc) update!( user_type: :reviewer, - validation_status: QC_APPROVAL_STATUS_TRANSITIONS[validation_status][qc_passed] + validation_status: qc.decision ) end + def qc_rejectable_decisions + ["Rejected", "Needs revision"] + end + def qc_available_decisions - return [] unless QC_APPROVAL_STATUS_TRANSITIONS[validation_status] + return [] unless qc_in_progress? + + STATUS_TRANSITIONS[:reviewer][validation_status] + end - QC_APPROVAL_STATUS_TRANSITIONS[validation_status].invert.to_a + def qc_decisions_hints + { + "Rejected" => I18n.t("active_admin.observations_page.rejected_hint"), + "Needs revision" => I18n.t("active_admin.observations_page.needs_revision_hint") + } end - def qc_metadata(qc_passed:) + def qc_metadata(qc) return {} unless qc_in_progress? { level: (validation_status == "QC1 in progress") ? "QC1" : "QC2", - decision: QC_APPROVAL_STATUS_TRANSITIONS[validation_status][qc_passed] + decision: qc.decision } end diff --git a/app/models/quality_control.rb b/app/models/quality_control.rb index 08edb1dc3..ded524b25 100644 --- a/app/models/quality_control.rb +++ b/app/models/quality_control.rb @@ -17,18 +17,27 @@ class QualityControl < ApplicationRecord belongs_to :reviewer, class_name: "User" validates :passed, inclusion: {in: [true, false]} + validates :decision, presence: true validates :comment, presence: true, if: -> { !passed && !metadata["backfilled"] } + before_validation :set_passed before_save :set_metadata - after_create :update_reviewable_qc_status + before_create :update_reviewable_qc_status private + def set_passed + self.passed = reviewable.qc_rejectable_decisions.exclude?(decision) if reviewable.present? + end + def set_metadata - self.metadata = reviewable.qc_metadata(qc_passed: passed) + self.metadata = reviewable.qc_metadata(self) unless metadata.present? end def update_reviewable_qc_status - reviewable.update_qc_status!(qc_passed: passed) + reviewable.update_qc_status!(self) + rescue => e + errors.add(:base, "Failed to update QC status: #{e.message}") + throw :abort end end diff --git a/app/resources/v1/quality_control_resource.rb b/app/resources/v1/quality_control_resource.rb index c05f126a4..cf13f69c3 100644 --- a/app/resources/v1/quality_control_resource.rb +++ b/app/resources/v1/quality_control_resource.rb @@ -7,7 +7,7 @@ class QualityControlResource < BaseResource has_one :reviewable, polymorphic: true, always_include_linkage_data: true - attributes :comment, :passed, :created_at, :updated_at + attributes :comment, :passed, :decision, :created_at, :updated_at before_create :set_reviewer diff --git a/app/views/admin/observations/_attributes_table.html.arb b/app/views/admin/observations/_attributes_table.html.arb index d23188ef9..4f9d25dbd 100644 --- a/app/views/admin/observations/_attributes_table.html.arb +++ b/app/views/admin/observations/_attributes_table.html.arb @@ -65,9 +65,7 @@ panel "Quality Controls" do end column :reviewer column :passed? - tag_column :decision do |qc| - qc.metadata["decision"].presence - end + tag_column :decision column :comment column :performed_at, &:created_at end diff --git a/config/locales/en.yml b/config/locales/en.yml index dced0219f..e735474fb 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -387,7 +387,6 @@ en: details: 'Operator Document Annex Details' observations_page: not_modified: 'Observation NOT modified' - performed_qc: 'Quality Control performed' moved_ready: 'Observation moved to Ready for Publication' moved_needs_revision: 'Observation moved to Needs Revision' moved_qc_in_progress: 'Observation moved to QC in Progress' @@ -418,10 +417,13 @@ en: location_on_map: 'Location on map' visible: 'Visible' scope_hidden: 'Hidden' + rejected_hint: 'The observation will be rejected and the monitor will be notified. The monitor can then edit the observation and submit it for QC again.' + needs_revision_hint: 'The monitor will be notified that the observation needs revision with proposed suggestions. The monitor can still publish the observation or submit it for QC again.' users_page: responsible_for_countries_hint: Admin responsible for countries will get notifications concerning the private sector in these countries. quality_control_page: reviewable_not_in_qc: "Reviewed entity is not in QC in progress status" + performed_qc: 'Quality Control performed with decision: %{decision}' powered_by: "Admin Backoffice" delete_model: "Delete %{model}" delete: "Delete" @@ -557,6 +559,7 @@ en: uploaded_document: Uploaded document #g user: User #g user_permission: User permission #g + quality_control: Quality control attributes: about_page_entry: code: Code #g diff --git a/config/locales/es.yml b/config/locales/es.yml index b8e0362db..56d5e7791 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -400,7 +400,6 @@ es: details: 'Detalles de Anexo de Documento del Operador' observations_page: not_modified: 'Observación NO modificada' - performed_qc: 'Control de Calidad realizado' moved_ready: 'Observación movida a Listo para Publicación' moved_needs_revision: 'Observación movida a Necesita Revisión' moved_qc_in_progress: 'Observación movida a QC en Progreso' @@ -431,9 +430,12 @@ es: location_on_map: 'Ubicación en el mapa' visible: 'Visible' scope_hidden: 'Oculto' + rejected_hint: 'La observación será rechazada y el monitor será notificado. El monitor puede luego editar la observación y enviarla nuevamente para QC.' + needs_revision_hint: 'El monitor será notificado de que la observación necesita revisión con sugerencias propuestas. El monitor aún puede publicar la observación o enviarla nuevamente para QC.' users_page: responsible_for_countries_hint: Los administradores responsables de países recibirán notificaciones relacionadas con el sector privado en estos países. quality_control_page: + performed_qc: 'Control de Calidad realizado con decisión: %{decision}' reviewable_not_in_qc: "La entidad revisada no está en estado QC en progreso" powered_by: "Admin Backoffice" delete_model: "Eliminar %{model}" @@ -570,6 +572,7 @@ es: uploaded_document: Documento subido user: Usuario user_permission: Permiso de usuario + quality_control: Control de calidad attributes: about_page_entry: code: Código diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 3b41c6888..36dd99f43 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -393,7 +393,6 @@ fr: details: "Détails de l'annexe du document de l'opérateur" observations_page: not_modified: 'Observation NON modifiée' - performed_qc: 'Contrôle qualité effectué' moved_ready: 'Observation déplacée vers Prêt pour publication' moved_needs_revision: 'Observation déplacée vers Nécessite une révision' moved_qc_in_progress: 'Observation déplacée au CQ en cours' @@ -424,7 +423,10 @@ fr: location_on_map: 'Emplacement sur la carte' visible: 'Visible' scope_hidden: 'Invisible' + rejected_hint: 'L’observation sera rejetée et le moniteur en sera informé. Le moniteur peut ensuite modifier l’observation et la soumettre à nouveau pour le contrôle de qualité.' + needs_revision_hint: 'Le moniteur sera informé que l’observation nécessite une révision avec des suggestions proposées. Le moniteur peut toujours publier l’observation ou la soumettre à nouveau pour le contrôle de qualité.' quality_control_page: + performed_qc: 'Contrôle de qualité effectué avec la décision : %{decision}' reviewable_not_in_qc: "L'entité examinée n'a pas le statut de contrôle qualité en cours" powered_by: "Admin Backoffice" delete_model: "Supprimer %{model}" @@ -554,6 +556,7 @@ fr: uploaded_document: Document téléchargé #g user: Utilisateur #g user_permission: Autorisation utilisateur #g + quality_control: Contrôle de qualité attributes: about_page_entry: code: 'Code' diff --git a/db/migrate/20260121104250_add_decision_to_quality_control.rb b/db/migrate/20260121104250_add_decision_to_quality_control.rb new file mode 100644 index 000000000..fa94d5cab --- /dev/null +++ b/db/migrate/20260121104250_add_decision_to_quality_control.rb @@ -0,0 +1,26 @@ +class AddDecisionToQualityControl < ActiveRecord::Migration[7.2] + class QualityControl < ApplicationRecord + end + + def up + add_column :quality_controls, :decision, :string + + QualityControl.find_each do |qc| + qc.update!(decision: qc.metadata["decision"]) + end + + change_column_null :quality_controls, :decision, false + end + + def down + QualityControl.find_each do |qc| + next if qc.metadata.present? && qc.metadata["decision"].present? + + qc.metadata ||= {} + qc.metadata["decision"] = qc.decision + qc.save! + end + + remove_column :quality_controls, :decision + end +end diff --git a/db/schema.rb b/db/schema.rb index 8cccdde53..6e22d9c35 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -808,6 +808,7 @@ t.jsonb "metadata", default: {} t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "decision", null: false t.index ["reviewable_type", "reviewable_id"], name: "index_quality_controls_on_reviewable" t.index ["reviewer_id"], name: "index_quality_controls_on_reviewer_id" end diff --git a/spec/controllers/admin/quality_controls_controller_spec.rb b/spec/controllers/admin/quality_controls_controller_spec.rb index 4f080f79f..a9a7b66e4 100644 --- a/spec/controllers/admin/quality_controls_controller_spec.rb +++ b/spec/controllers/admin/quality_controls_controller_spec.rb @@ -23,6 +23,7 @@ reviewable_id: observation.id, reviewable_type: "Observation", reviewer_id: admin.id, + decision: "Rejected", passed: false, comment: "Comment" } diff --git a/spec/factories/quality_controls.rb b/spec/factories/quality_controls.rb index 01a83f717..391c00cc4 100644 --- a/spec/factories/quality_controls.rb +++ b/spec/factories/quality_controls.rb @@ -14,11 +14,13 @@ # FactoryBot.define do factory :quality_control do - reviewable { create(:observation, validation_status: "QC2 in progress") } + reviewable { build(:observation, validation_status: "QC2 in progress") } reviewer { build(:admin) } passed { true } + decision { "Ready for publication" } trait :not_passed do + decision { "Rejected" } passed { false } comment { "Quality control not passed" } end diff --git a/spec/models/quality_control_spec.rb b/spec/models/quality_control_spec.rb index db438b44a..4f5b2de76 100644 --- a/spec/models/quality_control_spec.rb +++ b/spec/models/quality_control_spec.rb @@ -29,13 +29,8 @@ expect(subject).to have(1).error_on(:reviewer) end - it "should be invalid without passed" do - subject.passed = nil - expect(subject).to have(1).error_on(:passed) - end - it "should be invalid without a comment if not passed" do - subject.passed = false + subject = build(:quality_control, :not_passed, comment: nil) expect(subject).to have(1).error_on(:comment) end