Merge pull request #3512 from consul/backport-stats

Change stats layout
This commit is contained in:
Javier Martín
2019-05-21 17:18:47 +02:00
committed by GitHub
61 changed files with 2004 additions and 925 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -27,7 +27,7 @@
//= require app //= require app
//= require check_all_none //= require check_all_none
//= require comments //= require comments
//= require dropdown //= require foundation_extras
//= require ie_alert //= require ie_alert
//= require location_changer //= require location_changer
//= require moderator_comment //= require moderator_comment
@@ -93,7 +93,7 @@ var initialize_modules = function() {
App.Votes.initialize(); App.Votes.initialize();
App.AllowParticipation.initialize(); App.AllowParticipation.initialize();
App.Tags.initialize(); App.Tags.initialize();
App.Dropdown.initialize(); App.FoundationExtras.initialize();
App.LocationChanger.initialize(); App.LocationChanger.initialize();
App.CheckAllNone.initialize(); App.CheckAllNone.initialize();
App.PreventDoubleSubmission.initialize(); App.PreventDoubleSubmission.initialize();

View File

@@ -1,4 +0,0 @@
App.Dropdown =
initialize: ->
$(document).foundation()

View File

@@ -0,0 +1,5 @@
App.FoundationExtras =
initialize: ->
$(document).foundation()
$(window).trigger "load.zf.sticky"

View File

@@ -119,3 +119,6 @@ $tab-content-border: $border;
$orbit-bullet-diameter: 0.8rem; $orbit-bullet-diameter: 0.8rem;
$pagination-radius: $global-radius; $pagination-radius: $global-radius;
$show-header-for-stacked: true;

View File

@@ -12,6 +12,7 @@
@import "legislation"; @import "legislation";
@import "legislation_process"; @import "legislation_process";
@import "community"; @import "community";
@import "stats";
@import "custom"; @import "custom";
@import "c3"; @import "c3";
@import "annotator.min"; @import "annotator.min";

View File

@@ -1988,15 +1988,8 @@
.polls-results-stats { .polls-results-stats {
.sidebar { .sidebar {
border-bottom: 1px solid $border;
margin-bottom: $line-height;
@include breakpoint(medium) { .menu:not(.vertical) {
border-bottom: 0;
border-right: 1px solid $border;
}
.menu {
padding: 0; padding: 0;
li a { li a {
@@ -2030,10 +2023,4 @@
} }
} }
} }
.number {
font-size: rem-calc(60);
font-weight: bold;
line-height: rem-calc(60);
}
} }

View File

@@ -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;
}
}
}

View File

@@ -6,16 +6,12 @@ module Budgets
def show def show
authorize! :read_stats, @budget authorize! :read_stats, @budget
@stats = load_stats @stats = Budget::Stats.new(@budget)
@headings = @budget.headings.sort_by_name @headings = @budget.headings.sort_by_name
end end
private private
def load_stats
Budget::Stats.new(@budget).generate
end
def load_budget def load_budget
@budget = Budget.find_by(slug: params[:budget_id]) || Budget.find_by(id: params[:budget_id]) @budget = Budget.find_by(slug: params[:budget_id]) || Budget.find_by(id: params[:budget_id])
end end

View File

@@ -33,7 +33,7 @@ class PollsController < ApplicationController
end end
def stats def stats
@stats = Poll::Stats.new(@poll).generate @stats = Poll::Stats.new(@poll)
end end
def results def results

View File

@@ -30,4 +30,16 @@ module StatsHelper
opt[:data][:graph] = admin_api_stats_path(budget_investments: true) opt[:data][:graph] = admin_api_stats_path(budget_investments: true)
content_tag :div, "", opt content_tag :div, "", opt
end 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 end

View File

@@ -66,7 +66,6 @@ module Abilities
can [:hide, :update, :toggle_selection], Budget::Investment can [:hide, :update, :toggle_selection], Budget::Investment
can [:valuate, :comment_valuation], Budget::Investment can [:valuate, :comment_valuation], Budget::Investment
can :create, Budget::ValuatorAssignment can :create, Budget::ValuatorAssignment
can :read_stats, Budget, phase: "reviewing_ballots"
can [:search, :edit, :update, :create, :index, :destroy], Banner can [:search, :edit, :update, :create, :index, :destroy], Banner

View File

@@ -21,7 +21,7 @@ module Abilities
can [:read], Budget::Group can [:read], Budget::Group
can [:read, :print, :json_data], Budget::Investment can [:read, :print, :json_data], Budget::Investment
can [:read_results, :read_executions], Budget, phase: "finished" 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 :new, DirectMessage
can [:read, :debate, :draft_publication, :allegations, :result_publication, can [:read, :debate, :draft_publication, :allegations, :result_publication,
:proposals, :milestones], Legislation::Process, published: true :proposals, :milestones], Legislation::Process, published: true

View File

@@ -2,6 +2,7 @@ class Budget < ApplicationRecord
include Measurable include Measurable
include Sluggable include Sluggable
include StatsVersionable
translates :name, touch: true translates :name, touch: true
include Globalizable include Globalizable
@@ -125,8 +126,12 @@ class Budget < ApplicationRecord
Budget::Phase::PUBLISHED_PRICES_PHASES.include?(phase) Budget::Phase::PUBLISHED_PRICES_PHASES.include?(phase)
end end
def valuating_or_later?
current_phase&.valuating_or_later?
end
def publishing_prices_or_later? def publishing_prices_or_later?
publishing_prices? || balloting_or_later? current_phase&.publishing_prices_or_later?
end end
def balloting_process? def balloting_process?
@@ -134,7 +139,7 @@ class Budget < ApplicationRecord
end end
def balloting_or_later? def balloting_or_later?
balloting_process? || finished? current_phase&.balloting_or_later?
end end
def heading_price(heading) def heading_price(heading)

View File

@@ -28,16 +28,10 @@ class Budget
scope :enabled, -> { where(enabled: true) } scope :enabled, -> { where(enabled: true) }
scope :published, -> { enabled.where.not(kind: "drafting") } scope :published, -> { enabled.where.not(kind: "drafting") }
scope :drafting, -> { find_by_kind("drafting") }
scope :informing, -> { find_by_kind("informing") } PHASE_KINDS.each do |phase|
scope :accepting, -> { find_by_kind("accepting")} define_singleton_method(phase) { find_by_kind(phase) }
scope :reviewing, -> { find_by_kind("reviewing")} end
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")}
def next_enabled_phase def next_enabled_phase
next_phase&.enabled? ? next_phase : next_phase&.next_enabled_phase next_phase&.enabled? ? next_phase : next_phase&.next_enabled_phase
@@ -53,42 +47,58 @@ class Budget
end end
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 private
def adjust_date_ranges def adjust_date_ranges
if enabled? if enabled?
next_enabled_phase&.update_column(:starts_at, ends_at) next_enabled_phase&.update_column(:starts_at, ends_at)
prev_enabled_phase&.update_column(:ends_at, starts_at) prev_enabled_phase&.update_column(:ends_at, starts_at)
elsif enabled_changed? elsif enabled_changed?
next_enabled_phase&.update_column(:starts_at, starts_at) 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)
end end
end end
end
def next_phase_dates_valid? def touch_budget
if enabled? && ends_at.present? && next_enabled_phase.present? budget.touch
next_enabled_phase.assign_attributes(starts_at: ends_at) end
if next_enabled_phase.invalid_dates_range?
phase_name = I18n.t("budgets.phase.#{next_enabled_phase.kind}") def prev_phase_dates_valid?
error = I18n.t("budgets.phases.errors.next_phase_dates_invalid", phase_name: phase_name) if enabled? && starts_at.present? && prev_enabled_phase.present?
errors.add(:ends_at, error) 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 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
end end

View File

@@ -3,116 +3,145 @@ class Budget::Stats
alias_method :budget, :resource alias_method :budget, :resource
def self.stats_methods def self.stats_methods
super + super + support_phase_methods + vote_phase_methods
%i[total_participants_support_phase total_participants_vote_phase end
total_budget_investments total_votes total_selected_investments
total_unfeasible_investments total_supports headings def self.support_phase_methods
total_participants_web total_participants_booths] %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 end
private private
def total_participants def phase_methods
participants.distinct.count phases.map { |phase| self.class.send("#{phase}_phase_methods") }.flatten
end end
def total_participants_support_phase def participant_ids
voters.uniq.count phases.map { |phase| send("participant_ids_#{phase}_phase") }.flatten.uniq
end end
def total_participants_web def participant_ids_support_phase
(balloters - poll_ballot_voters).uniq.compact.count (authors + voters).uniq
end end
def total_participants_booths def participant_ids_vote_phase
poll_ballot_voters.uniq.count (balloters + poll_ballot_voters).uniq
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)
end end
def authors def authors
budget.investments.pluck(:author_id) @authors ||= budget.investments.pluck(:author_id)
end end
def voters def voters
supports(budget).pluck(:voter_id) @voters ||= supports(budget).distinct.pluck(:voter_id)
end end
def balloters 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 end
def poll_ballot_voters def poll_ballot_voters
budget&.poll ? budget.poll.voters.pluck(:user_id) : [] @poll_ballot_voters ||= budget.poll ? budget.poll.voters.pluck(:user_id) : []
end end
def balloters_by_heading(heading_id) def balloters_by_heading(heading_id)
stats_cache("balloters_by_heading_#{heading_id}") do 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
end end
def voters_by_heading(heading) def voters_by_heading(heading)
stats_cache("voters_by_heading_#{heading.id}") do stats_cache("voters_by_heading_#{heading.id}") do
supports(heading).pluck(:voter_id) supports(heading).distinct.pluck(:voter_id)
end end
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) def calculate_heading_totals(heading)
{ {
total_investments_count: heading.investments.count, total_investments_count: heading.investments.count,
total_participants_support_phase: voters_by_heading(heading).uniq.count, total_participants_support_phase: voters_by_heading(heading).count,
total_participants_vote_phase: balloters_by_heading(heading.id).uniq.count, total_participants_vote_phase: balloters_by_heading(heading.id).count,
total_participants_all_phase: voters_and_balloters_by_heading(heading) total_participants_every_phase: voters_and_balloters_by_heading(heading)
} }
end end
@@ -122,8 +151,8 @@ class Budget::Stats
percentage_district_population_support_phase: population_percent(population, heading_totals[:total_participants_support_phase]), 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_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_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_participants_every_phase: participants_percent(heading_totals, groups_totals, :total_participants_every_phase),
percentage_district_population_all_phase: population_percent(population, heading_totals[:total_participants_all_phase]) percentage_district_population_every_phase: population_percent(population, heading_totals[:total_participants_every_phase])
} }
end end
@@ -141,14 +170,12 @@ class Budget::Stats
end end
def supports(supportable) def supports(supportable)
ActsAsVotable::Vote.where(votable_type: "Budget::Investment", votable_id: supportable.investments.pluck(:id)) Vote.where(votable: supportable.investments)
end end
stats_cache(*stats_methods) stats_cache(*stats_methods)
stats_cache :total_participants_with_gender
stats_cache :voters, :participants, :authors, :balloters, :poll_ballot_voters
def stats_cache(key, &block) 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
end end

View File

@@ -1,79 +1,175 @@
module Statisticable module Statisticable
extend ActiveSupport::Concern extend ActiveSupport::Concern
PARTICIPATIONS = %w[gender age geozone]
included do included do
attr_reader :resource 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 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 class_methods do
def stats_methods def stats_methods
%i[total_participants total_male_participants base_stats_methods + gender_methods + age_methods + geozone_methods
total_female_participants total_unknown_gender_or_age end
male_percentage female_percentage age_groups]
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 end
def stats_cache(*method_names) def stats_cache(*method_names)

View File

@@ -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

View File

@@ -6,6 +6,7 @@ class Poll < ApplicationRecord
include ActsAsParanoidAliases include ActsAsParanoidAliases
include Notifiable include Notifiable
include Sluggable include Sluggable
include StatsVersionable
translates :name, touch: true translates :name, touch: true
translates :summary, touch: true translates :summary, touch: true

View File

@@ -2,122 +2,123 @@ class Poll::Stats
include Statisticable include Statisticable
alias_method :poll, :resource alias_method :poll, :resource
CHANNELS = Poll::Voter::VALID_ORIGINS
def self.stats_methods def self.stats_methods
%i[total_participants total_participants_web total_web_valid total_web_white total_web_null super +
total_participants_booth total_booth_valid total_booth_white total_booth_null %i[total_valid_votes total_white_votes total_null_votes
total_valid_votes total_white_votes total_null_votes valid_percentage_web valid_percentage_booth total_participants_web total_web_valid total_web_white total_web_null
total_valid_percentage white_percentage_web white_percentage_booth total_white_percentage total_participants_booth total_booth_valid total_booth_white total_booth_null
null_percentage_web null_percentage_booth total_null_percentage total_participants_web_percentage total_participants_letter total_letter_valid total_letter_white total_letter_null
total_participants_booth_percentage] 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 end
private private
def total_participants def participant_ids
total_participants_web + total_participants_booth voters
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)
end end
def voters def voters
poll.voters @voters ||= poll.voters.select(:user_id)
end end
def recounts 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 end
stats_cache(*stats_methods) stats_cache(*stats_methods)
stats_cache :voters, :recounts
def stats_cache(key, &block) 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
end end

View File

@@ -0,0 +1,5 @@
class StatsVersion < ApplicationRecord
validates :process, presence: true
belongs_to :process, polymorphic: true
end

View File

@@ -55,6 +55,8 @@ class User < ApplicationRecord
scope :moderators, -> { joins(:moderator) } scope :moderators, -> { joins(:moderator) }
scope :organizations, -> { joins(:organization) } scope :organizations, -> { joins(:organization) }
scope :officials, -> { where("official_level > 0") } scope :officials, -> { where("official_level > 0") }
scope :male, -> { where(gender: "male") }
scope :female, -> { where(gender: "female") }
scope :newsletter, -> { where(newsletter: true) } scope :newsletter, -> { where(newsletter: true) }
scope :for_render, -> { includes(:organization) } scope :for_render, -> { includes(:organization) }
scope :by_document, ->(document_type, document_number) do scope :by_document, ->(document_type, document_number) do
@@ -70,6 +72,13 @@ class User < ApplicationRecord
string = "%#{search_string}%" string = "%#{search_string}%"
where("username ILIKE ? OR email ILIKE ? OR document_number ILIKE ?", string, string, string) where("username ILIKE ? OR email ILIKE ? OR document_number ILIKE ?", string, string, string)
end 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 before_validation :clean_document_number

View File

@@ -32,7 +32,7 @@
<%= link_to t("budgets.results.link"), budget_results_path(@budget) %> <%= link_to t("budgets.results.link"), budget_results_path(@budget) %>
</li> </li>
<li class="tabs-title"> <li class="tabs-title">
<%= link_to t("budgets.stats.link"), budget_stats_path(@budget) %> <%= link_to t("stats.budgets.link"), budget_stats_path(@budget) %>
</li> </li>
<li class="tabs-title is-active"> <li class="tabs-title is-active">
<%= link_to t("budgets.executions.link"), budget_executions_path(@budget), class: "is-active" %> <%= link_to t("budgets.executions.link"), budget_executions_path(@budget), class: "is-active" %>

View File

@@ -32,7 +32,7 @@
<%= link_to t("budgets.results.link"), budget_results_path(@budget), class: "is-active" %> <%= link_to t("budgets.results.link"), budget_results_path(@budget), class: "is-active" %>
</li> </li>
<li class="tabs-title"> <li class="tabs-title">
<%= link_to t("budgets.stats.link"), budget_stats_path(@budget) %> <%= link_to t("stats.budgets.link"), budget_stats_path(@budget) %>
</li> </li>
<li class="tabs-title"> <li class="tabs-title">
<%= link_to t("budgets.executions.link"), budget_executions_path(@budget) %> <%= link_to t("budgets.executions.link"), budget_executions_path(@budget) %>

View File

@@ -1,254 +1,163 @@
<% provide :title do %> <% provide :title do %>
<%= t("budgets.stats.page_title", budget: @budget.name) %> <%= t("stats.budgets.page_title", budget: @budget.name) %>
<% end %> <% end %>
<% provide :social_media_meta_tags do %> <% provide :social_media_meta_tags do %>
<%= render "shared/social_media_meta_tags", <%= render "shared/social_media_meta_tags",
social_url: budget_url(@budget), social_url: budget_stats_url(@budget),
social_title: @budget.name, social_title: @budget.name,
social_description: @budget.description_finished %> social_description: @budget.description_for_phase("finished") %>
<% end %> <% end %>
<% cache [@stats] do %> <div class="participation-stats budgets-stats">
<div class="budgets-stats"> <div class="expanded no-margin-top padding header">
<div class="expanded no-margin-top padding header">
<div class="row">
<div class="small-12 column">
<%= back_link_to budgets_path %>
<h2 class="margin-top">
<%= t("budgets.stats.title") %><br>
<span><%= @budget.name %></span>
</h2>
</div>
</div>
</div>
<div class="row margin-top">
<div class="small-12 column">
<ul class="tabs">
<li class="tabs-title">
<span class="show-for-sr"><%= t("shared.you_are_in") %></span>
<%= link_to t("budgets.results.link"), budget_results_path(@budget) %>
</li>
<li class="tabs-title is-active">
<%= link_to t("budgets.stats.link"), budget_stats_path(@budget), class: "is-active" %>
</li>
<li class="tabs-title">
<%= link_to t("budgets.executions.link"), budget_executions_path(@budget) %>
</li>
</ul>
</div>
</div>
<div class="row">
<div class="small-12 medium-6 column">
<div class="callout">
<span class="uppercase"><%= t("budgets.stats.total_participants") %></span>
<p id="total_participants" class="big-number-stat budget">
<%= @stats[:total_participants] %>
</p>
<span class="uppercase"><%= t("budgets.stats.total_budget_investments") %></span>
<p class="big-number-stat budget">
<%= @stats[:total_budget_investments] %>
</p>
</div>
</div>
<div class="small-12 medium-6 column">
<p>
<span class="label feasible"></span>&nbsp;
<span id="total_selected_investments">
<span class="uppercase"><strong><%= t("budgets.stats.total_selected_investments") %></strong></span>:
<%= @stats[:total_selected_investments] %><br>
</span>
<span class="label unfeasible"></span>&nbsp;
<span id="total_unfeasible_investments">
<span class="uppercase"><strong><%= t("budgets.stats.total_unfeasible_investments") %></strong></span>:
<%= @stats[:total_unfeasible_investments] %><br>
</span>
<br>
<span class="label supports"></span>&nbsp;
<span class="uppercase"><strong><%= t("budgets.stats.total_participants_support_phase") %></strong></span>:
<span id="total_participants_support_phase">
<%= @stats[:total_participants_support_phase] %> <em><%= t("budgets.stats.participants") %></em>,
<%= @stats[:total_supports] %> <em><%= t("budgets.stats.supports") %></em><br>
</span>
<br>
<span class="label"></span>&nbsp;
<span class="uppercase"><strong><%= t("budgets.stats.total_participants_web") %></strong></span>:
<span id="total_participants_web">
<%= @stats[:total_participants_web] %><br>
</span>
<span class="label"></span>&nbsp;
<span class="uppercase"><strong><%= t("budgets.stats.total_participants_booths") %></strong></span>:
<span id="total_participants_booths">
<%= @stats[:total_participants_booths] %><br>
</span>
</p>
</div>
</div>
<div class="row margin">
<div class="small-12 column">
<h2 class="margin-bottom"><%= t("budgets.stats.by_gender") %></h2>
</div>
<div class="small-12 medium-6 column text-center">
<%= image_tag ("budgets/stats/gender.png") %>
</div>
<div class="small-12 medium-6 column">
<div class="small-12 medium-6 column text-right">
<p id="female_percentage">
<span class="label" style="background: #FF9E01"></span>
&nbsp;<%= t("budgets.stats.total_female_participants").upcase %>
(<%= number_to_percentage(@stats[:female_percentage],
strip_insignificant_zeros: true,
precision: 2) %>)
</p>
<p id="total_female_participants" class="big-number-stat">
<%= @stats[:total_female_participants] %>
</p>
</div>
<div class="small-12 medium-6 column text-left">
<p id="male_percentage">
<span class="label" style="background: #FF6600"></span>
&nbsp;<%= t("budgets.stats.total_male_participants").upcase %>
(<%= number_to_percentage(@stats[:male_percentage],
strip_insignificant_zeros: true,
precision: 2) %>)
</p>
<p id="total_male_participants" class="big-number-stat">
<%= @stats[:total_male_participants] %>
</p>
</div>
</div>
</div>
<div class="row margin">
<div class="small-12 column">
<h2 class="margin-bottom"><%= t("budgets.stats.by_age") %></h2>
<table>
<thead>
<tr>
<th scope="col" class="table-width border-right"><%= t("budgets.stats.age").upcase %></th>
<th scope="col" class="border-left"><%= t("budgets.stats.total").upcase %></th>
</tr>
</thead>
<tbody>
<% all_ages_count = @stats[:age_groups].values.sum.to_f %>
<% @stats[:age_groups].each do |age_group, count| %>
<tr id="age_group_<%= age_group.gsub(" - ", "_to_").gsub("+ ", "up_to_") %>">
<td class="border-right">
<%= age_group.gsub("+", t("budgets.stats.more_than")) + " " + t("budgets.stats.years") %>
</td>
<td class="border-left">
<strong>
<%
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 %>)
</strong>
<div class="progress" role="progressbar" tabindex="0" aria-valuenow="20" aria-valuemin="0" aria-valuetext="<%= percentage_age_count %>" aria-valuemax="100">
<span class="progress-meter" style="width: <%= number_to_percentage(percentage_age_count*5,
strip_insignificant_zeros: true,
precision: 2, locale: :en) %>;"></span>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<div class="row"> <div class="row">
<div class="small-12 column"> <div class="small-12 column">
<h2 class="margin-bottom"><%= t("budgets.stats.by_heading") %></h2> <%= back_link_to budgets_path %>
<h2 class="margin-top">
<%= t("stats.title") %><br>
<span><%= @budget.name %></span>
</h2>
</div>
</div>
</div>
<table class="stats-districts survey-districts"> <div class="row margin-top">
<thead> <div class="small-12 column">
<tr> <ul class="tabs">
<th scope="col" rowspan="2"><%= t("budgets.stats.heading") %></th> <li class="tabs-title">
<th scope="col" rowspan="2"><%= t("budgets.stats.investments_sent_html") %></th> <span class="show-for-sr"><%= t("shared.you_are_in") %></span>
<th scope="col" colspan="3"><%= t("budgets.stats.participants_support_phase") %></th> <%= link_to t("budgets.results.link"), budget_results_path(@budget) %>
<th scope="col" colspan="3"><%= t("budgets.stats.participants_voting_phase") %></th> </li>
<th scope="col" colspan="3"><%= t("budgets.stats.participants_total") %></th> <li class="tabs-title is-active">
</tr> <%= link_to t("stats.budgets.link"), budget_stats_path(@budget), class: "is-active" %>
<tr> </li>
<th scope="col" class="tiny"><%= t("budgets.stats.total") %></th> <li class="tabs-title">
<th scope="col" class="tiny"><%= t("budgets.stats.percent_total_participants_html") %></th> <%= link_to t("budgets.executions.link"), budget_executions_path(@budget) %>
<th scope="col" class="tiny"><%= t("budgets.stats.percent_heading_census_html") %></th> </li>
<th scope="col" class="tiny"><%= t("budgets.stats.total") %></th> </ul>
<th scope="col" class="tiny"><%= t("budgets.stats.percent_total_participants_html") %></th> </div>
<th scope="col" class="tiny"><%= t("budgets.stats.percent_heading_census_html") %></th> </div>
<th scope="col" class="tiny"><%= t("budgets.stats.total") %></th>
<th scope="col" class="tiny"><%= t("budgets.stats.percent_total_participants_html") %></th>
<th scope="col" class="tiny"><%= t("budgets.stats.percent_heading_census_html") %></th>
</tr>
</thead>
<tbody id="headings">
<% @headings.each do |heading| %>
<tr id="<%= heading.name.parameterize %>">
<td class="border-left">
<strong><%= heading.name %></strong>
</td>
<td id="total_spending_proposals_heading_<%= heading.id %>"
class="text-center border-left border-right">
<%= @stats[:headings][heading.id][:total_investments_count] %>
</td>
<% ["support", "vote", "all"].each do |phase| %> <div class="row margin">
<td id="total_participants_<%= phase %>_phase_heading_<%= heading.id %>" <div class="small-12 medium-3 column sidebar">
class="border-left text-center"> <%= render "shared/stats/links", stats: @stats %>
<%= @stats[:headings][heading.id]["total_participants_#{phase}_phase".to_sym] %>
</td> <p><strong><%= link_to t("stats.advanced"), "#advanced_statistics" %></strong></p>
<td id="percentage_participants_<%= phase %>_phase_heading_<%= heading.id %>" <ul class="menu vertical">
class="border-left border-right text-center"> <li>
<%= number_to_percentage(@stats[:headings][heading.id]["percentage_participants_#{phase}_phase".to_sym], <%= link_to t("stats.budgets.total_investments"), "#total_investments" %>
strip_insignificant_zeros: true, </li>
precision: 2) %> <li>
</td> <%= link_to t("stats.budgets.by_phase"), "#stats_by_phase" %>
<td id="percentage_district_population_<%= phase %>_phase_heading_<%= heading.id %>" </li>
class="text-center border-right"> <li>
<%= number_to_percentage(@stats[:headings][heading.id]["percentage_district_population_#{phase}_phase".to_sym], <%= link_to t("stats.budgets.by_heading"), "#stats_by_heading" %>
strip_insignificant_zeros: true, </li>
precision: 2) %> </ul>
</td> </div>
<div class="small-12 medium-9 column stats-content">
<%= render "shared/stats/participation", stats: @stats %>
<div id="advanced_statistics">
<h3 class="section-title"><%= t("stats.advanced") %></h3>
<div id="total_investments" class="stats-group">
<h4><%= t("stats.budgets.total_investments") %></h4>
<%= 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")) %>
</div>
<div id="stats_by_phase" class="stats-group">
<h4><%= t("stats.budgets.by_phase") %></h4>
<% @stats.phases.each do |phase| %>
<%= number_with_info_tags(
@stats.send("total_participants_#{phase}_phase"),
t("stats.budgets.participants_#{phase}_phase")
) %>
<% end %>
</div>
<div id="stats_by_heading" class="stats-group">
<h4 class="margin-bottom"><%= t("stats.budgets.by_heading") %></h4>
<table class="stats-districts survey-districts">
<thead>
<tr>
<th scope="col" rowspan="2"><%= t("stats.budgets.heading") %></th>
<th scope="col" rowspan="2"><%= t("stats.budgets.investments_sent_html") %></th>
<% @stats.all_phases.each do |phase| %>
<th scope="col" colspan="3">
<%= t("stats.budgets.participants_#{phase}_phase") %>
</th>
<% end %> <% end %>
</tr> </tr>
<% end %> <tr>
</tbody> <% @stats.all_phases.each do %>
</table> <th scope="col" class="phase-subheader"><%= t("stats.budgets.total") %></th>
</div> <th scope="col" class="phase-subheader"><%= t("stats.budgets.percent_total_participants") %></th>
</div> <th scope="col" class="phase-subheader"><%= t("stats.budgets.percent_heading_census") %></th>
<% end %>
</tr>
</thead>
<tbody id="headings">
<% @headings.each do |heading| %>
<tr id="<%= heading.name.parameterize %>">
<td class="border-left">
<strong><%= heading.name %></strong>
</td>
<td id="total_spending_proposals_heading_<%= heading.id %>"
class="text-center border-left border-right">
<%= @stats.headings[heading.id][:total_investments_count] %>
</td>
<div class="row margin"> <% @stats.all_phases.each do |phase| %>
<div class="small-12 column"> <td id="total_participants_<%= phase %>_phase_heading_<%= heading.id %>"
<div id="total_unknown_gender_or_age"> class="border-left text-center">
<p class="help-text"> <%= @stats.headings[heading.id]["total_participants_#{phase}_phase".to_sym] %>
<%= t("budgets.stats.no_demographic_data", total: @stats[:total_unknown_gender_or_age]) %> </td>
</p> <td id="percentage_participants_<%= phase %>_phase_heading_<%= heading.id %>"
<p class="help-text"> class="border-left border-right text-center">
<%= t("budgets.stats.participatory_disclaimer") %> <%= number_to_stats_percentage(@stats.headings[heading.id]["percentage_participants_#{phase}_phase".to_sym]) %>
</p> </td>
<p class="help-text"> <td id="percentage_district_population_<%= phase %>_phase_heading_<%= heading.id %>"
<%= t("budgets.stats.heading_disclaimer") %> class="text-center border-right">
</p> <%= number_to_stats_percentage(@stats.headings[heading.id]["percentage_district_population_#{phase}_phase".to_sym]) %>
</td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<div class="row margin">
<div class="small-12 column">
<div id="total_no_demographic_data">
<p class="help-text">
<%= t("stats.no_demographic_data", count: @stats.total_no_demographic_data) %>
</p>
<p class="help-text">
<%= t("stats.budgets.participatory_disclaimer") %>
</p>
<p class="help-text">
<%= t("stats.budgets.heading_disclaimer") %>
</p>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<% end %> </div>

View File

@@ -7,7 +7,7 @@
<%= link_to t("polls.poll_header.back_to_proposal"), [@poll.related] %> <%= link_to t("polls.poll_header.back_to_proposal"), [@poll.related] %>
<% end %> <% end %>
<h2><%= @poll.name %></h2> <h1><%= @poll.name %></h1>
<%= safe_html_with_links simple_format(@poll.summary) %> <%= safe_html_with_links simple_format(@poll.summary) %>

View File

@@ -1,95 +1,126 @@
<% provide :title do %><%= @poll.name %><% end %> <% provide :title do %><%= @poll.name %><% end %>
<div class="polls-results-stats"> <div class="participation-stats polls-results-stats">
<%= render "poll_header" %> <%= render "poll_header" %>
<%= render "poll_subnav" %> <%= render "poll_subnav" %>
<div class="row margin" data-equalizer data-equalize-on="medium"> <div class="row margin">
<div class="small-12 medium-3 column sidebar" data-equalizer-watch> <div class="small-12 medium-3 column sidebar">
<p><strong><%= t("polls.show.stats.title") %></strong></p> <%= render "shared/stats/links", stats: @stats %>
<ul class="menu vertical margin-top"> <p><strong><%= link_to t("stats.advanced"), "#advanced_statistics" %></strong></p>
<li><a href="#total"><%= t("polls.show.stats.total_participation") %></a></li> <ul class="menu vertical">
<li>
<%= link_to t("stats.polls.by_channel"), "#stats_by_channel" %>
</li>
<li>
<%= link_to t("stats.polls.vote_by_channel"), "#vote_stats_by_channel" %>
</li>
</ul> </ul>
</div> </div>
<div class="small-12 medium-9 column" data-equalizer-watch> <div class="small-12 medium-9 column stats-content">
<h3 id="total"><%= t("polls.show.stats.total_participation") %></h3> <%= render "shared/stats/participation", stats: @stats %>
<p class="margin-top uppercase"> <div id="advanced_statistics">
<%= t("polls.show.stats.total_votes") %><br> <h3 class="section-title"><%= t("stats.advanced") %></h3>
<span class="number"><%= @stats[:total_participants] %></span>
</p>
<table> <div id="stats_by_channel" class="stats-group">
<thead> <h4><%= t("stats.polls.by_channel") %></h4>
<tr>
<th scope="col"><%= t("polls.show.stats.votes") %></th> <% @stats.channels.each do |channel| %>
<th scope="col"><%= t("polls.show.stats.web") %></th> <%= number_with_info_tags(
<th scope="col"><%= t("polls.show.stats.booth") %></th> @stats.send("total_participants_#{channel}"),
<th scope="col"><%= t("polls.show.stats.total") %></th> t("stats.polls.#{channel}_percentage",
</tr> percentage: number_to_stats_percentage(@stats.send(:"total_participants_#{channel}_percentage"))
</thead> ),
<tbody> html_class: channel
<tr> ) %>
<th scope="row"><%= t("polls.show.stats.valid") %></th> <% end %>
<td> </div>
<%= @stats[:total_web_valid] %>
<small><em>(<%= @stats[:valid_percentage_web].round(2) %>%)</em></small> <div id="vote_stats_by_channel" class="stats-group">
</td> <h4><%= t("stats.polls.vote_by_channel") %></h4>
<td>
<%= @stats[:total_booth_valid] %> <table class="stack">
<small><em>(<%= @stats[:valid_percentage_booth].round(2) %>%)</em></small> <thead>
</td> <tr>
<td> <th scope="col"><%= t("polls.show.stats.votes") %></th>
<%= @stats[:total_valid_votes] %> <% @stats.channels.each do |channel| %>
<small><em>(<%= @stats[:total_valid_percentage].round(2) %>%)</em></small> <th scope="col"><%= t("polls.show.stats.#{channel}") %></th>
</td> <% end %>
</tr> <th scope="col"><%= t("polls.show.stats.total") %></th>
<tr> </tr>
<th scope="row"><%= t("polls.show.stats.white") %></th> </thead>
<td> <tbody>
<%= @stats[:total_web_white] %> <tr>
<small><em>(<%= @stats[:white_percentage_web].round(2) %>%)</em></small> <th scope="row"><%= t("polls.show.stats.valid") %></th>
</td>
<td> <% @stats.channels.each do |channel| %>
<%= @stats[:total_booth_white] %> <td>
<small><em>(<%= @stats[:white_percentage_booth].round(2) %>%)</em></small> <%= @stats.send(:"total_#{channel}_valid") %>
</td> <small><em>(<%= @stats.send(:"valid_percentage_#{channel}").round(2) %>%)</em></small>
<td><%= @stats[:total_white_votes] %> </td>
<small><em>(<%= @stats[:total_white_percentage].round(2) %>%)</em></small> <% end %>
</td>
</tr> <td>
<tr> <%= @stats.total_valid_votes %>
<th scope="row"><%= t("polls.show.stats.null_votes") %></th> <small><em>(<%= @stats.total_valid_percentage.round(2) %>%)</em></small>
<td> </td>
<%= @stats[:total_web_null] %>
<small><em>(<%= @stats[:null_percentage_web].round(2) %>%)</em></small> </tr>
</td> <tr>
<td> <th scope="row"><%= t("polls.show.stats.white") %></th>
<%= @stats[:total_booth_null] %>
<small><em>(<%= @stats[:null_percentage_booth].round(2) %>%)</em></small> <% @stats.channels.each do |channel| %>
</td> <td>
<td> <%= @stats.send(:"total_#{channel}_white") %>
<%= @stats[:total_null_votes] %> <small><em>(<%= @stats.send(:"white_percentage_#{channel}").round(2) %>%)</em></small>
<small><em>(<%= @stats[:total_null_percentage].round(2) %>%)</em></small> </td>
</td> <% end %>
</tr>
<tr> <td><%= @stats.total_white_votes %>
<th scope="row"><%= t("polls.show.stats.total") %></th> <small><em>(<%= @stats.total_white_percentage.round(2) %>%)</em></small>
<td> </td>
<%= @stats[:total_participants_web] %> </tr>
<small><em>(<%= @stats[:total_participants_web_percentage].round(2) %>%)</em></small> <tr>
</td> <th scope="row"><%= t("polls.show.stats.null_votes") %></th>
<td>
<%= @stats[:total_participants_booth] %> <% @stats.channels.each do |channel| %>
<small><em>(<%= @stats[:total_participants_booth_percentage].round(2) %>%)</em></small> <td>
</td> <%= @stats.send(:"total_#{channel}_null") %>
<td><%= @stats[:total_participants] %></td> <small><em>(<%= @stats.send(:"null_percentage_#{channel}").round(2) %>%)</em></small>
</tr> </td>
</tbody> <% end %>
</table>
<td>
<%= @stats.total_null_votes %>
<small><em>(<%= @stats.total_null_percentage.round(2) %>%)</em></small>
</td>
</tr>
<tr>
<th scope="row"><%= t("polls.show.stats.total") %></th>
<% @stats.channels.each do |channel| %>
<td>
<%= @stats.send(:"total_participants_#{channel}") %>
<small><em>(<%= @stats.send(:"total_participants_#{channel}_percentage").round(2) %>%)</em></small>
</td>
<% end %>
<td><%= @stats.total_participants %></td>
</tr>
</tbody>
</table>
</div>
<div id="total_no_demographic_data">
<p class="help-text">
<%= t("stats.no_demographic_data", count: @stats.total_no_demographic_data) %>
</p>
</div>
</div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,28 @@
<div id="participants_by_age" class="stats-group">
<h4><%= t("stats.by_age") %></h4>
<table>
<thead>
<tr>
<th class="small-4"><%= t("stats.age") %></th>
<th><%= t("stats.total") %></th>
</tr>
</thead>
<tbody>
<% stats.participants_by_age.values.each do |group| %>
<tr>
<td><%= group[:range] %></td>
<td>
<strong>
<%= "#{group[:count]} (#{number_to_stats_percentage(group[:percentage])})" %>
</strong>
<div class="progress" tabindex="0">
<span class="progress-meter" style="width: <%= group[:percentage] %>%"></span>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>

View File

@@ -0,0 +1,15 @@
<div id="participants_by_gender" class="stats-group">
<h4><%= t("stats.by_gender") %></h4>
<%= number_with_info_tags(
stats.total_male_participants,
t("stats.men_percentage", percentage: number_to_stats_percentage(stats.male_percentage)),
html_class: "participants male"
) %>
<%= number_with_info_tags(
stats.total_female_participants,
t("stats.women_percentage", percentage: number_to_stats_percentage(stats.female_percentage)),
html_class: "participants female"
) %>
</div>

View File

@@ -0,0 +1,21 @@
<div id="participants_by_geozone" class="stats-group">
<h4><%= t("stats.by_geozone") %></h4>
<table>
<thead>
<tr>
<th><%= t("stats.geozone") %></th>
<th><%= t("stats.total") %></th>
</tr>
</thead>
<tbody>
<% stats.participants_by_geozone.each do |geozone, participants| %>
<tr>
<td><%= geozone %></td>
<td><%= "#{participants[:count]} (#{number_to_stats_percentage(participants[:percentage])})" %></td>
</tr>
<% end %>
</tbody>
</table>
</div>

View File

@@ -0,0 +1,12 @@
<p><strong><%= link_to t("stats.title"), "#participation_statistics" %></strong></p>
<ul class="menu vertical">
<li>
<%= link_to t("stats.total_participants"), "#total_participants" %>
</li>
<% stats.participations.each do |stat| %>
<li>
<%= link_to t("stats.by_#{stat}"), "#participants_by_#{stat}" %>
</li>
<% end %>
</ul>

View File

@@ -0,0 +1,17 @@
<div id="participation_statistics">
<h3 class="section-title"><%= t("stats.title") %></h3>
<div id="total_participants" class="stats-group">
<h4><%= t("stats.total_participants") %></h4>
<%= number_with_info_tags(
stats.total_participants,
"",
html_class: "participants total-participants"
) %>
</div>
<% stats.participations.each do |participation| %>
<%= render "shared/stats/#{participation}", stats: stats %>
<% end %>
</div>

View File

@@ -45,6 +45,7 @@ data:
- config/locales/%{locale}/user_groups.yml - config/locales/%{locale}/user_groups.yml
- config/locales/%{locale}/i18n.yml - config/locales/%{locale}/i18n.yml
- config/locales/%{locale}/milestones.yml - config/locales/%{locale}/milestones.yml
- config/locales/%{locale}/stats.yml
# Locale files to write new keys to, based on a list of key pattern => file rules. Matched from top to bottom: # Locale files to write new keys to, based on a list of key pattern => file rules. Matched from top to bottom:
# `i18n-tasks normalize -p` will force move the keys according to these rules # `i18n-tasks normalize -p` will force move the keys according to these rules
@@ -193,6 +194,7 @@ ignore_unused:
- "admin.site_customization.pages.page.status_*" - "admin.site_customization.pages.page.status_*"
- "admin.legislation.processes.process.*" - "admin.legislation.processes.process.*"
- "legislation.processes.index.*" - "legislation.processes.index.*"
- "stats.budgets.participants_*_phase"
- "votes.budget_investments.different_heading_assigned*" - "votes.budget_investments.different_heading_assigned*"
- "*.form.map_skip_checkbox" - "*.form.map_skip_checkbox"
# - "{devise,kaminari,will_paginate}.*" # - "{devise,kaminari,will_paginate}.*"

View File

@@ -191,35 +191,3 @@ en:
dates_range_invalid: "Start date can't be equal or later than End date" dates_range_invalid: "Start date can't be equal or later than End date"
prev_phase_dates_invalid: "Start date must be later than the start date of the previous enabled phase (%{phase_name})" prev_phase_dates_invalid: "Start date must be later than the start date of the previous enabled phase (%{phase_name})"
next_phase_dates_invalid: "End date must be earlier than the end date of the next enabled phase (%{phase_name})" next_phase_dates_invalid: "End date must be earlier than the end date of the next enabled phase (%{phase_name})"
stats:
title: Participation stats
link: Stats
page_title: "%{budget} - Participation stats"
total_participants: Total Participants
total_budget_investments: Total Proposed Investments
total_selected_investments: Proposals on final phase
total_unfeasible_investments: Unfeasible proposals
total_participants_support_phase: Support phase
total_participants_web: Vote phase web participants
total_participants_booths: Vote phase booth participants
participants: Participants
supports: Supports
by_gender: "Participants by gender"
total_male_participants: Mens
total_female_participants: Women
by_age: "Participants by age groups"
age: Age
total: Total
more_than: More than
years: years
by_heading: "Participants by heading"
heading: Heading
investments_sent_html: "Investment proposals sent"
participants_support_phase: Participants support phase
participants_voting_phase: Participants voting phase
participants_total: Total Participants
percent_total_participants_html: "% <br>Total<br>Participants"
percent_heading_census_html: "% <br>Heading<br>Census"
no_demographic_data: "* There is no demographic data for %{total} participants."
participatory_disclaimer: "** The numbers of total participation refer to persons that created, supported or voted investment proposals."
heading_disclaimer: "*** Data about headings refer to the heading where each user voted, not necessarily the one that person is registered on."

View File

@@ -650,15 +650,14 @@ en:
stats_menu: "Participation statistics" stats_menu: "Participation statistics"
results_menu: "Poll results" results_menu: "Poll results"
stats: stats:
title: "Participation data"
total_participation: "Total participation"
total_votes: "Total amount of given votes" total_votes: "Total amount of given votes"
votes: "VOTES" votes: "VOTES"
web: "WEB" web: "WEB"
mail: "MAIL"
booth: "BOOTH" booth: "BOOTH"
total: "TOTAL" total: "TOTAL"
valid: "Valid" valid: "Valid"
white: "White votes" white: "Fully blank"
null_votes: "Invalid" null_votes: "Invalid"
results: results:
title: "Questions" title: "Questions"

View File

@@ -0,0 +1,43 @@
en:
stats:
title: "Participation data"
advanced: "Advanced statistics"
total_participants: "Participants"
by_gender: "Participants by gender"
by_age: "Participants by age"
by_geozone: "Participants by district"
men_percentage: "%{percentage} Men"
women_percentage: "%{percentage} Women"
age: "Age"
age_more_than: "%{start} years old and older"
age_range: "%{start} - %{finish} years old"
total: "Total"
geozone: "District"
no_demographic_data:
zero: ""
one: "* There is no demographic data for 1 participant."
other: "* There is no demographic data for %{count} participants."
budgets:
link: "Stats"
page_title: "%{budget} - Participation stats"
total_investments: "Total investment projects"
total_selected_investments: "Projects in final voting phase"
total_unfeasible_investments: "Unfeasible projects"
by_phase: "Participants by phase"
by_heading: "Participants by phase and heading"
total: "Total"
heading: "Heading"
investments_sent_html: "Investment proposals sent"
participants_support_phase: "Participants support phase"
participants_vote_phase: "Participants voting phase"
participants_every_phase: "Total participants"
percent_total_participants: "% Total participants"
percent_heading_census: "% Heading census"
participatory_disclaimer: "** The numbers of total participants refer to persons that created, supported or voted investment proposals."
heading_disclaimer: "*** Data about headings refer to the heading where each user voted, not necessarily the one that person is registered on."
polls:
by_channel: "Participants by channel"
vote_by_channel: "Vote type by channel"
web_percentage: "%{percentage} Web"
booth_percentage: "%{percentage} Booths"
letter_percentage: "%{percentage} Mail"

