-
Notifications
You must be signed in to change notification settings - Fork 24
Capture sector "Other" free-text as records; curate (promote/keep/dismiss) #1939
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
54c1109
e04a974
88dd079
ca7c6f2
175e393
302b47f
e6b1879
4069921
2175eee
796aa97
118aefe
b9f39de
49c1c21
39f3eb0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| class OtherResponsesController < ApplicationController | ||
| before_action :set_other_response, only: :update | ||
|
|
||
| # Review page: the same free-text "Other" value typed across many people, | ||
| # grouped so a curator can decide what to do. Sector "Other"s are bucketed | ||
| # together (they all promote into the one Sector catalog); every other question | ||
| # is grouped on its own, since its "Other"s are unrelated auxiliary data. | ||
| def index | ||
| authorize! | ||
| @status_filter = params[:status].presence_in(OtherResponse::VISIBLE_STATUSES) | ||
| statuses = @status_filter ? [ @status_filter ] : OtherResponse::VISIBLE_STATUSES | ||
| responses = OtherResponse.where(status: statuses).includes(:owner, :source_form_answer) | ||
|
|
||
| @groups = responses | ||
| .group_by { |response| [ response.group_key, response.normalized_text ] } | ||
| .map { |_key, rows| build_group(rows) } | ||
| .sort_by { |group| [ group[:promotable] ? 0 : 1, group[:question_label].downcase, -group[:count], group[:display_text].downcase ] } | ||
|
|
||
| @sectors = Sector.excluding_other.order(:name) | ||
| end | ||
|
|
||
| # Bulk keep/dismiss every visible person in a group, from the review queue. | ||
| # Keep leaves the value in place; dismiss hides it. | ||
| def curate | ||
| authorize! | ||
| status = params[:status] | ||
| unless %w[kept dismissed].include?(status) | ||
| return redirect_to other_responses_path, alert: "Choose keep or dismiss." | ||
| end | ||
|
|
||
| scope = group_scope.where(status: OtherResponse::VISIBLE_STATUSES) | ||
| count = scope.count | ||
| scope.find_each { |response| response.update!(status: status) } | ||
|
|
||
| verb = status == "kept" ? "Kept" : "Dismissed" | ||
| redirect_to other_responses_path(status: params[:return_status].presence), | ||
| status: :see_other, notice: "#{verb} #{count} response(s)." | ||
| end | ||
|
|
||
| # Curate a single response β the profile-edit "Γ" dismisses, and the review | ||
| # page can keep an individual person's response. | ||
| def update | ||
| authorize! @other_response | ||
| status = params.dig(:other_response, :status) | ||
| @other_response.update!(status: status) if OtherResponse::STATUSES.include?(status) | ||
|
|
||
| if params[:return_to] == "person_edit" | ||
| redirect_to edit_person_path(@other_response.owner), status: :see_other | ||
| else | ||
| redirect_to other_responses_path, status: :see_other | ||
| end | ||
| end | ||
|
|
||
| # Promote every non-dismissed person in a sector group into a real Sector tag β | ||
| # mapping to an existing sector or minting a new (published) one β and mark | ||
| # those responses promoted so they stop showing as free-text chips. Only sector | ||
| # groups are promotable, enforced by the .sectors scope. | ||
| def promote | ||
| authorize! | ||
| sector = target_sector | ||
| return redirect_to other_responses_path, alert: "Pick or name a sector to promote to." unless sector | ||
|
|
||
| responses = group_scope.sectors.promotable_now | ||
| responses.includes(:owner).find_each do |response| | ||
| # Reuse registration's tagging so the sector lands on the person AND the | ||
| # org(s) they registered with, always as an additional tag (never primary). | ||
| SectorTagging.apply(person: response.owner, organizations: response.registration_organizations, | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π€ From Claude: Update to my earlier note: promotion now tags the exact org from the registration ( |
||
| additional_ids: [ sector.id ]) | ||
| response.update!(status: "promoted", promotable: sector) | ||
| end | ||
|
|
||
| redirect_to other_responses_path, status: :see_other, | ||
| notice: "Promoted #{responses.size} response(s) to β#{sector.name}β." | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def set_other_response | ||
| @other_response = OtherResponse.find(params[:id]) | ||
| end | ||
|
|
||
| def build_group(rows) | ||
| first = rows.first | ||
| { | ||
| kind: first.kind, | ||
| field_identifier: first.field_identifier, | ||
| promotable: first.promotable?, | ||
| question_label: question_label_for(first), | ||
| display_text: first.text, | ||
| normalized_text: first.normalized_text, | ||
| anchor: first.review_anchor, | ||
| count: rows.size, | ||
| status_counts: rows.each_with_object(Hash.new(0)) { |r, h| h[r.status] += 1 } | ||
| } | ||
| end | ||
|
|
||
| def question_label_for(response) | ||
| case response.kind | ||
| when "sector" then "Sectors" | ||
| when "organization_type" then "Organization type" | ||
| else response.source_form_answer&.question_name_when_answered.presence || response.field_identifier.humanize | ||
| end | ||
| end | ||
|
|
||
| # The set of responses a curate/promote action targets, matching how the group | ||
| # was bucketed: captured kinds by kind, generic questions by field. | ||
| def group_scope | ||
| scope = OtherResponse.where(normalized_text: OtherResponse.normalize(params[:normalized_text])) | ||
| if params[:kind].present? && params[:kind] != "generic" | ||
| scope.where(kind: params[:kind]) | ||
| else | ||
| scope.where(field_identifier: params[:field_identifier]) | ||
| end | ||
| end | ||
|
|
||
| # The promote target: an existing sector by id, or find-or-create one from a | ||
| # typed name. Always published β a promoted sector is a real, public tag, even | ||
| # when the target already existed unpublished (a new name would default to | ||
| # unpublished, and an existing match keeps its state, so publish either way). | ||
| def target_sector | ||
| sector = | ||
| if params[:sector_id].present? | ||
| Sector.find_by(id: params[:sector_id]) | ||
| elsif params[:new_sector_name].present? | ||
| Sector.find_or_create_by!(name: params[:new_sector_name].strip) | ||
| end | ||
| return unless sector | ||
|
|
||
| sector.update!(published: true) unless sector.published? | ||
| sector | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| class OtherResponse < ApplicationRecord | ||
| # A free-text "Other" someone typed on a form question whose "Other" can (or | ||
| # will) become a real tag. Captured at submission time so a curator can | ||
| # promote / keep / dismiss it. The `owner` is polymorphic because the answer's | ||
| # subject differs by question: a sector "Other" is about the person, an | ||
| # organization-type "Other" is about their organization. | ||
| # | ||
| # - `sector` β owned by a Person; promotable into a `Sector`, shown | ||
| # on the person's profile beside the sector tags. | ||
| # - `organization_type` β owned by an Organization; stored now, but not yet | ||
| # promotable (its promote button is hidden until | ||
| # `OrganizationType` is a model). | ||
| # | ||
| # Questions whose "Other" will never become a tag (`generic`) are not captured | ||
| # at all β that data stays searchable in the form answers. | ||
| KINDS = %w[sector organization_type generic].freeze | ||
|
|
||
| # Kinds we materialize as records (the rest stay in the form answers). | ||
| CAPTURED_KINDS = %w[sector organization_type].freeze | ||
|
|
||
| # Kinds captured against a Person, via their form submission. | ||
| PERSON_KINDS = %w[sector].freeze | ||
|
|
||
| # Kinds that can be promoted into a real tag today. | ||
| PROMOTABLE_KINDS = %w[sector].freeze | ||
|
|
||
| # The organization-type question. Its "Other" is org-owned (see | ||
| # PublicRegistration#sync_agency_type). | ||
| ORGANIZATION_TYPE_FIELD_IDENTIFIER = "agency_type" | ||
|
|
||
| # A response starts life as `pending` (awaiting a curator's decision) and is | ||
| # then either promoted into a real tag, kept as auxiliary data, or dismissed. | ||
| STATUSES = %w[pending kept promoted dismissed].freeze | ||
|
|
||
| # Statuses that still surface (as an "(other)" chip for sectors, or a live row | ||
| # in the review queue). | ||
| VISIBLE_STATUSES = %w[pending kept].freeze | ||
|
|
||
| belongs_to :owner, polymorphic: true | ||
| belongs_to :promotable, polymorphic: true, optional: true | ||
| belongs_to :source_form_answer, class_name: "FormAnswer", optional: true | ||
|
|
||
| before_validation :set_kind | ||
| before_validation :set_normalized_text | ||
|
|
||
| validates :field_identifier, presence: true | ||
| validates :text, presence: true | ||
| validates :kind, inclusion: { in: KINDS } | ||
| validates :status, inclusion: { in: STATUSES } | ||
| validates :normalized_text, uniqueness: { scope: [ :owner_type, :owner_id, :field_identifier ] } | ||
|
|
||
| scope :sectors, -> { where(kind: "sector") } | ||
| scope :visible, -> { where(status: VISIBLE_STATUSES) } | ||
| scope :pending, -> { where(status: "pending") } | ||
| scope :promotable_now, -> { where.not(status: "dismissed") } | ||
|
|
||
| # The coarse category (and thus owner + promotability) for a question. | ||
| def self.kind_for(field_identifier) | ||
| identifier = field_identifier.to_s | ||
| if identifier.in?(FormField::SECTOR_FIELD_IDENTIFIERS) | ||
| "sector" | ||
| elsif identifier == ORGANIZATION_TYPE_FIELD_IDENTIFIER | ||
| "organization_type" | ||
| else | ||
| "generic" | ||
| end | ||
| end | ||
|
|
||
| # Case/whitespace-insensitive key used both for the unique index and for | ||
| # grouping the same typed value across owners on the review page. | ||
| def self.normalize(value) | ||
| value.to_s.strip.downcase | ||
| end | ||
|
|
||
| # Stable DOM id for a review-page group, so a chip can deep-link to its row. | ||
| def self.review_anchor(bucket, normalized_text) | ||
| "other-#{bucket}-#{normalized_text}".parameterize | ||
| end | ||
|
|
||
| def promotable? | ||
| kind.in?(PROMOTABLE_KINDS) | ||
| end | ||
|
|
||
| # The organization(s) the person registered with when they typed this response, | ||
| # derived from the source form answer's submission + the person's registration | ||
| # for that event. Lets promotion mirror a sector onto exactly the orgs a | ||
| # registration would have β no need to store the org here. Empty when there's no | ||
| # registration context (no source answer, or a non-person owner). | ||
| def registration_organizations | ||
| event = source_form_answer&.form_submission&.event | ||
| return Organization.none unless event && owner.is_a?(Person) | ||
|
|
||
| owner.event_registrations.find_by(event: event)&.organizations || Organization.none | ||
| end | ||
|
|
||
| # How the review page buckets this response: captured kinds group by kind (all | ||
| # sector "Other"s together, all org-type together); generic groups by question. | ||
| def group_key | ||
| kind == "generic" ? field_identifier : kind | ||
| end | ||
|
|
||
| def review_anchor | ||
| self.class.review_anchor(group_key, normalized_text) | ||
| end | ||
|
|
||
| def dismiss! | ||
| update!(status: "dismissed") | ||
| end | ||
|
|
||
| def keep! | ||
| update!(status: "kept") | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def set_kind | ||
| self.kind = self.class.kind_for(field_identifier) if field_identifier.present? | ||
| end | ||
|
|
||
| def set_normalized_text | ||
| self.normalized_text = self.class.normalize(text) | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| class OtherResponsePolicy < ApplicationPolicy | ||
| # Reviewing and curating free-text "Other" responses (promote/keep/dismiss) is | ||
| # an admin-only task. Defined explicitly rather than leaning on the inherited | ||
| # manage? fallback, matching how the other policies spell out their rules. | ||
|
|
||
| def index? | ||
| admin? | ||
| end | ||
|
|
||
| def update? | ||
| admin? | ||
| end | ||
|
|
||
| def curate? | ||
| admin? | ||
| end | ||
|
|
||
| def promote? | ||
| admin? | ||
| end | ||
| end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
π€ From Claude: Bulk keep/dismiss acts on the whole grouped value (all
VISIBLE_STATUSESrows for thisnormalized_text), mirroring howpromotefans out across everyone who typed it.return_statusround-trips the active filter so the queue stays put after the action.