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

feat: add escalation_email to course exam configurations #221

Merged
merged 1 commit into from
Dec 11, 2023

Conversation

MichaelRoytman
Copy link
Member

@MichaelRoytman MichaelRoytman commented Dec 5, 2023

JIRA: COSMO-39

Description: This commit adds an escalation email to course exam configurations. This value is used to specify who learners can contact in the event of issues with or questions about their exam attempts.

Author concerns: None.

Dependencies: None.

Installation instructions: None.

Testing instructions: None.

Merge checklist:

  • All reviewers approved
  • CI build is green
  • Changelog record added
  • Documentation updated (not only docstrings)
  • Commits are squashed

Post merge:

  • Delete working branch (if not needed anymore)

@MichaelRoytman MichaelRoytman force-pushed the michaelroytman/COSMO-39-escalalation-email branch from cfd7c4a to 1945397 Compare December 5, 2023 20:02
@MichaelRoytman MichaelRoytman changed the title [WIP] feat: add escalation_email to course exam configurations feat: add escalation_email to course exam configurations Dec 6, 2023
@MichaelRoytman MichaelRoytman force-pushed the michaelroytman/COSMO-39-escalalation-email branch 2 times, most recently from fe4505a to b7563f2 Compare December 6, 2023 17:37
"""
Serializer for the CourseExamConfiguration model
"""
provider = serializers.CharField(source='provider.name', allow_null=True)
Copy link
Member Author

Choose a reason for hiding this comment

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

I set the source so that you when you serialize a CourseExamConfiguration instance, it pulls the provider's name for the provider field and not the primary key. However, a side effect is that if you deserialize some data (i.e. in the PATCH handler) like the following,

{'provider': 'test', 'escalation_email': '[email protected]'}

you end up with this data.

{'provider': {'name': 'test'}, 'escalation_email': '[email protected]'}

I'm not really sure of a way around this, unless I write two separate serializers - one for GETs and one for PUTs/PATCHEs.

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't like it, but I have no better solution either. Maybe a quick comment on the class? I feel like this is going to bite someone later.

edx_exams/apps/api/v1/views.py Show resolved Hide resolved
return course_config.escalation_email


def create_or_update_course_exam_configuration(course_id, provider_name, escalation_email):
Copy link
Member Author

Choose a reason for hiding this comment

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

I added an API function so that callers don't have to have references to the CourseExamConfiguration model to call the class method.

@@ -246,46 +253,33 @@ class CourseExamConfigurationsView(ExamsAPIView):
def get(self, request, course_id):
"""
Get exam configuration for a course

TODO: This view should use a serializer to ensure the read/write bodies are the same
Copy link
Member Author

Choose a reason for hiding this comment

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

I've added a serializer, so I've removed this comment.

if value is not None:
try:
ProctoringProvider.objects.get(name=value)
# This exception is handled by the Django Rest Framework, so we don't want to use raise from and risk
Copy link
Member Author

Choose a reason for hiding this comment

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

Let me know if you disagree here. This is regarding disabling the Pylint rule below.

Copy link
Contributor

Choose a reason for hiding this comment

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

never ran into this rule. Solution seems fine. But, would raise ValidationError from None work?

migrations.AddField(
model_name='courseexamconfiguration',
name='escalation_email',
field=models.EmailField(default='', max_length=254),
Copy link
Member Author

Choose a reason for hiding this comment

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

I made the decision to set the default of '' only for existing instances in the database and not for all past and future instances. This is why default='' is not on the escalation_email model field.

exam.provider = new_provider
exam.save()

return len(exams)
Copy link
Member Author

Choose a reason for hiding this comment

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

The original function wasn't actually returning a length, so the count variable was None. I've fixed that here.

existing_config.save()

@classmethod
def _sync_exams_with_new_provider(cls, course_id, new_provider):
Copy link
Member Author

Choose a reason for hiding this comment

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

I pulled the exam sync behavior into its own utility function.

edx_exams/apps/core/test_utils/factories.py Show resolved Hide resolved
edx_exams/apps/core/tests/test_api.py Show resolved Hide resolved
@MichaelRoytman MichaelRoytman force-pushed the michaelroytman/COSMO-39-escalalation-email branch 2 times, most recently from 156eb50 to 3e7ea8b Compare December 6, 2023 17:50
Copy link
Contributor

@zacharis278 zacharis278 left a comment

Choose a reason for hiding this comment

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

still reviewing but a few comments to start

if value is not None:
try:
ProctoringProvider.objects.get(name=value)
# This exception is handled by the Django Rest Framework, so we don't want to use raise from and risk
Copy link
Contributor

Choose a reason for hiding this comment

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

never ran into this rule. Solution seems fine. But, would raise ValidationError from None work?

Comment on lines 13 to 20
migrations.AlterModelOptions(
name='historicalexam',
options={'get_latest_by': 'history_date', 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical exam'},
),
migrations.AlterModelOptions(
name='historicalexamattempt',
options={'get_latest_by': 'history_date', 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical exam attempt'},
),
Copy link
Contributor

Choose a reason for hiding this comment

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

huh something does seem off here. Also looking into what might cause this

edx_exams/apps/api/v1/views.py Show resolved Hide resolved
"""
Validate that escalalation_email is provided when the provider is provided.