View File

@@ -191,35 +191,3 @@ es:
dates_range_invalid: "La fecha de comienzo no puede ser igual o superior a la de finalización" dates_range_invalid: "La fecha de comienzo no puede ser igual o superior a la de finalización"
prev_phase_dates_invalid: "La fecha de inicio debe ser posterior a la fecha de inicio de la anterior fase habilitada (%{phase_name})" prev_phase_dates_invalid: "La fecha de inicio debe ser posterior a la fecha de inicio de la anterior fase habilitada (%{phase_name})"
next_phase_dates_invalid: "La fecha de fin debe ser anterior a la fecha de fin de la siguiente fase habilitada (%{phase_name})" next_phase_dates_invalid: "La fecha de fin debe ser anterior a la fecha de fin de la siguiente fase habilitada (%{phase_name})"
stats:
title: Estadísticas de participación
link: Estadísticas
page_title: "%{budget} - Estadísticas de participación"
total_participants: Total participantes
total_budget_investments: Total propuestas enviadas
total_selected_investments: Propuestas en la fase final
total_unfeasible_investments: Propuestas inviables
total_participants_support_phase: Fase de apoyos
total_participants_web: Participantes web en votación final
total_participants_booths: Participantes en urnas votación final
participants: Participantes
supports: Apoyos
total: Total
by_gender: "Participación por género"
total_male_participants: Hombres
total_female_participants: Mujeres
by_age: "Participación por grupos de edad"
age: Edad
more_than: Más de
years: años
by_heading: "Participación por distritos"
heading: Distrito
investments_sent_html: "Propuestas<br>enviadas"
participants_support_phase: Participación fase apoyos
participants_voting_phase: Participación fase votación
participants_total: Participación total
percent_total_participants_html: "% <br>Total<br>Participantes"
percent_heading_census_html: "% <br>Censo<br>Distrito"
no_demographic_data: "* No se dispone de los datos demográficos de %{total} participantes."
participatory_disclaimer: "** Las cifras de participación total se refieren a personas que han creado, apoyado o votado propuestas."
heading_disclaimer: "*** Los datos de distrito se refieren al distrito en el que el usuario ha votado, no necesariamente en el que está empadronado."

View File

@@ -650,15 +650,14 @@ es:
stats_menu: "Estadísticas de participación" stats_menu: "Estadísticas de participación"
results_menu: "Resultados de la votación" results_menu: "Resultados de la votación"
stats: stats:
title: "Datos de participación"
total_participation: "Participación total"
total_votes: "Nº total de votos emitidos" total_votes: "Nº total de votos emitidos"
votes: "VOTOS" votes: "VOTOS"
web: "WEB" web: "WEB"
booth: "URNAS" booth: "URNAS"
mail: "CORREO"
total: "TOTAL" total: "TOTAL"
valid: "Válidos" valid: "Válidos"
white: "En blanco" white: "Totalmente en blanco"
null_votes: "Nulos" null_votes: "Nulos"
results: results:
title: "Preguntas" title: "Preguntas"

View File

@@ -0,0 +1,43 @@
es:
stats:
title: "Estadísticas de participación"
advanced: "Estadísticas avanzadas"
total_participants: "Participantes"
by_gender: "Participantes por género"
by_age: "Participantes por grupos de edad"
by_geozone: "Participantes por distritos"
men_percentage: "%{percentage} Hombres"
women_percentage: "%{percentage} Mujeres"
age: "Edad"
age_more_than: "De %{start} y más años"
age_range: "De %{start} a %{finish} años"
total: "Total"
geozone: "Distrito"
no_demographic_data:
zero: ""
one: "* No se dispone de los datos demográficos de 1 participante."
other: "* No se dispone de los datos demográficos de %{count} participantes."
budgets:
link: "Estadísticas"
page_title: "%{budget} - Estadísticas de participación"
total_investments: "Total proyectos de gasto"
total_selected_investments: "Proyectos en la fase final"
total_unfeasible_investments: "Proyectos inviables"
by_phase: "Participantes por fases"
by_heading: "Participantes por distritos y fase"
total: "Total"
heading: "Distrito"
investments_sent_html: "Propuestas<br>enviadas"
participants_support_phase: Participantes fase apoyos
participants_vote_phase: Participantes fase votación
participants_every_phase: Total de participantes
percent_total_participants: "% Total participantes"
percent_heading_census: "% Censo distrito"
participatory_disclaimer: "** Las cifras de total de participantes se refieren a personas que han creado, apoyado o votado propuestas."
heading_disclaimer: "*** Los datos de distrito se refieren al distrito en el que el usuario ha votado, no necesariamente en el que está empadronado."
polls:
by_channel: "Participantes por medio"
vote_by_channel: "Votos emitidos por medio"
web_percentage: "%{percentage} Web"
booth_percentage: "%{percentage} Urnas"
letter_percentage: "%{percentage} Correo"

View File

@@ -27,6 +27,10 @@ every 1.day, at: "5:00 am" do
rake "-s sitemap:refresh" rake "-s sitemap:refresh"
end end
every 2.hours do
rake "-s stats:generate"
end
# Temporally not send dashboard's notifications # Temporally not send dashboard's notifications
# every 1.day, at: "7:00 am" do # every 1.day, at: "7:00 am" do
# rake "dashboards:send_notifications" # rake "dashboards:send_notifications"

View File

@@ -0,0 +1,8 @@
class CreateStatsVersions < ActiveRecord::Migration[4.2]
def change
create_table :stats_versions do |t|
t.references :process, polymorphic: true, index: true
t.timestamps null: false
end
end
end

View File

@@ -1334,6 +1334,14 @@ ActiveRecord::Schema.define(version: 20190411090023) do
t.index ["tsv"], name: "index_spending_proposals_on_tsv", using: :gin t.index ["tsv"], name: "index_spending_proposals_on_tsv", using: :gin
end end
create_table "stats_versions", force: :cascade do |t|
t.string "process_type"
t.integer "process_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["process_type", "process_id"], name: "index_stats_versions_on_process_type_and_process_id", using: :btree
end
create_table "taggings", force: :cascade do |t| create_table "taggings", force: :cascade do |t|
t.integer "tag_id" t.integer "tag_id"
t.integer "taggable_id" t.integer "taggable_id"

24
lib/geozone_stats.rb Normal file
View File

@@ -0,0 +1,24 @@
class GeozoneStats
attr_reader :geozone, :participants
def initialize(geozone, participants)
@geozone = geozone
@participants = participants
end
def geozone_participants
participants.where(geozone: geozone)
end
def name
geozone.name
end
def count
geozone_participants.count
end
def percentage
PercentageCalculator.calculate(count, participants.count)
end
end

View File

@@ -0,0 +1,7 @@
module PercentageCalculator
def self.calculate(fraction, total)
return 0.0 if total.zero?
(fraction * 100.0 / total).round(3)
end
end

24
lib/tasks/stats.rake Normal file
View File

@@ -0,0 +1,24 @@
namespace :stats do
desc "Generates stats which are not cached yet"
task generate: :environment do
Budget.find_each do |budget|
Budget::Stats.new(budget).generate
print "."
end
Poll.find_each do |poll|
Poll::Stats.new(poll).generate
print "."
end
end
desc "Expires stats cache"
task expire_cache: :environment do
[Budget, Poll].each do |model_class|
model_class.find_each { |record| record.find_or_create_stats_version.touch }
end
end
desc "Deletes stats cache and generates it again"
task regenerate: [:expire_cache, :generate]
end

View File

@@ -74,13 +74,15 @@ FactoryBot.define do
end end
factory :budget_heading, class: "Budget::Heading" do factory :budget_heading, class: "Budget::Heading" do
association :group, factory: :budget_group
sequence(:name) { |n| "Heading #{n}" } sequence(:name) { |n| "Heading #{n}" }
price 1000000 price 1000000
population 1234 population 1234
latitude "40.416775" latitude "40.416775"
longitude "-3.703790" longitude "-3.703790"
transient { budget nil }
group { association :budget_group, budget: budget || association(:budget) }
trait :drafting_budget do trait :drafting_budget do
association :group, factory: [:budget_group, :drafting_budget] association :group, factory: [:budget_group, :drafting_budget]
end end
@@ -88,7 +90,7 @@ FactoryBot.define do
factory :budget_investment, class: "Budget::Investment" do factory :budget_investment, class: "Budget::Investment" do
sequence(:title) { |n| "Budget Investment #{n} title" } sequence(:title) { |n| "Budget Investment #{n} title" }
association :heading, factory: :budget_heading heading { association :budget_heading, budget: budget }
association :author, factory: :user association :author, factory: :user
description "Spend money on this" description "Spend money on this"
price 10 price 10
@@ -189,8 +191,13 @@ FactoryBot.define do
end end
factory :budget_ballot_line, class: "Budget::Ballot::Line" do factory :budget_ballot_line, class: "Budget::Ballot::Line" do
association :ballot, factory: :budget_ballot
association :investment, factory: :budget_investment association :investment, factory: :budget_investment
transient { user nil }
ballot do
association :budget_ballot, budget: investment.budget.reload, user: user || association(:user)
end
end end
factory :budget_reclassified_vote, class: "Budget::ReclassifiedVote" do factory :budget_reclassified_vote, class: "Budget::ReclassifiedVote" do

View File

@@ -88,10 +88,13 @@ FactoryBot.define do
end end
factory :poll_voter, class: "Poll::Voter" do factory :poll_voter, class: "Poll::Voter" do
poll
association :user, :level_two association :user, :level_two
from_web from_web
transient { budget nil }
poll { budget&.poll || association(:poll, budget: budget) }
trait :from_web do trait :from_web do
origin "web" origin "web"
token SecureRandom.hex(32) token SecureRandom.hex(32)

View File

@@ -8,25 +8,16 @@ feature "Stats" do
describe "Show" do describe "Show" do
it "is not accessible to normal users if phase is not 'finished'" do it "is not accessible if supports phase is not finished" do
budget.update(phase: "reviewing_ballots") budget.update(phase: "selecting")
visit budget_stats_path(budget.id) visit budget_stats_path(budget.id)
expect(page).to have_content "You do not have permission to carry out the action "\ expect(page).to have_content "You do not have permission to carry out the action "\
"'read_stats' on budget." "'read_stats' on budget."
end end
it "is accessible to normal users if phase is 'finished'" do it "is accessible if supports phase is finished" do
budget.update(phase: "finished") budget.update(phase: "valuating")
visit budget_stats_path(budget.id)
expect(page).to have_content "Stats"
end
it "is accessible to administrators when budget has phase 'reviewing_ballots'" do
budget.update(phase: "reviewing_ballots")
login_as(create(:administrator).user)
visit budget_stats_path(budget.id) visit budget_stats_path(budget.id)
expect(page).to have_content "Stats" expect(page).to have_content "Stats"

View File

