Skip to content
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

Only allow teachers who attend workshops to print workshop certificates #32678

Merged
merged 5 commits into from Jan 16, 2020

Conversation

bencodeorg
Copy link
Contributor

@bencodeorg bencodeorg commented Jan 15, 2020

PLC-687: Prevents teachers who did not attend workshops from printing certificates of completion.

Testing story

Added a test for the new behavior. I was trying to test by actually generating a click event, but I ran into this bug (I think). In order to get feedback I added a less great test, but happy to adjust if there are ideas about how to work around this issue.

Also added/modified testing related to new server side logic to prevent rendering workshop certificates for teaches who haven't attended a workshop.

Reviewer Checklist:

  • Tests provide adequate coverage
  • Code is well-commented
  • New features are translatable or updates will not break translations
  • Relevant documentation has been added or updated
  • User impact is well-understood and desirable
  • Pull Request is labeled appropriately
  • Follow-up work items (including potential tech debt) are tracked and linked

const enrolledWorkshopsTable = shallow(
<EnrolledWorkshopsTable workshops={workshops} />
);

// Click the "Print Certificate" button
enrolledWorkshopsTable
.find('tr')
.last()
.find('tbody tr')
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed this find call because there is a header row that had me banging my head against the wall for a bit.

@islemaster
Copy link
Contributor

Going to get to a review of what you've got here, but first: What you've got here is a good client-side improvement for the user experience when viewing a workshop you didn't attend, but it doesn't actually prevent someone from going to studio.code.org/pd/generate_workshop_certificate/<code> and printing their unearned certificate. For that, we should make a server-side change: Let's update WorkshopCertificateController to check for attendance before generating the certificate at all.

@@ -78,6 +78,7 @@ class EnrolledWorkshopsTable extends React.Component {
<Button
onClick={() => this.openCertificate(workshop)}
style={styles.button}
disabled={!workshop.attended}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 The way you've serialized out the relevant data makes this line, where we eventually use it to make a decision, so nice. Good choice.

state: 'Not Started'
state: 'Not Started',
user_id: 123,
attended: false
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not required right now, but seeing this large fixture again, and how many places you had to edit it, reminded me that we have a better way to generate test data now: Rosie.js, the JavaScript equivalent to FactoryBot. Here's an example of cleaning up a large test fixture with Rosie.

.find('Button')
.first();

expect(printCertificateButton.prop('disabled')).to.be.true;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fact that disabled wasn't respected on this simulated button click may be by design - in particular, we're shallow-rendering here, and using a bootstrap Button component, so in theory we're not even rendering the DOM element that would normally enforce disabled as a behavior.

I think your approach here, checking the prop itself, is fine. Just make sure to update the comment on line 146 since we're not actually clicking the button anymore.

Copy link
Contributor

@islemaster islemaster left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great first pass! I have a few comments to address before we move forward here.

@bencodeorg
Copy link
Contributor Author

Going to get to a review of what you've got here, but first: What you've got here is a good client-side improvement for the user experience when viewing a workshop you didn't attend, but it doesn't actually prevent someone from going to studio.code.org/pd/generate_workshop_certificate/<code> and printing their unearned certificate. For that, we should make a server-side change: Let's update WorkshopCertificateController to check for attendance before generating the certificate at all.

@islemaster Can you think of an example you could share of how we gracefully handle this in other places?

@islemaster
Copy link
Contributor

islemaster commented Jan 15, 2020

One good example of this pattern of catching business logic edge cases in the controller is the route for cancelling a workshop enrollment. It takes an enrollment code as part of the URL.

GET /pd/workshop_enrollment/<code>/cancel

This route handles two special edge cases. One, perhaps we can't find an enrollment for the code you provided at all. In that case, we render this view saying the registration was not found.

    @enrollment = Pd::Enrollment.find_by_code params[:code]
    if @enrollment.nil?
      render :not_found

Two, maybe we find the enrollment, but you already attended the workshop, so it's too late to cancel! In that case, we render a view that says "You have already attended this workshop."

    elsif @enrollment.attendances.any?
      render :attended

Finally, if neither of the above happens, we fall through and render the cancellation form.

I think you could take a similar approach, or since we wouldn't expect someone to open this URL unless they were actually trying to bypass our UI, we could simply return a 404 if they didn't attend the workshop. After all, they didn't earn a certificate yet, so not found seems like a reasonable response.

return head :not_found unless enrollment.attendances.any?

@bencodeorg
Copy link
Contributor Author

Hold on review, need to fix some tests.


get :generate_certificate, params: {enrollment_code: @enrollment.code}
assert_response :missing
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉 Awesome.

@@ -6,6 +6,8 @@ class Pd::WorkshopCertificateController < ApplicationController
find_by: :code, id_param: :enrollment_code

def generate_certificate
return render_404 unless @enrollment&.attendances&.any?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the first safe-navigation operator isn't needed here because the load_resource line above that populates @enrollment should cause a 404 if it's not found before we reach this code; and the second safe-navigation operator shouldn't be necessary because if you have an Enrollment with no attendances, its attendances association will give an empty array, not nil. But use tests to check these cases before taking my word for it!

Suggested change
return render_404 unless @enrollment&.attendances&.any?
return render_404 unless @enrollment.attendances.any?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense -- we do have a test that an error is raised if a nonsense enrollment code is provided, so I think your first assumption is sound. I tested manually in console that an enrollment with no attendances returns false from enrollment.attendances.any?, not an error. Also tested the generate_workshop_certificate page with this code change and an enrollment with no attendance got the 404 I'd expect.

@@ -63,6 +63,10 @@ def enrollment_code
@scope.try(:[], :enrollment_code)
end

def attended
object.enrollments.find_by(code: @scope.try(:[], :enrollment_code))&.attendances&.any?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whereas here we need the two safe-navigation operators because find_by might have returned nil.

Copy link
Contributor

@islemaster islemaster left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants