Skip to content

Commit

Permalink
feat: add failed statuses for groups (#2159)
Browse files Browse the repository at this point in the history
  • Loading branch information
katrinan029 authored Jul 1, 2024
1 parent ab25983 commit 550f3cf
Show file tree
Hide file tree
Showing 11 changed files with 251 additions and 21 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ Unreleased
----------
* nothing unreleased

[4.20.13]
---------
* feat: added failed statuses for groups and new endpoint to update statuses

[4.20.12]
---------
* feat: added support for testing sftp connection inside ``EnterpriseCustomerReportingConfiguration`` instance.
Expand Down
2 changes: 1 addition & 1 deletion enterprise/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
Your project description goes here.
"""

__version__ = "4.20.12"
__version__ = "4.20.13"
2 changes: 2 additions & 0 deletions enterprise/api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,8 @@ def get_recent_action(self, obj):
"""
Return the timestamp and name of the most recent action associated with the membership.
"""
if obj.errored_at:
return f"Errored: {obj.errored_at.strftime('%B %d, %Y')}"
if obj.is_removed:
return f"Removed: {obj.removed_at.strftime('%B %d, %Y')}"
if obj.enterprise_customer_user and obj.activated_at:
Expand Down
2 changes: 1 addition & 1 deletion enterprise/api/v1/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@
re_path(
r'^enterprise-group/(?P<group_uuid>[A-Za-z0-9-]+)/learners/?$',
enterprise_group.EnterpriseGroupViewSet.as_view(
{'get': 'get_learners'}
{'get': 'get_learners', 'patch': 'update_pending_learner_status'}
),
name='enterprise-group-learners'
),
Expand Down
74 changes: 59 additions & 15 deletions enterprise/api/v1/views/enterprise_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,22 +46,34 @@ def remove_group_membership_records(group, catalog_uuid=None, user_emails=None):
records_to_delete = records_to_delete.filter((ecu_in_q | pecu_in_q))

records_to_delete_uuids = [record.uuid for record in records_to_delete]
records_to_delete.delete()
for records_to_delete_uuids_batch in utils.batch(records_to_delete_uuids, batch_size=200):
send_group_membership_removal_notification.delay(
group.enterprise_customer.uuid,
records_to_delete_uuids_batch,
catalog_uuid
try:
records_to_delete.delete()
for records_to_delete_uuids_batch in utils.batch(records_to_delete_uuids, batch_size=200):
send_group_membership_removal_notification.delay(
group.enterprise_customer.uuid,
records_to_delete_uuids_batch,
catalog_uuid
)
# Woohoo! Records removed! Now to update the soft deleted records
deleted_records = models.EnterpriseGroupMembership.all_objects.filter(
uuid__in=records_to_delete_uuids,
)
# Woohoo! Records removed! Now to update the soft deleted records
deleted_records = models.EnterpriseGroupMembership.all_objects.filter(
uuid__in=records_to_delete_uuids,
)
deleted_records.update(
status=constants.GROUP_MEMBERSHIP_REMOVED_STATUS,
removed_at=localized_utcnow()
)
return len(deleted_records)
deleted_records.update(
status=constants.GROUP_MEMBERSHIP_REMOVED_STATUS,
removed_at=localized_utcnow()
)
return len(deleted_records)
# This will error out all records even if only one failed.
except Exception as exc:
failed_deleted_records = models.EnterpriseGroupMembership.all_objects.filter(
uuid__in=records_to_delete_uuids,
)
failed_deleted_records.update(
status=constants.GROUP_MEMBERSHIP_INTERNAL_API_ERROR_STATUS,
errored_at=localized_utcnow()
)
LOGGER.exception(f'Failed to remove group membership records for group {group} with exception {exc}')
raise exc


class EnterpriseGroupViewSet(EnterpriseReadWriteModelViewSet):
Expand Down Expand Up @@ -133,6 +145,38 @@ def create(self, request, *args, **kwargs):
"""
return super().create(request, *args, **kwargs)

@action(methods=['patch'], detail=False, permission_classes=[permissions.IsAuthenticated])
@permission_required(
'enterprise.can_access_admin_dashboard',
fn=lambda request, group_uuid: get_enterprise_customer_from_enterprise_group_id(group_uuid)
)
def update_pending_learner_status(self, request, group_uuid):
"""
Endpoint location to update the status and errored at time for a pending learner:
PATCH api/v1/enterprise-group/<group_uuid>/learners/
Request Arguments:
- ``group_uuid`` (URL location, required): The uuid of the group which learners should be updated
"""
group = self.get_queryset().get(uuid=group_uuid)
request_data = self.request.data
learner = request_data.get("learner")
try:
pecu_in_q = Q(pending_enterprise_customer_user__user_email=learner)
learner_to_update = models.EnterpriseGroupMembership.objects.filter(
group=group,
).filter(pecu_in_q)

learner_to_update.update(
status=request_data.get("status"),
errored_at=localized_utcnow(),
)
return Response(f'Successfully updated learner record for learner email {learner}', status=201)
except models.EnterpriseGroup.DoesNotExist as exc:
LOGGER.warning(f"group_uuid {group_uuid} does not exist")
raise Http404 from exc

@action(detail=True, methods=['get'])
def get_learners(self, request, *args, **kwargs):
"""
Expand Down
4 changes: 4 additions & 0 deletions enterprise/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,10 +245,14 @@ class FulfillmentTypes:
GROUP_MEMBERSHIP_PENDING_STATUS = 'pending'
GROUP_MEMBERSHIP_REMOVED_STATUS = 'removed'
GROUP_MEMBERSHIP_ACCEPTED_STATUS = 'accepted'
GROUP_MEMBERSHIP_INTERNAL_API_ERROR_STATUS = 'internal_api_error'
GROUP_MEMBERSHIP_EMAIL_ERROR_STATUS = 'email_error'
GROUP_MEMBERSHIP_STATUS_CHOICES = (
(GROUP_MEMBERSHIP_REMOVED_STATUS, 'Removed'),
(GROUP_MEMBERSHIP_ACCEPTED_STATUS, 'Accepted'),
(GROUP_MEMBERSHIP_PENDING_STATUS, 'Pending'),
(GROUP_MEMBERSHIP_INTERNAL_API_ERROR_STATUS, 'Internal API error'),
(GROUP_MEMBERSHIP_EMAIL_ERROR_STATUS, 'Email error')
)

ENTITY_ID_REGEX = r"<(\w+:)?EntityDescriptor.*?entityID=['\"](.*?)['\"].*?>"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Generated by Django 4.2.13 on 2024-06-24 18:28

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('enterprise', '0215_remove_enterprisecustomer_career_engagement_network_message_and_more'),
]

operations = [
migrations.AddField(
model_name='enterprisegroupmembership',
name='errored_at',
field=models.DateTimeField(blank=True, default=None, help_text='The last time the membership action was in an error state. Null means the membership is not errored.', null=True),
),
migrations.AddField(
model_name='historicalenterprisegroupmembership',
name='errored_at',
field=models.DateTimeField(blank=True, default=None, help_text='The last time the membership action was in an error state. Null means the membership is not errored.', null=True),
),
migrations.AlterField(
model_name='enterprisegroupmembership',
name='status',
field=models.CharField(blank=True, choices=[('removed', 'Removed'), ('accepted', 'Accepted'), ('pending', 'Pending'), ('internal_api_error', 'Internal API error'), ('email_error', 'Email error')], default='pending', help_text='Current status of the membership record', max_length=20, null=True, verbose_name='Membership Status'),
),
migrations.AlterField(
model_name='historicalenterprisegroupmembership',
name='status',
field=models.CharField(blank=True, choices=[('removed', 'Removed'), ('accepted', 'Accepted'), ('pending', 'Pending'), ('internal_api_error', 'Internal API error'), ('email_error', 'Email error')], default='pending', help_text='Current status of the membership record', max_length=20, null=True, verbose_name='Membership Status'),
),
]
9 changes: 9 additions & 0 deletions enterprise/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4458,6 +4458,13 @@ class EnterpriseGroupMembership(TimeStampedModel, SoftDeletableModel):
"The moment at which the membership record was revoked by an Enterprise admin."
),
)
errored_at = models.DateTimeField(
default=None,
null=True,
blank=True,
help_text=_(
"The last time the membership action was in an error state. Null means the membership is not errored."),
)
history = HistoricalRecords()

class Meta:
Expand Down Expand Up @@ -4490,6 +4497,8 @@ def recent_action(self):
"""
Return the timestamp of the most recent action relating to the membership
"""
if self.errored_at:
return self.errored_at
if self.is_removed:
return self.removed_at
if self.enterprise_customer_user and self.activated_at:
Expand Down
11 changes: 9 additions & 2 deletions enterprise/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@
from django.core import mail
from django.db import IntegrityError

from enterprise import constants
from enterprise.api_client.braze import ENTERPRISE_BRAZE_ALIAS_LABEL, MAX_NUM_IDENTIFY_USERS_ALIASES, BrazeAPIClient
from enterprise.api_client.enterprise_catalog import EnterpriseCatalogApiClient
from enterprise.constants import SSO_BRAZE_CAMPAIGN_ID
from enterprise.utils import batch_dict, get_enterprise_customer, send_email_notification_message
from enterprise.utils import batch_dict, get_enterprise_customer, localized_utcnow, send_email_notification_message

LOGGER = getLogger(__name__)

Expand Down Expand Up @@ -353,6 +354,9 @@ def send_group_membership_invitation_notification(
"Groups learner invitation email could not be sent "
f"to {recipients} for enterprise {enterprise_customer_name}."
)
membership_records.update(
status=constants.GROUP_MEMBERSHIP_EMAIL_ERROR_STATUS,
errored_at=localized_utcnow())
LOGGER.exception(message)
raise exc

Expand Down Expand Up @@ -420,8 +424,11 @@ def send_group_membership_removal_notification(enterprise_customer_uuid, members
)
except BrazeClientError as exc:
message = (
"Groups learner invitation email could not be sent "
"Groups learner removal email could not be sent "
f"to {recipients} for enterprise {enterprise_customer_name}."
)
membership_records.update(
status=constants.GROUP_MEMBERSHIP_EMAIL_ERROR_STATUS,
errored_at=localized_utcnow())
LOGGER.exception(message)
raise exc
24 changes: 24 additions & 0 deletions tests/test_enterprise/api/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8552,6 +8552,30 @@ def test_patch_with_bad_request_customer_to_change_to(self):
response = self.client.patch(url, data=request_data)
assert response.status_code == 401

def test_update_pending_learner_status(self):
"""
Test that the PATCH endpoint updates pending learner status and errored at time
"""
# url: 'http://testserver/enterprise/api/v1/enterprise_group/<group uuid>/learners/'
url = settings.TEST_SERVER + reverse(
'enterprise-group-learners',
kwargs={'group_uuid': self.group_1.uuid},
)
new_uuid = uuid.uuid4()
new_customer = EnterpriseCustomerFactory(uuid=new_uuid)
self.set_multiple_enterprise_roles_to_jwt([
(ENTERPRISE_ADMIN_ROLE, self.enterprise_customer.pk),
(ENTERPRISE_ADMIN_ROLE, self.group_2.enterprise_customer.pk),
(ENTERPRISE_ADMIN_ROLE, new_customer.pk),
])
request_data = {
'learner': '[email protected]',
'status': 'email_error',
'errored_at': localized_utcnow()}
response = self.client.patch(url, data=request_data)
assert response.status_code == 201
assert response.json() == 'Successfully updated learner record for learner email [email protected]'

@mock.patch('enterprise.tasks.send_group_membership_removal_notification.delay', return_value=mock.MagicMock())
def test_successful_remove_all_learners_from_group(self, mock_send_group_membership_removal_notification):
"""
Expand Down
107 changes: 105 additions & 2 deletions tests/test_enterprise/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
from datetime import datetime
from unittest import mock

from pytest import mark
from braze.exceptions import BrazeClientError
from pytest import mark, raises

from enterprise.api_client.braze import ENTERPRISE_BRAZE_ALIAS_LABEL
from enterprise.constants import SSO_BRAZE_CAMPAIGN_ID
Expand All @@ -21,7 +22,7 @@
send_group_membership_removal_notification,
send_sso_configured_email,
)
from enterprise.utils import serialize_notification_content
from enterprise.utils import localized_utcnow, serialize_notification_content
from test_utils.factories import (
EnterpriseCustomerFactory,
EnterpriseCustomerUserFactory,
Expand Down Expand Up @@ -267,6 +268,58 @@ def test_send_group_membership_invitation_notification(self, mock_braze_api_clie
mock_braze_api_client().create_braze_alias.assert_called_once_with(
[self.pending_enterprise_customer_user.user_email], ENTERPRISE_BRAZE_ALIAS_LABEL)

@mock.patch('enterprise.tasks.EnterpriseCatalogApiClient', return_value=mock.MagicMock())
@mock.patch('enterprise.tasks.BrazeAPIClient', return_value=mock.MagicMock())
def test_fail_send_group_membership_invitation_notification(
self,
mock_braze_api_client,
mock_enterprise_catalog_client,
):
"""
Verify failed send group invitation email
"""
pending_membership = EnterpriseGroupMembershipFactory(
group=self.enterprise_group,
pending_enterprise_customer_user=self.pending_enterprise_customer_user,
enterprise_customer_user=None,
)
admin_email = '[email protected]'
mock_braze_api_client().create_recipients.return_value = {
self.user.email: {
"external_user_id": self.user.id,
"attributes": {
"user_alias": {
"external_id": self.user.id,
"user_alias": self.user.email,
},
}
}
}

mock_catalog_content_count = 5
mock_admin_mailto = f'mailto:{admin_email}'
mock_braze_api_client().generate_mailto_link.return_value = mock_admin_mailto
mock_braze_api_client().create_recipient_no_external_id.return_value = (
self.pending_enterprise_customer_user.user_email)
mock_enterprise_catalog_client().get_catalog_content_count.return_value = (
mock_catalog_content_count)
act_by_date = datetime.today()
catalog_uuid = uuid.uuid4()
membership_uuids = EnterpriseGroupMembership.objects.values_list('uuid', flat=True)
mock_braze_api_client().send_campaign_message.side_effect = BrazeClientError(
"Any thing that happens during email")
errored_at = localized_utcnow()
with raises(BrazeClientError):
send_group_membership_invitation_notification(
self.enterprise_customer.uuid,
membership_uuids,
act_by_date,
catalog_uuid)
pending_membership.refresh_from_db()
assert pending_membership.status == 'email_error'
assert pending_membership.errored_at == errored_at
assert pending_membership.recent_action == f"Errored: {errored_at.strftime('%B %d, %Y')}"

@mock.patch('enterprise.tasks.EnterpriseCatalogApiClient', return_value=mock.MagicMock())
@mock.patch('enterprise.tasks.BrazeAPIClient', return_value=mock.MagicMock())
def test_send_group_membership_removal_notification(self, mock_braze_api_client, mock_enterprise_catalog_client):
Expand Down Expand Up @@ -338,6 +391,56 @@ def test_send_group_membership_removal_notification(self, mock_braze_api_client,
[self.pending_enterprise_customer_user.user_email], ENTERPRISE_BRAZE_ALIAS_LABEL)
mock_braze_api_client().send_campaign_message.assert_has_calls(calls)

@mock.patch('enterprise.tasks.EnterpriseCatalogApiClient', return_value=mock.MagicMock())
@mock.patch('enterprise.tasks.BrazeAPIClient', return_value=mock.MagicMock())
def test_fail_send_group_membership_removal_notification(
self,
mock_braze_api_client,
mock_enterprise_catalog_client,
):
"""
Verify failed send group removal email
"""
pending_membership = EnterpriseGroupMembershipFactory(
group=self.enterprise_group,
pending_enterprise_customer_user=self.pending_enterprise_customer_user,
enterprise_customer_user=None,
)
admin_email = '[email protected]'
mock_braze_api_client().create_recipients.return_value = {
self.user.email: {
"external_user_id": self.user.id,
"attributes": {
"user_alias": {
"external_id": self.user.id,
"user_alias": self.user.email,
},
}
}
}

mock_catalog_content_count = 5
mock_admin_mailto = f'mailto:{admin_email}'
mock_braze_api_client().generate_mailto_link.return_value = mock_admin_mailto
mock_braze_api_client().create_recipient_no_external_id.return_value = (
self.pending_enterprise_customer_user.user_email)
mock_enterprise_catalog_client().get_catalog_content_count.return_value = (
mock_catalog_content_count)
catalog_uuid = uuid.uuid4()
membership_uuids = EnterpriseGroupMembership.objects.values_list('uuid', flat=True)
mock_braze_api_client().send_campaign_message.side_effect = BrazeClientError(
"Any thing that happens during email")
errored_at = localized_utcnow()
with raises(BrazeClientError):
send_group_membership_removal_notification(
self.enterprise_customer.uuid,
membership_uuids,
catalog_uuid)
pending_membership.refresh_from_db()
assert pending_membership.status == 'email_error'
assert pending_membership.errored_at == localized_utcnow()
assert pending_membership.recent_action == f"Errored: {errored_at.strftime('%B %d, %Y')}"

@mock.patch('enterprise.tasks.BrazeAPIClient', return_value=mock.MagicMock())
def test_sso_configuration_oauth_orchestration_email(self, mock_braze_client):
"""
Expand Down

0 comments on commit 550f3cf

Please sign in to comment.