@@ -0,0 +1,44 @@
require "rails_helper"
describe GeozoneStats do
let(:winterfell) { create(:geozone, name: "Winterfell") }
let(:riverlands) { create(:geozone, name: "Riverlands") }
describe "#name" do
let(:stats) { GeozoneStats.new(winterfell, []) }
it "returns the geozone name" do
expect(stats.name).to eq "Winterfell"
end
end
describe "#count" do
before do
2.times { create(:user, geozone: winterfell) }
1.times { create(:user, geozone: riverlands) }
end
let(:winterfell_stats) { GeozoneStats.new(winterfell, User.all) }
let(:riverlands_stats) { GeozoneStats.new(riverlands, User.all) }
it "counts participants from the geozone" do
expect(winterfell_stats.count).to eq 2
expect(riverlands_stats.count).to eq 1
end
end
describe "#percentage" do
before do
2.times { create(:user, geozone: winterfell) }
1.times { create(:user, geozone: riverlands) }
end
let(:winterfell_stats) { GeozoneStats.new(winterfell, User.all) }
let(:riverlands_stats) { GeozoneStats.new(riverlands, User.all) }
it "calculates percentage relative to the amount of participants" do
expect(winterfell_stats.percentage).to eq 66.667
expect(riverlands_stats.percentage).to eq 33.333
end
end
end

View File

@@ -7,17 +7,8 @@ describe Budget::Phase do
let(:second_phase) { budget.phases.informing } let(:second_phase) { budget.phases.informing }
let(:third_phase) { budget.phases.accepting } let(:third_phase) { budget.phases.accepting }
let(:fourth_phase) { budget.phases.reviewing } let(:fourth_phase) { budget.phases.reviewing }
let(:fifth_phase) { budget.phases.selecting }
let(:final_phase) { budget.phases.finished} let(:final_phase) { budget.phases.finished}
before do
first_phase.update_attributes(starts_at: Date.current - 3.days, ends_at: Date.current - 1.day)
second_phase.update_attributes(starts_at: Date.current - 1.days, ends_at: Date.current + 1.day)
third_phase.update_attributes(starts_at: Date.current + 1.days, ends_at: Date.current + 3.day)
fourth_phase.update_attributes(starts_at: Date.current + 3.days, ends_at: Date.current + 5.day)
fifth_phase.update_attributes(starts_at: Date.current + 5.days, ends_at: Date.current + 7.day)
end
describe "validates" do describe "validates" do
it "is not valid without a budget" do it "is not valid without a budget" do
expect(build(:budget_phase, budget: nil)).not_to be_valid expect(build(:budget_phase, budget: nil)).not_to be_valid

View File

@@ -1,212 +1,287 @@
require "rails_helper" require "rails_helper"
describe Budget::Stats do describe Budget::Stats do
let(:budget) { create(:budget, :finished) }
let(:stats) { Budget::Stats.new(budget) }
let(:investment) { create(:budget_investment, :selected, budget: budget) }
before(:each) do describe "#participants" do
@budget = create(:budget) let(:author) { investment.author }
@group = create(:budget_group, budget: @budget) let(:author_and_voter) { create(:user, hidden_at: Time.current) }
@heading = create(:budget_heading, group: @group, price: 1000) let(:voter) { create(:user) }
let(:voter_and_balloter) { create(:user) }
let(:balloter) { create(:user, hidden_at: Time.current) }
let(:poll_balloter) { create(:user, :level_two) }
let(:non_participant) { create(:user, :level_two) }
@investment1 = create(:budget_investment, :selected, author: create(:user, gender: "female"), before do
heading: @heading, price: 200, ballot_lines_count: 900, winner: true) create(:budget_investment, :selected, budget: budget, author: author_and_voter)
@investment2 = create(:budget_investment, :selected, author: create(:user, gender: "female"),
heading: @heading, price: 300, ballot_lines_count: 800, winner: true)
@investment3 = create(:budget_investment, :selected, author: create(:user, gender: "female",
date_of_birth: 40.years.ago), heading: @heading, price: 400,
ballot_lines_count: 880, winner: true)
@investment4 = create(:budget_investment, :selected, author: create(:user, gender: "male"),
heading: @heading, price: 100, ballot_lines_count: 915, winner: true)
@investment5 = create(:budget_investment, :unfeasible, author: create(:user, gender: "male",
date_of_birth: 25.years.ago), heading: @heading)
@support1 = create(:vote, votable: @investment1, voter: create(:user, gender: "male")) create(:vote, votable: investment, voter: author_and_voter)
@support2 = create(:vote, votable: @investment2, voter: create(:user)) create(:vote, votable: investment, voter: voter)
create(:vote, votable: investment, voter: voter_and_balloter)
@budget_ballot1 = create(:budget_ballot, budget: @budget, user: create(:user, gender: "female", create(:budget_ballot_line, investment: investment, user: balloter)
date_of_birth: 54.years.ago)) create(:budget_ballot_line, investment: investment, user: voter_and_balloter)
@budget_ballot2 = create(:budget_ballot, budget: @budget, user: create(:user, gender: "female"))
@budget_ballot3 = create(:budget_ballot, budget: @budget, user: create(:user, gender: "male"))
@budget_ballot_line1 = create(:budget_ballot_line, ballot: @budget_ballot1, create(:poll_voter, :from_booth, user: poll_balloter, budget: budget)
investment: @investment1)
@budget_ballot_line2 = create(:budget_ballot_line, ballot: @budget_ballot2,
investment: @investment2)
@budget_ballot_line3 = create(:budget_ballot_line, ballot: @budget_ballot3,
investment: @investment3)
@poll = create(:poll, budget: @budget) create(:poll_voter, :from_booth, user: non_participant, budget: create(:budget))
@poll_voter = create(:poll_voter, :from_booth, poll: @poll)
@budget_ballot4 = create(:budget_ballot, budget: @budget, physical: true, user: nil)
@budget_ballot_line4 = create(:budget_ballot_line, ballot: @budget_ballot4,
investment: @investment4)
@stats = Budget::Stats.new(@budget).generate
end
context "#total_participants" do
it "returns the number of total participants" do
expect(@stats[:total_participants]).to be 11
end end
it "returns unique participants, including authors and hidden users" do
expect(stats.participants).to match_array(
[author, author_and_voter, voter, voter_and_balloter, balloter, poll_balloter]
)
expect(stats.total_participants).to be 6
end
end end
context "#total_participants_support_phase" do describe "#total_participants_support_phase" do
it "returns the number of total participants in the support phase" do it "returns the number of total participants in the support phase" do
expect(@stats[:total_participants_support_phase]).to be 2 2.times { create(:vote, votable: investment) }
create(:budget_ballot_line, investment: investment)
expect(stats.total_participants_support_phase).to be 2
end end
it "counts a user who is voter and balloter" do
voter_and_balloter = create(:user)
create(:vote, votable: investment, voter: voter_and_balloter)
create(:budget_ballot_line, investment: investment, user: voter_and_balloter)
expect(stats.total_participants_support_phase).to be 1
end
end end
context "#total_participants_vote_phase" do describe "#total_participants_vote_phase" do
it "returns the number of total participants in the votes phase" do it "returns the number of total participants in the votes phase" do
expect(@stats[:total_participants_vote_phase]).to be 4 2.times { create(:budget_ballot_line, investment: investment) }
create(:vote, votable: investment)
expect(stats.total_participants_vote_phase).to be 2
end end
end it "counts a user who is voter and balloter" do
voter_and_balloter = create(:user)
create(:vote, votable: investment, voter: voter_and_balloter)
create(:budget_ballot_line, investment: investment, user: voter_and_balloter)
context "#total_participants_web" do expect(stats.total_participants_vote_phase).to be 1
it "returns the number of total participants in the votes phase via web" do
expect(@stats[:total_participants_web]).to be 3
end end
end it "includes balloters and poll balloters" do
create(:budget_ballot_line, investment: investment)
create(:poll_voter, :from_booth, budget: budget)
context "#total_participants_booths" do expect(stats.total_participants_vote_phase).to be 2
it "returns the number of total participants in the votes phase in booths" do
expect(@stats[:total_participants_booths]).to be 1
end end
it "counts once a user who is balloter and poll balloter" do
poller_and_balloter = create(:user, :level_two)
create(:budget_ballot_line, investment: investment, user: poller_and_balloter)
create(:poll_voter, :from_booth, user: poller_and_balloter, budget: budget)
expect(stats.total_participants_vote_phase).to be 1
end
it "doesn't count nil user ids" do
create(:budget_ballot_line, investment: investment,
ballot: create(:budget_ballot, budget: budget, user: nil, physical: true)
)
expect(stats.total_participants_vote_phase).to be 0
end
end end
context "#total_budget_investments" do describe "#total_budget_investments" do
it "returns the number of total budget investments" do it "returns the number of total budget investments" do
expect(@stats[:total_budget_investments]).to be 5 2.times { create(:budget_investment, budget: budget) }
end create(:budget_investment, budget: create(:budget))
expect(stats.total_budget_investments).to be 2
end
end end
context "#total_votes" do describe "#total_votes" do
it "returns the number of total votes" do it "returns the number of total votes" do
expect(@stats[:total_votes]).to be 4 create(:budget_ballot_line, investment: investment)
end create(:budget_ballot_line, investment: create(:budget_investment, :selected, budget: budget))
expect(stats.total_votes).to be 2
end
end end
context "#total_selected_investments" do describe "#total_selected_investments" do
it "returns the number of total selected investments" do it "returns the number of total selected investments" do
expect(@stats[:total_selected_investments]).to be 4 3.times { create(:budget_investment, :selected, budget: budget) }
end create(:budget_investment, :selected, budget: create(:budget))
create(:budget_investment, :unfeasible, budget: budget)
expect(stats.total_selected_investments).to be 3
end
end end
context "#total_unfeasible_investments" do describe "#total_unfeasible_investments" do
it "returns the number of total unfeasible investments" do it "returns the number of total unfeasible investments" do
expect(@stats[:total_unfeasible_investments]).to be 1 3.times { create(:budget_investment, :unfeasible, budget: budget) }
end create(:budget_investment, :unfeasible, budget: create(:budget))
create(:budget_investment, :selected, budget: budget)
expect(stats.total_unfeasible_investments).to be 3
end
end end
context "#total_male_participants" do describe "Participants by gender" do
before do
3.times { create(:user, gender: "male") }
2.times { create(:user, gender: "female") }
create(:user, gender: nil)
it "returns the number of total male participants" do allow(stats).to receive(:participants).and_return(User.all)
expect(@stats[:total_male_participants]).to be 4
end end
end describe "#total_male_participants" do
it "returns the number of total male participants" do
context "#total_female_participants" do expect(stats.total_male_participants).to be 3
end
it "returns the number of total female participants" do
expect(@stats[:total_female_participants]).to be 6
end end
end describe "#total_female_participants" do
it "returns the number of total female participants" do
context "#total_supports" do expect(stats.total_female_participants).to be 2
end
it "returns the number of total supports" do
expect(@stats[:total_supports]).to be 2
end end
end describe "#male_percentage" do
it "returns the percentage of male participants" do
context "#total_unknown_gender_or_age" do expect(stats.male_percentage).to be 60.0
end
it "returns the number of total unknown participants' gender or age" do
expect(@stats[:total_unknown_gender_or_age]).to be 1
end end
describe "#female_percentage" do
it "returns the percentage of female participants" do
expect(stats.female_percentage).to be 40.0
end
end
end end
context "#age_groups" do describe "#participants_by_age" do
before do
[21, 22, 23, 23, 34, 42, 43, 44, 50, 51].each do |age|
create(:user, date_of_birth: age.years.ago)
end
allow(stats).to receive(:participants).and_return(User.all)
end
it "returns the age groups hash" do it "returns the age groups hash" do
expect(@stats[:age_groups]["16 - 19"]).to be 0 expect(stats.participants_by_age["16 - 19"][:count]).to be 0
expect(@stats[:age_groups]["20 - 24"]).to be 7 expect(stats.participants_by_age["20 - 24"][:count]).to be 4
expect(@stats[:age_groups]["25 - 29"]).to be 1 expect(stats.participants_by_age["25 - 29"][:count]).to be 0
expect(@stats[:age_groups]["30 - 34"]).to be 0 expect(stats.participants_by_age["30 - 34"][:count]).to be 1
expect(@stats[:age_groups]["35 - 39"]).to be 1 expect(stats.participants_by_age["35 - 39"][:count]).to be 0
expect(@stats[:age_groups]["40 - 44"]).to be 1 expect(stats.participants_by_age["40 - 44"][:count]).to be 3
expect(@stats[:age_groups]["45 - 49"]).to be 0 expect(stats.participants_by_age["45 - 49"][:count]).to be 0
expect(@stats[:age_groups]["50 - 54"]).to be 1 expect(stats.participants_by_age["50 - 54"][:count]).to be 2
expect(@stats[:age_groups]["55 - 59"]).to be 0 expect(stats.participants_by_age["55 - 59"][:count]).to be 0
expect(@stats[:age_groups]["60 - 64"]).to be 0 expect(stats.participants_by_age["60 - 64"][:count]).to be 0
expect(@stats[:age_groups]["65 - 69"]).to be 0 expect(stats.participants_by_age["65 - 69"][:count]).to be 0
expect(@stats[:age_groups]["70 - 140"]).to be 0 expect(stats.participants_by_age["70 - 74"][:count]).to be 0
end end
end end
context "#male_percentage" do describe "#headings" do
before do
it "returns the percentage of male participants" do investment.heading.update_column(:population, 1234)
expect(@stats[:male_percentage]).to be 40.0 create(:budget_investment, heading: investment.heading)
2.times { create(:vote, votable: investment) }
create(:budget_ballot_line, investment: investment)
end end
end
context "#female_percentage" do
it "returns the percentage of female participants" do
expect(@stats[:female_percentage]).to be 60.0
end
end
context "#headings" do
it "returns headings data" do it "returns headings data" do
heading_stats = @stats[:headings][@heading.id] heading_stats = stats.headings[investment.heading.id]
expect(heading_stats[:total_investments_count]).to be 5 expect(heading_stats[:total_investments_count]).to be 2
expect(heading_stats[:total_participants_support_phase]).to be 2 expect(heading_stats[:total_participants_support_phase]).to be 2
expect(heading_stats[:total_participants_vote_phase]).to be 4 expect(heading_stats[:total_participants_vote_phase]).to be 1
expect(heading_stats[:total_participants_all_phase]).to be 6 expect(heading_stats[:total_participants_every_phase]).to be 3
expect(heading_stats[:percentage_participants_support_phase]).to be 100.0 expect(heading_stats[:percentage_participants_support_phase]).to be 100.0
expect(heading_stats[:percentage_district_population_support_phase]).to be 0.162 expect(heading_stats[:percentage_district_population_support_phase]).to be 0.162
expect(heading_stats[:percentage_participants_vote_phase]).to be 100.0 expect(heading_stats[:percentage_participants_vote_phase]).to be 100.0
expect(heading_stats[:percentage_district_population_vote_phase]).to be 0.324 expect(heading_stats[:percentage_district_population_vote_phase]).to be 0.081
expect(heading_stats[:percentage_participants_all_phase]).to be 100.0 expect(heading_stats[:percentage_participants_every_phase]).to be 100.0
expect(heading_stats[:percentage_district_population_all_phase]).to be 0.486 expect(heading_stats[:percentage_district_population_every_phase]).to be 0.243
expect(heading_stats[:total_investments_count]).to be 5
expect(heading_stats[:total_participants_support_phase]).to be 2
expect(heading_stats[:total_participants_vote_phase]).to be 4
expect(heading_stats[:total_participants_all_phase]).to be 6
expect(heading_stats[:percentage_participants_support_phase]).to be 100.0
expect(heading_stats[:percentage_participants_vote_phase]).to be 100.0
expect(heading_stats[:percentage_participants_all_phase]).to be 100.0
end end
end end
describe "#support_phase_finished?" do
context "support phase isn't finished" do
before { budget.phase = "selecting" }
it "is false" do
expect(stats.support_phase_finished?).to be false
end
end
context "support phase is finished" do
before { budget.phase = "valuating" }
it "is false" do
expect(stats.support_phase_finished?).to be true
end
end
end
describe "#vote_phase_finished" do
context "support phase isn't finished" do
before { budget.phase = "reviewing_ballots" }
it "is false" do
expect(stats.vote_phase_finished?).to be false
end
end
context "vote phase is finished" do
before { budget.phase = "finished" }
it "is false" do
expect(stats.vote_phase_finished?).to be true
end
end
end
describe "#all_phases" do
context "no phases are finished" do
before do
allow(stats).to receive(:support_phase_finished?).and_return(false)
allow(stats).to receive(:vote_phase_finished?).and_return(false)
end
it "returns an empty array" do
expect(stats.all_phases).to eq []
end
end
context "one phase is finished" do
before do
allow(stats).to receive(:support_phase_finished?).and_return(true)
allow(stats).to receive(:vote_phase_finished?).and_return(false)
end
it "returns the finished phase" do
expect(stats.all_phases).to eq ["support"]
end
end
context "all phases are finished" do
before do
allow(stats).to receive(:support_phase_finished?).and_return(true)
allow(stats).to receive(:vote_phase_finished?).and_return(true)
end
it "returns the finished phases and a total phase" do
expect(stats.all_phases).to eq ["support", "vote", "every"]
end
end
end
end end

