diff --git a/Gemfile b/Gemfile index d2718c3d..00d6683c 100644 --- a/Gemfile +++ b/Gemfile @@ -54,6 +54,7 @@ group :development, :test do gem 'rubocop-performance', require: false gem 'rubocop-rspec_rails' gem 'rubocop-rails', require: false + gem 'pry-rails' end group :development do diff --git a/Gemfile.lock b/Gemfile.lock index 17339f99..0798a4b5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -344,6 +344,8 @@ GEM pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) + pry-rails (0.3.11) + pry (>= 0.13.0) psych (5.3.0) date stringio @@ -615,6 +617,7 @@ DEPENDENCIES meta-tags (~> 2.22) pg premailer-rails + pry-rails puma pygmentize rails (>= 8.0.0) diff --git a/app/controllers/admin/elections_controller.rb b/app/controllers/admin/elections_controller.rb new file mode 100644 index 00000000..c095dd0f --- /dev/null +++ b/app/controllers/admin/elections_controller.rb @@ -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 diff --git a/app/controllers/admin/nominations_controller.rb b/app/controllers/admin/nominations_controller.rb new file mode 100644 index 00000000..cdb9becf --- /dev/null +++ b/app/controllers/admin/nominations_controller.rb @@ -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 diff --git a/app/controllers/ballots_controller.rb b/app/controllers/ballots_controller.rb new file mode 100644 index 00000000..e384c31d --- /dev/null +++ b/app/controllers/ballots_controller.rb @@ -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 diff --git a/app/controllers/elections_controller.rb b/app/controllers/elections_controller.rb new file mode 100644 index 00000000..e4b5f1df --- /dev/null +++ b/app/controllers/elections_controller.rb @@ -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 diff --git a/app/helpers/elections_helper.rb b/app/helpers/elections_helper.rb new file mode 100644 index 00000000..09497b1d --- /dev/null +++ b/app/helpers/elections_helper.rb @@ -0,0 +1,2 @@ +module ElectionsHelper +end diff --git a/app/models/ballot.rb b/app/models/ballot.rb new file mode 100644 index 00000000..2b9a40fa --- /dev/null +++ b/app/models/ballot.rb @@ -0,0 +1,3 @@ +class Ballot + include ActiveModel::Model +end diff --git a/app/models/election.rb b/app/models/election.rb new file mode 100644 index 00000000..f864a2c8 --- /dev/null +++ b/app/models/election.rb @@ -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 + + 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 + User.where( + id: nominations + .joins(:votes) + .group('nominations.id') + .order('SUM(votes.score) DESC') + .limit(vacancies) + .pluck(:nominee_id) + ) + end +end diff --git a/app/models/nomination.rb b/app/models/nomination.rb new file mode 100644 index 00000000..928c9fc3 --- /dev/null +++ b/app/models/nomination.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index 37bc780c..73c7b6fa 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -54,6 +54,8 @@ class User < ApplicationRecord has_many :memberships, dependent: :destroy has_many :emails, dependent: :destroy has_many :access_requests, foreign_key: :recorder_id, dependent: :destroy, inverse_of: :recorder + has_many :nominations_received, class_name: 'Nomination', foreign_key: :nominee_id, inverse_of: :nominee, dependent: :restrict_with_error + has_many :nominations_made, class_name: 'Nomination', foreign_key: :nominated_by, inverse_of: :nominated_by, dependent: :restrict_with_error scope :seeking_work, -> { where(seeking_work: true).where.not(linkedin_url: [nil, ""]) } scope :unconfirmed, -> { where(confirmed_at: nil) } diff --git a/app/models/vote.rb b/app/models/vote.rb new file mode 100644 index 00000000..2b238e7c --- /dev/null +++ b/app/models/vote.rb @@ -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 diff --git a/app/views/admin/elections/_election.html.erb b/app/views/admin/elections/_election.html.erb new file mode 100644 index 00000000..8119d5be --- /dev/null +++ b/app/views/admin/elections/_election.html.erb @@ -0,0 +1,89 @@ +
+
+
+
+

Election

+
+ +
+
+

Title

+

<%= election.title %>

+
+ +
+
+

Vacancies

+

<%= election.vacancies %>

+
+ +
+

Point scale

+

<%= election.point_scale %>

+
+
+ +
+
+

Opened at

+

<%= election.opened_at %>

+
+ +
+

Closed at

+

<%= election.closed_at %>

+
+
+ +
+

Created at

+

<%= election.created_at %>

+
+
+
+
+ +
+
+ <% if @nominations.none? %> +
+

No nominations.

+
+ <% else %> +
+

Nominations

+
+ +
+ + + + + + + + + + + <% @nominations.each do |nomination| %> + + + + + + + <% end %> + +
NameEmailNominated by
<%= nomination.nominee.full_name %><%= nomination.nominee.email %><%= nomination.nominated_by.email %>
+ + <%#
    %> + <%# <% @nominations.each do |nomination| %1> %> + <%#
  • <%= nomination.nominee.email %1>
  • %> + <%# <% end %1> %> + <%#
%> + +
+ <% end %> +
+
+
diff --git a/app/views/admin/elections/_form.html.erb b/app/views/admin/elections/_form.html.erb new file mode 100644 index 00000000..b67d6082 --- /dev/null +++ b/app/views/admin/elections/_form.html.erb @@ -0,0 +1,58 @@ +<%= form_with(model: [:admin, election], class: "w-full mx-auto") do |form| %> + <% if election.errors.any? %> +
+
+
+ + + +
+
+

+ <%= pluralize(election.errors.count, "error") %> prohibited this election from being saved: +

+
    + <% election.errors.each do |error| %> +
  • <%= error.full_message %>
  • + <% end %> +
+
+
+
+ <% end %> + +
+
+ <%= form.label :title, class: "block text-sm sm:text-base font-medium text-gray-700" %> + <%= form.text_field :title, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-ruby-red focus:ring focus:ring-ruby-red/20 text-sm sm:text-base" %> +
+ +
+
+ <%= form.label :vacancies, class: "block text-sm sm:text-base font-medium text-gray-700" %> + <%= form.number_field :vacancies, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-ruby-red focus:ring focus:ring-ruby-red/20 text-sm sm:text-base" %> +
+ +
+ <%= form.label :point_scale, class: "block text-sm sm:text-base font-medium text-gray-700" %> + <%= form.number_field :point_scale, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-ruby-red focus:ring focus:ring-ruby-red/20 text-sm sm:text-base" %> +
+
+ +
+
+ <%= form.label :opened_at, class: "block text-sm sm:text-base font-medium text-gray-700" %> + <%= form.datetime_field :opened_at, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-ruby-red focus:ring focus:ring-ruby-red/20 text-sm sm:text-base" %> +
+ +
+ <%= form.label :closed_at, class: "block text-sm sm:text-base font-medium text-gray-700" %> + <%= form.datetime_field :closed_at, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-ruby-red focus:ring focus:ring-ruby-red/20 text-sm sm:text-base" %> +
+
+ +
+ <%= form.submit class: "inline-flex justify-center py-2 px-4 sm:py-3 sm:px-6 border border-transparent shadow-sm text-sm sm:text-base font-medium rounded-md text-white bg-ruby-red hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-ruby-red transition-colors" %> +
+
+<% end %> diff --git a/app/views/admin/elections/edit.html.erb b/app/views/admin/elections/edit.html.erb new file mode 100644 index 00000000..51deb80c --- /dev/null +++ b/app/views/admin/elections/edit.html.erb @@ -0,0 +1,22 @@ +
+
+
+
+

Edit Election

+
+ +
+ <%= render "form", election: @election %> +
+ +
+ <%= link_to admin_election_path(@election), class: "group inline-flex items-center justify-center text-gray-700 hover:text-ruby-red transition-colors text-sm sm:text-base" do %> + + + + Back to election + <% end %> +
+
+
+
diff --git a/app/views/admin/elections/index.html.erb b/app/views/admin/elections/index.html.erb new file mode 100644 index 00000000..c33e124c --- /dev/null +++ b/app/views/admin/elections/index.html.erb @@ -0,0 +1,66 @@ +
+
+

Elections

+ + <%= link_to new_admin_election_path, class: "inline-flex items-center justify-center bg-ruby-red hover:bg-red-700 text-white font-medium py-2 px-4 rounded-lg transition-colors shadow-sm text-sm sm:text-base" do %> + + + + New Election + <% end %> +
+ + <% if @elections.none? %> +
+

No elections.

+
+ <% else %> +
+
+ + + + + + + + + + + + + + <% @elections.each do |election| %> + + + + + + + + + + <% end %> + +
Point scaleTitleVacancies
<%= election.point_scale %> + <%= link_to admin_election_path(election), class: "text-ruby-red hover:text-red-700 transition-colors" do %> + <%= election.title %> + <% end %> + <%= election.vacancies %>
+
+ + <% if @posts.respond_to?(:total_pages) && @posts.total_pages > 1 %> +
+
+
+ Showing page <%= @posts.current_page %> of <%= @posts.total_pages %> +
+
+ <%= paginate @posts %> +
+
+
+ <% end %> + <% end %> +
+
diff --git a/app/views/admin/elections/new.html.erb b/app/views/admin/elections/new.html.erb new file mode 100644 index 00000000..f0867316 --- /dev/null +++ b/app/views/admin/elections/new.html.erb @@ -0,0 +1,20 @@ +
+
+
+

New Election

+
+ +
+ <%= render "form", election: @election %> + +
+ <%= link_to admin_elections_path, class: "group inline-flex items-center justify-center text-gray-700 hover:text-ruby-red transition-colors text-sm sm:text-base" do %> + + + + Back to elections + <% end %> +
+
+
+
diff --git a/app/views/admin/elections/show.html.erb b/app/views/admin/elections/show.html.erb new file mode 100644 index 00000000..cd70e53d --- /dev/null +++ b/app/views/admin/elections/show.html.erb @@ -0,0 +1,19 @@ +
+ <%= render @election %> + +
+
+ <%= link_to "Edit", edit_admin_election_path(@election), class: "inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %> + + <%= link_to "Delete", admin_election_path(@election), + method: :delete, + data: { confirm: 'Are you sure you want to delete this election?' }, + class: "inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %> + + <%= link_to "Add nomination", new_admin_election_nomination_path(@election), class: "inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %> + + <%= link_to "Back to elections", admin_elections_path, + class: "inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-gray-600 border border-gray-300 rounded-md bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500" %> +
+
+
diff --git a/app/views/admin/nominations/_form.html.erb b/app/views/admin/nominations/_form.html.erb new file mode 100644 index 00000000..eabe240a --- /dev/null +++ b/app/views/admin/nominations/_form.html.erb @@ -0,0 +1,39 @@ +<%= form_with(model: [:admin, :election, nomination], class: "w-full mx-auto") do |form| %> + <% if nomination.errors.any? %> +
+
+
+ + + +
+
+

+ <%= pluralize(nomination.errors.count, "error") %> prohibited this nomination from being saved: +

+
    + <% nomination.errors.each do |error| %> +
  • <%= error.full_message %>
  • + <% end %> +
+
+
+
+ <% end %> + +
+
+ <%= form.label :member_email_address, class: "block text-sm sm:text-base font-medium text-gray-700" %> + <%= form.text_field :member_email_address, class: "appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-ruby-red focus:border-ruby-red sm:text-sm" %> +
+ +
+ <%= form.label :nominating_member_email_address, class: "block text-sm sm:text-base font-medium text-gray-700" %> + <%= form.text_field :nominating_member_email_address, class: "appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-ruby-red focus:border-ruby-red sm:text-sm" %> +
+ +
+ <%= form.submit class: "inline-flex justify-center py-2 px-4 sm:py-3 sm:px-6 border border-transparent shadow-sm text-sm sm:text-base font-medium rounded-md text-white bg-ruby-red hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-ruby-red transition-colors" %> +
+
+<% end %> diff --git a/app/views/admin/nominations/new.html.erb b/app/views/admin/nominations/new.html.erb new file mode 100644 index 00000000..1d4d7a42 --- /dev/null +++ b/app/views/admin/nominations/new.html.erb @@ -0,0 +1,20 @@ +
+
+
+

New Nomination

+
+ +
+ <%= render "form", nomination: @nomination %> + +
+ <%= link_to admin_election_path(@election), class: "group inline-flex items-center justify-center text-gray-700 hover:text-ruby-red transition-colors text-sm sm:text-base" do %> + + + + Back to election + <% end %> +
+
+
+
diff --git a/app/views/elections/_election.html.erb b/app/views/elections/_election.html.erb new file mode 100644 index 00000000..d929f25f --- /dev/null +++ b/app/views/elections/_election.html.erb @@ -0,0 +1,18 @@ +
+
+

+ Election for <%= election.title %> +

+
+ <% if election.open? %> + Closes in <%= time_ago_in_words(election.closed_at) %> + <% else %> + Closed at <%= I18n.l(election.closed_at) %> + <% end %> +
+
+
+

Give each nominee a score between 0 and <%= election.point_scale %>.

+
+ <%= render "form", election: election, ballot: ballot %> +
diff --git a/app/views/elections/_election.json.jbuilder b/app/views/elections/_election.json.jbuilder new file mode 100644 index 00000000..8af84ab4 --- /dev/null +++ b/app/views/elections/_election.json.jbuilder @@ -0,0 +1,2 @@ +json.extract! election, :id, :created_at, :updated_at +json.url election_url(election, format: :json) diff --git a/app/views/elections/_form.html.erb b/app/views/elections/_form.html.erb new file mode 100644 index 00000000..5fd50675 --- /dev/null +++ b/app/views/elections/_form.html.erb @@ -0,0 +1,51 @@ +<%= form_with(model: [election, ballot], class: "w-full mx-auto") do |form| %> + <% if election.errors.any? %> +
+
+
+ + + +
+
+

+ <%= pluralize(election.errors.count, "error") %> prohibited this election from being saved: +

+
    + <% election.errors.each do |error| %> +
  • <%= error.full_message %>
  • + <% end %> +
+
+
+
+ <% end %> + + + + + + + + + + <% election.nominations.each do |nomination| %> + + + + + <% end %> + +
Nominee
+ <%= nomination.nominee.full_name %> +
+ +
+ <%= form.submit "Submit", class: "inline-flex justify-center py-2 px-4 sm:py-3 sm:px-6 border border-transparent shadow-sm text-sm sm:text-base font-medium rounded-md text-white bg-ruby-red hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-ruby-red transition-colors" %> +
+<% end %> diff --git a/app/views/elections/edit.html.erb b/app/views/elections/edit.html.erb new file mode 100644 index 00000000..e43c0542 --- /dev/null +++ b/app/views/elections/edit.html.erb @@ -0,0 +1,12 @@ +<% content_for :title, "Editing election" %> + +

Editing election

+ +<%= render "form", election: @election %> + +
+ +
+ <%= link_to "Show this election", @election %> | + <%= link_to "Back to elections", elections_path %> +
diff --git a/app/views/elections/index.html.erb b/app/views/elections/index.html.erb new file mode 100644 index 00000000..6822de59 --- /dev/null +++ b/app/views/elections/index.html.erb @@ -0,0 +1,42 @@ +
+ <% if @current_elections.any? %> +
+
+

Current elections

+
+ + + + + + + + + <% @current_elections.each do |election| %> + + + + + <% end %> + +
Election for
+ <%= link_to election_path(election), class: "text-ruby-red hover:text-red-700 transition-colors" do %> + <%= election.title %> + <% end %> +
+
+ <% end %> + + <% if @closed_elections.open.any? %> +
+
+

Previous elections

+
+ <% @closed_elections.each do |election| %> + <%= link_to "Current elections: #{election.title}", election %> + <% end %> +
+ <% end %> +
diff --git a/app/views/elections/index.json.jbuilder b/app/views/elections/index.json.jbuilder new file mode 100644 index 00000000..75079f9f --- /dev/null +++ b/app/views/elections/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @elections, partial: "elections/election", as: :election diff --git a/app/views/elections/new.html.erb b/app/views/elections/new.html.erb new file mode 100644 index 00000000..b38d39bc --- /dev/null +++ b/app/views/elections/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :title, "New election" %> + +

New election

