Repository navigation
Extract organiser grant/revoke diffing into WorkshopOrganiserAssigner service (#2941) #3049
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
ad9dd83
b2b9854
1c5e3c9
dd44316
abbbc73
295bb4c
5f70e85
6630557
427328e
0095afb
88045ee
adb986e
9996058
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| class WorkshopOrganiserAssigner | ||
| def self.validation_error(workshop, organiser_ids, default_to_chapter: false) | ||
| return if default_to_chapter && default_selection?(organiser_ids) | ||
| return 'organiser_ids is required' if organiser_ids.nil? | ||
| return 'organiser_ids must be an array of ids' unless organiser_ids.is_a?(Array) | ||
|
|
||
| validate_ids(workshop, organiser_ids.reject(&:blank?)) | ||
| end | ||
|
|
||
| def self.default_selection?(organiser_ids) | ||
| organiser_ids.nil? || organiser_ids == [] || (organiser_ids.is_a?(Array) && organiser_ids.all?(&:blank?)) | ||
| end | ||
|
|
||
| def self.validate_ids(workshop, ids) | ||
| return if ids.empty? | ||
| return 'Select valid chapter organisers.' unless valid_ids?(ids) | ||
| return 'Select organisers from this chapter.' unless chapter_organisers?(workshop, ids) | ||
|
|
||
| nil | ||
| end | ||
|
|
||
| def self.valid_ids?(ids) | ||
| ids.all? { |id| valid_id?(id) } | ||
| end | ||
|
|
||
| def self.chapter_organisers?(workshop, ids) | ||
| eligible_ids = workshop.chapter&.organisers&.where(id: ids.map(&:to_i))&.pluck(:id).to_a.map(&:to_s) | ||
| (ids.map(&:to_s).uniq - eligible_ids).empty? | ||
| end | ||
|
|
||
| private_class_method :validate_ids, :valid_ids?, :chapter_organisers? | ||
|
|
||
| def self.valid_id?(id) | ||
| (id.is_a?(String) || id.is_a?(Integer)) && id.to_s.match?(/\A[1-9]\d*\z/) | ||
| end | ||
|
|
||
| private_class_method :valid_id? | ||
|
|
||
| def initialize(workshop, organiser_ids, default_to_chapter: false) | ||
| @workshop = workshop | ||
| @organiser_ids = organiser_ids | ||
| @default_to_chapter = default_to_chapter | ||
| end | ||
|
|
||
| def call | ||
| error = self.class.validation_error(@workshop, @organiser_ids, default_to_chapter: @default_to_chapter) | ||
| raise ArgumentError, error if error | ||
|
|
||
| default_selection = @default_to_chapter && self.class.default_selection?(@organiser_ids) | ||
| Member.transaction { assign_roles(default_selection) } | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def assign_roles(default_selection) | ||
| desired_ids = organiser_ids_for(default_selection) | ||
| grant(resolve_members(desired_ids)) | ||
| revoke_other_organisers(desired_ids) unless default_selection | ||
| end | ||
|
|
||
| def organiser_ids_for(default_selection) | ||
| return @workshop.chapter.organisers.pluck(:id).map(&:to_s) if default_selection | ||
|
|
||
| @organiser_ids.reject(&:blank?).map(&:to_s) | ||
| end | ||
|
|
||
| def resolve_members(desired_ids) | ||
| members = Member.where(id: desired_ids.map(&:to_i)).index_by { |member| member.id.to_s } | ||
| missing_ids = desired_ids.uniq - members.keys | ||
| raise ArgumentError, "Unknown organiser IDs: #{missing_ids.join(', ')}" if missing_ids.any? | ||
|
|
||
| members.values | ||
| end | ||
|
|
||
| def grant(organisers) | ||
| organisers.each { |organiser| organiser.add_role(:organiser, @workshop) } | ||
| end | ||
|
|
||
| def revoke_other_organisers(desired_ids) | ||
| current_ids = @workshop.organisers.pluck(:id).map(&:to_s) | ||
| (current_ids - desired_ids).each { |id| Member.find(id).revoke(:organiser, @workshop) } | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -334,6 +334,167 @@ def create_workshop | |||||||
| end | ||||||||
| end | ||||||||
|
|
||||||||
| describe 'POST #create organiser assignment' do | ||||||||
| let(:organiser) { Fabricate(:member) } | ||||||||
|
|
||||||||
| it 'does not raise when organiser_ids are provided' do | ||||||||
| organiser.add_role(:organiser, workshop.chapter) | ||||||||
| expect do | ||||||||
| post :create, params: { | ||||||||
| workshop: { | ||||||||
| chapter_id: workshop.chapter.id, | ||||||||
| local_date: (Time.zone.now + 1.week).strftime('%d/%m/%Y'), | ||||||||
| local_time: '15:00', local_end_time: '17:00', | ||||||||
| virtual: '1', slack_channel: 'workshop-channel', | ||||||||
| slack_channel_link: 'https://codebar.slack.link/workshop-channel', | ||||||||
| coach_spaces: 5, student_spaces: 15, | ||||||||
| host: nil, organisers: [organiser.id.to_s] | ||||||||
| } | ||||||||
| } | ||||||||
| end.not_to raise_error | ||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
( The
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this should now be resolved
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I have updated the comment above into a suggested change: the added line asserts that the provided organiser id reaches the created workshop, so the spec fails if the params key is dropped and the chapter fallback takes over. Use the "Commit suggestion" button to apply it as-is, or reorder to reuse
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. thanks I've applied the suggestion. |
||||||||
| created = Workshop.unscoped.order(:id).last | ||||||||
| expect(created.organisers).to include(organiser) | ||||||||
| expect(created.chapter).to eq(workshop.chapter) | ||||||||
| end | ||||||||
|
|
||||||||
| it 'does not raise when organiser_ids are blank' do | ||||||||
| chapter_organiser = Fabricate(:member) | ||||||||
| chapter_organiser.add_role(:organiser, workshop.chapter) | ||||||||
|
|
||||||||
| expect do | ||||||||
| post :create, params: { | ||||||||
| workshop: { | ||||||||
| chapter_id: workshop.chapter.id, | ||||||||
| local_date: (Time.zone.now + 1.week).strftime('%d/%m/%Y'), | ||||||||
| local_time: '15:00', local_end_time: '17:00', | ||||||||
| virtual: '1', slack_channel: 'workshop-channel', | ||||||||
| slack_channel_link: 'https://codebar.slack.link/workshop-channel', | ||||||||
| coach_spaces: 5, student_spaces: 15, | ||||||||
| host: nil | ||||||||
| } | ||||||||
| } | ||||||||
| end.not_to raise_error | ||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This example only proves that nothing raised: a service call that granted nothing keeps it green, and Adding a chapter organiser before the request and asserting
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. resolved |
||||||||
| created = Workshop.unscoped.order(:id).last | ||||||||
| expect(created.reload.organisers).to include(chapter_organiser) | ||||||||
| end | ||||||||
|
|
||||||||
| it 'defaults to chapter organisers when the form sends only the blank sentinel' do | ||||||||
| chapter_organiser = Fabricate(:member) | ||||||||
| chapter_organiser.add_role(:organiser, workshop.chapter) | ||||||||
|
|
||||||||
| post :create, params: { | ||||||||
| workshop: { | ||||||||
| chapter_id: workshop.chapter.id, | ||||||||
| local_date: (Time.zone.now + 1.week).strftime('%d/%m/%Y'), | ||||||||
| local_time: '15:00', local_end_time: '17:00', | ||||||||
| virtual: '1', slack_channel: 'workshop-channel', | ||||||||
| slack_channel_link: 'https://codebar.slack.link/workshop-channel', | ||||||||
| coach_spaces: 5, student_spaces: 15, | ||||||||
| host: nil, organisers: [''] | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| created = Workshop.unscoped.order(:id).last | ||||||||
| expect(flash[:warning]).to be_nil | ||||||||
| expect(response).to redirect_to(admin_workshop_path(created)) | ||||||||
| expect(created.organisers).to include(chapter_organiser) | ||||||||
| end | ||||||||
|
|
||||||||
| it 'rejects a scalar organiser value before creating the workshop' do | ||||||||
| expect do | ||||||||
| post :create, params: { | ||||||||
| workshop: { | ||||||||
| chapter_id: workshop.chapter.id, | ||||||||
| local_date: (Time.zone.now + 1.week).strftime('%d/%m/%Y'), | ||||||||
| local_time: '15:00', local_end_time: '17:00', | ||||||||
| virtual: '1', slack_channel: 'workshop-channel', | ||||||||
| slack_channel_link: 'https://codebar.slack.link/workshop-channel', | ||||||||
| coach_spaces: 5, student_spaces: 15, | ||||||||
| host: nil, organisers: 'not-an-array' | ||||||||
| } | ||||||||
| } | ||||||||
| end.not_to change(Workshop, :count) | ||||||||
|
|
||||||||
| expect(response).to have_http_status(:unprocessable_content) | ||||||||
| expect(flash[:warning]).to include('organiser_ids must be an array of ids') | ||||||||
| end | ||||||||
| end | ||||||||
|
|
||||||||
| describe 'PATCH #update organiser revocation' do | ||||||||
| let(:existing_organiser) { Fabricate(:member) } | ||||||||
| let(:new_organiser) { Fabricate(:member) } | ||||||||
|
|
||||||||
| def update_workshop(organisers: :omitted, **attributes) | ||||||||
| submitted_organisers = organisers == :omitted ? {} : { organisers: } | ||||||||
| patch :update, params: { id: workshop.id, workshop: workshop_update_attributes.merge(attributes, submitted_organisers) } | ||||||||
| end | ||||||||
|
|
||||||||
| def workshop_update_attributes | ||||||||
| date = workshop.date_and_time | ||||||||
| { | ||||||||
| local_date: date.strftime('%d/%m/%Y'), | ||||||||
| local_time: date.strftime('%H:%M'), | ||||||||
| local_end_time: (date + 2.hours).strftime('%H:%M'), | ||||||||
| host: workshop.host&.id || '' | ||||||||
| } | ||||||||
| end | ||||||||
|
|
||||||||
| it 'revokes removed organisers after update' do | ||||||||
| existing_organiser.add_role(:organiser, workshop) | ||||||||
| new_organiser.add_role(:organiser, workshop.chapter) | ||||||||
| update_workshop(organisers: [new_organiser.id.to_s]) | ||||||||
| workshop.reload | ||||||||
| expect(workshop.organisers).to include(new_organiser) | ||||||||
| expect(workshop.organisers).not_to include(existing_organiser) | ||||||||
| end | ||||||||
|
|
||||||||
| it 'clears organisers when the form submits only its blank sentinel' do | ||||||||
| chapter_organiser = Fabricate(:member) | ||||||||
| chapter_organiser.add_role(:organiser, workshop.chapter) | ||||||||
| existing_organiser.add_role(:organiser, workshop) | ||||||||
|
|
||||||||
| update_workshop(organisers: ['']) | ||||||||
|
|
||||||||
| expect(response).to redirect_to(admin_workshop_path(workshop)) | ||||||||
| expect(workshop.reload.organisers).to be_empty | ||||||||
| expect(workshop.organisers).not_to include(chapter_organiser) | ||||||||
| end | ||||||||
|
|
||||||||
| it 'preserves organisers when the request omits the organisers parameter' do | ||||||||
| existing_organiser.add_role(:organiser, workshop) | ||||||||
|
|
||||||||
| update_workshop | ||||||||
|
|
||||||||
| expect(response).to redirect_to(admin_workshop_path(workshop)) | ||||||||
| expect(workshop.reload.organisers).to include(existing_organiser) | ||||||||
| end | ||||||||
|
|
||||||||
| it 'rejects a blank scalar organiser value before saving workshop changes' do | ||||||||
| existing_organiser.add_role(:organiser, workshop) | ||||||||
| update_workshop(organisers: '', description: 'Should not save') | ||||||||
|
|
||||||||
| expect(response).to have_http_status(:unprocessable_content) | ||||||||
| expect(workshop.reload.description).not_to eq('Should not save') | ||||||||
| expect(workshop.organisers).to include(existing_organiser) | ||||||||
| end | ||||||||
|
|
||||||||
| it 'rejects an unknown organiser ID before saving workshop changes' do | ||||||||
| update_workshop(organisers: ['999999999'], description: 'Should not save') | ||||||||
|
|
||||||||
| expect(response).to have_http_status(:unprocessable_content) | ||||||||
| expect(workshop.reload.description).not_to eq('Should not save') | ||||||||
| end | ||||||||
|
|
||||||||
| it 'rejects a non-chapter organiser before saving workshop changes' do | ||||||||
| outsider = Fabricate(:member) | ||||||||
| update_workshop(organisers: [outsider.id.to_s], description: 'Should not save') | ||||||||
|
|
||||||||
| expect(response).to have_http_status(:unprocessable_content) | ||||||||
| expect(workshop.reload.description).not_to eq('Should not save') | ||||||||
| expect(workshop.organisers).to be_empty | ||||||||
| end | ||||||||
| end | ||||||||
|
|
||||||||
| describe 'DELETE #destroy' do | ||||||||
| context 'when workshop invitations have been sent' do | ||||||||
| before do | ||||||||
|
|
@@ -432,6 +593,13 @@ def create_workshop | |||||||
| describe 'GET #edit' do | ||||||||
| render_views | ||||||||
|
|
||||||||
| it 'includes a blank organiser value so deselecting all submits an empty selection' do | ||||||||
| get :edit, params: { id: workshop.id } | ||||||||
|
|
||||||||
| html = Nokogiri::HTML(response.body) | ||||||||
| expect(html.css('input[type="hidden"][name="workshop[organisers][]"][value=""]')).to be_present | ||||||||
| end | ||||||||
|
|
||||||||
| it 'pre-fills main date and time values in ISO format' do | ||||||||
| get :edit, params: { id: workshop.id } | ||||||||
|
|
||||||||
|
|
||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Both call sites now delegate here, which leaves
grant_organiser_access(line 246) andrevoke_organiser_access(line 250) without callers. They also lack theto_snormalisation the service applies, so anyone who reaches for the leftover copy gets the grant-only path this extraction exists to remove.Can they go in this PR? Events and meetings keep their own copies; extracting those is a separate change.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Removed the unused workshop grant/revoke helpers. The event and meeting copies are unchanged, as requested.