View File

@@ -97,33 +97,55 @@ describe Budget do
expect(budget).to be_finished expect(budget).to be_finished
end end
it "balloting_or_later?" do describe "#valuating_or_later?" do
budget.phase = "drafting" it "returns false before valuating" do
expect(budget).not_to be_balloting_or_later budget.phase = "selecting"
expect(budget).not_to be_valuating_or_later
end
budget.phase = "accepting" it "returns true while valuating" do
expect(budget).not_to be_balloting_or_later budget.phase = "valuating"
expect(budget).to be_valuating_or_later
end
budget.phase = "reviewing" it "returns true after valuating" do
expect(budget).not_to be_balloting_or_later budget.phase = "publishing_prices"
expect(budget).to be_valuating_or_later
end
end
budget.phase = "selecting" describe "#publishing_prices_or_later?" do
expect(budget).not_to be_balloting_or_later it "returns false before publishing prices" do
budget.phase = "valuating"
expect(budget).not_to be_publishing_prices_or_later
end
budget.phase = "valuating" it "returns true while publishing prices" do
expect(budget).not_to be_balloting_or_later budget.phase = "publishing_prices"
expect(budget).to be_publishing_prices_or_later
end
budget.phase = "publishing_prices" it "returns true after publishing prices" do
expect(budget).not_to be_balloting_or_later budget.phase = "balloting"
expect(budget).to be_publishing_prices_or_later
end
end
budget.phase = "balloting" describe "#balloting_or_later?" do
expect(budget).to be_balloting_or_later it "returns false before balloting" do
budget.phase = "publishing_prices"
expect(budget).not_to be_balloting_or_later
end
budget.phase = "reviewing_ballots" it "returns true while balloting" do
expect(budget).to be_balloting_or_later budget.phase = "balloting"
expect(budget).to be_balloting_or_later
end
budget.phase = "finished" it "returns true after balloting" do
expect(budget).to be_balloting_or_later budget.phase = "finished"
expect(budget).to be_balloting_or_later
end
end end
end end

View File

