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

Create Invitation API [Validation] #35448

Merged
merged 45 commits into from
Dec 6, 2024
Merged

Conversation

Jtang-1
Copy link
Contributor

@Jtang-1 Jtang-1 commented Nov 26, 2024

Product Description

Technical Summary

USH-4757

The main change in this PR is the addition of an API validation file. This will support the upcoming API for creating Invitations and updating Web Users. The remaining changes involve refactoring to consolidate validations used when bulk uploading Web Users or creating Invitations via the AdminInvitesUserForm.

I reused relevant validations already defined for bulk user import (in user_importer.validation) since the same checks are needed. Some validations previously implemented via the clean method in Django forms were consolidated, while built-in form validations were left as-is.

The key distinction between Invitation creation via the API and via bulk user import is how existing Web Users are handled. Bulk user import checks if the Web User exists and updates it if so. In contrast, the API will have a dedicated POST endpoint for creation. This endpoint will require validating whether the Invitation or Web User already exists and returning an error if it does, mirroring the behavior of the AdminInvitesUserForm.

Feature Flag

No feature flag

Safety Assurance

Safety story

The main changes (addition of validation for Web User creation API) will not be used yet. The changes to the existing validation code are solely refactors and I tested locally that those areas still work as expected.

Automated test coverage

There exists extensive testing on bulk user imports and surrounding validation. test_importer checks that web user and mobile workers can be successfully uploaded/updated. test_validators test that Location, Profile, Custom Data, etc.. validation catches invalid or unauthorized changes. I will add missing Tableau Group and Role validation.

QA Plan

no QA.

Rollback instructions

  • This PR can be reverted after deploy with no further considerations

Labels & Review

  • Risk label is set correctly
  • The set of people pinged as reviewers is appropriate for the level of risk of the change

@dimagimon dimagimon added Risk: High Change affects files that have been flagged as high risk. Risk: Medium Change affects files that have been flagged as medium risk. labels Nov 26, 2024
@Jtang-1 Jtang-1 force-pushed the jt/create-user-api-validation branch 4 times, most recently from 6568200 to 1a81553 Compare November 28, 2024 05:02
…to make it easier to tell what values are needed
…assigned locations or that primary location is defined if assigned locations is defined
… of the spec format in preparation for reuse
…for web user API. However, there wasn't as much

overlap with validation used for AdminInvitesUserForm. So move validation for Web User Resource its own file and
create a class to organize the validations relevant to AdminInvitesUserForm
@Jtang-1 Jtang-1 marked this pull request as ready for review November 30, 2024 05:15
Copy link
Contributor

@AddisonDunn AddisonDunn left a comment

Choose a reason for hiding this comment

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

Love what you're doing here with the refactoring. Was only able to do a partial review so far.

corehq/apps/user_importer/importer.py Show resolved Hide resolved
corehq/apps/api/tests/test_validation.py Show resolved Hide resolved
corehq/apps/api/tests/test_validation.py Show resolved Hide resolved
corehq/apps/registration/forms.py Show resolved Hide resolved
raise forms.ValidationError(_("A user with this email address is deactivated. "))

from corehq.apps.registration.validation import AdminInvitesUserFormValidator
error = AdminInvitesUserFormValidator.validate_email(self.domain, email, self.request.method == 'POST')
Copy link
Contributor

Choose a reason for hiding this comment

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

If an email is being cleaned, isn't this always a POST? Or at least an edit method where you'd want to do this sort of email validation?

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, I see there is no original call to django.core.validators.validate_email here? Agree that that sort of validation should be done here, aghast that it isn't already.

Copy link
Contributor

Choose a reason for hiding this comment

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

Lastly sidenote, not asking for this here and wasn't part of this PR obviously but WebUserInvitationForm needs a better name cause this Form architecture is confusing everytime I look at it. Maybe WebUserInvitationValidationForm.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If an email is being cleaned, isn't this always a POST?

you're right, it's always a POST. Removed the is_post parameter here. Also, I realized django.core.validators.validate_email is already done as part of EmailField definition. So I also removed that from AdminInvitesUserFormValidator.validate_email

WebUserInvitationForm needs a better name cause this Form architecture is confusing everytime I look at it. Maybe WebUserInvitationValidationForm.

I'm not sure what you mean by this? I don't think it makes sense to rename WebUserInvitationForm to WebUserInvitationValidationForm

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure what you mean by this? I don't think it makes sense to rename WebUserInvitationForm to WebUserInvitationValidationForm

