-
-
Notifications
You must be signed in to change notification settings - Fork 227
Manual Web User Deactivation #36447
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?
Manual Web User Deactivation #36447
Conversation
FYI: All the test failures are because |
Can web users not submit data via phone application? I thought they could. What stops them? |
@mkangia could you elaborate? I thought that you had to sign into the mobile app with a mobile account. Can you use a web user to sign into those?? |
@minhaminha |
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.
Got through reviewing 9e84ce2—I'll continue with the rest tomorrow. This looks like a pretty involved change, and you did a great job uncovering all the little places that needed to be touched. Nice work!
corehq/apps/users/urls.py
Outdated
@@ -106,6 +108,8 @@ | |||
url(r'^web/account/(?P<couch_user_id>[ \w-]+)/$', EditWebUserView.as_view(), name=EditWebUserView.urlname), | |||
url(r'^web/remove/(?P<couch_user_id>[ \w-]+)/$', remove_web_user, name='remove_web_user'), | |||
url(r'^web/undo_remove/(?P<record_id>[ \w-]+)/$', undo_remove_web_user, name='undo_remove_web_user'), | |||
url(r'^web/deactivate_web/(?P<couch_user_id>[ \w-]+)/$', deactivate_web_user, name='deactivate_web_user'), |
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.
super nit: deactivate
and activate
is clear enough since it's already under web
corehq/apps/users/views/__init__.py
Outdated
@always_allow_project_access | ||
@require_can_edit_web_users | ||
@require_POST | ||
@location_safe |
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.
location_safe
decorator doesn't make the view location safe. It just indicates that the view is location safe and as is, I don't think this or deactivate_web_user
are location safe. It need to check user_can_access_other_user
{% blocktrans %} | ||
Deactivated web users will not be able to log into the project space.<br /> | ||
Read more about | ||
<a href='https://crouton.net' target="_blank"> |
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.
wrong link?
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.
haha thank you for the reminder. I was going to change that out once the docs were written but I think I'll just link it to the general Web Users for this PR, since that'll probably happen after this is deployed.
corehq/apps/domain/views/base.py
Outdated
@@ -68,9 +68,16 @@ def select(request, do_not_redirect=False, next_view=None): | |||
or (request.user.is_superuser and not domain_obj.restrict_superusers) | |||
or domain_obj.is_snapshot | |||
): | |||
redirect_last_domain = True | |||
if ( |
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.
could this conditional be combined to the above conditional?
Also, do we need the redirect_last_domain
flag? Isn't it equivalent to remove the flag and nest the try block within the conditional?
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.
That took me a moment to understand. But I agree. I am reading the code the same way.
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.
yep yall are right I consolidated into the conditional above
corehq/apps/domain/views/base.py
Outdated
return sorted([{ | ||
'name': o.name, | ||
'display_name': o.display_name(), | ||
'url': reverse(view_name, args=[o.name]), | ||
} for o in domain_objects if o], key=lambda link: link['display_name'].lower()) | ||
} for o in domain_objects if o and user.is_active_in_domain(o.name)], |
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.
could Domain.active_for_user
be updated to return only the domains that the user is active for? This would avoid having to check is_active_in_domain
for each domain
corehq/apps/users/models.py
Outdated
@@ -748,6 +748,12 @@ class MultiMembershipMixin(_AuthorizableMixin): | |||
domains = StringListProperty() | |||
domain_memberships = SchemaListProperty(DomainMembership) | |||
|
|||
@memoized | |||
def is_active_in_domain(self, domain): | |||
if self.get_domain_membership(domain): |
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.
is there overhead to calling get_domain_membership
twice? It doesn't require querying the db right since it's just in the user doc? but anyways this would be equivalent
@memoized
def is_active_in_domain(self, domain):
domain_membership = self.get_domain_membership(domain)
return domain_membership.is_active if domain_membership else False
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.
yep it's all in the user doc but true this is better
corehq/apps/users/dbaccessors.py
Outdated
start_date = today - relativedelta(months=1) | ||
for u in web_users: | ||
if not u.is_active_in_domain(domain): | ||
user_history = UserHistory.objects.filter( |
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.
I think this business logic ("not counting users only if it has been deactivated for the whole month") is specific to billing so shouldn't be done here
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.
hmm that's why I put it under the exclude_deactivated_web
conditional, which is only set to True from accounting contexts. Do you think that's not enough? Maybe a comment to explain it's purpose?
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.
I think it should be done in the get_web_user_count
defined in corehq/apps/accounting/models.py
.
Since this is a general dbaccessors function it's not intuitive that exclude_deactivated_web=True
would only exclude web users that are deactivated AND not active in the past month. I would expect it to exclude all webs users that are deactivated.
So if it was just this, that would be the intuitive behavior (and the "not active in the past month" should be done elsewhere
for u in web_users:
if not u.is_active_in_domain(domain):
total -= 1
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.
that's fair - moved to the get_web_user_count
corehq/apps/users/dbaccessors.py
Outdated
today = datetime.datetime.today() | ||
start_date = today - relativedelta(months=1) | ||
for u in web_users: | ||
if not u.is_active_in_domain(domain): |
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.
I'm not sure - but from a performance consideration, should the view/index be updated so that we're not doing this filtering in memory?
also, can you remind me what the difference between what include_inactive
and exclude_deactivated_web
is? Is "inactive" a web user that has been soft deleted?
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.
- I think I wasn't sure how to do that which is why I'm filtering like this but I can look into that again
- inactive in this case refers to the user level is_active status. For web users, this is used to refer to the fact that they use SSO but had their access revoked by their idp. For mobile workers, it refers to their domain level deactivated/active state. Exclude deactivated web refers only to domain level deactivated/active state for web users.
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.
Thanks for the clarifying definitions. Before you look into the view/index again, I would want to get @esoergel thoughts if it's even a concern. Do you think think doing the filtering in memory here is fine?
corehq/apps/api/resources/v0_5.py
Outdated
@@ -398,6 +398,7 @@ class WebUserResource(v0_1.WebUserResource): | |||
profile = fields.CharField(null=True) | |||
user_data = fields.DictField() | |||
tableau_role = fields.CharField(null=True) | |||
is_active_in_domain = fields.BooleanField() |
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.
Is there an ask to include this in the response? Otherwise, I think this can be removed. It would be more beneficial as a filter parameter so that the response would include only deactivated users or only activated users. And if that's outside of the scope of this work, then that's something that can be done in the future.
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.
Woody asked about it here but now that I'm going back to read it I guess it doesn't explicitly need to be included in the response - just some way to tell deactivated apart from activated. I can look into using it to filter instead of just returning the state
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.
I think this is fine. Even with a filter, you might not want to have to combine two files just so you can get all users
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.
Part of why i thought of that is because that's also how it's done for mobile workers (https://commcare-hq.readthedocs.io/api/list-mobile-workers.html) where there's an archived
parameter. So it would be good for consistency too
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.
I think we're gonna need more input (i.e. Woody's) before committing to either direction.
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.
Halfway. Just submitting so you get the comments. Will continue shortly
corehq/apps/domain/decorators.py
Outdated
@@ -107,6 +107,9 @@ def call_view(): | |||
return call_view() | |||
|
|||
if couch_user.is_member_of(domain_obj, allow_enterprise=True): | |||
if (couch_user.is_web_user() and not couch_user.is_superuser | |||
and not couch_user.is_active_in_domain(domain_name)): | |||
return HttpResponseForbidden(_("You have been deactivated from {domain}.").format(domain=domain)) |
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.
I think this shows up as just a text in the browser. I wonder, if we can make that a templateResponse like two lines down. So it looks better.
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.
Added a new deactivated_notice.html
template to show instead of a blank page with text
corehq/apps/domain/views/base.py
Outdated
@@ -68,9 +68,16 @@ def select(request, do_not_redirect=False, next_view=None): | |||
or (request.user.is_superuser and not domain_obj.restrict_superusers) | |||
or domain_obj.is_snapshot | |||
): | |||
redirect_last_domain = True | |||
if ( |
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.
That took me a moment to understand. But I agree. I am reading the code the same way.
@@ -274,7 +274,8 @@ class WebUserResource(ODataEnterpriseReportResource): | |||
'last_login': 3, | |||
'last_access_date': 4, | |||
'status': 5, | |||
'domain': 6, | |||
'is_active_in_domain': 6, |
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.
Curious why you are reordering the columns?
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.
This was specifically for tests (only tests seem to be using this column map?) which expected a certain order that was impossible to do with is_active_in_domain being 7
corehq/apps/hqwebapp/views.py
Outdated
show_domain_login = True | ||
if req.couch_user.is_web_user() and not req.couch_user.is_active_in_domain(domain): | ||
show_domain_login = False |
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.
show_domain_login = not (req.couch_user.is_web_user() and not req.couch_user.is_active_in_domain(domain))
?
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.
updated - clearly I was not in the headspace for understanding conditionals this day
corehq/apps/users/dbaccessors.py
Outdated
@@ -183,6 +191,11 @@ def _get_invitations_by_filters(domain, user_filters, count_only=False): | |||
support ES search syntax, it's just a case-insensitive substring search. | |||
Ignores any other filters. | |||
""" | |||
only_active = user_filters.get("user_active_status", None) | |||
if not only_active and only_active is not None: |
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.
Is this conditional right? I'm interpreting user_active_status=True
means returning only invitations whose users are active on the domain (which would be 0). But that conditional would evaluate as False
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.
no this is specifically to filter out invitations (or return a count of 0) if you choose the "Only Deactivated" filter in the web user downloads. The options are None (include everything), True (include only active, which all invitations are considered to be) and False (only include deactivated users). Going to add a comment to explain this since I don't really know a better way to convey this through variable naming
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.
ah, i was thinking non accepted invitations would be considered inactive, but active makes more sense (as you have it)
if str(current_user.is_active_in_domain(self.domain)) != imported_is_active: | ||
if imported_is_active == 'True': | ||
current_user.reactivate(self.domain, changed_by=web_user_importer.upload_user) | ||
elif imported_is_active == 'False': |
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.
Can this not just be an else
?
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.
I think I wanted users to be very intentional about deactivating users through the bulk upload. I don't want deactivation to happen if they accidentally leave it blank, misspell 'True', or whatever else.
corehq/apps/users/dbaccessors.py
Outdated
@@ -183,6 +191,11 @@ def _get_invitations_by_filters(domain, user_filters, count_only=False): | |||
support ES search syntax, it's just a case-insensitive substring search. | |||
Ignores any other filters. | |||
""" | |||
only_active = user_filters.get("user_active_status", None) | |||
if not only_active and only_active is not None: |
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.
not sure if I understand this. can a user for an invitation ever be active on that same domain?
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.
Jonathan raised the same point haha. copy pasted:
this is specifically to filter out invitations (or return a count of 0) if you choose the "Only Deactivated" filter in the web user downloads. The options are None (include everything), True (include only active, which all invitations are considered to be) and False (only include deactivated users). Going to add a comment to explain this since I don't really know a better way to convey this through variable naming
For mass emails we just care that the user is active on at least one domain
Domain now filters out inactive users by default Add right flags to get intended users.
Domain now filters out inactive users by default Add right flags to get intended users.
…er-deactivation-manual
…user-deactivation-manual
…ommcare-hq into ml/web-user-deactivation-manual
The same check already happens in the requires_privilege_for_commcare_user decorator with addtion of accounting for superusers. Whereas the removed check will throw and exception for superusers
Fix its usage of the UserES filter to match the new functionality. Update the test data
corehq/apps/reports/filters/users.py
Outdated
@@ -351,6 +351,7 @@ def filter_context(self): | |||
@classmethod | |||
def user_es_query(cls, domain, mobile_user_and_group_slugs, request_user): | |||
# The queryset returned by this method is location-safe | |||
# import pdb; pdb.set_trace() |
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.
👀
Product Description
This PR adds the ability to manually deactivate and reactivate web users, exactly the same way as you would mobile workers. Not to be confused with the existing way to deactivate users that use SSO - this deactivation (going to refer to as domain active status) is scoped only to a single domain. Deactivated web users will be:
Additionally, you'll now be able to filter any reports with user type filtering by active vs deactivated web users (this includes web user download). For more things that deactivation affects, please see the design doc below or the commit names.
Technical Summary
Jira epic
Design Doc / Tech Spec
The deactivation state will live in the individual domain_memberships obj a web user has. See this PR to see how that'll be queried in ES.
Feature Flag
This will not be behind a FF (as specified in the design doc)
Safety Assurance
Safety story
Tested locally and on staging.
Automated test coverage
--
QA Plan
QA Ticket
Rollback instructions
Labels & Review