@@ -1,44 +1,288 @@
require "rails_helper" require "rails_helper"
describe Poll::Stats do describe Poll::Stats do
let(:poll) { create(:poll) }
let(:stats) { Poll::Stats.new(poll) }
describe "#generate" do describe "#participants" do
it "generates the correct stats" do it "includes hidden users" do
poll = create(:poll) create(:poll_voter, poll: poll)
2.times { create(:poll_voter, :from_web, poll: poll) } create(:poll_voter, poll: poll, user: create(:user, :level_two, hidden_at: Time.current))
3.times { create(:poll_voter, :from_booth, poll: poll) }
create(:poll_recount, :from_booth, poll: poll,
white_amount: 1, null_amount: 0, total_amount: 2)
stats = Poll::Stats.new(poll).generate expect(stats.participants.count).to eq(2)
expect(stats[:total_participants]).to eq(5)
expect(stats[:total_participants_web]).to eq(2)
expect(stats[:total_participants_booth]).to eq(3)
expect(stats[:total_valid_votes]).to eq(4)
expect(stats[:total_white_votes]).to eq(1)
expect(stats[:total_null_votes]).to eq(0)
expect(stats[:total_web_valid]).to eq(2)
expect(stats[:total_web_white]).to eq(0)
expect(stats[:total_web_null]).to eq(0)
expect(stats[:total_booth_valid]).to eq(2)
expect(stats[:total_booth_white]).to eq(1)
expect(stats[:total_booth_null]).to eq(0)
expect(stats[:total_participants_web_percentage]).to eq(40)
expect(stats[:total_participants_booth_percentage]).to eq(60)
expect(stats[:valid_percentage_web]).to eq(50)
expect(stats[:white_percentage_web]).to eq(0)
expect(stats[:null_percentage_web]).to eq(0)
expect(stats[:valid_percentage_booth]).to eq(50)
expect(stats[:white_percentage_booth]).to eq(100)
expect(stats[:null_percentage_booth]).to eq(0)
expect(stats[:total_valid_percentage]).to eq(80)
expect(stats[:total_white_percentage]).to eq(20)
expect(stats[:total_null_percentage]).to eq(0)
end end
end end
end describe "total participants" do
before { allow(stats).to receive(:total_web_white).and_return(1) }
it "supports every channel" do
3.times { create(:poll_voter, :from_web, poll: poll) }
create(:poll_recount, :from_booth, poll: poll,
total_amount: 8, white_amount: 4, null_amount: 1)
expect(stats.total_participants_web).to eq(3)
expect(stats.total_participants_booth).to eq(13)
expect(stats.total_participants).to eq(16)
end
end
describe "#total_participants_booth" do
it "uses recounts even if there are discrepancies when recounting" do
create(:poll_recount, :from_booth, poll: poll, total_amount: 1)
2.times { create(:poll_voter, :from_booth, poll: poll) }
expect(stats.total_participants_booth).to eq(1)
end
end
describe "total participants percentage by channel" do
it "is relative to the total amount of participants" do
create(:poll_voter, :from_web, poll: poll)
create(:poll_recount, :from_booth, poll: poll, total_amount: 5)
expect(stats.total_participants_web_percentage).to eq(16.667)
expect(stats.total_participants_booth_percentage).to eq(83.333)
end
end
describe "#total_web_valid" do
before { allow(stats).to receive(:total_web_white).and_return(1) }
it "returns only valid votes" do
3.times { create(:poll_voter, :from_web, poll: poll) }
expect(stats.total_web_valid).to eq(2)
end
end
describe "#total_web_white" do
pending "Too complex to test"
end
describe "#total_web_null" do
it "returns 0" do
expect(stats.total_web_null).to eq(0)
end
end
describe "#total_booth_valid" do
it "sums the total amounts in the recounts" do
create(:poll_recount, :from_booth, poll: poll, total_amount: 3, white_amount: 1)
create(:poll_recount, :from_booth, poll: poll, total_amount: 4, null_amount: 2)
expect(stats.total_booth_valid).to eq(7)
end
end
describe "#total_booth_white" do
it "sums the white amounts in the recounts" do
create(:poll_recount, :from_booth, poll: poll, white_amount: 120, total_amount: 3)
create(:poll_recount, :from_booth, poll: poll, white_amount: 203, null_amount: 5)
expect(stats.total_booth_white).to eq(323)
end
end
describe "#total_booth_null" do
it "sums the null amounts in the recounts" do
create(:poll_recount, :from_booth, poll: poll, null_amount: 125, total_amount: 3)
create(:poll_recount, :from_booth, poll: poll, null_amount: 34, white_amount: 5)
expect(stats.total_booth_null).to eq(159)
end
end
describe "valid percentage by channel" do
it "is relative to the total amount of valid votes" do
create(:poll_recount, :from_booth, poll: poll, total_amount: 2)
create(:poll_voter, :from_web, poll: poll)
expect(stats.valid_percentage_web).to eq(33.333)
expect(stats.valid_percentage_booth).to eq(66.667)
end
end
describe "white percentage by channel" do
before { allow(stats).to receive(:total_web_white).and_return(10) }
it "is relative to the total amount of white votes" do
create(:poll_recount, :from_booth, poll: poll, white_amount: 70)
expect(stats.white_percentage_web).to eq(12.5)
expect(stats.white_percentage_booth).to eq(87.5)
end
end
describe "null percentage by channel" do
it "only accepts null votes from booth" do
create(:poll_recount, :from_booth, poll: poll, null_amount: 70)
expect(stats.null_percentage_web).to eq(0)
expect(stats.null_percentage_booth).to eq(100)
end
end
describe "#total_valid_votes" do
it "counts valid votes from every channel" do
2.times { create(:poll_voter, :from_web, poll: poll) }
create(:poll_recount, :from_booth, poll: poll, total_amount: 3, white_amount: 10)
create(:poll_recount, :from_booth, poll: poll, total_amount: 4, null_amount: 20)
expect(stats.total_valid_votes).to eq(9)
end
end
describe "#total_white_votes" do
before { allow(stats).to receive(:total_web_white).and_return(9) }
it "counts white votes on every channel" do
create(:poll_recount, :from_booth, poll: poll, white_amount: 12)
expect(stats.total_white_votes).to eq(21)
end
end
describe "#total_null_votes" do
it "only accepts null votes from booth" do
create(:poll_recount, :from_booth, poll: poll, null_amount: 32)
expect(stats.total_null_votes).to eq(32)
end
end
describe "total percentage by type" do
before { allow(stats).to receive(:total_web_white).and_return(1) }
it "is relative to the total amount of votes" do
3.times { create(:poll_voter, :from_web, poll: poll) }
create(:poll_recount, :from_booth, poll: poll,
total_amount: 8, white_amount: 5, null_amount: 4)
expect(stats.total_valid_percentage).to eq(50)
expect(stats.total_white_percentage).to eq(30)
expect(stats.total_null_percentage).to eq(20)
end
end
describe "#participants_by_geozone" do
it "groups by geozones in alphabetic order" do
%w[Oceania Eurasia Eastasia].each { |name| create(:geozone, name: name) }
expect(stats.participants_by_geozone.keys).to eq %w[Eastasia Eurasia Oceania]
end
it "calculates percentage relative to total participants" do
hobbiton = create(:geozone, name: "Hobbiton")
rivendel = create(:geozone, name: "Rivendel")
3.times { create :poll_voter, poll: poll, user: create(:user, :level_two, geozone: hobbiton) }
2.times { create :poll_voter, poll: poll, user: create(:user, :level_two, geozone: rivendel) }
expect(stats.participants_by_geozone["Hobbiton"][:count]).to eq 3
expect(stats.participants_by_geozone["Hobbiton"][:percentage]).to eq 60.0
expect(stats.participants_by_geozone["Rivendel"][:count]).to eq 2
expect(stats.participants_by_geozone["Rivendel"][:percentage]).to eq 40.0
end
end
describe "#total_no_demographic_data" do
before do
create(:poll_voter, :from_web, poll: poll, user: create(:user, :level_two, gender: nil))
end
context "more registered participants than participants in recounts" do
before do
create(:poll_recount, :from_booth, poll: poll, total_amount: 1)
2.times { create(:poll_voter, :from_booth, poll: poll) }
end
it "returns registered users with no demographic data" do
expect(stats.total_no_demographic_data).to eq 1
end
end
context "more participants in recounts than registered participants" do
before do
create(:poll_recount, :from_booth, poll: poll, total_amount: 3)
2.times { create(:poll_voter, :from_booth, poll: poll) }
end
it "returns registered users with no demographic data plus users not registered" do
expect(stats.total_no_demographic_data).to eq 2
end
end
end
describe "#channels" do
context "no participants" do
it "returns no channels" do
expect(stats.channels).to eq []
end
end
context "only participants from web" do
before { create(:poll_voter, :from_web, poll: poll) }
it "returns the web channel" do
expect(stats.channels).to eq ["web"]
end
end
context "only participants from booth" do
before do
create(:poll_recount, :from_booth, poll: poll, total_amount: 1)
end
it "returns the booth channel" do
expect(stats.channels).to eq ["booth"]
end
end
context "only participants from letter" do
before { create(:poll_voter, origin: "letter", poll: poll) }
it "returns the web channel" do
expect(stats.channels).to eq ["letter"]
end
end
context "participants from all channels" do
before do
create(:poll_voter, :from_web, poll: poll)
create(:poll_recount, :from_booth, poll: poll, total_amount: 1)
create(:poll_voter, origin: "letter", poll: poll)
end
it "returns all channels" do
expect(stats.channels).to eq %w[web booth letter]
end
end
end
describe "#version", :with_frozen_time do
context "record with no stats" do
it "returns a string based on the current time" do
expect(stats.version).to eq "v#{Time.current.to_i}"
end
it "doesn't overwrite the timestamp when called multiple times" do
time = Time.current
expect(stats.version).to eq "v#{time.to_i}"
travel_to 2.seconds.from_now do
expect(stats.version).to eq "v#{time.to_i}"
end
end
end
context "record with stats" do
before { poll.create_stats_version(updated_at: 1.day.ago) }
it "returns the version of the existing stats" do
expect(stats.version).to eq "v#{1.day.ago.to_i}"
end
end
end
end

View File

@@ -0,0 +1,180 @@
require "rails_helper"
describe Statisticable do
class DummyStats
include Statisticable
def participants
User.all
end
end
let(:stats) { DummyStats.new(nil) }
describe "#gender?" do
context "No participants" do
it "is false" do
expect(stats.gender?).to be false
end
end
context "All participants have no defined gender" do
before { create(:user, gender: nil) }
it "is false" do
expect(stats.gender?).to be false
end
end
context "There's a male participant" do
before { create(:user, gender: "male") }
it "is true" do
expect(stats.gender?).to be true
end
end
context "There's a female participant" do
before { create(:user, gender: "female") }
it "is true" do
expect(stats.gender?).to be true
end
end
end
describe "#age?" do
context "No participants" do
it "is false" do
expect(stats.age?).to be false
end
end
context "All participants have no defined age" do
before { create(:user, date_of_birth: nil) }
it "is false" do
expect(stats.age?).to be false
end
end
context "All participants have impossible ages" do
before do
create(:user, date_of_birth: 3.seconds.ago)
create(:user, date_of_birth: 3000.years.ago)
end
it "is false" do
expect(stats.age?).to be false
end
end
context "There's a participant with a defined age" do
before { create(:user, date_of_birth: 30.years.ago) }
it "is true" do
expect(stats.age?).to be true
end
end
end
describe "#geozone?" do
context "No participants" do
it "is false" do
expect(stats.geozone?).to be false
end
end
context "All participants have no defined geozone" do
before { create(:user, geozone: nil) }
it "is false" do
expect(stats.geozone?).to be false
end
end
context "There's a participant with a defined geozone" do
before { create(:user, geozone: create(:geozone)) }
it "is true" do
expect(stats.geozone?).to be true
end
end
end
describe "#total_no_demographic_data" do
it "returns users with no defined gender" do
create(:user, gender: nil)
expect(stats.total_no_demographic_data).to be 1
end
it "returns users with no defined age" do
create(:user, gender: "female", date_of_birth: nil)
expect(stats.total_no_demographic_data).to be 1
end
it "returns users with no defined geozone" do
create(:user, gender: "female", geozone: nil)
expect(stats.total_no_demographic_data).to be 1
end
it "returns users with no defined gender, age nor geozone" do
create(:user, gender: nil, date_of_birth: nil, geozone: nil)
expect(stats.total_no_demographic_data).to be 1
end
it "doesn't return users with defined gender, age and geozone" do
create(:user, gender: "male", date_of_birth: 20.years.ago, geozone: create(:geozone))
expect(stats.total_no_demographic_data).to be 0
end
end
describe "#stats_methods" do
it "includes total participants" do
expect(stats.stats_methods).to include(:total_participants)
end
context "no gender stats" do
before { allow(stats).to receive(:gender?).and_return(false) }
it "doesn't include gender methods" do
expect(stats.stats_methods).not_to include(:total_male_participants)
end
end
context "no age stats" do
before { allow(stats).to receive(:age?).and_return(false) }
it "doesn't include age methods" do
expect(stats.stats_methods).not_to include(:participants_by_age)
end
end
context "no geozone stats" do
before { allow(stats).to receive(:geozone?).and_return(false) }
it "doesn't include age methods" do
expect(stats.stats_methods).not_to include(:participants_by_geozone)
end
end
context "all gender, age and geozone stats" do
before do
allow(stats).to receive(:gender?).and_return(true)
allow(stats).to receive(:age?).and_return(true)
allow(stats).to receive(:geozone?).and_return(true)
end
it "includes all stats methods" do
expect(stats.stats_methods).to include(:total_male_participants)
expect(stats.stats_methods).to include(:participants_by_age)
expect(stats.stats_methods).to include(:participants_by_geozone)
end
end
end
end

View File

@@ -0,0 +1,13 @@
require "rails_helper"
describe StatsVersion do
describe "validations" do
it "is valid with a process" do
expect(StatsVersion.new(process: Budget.new)).to be_valid
end
it "is not valid without a process" do
expect(StatsVersion.new(process: nil)).not_to be_valid
end
end
end