Skip to content

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

Open
wants to merge 38 commits into
base: master
Choose a base branch
from

Conversation

minhaminha
Copy link
Contributor

@minhaminha minhaminha commented May 16, 2025

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:

  • Unable to log in or access the deactivated domain
  • Not counted towards the total web user count for billing purposes
  • Omitted from the recipient list of any scheduled reports/broadcasts/conditional alerts they might've been part of

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.

screen 1

screen 2

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

  • 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

@minhaminha minhaminha added the Open for review: do not merge A work in progress label May 16, 2025
@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 May 16, 2025
@minhaminha minhaminha marked this pull request as ready for review May 22, 2025 15:42
@minhaminha
Copy link
Contributor Author

FYI: All the test failures are because DomainMemebership currently lacks the is_active attribute, which is added on the other PR

@mkangia
Copy link
Contributor

mkangia commented May 23, 2025

Can web users not submit data via phone application? I thought they could. What stops them?

@minhaminha
Copy link
Contributor Author

minhaminha commented May 23, 2025

@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 minhaminha added awaiting QA QA in progress. Do not merge and removed Open for review: do not merge A work in progress labels May 23, 2025
@minhaminha minhaminha requested a review from MartinRiese May 23, 2025 21:29
@mkangia
Copy link
Contributor

mkangia commented May 25, 2025

@minhaminha
That is my understanding. You can just login a mobile app using a web user account, it will only fail in one known scenario.
Should be easy to test.

Copy link
Contributor

@Jtang-1 Jtang-1 left a 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!

@@ -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'),
Copy link
Contributor

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

@always_allow_project_access
@require_can_edit_web_users
@require_POST
@location_safe
Copy link
Contributor

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">
Copy link
Contributor

Choose a reason for hiding this comment

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

wrong link?

Copy link
Contributor Author

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.

@@ -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 (
Copy link
Contributor

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?

Copy link
Contributor

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.

Copy link
Contributor Author

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

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)],
Copy link
Contributor

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

@@ -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):
Copy link
Contributor

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

Copy link
Contributor Author

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

start_date = today - relativedelta(months=1)
for u in web_users:
if not u.is_active_in_domain(domain):
user_history = UserHistory.objects.filter(
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 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

Copy link
Contributor Author

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?

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 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

Copy link
Contributor Author

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

today = datetime.datetime.today()
start_date = today - relativedelta(months=1)
for u in web_users:
if not u.is_active_in_domain(domain):
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 - 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?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

  1. 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
  2. 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.

Copy link
Contributor

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?

@@ -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()
Copy link
Contributor

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.

Copy link
Contributor Author

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

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 this is fine. Even with a filter, you might not want to have to combine two files just so you can get all users

Copy link
Contributor

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

Copy link
Contributor Author

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.

Copy link
Contributor

@MartinRiese MartinRiese left a 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

@@ -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))
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 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.

Copy link
Contributor Author

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

@@ -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 (
Copy link
Contributor

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,
Copy link
Contributor

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?

Copy link
Contributor Author

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

Comment on lines 563 to 565
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
Copy link
Contributor

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))?

Copy link
Contributor Author

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

@@ -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:
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 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

Copy link
Contributor Author

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

Copy link
Contributor

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':
Copy link
Contributor

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?

Copy link
Contributor Author

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.

@@ -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:
Copy link
Contributor

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?

Copy link
Contributor Author

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

@esoergel esoergel requested a review from AmitPhulera as a code owner June 3, 2025 20:46
@dimagimon dimagimon added the reindex/migration Reindex or migration will be required during or before deploy label Jun 3, 2025
MartinRiese and others added 11 commits June 3, 2025 19:40
…ommcare-hq into ml/web-user-deactivation-manual
functions calls were changed in a previous commit
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
@@ -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()
Copy link
Contributor

Choose a reason for hiding this comment

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

👀

@esoergel esoergel mentioned this pull request Jun 18, 2025
3 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
awaiting QA QA in progress. Do not merge reindex/migration Reindex or migration will be required during or before deploy 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.

6 participants