diff --git a/app/assets/images/stats_booth.png b/app/assets/images/stats_booth.png new file mode 100644 index 000000000..3231a75c8 Binary files /dev/null and b/app/assets/images/stats_booth.png differ diff --git a/app/assets/images/stats_investments.png b/app/assets/images/stats_investments.png new file mode 100644 index 000000000..951dfe393 Binary files /dev/null and b/app/assets/images/stats_investments.png differ diff --git a/app/assets/images/stats_mail.png b/app/assets/images/stats_mail.png new file mode 100644 index 000000000..905f32696 Binary files /dev/null and b/app/assets/images/stats_mail.png differ diff --git a/app/assets/images/stats_man.png b/app/assets/images/stats_man.png new file mode 100644 index 000000000..a1a0d028a Binary files /dev/null and b/app/assets/images/stats_man.png differ diff --git a/app/assets/images/stats_users.png b/app/assets/images/stats_users.png new file mode 100644 index 000000000..d314a7587 Binary files /dev/null and b/app/assets/images/stats_users.png differ diff --git a/app/assets/images/stats_web.png b/app/assets/images/stats_web.png new file mode 100644 index 000000000..18a9523f2 Binary files /dev/null and b/app/assets/images/stats_web.png differ diff --git a/app/assets/images/stats_woman.png b/app/assets/images/stats_woman.png new file mode 100644 index 000000000..6f977d8b7 Binary files /dev/null and b/app/assets/images/stats_woman.png differ diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index f8903dd30..dbfcfea65 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -27,7 +27,7 @@ //= require app //= require check_all_none //= require comments -//= require dropdown +//= require foundation_extras //= require ie_alert //= require location_changer //= require moderator_comment @@ -93,7 +93,7 @@ var initialize_modules = function() { App.Votes.initialize(); App.AllowParticipation.initialize(); App.Tags.initialize(); - App.Dropdown.initialize(); + App.FoundationExtras.initialize(); App.LocationChanger.initialize(); App.CheckAllNone.initialize(); App.PreventDoubleSubmission.initialize(); diff --git a/app/assets/javascripts/dropdown.js.coffee b/app/assets/javascripts/dropdown.js.coffee deleted file mode 100644 index fafa53542..000000000 --- a/app/assets/javascripts/dropdown.js.coffee +++ /dev/null @@ -1,4 +0,0 @@ -App.Dropdown = - - initialize: -> - $(document).foundation() diff --git a/app/assets/javascripts/foundation_extras.js.coffee b/app/assets/javascripts/foundation_extras.js.coffee new file mode 100644 index 000000000..720b62047 --- /dev/null +++ b/app/assets/javascripts/foundation_extras.js.coffee @@ -0,0 +1,5 @@ +App.FoundationExtras = + + initialize: -> + $(document).foundation() + $(window).trigger "load.zf.sticky" diff --git a/app/assets/stylesheets/_consul_settings.scss b/app/assets/stylesheets/_consul_settings.scss index 7ffd8ae4f..1275efcfc 100644 --- a/app/assets/stylesheets/_consul_settings.scss +++ b/app/assets/stylesheets/_consul_settings.scss @@ -119,3 +119,6 @@ $tab-content-border: $border; $orbit-bullet-diameter: 0.8rem; $pagination-radius: $global-radius; + +$show-header-for-stacked: true; + diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 85a9ed041..ad1a0dd0b 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -12,6 +12,7 @@ @import "legislation"; @import "legislation_process"; @import "community"; +@import "stats"; @import "custom"; @import "c3"; @import "annotator.min"; diff --git a/app/assets/stylesheets/participation.scss b/app/assets/stylesheets/participation.scss index 2e35b8256..a9b4aca69 100644 --- a/app/assets/stylesheets/participation.scss +++ b/app/assets/stylesheets/participation.scss @@ -1988,15 +1988,8 @@ .polls-results-stats { .sidebar { - border-bottom: 1px solid $border; - margin-bottom: $line-height; - @include breakpoint(medium) { - border-bottom: 0; - border-right: 1px solid $border; - } - - .menu { + .menu:not(.vertical) { padding: 0; li a { @@ -2030,10 +2023,4 @@ } } } - - .number { - font-size: rem-calc(60); - font-weight: bold; - line-height: rem-calc(60); - } } diff --git a/app/assets/stylesheets/stats.scss b/app/assets/stylesheets/stats.scss new file mode 100644 index 000000000..61b86e1dd --- /dev/null +++ b/app/assets/stylesheets/stats.scss @@ -0,0 +1,215 @@ +.participation-stats { + + @include breakpoint(medium) { + + .stats-content { + border-left: 1px solid $border; + } + } + + .sidebar { + border-bottom: 1px solid $border; + margin-bottom: $line-height; + + @include breakpoint(medium) { + border-bottom: 0; + } + + ul { + margin-left: $line-height / 2; + margin-top: 0; + padding: 0; + } + } + + .section-title { + font-size: rem-calc(24); + border-bottom: 1px solid $border; + } + + .stats-group { + margin-bottom: $line-height; + margin-top: $line-height; + } + + .number-with-info { + display: inline-block; + + &::before { + display: inline-block; + margin-right: $line-height / 2; + vertical-align: top; + } + + &.total-participants::before { + content: image-url("stats_users.png"); + } + + &.male::before { + content: image-url("stats_man.png"); + } + + &.female::before { + content: image-url("stats_woman.png"); + } + + &.total-investments::before { + content: image-url("stats_investments.png"); + } + + &.web::before { + content: image-url("stats_web.png"); + } + + &.booth::before { + content: image-url("stats_booth.png"); + } + + &.mail::before { + content: image-url("stats_mail.png"); + } + + .content { + display: inline-block; + margin-right: $line-height * 2; + } + + .number { + display: block; + font-size: rem-calc(36); + font-weight: bold; + line-height: rem-calc(36); + } + + .info { + color: $text-medium; + font-size: $small-font-size; + font-style: italic; + } + + &.total-participants { + + .number { + font-size: rem-calc(60); + line-height: rem-calc(60); + } + } + } + + .progress { + background: #ebf0f4; + border-radius: rem-calc(20); + height: $line-height / 2; + } + + .progress-meter { + background: #52a4ee; + border-bottom-left-radius: rem-calc(20); + border-top-left-radius: rem-calc(20); + } + + .gender-and-channel { + + thead, + tbody { + border: 0; + } + + thead, + tr, + th { + background-color: transparent; + } + + td, + th { + text-align: center; + text-transform: uppercase; + + &::before { + display: inline-block; + transform: scale(0.6); + vertical-align: middle; + } + } + + th { + font-weight: bold; + font-size: rem-calc(21); + + &.gender { + opacity: 0; + } + + &.web::before { + content: image-url("stats_web.png"); + } + + &.booth::before { + content: image-url("stats_booth.png"); + } + + &.mail::before { + content: image-url("stats_mail.png"); + } + } + + td { + + &.gender { + font-weight: bold; + font-size: rem-calc(21); + text-align: left; + } + + &.male::before { + content: image-url("stats_man.png"); + } + + &.female::before { + content: image-url("stats_woman.png"); + } + } + } +} + +table { + + &.survey-districts { + + @include breakpoint(medium down) { + thead { + font-size: rem-calc(12); + + th { + width: 25%; + word-break: break-all; + } + } + } + + tr th { + border: 1px solid $border; + text-align: center; + } + + .phase-subheader { + font-size: rem-calc(11); + font-weight: normal; + } + + .success { + background: #c1fdd2; + } + + .border-left-success { + background: #c1fdd2; + border-left: 2px solid #58ff87; + } + + .border-right-success { + background: #c1fdd2; + border-right: 2px solid #58ff87; + } + } +} diff --git a/app/controllers/budgets/stats_controller.rb b/app/controllers/budgets/stats_controller.rb index 080bf0ef9..d8ac1e267 100644 --- a/app/controllers/budgets/stats_controller.rb +++ b/app/controllers/budgets/stats_controller.rb @@ -6,16 +6,12 @@ module Budgets def show authorize! :read_stats, @budget - @stats = load_stats + @stats = Budget::Stats.new(@budget) @headings = @budget.headings.sort_by_name end private - def load_stats - Budget::Stats.new(@budget).generate - end - def load_budget @budget = Budget.find_by(slug: params[:budget_id]) || Budget.find_by(id: params[:budget_id]) end diff --git a/app/controllers/polls_controller.rb b/app/controllers/polls_controller.rb index 790045b0f..60e1e4eff 100644 --- a/app/controllers/polls_controller.rb +++ b/app/controllers/polls_controller.rb @@ -33,7 +33,7 @@ class PollsController < ApplicationController end def stats - @stats = Poll::Stats.new(@poll).generate + @stats = Poll::Stats.new(@poll) end def results diff --git a/app/helpers/stats_helper.rb b/app/helpers/stats_helper.rb index 1bacf1139..d321bce0b 100644 --- a/app/helpers/stats_helper.rb +++ b/app/helpers/stats_helper.rb @@ -30,4 +30,16 @@ module StatsHelper opt[:data][:graph] = admin_api_stats_path(budget_investments: true) content_tag :div, "", opt end + + def number_to_stats_percentage(number, options = {}) + number_to_percentage(number, { strip_insignificant_zeros: true, precision: 2 }.merge(options)) + end + + def number_with_info_tags(number, text, html_class: "") + content_tag :p, class: "number-with-info #{html_class}".strip do + content_tag :span, class: "content" do + content_tag(:span, number, class: "number") + content_tag(:span, text, class: "info") + end + end + end end diff --git a/app/models/abilities/administrator.rb b/app/models/abilities/administrator.rb index 9f8c7e965..a72997dbf 100644 --- a/app/models/abilities/administrator.rb +++ b/app/models/abilities/administrator.rb @@ -66,7 +66,6 @@ module Abilities can [:hide, :update, :toggle_selection], Budget::Investment can [:valuate, :comment_valuation], Budget::Investment can :create, Budget::ValuatorAssignment - can :read_stats, Budget, phase: "reviewing_ballots" can [:search, :edit, :update, :create, :index, :destroy], Banner diff --git a/app/models/abilities/everyone.rb b/app/models/abilities/everyone.rb index 626be1c0a..46dc19585 100644 --- a/app/models/abilities/everyone.rb +++ b/app/models/abilities/everyone.rb @@ -21,7 +21,7 @@ module Abilities can [:read], Budget::Group can [:read, :print, :json_data], Budget::Investment can [:read_results, :read_executions], Budget, phase: "finished" - can :read_stats, Budget, phase: "finished" + can(:read_stats, Budget) { |budget| budget.valuating_or_later? } can :new, DirectMessage can [:read, :debate, :draft_publication, :allegations, :result_publication, :proposals, :milestones], Legislation::Process, published: true diff --git a/app/models/budget.rb b/app/models/budget.rb index 8cc5c9b8f..f11c378ff 100644 --- a/app/models/budget.rb +++ b/app/models/budget.rb @@ -2,6 +2,7 @@ class Budget < ApplicationRecord include Measurable include Sluggable + include StatsVersionable translates :name, touch: true include Globalizable @@ -125,8 +126,12 @@ class Budget < ApplicationRecord Budget::Phase::PUBLISHED_PRICES_PHASES.include?(phase) end + def valuating_or_later? + current_phase&.valuating_or_later? + end + def publishing_prices_or_later? - publishing_prices? || balloting_or_later? + current_phase&.publishing_prices_or_later? end def balloting_process? @@ -134,7 +139,7 @@ class Budget < ApplicationRecord end def balloting_or_later? - balloting_process? || finished? + current_phase&.balloting_or_later? end def heading_price(heading) diff --git a/app/models/budget/phase.rb b/app/models/budget/phase.rb index 5912a1453..125368ccd 100644 --- a/app/models/budget/phase.rb +++ b/app/models/budget/phase.rb @@ -28,16 +28,10 @@ class Budget scope :enabled, -> { where(enabled: true) } scope :published, -> { enabled.where.not(kind: "drafting") } - scope :drafting, -> { find_by_kind("drafting") } - scope :informing, -> { find_by_kind("informing") } - scope :accepting, -> { find_by_kind("accepting")} - scope :reviewing, -> { find_by_kind("reviewing")} - scope :selecting, -> { find_by_kind("selecting")} - scope :valuating, -> { find_by_kind("valuating")} - scope :publishing_prices, -> { find_by_kind("publishing_prices")} - scope :balloting, -> { find_by_kind("balloting")} - scope :reviewing_ballots, -> { find_by_kind("reviewing_ballots")} - scope :finished, -> { find_by_kind("finished")} + + PHASE_KINDS.each do |phase| + define_singleton_method(phase) { find_by_kind(phase) } + end def next_enabled_phase next_phase&.enabled? ? next_phase : next_phase&.next_enabled_phase @@ -53,42 +47,58 @@ class Budget end end + def valuating_or_later? + in_phase_or_later?("valuating") + end + + def publishing_prices_or_later? + in_phase_or_later?("publishing_prices") + end + + def balloting_or_later? + in_phase_or_later?("balloting") + end + private - def adjust_date_ranges - if enabled? - next_enabled_phase&.update_column(:starts_at, ends_at) - prev_enabled_phase&.update_column(:ends_at, starts_at) - elsif enabled_changed? - next_enabled_phase&.update_column(:starts_at, starts_at) - end - end - - def touch_budget - budget.touch - end - - def prev_phase_dates_valid? - if enabled? && starts_at.present? && prev_enabled_phase.present? - prev_enabled_phase.assign_attributes(ends_at: starts_at) - if prev_enabled_phase.invalid_dates_range? - phase_name = I18n.t("budgets.phase.#{prev_enabled_phase.kind}") - error = I18n.t("budgets.phases.errors.prev_phase_dates_invalid", phase_name: phase_name) - errors.add(:starts_at, error) + def adjust_date_ranges + if enabled? + next_enabled_phase&.update_column(:starts_at, ends_at) + prev_enabled_phase&.update_column(:ends_at, starts_at) + elsif enabled_changed? + next_enabled_phase&.update_column(:starts_at, starts_at) end end - end - def next_phase_dates_valid? - if enabled? && ends_at.present? && next_enabled_phase.present? - next_enabled_phase.assign_attributes(starts_at: ends_at) - if next_enabled_phase.invalid_dates_range? - phase_name = I18n.t("budgets.phase.#{next_enabled_phase.kind}") - error = I18n.t("budgets.phases.errors.next_phase_dates_invalid", phase_name: phase_name) - errors.add(:ends_at, error) + def touch_budget + budget.touch + end + + def prev_phase_dates_valid? + if enabled? && starts_at.present? && prev_enabled_phase.present? + prev_enabled_phase.assign_attributes(ends_at: starts_at) + if prev_enabled_phase.invalid_dates_range? + phase_name = I18n.t("budgets.phase.#{prev_enabled_phase.kind}") + error = I18n.t("budgets.phases.errors.prev_phase_dates_invalid", phase_name: phase_name) + errors.add(:starts_at, error) + end end end - end + + def next_phase_dates_valid? + if enabled? && ends_at.present? && next_enabled_phase.present? + next_enabled_phase.assign_attributes(starts_at: ends_at) + if next_enabled_phase.invalid_dates_range? + phase_name = I18n.t("budgets.phase.#{next_enabled_phase.kind}") + error = I18n.t("budgets.phases.errors.next_phase_dates_invalid", phase_name: phase_name) + errors.add(:ends_at, error) + end + end + end + + def in_phase_or_later?(phase) + PHASE_KINDS.index(kind) >= PHASE_KINDS.index(phase) + end end end diff --git a/app/models/budget/stats.rb b/app/models/budget/stats.rb index 24ba6fafc..cd9bff741 100644 --- a/app/models/budget/stats.rb +++ b/app/models/budget/stats.rb @@ -3,116 +3,145 @@ class Budget::Stats alias_method :budget, :resource def self.stats_methods - super + - %i[total_participants_support_phase total_participants_vote_phase - total_budget_investments total_votes total_selected_investments - total_unfeasible_investments total_supports headings - total_participants_web total_participants_booths] + super + support_phase_methods + vote_phase_methods + end + + def self.support_phase_methods + %i[total_participants_support_phase total_budget_investments + total_selected_investments total_unfeasible_investments headings] + end + + def self.vote_phase_methods + %i[total_votes total_participants_vote_phase] + end + + def stats_methods + base_stats_methods + participation_methods + phase_methods + end + + def phases + %w[support vote].select { |phase| send("#{phase}_phase_finished?") } + end + + def all_phases + return phases unless phases.many? + + [*phases, "every"] + end + + def support_phase_finished? + budget.valuating_or_later? + end + + def vote_phase_finished? + budget.finished? + end + + def total_participants + participants.distinct.count + end + + def total_participants_support_phase + voters.count + end + + def total_participants_vote_phase + (balloters + poll_ballot_voters).uniq.count + end + + def total_budget_investments + budget.investments.count + end + + def total_votes + budget.ballots.pluck(:ballot_lines_count).inject(0) { |sum, x| sum + x } + end + + def total_selected_investments + budget.investments.selected.count + end + + def total_unfeasible_investments + budget.investments.unfeasible.count + end + + def headings + groups = Hash.new(0) + budget.headings.order("id ASC").each do |heading| + groups[heading.id] = Hash.new(0).merge(calculate_heading_totals(heading)) + end + + groups[:total] = Hash.new(0) + groups[:total][:total_investments_count] = groups.collect {|_k, v| v[:total_investments_count]}.sum + groups[:total][:total_participants_support_phase] = groups.collect {|_k, v| v[:total_participants_support_phase]}.sum + groups[:total][:total_participants_vote_phase] = groups.collect {|_k, v| v[:total_participants_vote_phase]}.sum + groups[:total][:total_participants_every_phase] = groups.collect {|_k, v| v[:total_participants_every_phase]}.sum + + budget.headings.each do |heading| + groups[heading.id].merge!(calculate_heading_stats_with_totals(groups[heading.id], groups[:total], heading.population)) + end + + groups[:total][:percentage_participants_support_phase] = groups.collect {|_k, v| v[:percentage_participants_support_phase]}.sum + groups[:total][:percentage_participants_vote_phase] = groups.collect {|_k, v| v[:percentage_participants_vote_phase]}.sum + groups[:total][:percentage_participants_every_phase] = groups.collect {|_k, v| v[:percentage_participants_every_phase]}.sum + + groups end private - def total_participants - participants.distinct.count + def phase_methods + phases.map { |phase| self.class.send("#{phase}_phase_methods") }.flatten end - def total_participants_support_phase - voters.uniq.count + def participant_ids + phases.map { |phase| send("participant_ids_#{phase}_phase") }.flatten.uniq end - def total_participants_web - (balloters - poll_ballot_voters).uniq.compact.count + def participant_ids_support_phase + (authors + voters).uniq end - def total_participants_booths - poll_ballot_voters.uniq.count - end - - def total_participants_vote_phase - balloters.uniq.count - end - - def total_budget_investments - budget.investments.count - end - - def total_votes - budget.ballots.pluck(:ballot_lines_count).inject(0) { |sum, x| sum + x } - end - - def total_selected_investments - budget.investments.selected.count - end - - def total_unfeasible_investments - budget.investments.unfeasible.count - end - - def total_supports - supports(budget).count - end - - def participants - User.where(id: (authors + voters + balloters + poll_ballot_voters).uniq.compact) + def participant_ids_vote_phase + (balloters + poll_ballot_voters).uniq end def authors - budget.investments.pluck(:author_id) + @authors ||= budget.investments.pluck(:author_id) end def voters - supports(budget).pluck(:voter_id) + @voters ||= supports(budget).distinct.pluck(:voter_id) end def balloters - budget.ballots.where("ballot_lines_count > ?", 0).pluck(:user_id) + @balloters ||= budget.ballots.where("ballot_lines_count > ?", 0).distinct.pluck(:user_id).compact end def poll_ballot_voters - budget&.poll ? budget.poll.voters.pluck(:user_id) : [] + @poll_ballot_voters ||= budget.poll ? budget.poll.voters.pluck(:user_id) : [] end def balloters_by_heading(heading_id) stats_cache("balloters_by_heading_#{heading_id}") do - budget.ballots.joins(:lines).where(budget_ballot_lines: {heading_id: heading_id}).pluck(:user_id) + budget.ballots.joins(:lines) + .where(budget_ballot_lines: { heading_id: heading_id} ) + .distinct.pluck(:user_id) end end def voters_by_heading(heading) stats_cache("voters_by_heading_#{heading.id}") do - supports(heading).pluck(:voter_id) + supports(heading).distinct.pluck(:voter_id) end end - def headings - groups = Hash.new(0) - budget.headings.order("id ASC").each do |heading| - groups[heading.id] = Hash.new(0).merge(calculate_heading_totals(heading)) - end - - groups[:total] = Hash.new(0) - groups[:total][:total_investments_count] = groups.collect {|_k, v| v[:total_investments_count]}.sum - groups[:total][:total_participants_support_phase] = groups.collect {|_k, v| v[:total_participants_support_phase]}.sum - groups[:total][:total_participants_vote_phase] = groups.collect {|_k, v| v[:total_participants_vote_phase]}.sum - groups[:total][:total_participants_all_phase] = groups.collect {|_k, v| v[:total_participants_all_phase]}.sum - - budget.headings.each do |heading| - groups[heading.id].merge!(calculate_heading_stats_with_totals(groups[heading.id], groups[:total], heading.population)) - end - - groups[:total][:percentage_participants_support_phase] = groups.collect {|_k, v| v[:percentage_participants_support_phase]}.sum - groups[:total][:percentage_participants_vote_phase] = groups.collect {|_k, v| v[:percentage_participants_vote_phase]}.sum - groups[:total][:percentage_participants_all_phase] = groups.collect {|_k, v| v[:percentage_participants_all_phase]}.sum - - groups - end - def calculate_heading_totals(heading) { total_investments_count: heading.investments.count, - total_participants_support_phase: voters_by_heading(heading).uniq.count, - total_participants_vote_phase: balloters_by_heading(heading.id).uniq.count, - total_participants_all_phase: voters_and_balloters_by_heading(heading) + total_participants_support_phase: voters_by_heading(heading).count, + total_participants_vote_phase: balloters_by_heading(heading.id).count, + total_participants_every_phase: voters_and_balloters_by_heading(heading) } end @@ -122,8 +151,8 @@ class Budget::Stats percentage_district_population_support_phase: population_percent(population, heading_totals[:total_participants_support_phase]), percentage_participants_vote_phase: participants_percent(heading_totals, groups_totals, :total_participants_vote_phase), percentage_district_population_vote_phase: population_percent(population, heading_totals[:total_participants_vote_phase]), - percentage_participants_all_phase: participants_percent(heading_totals, groups_totals, :total_participants_all_phase), - percentage_district_population_all_phase: population_percent(population, heading_totals[:total_participants_all_phase]) + percentage_participants_every_phase: participants_percent(heading_totals, groups_totals, :total_participants_every_phase), + percentage_district_population_every_phase: population_percent(population, heading_totals[:total_participants_every_phase]) } end @@ -141,14 +170,12 @@ class Budget::Stats end def supports(supportable) - ActsAsVotable::Vote.where(votable_type: "Budget::Investment", votable_id: supportable.investments.pluck(:id)) + Vote.where(votable: supportable.investments) end stats_cache(*stats_methods) - stats_cache :total_participants_with_gender - stats_cache :voters, :participants, :authors, :balloters, :poll_ballot_voters def stats_cache(key, &block) - Rails.cache.fetch("budgets_stats/#{budget.id}/#{key}/v10", &block) + Rails.cache.fetch("budgets_stats/#{budget.id}/#{phases.join}/#{key}/#{version}", &block) end end diff --git a/app/models/concerns/statisticable.rb b/app/models/concerns/statisticable.rb index 6548df8c6..acb5d00c9 100644 --- a/app/models/concerns/statisticable.rb +++ b/app/models/concerns/statisticable.rb @@ -1,79 +1,175 @@ module Statisticable extend ActiveSupport::Concern + PARTICIPATIONS = %w[gender age geozone] included do attr_reader :resource - - def initialize(resource) - @resource = resource - end - - def generate - self.class.stats_methods.map { |stat_name| [stat_name, send(stat_name)] }.to_h - end - - private - - def total_participants_with_gender - participants.where.not(gender: nil).distinct.count - end - - def total_male_participants - participants.where(gender: "male").count - end - - def total_female_participants - participants.where(gender: "female").count - end - - def total_unknown_gender_or_age - participants.where("gender IS NULL OR date_of_birth is NULL").uniq.count - end - - def male_percentage - calculate_percentage(total_male_participants, total_participants_with_gender) - end - - def female_percentage - calculate_percentage(total_female_participants, total_participants_with_gender) - end - - def age_groups - groups = Hash.new(0) - ["16 - 19", - "20 - 24", - "25 - 29", - "30 - 34", - "35 - 39", - "40 - 44", - "45 - 49", - "50 - 54", - "55 - 59", - "60 - 64", - "65 - 69", - "70 - 140"].each do |group| - start, finish = group.split(" - ") - group_name = (group == "70 - 140" ? "+ 70" : group) - groups[group_name] = User.where(id: participants) - .where("date_of_birth > ? AND date_of_birth < ?", - finish.to_i.years.ago.beginning_of_year, - start.to_i.years.ago.end_of_year).count - end - groups - end - - def calculate_percentage(fraction, total) - return 0.0 if total.zero? - - (fraction * 100.0 / total).round(3) - end end + def initialize(resource) + @resource = resource + end + + def generate + stats_methods.each { |stat_name| send(stat_name) } + end + + def stats_methods + base_stats_methods + participation_methods + end + + def participations + PARTICIPATIONS.select { |participation| send("#{participation}?") } + end + + def gender? + participants.male.any? || participants.female.any? + end + + def age? + participants.between_ages(age_groups.flatten.min, age_groups.flatten.max).any? + end + + def geozone? + participants.where(geozone: geozones).any? + end + + def participants + @participants ||= User.unscoped.where(id: participant_ids) + end + + def total_male_participants + participants.male.count + end + + def total_female_participants + participants.female.count + end + + def total_no_demographic_data + participants.where("gender IS NULL OR date_of_birth IS NULL OR geozone_id IS NULL").count + end + + def male_percentage + calculate_percentage(total_male_participants, total_participants_with_gender) + end + + def female_percentage + calculate_percentage(total_female_participants, total_participants_with_gender) + end + + def participants_by_age + age_groups.map do |start, finish| + count = participants.between_ages(start, finish).count + + [ + "#{start} - #{finish}", + { + range: range_description(start, finish), + count: count, + percentage: calculate_percentage(count, total_participants) + } + ] + end.to_h + end + + def participants_by_geozone + geozone_stats.map do |stats| + [ + stats.name, + { + count: stats.count, + percentage: stats.percentage + } + ] + end.to_h + end + + def calculate_percentage(fraction, total) + PercentageCalculator.calculate(fraction, total) + end + + def version + "v#{resource.find_or_create_stats_version.updated_at.to_i}" + end + + private + + def base_stats_methods + self.class.base_stats_methods + end + + def participation_methods + participations.map { |participation| self.class.send("#{participation}_methods") }.flatten + end + + def total_participants_with_gender + @total_participants_with_gender ||= participants.where.not(gender: nil).distinct.count + end + + def age_groups + [[16, 19], + [20, 24], + [25, 29], + [30, 34], + [35, 39], + [40, 44], + [45, 49], + [50, 54], + [55, 59], + [60, 64], + [65, 69], + [70, 74], + [75, 79], + [80, 84], + [85, 89], + [90, 300] + ] + end + + def participants_between_ages(from, to) + participants.between_ages(from, to) + end + + def geozones + Geozone.all.order("name") + end + + def geozone_stats + geozones.map { |geozone| GeozoneStats.new(geozone, participants) } + end + + def range_description(start, finish) + if finish > 200 + I18n.t("stats.age_more_than", start: start) + else + I18n.t("stats.age_range", start: start, finish: finish) + end + end + class_methods do def stats_methods - %i[total_participants total_male_participants - total_female_participants total_unknown_gender_or_age - male_percentage female_percentage age_groups] + base_stats_methods + gender_methods + age_methods + geozone_methods + end + + def base_stats_methods + %i[total_participants participations] + participation_check_methods + end + + def participation_check_methods + PARTICIPATIONS.map { |participation| :"#{participation}?" } + end + + def gender_methods + %i[total_male_participants total_female_participants male_percentage female_percentage] + end + + def age_methods + [:participants_by_age] + end + + def geozone_methods + %i[participants_by_geozone total_no_demographic_data] end def stats_cache(*method_names) diff --git a/app/models/concerns/stats_versionable.rb b/app/models/concerns/stats_versionable.rb new file mode 100644 index 000000000..5f1d94ffd --- /dev/null +++ b/app/models/concerns/stats_versionable.rb @@ -0,0 +1,11 @@ +module StatsVersionable + extend ActiveSupport::Concern + + included do + has_one :stats_version, as: :process + end + + def find_or_create_stats_version + stats_version || create_stats_version + end +end diff --git a/app/models/poll.rb b/app/models/poll.rb index 6c70dd126..4484a4589 100644 --- a/app/models/poll.rb +++ b/app/models/poll.rb @@ -6,6 +6,7 @@ class Poll < ApplicationRecord include ActsAsParanoidAliases include Notifiable include Sluggable + include StatsVersionable translates :name, touch: true translates :summary, touch: true diff --git a/app/models/poll/stats.rb b/app/models/poll/stats.rb index ebba64b35..d7e0031da 100644 --- a/app/models/poll/stats.rb +++ b/app/models/poll/stats.rb @@ -2,122 +2,123 @@ class Poll::Stats include Statisticable alias_method :poll, :resource + CHANNELS = Poll::Voter::VALID_ORIGINS + def self.stats_methods - %i[total_participants total_participants_web total_web_valid total_web_white total_web_null - total_participants_booth total_booth_valid total_booth_white total_booth_null - total_valid_votes total_white_votes total_null_votes valid_percentage_web valid_percentage_booth - total_valid_percentage white_percentage_web white_percentage_booth total_white_percentage - null_percentage_web null_percentage_booth total_null_percentage total_participants_web_percentage - total_participants_booth_percentage] + super + + %i[total_valid_votes total_white_votes total_null_votes + total_participants_web total_web_valid total_web_white total_web_null + total_participants_booth total_booth_valid total_booth_white total_booth_null + total_participants_letter total_letter_valid total_letter_white total_letter_null + total_participants_web_percentage total_participants_booth_percentage + total_participants_letter_percentage + valid_percentage_web valid_percentage_booth valid_percentage_letter total_valid_percentage + white_percentage_web white_percentage_booth white_percentage_letter total_white_percentage + null_percentage_web null_percentage_booth null_percentage_letter total_null_percentage] + end + + def total_participants + total_participants_web + total_participants_booth + end + + def channels + CHANNELS.select { |channel| send(:"total_participants_#{channel}") > 0 } + end + + CHANNELS.each do |channel| + define_method :"total_participants_#{channel}" do + send(:"total_#{channel}_valid") + + send(:"total_#{channel}_white") + + send(:"total_#{channel}_null") + end + + define_method :"total_participants_#{channel}_percentage" do + calculate_percentage(send(:"total_participants_#{channel}"), total_participants) + end + end + + def total_web_valid + voters.where(origin: "web").count - total_web_white + end + + def total_web_white + 0 + end + + def total_web_null + 0 + end + + def total_booth_valid + recounts.sum(:total_amount) + end + + def total_booth_white + recounts.sum(:white_amount) + end + + def total_booth_null + recounts.sum(:null_amount) + end + + def total_letter_valid + voters.where(origin: "letter").count # TODO: count only valid votes + end + + def total_letter_white + 0 # TODO + end + + def total_letter_null + 0 # TODO + end + + %i[valid white null].each do |type| + CHANNELS.each do |channel| + define_method :"#{type}_percentage_#{channel}" do + calculate_percentage(send(:"total_#{channel}_#{type}"), send(:"total_#{type}_votes")) + end + end + + define_method :"total_#{type}_votes" do + send(:"total_web_#{type}") + send(:"total_booth_#{type}") + end + + define_method :"total_#{type}_percentage" do + calculate_percentage(send(:"total_#{type}_votes"), total_participants) + end + end + + def total_no_demographic_data + super + total_unregistered_booth end private - def total_participants - total_participants_web + total_participants_booth - end - - def total_participants_web - total_web_valid + total_web_white + total_web_null - end - - def total_participants_web_percentage - calculate_percentage(total_participants_web, total_participants) - end - - def total_participants_booth - total_booth_valid + total_booth_white + total_booth_null - end - - def total_participants_booth_percentage - calculate_percentage(total_participants_booth, total_participants) - end - - def total_web_valid - voters.where(origin: "web").count - total_web_white - end - - def valid_percentage_web - calculate_percentage(total_web_valid, total_valid_votes) - end - - def total_web_white - 0 - end - - def white_percentage_web - calculate_percentage(total_web_white, total_white_votes) - end - - def total_web_null - 0 - end - - def null_percentage_web - calculate_percentage(total_web_null, total_null_votes) - end - - def total_booth_valid - recounts.sum(:total_amount) - end - - def valid_percentage_booth - calculate_percentage(total_booth_valid, total_valid_votes) - end - - def total_booth_white - recounts.sum(:white_amount) - end - - def white_percentage_booth - calculate_percentage(total_booth_white, total_white_votes) - end - - def total_booth_null - recounts.sum(:null_amount) - end - - def null_percentage_booth - calculate_percentage(total_booth_null, total_null_votes) - end - - def total_valid_votes - total_web_valid + total_booth_valid - end - - def total_valid_percentage - calculate_percentage(total_valid_votes, total_participants) - end - - def total_white_votes - total_web_white + total_booth_white - end - - def total_white_percentage - calculate_percentage(total_white_votes, total_participants) - end - - def total_null_votes - total_web_null + total_booth_null - end - - def total_null_percentage - calculate_percentage(total_null_votes, total_participants) + def participant_ids + voters end def voters - poll.voters + @voters ||= poll.voters.select(:user_id) end def recounts - poll.recounts + @recounts ||= poll.recounts + end + + def total_registered_booth + voters.where(origin: "booth").count + end + + def total_unregistered_booth + [total_participants_booth - total_registered_booth, 0].max end stats_cache(*stats_methods) - stats_cache :voters, :recounts def stats_cache(key, &block) - Rails.cache.fetch("polls_stats/#{poll.id}/#{key}/v12", &block) + Rails.cache.fetch("polls_stats/#{poll.id}/#{key}/#{version}", &block) end end diff --git a/app/models/stats_version.rb b/app/models/stats_version.rb new file mode 100644 index 000000000..f47ddea44 --- /dev/null +++ b/app/models/stats_version.rb @@ -0,0 +1,5 @@ +class StatsVersion < ApplicationRecord + validates :process, presence: true + + belongs_to :process, polymorphic: true +end diff --git a/app/models/user.rb b/app/models/user.rb index 35bff958a..3f67b4748 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -55,6 +55,8 @@ class User < ApplicationRecord scope :moderators, -> { joins(:moderator) } scope :organizations, -> { joins(:organization) } scope :officials, -> { where("official_level > 0") } + scope :male, -> { where(gender: "male") } + scope :female, -> { where(gender: "female") } scope :newsletter, -> { where(newsletter: true) } scope :for_render, -> { includes(:organization) } scope :by_document, ->(document_type, document_number) do @@ -70,6 +72,13 @@ class User < ApplicationRecord string = "%#{search_string}%" where("username ILIKE ? OR email ILIKE ? OR document_number ILIKE ?", string, string, string) end + scope :between_ages, -> (from, to) do + where( + "date_of_birth > ? AND date_of_birth < ?", + to.years.ago.beginning_of_year, + from.years.ago.end_of_year + ) + end before_validation :clean_document_number diff --git a/app/views/budgets/executions/show.html.erb b/app/views/budgets/executions/show.html.erb index aa107e3b6..a0e3324bc 100644 --- a/app/views/budgets/executions/show.html.erb +++ b/app/views/budgets/executions/show.html.erb @@ -32,7 +32,7 @@ <%= link_to t("budgets.results.link"), budget_results_path(@budget) %>
  • - <%= link_to t("budgets.stats.link"), budget_stats_path(@budget) %> + <%= link_to t("stats.budgets.link"), budget_stats_path(@budget) %>
  • <%= link_to t("budgets.executions.link"), budget_executions_path(@budget), class: "is-active" %> diff --git a/app/views/budgets/results/show.html.erb b/app/views/budgets/results/show.html.erb index 43e403090..58916ae95 100644 --- a/app/views/budgets/results/show.html.erb +++ b/app/views/budgets/results/show.html.erb @@ -32,7 +32,7 @@ <%= link_to t("budgets.results.link"), budget_results_path(@budget), class: "is-active" %>
  • - <%= link_to t("budgets.stats.link"), budget_stats_path(@budget) %> + <%= link_to t("stats.budgets.link"), budget_stats_path(@budget) %>
  • <%= link_to t("budgets.executions.link"), budget_executions_path(@budget) %> diff --git a/app/views/budgets/stats/show.html.erb b/app/views/budgets/stats/show.html.erb index 51c12e292..0ad241529 100644 --- a/app/views/budgets/stats/show.html.erb +++ b/app/views/budgets/stats/show.html.erb @@ -1,254 +1,163 @@ <% provide :title do %> - <%= t("budgets.stats.page_title", budget: @budget.name) %> + <%= t("stats.budgets.page_title", budget: @budget.name) %> <% end %> <% provide :social_media_meta_tags do %> <%= render "shared/social_media_meta_tags", - social_url: budget_url(@budget), + social_url: budget_stats_url(@budget), social_title: @budget.name, - social_description: @budget.description_finished %> + social_description: @budget.description_for_phase("finished") %> <% end %> -<% cache [@stats] do %> -
    -
    -
    -
    - <%= back_link_to budgets_path %> -

    - <%= t("budgets.stats.title") %>
    - <%= @budget.name %> -

    -
    -
    -
    - -
    -
    -
      -
    • - <%= t("shared.you_are_in") %> - <%= link_to t("budgets.results.link"), budget_results_path(@budget) %> -
    • -
    • - <%= link_to t("budgets.stats.link"), budget_stats_path(@budget), class: "is-active" %> -
    • -
    • - <%= link_to t("budgets.executions.link"), budget_executions_path(@budget) %> -
    • -
    -
    -
    - -
    -
    -
    - <%= t("budgets.stats.total_participants") %> -

    - <%= @stats[:total_participants] %> -

    - - <%= t("budgets.stats.total_budget_investments") %> -

    - <%= @stats[:total_budget_investments] %> -

    -
    -
    - -
    -

    -   - - <%= t("budgets.stats.total_selected_investments") %>: - <%= @stats[:total_selected_investments] %>
    -
    - -   - - <%= t("budgets.stats.total_unfeasible_investments") %>: - <%= @stats[:total_unfeasible_investments] %>
    -
    - -
    - -   - <%= t("budgets.stats.total_participants_support_phase") %>: - - <%= @stats[:total_participants_support_phase] %> <%= t("budgets.stats.participants") %>, - <%= @stats[:total_supports] %> <%= t("budgets.stats.supports") %>
    -
    - -
    - -   - <%= t("budgets.stats.total_participants_web") %>: - - <%= @stats[:total_participants_web] %>
    -
    - -   - <%= t("budgets.stats.total_participants_booths") %>: - - <%= @stats[:total_participants_booths] %>
    -
    -

    -
    -
    - -
    -
    -

    <%= t("budgets.stats.by_gender") %>

    -
    - -
    - <%= image_tag ("budgets/stats/gender.png") %> -
    - -
    -
    -

    - -  <%= t("budgets.stats.total_female_participants").upcase %> - (<%= number_to_percentage(@stats[:female_percentage], - strip_insignificant_zeros: true, - precision: 2) %>) -

    -

    - <%= @stats[:total_female_participants] %> -

    -
    - -
    -

    - -  <%= t("budgets.stats.total_male_participants").upcase %> - (<%= number_to_percentage(@stats[:male_percentage], - strip_insignificant_zeros: true, - precision: 2) %>) -

    -

    - <%= @stats[:total_male_participants] %> -

    -
    -
    -
    - -
    -
    -

    <%= t("budgets.stats.by_age") %>

    - - - - - - - - - - <% all_ages_count = @stats[:age_groups].values.sum.to_f %> - <% @stats[:age_groups].each do |age_group, count| %> - "> - - - - <% end %> - -
    <%= t("budgets.stats.age").upcase %><%= t("budgets.stats.total").upcase %>
    - <%= age_group.gsub("+", t("budgets.stats.more_than")) + " " + t("budgets.stats.years") %> - - - <% - percentage_age_count = all_ages_count == 0 ? 0 : (count / all_ages_count * 100) - formatted_percentage_age_count = number_to_percentage(percentage_age_count, - strip_insignificant_zeros: true, - precision: 2) - %> - <%= count %> - (<%= formatted_percentage_age_count %>) - -
    - -
    -
    -
    -
    - +
    +
    -

    <%= t("budgets.stats.by_heading") %>

    + <%= back_link_to budgets_path %> +

    + <%= t("stats.title") %>
    + <%= @budget.name %> +

    +
    +
    +
    - - - - - - - - - - - - - - - - - - - - - - - <% @headings.each do |heading| %> - - - +
    +
    +
      +
    • + <%= t("shared.you_are_in") %> + <%= link_to t("budgets.results.link"), budget_results_path(@budget) %> +
    • +
    • + <%= link_to t("stats.budgets.link"), budget_stats_path(@budget), class: "is-active" %> +
    • +
    • + <%= link_to t("budgets.executions.link"), budget_executions_path(@budget) %> +
    • +
    +
    +
    - <% ["support", "vote", "all"].each do |phase| %> - - - +
    + + +
    + <%= render "shared/stats/participation", stats: @stats %> + +
    +

    <%= t("stats.advanced") %>

    + +
    +

    <%= t("stats.budgets.total_investments") %>

    + + <%= number_with_info_tags( + @stats.total_budget_investments, + t("stats.budgets.total_investments"), + html_class: "total-investments" + ) %> + + <%= number_with_info_tags(@stats.total_unfeasible_investments, + t("stats.budgets.total_unfeasible_investments")) %> + <%= number_with_info_tags(@stats.total_selected_investments, + t("stats.budgets.total_selected_investments")) %> +
    + +
    +

    <%= t("stats.budgets.by_phase") %>

    + + <% @stats.phases.each do |phase| %> + <%= number_with_info_tags( + @stats.send("total_participants_#{phase}_phase"), + t("stats.budgets.participants_#{phase}_phase") + ) %> + <% end %> +
    + +
    +

    <%= t("stats.budgets.by_heading") %>

    + +
    <%= t("budgets.stats.heading") %><%= t("budgets.stats.investments_sent_html") %><%= t("budgets.stats.participants_support_phase") %><%= t("budgets.stats.participants_voting_phase") %><%= t("budgets.stats.participants_total") %>
    <%= t("budgets.stats.total") %><%= t("budgets.stats.percent_total_participants_html") %><%= t("budgets.stats.percent_heading_census_html") %><%= t("budgets.stats.total") %><%= t("budgets.stats.percent_total_participants_html") %><%= t("budgets.stats.percent_heading_census_html") %><%= t("budgets.stats.total") %><%= t("budgets.stats.percent_total_participants_html") %><%= t("budgets.stats.percent_heading_census_html") %>
    - <%= heading.name %> - - <%= @stats[:headings][heading.id][:total_investments_count] %> - - <%= @stats[:headings][heading.id]["total_participants_#{phase}_phase".to_sym] %> - - <%= number_to_percentage(@stats[:headings][heading.id]["percentage_participants_#{phase}_phase".to_sym], - strip_insignificant_zeros: true, - precision: 2) %> - - <%= number_to_percentage(@stats[:headings][heading.id]["percentage_district_population_#{phase}_phase".to_sym], - strip_insignificant_zeros: true, - precision: 2) %> -
    + + + + + + <% @stats.all_phases.each do |phase| %> + <% end %> - <% end %> - -
    <%= t("stats.budgets.heading") %><%= t("stats.budgets.investments_sent_html") %> + <%= t("stats.budgets.participants_#{phase}_phase") %> +
    -
    -
    + + <% @stats.all_phases.each do %> + <%= t("stats.budgets.total") %> + <%= t("stats.budgets.percent_total_participants") %> + <%= t("stats.budgets.percent_heading_census") %> + <% end %> + + + + <% @headings.each do |heading| %> + + + <%= heading.name %> + + + <%= @stats.headings[heading.id][:total_investments_count] %> + -
    -
    -
    -

    - <%= t("budgets.stats.no_demographic_data", total: @stats[:total_unknown_gender_or_age]) %> -

    -

    - <%= t("budgets.stats.participatory_disclaimer") %> -

    -

    - <%= t("budgets.stats.heading_disclaimer") %> -

    + <% @stats.all_phases.each do |phase| %> + + <%= @stats.headings[heading.id]["total_participants_#{phase}_phase".to_sym] %> + + + <%= number_to_stats_percentage(@stats.headings[heading.id]["percentage_participants_#{phase}_phase".to_sym]) %> + + + <%= number_to_stats_percentage(@stats.headings[heading.id]["percentage_district_population_#{phase}_phase".to_sym]) %> + + <% end %> + + <% end %> + + +
    +
    + +
    +
    +
    +

    + <%= t("stats.no_demographic_data", count: @stats.total_no_demographic_data) %> +

    +

    + <%= t("stats.budgets.participatory_disclaimer") %> +

    +

    + <%= t("stats.budgets.heading_disclaimer") %> +

    +
    -<% end %> + diff --git a/app/views/polls/_poll_header.html.erb b/app/views/polls/_poll_header.html.erb index 93f41f9d1..d241e455c 100644 --- a/app/views/polls/_poll_header.html.erb +++ b/app/views/polls/_poll_header.html.erb @@ -7,7 +7,7 @@ <%= link_to t("polls.poll_header.back_to_proposal"), [@poll.related] %> <% end %> -

    <%= @poll.name %>

    +

    <%= @poll.name %>

    <%= safe_html_with_links simple_format(@poll.summary) %> diff --git a/app/views/polls/stats.html.erb b/app/views/polls/stats.html.erb index d472ebc3d..1284b26fa 100644 --- a/app/views/polls/stats.html.erb +++ b/app/views/polls/stats.html.erb @@ -1,95 +1,126 @@ <% provide :title do %><%= @poll.name %><% end %> -
    +
    <%= render "poll_header" %> <%= render "poll_subnav" %> -
    -