NOTE: Currently, the LTI-based proctoring providers will require an escalation_email. Because the
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this just because of our existing data. If we split the PR to just add the migration for the model field first and than give existing configs a value in stage does that eliminate the need for the empty string default on the model?

Copy link
Member Author

Choose a reason for hiding this comment

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

Just to be sure we're on the same page, the empty string default is on the serializer. There is no default on the model, but blanks are allowed. Do you mean the default on the serializer? Or the blank on the model?

I changed the model field and forgot to update this comment, so that might be why I was talking about the model here.

If I don't define the escalation_email serializer field explicitly, the DRF-generated one will look like this.

CourseExamConfigurationSerializer():
    provider = CharField(allow_null=True, source='provider.name')
    escalation_email = EmailField(allow_blank=True, max_length=254, required=False)

If I remove the explicit escalation_email serializer field and remove this comment, does that work? I think a more accurate comment is, "Because an escalation email is only required when a proctoring provider is set, we have to enforce this requirement in the validate function."

Copy link
Contributor

@zacharis278 zacharis278 Dec 6, 2023

Choose a reason for hiding this comment

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

ah yeah I just got confused by this comment and thought you were referring to the model itself. I'm still fuzzy on what the explicit escalation_email serializer field is doing for us. The default looks fine to me but I might be missing some nuance here.

existing_config.provider = new_provider

if new_provider is None:
existing_config.escalation_email = ''
Copy link
Contributor

Choose a reason for hiding this comment

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

basically same as my earlier comment is this only because of our existing data?

Copy link
Member Author

@MichaelRoytman MichaelRoytman Dec 6, 2023

Choose a reason for hiding this comment

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

I wanted to avoid a situation where someone sends this data {'provider': None, 'escalation_email': '[email protected]'}. I believe we should always set the escalation_email to the empty string if there is no provider. Do you want to leave it up to the client to be sure to do this? Or we could raise a validation error, alternatively. That would be more explicit.

Copy link
Contributor

Choose a reason for hiding this comment

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

oh this make sense. I misread the condition as if escalation_email is None: thinking we were doing some Null -> '' conversion.

@MichaelRoytman MichaelRoytman force-pushed the michaelroytman/COSMO-39-escalalation-email branch 3 times, most recently from 85168c9 to 6624ee5 Compare December 7, 2023 15:48
Comment on lines 357 to 360
# If the provider is set to None, then we must clear the escalation_email,
# even if a non-empty/non-null value is provided.
if provider is None:
escalation_email = ''
Copy link
Member Author

Choose a reason for hiding this comment

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

I'm finding that I'm duplicating this validation in api.py and models.py. There’s only so much one can do to prevent someone from saving invalid data into the database if the invariant isn’t at the database level, but I think it's okay to have it on the model as well because I cannot guarantee the API will be used, and I don't want to save invalid data into the database.

Copy link
Contributor

Choose a reason for hiding this comment

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

Some learnings I've taken from this design are the negatives of rigidly following the pattern for view <-> api <-> model. This kind of duplication being one of them.

@MichaelRoytman MichaelRoytman force-pushed the michaelroytman/COSMO-39-escalalation-email branch from 6624ee5 to d6cd4ec Compare December 7, 2023 16:24
Comment on lines 357 to 360
# If the provider is set to None, then we must clear the escalation_email,
# even if a non-empty/non-null value is provided.
if provider is None:
escalation_email = ''
Copy link
Contributor