+ +<%= render "form", election: @election %> + +
+ +
+ <%= link_to "Back to elections", elections_path %> +
diff --git a/app/views/elections/show.html.erb b/app/views/elections/show.html.erb new file mode 100644 index 00000000..2bdaad4a --- /dev/null +++ b/app/views/elections/show.html.erb @@ -0,0 +1,14 @@ +
+
+ <%= render @election, ballot: @ballot %> +
+ +
+ <%= link_to elections_path, class: "inline-flex items-center px-4 py-2 text-sm sm:text-base rounded-full bg-gray-100 hover:bg-ruby-red hover:text-white transition-colors border border-gray-200 shadow-sm" do %> + + + + Back to elections + <% end %> +
+
diff --git a/app/views/shared/_navigation.html.erb b/app/views/shared/_navigation.html.erb index 9cdddc94..bc524407 100644 --- a/app/views/shared/_navigation.html.erb +++ b/app/views/shared/_navigation.html.erb @@ -86,6 +86,7 @@ <%= link_to "Members", admin_memberships_path, class: "nav-link text-sm sm:text-base block px-4 py-2 hover:bg-white/10" %> <%= link_to "Access Requests", admin_access_requests_path, class: "nav-link text-sm sm:text-base block px-4 py-2 hover:bg-white/10" %> <%= link_to "Imported Members", admin_imported_members_path, class: "nav-link text-sm sm:text-base block px-4 py-2 hover:bg-white/10" %> + <%= link_to "Elections", admin_elections_path, class: "nav-link text-sm sm:text-base block px-4 py-2 hover:bg-white/10" %> <% end %> diff --git a/config/routes.rb b/config/routes.rb index 6ddae4e7..f12fc7fc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,7 @@ Rails.application.routes.draw do + resources :elections, only: [:show, :index] do + resources :ballots, only: [:create] + end constraints subdomain: "melbourne" do mount(Melbourne::Engine, at: "/") end @@ -56,6 +59,9 @@ get 'posts/*slug', to: 'posts#show', as: :post patch 'posts/*slug', to: 'posts#update', as: :update_post resources :analytics, only: [:index] + resources :elections, only: [:index, :show, :new, :create, :edit, :update, :destroy] do + resources :nominations, only: [:new, :create, :destroy] + end end namespace :api do diff --git a/db/migrate/20251007055656_create_elections.rb b/db/migrate/20251007055656_create_elections.rb new file mode 100644 index 00000000..3d54b396 --- /dev/null +++ b/db/migrate/20251007055656_create_elections.rb @@ -0,0 +1,14 @@ +class CreateElections < ActiveRecord::Migration[8.0] + def change + create_table :elections do |t| + t.string :title, null: false + t.integer :vacancies, default: 1, null: false + t.integer :maximum_score, default: 5, null: false + t.integer :minimum_score, default: -5, null: false + t.datetime :opened_at + t.datetime :closed_at + + t.timestamps + end + end +end diff --git a/db/migrate/20251007062829_create_nominations.rb b/db/migrate/20251007062829_create_nominations.rb new file mode 100644 index 00000000..c1c022a9 --- /dev/null +++ b/db/migrate/20251007062829_create_nominations.rb @@ -0,0 +1,13 @@ +class CreateNominations < ActiveRecord::Migration[8.0] + def change + create_table :nominations do |t| + t.references :election, null: false, foreign_key: true + t.references :nominee, null: false, foreign_key: { to_table: :users } + t.references :nominated_by, null: false, foreign_key: { to_table: :users } + + t.timestamps + end + + add_index :nominations, [:election_id, :nominee_id], unique: true + end +end diff --git a/db/migrate/20251008023211_create_votes.rb b/db/migrate/20251008023211_create_votes.rb new file mode 100644 index 00000000..64050019 --- /dev/null +++ b/db/migrate/20251008023211_create_votes.rb @@ -0,0 +1,13 @@ +class CreateVotes < ActiveRecord::Migration[8.0] + def change + create_table :votes do |t| + t.integer :score, null: false + t.references :voter, null: false, foreign_key: { to_table: :users } + t.references :votable, null: false, polymorphic: true + + t.timestamps + end + + add_index :votes, [:voter_id, :votable_id], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 2847ff86..a31383c8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,50 +10,50 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_09_22_132138) do +ActiveRecord::Schema[8.1].define(version: 2025_10_08_023211) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" create_table "access_requests", force: :cascade do |t| + t.datetime "created_at", precision: nil, null: false t.string "name", null: false t.text "reason" - t.date "requested_on", null: false - t.date "viewed_on" t.bigint "recorder_id" - t.datetime "created_at", precision: nil, null: false + t.date "requested_on", null: false t.datetime "updated_at", precision: nil, null: false + t.date "viewed_on" t.index ["recorder_id"], name: "index_access_requests_on_recorder_id" end create_table "action_text_rich_texts", force: :cascade do |t| - t.string "name", null: false t.text "body" - t.string "record_type", null: false - t.bigint "record_id", null: false t.datetime "created_at", null: false + t.string "name", null: false + t.bigint "record_id", null: false + t.string "record_type", null: false t.datetime "updated_at", null: false t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true end create_table "active_storage_attachments", force: :cascade do |t| - t.string "name", null: false - t.string "record_type", null: false - t.bigint "record_id", null: false t.bigint "blob_id", null: false t.datetime "created_at", null: false + t.string "name", null: false + t.bigint "record_id", null: false + t.string "record_type", null: false t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true end create_table "active_storage_blobs", force: :cascade do |t| - t.string "key", null: false - t.string "filename", null: false - t.string "content_type" - t.text "metadata" - t.string "service_name", null: false t.bigint "byte_size", null: false t.string "checksum" + t.string "content_type" t.datetime "created_at", null: false + t.string "filename", null: false + t.string "key", null: false + t.text "metadata" + t.string "service_name", null: false t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true end @@ -64,11 +64,11 @@ end create_table "ahoy_events", force: :cascade do |t| - t.bigint "visit_id" - t.bigint "user_id" t.string "name" t.jsonb "properties" t.datetime "time" + t.bigint "user_id" + t.bigint "visit_id" t.index ["name", "time"], name: "index_ahoy_events_on_name_and_time" t.index ["properties"], name: "index_ahoy_events_on_properties", opclass: :jsonb_path_ops, using: :gin t.index ["user_id"], name: "index_ahoy_events_on_user_id" @@ -76,31 +76,31 @@ end create_table "ahoy_visits", force: :cascade do |t| - t.string "visit_token" - t.string "visitor_token" - t.bigint "user_id" - t.string "ip" - t.text "user_agent" - t.text "referrer" - t.string "referring_domain" - t.text "landing_page" + t.string "app_version" t.string "browser" - t.string "os" - t.string "device_type" - t.string "country" - t.string "region" t.string "city" + t.string "country" + t.string "device_type" + t.string "ip" + t.text "landing_page" t.float "latitude" t.float "longitude" - t.string "utm_source" - t.string "utm_medium" - t.string "utm_term" - t.string "utm_content" - t.string "utm_campaign" - t.string "app_version" + t.string "os" t.string "os_version" t.string "platform" + t.text "referrer" + t.string "referring_domain" + t.string "region" t.datetime "started_at" + t.text "user_agent" + t.bigint "user_id" + t.string "utm_campaign" + t.string "utm_content" + t.string "utm_medium" + t.string "utm_source" + t.string "utm_term" + t.string "visit_token" + t.string "visitor_token" t.index ["user_id"], name: "index_ahoy_visits_on_user_id" t.index ["visit_token"], name: "index_ahoy_visits_on_visit_token", unique: true t.index ["visitor_token", "started_at"], name: "index_ahoy_visits_on_visitor_token_and_started_at" @@ -108,48 +108,58 @@ create_table "campaign_deliveries", force: :cascade do |t| t.bigint "campaign_id" - t.bigint "membership_id" - t.datetime "delivered_at", precision: nil, null: false t.datetime "created_at", precision: nil, null: false + t.datetime "delivered_at", precision: nil, null: false + t.bigint "membership_id" t.datetime "updated_at", precision: nil, null: false t.index ["campaign_id"], name: "index_campaign_deliveries_on_campaign_id" t.index ["membership_id"], name: "index_campaign_deliveries_on_membership_id" end create_table "campaigns", force: :cascade do |t| - t.bigint "rsvp_event_id" - t.string "subject", null: false - t.string "preheader", null: false t.text "content" - t.datetime "delivered_at", precision: nil t.datetime "created_at", precision: nil, null: false + t.datetime "delivered_at", precision: nil + t.string "preheader", null: false + t.bigint "rsvp_event_id" + t.string "subject", null: false t.datetime "updated_at", precision: nil, null: false t.index ["rsvp_event_id"], name: "index_campaigns_on_rsvp_event_id" end + create_table "elections", force: :cascade do |t| + t.datetime "closed_at" + t.datetime "created_at", null: false + t.datetime "opened_at" + t.integer "point_scale", default: 10, null: false + t.string "title", null: false + t.datetime "updated_at", null: false + t.integer "vacancies", default: 1, null: false + end + create_table "emails", force: :cascade do |t| - t.bigint "user_id" - t.string "email", null: false - t.boolean "primary", default: false, null: false - t.string "unconfirmed_email" + t.datetime "confirmation_sent_at" t.string "confirmation_token" t.datetime "confirmed_at" - t.datetime "confirmation_sent_at" t.datetime "created_at", null: false + t.string "email", null: false + t.boolean "primary", default: false, null: false + t.string "unconfirmed_email" t.datetime "updated_at", null: false + t.bigint "user_id" t.index ["email"], name: "index_emails_on_email", unique: true t.index ["user_id"], name: "index_emails_on_user_id" end create_table "imported_members", force: :cascade do |t| - t.string "full_name", null: false - t.string "email", null: false - t.json "data", default: {}, null: false t.datetime "contacted_at", precision: nil t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false + t.json "data", default: {}, null: false + t.string "email", null: false + t.string "full_name", null: false t.string "token", null: false t.datetime "unsubscribed_at", precision: nil + t.datetime "updated_at", precision: nil, null: false t.index ["contacted_at"], name: "index_imported_members_on_contacted_at" t.index ["email"], name: "index_imported_members_on_email" t.index ["token"], name: "index_imported_members_on_token", unique: true @@ -157,89 +167,101 @@ end create_table "memberships", force: :cascade do |t| - t.bigint "user_id", null: false + t.datetime "created_at", precision: nil, null: false t.datetime "joined_at", precision: nil, null: false t.datetime "left_at", precision: nil - t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false + t.bigint "user_id", null: false t.index ["user_id"], name: "index_memberships_on_user_id" end + create_table "nominations", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "election_id", null: false + t.bigint "nominated_by_id", null: false + t.bigint "nominee_id", null: false + t.datetime "updated_at", null: false + t.index ["election_id", "nominee_id"], name: "index_nominations_on_election_id_and_nominee_id", unique: true + t.index ["election_id"], name: "index_nominations_on_election_id" + t.index ["nominated_by_id"], name: "index_nominations_on_nominated_by_id" + t.index ["nominee_id"], name: "index_nominations_on_nominee_id" + end + create_table "posts", force: :cascade do |t| - t.bigint "user_id" - t.string "title" - t.string "slug" - t.text "content" - t.integer "status" - t.datetime "published_at" - t.integer "category" - t.datetime "publish_scheduled_at" t.datetime "archived_at" + t.integer "category" + t.text "content" t.datetime "created_at", null: false + t.datetime "publish_scheduled_at" + t.datetime "published_at" + t.string "slug" + t.integer "status" + t.string "title" t.datetime "updated_at", null: false + t.bigint "user_id" t.index ["user_id"], name: "index_posts_on_user_id" end create_table "rsvp_events", force: :cascade do |t| - t.string "title", null: false - t.datetime "happens_at", precision: nil, null: false t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false + t.datetime "happens_at", precision: nil, null: false t.string "link" + t.string "title", null: false + t.datetime "updated_at", precision: nil, null: false end create_table "rsvps", force: :cascade do |t| - t.bigint "rsvp_event_id", null: false + t.datetime "created_at", precision: nil, null: false t.bigint "membership_id", null: false + t.datetime "proxy_assigned_at", precision: nil + t.string "proxy_name" + t.text "proxy_signature" + t.bigint "rsvp_event_id", null: false t.string "status", default: "unknown", null: false t.string "token", null: false - t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false - t.string "proxy_name" - t.text "proxy_signature" - t.datetime "proxy_assigned_at", precision: nil t.index ["membership_id"], name: "index_rsvps_on_membership_id" t.index ["rsvp_event_id"], name: "index_rsvps_on_rsvp_event_id" t.index ["token"], name: "index_rsvps_on_token", unique: true end create_table "solid_queue_blocked_executions", force: :cascade do |t| - t.bigint "job_id", null: false - t.string "queue_name", null: false - t.integer "priority", default: 0, null: false t.string "concurrency_key", null: false - t.datetime "expires_at", null: false t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release" t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance" t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true end create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false t.bigint "process_id" - t.datetime "created_at", null: false t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" end create_table "solid_queue_failed_executions", force: :cascade do |t| - t.bigint "job_id", null: false - t.text "error" t.datetime "created_at", null: false + t.text "error" + t.bigint "job_id", null: false t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true end create_table "solid_queue_jobs", force: :cascade do |t| - t.string "queue_name", null: false - t.string "class_name", null: false - t.text "arguments" - t.integer "priority", default: 0, null: false t.string "active_job_id" - t.datetime "scheduled_at" - t.datetime "finished_at" + t.text "arguments" + t.string "class_name", null: false t.string "concurrency_key" t.datetime "created_at", null: false + t.datetime "finished_at" + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.datetime "scheduled_at" t.datetime "updated_at", null: false t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id" t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name" @@ -249,115 +271,137 @@ end create_table "solid_queue_pauses", force: :cascade do |t| - t.string "queue_name", null: false t.datetime "created_at", null: false + t.string "queue_name", null: false t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true end create_table "solid_queue_processes", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "hostname" t.string "kind", null: false t.datetime "last_heartbeat_at", null: false - t.bigint "supervisor_id" - t.integer "pid", null: false - t.string "hostname" t.text "metadata" - t.datetime "created_at", null: false t.string "name", null: false + t.integer "pid", null: false + t.bigint "supervisor_id" t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at" t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id" end create_table "solid_queue_ready_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false - t.string "queue_name", null: false t.integer "priority", default: 0, null: false - t.datetime "created_at", null: false + t.string "queue_name", null: false t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true t.index ["priority", "job_id"], name: "index_solid_queue_poll_all" t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue" end create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false - t.string "task_key", null: false t.datetime "run_at", null: false - t.datetime "created_at", null: false + t.string "task_key", null: false t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true end create_table "solid_queue_recurring_tasks", force: :cascade do |t| - t.string "key", null: false - t.string "schedule", null: false - t.string "command", limit: 2048 - t.string "class_name" t.text "arguments" - t.string "queue_name" + t.string "class_name" + t.string "command", limit: 2048 + t.datetime "created_at", null: false + t.text "description" + t.string "key", null: false t.integer "priority", default: 0 + t.string "queue_name" + t.string "schedule", null: false t.boolean "static", default: true, null: false - t.text "description" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static" end create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false - t.string "queue_name", null: false t.integer "priority", default: 0, null: false + t.string "queue_name", null: false t.datetime "scheduled_at", null: false - t.datetime "created_at", null: false t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all" end create_table "solid_queue_semaphores", force: :cascade do |t| - t.string "key", null: false - t.integer "value", default: 1, null: false - t.datetime "expires_at", null: false t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.string "key", null: false t.datetime "updated_at", null: false + t.integer "value", default: 1, null: false t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at" t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value" t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true end create_table "users", id: :serial, force: :cascade do |t| - t.string "email" + t.text "address" + t.boolean "committee", default: false, null: false + t.datetime "confirmation_sent_at", precision: nil + t.string "confirmation_token" + t.datetime "confirmed_at", precision: nil t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false - t.string "preferred_name" - t.string "full_name" - t.boolean "mailing_list", default: false, null: false - t.boolean "visible", default: false, null: false - t.string "encrypted_password" - t.string "token" - t.boolean "email_confirmed", default: false - t.string "reset_password_token" - t.datetime "reset_password_sent_at", precision: nil - t.datetime "remember_created_at", precision: nil - t.integer "sign_in_count", default: 0, null: false t.datetime "current_sign_in_at", precision: nil - t.datetime "last_sign_in_at", precision: nil t.inet "current_sign_in_ip" - t.inet "last_sign_in_ip" - t.string "confirmation_token" - t.datetime "confirmed_at", precision: nil - t.datetime "confirmation_sent_at", precision: nil - t.string "unconfirmed_email" - t.text "address" t.datetime "deactivated_at", precision: nil - t.boolean "committee", default: false, null: false - t.json "mailing_lists", default: {}, null: false + t.string "email" + t.boolean "email_confirmed", default: false + t.string "encrypted_password" + t.string "full_name" + t.datetime "last_sign_in_at", precision: nil + t.inet "last_sign_in_ip" t.string "linkedin_url" + t.boolean "mailing_list", default: false, null: false + t.json "mailing_lists", default: {}, null: false + t.string "preferred_name" + t.datetime "remember_created_at", precision: nil + t.datetime "reset_password_sent_at", precision: nil + t.string "reset_password_token" t.boolean "seeking_work", default: false, null: false + t.integer "sign_in_count", default: 0, null: false + t.string "token" + t.string "unconfirmed_email" + t.datetime "updated_at", precision: nil, null: false + t.boolean "visible", default: false, null: false t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end + create_table "versions", force: :cascade do |t| + t.datetime "created_at" + t.string "event", null: false + t.bigint "item_id", null: false + t.string "item_type", null: false + t.text "object" + t.string "whodunnit" + t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id" + end + + create_table "votes", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "score", null: false + t.datetime "updated_at", null: false + t.bigint "votable_id", null: false + t.string "votable_type", null: false + t.bigint "voter_id", null: false + t.index ["votable_type", "votable_id"], name: "index_votes_on_votable" + t.index ["voter_id", "votable_id"], name: "index_votes_on_voter_id_and_votable_id", unique: true + t.index ["voter_id"], name: "index_votes_on_voter_id" + end + add_foreign_key "access_requests", "users", column: "recorder_id" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" @@ -366,6 +410,9 @@ add_foreign_key "campaigns", "rsvp_events" add_foreign_key "emails", "users" add_foreign_key "memberships", "users" + add_foreign_key "nominations", "elections" + add_foreign_key "nominations", "users", column: "nominated_by_id" + add_foreign_key "nominations", "users", column: "nominee_id" add_foreign_key "rsvps", "memberships" add_foreign_key "rsvps", "rsvp_events" add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade @@ -374,4 +421,5 @@ add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "votes", "users", column: "voter_id" end diff --git a/spec/factories/elections.rb b/spec/factories/elections.rb new file mode 100644 index 00000000..66a3ae25 --- /dev/null +++ b/spec/factories/elections.rb @@ -0,0 +1,36 @@ +# == 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 +# +FactoryBot.define do + factory :election do + title { 'President' } + closed_at { nil } + opened_at { nil } + point_scale { 10 } + vacancies { 1 } + + trait :open do + opened_at { 1.week.ago } + closed_at { 1.week.from_now } + end + + trait :closed do + opened_at { 1.month.ago } + closed_at { 1.day.ago } + end + + trait :pending do + opened_at { 1.week.from_now } + end + end +end diff --git a/spec/factories/nominations.rb b/spec/factories/nominations.rb new file mode 100644 index 00000000..f1cd6bd8 --- /dev/null +++ b/spec/factories/nominations.rb @@ -0,0 +1,31 @@ +# == 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) +# +FactoryBot.define do + factory :nomination do + association :election, :open + association :nominee, factory: :user + association :nominated_by, factory: :user + end +end diff --git a/spec/factories/votes.rb b/spec/factories/votes.rb new file mode 100644 index 00000000..61cdb4c7 --- /dev/null +++ b/spec/factories/votes.rb @@ -0,0 +1,32 @@ +# == 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) +# +FactoryBot.define do + factory :vote do + score { 5 } + association :voter, factory: :user + + trait :for_nomination do + association :votable, factory: :nomination + end + end +end diff --git a/spec/helpers/elections_helper_spec.rb b/spec/helpers/elections_helper_spec.rb new file mode 100644 index 00000000..c50ba7e7 --- /dev/null +++ b/spec/helpers/elections_helper_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the ElectionsHelper. For example: +# +# describe ElectionsHelper do +# describe "string concat" do +# it "concats two strings with spaces" do +# expect(helper.concat_strings("this","that")).to eq("this that") +# end +# end +# end +RSpec.describe ElectionsHelper, type: :helper do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/election_spec.rb b/spec/models/election_spec.rb new file mode 100644 index 00000000..9156e684 --- /dev/null +++ b/spec/models/election_spec.rb @@ -0,0 +1,64 @@ +# == 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 +# +require 'rails_helper' + +RSpec.describe Election, type: :model do + describe '#elected_users' do + subject(:elected_users) { election.elected_users } + + context 'when there are sufficient vacancies for nominees' do + let(:election) { create(:election, :open, vacancies: 3) } + let(:first_nomination) { create(:nomination, election: election) } + let(:second_nomination) { create(:nomination, election: election) } + + it 'returns all the nominated users' do + expect(elected_users).to contain_exactly( + first_nomination.nominee, + second_nomination.nominee, + ) + end + end + + context 'when there are more nominees than vacancies' do + let(:election) { create(:election, :open, vacancies: 2, point_scale: 10) } + let(:nomination_amir) { create(:nomination, election: election) } + let(:nomination_billie) { create(:nomination, election: election) } + let(:nomination_carol) { create(:nomination, election: election) } + let(:nomination_denise) { create(:nomination, election: election) } + + before do + create(:vote, :for_nomination, votable: nomination_amir, score: 3) + create(:vote, :for_nomination, votable: nomination_amir, score: 1) + + create(:vote, :for_nomination, votable: nomination_billie, score: 2) + create(:vote, :for_nomination, votable: nomination_billie, score: 8) + create(:vote, :for_nomination, votable: nomination_billie, score: 2) + + create(:vote, :for_nomination, votable: nomination_carol, score: 4) + create(:vote, :for_nomination, votable: nomination_carol, score: 10) + + create(:vote, :for_nomination, votable: nomination_denise, score: 9) + create(:vote, :for_nomination, votable: nomination_denise, score: 1) + create(:vote, :for_nomination, votable: nomination_denise, score: 8) + end + + it 'returns the top scoring candidates required to fill the vacancies' do + expect(elected_users).to contain_exactly( + nomination_carol.nominee, + nomination_denise.nominee, + ) + end + end + end +end diff --git a/spec/models/nomination_spec.rb b/spec/models/nomination_spec.rb new file mode 100644 index 00000000..142cd5b7 --- /dev/null +++ b/spec/models/nomination_spec.rb @@ -0,0 +1,29 @@ +# == 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) +# +require 'rails_helper' + +RSpec.describe Nomination, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/vote_spec.rb b/spec/models/vote_spec.rb new file mode 100644 index 00000000..52e4bebd --- /dev/null +++ b/spec/models/vote_spec.rb @@ -0,0 +1,27 @@ +# == 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) +# +require 'rails_helper' + +RSpec.describe Vote, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/requests/elections_spec.rb b/spec/requests/elections_spec.rb new file mode 100644 index 00000000..b95938e0 --- /dev/null +++ b/spec/requests/elections_spec.rb @@ -0,0 +1,131 @@ +require 'rails_helper' + +# This spec was generated by rspec-rails when you ran the scaffold generator. +# It demonstrates how one might use RSpec to test the controller code that +# was generated by Rails when you ran the scaffold generator. +# +# It assumes that the implementation code is generated by the rails scaffold +# generator. If you are using any extension libraries to generate different +# controller code, this generated spec may or may not pass. +# +# It only uses APIs available in rails and/or rspec-rails. There are a number +# of tools you can use to make these specs even more expressive, but we're +# sticking to rails and rspec-rails APIs to keep things simple and stable. + +RSpec.describe "/elections", type: :request do + + # This should return the minimal set of attributes required to create a valid + # Election. As you add validations to Election, be sure to + # adjust the attributes here as well. + let(:valid_attributes) { + skip("Add a hash of attributes valid for your model") + } + + let(:invalid_attributes) { + skip("Add a hash of attributes invalid for your model") + } + + describe "GET /index" do + it "renders a successful response" do + Election.create! valid_attributes + get elections_url + expect(response).to be_successful + end + end + + describe "GET /show" do + it "renders a successful response" do + election = Election.create! valid_attributes + get election_url(election) + expect(response).to be_successful + end + end + + describe "GET /new" do + it "renders a successful response" do + get new_election_url + expect(response).to be_successful + end + end + + describe "GET /edit" do + it "renders a successful response" do + election = Election.create! valid_attributes + get edit_election_url(election) + expect(response).to be_successful + end + end + + describe "POST /create" do + context "with valid parameters" do + it "creates a new Election" do + expect { + post elections_url, params: { election: valid_attributes } + }.to change(Election, :count).by(1) + end + + it "redirects to the created election" do + post elections_url, params: { election: valid_attributes } + expect(response).to redirect_to(election_url(Election.last)) + end + end + + context "with invalid parameters" do + it "does not create a new Election" do + expect { + post elections_url, params: { election: invalid_attributes } + }.to change(Election, :count).by(0) + end + + it "renders a response with 422 status (i.e. to display the 'new' template)" do + post elections_url, params: { election: invalid_attributes } + expect(response).to have_http_status(:unprocessable_content) + end + end + end + + describe "PATCH /update" do + context "with valid parameters" do + let(:new_attributes) { + skip("Add a hash of attributes valid for your model") + } + + it "updates the requested election" do + election = Election.create! valid_attributes + patch election_url(election), params: { election: new_attributes } + election.reload + skip("Add assertions for updated state") + end + + it "redirects to the election" do + election = Election.create! valid_attributes + patch election_url(election), params: { election: new_attributes } + election.reload + expect(response).to redirect_to(election_url(election)) + end + end + + context "with invalid parameters" do + it "renders a response with 422 status (i.e. to display the 'edit' template)" do + election = Election.create! valid_attributes + patch election_url(election), params: { election: invalid_attributes } + expect(response).to have_http_status(:unprocessable_content) + end + end + end + + describe "DELETE /destroy" do + it "destroys the requested election" do + election = Election.create! valid_attributes + expect { + delete election_url(election) + }.to change(Election, :count).by(-1) + end + + it "redirects to the elections list" do + election = Election.create! valid_attributes + delete election_url(election) + expect(response).to redirect_to(elections_url) + end + end +end diff --git a/spec/routing/elections_routing_spec.rb b/spec/routing/elections_routing_spec.rb new file mode 100644 index 00000000..902eb4ca --- /dev/null +++ b/spec/routing/elections_routing_spec.rb @@ -0,0 +1,38 @@ +require "rails_helper" + +RSpec.describe ElectionsController, type: :routing do + describe "routing" do + it "routes to #index" do + expect(get: "/elections").to route_to("elections#index") + end + + it "routes to #new" do + expect(get: "/elections/new").to route_to("elections#new") + end + + it "routes to #show" do + expect(get: "/elections/1").to route_to("elections#show", id: "1") + end + + it "routes to #edit" do + expect(get: "/elections/1/edit").to route_to("elections#edit", id: "1") + end + + + it "routes to #create" do + expect(post: "/elections").to route_to("elections#create") + end + + it "routes to #update via PUT" do + expect(put: "/elections/1").to route_to("elections#update", id: "1") + end + + it "routes to #update via PATCH" do + expect(patch: "/elections/1").to route_to("elections#update", id: "1") + end + + it "routes to #destroy" do + expect(delete: "/elections/1").to route_to("elections#destroy", id: "1") + end + end +end diff --git a/spec/views/elections/edit.html.erb_spec.rb b/spec/views/elections/edit.html.erb_spec.rb new file mode 100644 index 00000000..5a09ce6f --- /dev/null +++ b/spec/views/elections/edit.html.erb_spec.rb @@ -0,0 +1,18 @@ +require 'rails_helper' + +RSpec.describe "elections/edit", type: :view do + let(:election) { + Election.create!() + } + + before(:each) do + assign(:election, election) + end + + it "renders the edit election form" do + render + + assert_select "form[action=?][method=?]", election_path(election), "post" do + end + end +end diff --git a/spec/views/elections/index.html.erb_spec.rb b/spec/views/elections/index.html.erb_spec.rb new file mode 100644 index 00000000..a95de164 --- /dev/null +++ b/spec/views/elections/index.html.erb_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +RSpec.describe "elections/index", type: :view do + before(:each) do + assign(:elections, [ + Election.create!(), + Election.create!() + ]) + end + + it "renders a list of elections" do + render + cell_selector = 'div>p' + end +end diff --git a/spec/views/elections/new.html.erb_spec.rb b/spec/views/elections/new.html.erb_spec.rb new file mode 100644 index 00000000..05294231 --- /dev/null +++ b/spec/views/elections/new.html.erb_spec.rb @@ -0,0 +1,14 @@ +require 'rails_helper' + +RSpec.describe "elections/new", type: :view do + before(:each) do + assign(:election, Election.new()) + end + + it "renders new election form" do + render + + assert_select "form[action=?][method=?]", elections_path, "post" do + end + end +end diff --git a/spec/views/elections/show.html.erb_spec.rb b/spec/views/elections/show.html.erb_spec.rb new file mode 100644 index 00000000..e3bfca73 --- /dev/null +++ b/spec/views/elections/show.html.erb_spec.rb @@ -0,0 +1,11 @@ +require 'rails_helper' + +RSpec.describe "elections/show", type: :view do + before(:each) do + assign(:election, Election.create!()) + end + + it "renders attributes in

" do + render + end +end