The name makes me think it is the form for inviting a web user. But that's AdminInvitesUserForm, this form is for just validating an accepted invitation from what I can tell. Maybe AcceptedWebUserInvitationForm.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

oh i see, I agree that's confusing 06dd234

Copy link
Contributor

Choose a reason for hiding this comment

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

Nice

corehq/apps/user_importer/importer.py Show resolved Hide resolved
with patch('corehq.apps.users.views.mobile.users.BaseUploadUser.upload_users'):
response = self._make_post_request(file)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {'success': 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 am seeing some of the code for uploading via the API now... with the validation in this PR it seems like we're pretty close to having a complete solution. The spec from July obviously doesn't account for a web user API that uses bulk upload.. which sounds perfectly valid to me. I assume you are planning for us to use that same bulk upload/excel sheet functionality? I think that's probably best but gotta say from the D&A side it will be a bit of a curveball to upload using a CSV.

Copy link
Contributor

Choose a reason for hiding this comment

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

Well I see you wrote all that code for WebUserResource. I would hate to suggest not using it. It would be a bit easier on the D&A side to use normal pagination/user. Perhaps we could continue this conversation in Jira or slack.

Copy link
Contributor Author

@Jtang-1 Jtang-1 Dec 4, 2024

Choose a reason for hiding this comment

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

Just copying over what we talked about on slack - I don't plan to use the bulk upload functionality. I plan to implement it as a normal API to be consistent with how it's done for mobile workers

@@ -150,12 +151,16 @@ class TableauRoleValidator(ImportValidator):

def __init__(self, domain):
super().__init__(domain)
self.valid_role_options = [e.value for e in TableauUser.Roles]
Copy link
Contributor

Choose a reason for hiding this comment

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

Do you need to remove this from __init__ to get the classmethod to work? I think having this line here saves on memory for the import.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good point, that's preferable to keep it in __init__ 11edd76

corehq/apps/user_importer/validation.py Show resolved Hide resolved
@@ -513,30 +547,18 @@ def _validate_uploading_user_access(self, spec):

# 2. Ensure the user is only adding the user to/removing from *new locations* that they have permission
# to access.
if 'location_code' in spec:
locs_being_assigned = self._get_locs_being_assigned(spec)
if locs_ids_being_assigned:
Copy link
Contributor

@AddisonDunn AddisonDunn Dec 3, 2024

Choose a reason for hiding this comment

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

With this change the check that a user has access to the locations being removed will be skipped if the user tries to remove all of them. (blocking if 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 would reccomend a second variable:

user_is_editing_locations = 'location_code' in spec

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thank you for catching that bug, fixed it a5ee207

I think this is equivalent - I do the location_code' in spec within validate_spec and only do the validations if that's true. For the snippet referenced here, I removed the conditional check so that validation will always run.

Copy link
Contributor

Choose a reason for hiding this comment

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

So the LocationValidator doesn't just validate that the uploading user has ability to change the locations for another user, IIRC it needs to validate that the uploading user has access to the other user at all. I.e. if the uploading user has access to location 1 and 2 they should not be able to update the profile for a user assigned to just location 3.

So the problem here is that checking 'location_code' in spec skips that validation. Sorry this code is such a pain. I think what might be best is to extract #1 from _validate_uploading_user_access and do that before everything else, and then everything else can go under if 'location_code' in spec.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh dang, thanks for catching that. 8fee5fe should fix it. Although I added some nasty looking variable and function names that i'm open to renaming.

Copy link
Contributor

Choose a reason for hiding this comment

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

Looks good. Almost like reading English (almost). I don't think they're nasty, IMO much better than lumping all the logic in one giant method like some code I've seen. Some method names I'd even make longer.

Also note-to-self: if you have "#1" and "#2" blocks a method, consider just breaking it into 2 different methods.

Copy link
Contributor

@jingcheng16 jingcheng16 left a comment

Choose a reason for hiding this comment

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

Only did partial review. Left a few comments. Nothing blocking.

Copy link
Contributor

Choose a reason for hiding this comment

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

Can you link which part of CustomDataEditor form already did the validation you deleted?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

See my reply here. The validation is done here

@@ -40,3 +51,12 @@ def validate_profile(self, new_profile_name):
profile_validator = ProfileValidator(self.domain, self.upload_user, True, self.profiles_by_name())
spec = {'user_profile': new_profile_name}
return profile_validator.validate_spec(spec)

def validate_email(self, email, is_post):
if is_post:
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is_post flag is required here? What can happen if we don't have this parameter?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The is_post is needed in preparation for it being used for the Create Invitation / Update Web User API. When we're creating a new invitation, we need to check that the email isn’t already associated with an existing user or invitation. But if we’re updating it, we already know the user or invitation exists, so running the same check would always fail.



def validate_assigned_locations_has_users(domain, assigned_location_ids):
error_message_location_not_has_users = _("These locations cannot have users assigned because of their "
Copy link
Contributor

Choose a reason for hiding this comment

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

Shouldn't we remove error_message_location_not_has_users from LocationValidator class?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yes you're right, thanks
d3b2d3a

Copy link
Contributor

@AddisonDunn AddisonDunn left a comment

Choose a reason for hiding this comment

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

Most of the way there with this PR. This is some cool refactoring.

corehq/apps/user_importer/tests/test_validators.py Outdated Show resolved Hide resolved
corehq/apps/users/validation.py Show resolved Hide resolved
return error

location_validator = LocationValidator(self.domain, self.requesting_user, self.location_cache, True)
location_codes = list(set(assigned_location_codes + [primary_location_code]))
Copy link
Contributor

Choose a reason for hiding this comment

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

Doesn't really apply to this PR but I'm just sort of curious... @jingcheng16 isn't there some thing with mobile worker location assignment where the primary location needs to come first in an import or in the API? I vaguely remember some discussion a while back about that.

Doesn't look like for web user import the primary needs to come first in the set of codes, and certainly there is no validation for this in bulk import at the moment.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

IIRC, for both mobile and web user bulk import, it assumes that the first location listed is the primary location. So there's no validation involves, it just assumes and updates/assigns the primary location as such.

corehq/apps/api/validation.py Show resolved Hide resolved
corehq/apps/api/tests/test_validation.py Show resolved Hide resolved
@@ -355,6 +355,10 @@ def test_cant_edit_web_user(self):
validation_result = self.validator.validate_spec(user_spec)
assert validation_result == self.validator.error_message_user_access

user_spec = {'username': self.editable_user.username}
validation_result = self.validator.validate_spec(user_spec)
assert validation_result == self.validator.error_message_user_access
Copy link
Contributor

Choose a reason for hiding this comment

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

👍

@@ -513,30 +547,18 @@ def _validate_uploading_user_access(self, spec):

# 2. Ensure the user is only adding the user to/removing from *new locations* that they have permission
# to access.
if 'location_code' in spec:
locs_being_assigned = self._get_locs_being_assigned(spec)
if locs_ids_being_assigned:
Copy link
Contributor

Choose a reason for hiding this comment

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

Looks good. Almost like reading English (almost). I don't think they're nasty, IMO much better than lumping all the logic in one giant method like some code I've seen. Some method names I'd even make longer.

Also note-to-self: if you have "#1" and "#2" blocks a method, consider just breaking it into 2 different methods.

Copy link
Contributor

@AddisonDunn AddisonDunn left a comment

Choose a reason for hiding this comment

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

Nicely done. Some excellent refactoring done here.

The changes to the existing validation code are solely refactors and I tested locally that those areas still work as expected

Nice. Given the size of this PR, I reccomend re-testing areas that have had recent commits if you haven't already, and testing the forms that were changed, too (if you haven't already).

@@ -110,7 +110,7 @@ def test_redirect_if_invite_email_does_not_match(self):
print(form.errors)
self.assertTrue(form.is_valid())

form = WebUserInvitationForm(
form = AcceptedWebUserInvitationForm(
Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks :)

@Jtang-1 Jtang-1 changed the title Jt/create user api validation Create Invitation API [Validation] Dec 6, 2024
@Jtang-1
Copy link
Contributor Author

Jtang-1 commented Dec 6, 2024

Thanks for the review @AddisonDunn! I sanity tested admin invite and bulk upload validation surrounding locations and didn't run into any issues.

@Jtang-1 Jtang-1 merged commit cbac4e4 into master Dec 6, 2024
13 checks passed
@Jtang-1 Jtang-1 deleted the jt/create-user-api-validation branch December 6, 2024 07:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Risk: High Change affects files that have been flagged as high risk. Risk: Medium Change affects files that have been flagged as medium risk.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants