From 2893a2bf471c7a1bd8ed13eb00d9c1a8a70b8481 Mon Sep 17 00:00:00 2001 From: Eric Schultz Date: Mon, 11 Jan 2021 16:34:48 -0600 Subject: [PATCH] Add ticket_level object events --- app/controllers/ticket_levels_controller.rb | 2 +- app/models/event.rb | 10 + app/models/event_discount.rb | 13 + app/models/nonprofit.rb | 7 + app/models/tag_master.rb | 5 +- app/models/ticket_level.rb | 80 ++++ .../Nonprofit/Event/EventDiscount.ts | 15 + .../Nonprofit/Event/TicketLevel.ts | 44 +++ .../Nonprofit/Event/index.ts | 14 + docs/event_definitions/common.ts | 13 + spec/controllers/ticket_levels_spec.rb | 44 +++ spec/lib/insert/insert_tickets_spec.rb | 3 +- spec/lib/query/query_ticket_levels_spec.rb | 4 +- spec/models/ticket_level_spec.rb | 348 ++++++++++++++++++ 14 files changed, 594 insertions(+), 8 deletions(-) create mode 100644 docs/event_definitions/Nonprofit/Event/EventDiscount.ts create mode 100644 docs/event_definitions/Nonprofit/Event/TicketLevel.ts create mode 100644 docs/event_definitions/Nonprofit/Event/index.ts create mode 100644 spec/models/ticket_level_spec.rb diff --git a/app/controllers/ticket_levels_controller.rb b/app/controllers/ticket_levels_controller.rb index 367e9195..89128bd0 100644 --- a/app/controllers/ticket_levels_controller.rb +++ b/app/controllers/ticket_levels_controller.rb @@ -46,6 +46,6 @@ class TicketLevelsController < ApplicationController end def ticket_level_params - params.require(:ticket_level).permit(:amount, :amount_dollars, :name, :description, :quantity, :deleted, :event_id, :admin_only, :limit, :order) + params.require(:ticket_level).permit(:amount, :amount_dollars, :name, :description, :quantity, :event_id, :admin_only, :limit, :order) end end diff --git a/app/models/event.rb b/app/models/event.rb index 38884d48..4c1d3ec1 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -101,6 +101,16 @@ class Event < ApplicationRecord self end + def to_builder(*expand) + Jbuilder.new do |json| + json.(self, :id, :name) + json.object "event" + json.nonprofit expand.include?(:nonprofit) && nonprofit ? + nonprofit.to_builder : + nonprofit && nonprofit.id + end + end + def url "#{nonprofit.url}/events/#{slug}" end diff --git a/app/models/event_discount.rb b/app/models/event_discount.rb index 897081c7..f611b13f 100644 --- a/app/models/event_discount.rb +++ b/app/models/event_discount.rb @@ -10,4 +10,17 @@ class EventDiscount < ApplicationRecord belongs_to :event has_many :tickets + + def to_builder(*expand) + Jbuilder.new do |json| + json.(self, :id, :name) + if event + if expand.include? :event + json.event event.to_builder + else + json.event event.id + end + end + end + end end diff --git a/app/models/nonprofit.rb b/app/models/nonprofit.rb index da6d80a5..c06a1c89 100755 --- a/app/models/nonprofit.rb +++ b/app/models/nonprofit.rb @@ -247,6 +247,13 @@ class Nonprofit < ApplicationRecord Houdini.intl.all_currencies[currency.downcase.to_sym][:symbol] end + def to_builder(*expand) + Jbuilder.new do |json| + json.(self, :id, :name) + json.object 'nonprofit' + end + end + private def build_admin_role role = user.roles.build(host: self, name: 'nonprofit_admin') diff --git a/app/models/tag_master.rb b/app/models/tag_master.rb index 219f74e1..c121a1a6 100644 --- a/app/models/tag_master.rb +++ b/app/models/tag_master.rb @@ -44,10 +44,7 @@ class TagMaster < ApplicationRecord tag.(self, :id, :name, :deleted) tag.object 'tag_master' if expand.include? :nonprofit && nonprofit - tag.nonprofit do - tag.id nonprofit.id - tag.name nonprofit.name - end + tag.nonprofit nonprofit.to_builder else tag.nonprofit nonprofit && nonprofit.id end diff --git a/app/models/ticket_level.rb b/app/models/ticket_level.rb index acdd4d67..a39bfdd8 100644 --- a/app/models/ticket_level.rb +++ b/app/models/ticket_level.rb @@ -13,7 +13,15 @@ class TicketLevel < ApplicationRecord # :admin_only, #bool, only admins can create tickets for this level # :limit, #int: for limiting the number of tickets to be sold # :order #int: order in which to be displayed + + # TODO replace with Discard gem + define_model_callbacks :discard + after_discard :publish_delete + + after_create :publish_create + after_update :publish_updated + attr_accessor :amount_dollars has_many :tickets @@ -28,4 +36,76 @@ class TicketLevel < ApplicationRecord self.amount = Format::Currency.dollars_to_cents(amount_dollars) if amount_dollars.present? self.amount = 0 if amount.nil? end + + # TODO replace with discard gem + def discard! + run_callbacks(:discard) do + self.deleted = true + save! + end + end + + def to_builder(*expand) + Jbuilder.new do |json| + json.(self, :id, :name, :deleted, :order, :limit, :description) + json.object 'ticket_level' + json.amount do + json.value_in_cents amount || 0 + json.currency event.nonprofit.currency + end + json.available_to admin_only ? 'admins' : 'everyone' + if event + if event.nonprofit + if expand.include? :nonprofit + json.nonprofit event.nonprofit.to_builder + else + json.nonprofit event.nonprofit.id + end + else + json.nonprofit nil + end + + if expand.include? :event + json.event event.to_builder + else + json.event event.id + end + + if expand.include? :event_discounts + json.event_discounts event.event_discounts do |disc| + disc.to_builder + end + else + json.event_discounts event.event_discounts.pluck(:id) + end + end + end + end + + private + def publish_create + Houdini.event_publisher.announce(:ticket_level_created, to_event('ticket_level.created', :event, :nonprofit).attributes!) + end + + def publish_updated + # we don't run update when we've really just discarded + unless deleted + Houdini.event_publisher.announce(:ticket_level_updated, to_event('ticket_level.updated', :event, :nonprofit).attributes!) + end + end + + def publish_delete + Houdini.event_publisher.announce(:ticket_level_deleted, to_event('ticket_level.deleted', :event, :nonprofit).attributes!) + end + + def to_event(event_type, *expand) + Jbuilder.new do |event| + event.id SecureRandom.uuid + event.object 'object_event' + event.type event_type + event.data do + event.object to_builder(*expand) + end + end + end end diff --git a/docs/event_definitions/Nonprofit/Event/EventDiscount.ts b/docs/event_definitions/Nonprofit/Event/EventDiscount.ts new file mode 100644 index 00000000..cf542f27 --- /dev/null +++ b/docs/event_definitions/Nonprofit/Event/EventDiscount.ts @@ -0,0 +1,15 @@ +// License: LGPL-3.0-or-later +import type { IdType, HoudiniObject } from '../../common'; +import type Nonprofit from '..'; +import type Event from '.'; +import type { TicketLevel } from './TicketLevel'; + +/** + * Describes an EventDiscount (shell) + */ +export interface EventDiscount extends HoudiniObject { + event: IdType | Event; + nonprofit: IdType | Nonprofit; + object: "event_discount"; + ticket_levels: IdType[] | TicketLevel[]; +} \ No newline at end of file diff --git a/docs/event_definitions/Nonprofit/Event/TicketLevel.ts b/docs/event_definitions/Nonprofit/Event/TicketLevel.ts new file mode 100644 index 00000000..6c457c86 --- /dev/null +++ b/docs/event_definitions/Nonprofit/Event/TicketLevel.ts @@ -0,0 +1,44 @@ +// License: LGPL-3.0-or-later +import { IdType, HoudiniObject, HoudiniEvent, Amount } from '../../common'; +import Nonprofit from '..'; +import Event from './'; +import { EventDiscount } from './EventDiscount'; + +/** + * Describes a single ticket level for an event. Each Ticket is associated with a TicketLevel + */ +export interface TicketLevel extends HoudiniObject { + /** The cost of one ticket of the given amount */ + amount: Amount; + /** + * Who can see and/or buy the ticket. 'everyone' is every visitor, 'admins' means event + * and nonprofit admins only + */ + available_to: 'everyone' | 'admins'; + deleted: boolean; + description: string; + event: IdType | Event; + /** + * at some time, event discounts will be associated with a given ticket level. + * For now, this returns all of the discounts for the event though. + */ + event_discounts: IdType[] | EventDiscount[]; + /** + * the max number of tickets to be sold on this TicketLevel, null means unlimited. + * If you edit this and decide to lower the limit below the number of tickets currently sold, + * we don't remove the tickets already there. + */ + limit?: number; + /** + * Nice readable name fo the ticket level + */ + name: string; + nonprofit: IdType | Nonprofit; + object: "ticket_level"; + /** order to be displayed */ + order: number; +} + +export type TicketLevelCreated = HoudiniEvent<'ticket_level.created', TicketLevel>; +export type TicketLevelUpdated = HoudiniEvent<'ticket_level.updated', TicketLevel>; +export type TicketLevelDeleted = HoudiniEvent<'ticket_level.deleted', TicketLevel>; \ No newline at end of file diff --git a/docs/event_definitions/Nonprofit/Event/index.ts b/docs/event_definitions/Nonprofit/Event/index.ts new file mode 100644 index 00000000..eab4ef4a --- /dev/null +++ b/docs/event_definitions/Nonprofit/Event/index.ts @@ -0,0 +1,14 @@ +// License: LGPL-3.0-or-later +import { IdType, HoudiniObject } from '../../common'; +import Nonprofit from '../'; + +export default interface Event extends HoudiniObject { + end_date: Date; + name: string; + nonprofit: IdType | Nonprofit; + object: "event"; + start_date: Date; +} + +export * from './TicketLevel'; +export * from './EventDiscount'; \ No newline at end of file diff --git a/docs/event_definitions/common.ts b/docs/event_definitions/common.ts index c908789a..ed3ba2a7 100644 --- a/docs/event_definitions/common.ts +++ b/docs/event_definitions/common.ts @@ -6,6 +6,19 @@ */ export type IdType = number; +/** + * Describes a monetary value in the minimum unit for this current. Corresponds to Money class in + * Ruby and Typescript + */ +export type Amount = { currency: string, value_in_cents: string }; + +/** + * A more flexible version of Amount. In cases where we can assume what the currency is, + * we don't actually require you to provide it. Probably will be used most by APIs + */ +export type FlexibleAmount = Amount | string | number; + + /** * Every object controlled by the Houdini event publisher must meet this standard interface * and will inherit from it. diff --git a/spec/controllers/ticket_levels_spec.rb b/spec/controllers/ticket_levels_spec.rb index bd3e7000..3e2b8762 100644 --- a/spec/controllers/ticket_levels_spec.rb +++ b/spec/controllers/ticket_levels_spec.rb @@ -33,4 +33,48 @@ describe TicketLevelsController, type: :controller do end end end + + describe 'verify deleted doesnt get passed through on update' do + include_context :shared_user_context + include_context :shared_donation_charge_context + let(:ticket_level_name) {"TICKET LEVEL"} + let(:order) { 3} + let(:free_amount) { 0} + let(:non_free_amount) {7500} + let(:ticket_limit) {4} + let(:description) {"Description"} + let(:ticket_level_2) { + event.ticket_levels.create( + name: ticket_level_name, + limit: nil, + admin_only: false, + order: order, + amount: non_free_amount, + description: description + ) + } + it 'updates safely' do + input = { + nonprofit_id: nonprofit.id, + event_id: event.id, + id: ticket_level_2.id, + ticket_level: { + name: ticket_level_name, + limit: nil, + admin_only: false, + order: order, + amount: 0, + description: description, + deleted: true + } + } + sign_in user_as_np_admin + put :update, params: input, xhr: true + expect(response).to have_http_status :ok + + ticket_level_2.reload + expect(ticket_level_2.deleted).to eq false + expect(ticket_level_2.amount).to eq 0 + end + end end diff --git a/spec/lib/insert/insert_tickets_spec.rb b/spec/lib/insert/insert_tickets_spec.rb index 56aa503a..196ce35d 100644 --- a/spec/lib/insert/insert_tickets_spec.rb +++ b/spec/lib/insert/insert_tickets_spec.rb @@ -286,7 +286,8 @@ describe InsertTickets do success_expectations expect(QueryRoles).to receive(:is_authorized_for_nonprofit?).with(user.id, nonprofit.id).and_return true result = nil - expect(Houdini.event_publisher).to receive(:announce).with(:ticket_create, any_args) + expect(Houdini.event_publisher).to receive(:announce).with(:ticket_level_created, any_args).ordered + expect(Houdini.event_publisher).to receive(:announce).with(:ticket_create, any_args).ordered result = InsertTickets.create(tickets: [{ quantity: 1, ticket_level_id: ticket_level.id }], nonprofit_id: nonprofit.id, supporter_id: supporter.id, token: source_token.token, event_id: event.id, kind: 'offsite', offsite_payment: { kind: 'check', check_number: 'fake_checknumber' }, current_user: user) expected = generate_expected_tickets(payment_id: result['payment'].id, diff --git a/spec/lib/query/query_ticket_levels_spec.rb b/spec/lib/query/query_ticket_levels_spec.rb index 9033dfb7..f184492c 100644 --- a/spec/lib/query/query_ticket_levels_spec.rb +++ b/spec/lib/query/query_ticket_levels_spec.rb @@ -37,8 +37,8 @@ describe QueryTicketLevels do end describe '.verify_tickets_available' do - let(:ticket_level_1) { force_create(:ticket_level, limit: 3) } - let(:ticket_level_2) { force_create(:ticket_level, limit: 2) } + let(:ticket_level_1) { force_create(:ticket_level, limit: 3, event: event) } + let(:ticket_level_2) { force_create(:ticket_level, limit: 2, event: event) } let(:tickets) do [ force_create(:ticket, ticket_level: ticket_level_1, quantity: 1), diff --git a/spec/models/ticket_level_spec.rb b/spec/models/ticket_level_spec.rb new file mode 100644 index 00000000..c59d8272 --- /dev/null +++ b/spec/models/ticket_level_spec.rb @@ -0,0 +1,348 @@ +# frozen_string_literal: true + +# License: AGPL-3.0-or-later WITH WTO-AP-3.0-or-later +# Full license explanation at https://github.com/houdiniproject/houdini/blob/master/LICENSE +require 'rails_helper' + +RSpec.describe TicketLevel, type: :model do + include_context :shared_donation_charge_context + let(:ticket_level_name) {"TICKET LEVEL"} + let(:order) { 3} + let(:free_amount) { 0} + let(:non_free_amount) {7500} + let(:ticket_limit) {4} + let(:description) {"Description"} + + + let(:ticket_level_1) { event.ticket_levels.create( + name: ticket_level_name, + limit: ticket_limit, + admin_only: true, + order: order, + amount: free_amount, + description: description + + ) } + + let(:ticket_level_2) { + event.ticket_levels.create( + name: ticket_level_name, + limit: nil, + admin_only: false, + order: order, + amount: non_free_amount, + description: description + + ) + } + + describe 'create' do + describe 'ticket_level_1' do + it 'is without error' do + expect(ticket_level_1.errors).to be_empty + end + + it 'announces create' do + expect(Houdini.event_publisher).to receive(:announce).with(:ticket_level_created, { + 'id' => kind_of(String), + 'object' => 'object_event', + 'type' => 'ticket_level.created', + 'data' => { + 'object' => { + 'amount' => {'value_in_cents' => 0, 'currency' => 'usd'}, + 'available_to' => 'admins', + 'deleted' => false, + 'description' => description, + 'event' => { + 'id' => event.id, + 'name' => event.name, + 'object' => 'event', + 'nonprofit' => nonprofit.id + }, + 'id'=> kind_of(Numeric), + 'limit' => 4, + 'name' => ticket_level_name, + 'nonprofit'=> { + 'id' => nonprofit.id, + 'name' => nonprofit.name, + 'object' => 'nonprofit' + }, + 'event_discounts' => [], + 'object' => 'ticket_level', + 'order' => order + } + } + }) + + ticket_level_1 + end + + end + describe 'ticket_level_2' do + it 'is without error' do + expect(ticket_level_2.errors).to be_empty + end + + it 'announces create' do + expect(Houdini.event_publisher).to receive(:announce).with(:ticket_level_created, { + 'id' => kind_of(String), + 'object' => 'object_event', + 'type' => 'ticket_level.created', + 'data' => { + 'object' => { + 'amount' => {'value_in_cents' => non_free_amount, 'currency' => 'usd'}, + 'available_to' => 'everyone', + 'deleted' => false, + 'description' => description, + 'event' => { + 'id' => event.id, + 'name' => event.name, + 'object' => 'event', + 'nonprofit' => nonprofit.id + }, + 'id'=> kind_of(Numeric), + 'limit' => nil, + 'name' => ticket_level_name, + 'nonprofit'=> { + 'id' => nonprofit.id, + 'name' => nonprofit.name, + 'object' => 'nonprofit' + }, + 'event_discounts' => [], + 'object' => 'ticket_level', + 'order' => order + } + } + }) + + ticket_level_2 + end + end + end + + describe 'update' do + describe 'ticket_level_1' do + it 'is without error' do + ticket_level_1.amount = 5000 + ticket_level_1.save + expect(ticket_level_1.errors).to be_empty + end + + it 'announces updated' do + expect(Houdini.event_publisher).to receive(:announce).with(:ticket_level_created, anything).ordered + expect(Houdini.event_publisher).to receive(:announce).with(:ticket_level_updated, { + 'id' => kind_of(String), + 'object' => 'object_event', + 'type' => 'ticket_level.updated', + 'data' => { + 'object' => { + 'amount' => {'value_in_cents' => 5000, 'currency' => 'usd'}, + 'available_to' => 'admins', + 'deleted' => false, + 'description' => description, + 'event' => { + 'id' => event.id, + 'name' => event.name, + 'object' => 'event', + 'nonprofit' => nonprofit.id + }, + 'id'=> kind_of(Numeric), + 'limit' => 4, + 'name' => ticket_level_name, + 'nonprofit'=> { + 'id' => nonprofit.id, + 'name' => nonprofit.name, + 'object' => 'nonprofit' + }, + 'event_discounts' => [], + 'object' => 'ticket_level', + 'order' => order + } + } + }) + + ticket_level_1 + ticket_level_1.amount = 5000 + ticket_level_1.save + end + + end + describe 'ticket_level_2' do + it 'is without error' do + ticket_level_2.amount = 0 + ticket_level_2.save + expect(ticket_level_2.errors).to be_empty + end + + it 'announces updated' do + expect(Houdini.event_publisher).to receive(:announce).with(:ticket_level_created, anything).ordered + expect(Houdini.event_publisher).to receive(:announce).with(:ticket_level_updated, { + 'id' => kind_of(String), + 'object' => 'object_event', + 'type' => 'ticket_level.updated', + 'data' => { + 'object' => { + 'amount' => {'value_in_cents' => 0, 'currency' => 'usd'}, + 'available_to' => 'everyone', + 'deleted' => false, + 'description' => description, + 'event' => { + 'id' => event.id, + 'name' => event.name, + 'object' => 'event', + 'nonprofit' => nonprofit.id + }, + 'id'=> kind_of(Numeric), + 'limit' => nil, + 'name' => ticket_level_name, + 'nonprofit'=> { + 'id' => nonprofit.id, + 'name' => nonprofit.name, + 'object' => 'nonprofit' + }, + 'event_discounts' => [], + 'object' => 'ticket_level', + 'order' => order + } + } + }) + + ticket_level_2 + ticket_level_2.amount = 0 + ticket_level_2.save + end + end + end + + + describe 'deleted' do + describe 'ticket_level_1' do + it 'is without error' do + + ticket_level_1.discard! + expect(ticket_level_1.deleted).to eq true + end + + it 'announces deleted' do + expect(Houdini.event_publisher).to receive(:announce).with(:ticket_level_created, anything).ordered + expect(Houdini.event_publisher).to receive(:announce).with(:ticket_level_deleted, { + 'id' => kind_of(String), + 'object' => 'object_event', + 'type' => 'ticket_level.deleted', + 'data' => { + 'object' => { + 'amount' => {'value_in_cents' => 0, 'currency' => 'usd'}, + 'available_to' => 'admins', + 'deleted' => true, + 'description' => description, + 'event' => { + 'id' => event.id, + 'name' => event.name, + 'object' => 'event', + 'nonprofit' => nonprofit.id + }, + 'id'=> kind_of(Numeric), + 'limit' => 4, + 'name' => ticket_level_name, + 'nonprofit'=> { + 'id' => nonprofit.id, + 'name' => nonprofit.name, + 'object' => 'nonprofit' + }, + 'event_discounts' => [], + 'object' => 'ticket_level', + 'order' => order + } + } + }).ordered + + ticket_level_1.discard! + end + + end + describe 'ticket_level_2' do + it 'is without error' do + expect(ticket_level_2.errors).to be_empty + end + + it 'announces deleted' do + expect(Houdini.event_publisher).to receive(:announce).with(:ticket_level_created, anything).ordered + expect(Houdini.event_publisher).to receive(:announce).with(:ticket_level_deleted, { + 'id' => kind_of(String), + 'object' => 'object_event', + 'type' => 'ticket_level.deleted', + 'data' => { + 'object' => { + 'amount' => {'value_in_cents' => non_free_amount, 'currency' => 'usd'}, + 'available_to' => 'everyone', + 'deleted' => true, + 'description' => description, + 'event' => { + 'id' => event.id, + 'name' => event.name, + 'object' => 'event', + 'nonprofit' => nonprofit.id + }, + 'id'=> kind_of(Numeric), + 'limit' => nil, + 'name' => ticket_level_name, + 'nonprofit'=> { + 'id' => nonprofit.id, + 'name' => nonprofit.name, + 'object' => 'nonprofit' + }, + 'event_discounts' => [], + 'object' => 'ticket_level', + 'order' => order + } + } + }) + + ticket_level_2.discard! + end + end + end + # it 'creates' do + # expect(tag_master.errors).to be_empty + # end + + # it 'announces create' do + # expect(Houdini.event_publisher).to receive(:announce).with(:tag_master_created, { + # 'id' => kind_of(String), + # 'object' => 'event', + # 'type' => 'tag_master.created', + # 'data' => { + # 'object' => { + # 'id'=> kind_of(Numeric), + # 'deleted' => false, + # 'name' => name, + # 'nonprofit'=> nonprofit.id, + # 'object' => 'tag_master' + # } + # } + # }) + + # tag_master + # end + + # it 'announces deleted' do + # expect(Houdini.event_publisher).to receive(:announce).with(:tag_master_created, anything).ordered + # expect(Houdini.event_publisher).to receive(:announce).with(:tag_master_deleted, { + # 'id' => kind_of(String), + # 'object' => 'event', + # 'type' => 'tag_master.deleted', + # 'data' => { + # 'object' => { + # 'id'=> kind_of(Numeric), + # 'deleted' => true, + # 'name' => name, + # 'nonprofit'=> nonprofit.id, + # 'object' => 'tag_master' + # } + # } + # }).ordered + + # tag_master.discard! + + # end +end