diff --git a/app/abilities/ability.rb b/app/abilities/ability.rb index 6ef72ea3c0c..805b68f8e8c 100644 --- a/app/abilities/ability.rb +++ b/app/abilities/ability.rb @@ -63,6 +63,8 @@ def initialize(user) can [:create, :destroy], CommunityMember, :user_id => user.id can [:destroy, :edit, :update], CommunityMember, :community => user_is_community_organizer can [:create, :edit, :new, :update], Event, :community => user_is_community_organizer + can [:create], EventAttendance + can [:update], EventAttendance, :user_id => user.id can [:close, :reopen], Note can [:show, :edit, :update], :preference can [:edit, :update], :profile 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 3e78cd1eb46..4a7e9704cc2 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -26,6 +26,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) } @@ -63,4 +64,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..030c8868714 --- /dev/null +++ b/app/models/event_attendance.rb @@ -0,0 +1,35 @@ +# == Schema Information +# +# Table name: event_attendances +# +# id :bigint(8) not null, primary key +# user_id :bigint(8) not null +# event_id :bigint(8) 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) +# index_event_attendances_on_user_id_and_event_id (user_id,event_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (event_id => events.id) +# fk_rails_... (user_id => users.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 e94febe980a..199c456c38a 100644 --- a/app/views/events/show.html.erb +++ b/app/views/events/show.html.erb @@ -27,6 +27,24 @@ <%= render :partial => "events/event_property", :locals => { :name => t(".description"), :value => @event.description } %> +
+

<%= 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 %> +
<%= tag.div "", @@ -43,3 +61,27 @@
+
+
+
+ <%= 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 1abcb8e329e..dfa51829a41 100644 --- a/app/views/layouts/_header.html.erb +++ b/app/views/layouts/_header.html.erb @@ -72,7 +72,7 @@ <% end %>
  • <%= 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"), communities_path, :class => "dropdown-item" %>
  • +
  • <%= link_to t("layouts.communities"), communities_index_path, :class => "dropdown-item" %>
  • <%= link_to t("layouts.copyright"), copyright_path, :class => "dropdown-item" %>
  • <%= link_to t("layouts.help"), help_path, :class => "dropdown-item" %>
  • <%= link_to t("layouts.about"), about_path, :class => "dropdown-item" %>
  • diff --git a/config/locales/en.yml b/config/locales/en.yml index d40d4cb1114..445e9a91ec7 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -774,6 +774,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. @@ -795,14 +802,27 @@ en: new: new: "New Event" show: + are_you_going: "Are you going?" 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: + 0: "" # TODO: Not working, but can't use "zero" due to lint. + 1: "1 person is going." # TODO: Not working. + one: "1 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." @@ -1717,6 +1737,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 1e19b1517c3..acfe0e2983d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -357,6 +357,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 "/400", :to => "errors#bad_request", :via => :all diff --git a/db/migrate/20240730234421_create_event_attendances.rb b/db/migrate/20240730234421_create_event_attendances.rb new file mode 100644 index 00000000000..c21a584ea76 --- /dev/null +++ b/db/migrate/20240730234421_create_event_attendances.rb @@ -0,0 +1,18 @@ +class CreateEventAttendances < ActiveRecord::Migration[7.0] + def up + create_enum :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_enum :event_attendances_intention_enum + end +end diff --git a/db/structure.sql b/db/structure.sql index b782d3a81f0..6393e58c321 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -33,6 +33,17 @@ CREATE TYPE public.community_member_role_enum AS ENUM ( ); +-- +-- 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: - -- @@ -912,6 +923,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: - -- @@ -2017,6 +2061,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: - -- @@ -2370,6 +2421,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: - -- @@ -2957,6 +3016,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: - -- @@ -3593,6 +3673,14 @@ ALTER TABLE ONLY public.user_mutes ADD CONSTRAINT fk_rails_591dad3359 FOREIGN KEY (owner_id) REFERENCES public.users(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: - -- @@ -3665,6 +3753,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: user_mutes fk_rails_e9dd4fb6c3; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -4028,6 +4124,7 @@ INSERT INTO "schema_migrations" (version) VALUES ('23'), ('22'), ('21'), +('20240730234421'), ('20240728224134'), ('20240728144036'), ('20240618193051'), diff --git a/test/controllers/event_attendances_controller_test.rb b/test/controllers/event_attendances_controller_test.rb new file mode 100644 index 00000000000..e9f6c8f222d --- /dev/null +++ b/test/controllers/event_attendances_controller_test.rb @@ -0,0 +1,98 @@ +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 + 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) + + assert_difference "EventAttendance.count", 1 do + post event_attendances_url, :params => { :event_attendance => form.as_json }, :xhr => true + end + + 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 + 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) + + # Update ea1 with the values from ea2. + put event_attendance_url(ea1), :params => { :event_attendance => form }, :xhr => true + + assert_redirected_to :controller => :errors, :action => :forbidden + end + + def test_create_when_save_fails + 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") + + assert_no_difference "EventAttendance.count", 0 do + post event_attendances_path, :params => { :event_attendance => form } + end + end + + def test_update_success + 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) + + # Update m1 with the values from m2. + put event_attendance_url(ea1), :params => { :event_attendance => form.as_json }, :xhr => true + + 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 + 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. + + assert_difference "EventAttendance.count", 0 do + put event_attendance_url(ea), :params => { :event_attendance => form.as_json }, :xhr => true + end + + 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