".html_safe +
+ "".html_safe +
valuators.first.name +
"".html_safe
else
"".html_safe +
- t('valuation.spending_proposals.index.valuators_assigned', count: valuators.size) +
+ t('valuation.budget_investments.index.valuators_assigned', count: valuators.size) +
"".html_safe
end
end
diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb
index c1f3e4bb6..ea1d4fa40 100644
--- a/app/mailers/application_mailer.rb
+++ b/app/mailers/application_mailer.rb
@@ -1,5 +1,5 @@
class ApplicationMailer < ActionMailer::Base
helper :settings
- default from: "participacion@madrid.es"
+ default from: "#{Setting['mailer_from_name']} <#{Setting['mailer_from_address']}>"
layout 'mailer'
end
diff --git a/app/mailers/mailer.rb b/app/mailers/mailer.rb
index 82abbb205..7e8970c61 100644
--- a/app/mailers/mailer.rb
+++ b/app/mailers/mailer.rb
@@ -42,6 +42,73 @@ class Mailer < ApplicationMailer
end
end
+ def direct_message_for_receiver(direct_message)
+ @direct_message = direct_message
+ @receiver = @direct_message.receiver
+
+ with_user(@receiver) do
+ mail(to: @receiver.email, subject: t('mailers.direct_message_for_receiver.subject'))
+ end
+ end
+
+ def direct_message_for_sender(direct_message)
+ @direct_message = direct_message
+ @sender = @direct_message.sender
+
+ with_user(@sender) do
+ mail(to: @sender.email, subject: t('mailers.direct_message_for_sender.subject'))
+ end
+ end
+
+ def proposal_notification_digest(user, notifications)
+ @notifications = notifications
+
+ with_user(user) do
+ mail(to: user.email, subject: t('mailers.proposal_notification_digest.title', org_name: Setting['org_name']))
+ end
+ end
+
+ def user_invite(email)
+ I18n.with_locale(I18n.default_locale) do
+ mail(to: email, subject: t('mailers.user_invite.subject', org_name: Setting["org_name"]))
+ end
+ end
+
+ def budget_investment_created(investment)
+ @investment = investment
+
+ with_user(@investment.author) do
+ mail(to: @investment.author.email, subject: t('mailers.budget_investment_created.subject'))
+ end
+ end
+
+ def budget_investment_unfeasible(investment)
+ @investment = investment
+ @author = investment.author
+
+ with_user(@author) do
+ mail(to: @author.email, subject: t('mailers.budget_investment_unfeasible.subject', code: @investment.code))
+ end
+ end
+
+ def budget_investment_selected(investment)
+ @investment = investment
+ @author = investment.author
+
+ with_user(@author) do
+ mail(to: @author.email, subject: t('mailers.budget_investment_selected.subject', code: @investment.code))
+ end
+ end
+
+ def budget_investment_unselected(investment)
+ @investment = investment
+ @author = investment.author
+
+ with_user(@author) do
+ mail(to: @author.email, subject: t('mailers.budget_investment_unselected.subject', code: @investment.code))
+ end
+ end
+
private
def with_user(user, &block)
diff --git a/app/models/abilities/administrator.rb b/app/models/abilities/administrator.rb
index 1b440795f..8df52e6eb 100644
--- a/app/models/abilities/administrator.rb
+++ b/app/models/abilities/administrator.rb
@@ -4,7 +4,6 @@ module Abilities
def initialize(user)
self.merge Abilities::Moderation.new(user)
- self.merge Abilities::Valuator.new(user)
can :restore, Comment
cannot :restore, Comment, hidden_at: nil
@@ -30,14 +29,41 @@ module Abilities
can :confirm_hide, User
cannot :confirm_hide, User, hidden_at: nil
- can :comment_as_administrator, [Debate, Comment, Proposal]
+ can :mark_featured, Debate
+ can :unmark_featured, Debate
+
+ can :comment_as_administrator, [Debate, Comment, Proposal, Poll::Question, Budget::Investment]
can [:search, :create, :index, :destroy], ::Moderator
- can [:search, :create, :index], ::Valuator
+ can [:search, :create, :index, :summary], ::Valuator
+ can [:search, :create, :index, :destroy], ::Manager
can :manage, Annotation
- can [:read, :update, :destroy], SpendingProposal
+ can [:read, :update, :valuate, :destroy, :summary], SpendingProposal
+
+ can [:index, :read, :new, :create, :update, :destroy], Budget
+ can [:read, :create, :update, :destroy], Budget::Group
+ can [:read, :create, :update, :destroy], Budget::Heading
+ can [:hide, :update, :toggle_selection], Budget::Investment
+ can :valuate, Budget::Investment
+ can :create, Budget::ValuatorAssignment
+
+ can [:search, :edit, :update, :create, :index, :destroy], Banner
+
+ can [:index, :create, :edit, :update, :destroy], Geozone
+
+ can [:read, :create, :update, :destroy, :add_question, :remove_question, :search_booths, :search_questions, :search_officers], Poll
+ can [:read, :create, :update, :destroy], Poll::Booth
+ can [:search, :create, :index, :destroy], ::Poll::Officer
+ can [:create, :destroy], ::Poll::BoothAssignment
+ can [:create, :destroy], ::Poll::OfficerAssignment
+ can [:read, :create, :update], Poll::Question
+ can :destroy, Poll::Question # , comments_count: 0, votes_up: 0
+
+ can :manage, SiteCustomization::Page
+ can :manage, SiteCustomization::Image
+ can :manage, SiteCustomization::ContentBlock
end
end
end
diff --git a/app/models/abilities/common.rb b/app/models/abilities/common.rb
index 5bb43725b..6a8ef594c 100644
--- a/app/models/abilities/common.rb
+++ b/app/models/abilities/common.rb
@@ -16,8 +16,7 @@ module Abilities
can :update, Proposal do |proposal|
proposal.editable_by?(user)
end
-
- can :read, SpendingProposal
+ can [:retire_form, :retire], Proposal, author_id: user.id
can :create, Comment
can :create, Debate
@@ -45,11 +44,27 @@ module Abilities
can :vote_featured, Proposal
can :vote, SpendingProposal
can :create, SpendingProposal
+
+ can :create, Budget::Investment, budget: { phase: "accepting" }
+ can :destroy, Budget::Investment, budget: { phase: ["accepting", "reviewing"] }, author_id: user.id
+ can :vote, Budget::Investment, budget: { phase: "selecting" }
+ can [:show, :create], Budget::Ballot, budget: { phase: "balloting" }
+ can [:create, :destroy], Budget::Ballot::Line, budget: { phase: "balloting" }
+
+ can :create, DirectMessage
+ can :show, DirectMessage, sender_id: user.id
+ can :answer, Poll do |poll|
+ poll.answerable_by?(user)
+ end
+ can :answer, Poll::Question do |question|
+ question.answerable_by?(user)
+ end
end
+ can [:create, :show], ProposalNotification, proposal: { author_id: user.id }
+
can :create, Annotation
can [:update, :destroy], Annotation, user_id: user.id
-
end
end
end
diff --git a/app/models/abilities/everyone.rb b/app/models/abilities/everyone.rb
index 21e142c05..c2b6cd6d7 100644
--- a/app/models/abilities/everyone.rb
+++ b/app/models/abilities/everyone.rb
@@ -6,10 +6,18 @@ module Abilities
can [:read, :map], Debate
can [:read, :map, :summary], Proposal
can :read, Comment
+ can :read, Poll
+ can :read, Poll::Question
+ can [:read, :welcome], Budget
+ can :read, Budget::Investment
can :read, SpendingProposal
can :read, Legislation
can :read, User
can [:search, :read], Annotation
+ can [:read], Budget
+ can [:read], Budget::Group
+ can [:read, :print], Budget::Investment
+ can :new, DirectMessage
end
end
end
diff --git a/app/models/abilities/moderator.rb b/app/models/abilities/moderator.rb
index f6c5c5004..5740e302e 100644
--- a/app/models/abilities/moderator.rb
+++ b/app/models/abilities/moderator.rb
@@ -5,7 +5,7 @@ module Abilities
def initialize(user)
self.merge Abilities::Moderation.new(user)
- can :comment_as_moderator, [Debate, Comment, Proposal]
+ can :comment_as_moderator, [Debate, Comment, Proposal, Budget::Investment, Poll::Question]
end
end
end
diff --git a/app/models/abilities/valuator.rb b/app/models/abilities/valuator.rb
index 15add866a..614869665 100644
--- a/app/models/abilities/valuator.rb
+++ b/app/models/abilities/valuator.rb
@@ -3,7 +3,10 @@ module Abilities
include CanCan::Ability
def initialize(user)
+ valuator = user.valuator
can [:read, :update, :valuate], SpendingProposal
+ can [:read, :update, :valuate], Budget::Investment, id: valuator.investment_ids
+ cannot [:update, :valuate], Budget::Investment, budget: { phase: 'finished' }
end
end
-end
\ No newline at end of file
+end
diff --git a/app/models/activity.rb b/app/models/activity.rb
index 047ccb7dd..0fc35ad11 100644
--- a/app/models/activity.rb
+++ b/app/models/activity.rb
@@ -2,7 +2,7 @@ class Activity < ActiveRecord::Base
belongs_to :actionable, -> { with_hidden }, polymorphic: true
belongs_to :user, -> { with_hidden }
- VALID_ACTIONS = %w( hide block restore )
+ VALID_ACTIONS = %w( hide block restore valuate )
validates :action, inclusion: {in: VALID_ACTIONS}
@@ -10,6 +10,7 @@ class Activity < ActiveRecord::Base
scope :on_debates, -> { where(actionable_type: 'Debate') }
scope :on_users, -> { where(actionable_type: 'User') }
scope :on_comments, -> { where(actionable_type: 'Comment') }
+ scope :on_budget_investments, -> { where(actionable_type: 'Budget::Investment') }
scope :for_render, -> { includes(user: [:moderator, :administrator]).includes(:actionable) }
def self.log(user, action, actionable)
diff --git a/app/models/banner.rb b/app/models/banner.rb
new file mode 100644
index 000000000..c4f2295e6
--- /dev/null
+++ b/app/models/banner.rb
@@ -0,0 +1,20 @@
+class Banner < ActiveRecord::Base
+
+ acts_as_paranoid column: :hidden_at
+ include ActsAsParanoidAliases
+
+ validates :title, presence: true,
+ length: { minimum: 2 }
+ validates :description, presence: true
+ validates :target_url, presence: true
+ validates :style, presence: true
+ validates :image, presence: true
+ validates :post_started_at, presence: true
+ validates :post_ended_at, presence: true
+
+ scope :with_active, -> {where("post_started_at <= ?", Time.current).
+ where("post_ended_at >= ?", Time.current) }
+
+ scope :with_inactive,-> {where("post_started_at > ? or post_ended_at < ?", Time.current, Time.current) }
+
+end
diff --git a/app/models/budget.rb b/app/models/budget.rb
new file mode 100644
index 000000000..a5932921c
--- /dev/null
+++ b/app/models/budget.rb
@@ -0,0 +1,138 @@
+class Budget < ActiveRecord::Base
+
+ include Measurable
+
+ PHASES = %w(accepting reviewing selecting valuating balloting reviewing_ballots finished).freeze
+ CURRENCY_SYMBOLS = %w(€ $ £ ¥).freeze
+
+ validates :name, presence: true
+ validates :phase, inclusion: { in: PHASES }
+ validates :currency_symbol, presence: true
+
+ has_many :investments, dependent: :destroy
+ has_many :ballots, dependent: :destroy
+ has_many :groups, dependent: :destroy
+ has_many :headings, through: :groups
+
+ before_validation :sanitize_descriptions
+
+ scope :on_hold, -> { where(phase: %w(reviewing valuating reviewing_ballots")) }
+ scope :accepting, -> { where(phase: "accepting") }
+ scope :reviewing, -> { where(phase: "reviewing") }
+ scope :selecting, -> { where(phase: "selecting") }
+ scope :valuating, -> { where(phase: "valuating") }
+ scope :balloting, -> { where(phase: "balloting") }
+ scope :reviewing_ballots, -> { where(phase: "reviewing_ballots") }
+ scope :finished, -> { where(phase: "finished") }
+
+ scope :current, -> { where.not(phase: "finished") }
+
+ def description
+ self.send("description_#{self.phase}").try(:html_safe)
+ end
+
+ def self.description_max_length
+ 2000
+ end
+
+ def self.title_max_length
+ 80
+ end
+
+ def accepting?
+ phase == "accepting"
+ end
+
+ def reviewing?
+ phase == "reviewing"
+ end
+
+ def selecting?
+ phase == "selecting"
+ end
+
+ def valuating?
+ phase == "valuating"
+ end
+
+ def balloting?
+ phase == "balloting"
+ end
+
+ def reviewing_ballots?
+ phase == "reviewing_ballots"
+ end
+
+ def finished?
+ phase == "finished"
+ end
+
+ def balloting_or_later?
+ balloting? || reviewing_ballots? || finished?
+ end
+
+ def on_hold?
+ reviewing? || valuating? || reviewing_ballots?
+ end
+
+ def current?
+ !finished?
+ end
+
+ def heading_price(heading)
+ heading_ids.include?(heading.id) ? heading.price : -1
+ end
+
+ def translated_phase
+ I18n.t "budgets.phase.#{phase}"
+ end
+
+ def formatted_amount(amount)
+ ActionController::Base.helpers.number_to_currency(amount,
+ precision: 0,
+ locale: I18n.default_locale,
+ unit: currency_symbol)
+ end
+
+ def formatted_heading_price(heading)
+ formatted_amount(heading_price(heading))
+ end
+
+ def formatted_heading_amount_spent(heading)
+ formatted_amount(amount_spent(heading))
+ end
+
+ def investments_orders
+ case phase
+ when 'accepting', 'reviewing'
+ %w{random}
+ when 'balloting', 'reviewing_ballots'
+ %w{random price}
+ else
+ %w{random confidence_score}
+ end
+ end
+
+ def email_selected
+ investments.selected.each do |investment|
+ Mailer.budget_investment_selected(investment).deliver_later
+ end
+ end
+
+ def email_unselected
+ investments.unselected.each do |investment|
+ Mailer.budget_investment_unselected(investment).deliver_later
+ end
+ end
+
+ private
+
+ def sanitize_descriptions
+ s = WYSIWYGSanitizer.new
+ PHASES.each do |phase|
+ sanitized = s.sanitize(self.send("description_#{phase}"))
+ self.send("description_#{phase}=", sanitized)
+ end
+ end
+end
+
diff --git a/app/models/budget/ballot.rb b/app/models/budget/ballot.rb
new file mode 100644
index 000000000..83e799d6f
--- /dev/null
+++ b/app/models/budget/ballot.rb
@@ -0,0 +1,74 @@
+class Budget
+ class Ballot < ActiveRecord::Base
+ belongs_to :user
+ belongs_to :budget
+
+ has_many :lines, dependent: :destroy
+ has_many :investments, through: :lines
+ has_many :groups, -> { uniq }, through: :lines
+ has_many :headings, -> { uniq }, through: :groups
+
+ def add_investment(investment)
+ lines.create(investment: investment).persisted?
+ end
+
+ def total_amount_spent
+ investments.sum(:price).to_i
+ end
+
+ def amount_spent(heading)
+ investments.by_heading(heading.id).sum(:price).to_i
+ end
+
+ def formatted_amount_spent(heading)
+ budget.formatted_amount(amount_spent(heading))
+ end
+
+ def amount_available(heading)
+ budget.heading_price(heading) - amount_spent(heading)
+ end
+
+ def formatted_amount_available(heading)
+ budget.formatted_amount(amount_available(heading))
+ end
+
+ def has_lines_in_group?(group)
+ self.groups.include?(group)
+ end
+
+ def wrong_budget?(heading)
+ heading.budget_id != budget_id
+ end
+
+ def different_heading_assigned?(heading)
+ other_heading_ids = heading.group.heading_ids - [heading.id]
+ lines.where(heading_id: other_heading_ids).exists?
+ end
+
+ def valid_heading?(heading)
+ !wrong_budget?(heading) && !different_heading_assigned?(heading)
+ end
+
+ def has_lines_with_no_heading?
+ investments.no_heading.count > 0
+ end
+
+ def has_lines_with_heading?
+ self.heading_id.present?
+ end
+
+ def has_lines_in_heading?(heading)
+ investments.by_heading(heading.id).any?
+ end
+
+ def has_investment?(investment)
+ self.investment_ids.include?(investment.id)
+ end
+
+ def heading_for_group(group)
+ return nil unless has_lines_in_group?(group)
+ self.investments.where(group: group).first.heading
+ end
+
+ end
+end
diff --git a/app/models/budget/ballot/line.rb b/app/models/budget/ballot/line.rb
new file mode 100644
index 000000000..175e9886d
--- /dev/null
+++ b/app/models/budget/ballot/line.rb
@@ -0,0 +1,41 @@
+class Budget
+ class Ballot
+ class Line < ActiveRecord::Base
+ belongs_to :ballot
+ belongs_to :investment, counter_cache: :ballot_lines_count
+ belongs_to :heading
+ belongs_to :group
+ belongs_to :budget
+
+ validates :ballot_id, :investment_id, :heading_id, :group_id, :budget_id, presence: true
+
+ validate :check_selected
+ validate :check_sufficient_funds
+ validate :check_valid_heading
+
+ scope :by_investment, -> (investment_id) { where(investment_id: investment_id) }
+
+ before_validation :set_denormalized_ids
+
+ def check_sufficient_funds
+ errors.add(:money, "insufficient funds") if ballot.amount_available(investment.heading) < investment.price.to_i
+ end
+
+ def check_valid_heading
+ errors.add(:heading, "This heading's budget is invalid, or a heading on the same group was already selected") unless ballot.valid_heading?(self.heading)
+ end
+
+ def check_selected
+ errors.add(:investment, "unselected investment") unless investment.selected?
+ end
+
+ private
+
+ def set_denormalized_ids
+ self.heading_id ||= self.investment.try(:heading_id)
+ self.group_id ||= self.investment.try(:group_id)
+ self.budget_id ||= self.investment.try(:budget_id)
+ end
+ end
+ end
+end
diff --git a/app/models/budget/group.rb b/app/models/budget/group.rb
new file mode 100644
index 000000000..dd7910950
--- /dev/null
+++ b/app/models/budget/group.rb
@@ -0,0 +1,10 @@
+class Budget
+ class Group < ActiveRecord::Base
+ belongs_to :budget
+
+ has_many :headings, dependent: :destroy
+
+ validates :budget_id, presence: true
+ validates :name, presence: true
+ end
+end
\ No newline at end of file
diff --git a/app/models/budget/heading.rb b/app/models/budget/heading.rb
new file mode 100644
index 000000000..a81308947
--- /dev/null
+++ b/app/models/budget/heading.rb
@@ -0,0 +1,20 @@
+class Budget
+ class Heading < ActiveRecord::Base
+ belongs_to :group
+
+ has_many :investments
+
+ validates :group_id, presence: true
+ validates :name, presence: true
+ validates :price, presence: true
+
+ delegate :budget, :budget_id, to: :group, allow_nil: true
+
+ scope :order_by_group_name, -> { includes(:group).order('budget_groups.name', 'budget_headings.name') }
+
+ def name_scoped_by_group
+ "#{group.name}: #{name}"
+ end
+
+ end
+end
diff --git a/app/models/budget/investment.rb b/app/models/budget/investment.rb
new file mode 100644
index 000000000..56a50deec
--- /dev/null
+++ b/app/models/budget/investment.rb
@@ -0,0 +1,255 @@
+class Budget
+ class Investment < ActiveRecord::Base
+
+ include Measurable
+ include Sanitizable
+ include Taggable
+ include Searchable
+ include Reclassification
+
+ acts_as_votable
+ acts_as_paranoid column: :hidden_at
+ include ActsAsParanoidAliases
+
+ belongs_to :author, -> { with_hidden }, class_name: 'User', foreign_key: 'author_id'
+ belongs_to :heading
+ belongs_to :group
+ belongs_to :budget
+ belongs_to :administrator
+
+ has_many :valuator_assignments, dependent: :destroy
+ has_many :valuators, through: :valuator_assignments
+ has_many :comments, as: :commentable
+
+ validates :title, presence: true
+ validates :author, presence: true
+ validates :description, presence: true
+ validates :heading_id, presence: true
+ validates_presence_of :unfeasibility_explanation, if: :unfeasibility_explanation_required?
+ validates_presence_of :price, if: :price_required?
+
+ validates :title, length: { in: 4..Budget::Investment.title_max_length }
+ validates :description, length: { maximum: Budget::Investment.description_max_length }
+ validates :terms_of_service, acceptance: { allow_nil: false }, on: :create
+
+ scope :sort_by_confidence_score, -> { reorder(confidence_score: :desc, id: :desc) }
+ scope :sort_by_ballots, -> { reorder(ballot_lines_count: :desc, id: :desc) }
+ scope :sort_by_price, -> { reorder(price: :desc, confidence_score: :desc, id: :desc) }
+ scope :sort_by_random, -> { reorder("RANDOM()") }
+
+ scope :valuation_open, -> { where(valuation_finished: false) }
+ scope :without_admin, -> { valuation_open.where(administrator_id: nil) }
+ scope :managed, -> { valuation_open.where(valuator_assignments_count: 0).where("administrator_id IS NOT ?", nil) }
+ scope :valuating, -> { valuation_open.where("valuator_assignments_count > 0 AND valuation_finished = ?", false) }
+ scope :valuation_finished, -> { where(valuation_finished: true) }
+ scope :valuation_finished_feasible, -> { where(valuation_finished: true, feasibility: "feasible") }
+ scope :feasible, -> { where(feasibility: "feasible") }
+ scope :unfeasible, -> { where(feasibility: "unfeasible") }
+ scope :not_unfeasible, -> { where.not(feasibility: "unfeasible") }
+ scope :undecided, -> { where(feasibility: "undecided") }
+ scope :with_supports, -> { where('cached_votes_up > 0') }
+ scope :selected, -> { feasible.where(selected: true) }
+ scope :unselected, -> { not_unfeasible.where(selected: false) }
+ scope :last_week, -> { where("created_at >= ?", 7.days.ago)}
+
+ scope :by_group, -> (group_id) { where(group_id: group_id) }
+ scope :by_heading, -> (heading_id) { where(heading_id: heading_id) }
+ scope :by_admin, -> (admin_id) { where(administrator_id: admin_id) }
+ scope :by_tag, -> (tag_name) { tagged_with(tag_name) }
+ scope :by_valuator, -> (valuator_id) { where("budget_valuator_assignments.valuator_id = ?", valuator_id).joins(:valuator_assignments) }
+
+ scope :for_render, -> { includes(:heading) }
+
+ before_save :calculate_confidence_score
+ before_validation :set_responsible_name
+ before_validation :set_denormalized_ids
+
+ def self.filter_params(params)
+ params.select{|x,_| %w{heading_id group_id administrator_id tag_name valuator_id}.include? x.to_s }
+ end
+
+ def self.scoped_filter(params, current_filter)
+ results = Investment.where(budget_id: params[:budget_id])
+ results = results.where(group_id: params[:group_id]) if params[:group_id].present?
+ results = results.by_heading(params[:heading_id]) if params[:heading_id].present?
+ results = results.by_admin(params[:administrator_id]) if params[:administrator_id].present?
+ results = results.by_tag(params[:tag_name]) if params[:tag_name].present?
+ results = results.by_valuator(params[:valuator_id]) if params[:valuator_id].present?
+ results = results.send(current_filter) if current_filter.present?
+ results.includes(:heading, :group, :budget, administrator: :user, valuators: :user)
+ end
+
+ def searchable_values
+ { title => 'A',
+ author.username => 'B',
+ heading.try(:name) => 'B',
+ tag_list.join(' ') => 'B',
+ description => 'C'
+ }
+ end
+
+ def self.search(terms)
+ self.pg_search(terms)
+ end
+
+ def self.by_heading(heading)
+ where(heading_id: heading == 'all' ? nil : heading.presence)
+ end
+
+ def undecided?
+ feasibility == "undecided"
+ end
+
+ def feasible?
+ feasibility == "feasible"
+ end
+
+ def unfeasible?
+ feasibility == "unfeasible"
+ end
+
+ def unfeasibility_explanation_required?
+ unfeasible? && valuation_finished?
+ end
+
+ def price_required?
+ feasible? && valuation_finished?
+ end
+
+ def unfeasible_email_pending?
+ unfeasible_email_sent_at.blank? && unfeasible? && valuation_finished?
+ end
+
+ def total_votes
+ cached_votes_up + physical_votes
+ end
+
+ def code
+ "#{created_at.strftime('%Y')}-#{id}" + (administrator.present? ? "-A#{administrator.id}" : "")
+ end
+
+ def send_unfeasible_email
+ Mailer.budget_investment_unfeasible(self).deliver_later
+ update(unfeasible_email_sent_at: Time.current)
+ end
+
+ def reason_for_not_being_selectable_by(user)
+ return permission_problem(user) if permission_problem?(user)
+ return :different_heading_assigned unless valid_heading?(user)
+
+ return :no_selecting_allowed unless budget.selecting?
+ end
+
+ def reason_for_not_being_ballotable_by(user, ballot)
+ return permission_problem(user) if permission_problem?(user)
+ return :not_selected unless selected?
+ return :no_ballots_allowed unless budget.balloting?
+ return :different_heading_assigned unless ballot.valid_heading?(heading)
+ return :not_enough_money if ballot.present? && !enough_money?(ballot)
+ end
+
+ def permission_problem(user)
+ return :not_logged_in unless user
+ return :organization if user.organization?
+ return :not_verified unless user.can?(:vote, Budget::Investment)
+ return nil
+ end
+
+ def permission_problem?(user)
+ permission_problem(user).present?
+ end
+
+ def selectable_by?(user)
+ reason_for_not_being_selectable_by(user).blank?
+ end
+
+ def valid_heading?(user)
+ !different_heading_assigned?(user)
+ end
+
+ def different_heading_assigned?(user)
+ other_heading_ids = group.heading_ids - [heading.id]
+ voted_in?(other_heading_ids, user)
+ end
+
+ def voted_in?(heading_ids, user)
+ heading_ids.include? heading_voted_by_user?(user)
+ end
+
+ def heading_voted_by_user?(user)
+ user.votes.for_budget_investments(budget.investments.where(group: group)).
+ votables.map(&:heading_id).first
+ end
+
+ def ballotable_by?(user)
+ reason_for_not_being_ballotable_by(user).blank?
+ end
+
+ def enough_money?(ballot)
+ available_money = ballot.amount_available(self.heading)
+ price.to_i <= available_money
+ end
+
+ def register_selection(user)
+ vote_by(voter: user, vote: 'yes') if selectable_by?(user)
+ end
+
+ def calculate_confidence_score
+ self.confidence_score = ScoreCalculator.confidence_score(total_votes, total_votes)
+ end
+
+ def set_responsible_name
+ self.responsible_name = author.try(:document_number) if author.try(:document_number).present?
+ end
+
+ def should_show_aside?
+ (budget.selecting? && !unfeasible?) ||
+ (budget.balloting? && feasible?) ||
+ (budget.valuating? && !unfeasible?)
+ end
+
+ def should_show_votes?
+ budget.selecting?
+ end
+
+ def should_show_vote_count?
+ budget.valuating?
+ end
+
+ def should_show_ballots?
+ budget.balloting? && selected?
+ end
+
+ def should_show_price?
+ feasible? &&
+ selected? &&
+ (budget.reviewing_ballots? || budget.finished?)
+ end
+
+ def should_show_price_info?
+ feasible? &&
+ price_explanation.present? &&
+ (budget.balloting? || budget.reviewing_ballots? || budget.finished?)
+ end
+
+ def formatted_price
+ budget.formatted_amount(price)
+ end
+
+ def self.apply_filters_and_search(budget, params, current_filter=nil)
+ investments = all
+ investments = investments.send(current_filter) if current_filter.present?
+ investments = investments.by_heading(params[:heading_id]) if params[:heading_id].present?
+ investments = investments.search(params[:search]) if params[:search].present?
+ investments
+ end
+
+ private
+
+ def set_denormalized_ids
+ self.group_id = self.heading.try(:group_id) if self.heading_id_changed?
+ self.budget_id ||= self.heading.try(:group).try(:budget_id)
+ end
+
+ end
+end
diff --git a/app/models/budget/reclassification.rb b/app/models/budget/reclassification.rb
new file mode 100644
index 000000000..f8d1db7fd
--- /dev/null
+++ b/app/models/budget/reclassification.rb
@@ -0,0 +1,50 @@
+class Budget
+ module Reclassification
+ extend ActiveSupport::Concern
+
+ included do
+ after_save :check_for_reclassification
+ end
+
+ def check_for_reclassification
+ if heading_changed?
+ log_heading_change
+ store_reclassified_votes("heading_changed")
+ remove_reclassified_votes
+ elsif marked_as_unfeasible?
+ store_reclassified_votes("unfeasible")
+ remove_reclassified_votes
+ end
+ end
+
+ def heading_changed?
+ budget.balloting? && heading_id_changed?
+ end
+
+ def marked_as_unfeasible?
+ budget.balloting? && feasibility_changed? && unfeasible?
+ end
+
+ def log_heading_change
+ update_column(:previous_heading_id, heading_id_was)
+ end
+
+ def store_reclassified_votes(reason)
+ ballot_lines_for_investment.each do |line|
+ attrs = { user: line.ballot.user,
+ investment: self,
+ reason: reason }
+ Budget::ReclassifiedVote.create!(attrs)
+ end
+ end
+
+ def remove_reclassified_votes
+ ballot_lines_for_investment.destroy_all
+ end
+
+ def ballot_lines_for_investment
+ Budget::Ballot::Line.by_investment(self.id)
+ end
+
+ end
+end
\ No newline at end of file
diff --git a/app/models/budget/reclassified_vote.rb b/app/models/budget/reclassified_vote.rb
new file mode 100644
index 000000000..0eb44d8cc
--- /dev/null
+++ b/app/models/budget/reclassified_vote.rb
@@ -0,0 +1,12 @@
+class Budget
+ class ReclassifiedVote < ActiveRecord::Base
+ REASONS = %w(heading_changed unfeasible)
+
+ belongs_to :user
+ belongs_to :investment
+
+ validates :user, presence: true
+ validates :investment, presence: true
+ validates :reason, inclusion: {in: REASONS, allow_nil: false}
+ end
+end
\ No newline at end of file
diff --git a/app/models/budget/result.rb b/app/models/budget/result.rb
new file mode 100644
index 000000000..f29bc72cc
--- /dev/null
+++ b/app/models/budget/result.rb
@@ -0,0 +1,55 @@
+class Budget
+ class Result
+
+ attr_accessor :budget, :heading, :money_spent, :current_investment
+
+ def initialize(budget, heading)
+ @budget = budget
+ @heading = heading
+ end
+
+ def calculate_winners
+ reset_winners
+ investments.each do |investment|
+ @current_investment = investment
+ if inside_budget?
+ set_winner
+ end
+ end
+ end
+
+ def investments
+ heading.investments.selected.sort_by_ballots
+ end
+
+ def inside_budget?
+ available_budget >= @current_investment.price
+ end
+
+ def available_budget
+ total_budget - money_spent
+ end
+
+ def total_budget
+ heading.price
+ end
+
+ def money_spent
+ @money_spent ||= 0
+ end
+
+ def reset_winners
+ investments.update_all(winner: false)
+ end
+
+ def set_winner
+ @money_spent += @current_investment.price
+ @current_investment.update(winner: true)
+ end
+
+ def winners
+ investments.where(winner: true)
+ end
+
+ end
+end
\ No newline at end of file
diff --git a/app/models/budget/valuator_assignment.rb b/app/models/budget/valuator_assignment.rb
new file mode 100644
index 000000000..18ef73812
--- /dev/null
+++ b/app/models/budget/valuator_assignment.rb
@@ -0,0 +1,6 @@
+class Budget
+ class ValuatorAssignment < ActiveRecord::Base
+ belongs_to :valuator, counter_cache: :budget_investments_count
+ belongs_to :investment, counter_cache: true
+ end
+end
diff --git a/app/models/comment.rb b/app/models/comment.rb
index 47beb5050..cd84a3578 100644
--- a/app/models/comment.rb
+++ b/app/models/comment.rb
@@ -10,7 +10,8 @@ class Comment < ActiveRecord::Base
validates :body, presence: true
validates :user, presence: true
- validates_inclusion_of :commentable_type, in: ["Debate", "Proposal"]
+
+ validates_inclusion_of :commentable_type, in: ["Debate", "Proposal", "Budget::Investment", "Poll::Question"]
validate :validate_body_length
@@ -24,8 +25,8 @@ class Comment < ActiveRecord::Base
scope :not_as_admin_or_moderator, -> { where("administrator_id IS NULL").where("moderator_id IS NULL")}
scope :sort_by_flags, -> { order(flags_count: :desc, updated_at: :desc) }
- scope :sort_by_most_voted , -> { order(confidence_score: :desc, created_at: :desc) }
- scope :sort_descendants_by_most_voted , -> { order(confidence_score: :desc, created_at: :asc) }
+ scope :sort_by_most_voted, -> { order(confidence_score: :desc, created_at: :desc) }
+ scope :sort_descendants_by_most_voted, -> { order(confidence_score: :desc, created_at: :asc) }
scope :sort_by_newest, -> { order(created_at: :desc) }
scope :sort_descendants_by_newest, -> { order(created_at: :desc) }
@@ -95,7 +96,7 @@ class Comment < ActiveRecord::Base
end
def self.body_max_length
- Setting['comments_body_max_length'].to_i
+ Setting['comments_body_max_length'].to_i
end
def calculate_confidence_score
diff --git a/app/models/concerns/flaggable.rb b/app/models/concerns/flaggable.rb
index a111562b3..c3125c440 100644
--- a/app/models/concerns/flaggable.rb
+++ b/app/models/concerns/flaggable.rb
@@ -13,7 +13,7 @@ module Flaggable
end
def ignore_flag
- update(ignored_flag_at: Time.now)
+ update(ignored_flag_at: Time.current)
end
end
diff --git a/app/models/concerns/sanitizable.rb b/app/models/concerns/sanitizable.rb
index 261eccbf2..a4520ba2f 100644
--- a/app/models/concerns/sanitizable.rb
+++ b/app/models/concerns/sanitizable.rb
@@ -6,6 +6,10 @@ module Sanitizable
before_validation :sanitize_tag_list
end
+ def description
+ super.try :html_safe
+ end
+
protected
def sanitize_description
diff --git a/app/models/concerns/searchable.rb b/app/models/concerns/searchable.rb
index 4d717959e..147a37fbc 100644
--- a/app/models/concerns/searchable.rb
+++ b/app/models/concerns/searchable.rb
@@ -12,7 +12,7 @@ module Searchable
},
ignoring: :accents,
ranked_by: '(:tsearch)',
- order_within_rank: "#{self.table_name}.cached_votes_up DESC"
+ order_within_rank: (self.column_names.include?('cached_votes_up') ? "#{self.table_name}.cached_votes_up DESC" : nil)
}
end
diff --git a/app/models/concerns/verification.rb b/app/models/concerns/verification.rb
index 3520e5b31..4eb933204 100644
--- a/app/models/concerns/verification.rb
+++ b/app/models/concerns/verification.rb
@@ -54,6 +54,17 @@ module Verification
!verification_sms_sent?
end
+ def user_type
+ case
+ when level_three_verified?
+ :level_3_user
+ when level_two_verified?
+ :level_2_user
+ else
+ :level_1_user
+ end
+ end
+
def sms_code_not_confirmed?
!sms_verified?
end
diff --git a/app/models/custom/.keep b/app/models/custom/.keep
new file mode 100644
index 000000000..e69de29bb
diff --git a/app/models/custom/verification/residence.rb b/app/models/custom/verification/residence.rb
new file mode 100644
index 000000000..1cbc6f7ab
--- /dev/null
+++ b/app/models/custom/verification/residence.rb
@@ -0,0 +1,29 @@
+
+require_dependency Rails.root.join('app', 'models', 'verification', 'residence').to_s
+
+class Verification::Residence
+
+ validate :postal_code_in_madrid
+ validate :residence_in_madrid
+
+ def postal_code_in_madrid
+ errors.add(:postal_code, I18n.t('verification.residence.new.error_not_allowed_postal_code')) unless valid_postal_code?
+ end
+
+ def residence_in_madrid
+ return if errors.any?
+
+ unless residency_valid?
+ errors.add(:residence_in_madrid, false)
+ store_failed_attempt
+ Lock.increase_tries(user)
+ end
+ end
+
+ private
+
+ def valid_postal_code?
+ postal_code =~ /^280/
+ end
+
+end
diff --git a/app/models/debate.rb b/app/models/debate.rb
index ceec9946b..de29c5864 100644
--- a/app/models/debate.rb
+++ b/app/models/debate.rb
@@ -8,7 +8,6 @@ class Debate < ActiveRecord::Base
include Searchable
include Filterable
- apply_simple_captcha
acts_as_votable
acts_as_paranoid column: :hidden_at
include ActsAsParanoidAliases
@@ -29,14 +28,15 @@ class Debate < ActiveRecord::Base
before_save :calculate_hot_score, :calculate_confidence_score
scope :for_render, -> { includes(:tags) }
- scope :sort_by_hot_score , -> { reorder(hot_score: :desc) }
+ scope :sort_by_hot_score, -> { reorder(hot_score: :desc) }
scope :sort_by_confidence_score, -> { reorder(confidence_score: :desc) }
scope :sort_by_created_at, -> { reorder(created_at: :desc) }
scope :sort_by_most_commented, -> { reorder(comments_count: :desc) }
scope :sort_by_random, -> { reorder("RANDOM()") }
scope :sort_by_relevance, -> { all }
scope :sort_by_flags, -> { order(flags_count: :desc, updated_at: :desc) }
- scope :last_week, -> { where("created_at >= ?", 7.days.ago)}
+ scope :last_week, -> { where("created_at >= ?", 7.days.ago)}
+ scope :featured, -> { where("featured_at is not null")}
# Ahoy setup
visitable # Ahoy will automatically assign visit_id on create
@@ -59,10 +59,6 @@ class Debate < ActiveRecord::Base
"#{id}-#{title}".parameterize
end
- def description
- super.try :html_safe
- end
-
def likes
cached_votes_up
end
@@ -132,4 +128,8 @@ class Debate < ActiveRecord::Base
self.tags.each{ |t| t.increment_custom_counter_for('Debate') }
end
+ def featured?
+ self.featured_at.present?
+ end
+
end
diff --git a/app/models/direct_message.rb b/app/models/direct_message.rb
new file mode 100644
index 000000000..476194aea
--- /dev/null
+++ b/app/models/direct_message.rb
@@ -0,0 +1,22 @@
+class DirectMessage < ActiveRecord::Base
+ belongs_to :sender, class_name: 'User', foreign_key: 'sender_id'
+ belongs_to :receiver, class_name: 'User', foreign_key: 'receiver_id'
+
+ validates :title, presence: true
+ validates :body, presence: true
+ validates :sender, presence: true
+ validates :receiver, presence: true
+ validate :max_per_day
+
+ scope :today, lambda { where('DATE(created_at) = ?', Date.today) }
+
+ def max_per_day
+ return if errors.any?
+ max = Setting[:direct_message_max_per_day]
+
+ if sender.direct_messages_sent.today.count >= max.to_i
+ errors.add(:title, I18n.t('activerecord.errors.models.direct_message.attributes.max_per_day.invalid'))
+ end
+ end
+
+end
diff --git a/app/models/failed_census_call.rb b/app/models/failed_census_call.rb
index ac792d7b7..b7d60e63a 100644
--- a/app/models/failed_census_call.rb
+++ b/app/models/failed_census_call.rb
@@ -1,3 +1,4 @@
class FailedCensusCall < ActiveRecord::Base
belongs_to :user, counter_cache: true
+ belongs_to :poll_officer, class_name: 'Poll::Officer', counter_cache: true
end
diff --git a/app/models/geozone.rb b/app/models/geozone.rb
index ee612f355..824879ec6 100644
--- a/app/models/geozone.rb
+++ b/app/models/geozone.rb
@@ -1,7 +1,21 @@
class Geozone < ActiveRecord::Base
+ has_many :proposals
+ has_many :spending_proposals
+ has_many :debates
+ has_many :users
validates :name, presence: true
def self.names
Geozone.pluck(:name)
end
+
+ def self.city
+ where(name: 'city').first
+ end
+
+ def safe_to_destroy?
+ Geozone.reflect_on_all_associations(:has_many).all? do |association|
+ association.klass.where(geozone: self).empty?
+ end
+ end
end
diff --git a/app/models/lock.rb b/app/models/lock.rb
index 3c043de79..c0d5fae39 100644
--- a/app/models/lock.rb
+++ b/app/models/lock.rb
@@ -4,7 +4,7 @@ class Lock < ActiveRecord::Base
before_save :set_locked_until
def locked?
- locked_until > Time.now
+ locked_until > Time.current
end
def set_locked_until
@@ -12,7 +12,7 @@ class Lock < ActiveRecord::Base
end
def lock_time
- Time.now + (2**tries).minutes
+ Time.current + (2**tries).minutes
end
def too_many_tries?
diff --git a/app/models/manager.rb b/app/models/manager.rb
new file mode 100644
index 000000000..d9c1aff07
--- /dev/null
+++ b/app/models/manager.rb
@@ -0,0 +1,6 @@
+class Manager < ActiveRecord::Base
+ belongs_to :user, touch: true
+ delegate :name, :email, :name_and_email, to: :user
+
+ validates :user_id, presence: true, uniqueness: true
+end
diff --git a/app/models/notification.rb b/app/models/notification.rb
index 1cb500ccf..e993e55f8 100644
--- a/app/models/notification.rb
+++ b/app/models/notification.rb
@@ -2,9 +2,11 @@ class Notification < ActiveRecord::Base
belongs_to :user, counter_cache: true
belongs_to :notifiable, polymorphic: true
- scope :unread, -> { all }
- scope :recent, -> { order(id: :desc) }
- scope :for_render, -> { includes(:notifiable) }
+ scope :unread, -> { all }
+ scope :recent, -> { order(id: :desc) }
+ scope :not_emailed, -> { where(emailed_at: nil) }
+ scope :for_render, -> { includes(:notifiable) }
+
def timestamp
notifiable.created_at
@@ -21,4 +23,31 @@ class Notification < ActiveRecord::Base
Notification.create!(user_id: user_id, notifiable: notifiable)
end
end
+
+ def notifiable_title
+ case notifiable.class.name
+ when "ProposalNotification"
+ notifiable.proposal.title
+ when "Comment"
+ notifiable.commentable.title
+ else
+ notifiable.title
+ end
+ end
+
+ def notifiable_action
+ case notifiable_type
+ when "ProposalNotification"
+ "proposal_notification"
+ when "Comment"
+ "replies_to"
+ else
+ "comments_on"
+ end
+ end
+
+ def linkable_resource
+ notifiable.is_a?(ProposalNotification) ? notifiable.proposal : notifiable
+ end
+
end
\ No newline at end of file
diff --git a/app/models/officing/residence.rb b/app/models/officing/residence.rb
new file mode 100644
index 000000000..6343fc5f7
--- /dev/null
+++ b/app/models/officing/residence.rb
@@ -0,0 +1,126 @@
+class Officing::Residence
+ include ActiveModel::Model
+ include ActiveModel::Validations::Callbacks
+
+ attr_accessor :user, :officer, :document_number, :document_type, :year_of_birth
+
+ before_validation :call_census_api
+
+ validates_presence_of :document_number
+ validates_presence_of :document_type
+ validates_presence_of :year_of_birth
+
+ validate :allowed_age
+ validate :residence_in_madrid
+
+ def initialize(attrs={})
+ super
+ clean_document_number
+ end
+
+ def save
+ return false unless valid?
+
+ if user_exists?
+ self.user = find_user_by_document
+ self.user.update(verified_at: Time.current)
+ else
+ user_params = {
+ document_number: document_number,
+ document_type: document_type,
+ geozone: self.geozone,
+ date_of_birth: date_of_birth.to_datetime,
+ gender: gender,
+ residence_verified_at: Time.current,
+ verified_at: Time.current,
+ erased_at: Time.current,
+ password: random_password,
+ terms_of_service: '1',
+ email: nil
+ }
+ self.user = User.create!(user_params)
+ end
+ end
+
+ def store_failed_census_call
+ FailedCensusCall.create({
+ user: user,
+ document_number: document_number,
+ document_type: document_type,
+ year_of_birth: year_of_birth,
+ poll_officer: officer
+ })
+
+ end
+
+ def user_exists?
+ find_user_by_document.present?
+ end
+
+ def find_user_by_document
+ User.where(document_number: document_number,
+ document_type: document_type).first
+ end
+
+ def residence_in_madrid
+ return if errors.any?
+
+ unless residency_valid?
+ store_failed_census_call
+ errors.add(:residence_in_madrid, false)
+ end
+ end
+
+ def allowed_age
+ return if errors[:year_of_birth].any?
+ return unless @census_api_response.valid?
+
+ unless allowed_age?
+ errors.add(:year_of_birth, I18n.t('verification.residence.new.error_not_allowed_age'))
+ end
+ end
+
+ def allowed_age?
+ Age.in_years(date_of_birth) >= User.minimum_required_age
+ end
+
+ def geozone
+ Geozone.where(census_code: district_code).first
+ end
+
+ def district_code
+ @census_api_response.district_code
+ end
+
+ def gender
+ @census_api_response.gender
+ end
+
+ def date_of_birth
+ @census_api_response.date_of_birth
+ end
+
+ private
+
+ def call_census_api
+ @census_api_response = CensusApi.new.call(document_type, document_number)
+ end
+
+ def residency_valid?
+ @census_api_response.valid? &&
+ @census_api_response.date_of_birth.year.to_s == year_of_birth.to_s
+ end
+
+ def census_year_of_birth
+ @census_api_response.date_of_birth.year
+ end
+
+ def clean_document_number
+ self.document_number = self.document_number.gsub(/[^a-z0-9]+/i, "").upcase unless self.document_number.blank?
+ end
+
+ def random_password
+ (0...20).map { ('a'..'z').to_a[rand(26)] }.join
+ end
+
+end
diff --git a/app/models/organization.rb b/app/models/organization.rb
index 069afc27f..74fd16111 100644
--- a/app/models/organization.rb
+++ b/app/models/organization.rb
@@ -14,11 +14,11 @@ class Organization < ActiveRecord::Base
scope :rejected, -> { where.not(rejected_at: nil).where("(organizations.verified_at IS NULL or organizations.verified_at < rejected_at)") }
def verify
- update(verified_at: Time.now)
+ update(verified_at: Time.current)
end
def reject
- update(rejected_at: Time.now)
+ update(rejected_at: Time.current)
end
def verified?
diff --git a/app/models/poll.rb b/app/models/poll.rb
new file mode 100644
index 000000000..c6be3073a
--- /dev/null
+++ b/app/models/poll.rb
@@ -0,0 +1,65 @@
+class Poll < ActiveRecord::Base
+ has_many :booth_assignments, class_name: "Poll::BoothAssignment"
+ has_many :booths, through: :booth_assignments
+ has_many :partial_results, through: :booth_assignments
+ has_many :white_results, through: :booth_assignments
+ has_many :null_results, through: :booth_assignments
+ has_many :voters
+ has_many :officer_assignments, through: :booth_assignments
+ has_many :officers, through: :officer_assignments
+ has_many :questions
+
+ has_and_belongs_to_many :geozones
+
+ validates :name, presence: true
+
+ validate :date_range
+
+ scope :current, -> { where('starts_at <= ? and ? <= ends_at', Time.current, Time.current) }
+ scope :incoming, -> { where('? < starts_at', Time.current) }
+ scope :expired, -> { where('ends_at < ?', Time.current) }
+ scope :published, -> { where('published = ?', true) }
+ scope :by_geozone_id, ->(geozone_id) { where(geozones: {id: geozone_id}.joins(:geozones)) }
+
+ scope :sort_for_list, -> { order(:geozone_restricted, :starts_at, :name) }
+
+ def current?(timestamp = DateTime.current)
+ starts_at <= timestamp && timestamp <= ends_at
+ end
+
+ def incoming?(timestamp = DateTime.current)
+ timestamp < starts_at
+ end
+
+ def expired?(timestamp = DateTime.current)
+ ends_at < timestamp
+ end
+
+ def answerable_by?(user)
+ user.present? &&
+ user.level_two_or_three_verified? &&
+ current? &&
+ (!geozone_restricted || geozone_ids.include?(user.geozone_id))
+ end
+
+ def self.answerable_by(user)
+ return none if user.nil? || user.unverified?
+ current.joins('LEFT JOIN "geozones_polls" ON "geozones_polls"."poll_id" = "polls"."id"')
+ .where('geozone_restricted = ? OR geozones_polls.geozone_id = ?', false, user.geozone_id)
+ end
+
+ def votable_by?(user)
+ !document_has_voted?(user.document_number, user.document_type)
+ end
+
+ def document_has_voted?(document_number, document_type)
+ voters.where(document_number: document_number, document_type: document_type).exists?
+ end
+
+ def date_range
+ unless starts_at.present? && ends_at.present? && starts_at <= ends_at
+ errors.add(:starts_at, I18n.t('errors.messages.invalid_date_range'))
+ end
+ end
+
+end
diff --git a/app/models/poll/answer.rb b/app/models/poll/answer.rb
new file mode 100644
index 000000000..52fb11469
--- /dev/null
+++ b/app/models/poll/answer.rb
@@ -0,0 +1,19 @@
+class Poll::Answer < ActiveRecord::Base
+
+ belongs_to :question, -> { with_hidden }
+ belongs_to :author, -> { with_hidden }, class_name: 'User', foreign_key: 'author_id'
+
+ delegate :poll, :poll_id, to: :question
+
+ validates :question, presence: true
+ validates :author, presence: true
+ validates :answer, presence: true
+ validates :answer, inclusion: {in: ->(a) { a.question.valid_answers }}
+
+ scope :by_author, -> (author_id) { where(author_id: author_id) }
+ scope :by_question, -> (question_id) { where(question_id: question_id) }
+
+ def record_voter_participation
+ Poll::Voter.create!(user: author, poll: poll)
+ end
+end
\ No newline at end of file
diff --git a/app/models/poll/booth.rb b/app/models/poll/booth.rb
new file mode 100644
index 000000000..c7fb63efc
--- /dev/null
+++ b/app/models/poll/booth.rb
@@ -0,0 +1,13 @@
+class Poll
+ class Booth < ActiveRecord::Base
+ has_many :booth_assignments, class_name: "Poll::BoothAssignment"
+ has_many :polls, through: :booth_assignments
+
+ validates :name, presence: true, uniqueness: true
+
+ def self.search(terms)
+ return Booth.none if terms.blank?
+ Booth.where("name ILIKE ? OR location ILIKE ?", "%#{terms}%", "%#{terms}%")
+ end
+ end
+end
\ No newline at end of file
diff --git a/app/models/poll/booth_assignment.rb b/app/models/poll/booth_assignment.rb
new file mode 100644
index 000000000..0519fffa6
--- /dev/null
+++ b/app/models/poll/booth_assignment.rb
@@ -0,0 +1,15 @@
+class Poll
+ class BoothAssignment < ActiveRecord::Base
+ belongs_to :booth
+ belongs_to :poll
+
+ has_many :officer_assignments, class_name: "Poll::OfficerAssignment", dependent: :destroy
+ has_many :recounts, class_name: "Poll::Recount", dependent: :destroy
+ has_many :final_recounts, class_name: "Poll::FinalRecount", dependent: :destroy
+ has_many :officers, through: :officer_assignments
+ has_many :voters
+ has_many :partial_results
+ has_many :white_results
+ has_many :null_results
+ end
+end
diff --git a/app/models/poll/final_recount.rb b/app/models/poll/final_recount.rb
new file mode 100644
index 000000000..6ebf5eede
--- /dev/null
+++ b/app/models/poll/final_recount.rb
@@ -0,0 +1,19 @@
+class Poll
+ class FinalRecount < ActiveRecord::Base
+ belongs_to :booth_assignment, class_name: "Poll::BoothAssignment"
+ belongs_to :officer_assignment, class_name: "Poll::OfficerAssignment"
+
+ validates :booth_assignment_id, presence: true
+ validates :date, presence: true, uniqueness: {scope: :booth_assignment_id}
+ validates :count, presence: true, numericality: {only_integer: true}
+
+ before_save :update_logs
+
+ def update_logs
+ if self.count_changed? && self.count_was.present?
+ self.count_log += ":#{self.count_was.to_s}"
+ self.officer_assignment_id_log += ":#{self.officer_assignment_id_was.to_s}"
+ end
+ end
+ end
+end
\ No newline at end of file
diff --git a/app/models/poll/null_result.rb b/app/models/poll/null_result.rb
new file mode 100644
index 000000000..222432c7f
--- /dev/null
+++ b/app/models/poll/null_result.rb
@@ -0,0 +1,23 @@
+class Poll::NullResult < ActiveRecord::Base
+
+ VALID_ORIGINS = %w{ web booth }
+
+ belongs_to :author, -> { with_hidden }, class_name: 'User', foreign_key: 'author_id'
+ belongs_to :booth_assignment
+ belongs_to :officer_assignment
+
+ validates :author, presence: true
+ validates :origin, inclusion: {in: VALID_ORIGINS}
+
+ scope :by_author, -> (author_id) { where(author_id: author_id) }
+
+ before_save :update_logs
+
+ def update_logs
+ if self.amount_changed? && self.amount_was.present?
+ self.amount_log += ":#{self.amount_was.to_s}"
+ self.officer_assignment_id_log += ":#{self.officer_assignment_id_was.to_s}"
+ self.author_id_log += ":#{self.author_id_was.to_s}"
+ end
+ end
+end
\ No newline at end of file
diff --git a/app/models/poll/officer.rb b/app/models/poll/officer.rb
new file mode 100644
index 000000000..bf4c73c36
--- /dev/null
+++ b/app/models/poll/officer.rb
@@ -0,0 +1,26 @@
+class Poll
+ class Officer < ActiveRecord::Base
+ belongs_to :user
+ has_many :officer_assignments, class_name: "Poll::OfficerAssignment"
+ has_many :failed_census_calls, foreign_key: :poll_officer_id
+
+ validates :user_id, presence: true, uniqueness: true
+
+ delegate :name, :email, to: :user
+
+ def voting_days_assigned_polls
+ officer_assignments.voting_days.includes(booth_assignment: :poll).
+ map(&:booth_assignment).
+ map(&:poll).uniq.compact.
+ sort {|x, y| y.ends_at <=> x.ends_at}
+ end
+
+ def final_days_assigned_polls
+ officer_assignments.final.includes(booth_assignment: :poll).
+ map(&:booth_assignment).
+ map(&:poll).uniq.compact.
+ sort {|x, y| y.ends_at <=> x.ends_at}
+ end
+
+ end
+end
diff --git a/app/models/poll/officer_assignment.rb b/app/models/poll/officer_assignment.rb
new file mode 100644
index 000000000..cd4f53266
--- /dev/null
+++ b/app/models/poll/officer_assignment.rb
@@ -0,0 +1,25 @@
+class Poll
+ class OfficerAssignment < ActiveRecord::Base
+ belongs_to :officer
+ belongs_to :booth_assignment
+ has_one :recount
+ has_many :final_recounts
+ has_many :partial_results
+ has_many :voters
+
+ validates :officer_id, presence: true
+ validates :booth_assignment_id, presence: true
+ validates :date, presence: true, uniqueness: { scope: [:officer_id, :booth_assignment_id] }
+
+ delegate :poll_id, :booth_id, to: :booth_assignment
+
+ scope :voting_days, -> { where(final: false) }
+ scope :final, -> { where(final: true) }
+
+ before_create :log_user_data
+
+ def log_user_data
+ self.user_data_log = "#{officer.user_id} - #{officer.user.name_and_email}"
+ end
+ end
+end
diff --git a/app/models/poll/partial_result.rb b/app/models/poll/partial_result.rb
new file mode 100644
index 000000000..e42589a03
--- /dev/null
+++ b/app/models/poll/partial_result.rb
@@ -0,0 +1,28 @@
+class Poll::PartialResult < ActiveRecord::Base
+
+ VALID_ORIGINS = %w{ web booth }
+
+ belongs_to :question, -> { with_hidden }
+ belongs_to :author, -> { with_hidden }, class_name: 'User', foreign_key: 'author_id'
+ belongs_to :booth_assignment
+ belongs_to :officer_assignment
+
+ validates :question, presence: true
+ validates :author, presence: true
+ validates :answer, presence: true
+ validates :answer, inclusion: {in: ->(a) { a.question.valid_answers }}
+ validates :origin, inclusion: {in: VALID_ORIGINS}
+
+ scope :by_author, -> (author_id) { where(author_id: author_id) }
+ scope :by_question, -> (question_id) { where(question_id: question_id) }
+
+ before_save :update_logs
+
+ def update_logs
+ if self.amount_changed? && self.amount_was.present?
+ self.amount_log += ":#{self.amount_was.to_s}"
+ self.officer_assignment_id_log += ":#{self.officer_assignment_id_was.to_s}"
+ self.author_id_log += ":#{self.author_id_was.to_s}"
+ end
+ end
+end
\ No newline at end of file
diff --git a/app/models/poll/question.rb b/app/models/poll/question.rb
new file mode 100644
index 000000000..dd4eae3bc
--- /dev/null
+++ b/app/models/poll/question.rb
@@ -0,0 +1,70 @@
+class Poll::Question < ActiveRecord::Base
+ include Measurable
+ include Searchable
+
+ acts_as_paranoid column: :hidden_at
+ include ActsAsParanoidAliases
+
+ belongs_to :poll
+ belongs_to :author, -> { with_hidden }, class_name: 'User', foreign_key: 'author_id'
+
+ has_many :comments, as: :commentable
+ has_many :answers
+ has_many :partial_results
+ belongs_to :proposal
+
+ validates :title, presence: true
+ validates :author, presence: true
+
+ validates :title, length: { minimum: 4 }
+ validates :description, length: { maximum: Poll::Question.description_max_length }
+
+ scope :by_poll_id, ->(poll_id) { where(poll_id: poll_id) }
+
+ scope :sort_for_list, -> { order('poll_questions.proposal_id IS NULL', :created_at)}
+ scope :for_render, -> { includes(:author, :proposal) }
+
+ def self.search(params)
+ results = self.all
+ results = results.by_poll_id(params[:poll_id]) if params[:poll_id].present?
+ results = results.pg_search(params[:search]) if params[:search].present?
+ results
+ end
+
+ def searchable_values
+ { title => 'A',
+ proposal.try(:title) => 'A',
+ description => 'B',
+ author.username => 'C',
+ author_visible_name => 'C' }
+ end
+
+ def description
+ super.try :html_safe
+ end
+
+ def valid_answers
+ (super.try(:split, ',').compact || []).map(&:strip)
+ end
+
+ def copy_attributes_from_proposal(proposal)
+ if proposal.present?
+ self.author = proposal.author
+ self.author_visible_name = proposal.author.name
+ self.proposal_id = proposal.id
+ self.title = proposal.title
+ self.description = proposal.description
+ self.valid_answers = I18n.t('poll_questions.default_valid_answers')
+ end
+ end
+
+ def answerable_by?(user)
+ poll.answerable_by?(user)
+ end
+
+ def self.answerable_by(user)
+ return none if user.nil? || user.unverified?
+ where(poll_id: Poll.answerable_by(user).pluck(:id))
+ end
+
+end
diff --git a/app/models/poll/recount.rb b/app/models/poll/recount.rb
new file mode 100644
index 000000000..b4e28583e
--- /dev/null
+++ b/app/models/poll/recount.rb
@@ -0,0 +1,20 @@
+class Poll
+ class Recount < ActiveRecord::Base
+ belongs_to :booth_assignment, class_name: "Poll::BoothAssignment"
+ belongs_to :officer_assignment, class_name: "Poll::OfficerAssignment"
+
+ validates :booth_assignment_id, presence: true
+ validates :date, presence: true, uniqueness: {scope: :booth_assignment_id}
+ validates :officer_assignment_id, presence: true, uniqueness: {scope: :booth_assignment_id}
+ validates :count, presence: true, numericality: {only_integer: true}
+
+ before_save :update_logs
+
+ def update_logs
+ if self.count_changed? && self.count_was.present?
+ self.count_log += ":#{self.count_was.to_s}"
+ self.officer_assignment_id_log += ":#{self.officer_assignment_id_was.to_s}"
+ end
+ end
+ end
+end
\ No newline at end of file
diff --git a/app/models/poll/voter.rb b/app/models/poll/voter.rb
new file mode 100644
index 000000000..8fe612151
--- /dev/null
+++ b/app/models/poll/voter.rb
@@ -0,0 +1,59 @@
+class Poll
+ class Voter < ActiveRecord::Base
+ belongs_to :poll
+ belongs_to :user
+ belongs_to :geozone
+ belongs_to :booth_assignment
+ belongs_to :officer_assignment
+
+ validates :poll_id, presence: true
+ validates :user_id, presence: true
+
+ validates :document_number, presence: true, uniqueness: { scope: [:poll_id, :document_type], message: :has_voted }
+
+ before_validation :set_demographic_info, :set_document_info
+
+ def set_demographic_info
+ return unless user.present?
+
+ self.gender = user.gender
+ self.age = user.age
+ self.geozone = user.geozone
+ end
+
+ def set_document_info
+ return unless user.present?
+
+ self.document_type = user.document_type
+ self.document_number = user.document_number
+ end
+
+ private
+
+ def in_census?
+ census_api_response.valid?
+ end
+
+ def census_api_response
+ @census_api_response ||= CensusApi.new.call(document_type, document_number)
+ end
+
+ def fill_stats_fields
+ if in_census?
+ self.gender = census_api_response.gender
+ self.geozone_id = Geozone.select(:id).where(census_code: census_api_response.district_code).first.try(:id)
+ self.age = voter_age(census_api_response.date_of_birth)
+ end
+ end
+
+ def voter_age(dob)
+ if dob.blank?
+ nil
+ else
+ now = Time.now.utc.to_date
+ now.year - dob.year - ((now.month > dob.month || (now.month == dob.month && now.day >= dob.day)) ? 0 : 1)
+ end
+ end
+
+ end
+end
\ No newline at end of file
diff --git a/app/models/poll/white_result.rb b/app/models/poll/white_result.rb
new file mode 100644
index 000000000..5b0aa4966
--- /dev/null
+++ b/app/models/poll/white_result.rb
@@ -0,0 +1,23 @@
+class Poll::WhiteResult < ActiveRecord::Base
+
+ VALID_ORIGINS = %w{ web booth }
+
+ belongs_to :author, -> { with_hidden }, class_name: 'User', foreign_key: 'author_id'
+ belongs_to :booth_assignment
+ belongs_to :officer_assignment
+
+ validates :author, presence: true
+ validates :origin, inclusion: {in: VALID_ORIGINS}
+
+ scope :by_author, -> (author_id) { where(author_id: author_id) }
+
+ before_save :update_logs
+
+ def update_logs
+ if self.amount_changed? && self.amount_was.present?
+ self.amount_log += ":#{self.amount_was.to_s}"
+ self.officer_assignment_id_log += ":#{self.officer_assignment_id_was.to_s}"
+ self.author_id_log += ":#{self.author_id_was.to_s}"
+ end
+ end
+end
\ No newline at end of file
diff --git a/app/models/proposal.rb b/app/models/proposal.rb
index f437fa134..0abde3584 100644
--- a/app/models/proposal.rb
+++ b/app/models/proposal.rb
@@ -7,14 +7,16 @@ class Proposal < ActiveRecord::Base
include Searchable
include Filterable
- apply_simple_captcha
acts_as_votable
acts_as_paranoid column: :hidden_at
include ActsAsParanoidAliases
+ RETIRE_OPTIONS = %w(duplicated started unfeasible done other)
+
belongs_to :author, -> { with_hidden }, class_name: 'User', foreign_key: 'author_id'
belongs_to :geozone
has_many :comments, as: :commentable
+ has_many :proposal_notifications
validates :title, presence: true
validates :question, presence: true
@@ -26,6 +28,7 @@ class Proposal < ActiveRecord::Base
validates :description, length: { maximum: Proposal.description_max_length }
validates :question, length: { in: 10..Proposal.question_max_length }
validates :responsible_name, length: { in: 6..Proposal.responsible_name_max_length }
+ validates :retired_reason, inclusion: {in: RETIRE_OPTIONS, allow_nil: true}
validates :terms_of_service, acceptance: { allow_nil: false }, on: :create
@@ -41,7 +44,13 @@ class Proposal < ActiveRecord::Base
scope :sort_by_random, -> { reorder("RANDOM()") }
scope :sort_by_relevance, -> { all }
scope :sort_by_flags, -> { order(flags_count: :desc, updated_at: :desc) }
+ scope :sort_by_archival_date, -> { archived.sort_by_confidence_score }
+ scope :archived, -> { where("proposals.created_at <= ?", Setting["months_to_archive_proposals"].to_i.months.ago) }
+ scope :not_archived, -> { where("proposals.created_at > ?", Setting["months_to_archive_proposals"].to_i.months.ago) }
scope :last_week, -> { where("proposals.created_at >= ?", 7.days.ago)}
+ scope :retired, -> { where.not(retired_at: nil) }
+ scope :not_retired, -> { where(retired_at: nil) }
+ scope :successful, -> { where("cached_votes_up >= ?", Proposal.votes_needed_for_success) }
def to_param
"#{id}-#{title}".parameterize
@@ -85,12 +94,12 @@ class Proposal < ActiveRecord::Base
summary
end
- def description
- super.try :html_safe
+ def total_votes
+ cached_votes_up
end
- def total_votes
- cached_votes_up + physical_votes
+ def voters
+ User.active.where(id: votes_for.voters)
end
def editable?
@@ -105,8 +114,12 @@ class Proposal < ActiveRecord::Base
user && user.level_two_or_three_verified?
end
+ def retired?
+ retired_at.present?
+ end
+
def register_vote(user, vote_value)
- if votable_by?(user)
+ if votable_by?(user) && !archived?
vote_by(voter: user, vote: vote_value)
end
end
@@ -142,6 +155,18 @@ class Proposal < ActiveRecord::Base
Setting['votes_for_proposal_success'].to_i
end
+ def successful?
+ total_votes >= Proposal.votes_needed_for_success
+ end
+
+ def archived?
+ self.created_at <= Setting["months_to_archive_proposals"].to_i.months.ago
+ end
+
+ def notifications
+ proposal_notifications
+ end
+
protected
def set_responsible_name
diff --git a/app/models/proposal_notification.rb b/app/models/proposal_notification.rb
new file mode 100644
index 000000000..60912d887
--- /dev/null
+++ b/app/models/proposal_notification.rb
@@ -0,0 +1,17 @@
+class ProposalNotification < ActiveRecord::Base
+ belongs_to :author, class_name: 'User', foreign_key: 'author_id'
+ belongs_to :proposal
+
+ validates :title, presence: true
+ validates :body, presence: true
+ validates :proposal, presence: true
+ validate :minimum_interval
+
+ def minimum_interval
+ return true if proposal.try(:notifications).blank?
+ if proposal.notifications.last.created_at > (Time.current - Setting[:proposal_notification_minimum_interval_in_days].to_i.days).to_datetime
+ errors.add(:title, I18n.t('activerecord.errors.models.proposal_notification.attributes.minimum_interval.invalid', interval: Setting[:proposal_notification_minimum_interval_in_days]))
+ end
+ end
+
+end
diff --git a/app/models/setting.rb b/app/models/setting.rb
index 40659ed74..9010abba5 100644
--- a/app/models/setting.rb
+++ b/app/models/setting.rb
@@ -2,6 +2,20 @@ class Setting < ActiveRecord::Base
validates :key, presence: true, uniqueness: true
default_scope { order(id: :asc) }
+ scope :banner_style, -> { where("key ilike ?", "banner-style.%")}
+ scope :banner_img, -> { where("key ilike ?", "banner-img.%")}
+
+ def type
+ if feature_flag?
+ 'feature'
+ elsif banner_style?
+ 'banner-style'
+ elsif banner_img?
+ 'banner-img'
+ else
+ 'common'
+ end
+ end
def feature_flag?
key.start_with?('feature.')
@@ -11,6 +25,14 @@ class Setting < ActiveRecord::Base
feature_flag? && value.present?
end
+ def banner_style?
+ key.start_with?('banner-style.')
+ end
+
+ def banner_img?
+ key.start_with?('banner-img.')
+ end
+
class << self
def [](key)
where(key: key).pluck(:value).first.presence
diff --git a/app/models/signature.rb b/app/models/signature.rb
new file mode 100644
index 000000000..543965aed
--- /dev/null
+++ b/app/models/signature.rb
@@ -0,0 +1,97 @@
+class Signature < ActiveRecord::Base
+ belongs_to :signature_sheet
+ belongs_to :user
+
+ validates :document_number, presence: true
+ validates :signature_sheet, presence: true
+
+ scope :verified, -> { where(verified: true) }
+ scope :unverified, -> { where(verified: false) }
+
+ delegate :signable, to: :signature_sheet
+
+ before_validation :clean_document_number
+
+ def verify
+ if user_exists?
+ assign_vote_to_user
+ mark_as_verified
+ elsif in_census?
+ create_user
+ assign_vote_to_user
+ mark_as_verified
+ end
+ end
+
+ def assign_vote_to_user
+ set_user
+ if signable.is_a? Budget::Investment
+ signable.vote_by(voter: user, vote: 'yes') if [nil, :no_selecting_allowed].include?(signable.reason_for_not_being_selectable_by(user))
+ else
+ signable.register_vote(user, "yes")
+ end
+ assign_signature_to_vote
+ end
+
+ def assign_signature_to_vote
+ vote = Vote.where(votable: signable, voter: user).first
+ vote.update(signature: self) if vote
+ end
+
+ def user_exists?
+ User.where(document_number: document_number).any?
+ end
+
+ def create_user
+ user_params = {
+ document_number: document_number,
+ created_from_signature: true,
+ verified_at: Time.current,
+ erased_at: Time.current,
+ password: random_password,
+ terms_of_service: '1',
+ email: nil,
+ date_of_birth: @census_api_response.date_of_birth,
+ gender: @census_api_response.gender,
+ geozone: Geozone.where(census_code: @census_api_response.district_code).first
+ }
+ User.create!(user_params)
+ end
+
+ def clean_document_number
+ return if self.document_number.blank?
+ self.document_number = self.document_number.gsub(/[^a-z0-9]+/i, "").upcase
+ end
+
+ def random_password
+ (0...20).map { ('a'..'z').to_a[rand(26)] }.join
+ end
+
+ def in_census?
+ document_types.detect do |document_type|
+ response = CensusApi.new.call(document_type, document_number)
+ if response.valid?
+ @census_api_response = response
+ true
+ else
+ false
+ end
+ end
+
+ @census_api_response.present?
+ end
+
+ def set_user
+ user = User.where(document_number: document_number).first
+ update(user: user)
+ end
+
+ def mark_as_verified
+ update(verified: true)
+ end
+
+ def document_types
+ %w(1 2 3 4)
+ end
+
+end
\ No newline at end of file
diff --git a/app/models/signature_sheet.rb b/app/models/signature_sheet.rb
new file mode 100644
index 000000000..1434143ac
--- /dev/null
+++ b/app/models/signature_sheet.rb
@@ -0,0 +1,38 @@
+class SignatureSheet < ActiveRecord::Base
+ belongs_to :signable, polymorphic: true
+ belongs_to :author, class_name: 'User', foreign_key: 'author_id'
+
+ VALID_SIGNABLES = %w( Proposal Budget::Investment SpendingProposal )
+
+ has_many :signatures
+
+ validates :author, presence: true
+ validates :signable_type, inclusion: {in: VALID_SIGNABLES}
+ validates :document_numbers, presence: true
+ validates :signable, presence: true
+ validate :signable_found
+
+ def name
+ "#{signable_name} #{signable_id}"
+ end
+
+ def signable_name
+ I18n.t("activerecord.models.#{signable_type.underscore}", count: 1)
+ end
+
+ def verify_signatures
+ parsed_document_numbers.each do |document_number|
+ signature = self.signatures.where(document_number: document_number).first_or_create
+ signature.verify
+ end
+ update(processed: true)
+ end
+
+ def parsed_document_numbers
+ document_numbers.split(/\r\n|\n|[,]/).collect {|d| d.gsub(/\s+/, '') }
+ end
+
+ def signable_found
+ errors.add(:signable_id, :not_found) if errors.messages[:signable].present?
+ end
+end
\ No newline at end of file
diff --git a/app/models/site_customization.rb b/app/models/site_customization.rb
new file mode 100644
index 000000000..e5d2f2137
--- /dev/null
+++ b/app/models/site_customization.rb
@@ -0,0 +1,5 @@
+module SiteCustomization
+ def self.table_name_prefix
+ 'site_customization_'
+ end
+end
diff --git a/app/models/site_customization/content_block.rb b/app/models/site_customization/content_block.rb
new file mode 100644
index 000000000..c08beb52e
--- /dev/null
+++ b/app/models/site_customization/content_block.rb
@@ -0,0 +1,11 @@
+class SiteCustomization::ContentBlock < ActiveRecord::Base
+ VALID_BLOCKS = %w(top_links footer)
+
+ validates :locale, presence: true, inclusion: { in: I18n.available_locales.map(&:to_s) }
+ validates :name, presence: true, uniqueness: { scope: :locale }, inclusion: { in: VALID_BLOCKS }
+
+ def self.block_for(name, locale)
+ locale ||= I18n.default_locale
+ find_by(name: name, locale: locale).try(:body)
+ end
+end
diff --git a/app/models/site_customization/image.rb b/app/models/site_customization/image.rb
new file mode 100644
index 000000000..2230a96ce
--- /dev/null
+++ b/app/models/site_customization/image.rb
@@ -0,0 +1,48 @@
+class SiteCustomization::Image < ActiveRecord::Base
+ VALID_IMAGES = {
+ "icon_home" => [330, 240],
+ "logo_header" => [80, 80],
+ "social-media-icon" => [200, 200],
+ "apple-touch-icon-200" => [200, 200]
+ }
+
+ has_attached_file :image
+
+ validates :name, presence: true, uniqueness: true, inclusion: { in: VALID_IMAGES.keys }
+ validates_attachment_content_type :image, :content_type => ["image/png"]
+ validate :check_image
+
+ def self.all_images
+ VALID_IMAGES.keys.map do |image_name|
+ find_by(name: image_name) || create!(name: image_name.to_s)
+ end
+ end
+
+ def self.image_path_for(filename)
+ image_name = filename.split(".").first
+
+ if i = find_by(name: image_name)
+ i.image.exists? ? i.image.url : nil
+ end
+ end
+
+ def required_width
+ VALID_IMAGES[name].try(:first)
+ end
+
+ def required_height
+ VALID_IMAGES[name].try(:second)
+ end
+
+ private
+
+ def check_image
+ return unless image?
+
+ dimensions = Paperclip::Geometry.from_file(image.queued_for_write[:original].path)
+
+ errors.add(:image, :image_width, required_width: required_width) unless dimensions.width == required_width
+ errors.add(:image, :image_height, required_height: required_height) unless dimensions.height == required_height
+ end
+
+end
diff --git a/app/models/site_customization/page.rb b/app/models/site_customization/page.rb
new file mode 100644
index 000000000..c2a9b1467
--- /dev/null
+++ b/app/models/site_customization/page.rb
@@ -0,0 +1,16 @@
+class SiteCustomization::Page < ActiveRecord::Base
+ VALID_STATUSES = %w(draft published)
+
+ validates :slug, presence: true,
+ uniqueness: { case_sensitive: false },
+ format: { with: /\A[0-9a-zA-Z\-_]*\Z/, message: :slug_format }
+ validates :title, presence: true
+ validates :status, presence: true, inclusion: { in: VALID_STATUSES }
+
+ scope :published, -> { where(status: 'published').order('id DESC') }
+ scope :with_more_info_flag, -> { where(status: 'published', more_info_flag: true).order('id ASC') }
+
+ def url
+ "/#{slug}"
+ end
+end
diff --git a/app/models/spending_proposal.rb b/app/models/spending_proposal.rb
index f11dc71c3..223e9adfe 100644
--- a/app/models/spending_proposal.rb
+++ b/app/models/spending_proposal.rb
@@ -4,7 +4,6 @@ class SpendingProposal < ActiveRecord::Base
include Taggable
include Searchable
- apply_simple_captcha
acts_as_votable
belongs_to :author, -> { with_hidden }, class_name: 'User', foreign_key: 'author_id'
@@ -16,6 +15,7 @@ class SpendingProposal < ActiveRecord::Base
validates :title, presence: true
validates :author, presence: true
validates :description, presence: true
+ validates_presence_of :feasible_explanation, if: :feasible_explanation_required?
validates :title, length: { in: 4..SpendingProposal.title_max_length }
validates :description, length: { maximum: SpendingProposal.description_max_length }
@@ -29,6 +29,7 @@ class SpendingProposal < ActiveRecord::Base
scope :feasible, -> { where(feasible: true) }
scope :unfeasible, -> { where(feasible: false) }
scope :not_unfeasible, -> { where("feasible IS ? OR feasible = ?", nil, true) }
+ scope :with_supports, -> { where('cached_votes_up > 0') }
scope :by_admin, -> (admin) { where(administrator_id: admin.presence) }
scope :by_tag, -> (tag_name) { tagged_with(tag_name) }
@@ -36,6 +37,8 @@ class SpendingProposal < ActiveRecord::Base
scope :for_render, -> { includes(:geozone) }
+ before_validation :set_responsible_name
+
def description
super.try :html_safe
end
@@ -97,8 +100,12 @@ class SpendingProposal < ActiveRecord::Base
valuation_finished
end
+ def feasible_explanation_required?
+ valuation_finished? && unfeasible?
+ end
+
def total_votes
- cached_votes_up
+ cached_votes_up + physical_votes
end
def code
@@ -107,11 +114,19 @@ class SpendingProposal < ActiveRecord::Base
def send_unfeasible_email
Mailer.unfeasible_spending_proposal(self).deliver_later
- update(unfeasible_email_sent_at: Time.now)
+ update(unfeasible_email_sent_at: Time.current)
+ end
+
+ def reason_for_not_being_votable_by(user)
+ return :not_voting_allowed if Setting["feature.spending_proposal_features.voting_allowed"].blank?
+ return :not_logged_in unless user
+ return :not_verified unless user.can?(:vote, SpendingProposal)
+ return :unfeasible if unfeasible?
+ return :organization if user.organization?
end
def votable_by?(user)
- user && user.level_two_or_three_verified?
+ reason_for_not_being_votable_by(user).blank?
end
def register_vote(user, vote_value)
@@ -120,4 +135,16 @@ class SpendingProposal < ActiveRecord::Base
end
end
+ def set_responsible_name
+ self.responsible_name = author.try(:document_number) if author.try(:document_number).present?
+ end
+
+ def self.finished_and_feasible
+ valuation_finished.feasible
+ end
+
+ def self.finished_and_unfeasible
+ valuation_finished.unfeasible
+ end
+
end
diff --git a/app/models/tag_cloud.rb b/app/models/tag_cloud.rb
index f3ea655f0..107ecbf1a 100644
--- a/app/models/tag_cloud.rb
+++ b/app/models/tag_cloud.rb
@@ -32,7 +32,7 @@ class TagCloud
end
def table_name
- resource_model.to_s.downcase.pluralize
+ resource_model.to_s.downcase.pluralize.gsub("::", "/")
end
end
\ No newline at end of file
diff --git a/app/models/user.rb b/app/models/user.rb
index bc4b0f120..c3038c88a 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -2,9 +2,8 @@ class User < ActiveRecord::Base
include Verification
- apply_simple_captcha
- devise :database_authenticatable, :registerable, :confirmable,
- :recoverable, :rememberable, :trackable, :validatable, :omniauthable, :async
+ devise :database_authenticatable, :registerable, :confirmable, :recoverable, :rememberable,
+ :trackable, :validatable, :omniauthable, :async, :password_expirable, :secure_validatable
acts_as_voter
acts_as_paranoid column: :hidden_at
@@ -13,20 +12,25 @@ class User < ActiveRecord::Base
has_one :administrator
has_one :moderator
has_one :valuator
+ has_one :manager
+ has_one :poll_officer, class_name: "Poll::Officer"
has_one :organization
has_one :lock
has_many :flags
has_many :identities, dependent: :destroy
has_many :debates, -> { with_hidden }, foreign_key: :author_id
has_many :proposals, -> { with_hidden }, foreign_key: :author_id
+ has_many :budget_investments, -> { with_hidden }, foreign_key: :author_id, class_name: 'Budget::Investment'
has_many :comments, -> { with_hidden }
has_many :spending_proposals, foreign_key: :author_id
has_many :failed_census_calls
has_many :notifications
+ has_many :direct_messages_sent, class_name: 'DirectMessage', foreign_key: :sender_id
+ has_many :direct_messages_received, class_name: 'DirectMessage', foreign_key: :receiver_id
belongs_to :geozone
validates :username, presence: true, if: :username_required?
- validates :username, uniqueness: true, if: :username_required?
+ validates :username, uniqueness: { scope: :registering_with_oauth }, if: :username_required?
validates :document_number, uniqueness: { scope: :document_type }, allow_nil: true
validate :validate_username_length
@@ -50,6 +54,9 @@ class User < ActiveRecord::Base
scope :officials, -> { where("official_level > 0") }
scope :for_render, -> { includes(:organization) }
scope :by_document, -> (document_type, document_number) { where(document_type: document_type, document_number: document_number) }
+ scope :email_digest, -> { where(email_digest: true) }
+ scope :active, -> { where(erased_at: nil) }
+ scope :erased, -> { where.not(erased_at: nil) }
before_validation :clean_document_number
@@ -65,7 +72,7 @@ class User < ActiveRecord::Base
oauth_email: oauth_email,
password: Devise.friendly_token[0,20],
terms_of_service: '1',
- confirmed_at: oauth_email_confirmed ? DateTime.now : nil
+ confirmed_at: oauth_email_confirmed ? DateTime.current : nil
)
end
@@ -88,11 +95,20 @@ class User < ActiveRecord::Base
voted.each_with_object({}) { |v, h| h[v.votable_id] = v.value }
end
+ def budget_investment_votes(budget_investments)
+ voted = votes.for_budget_investments(budget_investments)
+ voted.each_with_object({}) { |v, h| h[v.votable_id] = v.value }
+ end
+
def comment_flags(comments)
comment_flags = flags.for_comments(comments)
comment_flags.each_with_object({}){ |f, h| h[f.flaggable_id] = true }
end
+ def voted_in_group?(group)
+ votes.for_budget_investments(Budget::Investment.where(group: group)).exists?
+ end
+
def administrator?
administrator.present?
end
@@ -105,6 +121,14 @@ class User < ActiveRecord::Base
valuator.present?
end
+ def manager?
+ manager.present?
+ end
+
+ def poll_officer?
+ poll_officer.present?
+ end
+
def organization?
organization.present?
end
@@ -126,6 +150,16 @@ class User < ActiveRecord::Base
update official_position: nil, official_level: 0
end
+ def has_official_email?
+ domain = Setting['email_domain_for_officials']
+ !email.blank? && ( (email.end_with? "@#{domain}") || (email.end_with? ".#{domain}") )
+ end
+
+ def display_official_position_badge?
+ return true if official_level > 1
+ official_position_badge? && official_level == 1
+ end
+
def block
debates_ids = Debate.where(author_id: id).pluck(:id)
comments_ids = Comment.where(user_id: id).pluck(:id)
@@ -140,12 +174,11 @@ class User < ActiveRecord::Base
def erase(erase_reason = nil)
self.update(
- erased_at: Time.now,
+ erased_at: Time.current,
erase_reason: erase_reason,
username: nil,
email: nil,
unconfirmed_email: nil,
- document_number: nil,
phone_number: nil,
encrypted_password: "",
confirmation_token: nil,
@@ -154,12 +187,29 @@ class User < ActiveRecord::Base
confirmed_phone: nil,
unconfirmed_phone: nil
)
+ self.identities.destroy_all
end
def erased?
erased_at.present?
end
+ def take_votes_if_erased_document(document_number, document_type)
+ erased_user = User.erased.where(document_number: document_number).where(document_type: document_type).first
+ if erased_user.present?
+ self.take_votes_from(erased_user)
+ erased_user.update(document_number: nil, document_type: nil)
+ end
+ end
+
+ def take_votes_from(other_user)
+ return if other_user.blank?
+ Poll::Voter.where(user_id: other_user.id).update_all(user_id: self.id)
+ Budget::Ballot.where(user_id: other_user.id).update_all(user_id: self.id)
+ Vote.where("voter_id = ? AND voter_type = ?", other_user.id, "User").update_all(voter_id: self.id)
+ self.update(former_users_data_log: "#{self.former_users_data_log} | id: #{other_user.id} - #{Time.current.strftime('%Y-%m-%d %H:%M:%S')}")
+ end
+
def locked?
Lock.find_or_create_by(user: self).locked?
end
@@ -172,6 +222,10 @@ class User < ActiveRecord::Base
@@username_max_length ||= self.columns.find { |c| c.name == 'username' }.limit || 60
end
+ def self.minimum_required_age
+ (Setting['min_age_to_participate'] || 16).to_i
+ end
+
def show_welcome_screen?
sign_in_count == 1 && unverified? && !organization && !administrator?
end
@@ -182,16 +236,11 @@ class User < ActiveRecord::Base
end
def username_required?
- !organization? && !erased? && !registering_with_oauth
+ !organization? && !erased?
end
def email_required?
- !erased? && !registering_with_oauth
- end
-
- def has_official_email?
- domain = Setting['email_domain_for_officials']
- !email.blank? && ( (email.end_with? "@#{domain}") || (email.end_with? ".#{domain}") )
+ !erased?
end
def locale
@@ -214,15 +263,29 @@ class User < ActiveRecord::Base
"#{name} (#{email})"
end
- def save_requiring_finish_signup
- self.update(registering_with_oauth: true)
+ def age
+ Age.in_years(date_of_birth)
end
- def save_requiring_finish_signup_without_email
- self.update(registering_with_oauth: true, email: nil)
+ def save_requiring_finish_signup
+ begin
+ self.registering_with_oauth = true
+ self.save(validate: false)
+ # Devise puts unique constraints for the email the db, so we must detect & handle that
+ rescue ActiveRecord::RecordNotUnique
+ self.email = nil
+ self.save(validate: false)
+ end
+ true
end
+ def ability
+ @ability ||= Ability.new(self)
+ end
+ delegate :can?, :cannot?, to: :ability
+
private
+
def clean_document_number
self.document_number = self.document_number.gsub(/[^a-z0-9]+/i, "").upcase unless self.document_number.blank?
end
diff --git a/app/models/valuation_assignment.rb b/app/models/valuation_assignment.rb
index 8f50d9082..a0505a8aa 100644
--- a/app/models/valuation_assignment.rb
+++ b/app/models/valuation_assignment.rb
@@ -1,4 +1,4 @@
class ValuationAssignment < ActiveRecord::Base
- belongs_to :valuator
+ belongs_to :valuator, counter_cache: :spending_proposals_count
belongs_to :spending_proposal, counter_cache: true
end
diff --git a/app/models/valuator.rb b/app/models/valuator.rb
index c7cb6e4a8..5df6ea030 100644
--- a/app/models/valuator.rb
+++ b/app/models/valuator.rb
@@ -4,10 +4,16 @@ class Valuator < ActiveRecord::Base
has_many :valuation_assignments, dependent: :destroy
has_many :spending_proposals, through: :valuation_assignments
+ has_many :valuator_assignments, dependent: :destroy, class_name: 'Budget::ValuatorAssignment'
+ has_many :investments, through: :valuator_assignments, class_name: 'Budget::Investment'
validates :user_id, presence: true, uniqueness: true
def description_or_email
description.present? ? description : email
end
+
+ def description_or_name
+ description.present? ? description : name
+ end
end
diff --git a/app/models/verification/letter.rb b/app/models/verification/letter.rb
index 0beb7c8d8..c746e73e4 100644
--- a/app/models/verification/letter.rb
+++ b/app/models/verification/letter.rb
@@ -17,7 +17,7 @@ class Verification::Letter
end
def letter_requested!
- user.update(letter_requested_at: Time.now, letter_verification_code: generate_verification_code)
+ user.update(letter_requested_at: Time.current, letter_verification_code: generate_verification_code)
end
def validate_existing_user
diff --git a/app/models/verification/management/document.rb b/app/models/verification/management/document.rb
index ee9e5462d..420dcf49c 100644
--- a/app/models/verification/management/document.rb
+++ b/app/models/verification/management/document.rb
@@ -10,7 +10,7 @@ class Verification::Management::Document
delegate :username, :email, to: :user, allow_nil: true
def user
- @user = User.by_document(document_type, document_number).first
+ @user = User.active.by_document(document_type, document_number).first
end
def user?
@@ -23,7 +23,7 @@ class Verification::Management::Document
end
def valid_age?(response)
- if under_sixteen?(response)
+ if under_age?(response)
errors.add(:age, true)
return false
else
@@ -31,8 +31,8 @@ class Verification::Management::Document
end
end
- def under_sixteen?(response)
- 16.years.ago < string_to_date(response.date_of_birth)
+ def under_age?(response)
+ response.date_of_birth.blank? || Age.in_years(response.date_of_birth) < User.minimum_required_age
end
def verified?
@@ -40,7 +40,7 @@ class Verification::Management::Document
end
def verify
- user.update(verified_at: Time.now) if user?
+ user.update(verified_at: Time.current) if user?
end
-end
\ No newline at end of file
+end
diff --git a/app/models/verification/management/email.rb b/app/models/verification/management/email.rb
index 33282b569..de13d1ab4 100644
--- a/app/models/verification/management/email.rb
+++ b/app/models/verification/management/email.rb
@@ -27,8 +27,8 @@ class Verification::Management::Email
user.update(document_type: document_type,
document_number: document_number,
- residence_verified_at: Time.now,
- level_two_verified_at: Time.now,
+ residence_verified_at: Time.current,
+ level_two_verified_at: Time.current,
email_verification_token: plain_token)
Mailer.email_verification(user, email, encrypted_token, document_type, document_number).deliver_later
diff --git a/app/models/verification/residence.rb b/app/models/verification/residence.rb
index 0c1ed5f9a..ea000677f 100644
--- a/app/models/verification/residence.rb
+++ b/app/models/verification/residence.rb
@@ -16,8 +16,6 @@ class Verification::Residence
validate :allowed_age
validate :document_number_uniqueness
- validate :postal_code_in_madrid
- validate :residence_in_madrid
def initialize(attrs={})
self.date_of_birth = parse_date('date_of_birth', attrs)
@@ -28,33 +26,24 @@ class Verification::Residence
def save
return false unless valid?
+
+ user.take_votes_if_erased_document(document_number, document_type)
+
user.update(document_number: document_number,
document_type: document_type,
geozone: self.geozone,
- residence_verified_at: Time.now)
+ date_of_birth: date_of_birth.to_datetime,
+ gender: gender,
+ residence_verified_at: Time.current)
end
def allowed_age
return if errors[:date_of_birth].any?
- errors.add(:date_of_birth, I18n.t('verification.residence.new.error_not_allowed_age')) unless self.date_of_birth <= 16.years.ago
+ errors.add(:date_of_birth, I18n.t('verification.residence.new.error_not_allowed_age')) unless Age.in_years(self.date_of_birth) >= User.minimum_required_age
end
def document_number_uniqueness
- errors.add(:document_number, I18n.t('errors.messages.taken')) if User.where(document_number: document_number).any?
- end
-
- def postal_code_in_madrid
- errors.add(:postal_code, I18n.t('verification.residence.new.error_not_allowed_postal_code')) unless valid_postal_code?
- end
-
- def residence_in_madrid
- return if errors.any?
-
- unless residency_valid?
- errors.add(:residence_in_madrid, false)
- store_failed_attempt
- Lock.increase_tries(user)
- end
+ errors.add(:document_number, I18n.t('errors.messages.taken')) if User.active.where(document_number: document_number).any?
end
def store_failed_attempt
@@ -75,6 +64,10 @@ class Verification::Residence
@census_api_response.district_code
end
+ def gender
+ @census_api_response.gender
+ end
+
private
def call_census_api
@@ -84,15 +77,11 @@ class Verification::Residence
def residency_valid?
@census_api_response.valid? &&
@census_api_response.postal_code == postal_code &&
- @census_api_response.date_of_birth == date_to_string(date_of_birth)
+ @census_api_response.date_of_birth == date_of_birth
end
def clean_document_number
self.document_number = self.document_number.gsub(/[^a-z0-9]+/i, "").upcase unless self.document_number.blank?
end
- def valid_postal_code?
- postal_code =~ /^280/
- end
-
end
diff --git a/app/models/verification/sms.rb b/app/models/verification/sms.rb
index ba484c4f3..1a013f1d8 100644
--- a/app/models/verification/sms.rb
+++ b/app/models/verification/sms.rb
@@ -4,14 +4,9 @@ class Verification::Sms
attr_accessor :user, :phone, :confirmation_code
validates_presence_of :phone
- validates :phone, length: { is: 9 }
- validate :spanish_phone
+ validates :phone, format: { with: /\A[\d \+]+\z/ }
validate :uniqness_phone
- def spanish_phone
- errors.add(:phone, :invalid) unless phone.start_with?('6', '7')
- end
-
def uniqness_phone
errors.add(:phone, :taken) if User.where(confirmed_phone: phone).any?
end
@@ -40,4 +35,4 @@ class Verification::Sms
def generate_confirmation_code
rand.to_s[2..5]
end
-end
\ No newline at end of file
+end
diff --git a/app/views/account/show.html.erb b/app/views/account/show.html.erb
index f0d2b6abc..247aa6024 100644
--- a/app/views/account/show.html.erb
+++ b/app/views/account/show.html.erb
@@ -34,7 +34,9 @@
<%= f.label :public_activity do %>
<%= f.check_box :public_activity, title: t('account.show.public_activity_label'), label: false %>
- <%= t("account.show.public_activity_label") %>
+
+ <%= t("account.show.public_activity_label") %>
+
<% end %>
@@ -43,24 +45,61 @@
<%= f.label :email_on_comment do %>
<%= f.check_box :email_on_comment, title: t('account.show.email_on_comment_label'), label: false %>
- <%= t("account.show.email_on_comment_label") %>
+
+ <%= t("account.show.email_on_comment_label") %>
+
<% end %>
<%= f.label :email_on_comment_reply do %>
<%= f.check_box :email_on_comment_reply, title: t('account.show.email_on_comment_reply_label'), label: false %>
- <%= t("account.show.email_on_comment_reply_label") %>
+
+ <%= t("account.show.email_on_comment_reply_label") %>
+
<% end %>
<%= f.label :email_newsletter_subscribed do %>
<%= f.check_box :newsletter, title: t('account.show.subscription_to_website_newsletter_label'), label: false %>
- <%= t("account.show.subscription_to_website_newsletter_label") %>
+
+ <%= t("account.show.subscription_to_website_newsletter_label") %>
+
<% end %>
+
+ <%= f.label :email_digest do %>
+ <%= f.check_box :email_digest, title: t('account.show.email_digest_label'), label: false %>
+
+ <%= t("account.show.email_digest_label") %>
+
+ <% end %>
+
+
+
+ <%= f.label :email_on_direct_message do %>
+ <%= f.check_box :email_on_direct_message, title: t('account.show.email_on_direct_message_label'), label: false %>
+
+ <%= t("account.show.email_on_direct_message_label") %>
+
+ <% end %>
+
+
+ <% if @account.official_level == 1 %>
+
+ <%= f.label :official_position_badge do %>
+ <%= f.check_box :official_position_badge,
+ title: t('account.show.official_position_badge_label'),
+ label: false %>
+
+ <%= t("account.show.official_position_badge_label") %>
+
+ <% end %>
+
+ <% end %>
+
<%= f.submit t("account.show.save_changes_submit"), class: "button" %>
diff --git a/app/views/admin/_menu.html.erb b/app/views/admin/_menu.html.erb
index f435c5511..0b9929f46 100644
--- a/app/views/admin/_menu.html.erb
+++ b/app/views/admin/_menu.html.erb
@@ -1,109 +1,166 @@
-