-
Notifications
You must be signed in to change notification settings - Fork 31
Feedback wanted: scored elections #387
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f224da1
6dc4f86
eca4e16
f5ad005
0f45b41
5bd9c8a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| class Admin::ElectionsController < Admin::ApplicationController | ||
| before_action :set_election, only: %i[show edit update destroy] | ||
| before_action :set_election_nominations, only: %i[show] | ||
|
|
||
| def index | ||
| @elections = Election.order(created_at: :desc) | ||
| end | ||
|
|
||
| def show; end | ||
|
|
||
| def new | ||
| @election = Election.new | ||
| end | ||
|
|
||
| def edit; end | ||
|
|
||
| def create | ||
| @election = Election.new(election_params) | ||
|
|
||
| if @election.save | ||
| redirect_to admin_election_path(@election), notice: "Election was successfully created." | ||
| else | ||
| render :new, status: :unprocessable_content | ||
| end | ||
| end | ||
|
|
||
| def update | ||
| if @election.update(election_params) | ||
| redirect_to admin_election_path(@election), notice: "Election was successfully updated." | ||
| else | ||
| render :edit, status: :unprocessable_content, alert: "Election could not be updated due to #{@election.errors.full_messages.to_sentence}." | ||
| end | ||
| end | ||
|
|
||
| def destroy | ||
| raise NotImplementedError | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def set_election | ||
| @election = Election.find(params[:id]) | ||
| end | ||
|
|
||
| def set_election_nominations | ||
| @nominations = @election.nominations | ||
| end | ||
|
|
||
| def election_params | ||
| params.expect(election: [:closed_at, :opened_at, :point_scale, :title, :vacancies]) | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| class Admin::NominationsController < Admin::ApplicationController | ||
| before_action :set_election, only: %i[new create] | ||
|
|
||
| def new | ||
| @nomination = Nomination.new | ||
| end | ||
|
|
||
| def create | ||
| p params | ||
| nomination_attributes = build_nomination_attributes | ||
|
|
||
| if nomination_attributes[:errors].present? | ||
| @nomination = Nomination.new | ||
| @nomination.errors.add(:base, nomination_attributes[:errors]) | ||
| render :new, status: :unprocessable_entity | ||
| else | ||
| @nomination = Nomination.new(nomination_attributes.merge(election: @election)) | ||
|
|
||
| if @nomination.save | ||
| redirect_to admin_election_path(@election), notice: "Nomination was successfully created." | ||
| else | ||
| render :new, status: :unprocessable_content | ||
| end | ||
| end | ||
| end | ||
|
|
||
| def destroy | ||
| raise NotImplementedError | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def set_election | ||
| @election = Election.find(params[:election_id]) | ||
| end | ||
|
|
||
| def build_nomination_attributes | ||
| nominated_member = Email.find_by(email: nomination_params[:member_email_address])&.user | ||
| nominating_member = Email.find_by(email: nomination_params[:nominating_member_email_address])&.user | ||
|
|
||
| errors = [] | ||
| errors << "Member not found" unless nominated_member | ||
| errors << "Nominating member not found" unless nominating_member | ||
|
|
||
| return { errors: errors.join(", ") } if errors.any? | ||
|
|
||
| { | ||
| nominee_id: nominated_member.id, | ||
| nominated_by_id: nominating_member.id | ||
| } | ||
| end | ||
|
|
||
| def nomination_params | ||
| params.expect(nomination: [:member_email_address, :nominating_member_email_address]) | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| class BallotsController < ApplicationController | ||
| def create | ||
| ballot_params.each do |nomination_id, score| | ||
| nomination = Nomination.find(nomination_id) | ||
|
|
||
| Vote.create!( | ||
| voter: current_user, | ||
| votable: nomination, | ||
| score: score, | ||
| ) | ||
| end | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def ballot_params | ||
| params.require(:ballot).require(:nomination_id_scores).permit! | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| class ElectionsController < ApplicationController | ||
| before_action :set_election, only: %i[ show edit update destroy ] | ||
|
|
||
| # GET /elections or /elections.json | ||
| def index | ||
| @current_elections = Election.open | ||
| @closed_elections = Election.closed | ||
| end | ||
|
|
||
| # GET /elections/1 or /elections/1.json | ||
| def show | ||
| @ballot = Ballot.new | ||
| end | ||
|
|
||
| # GET /elections/new | ||
| def new | ||
| @election = Election.new | ||
| end | ||
|
|
||
| # GET /elections/1/edit | ||
| def edit | ||
| end | ||
|
|
||
| # POST /elections or /elections.json | ||
| def create | ||
| @election = Election.new(election_params) | ||
|
|
||
| respond_to do |format| | ||
| if @election.save | ||
| format.html { redirect_to @election, notice: "Election was successfully created." } | ||
| format.json { render :show, status: :created, location: @election } | ||
| else | ||
| format.html { render :new, status: :unprocessable_entity } | ||
| format.json { render json: @election.errors, status: :unprocessable_entity } | ||
| end | ||
| end | ||
| end | ||
|
|
||
| # PATCH/PUT /elections/1 or /elections/1.json | ||
| def update | ||
| respond_to do |format| | ||
| if @election.update(election_params) | ||
| format.html { redirect_to @election, notice: "Election was successfully updated.", status: :see_other } | ||
| format.json { render :show, status: :ok, location: @election } | ||
| else | ||
| format.html { render :edit, status: :unprocessable_entity } | ||
| format.json { render json: @election.errors, status: :unprocessable_entity } | ||
| end | ||
| end | ||
| end | ||
|
|
||
| # DELETE /elections/1 or /elections/1.json | ||
| def destroy | ||
| @election.destroy! | ||
|
|
||
| respond_to do |format| | ||
| format.html { redirect_to elections_path, notice: "Election was successfully destroyed.", status: :see_other } | ||
| format.json { head :no_content } | ||
| end | ||
| end | ||
|
|
||
| private | ||
|
|
||
| # Use callbacks to share common setup or constraints between actions. | ||
| def set_election | ||
| @election = Election.includes(nominations: :nominee).find(params.expect(:id)) | ||
| end | ||
|
|
||
| # Only allow a list of trusted parameters through. | ||
| def election_params | ||
| params.fetch(:election, {}) | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| module ElectionsHelper | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| class Ballot | ||
| include ActiveModel::Model | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| # == Schema Information | ||
| # | ||
| # Table name: elections | ||
| # | ||
| # id :bigint not null, primary key | ||
| # closed_at :datetime | ||
| # opened_at :datetime | ||
| # point_scale :integer default(10), not null | ||
| # title :string not null | ||
| # vacancies :integer default(1), not null | ||
| # created_at :datetime not null | ||
| # updated_at :datetime not null | ||
| # | ||
| class Election < ApplicationRecord | ||
| has_many :nominations, dependent: :restrict_with_error | ||
| has_many :votes, through: :nominations | ||
| has_many :nominated_candidates, through: :nominations, source: :nominee | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nominated_candidates somewhat rules out if the vote is on something other than an election (ie, if it was about voting on the approval of a new constitutional change )
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was seeing
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sure, it's not always going to be a resolution either, ideally it could theoretically be voting on a new mascot or something. |
||
|
|
||
| scope :open, -> { where('opened_at <= ?', Time.current).where('closed_at > ? OR closed_at IS NULL', Time.current) } | ||
| scope :closed, -> { where('closed_at <= ?', Time.current) } | ||
|
|
||
| validates_presence_of :title, :point_scale, :vacancies | ||
|
|
||
| def open? | ||
| opened_at.past? && !closed? | ||
| end | ||
|
|
||
| def closed? | ||
| closed_at.past? | ||
| end | ||
|
|
||
| def elected_users | ||
| if voting_required? | ||
| top_scoring_candidates | ||
| else | ||
| nominated_candidates | ||
| end | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def voting_required? | ||
| nominated_candidates.count > vacancies | ||
| end | ||
|
|
||
| def top_scoring_candidates | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So would we have multiple elections/votes for each role? So I can imagine a separate vote for President, and then a vote for Secretary, but let's assume that we have a general members election and Candidate A is keen to run for both Sponsor Outreach and Diversity Lead, my first thought is to the option to vote for
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My intention with this method returning multiple users is to accommodate both elections for President etc., and the election of ordinary members. President etc
Ordinary members
Your scenario is interesting, and possibly I've either misinterpreted the process for ordinary members, or, in "A single election may be held", the word "may" is key. Is it typical to hold an election (ballot) per office to be held by an ordinary member? In any case, I reckon this scenario of a candidate running for multiple positions could be handled by the candidate withdrawing acceptance for the nomination, and scoping accordingly here.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So if I'm understanding this correctly if we had 4 open ordinary members this would returns the 4 nominations with the most votes? Would that be in order?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In that case, there would be 4 vacancies, so this query would get the 4 top scoring nominations (so not necessarily the nominations with the most votes -- e.g. a nomination with 1 vote with a score of 10 beats a nomination with 9 votes with a score of 1). It then returns the users referenced by the nominations, in no particular order. And this code path is only invoked if there are more nominations than vacancies (i.e. voting is required). |
||
| User.where( | ||
| id: nominations | ||
| .joins(:votes) | ||
| .group('nominations.id') | ||
| .order('SUM(votes.score) DESC') | ||
| .limit(vacancies) | ||
| .pluck(:nominee_id) | ||
| ) | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| # == Schema Information | ||
| # | ||
| # Table name: nominations | ||
| # | ||
| # id :bigint not null, primary key | ||
| # created_at :datetime not null | ||
| # updated_at :datetime not null | ||
| # election_id :bigint not null | ||
| # nominated_by_id :bigint not null | ||
| # nominee_id :bigint not null | ||
| # | ||
| # Indexes | ||
| # | ||
| # index_nominations_on_election_id (election_id) | ||
| # index_nominations_on_election_id_and_nominee_id (election_id,nominee_id) UNIQUE | ||
| # index_nominations_on_nominated_by_id (nominated_by_id) | ||
| # index_nominations_on_nominee_id (nominee_id) | ||
| # | ||
| # Foreign Keys | ||
| # | ||
| # fk_rails_... (election_id => elections.id) | ||
| # fk_rails_... (nominated_by_id => users.id) | ||
| # fk_rails_... (nominee_id => users.id) | ||
| # | ||
| class Nomination < ApplicationRecord | ||
| has_many :votes, as: :votable, dependent: :restrict_with_error | ||
| belongs_to :election | ||
| belongs_to :nominee, class_name: 'User' | ||
| belongs_to :nominated_by, class_name: 'User' | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| # == Schema Information | ||
| # | ||
| # Table name: votes | ||
| # | ||
| # id :bigint not null, primary key | ||
| # score :integer not null | ||
| # votable_type :string not null | ||
| # created_at :datetime not null | ||
| # updated_at :datetime not null | ||
| # votable_id :bigint not null | ||
| # voter_id :bigint not null | ||
| # | ||
| # Indexes | ||
| # | ||
| # index_votes_on_votable (votable_type,votable_id) | ||
| # index_votes_on_voter_id (voter_id) | ||
| # index_votes_on_voter_id_and_votable_id (voter_id,votable_id) UNIQUE | ||
| # | ||
| # Foreign Keys | ||
| # | ||
| # fk_rails_... (voter_id => users.id) | ||
| # | ||
| class Vote < ApplicationRecord | ||
| belongs_to :votable, polymorphic: true | ||
| belongs_to :voter, class_name: 'User' | ||
|
|
||
| validate :election_must_be_open_validation | ||
|
|
||
| private | ||
|
|
||
| def election_must_be_open_validation | ||
| errors.add(:base, "Election is not currently open") if votable.is_a?(Nomination) && !votable.election.open? | ||
| end | ||
| end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe the Election should have a title of what is being voted on.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
position :stringwas intended for that, maybe the naming could be improved.