diff --git a/app/abilities/ability.rb b/app/abilities/ability.rb index 313e604e064..4f0baf90e4a 100644 --- a/app/abilities/ability.rb +++ b/app/abilities/ability.rb @@ -72,6 +72,8 @@ def initialize(user) can [:destroy, :edit, :update], CommunityMember, :community => user_is_community_organizer can [:destroy], CommunityMember, :user_id => user.id can [:create, :edit, :new, :update], Event, :community => user_is_community_organizer + can [:create], EventAttendance + can [:update], EventAttendance, :user_id => user.id if user.moderator? can [:hide, :hidecomment], DiaryEntry diff --git a/app/controllers/event_attendances_controller.rb b/app/controllers/event_attendances_controller.rb new file mode 100644 index 00000000000..ed02e0382aa --- /dev/null +++ b/app/controllers/event_attendances_controller.rb @@ -0,0 +1,40 @@ +class EventAttendancesController < ApplicationController + layout "site" + before_action :authorize_web + before_action :set_event_attendance, :only => [:update] + + authorize_resource + + def create + attendance = EventAttendance.new(event_attendance_params) + if attendance.save + redirect_to event_path(attendance.event), :notice => t(".success") + else + redirect_to event_path(attendance.event), :alert => t(".failure") + end + end + + def update + respond_to do |format| + if @event_attendance.update(update_params) + format.html { redirect_to @event_attendance.event, :notice => t(".success") } + else + format.html { redirect_to :edit, :alert => t(".failure") } + end + end + end + + private + + def set_event_attendance + @event_attendance = EventAttendance.find(params[:id]) + end + + def event_attendance_params + params.require(:event_attendance).permit(:event_id, :user_id, :intention) + end + + def update_params + params.require(:event_attendance).permit(:intention) + end +end diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index c100c092f22..17971e97140 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -27,6 +27,13 @@ def index # GET /events/1.json def show @community = Community.friendly.find(params[:community_id]) if params[:community_id] + @my_attendance = EventAttendance.find_or_initialize_by(:event_id => @event.id, :user_id => current_user&.id) + @yes_check = @my_attendance.intention == EventAttendance::Intentions::YES ? "✓" : "" + @no_check = @my_attendance.intention == EventAttendance::Intentions::NO ? "✓" : "" + @maybe_check = @my_attendance.intention == EventAttendance::Intentions::MAYBE ? "✓" : "" + @yes_disabled = @my_attendance.intention == EventAttendance::Intentions::YES + @no_disabled = @my_attendance.intention == EventAttendance::Intentions::NO + @maybe_disabled = @my_attendance.intention == EventAttendance::Intentions::MAYBE rescue ActiveRecord::RecordNotFound @not_found_community = params[:community_id] render :template => "communities/no_such_community", :status => :not_found diff --git a/app/models/event.rb b/app/models/event.rb index 72ed286967e..68a367bac44 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -18,6 +18,7 @@ class Event < ApplicationRecord belongs_to :community has_many :event_organizers + has_many :event_attendances scope :future, -> { where("moment >= ?", Time.now.utc) } scope :past, -> { where("moment < ?", Time.now.utc) } @@ -55,4 +56,20 @@ def organizers def past? moment < Time.now.utc end + + def attendees(intention) + EventAttendance.where(:event_id => id, :intention => intention) + end + + def yes_attendees + attendees(EventAttendance::Intentions::YES) + end + + def no_attendees + attendees(EventAttendance::Intentions::NO) + end + + def maybe_attendees + attendees(EventAttendance::Intentions::MAYBE) + end end diff --git a/app/models/event_attendance.rb b/app/models/event_attendance.rb new file mode 100644 index 00000000000..aa98e1f8d02 --- /dev/null +++ b/app/models/event_attendance.rb @@ -0,0 +1,29 @@ +# == Schema Information +# +# Table name: event_attendances +# +# id :bigint(8) not null, primary key +# user_id :integer not null +# event_id :integer not null +# intention :enum not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_event_attendances_on_event_id (event_id) +# index_event_attendances_on_user_id (user_id) +# + +class EventAttendance < ApplicationRecord + module Intentions + YES = "Yes".freeze + NO = "No".freeze + MAYBE = "Maybe".freeze + ALL_INTENTIONS = [YES, NO, MAYBE].freeze + end + validates :intention, :inclusion => { :in => Intentions::ALL_INTENTIONS } + + belongs_to :event + belongs_to :user +end diff --git a/app/views/events/show.html.erb b/app/views/events/show.html.erb index 2d89e2c312e..f0bf0dd5663 100644 --- a/app/views/events/show.html.erb +++ b/app/views/events/show.html.erb @@ -23,7 +23,22 @@

+

<%= t(".people_are_going", :count => @event.yes_attendees.size) %>

<%= t(".are_you_going") %>

+ <% if current_user %> + <%= form_with :model => @my_attendance do |form| %> + <%= form.hidden_field(:event_id, :value => @event.id) %> + <%= form.hidden_field(:user_id, :value => current_user&.id) %> + <%= form.submit :name => "event_attendance[intention]", :value => @yes_check + t(".going_yes"), :disabled => @yes_disabled %> + <%= form.submit :name => "event_attendance[intention]", :value => @no_check + t(".going_no"), :disabled => @no_disabled %> + <%= form.submit :name => "event_attendance[intention]", :value => @maybe_check + t(".going_maybe"), :disabled => @maybe_disabled %> + <% end %> + <% else %> +

+ <%# TODO: Use login_path %> + <%= t(".login_to_rsvp") %> +

+ <% end %>

<%= @event.title %> @@ -56,6 +71,24 @@ <%= t(".description") %>: <%= @event.description %>

+ <%= t(".who_yes") %> +
+ <% @event.yes_attendees.each do |attendance| %> + <%= render :partial => "users/user_card", :locals => { :user => attendance.user } %> + <% end %> +
+ <%= t(".who_maybe") %> +
+ <% @event.maybe_attendees.each do |attendance| %> + <%= render :partial => "users/user_card", :locals => { :user => attendance.user } %> + <% end %> +
+ <%= t(".who_no") %> +
+ <% @event.no_attendees.each do |attendance| %> + <%= render :partial => "users/user_card", :locals => { :user => attendance.user } %> + <% end %> +

diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb index 8b741b28c1d..75999b1696b 100644 --- a/app/views/layouts/_header.html.erb +++ b/app/views/layouts/_header.html.erb @@ -47,6 +47,9 @@