Choose a reason for hiding this comment

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

Some learnings I've taken from this design are the negatives of rigidly following the pattern for view <-> api <-> model. This kind of duplication being one of them.

"""
Serializer for the CourseExamConfiguration model
"""
provider = serializers.CharField(source='provider.name', allow_null=True)
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't like it, but I have no better solution either. Maybe a quick comment on the class? I feel like this is going to bite someone later.

edx_exams/apps/core/tests/test_api.py Show resolved Hide resolved
@MichaelRoytman MichaelRoytman force-pushed the michaelroytman/COSMO-39-escalalation-email branch from bec8966 to be2e50e Compare December 8, 2023 14:45
"""
Serializer for writing to the CourseExamConfiguration model.

We have separate read and write serializers because the read serializer uses the name field of the provider instance
Copy link
Member Author

Choose a reason for hiding this comment

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

Based on an earlier conversation with Zach, I decided it would be better to have two separate serializers than to have to worry about someone reading the data from the serializer improperly. This will be less error prone, and it's explicit that there are two different serializers - one for reading and one for writing. I can revert this to the old code (see my first commit), but I think this is an improvement.

"""
provider = serializers.CharField(allow_null=True)
# The escalation_email is a nullable field, but we require that clients send us an escalation_email.
escalation_email = serializers.EmailField(allow_null=True, allow_blank=False, required=True)
Copy link
Member Author

Choose a reason for hiding this comment

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

This is a change.

The original was escalation_email = EmailField(allow_blank=True, max_length=254, required=False), which is the default generated by DRF.

The new one is escalation_email = serializers.EmailField(allow_null=True, allow_blank=False, required=True).

The two differences are...

  1. We now allow nulls and forbid blanks. Although this is not Django/DRF best-practice for character based fields, this more closely matches both the existing API on this endpoint, because provider is a nullable character field, and the Studio proctoring settings API, which accepts a null for the escalation_email. I have set allow_blank=False so that we only have one representation of the empty value.
  2. This field is now required. I think it's a clearer API contract with the client for this value to always be required. Otherwise, the client is left wondering why a value is required sometimes and not other times. Sending escalation_email: null from the client is more explicit.

if provider_name and not escalation_email:
raise serializers.ValidationError('Escalation email is a required field when provider is provided.')
if provider_name is None and escalation_email is not None:
raise serializers.ValidationError('Escalation email must be None when provider is None.')
Copy link
Member Author

Choose a reason for hiding this comment

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

This is a new validation error. In the spirit of being more explicit, I've removed the behavior I had originally, which was to set the escalation_email to None when the provider is None, even when the escalation_email is not None (e.g. {'provider': None, 'escalation_email': '[email protected]'}). The client should supply valid data or receive a 400 error. I don't want to get in the habit of guessing what the client's intentions are when the escalation is not valid.

if provider_name is not None:
provider = ProctoringProvider.objects.get(name=provider_name)
else:
# If the provider is set to None, then we must clear the escalation_email,
Copy link
Member Author

Choose a reason for hiding this comment

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

Despite my comment in the serializers.py file, I think we still need to enforce this requirement in the Python API.

@MichaelRoytman MichaelRoytman force-pushed the michaelroytman/COSMO-39-escalalation-email branch 2 times, most recently from 381a070 to 5eb4b10 Compare December 8, 2023 15:08
Copy link
Contributor

@zacharis278 zacharis278 left a comment

Choose a reason for hiding this comment

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

nice improvements, this is a bit easier to understand now 👍

This commit adds an escalation email to course exam configurations. This value is used to specify who learners can contact in the event of issues with or questions about their exam attempts.
@MichaelRoytman MichaelRoytman force-pushed the michaelroytman/COSMO-39-escalalation-email branch from 5eb4b10 to 2bc4859 Compare December 11, 2023 14:56
@MichaelRoytman MichaelRoytman merged commit ceceedd into main Dec 11, 2023
8 checks passed
@MichaelRoytman MichaelRoytman deleted the michaelroytman/COSMO-39-escalalation-email branch December 11, 2023 15:13
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.

2 participants