+ @@ -77,6 +80,7 @@ <% end %>
  • <%= link_to t("layouts.communities"), communities_path, :class => "dropdown-item" %>
  • +
  • <%= link_to t("layouts.events"), events_path, :class => "dropdown-item" %>
  • <%= link_to t("layouts.gps_traces"), traces_path, :class => "dropdown-item" %>
  • <%= link_to t("layouts.user_diaries"), diary_entries_path, :class => "dropdown-item" %>
  • <%= link_to t("layouts.communities"), community_index_path, :class => "dropdown-item" %>
  • diff --git a/config/locales/en.yml b/config/locales/en.yml index 5a3a7ea5fca..b41d2379d5c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -699,6 +699,13 @@ en: not_found: title: File not found description: Couldn't find a file/directory/API operation by that name on the OpenStreetMap server (HTTP 404) + event_attendances: + create: + success: Attendance was successfully saved. + failure: Attendance could not be saved. + update: + success: Attendance was successfully updated. + failure: Attendance could not be updated. events: create: success: Event was created successfully. @@ -724,11 +731,22 @@ en: description: "Description" directions_to: "Directions to this location." edit: "Edit" + going_maybe: "Maybe" + going_no: "No" + going_yes: "Yes" hosted_by: "Hosted by" location: "Location" + login_to_rsvp: "Login to RSVP." organized_by: "Organized by" past: "Event is in the past." + people_are_going: + zero: "" + one: "One person is going." + other: "%{count} people are going" when: "When" + who_yes: "Going" + who_no: "Not Going" + who_maybe: "Might Go" update: success: "The event was successfully updated." failure: "The event was not updated." @@ -1644,6 +1662,9 @@ en: home: Go to Home Location logout: Log Out log_in: Log In + log_in_tooltip: Log in with an existing account + communities: Communities + events: Events sign_up: Sign Up start_mapping: Start Mapping edit: Edit diff --git a/config/routes.rb b/config/routes.rb index 053009ba6aa..992feec49d9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -339,6 +339,7 @@ resources :community_members, :only => [:create, :destroy, :edit, :new, :update] get "/community_members" => "community_members#create", :as => "login_to_join" resources :events + resources :event_attendances # errors match "/403", :to => "errors#forbidden", :via => :all diff --git a/db/migrate/20221010234421_create_event_attendances.rb b/db/migrate/20221010234421_create_event_attendances.rb new file mode 100644 index 00000000000..213199034bf --- /dev/null +++ b/db/migrate/20221010234421_create_event_attendances.rb @@ -0,0 +1,18 @@ +class CreateEventAttendances < ActiveRecord::Migration[7.0] + def up + create_enumeration :event_attendances_intention_enum, %w[Maybe No Yes] + create_table :event_attendances do |t| + t.references :user, :foreign_key => true, :null => false, :index => true + t.references :event, :foreign_key => true, :null => false, :index => true + t.column :intention, :event_attendances_intention_enum, :null => false + + t.timestamps + end + add_index :event_attendances, [:user_id, :event_id], :unique => true + end + + def down + drop_table :event_attendances + drop_enumeration :event_attendances_intention_enum + end +end diff --git a/db/structure.sql b/db/structure.sql index c8d207ccd1f..adb0cbfe2f9 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -16,6 +16,17 @@ SET row_security = off; CREATE EXTENSION IF NOT EXISTS btree_gist WITH SCHEMA public; +-- +-- Name: event_attendances_intention_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.event_attendances_intention_enum AS ENUM ( + 'Maybe', + 'No', + 'Yes' + ); + + -- -- Name: format_enum; Type: TYPE; Schema: public; Owner: - -- @@ -110,8 +121,6 @@ CREATE TYPE public.user_status_enum AS ENUM ( SET default_tablespace = ''; -SET default_table_access_method = heap; - -- -- Name: acls; Type: TABLE; Schema: public; Owner: - -- @@ -777,6 +786,39 @@ CREATE TABLE public.diary_entry_subscriptions ( ); +-- +-- Name: event_attendances; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.event_attendances ( + id bigint NOT NULL, + user_id bigint NOT NULL, + event_id bigint NOT NULL, + intention public.event_attendances_intention_enum NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: event_attendances_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.event_attendances_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: event_attendances_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.event_attendances_id_seq OWNED BY public.event_attendances.id; + + -- -- Name: event_organizers; Type: TABLE; Schema: public; Owner: - -- @@ -1852,6 +1894,13 @@ ALTER TABLE ONLY public.diary_comments ALTER COLUMN id SET DEFAULT nextval('publ ALTER TABLE ONLY public.diary_entries ALTER COLUMN id SET DEFAULT nextval('public.diary_entries_id_seq'::regclass); +-- +-- Name: event_attendances id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.event_attendances ALTER COLUMN id SET DEFAULT nextval('public.event_attendances_id_seq'::regclass); + + -- -- Name: event_organizers id; Type: DEFAULT; Schema: public; Owner: - -- @@ -2190,6 +2239,14 @@ ALTER TABLE ONLY public.diary_entry_subscriptions ADD CONSTRAINT diary_entry_subscriptions_pkey PRIMARY KEY (user_id, diary_entry_id); +-- +-- Name: event_attendances event_attendances_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.event_attendances + ADD CONSTRAINT event_attendances_pkey PRIMARY KEY (id); + + -- -- Name: event_organizers event_organizers_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -2762,6 +2819,27 @@ CREATE INDEX index_community_members_on_user_id ON public.community_members USIN CREATE INDEX index_diary_entry_subscriptions_on_diary_entry_id ON public.diary_entry_subscriptions USING btree (diary_entry_id); +-- +-- Name: index_event_attendances_on_event_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_event_attendances_on_event_id ON public.event_attendances USING btree (event_id); + + +-- +-- Name: index_event_attendances_on_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_event_attendances_on_user_id ON public.event_attendances USING btree (user_id); + + +-- +-- Name: index_event_attendances_on_user_id_and_event_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_event_attendances_on_user_id_and_event_id ON public.event_attendances USING btree (user_id, event_id); + + -- -- Name: index_event_organizers_on_event_id; Type: INDEX; Schema: public; Owner: - -- @@ -3383,6 +3461,14 @@ ALTER TABLE ONLY public.events ADD CONSTRAINT fk_rails_3451eeb877 FOREIGN KEY (community_id) REFERENCES public.communities(id); +-- +-- Name: event_attendances fk_rails_64ad6920ae; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.event_attendances + ADD CONSTRAINT fk_rails_64ad6920ae FOREIGN KEY (user_id) REFERENCES public.users(id); + + -- -- Name: oauth_access_tokens fk_rails_732cb83ab7; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -3447,6 +3533,14 @@ ALTER TABLE ONLY public.oauth_applications ADD CONSTRAINT fk_rails_cc886e315a FOREIGN KEY (owner_id) REFERENCES public.users(id) NOT VALID; +-- +-- Name: event_attendances fk_rails_d082d0d206; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.event_attendances + ADD CONSTRAINT fk_rails_d082d0d206 FOREIGN KEY (event_id) REFERENCES public.events(id); + + -- -- Name: oauth_access_tokens fk_rails_ee63f25419; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -3842,6 +3936,7 @@ INSERT INTO "schema_migrations" (version) VALUES ('20220925043305'), ('20221008144036'), ('20221008224134'), +('20221010234421'), ('21'), ('22'), ('23'), diff --git a/test/controllers/event_attendances_controller_test.rb b/test/controllers/event_attendances_controller_test.rb new file mode 100644 index 00000000000..13956517616 --- /dev/null +++ b/test/controllers/event_attendances_controller_test.rb @@ -0,0 +1,113 @@ +require "test_helper" + +class EventAttendancesControllerTest < ActionDispatch::IntegrationTest + def test_routes + assert_routing( + { :path => "/event_attendances/1", :method => :put }, + { :controller => "event_attendances", :action => "update", :id => "1" } + ) + assert_routing( + { :path => "/event_attendances", :method => :post }, + { :controller => "event_attendances", :action => "create" } + ) + end + + def test_create_when_save_works + # arrange + cm = create(:community_member) + ev = create(:event, :community => cm.community) + ea_orig = build(:event_attendance, :user => cm.user, :event => ev) + form = ea_orig.attributes.except("id", "created_at", "updated_at") + session_for(cm.user) + + # act + assert_difference "EventAttendance.count", 1 do + post event_attendances_url, :params => { :event_attendance => form.as_json }, :xhr => true + end + + # assert + assert_redirected_to event_path(ev) + ea_new_id = EventAttendance.maximum(:id) + assert_equal I18n.t("event_attendances.create.success"), flash[:notice] + ea_new = EventAttendance.find(ea_new_id) + # Assign the new id to the original object, so we can do an equality test easily. + ea_orig.id = ea_new.id + assert_equal(ea_orig, ea_new) + end + + def test_update_as_wrong_user + # arrange + cm = create(:community_member) + ev = create(:event, :community => cm.community) + ea1 = create(:event_attendance, :user => cm.user, :event => ev) # original object + u2 = create(:user) + ea2 = build(:event_attendance, :user => u2, :event => ev) # new data + form = ea2.attributes.except("id", "created_at", "updated_at") + session_for(u2) + + # act + # Update ea1 with the values from ea2. + put event_attendance_url(ea1), :params => { :event_attendance => form }, :xhr => true + + # assert + follow_redirect! + assert_response :forbidden + end + + def test_create_when_save_fails + # arrange + cm = create(:community_member, :organizer) + session_for(cm.user) + + e = create(:event, :community => cm.community) + ea = build(:event_attendance, :event => e, :user => cm.user, :intention => "Invalid") + form = ea.attributes.except("id", "created_at", "updated_at") + + # act and assert + assert_no_difference "EventAttendance.count", 0 do + post event_attendances_path, :params => { :event_attendance => form } + end + end + + def test_update_success + # arrange + cm = create(:community_member) + ev = create(:event, :community => cm.community) + ea1 = create(:event_attendance, :user => cm.user, :event => ev, :intention => "Yes") # original object + ea2 = build(:event_attendance, :user => cm.user, :event => ev, :intention => "No") # new data + form = ea2.attributes.except("id", "created_at", "updated_at") + session_for(cm.user) + + # act + # Update m1 with the values from m2. + put event_attendance_url(ea1), :params => { :event_attendance => form.as_json }, :xhr => true + + # assert + assert_redirected_to event_path(ev) + assert_equal I18n.t("event_attendances.update.success"), flash[:notice] + ea1.reload + # Assign the id of object 1 to object 2, so we can do an equality test easily. + ea2.id = ea1.id + assert_equal(ea2, ea1) + end + + def test_update_when_save_fails + # arrange + cm = create(:community_member) + ev = create(:event, :community => cm.community) + session_for(cm.user) + + ea = create(:event_attendance, :user => cm.user, :event => ev) # original object + form = ea.attributes.except("id", "created_at", "updated_at") + form["intention"] = "Invalid" # Force "save" to fail. + + # act + assert_difference "EventAttendance.count", 0 do + put event_attendance_url(ea), :params => { :event_attendance => form.as_json }, :xhr => true + end + + # assert + follow_redirect! + assert_equal I18n.t("event_attendances.update.failure"), flash[:alert] + end +end diff --git a/test/factories/event_attendances.rb b/test/factories/event_attendances.rb new file mode 100644 index 00000000000..89c1336c911 --- /dev/null +++ b/test/factories/event_attendances.rb @@ -0,0 +1,15 @@ +FactoryBot.define do + factory :event_attendance do + user + event + intention { "Maybe" } + + trait :no do + intention { "No" } + end + + trait :yes do + intention { "Yes" } + end + end +end diff --git a/test/models/event_attendance_test.rb b/test/models/event_attendance_test.rb new file mode 100644 index 00000000000..dd8c492497c --- /dev/null +++ b/test/models/event_attendance_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class EventAttendanceTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/event_test.rb b/test/models/event_test.rb index 537ec6dc1fd..bb63a9ae68a 100644 --- a/test/models/event_test.rb +++ b/test/models/event_test.rb @@ -33,4 +33,60 @@ def test_validations validate({ :longitude => -180 }, true) validate({ :longitude => -180.00001 }, false) end + + def test_attendees + # Zero + event = create(:event) + assert_equal(0, event.maybe_attendees.length) + assert_equal(0, event.no_attendees.length) + assert_equal(0, event.yes_attendees.length) + + # 1 Maybe + ea = create(:event_attendance) + assert_equal(1, ea.event.maybe_attendees.length) + assert_equal(0, ea.event.no_attendees.length) + assert_equal(0, ea.event.yes_attendees.length) + assert_equal("Maybe", ea.event.event_attendances[0].intention) + assert_equal(ea.user.display_name, ea.event.event_attendances[0].user.display_name) + + # 1 No + ea = create(:event_attendance, :no) + assert_equal(0, ea.event.maybe_attendees.length) + assert_equal(1, ea.event.no_attendees.length) + assert_equal(0, ea.event.yes_attendees.length) + assert_equal("No", ea.event.event_attendances[0].intention) + assert_equal(ea.user.display_name, ea.event.event_attendances[0].user.display_name) + + # 1 Yes + ea = create(:event_attendance, :yes) + assert_equal(0, ea.event.maybe_attendees.length) + assert_equal(0, ea.event.no_attendees.length) + assert_equal(1, ea.event.yes_attendees.length) + assert_equal("Yes", ea.event.event_attendances[0].intention) + assert_equal(ea.user.display_name, ea.event.event_attendances[0].user.display_name) + + # 2 Yes + event = create(:event) + u1 = create(:user) + ea1 = EventAttendance.new(:event => event, :user => u1, :intention => EventAttendance::Intentions::YES) + ea1.save + u2 = create(:user) + ea2 = EventAttendance.new(:event => event, :user => u2, :intention => EventAttendance::Intentions::YES) + ea2.save + assert_equal(2, event.yes_attendees.length) + assert_equal(u1.display_name, event.yes_attendees[0].user.display_name) + assert_equal(u2.display_name, event.yes_attendees[1].user.display_name) + + # 1 Yes and 1 No + event = create(:event) + u1 = create(:user) + ea1 = EventAttendance.new(:event => event, :user => u1, :intention => EventAttendance::Intentions::NO) + ea1.save + u2 = create(:user) + ea2 = EventAttendance.new(:event => event, :user => u2, :intention => EventAttendance::Intentions::YES) + ea2.save + assert_equal(1, event.yes_attendees.length) + assert_equal(1, event.no_attendees.length) + assert_equal(u2.display_name, event.yes_attendees[0].user.display_name) + end end