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

fix: data migration to delete Actions sans Assignments, and use on_delete=CASCADE #314

Merged
merged 8 commits into from
Nov 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ class LearnerContentAssignmentActionSerializer(serializers.ModelSerializer):
class Meta:
model = LearnerContentAssignmentAction
fields = [
'created',
'modified',
'uuid',
'action_type',
'completed_at',
Expand Down Expand Up @@ -100,10 +102,35 @@ class LearnerContentAssignmentAdminResponseSerializer(LearnerContentAssignmentRe
),
choices=AssignmentLearnerStates.CHOICES,
)
error_reason = serializers.SerializerMethodField()

class Meta(LearnerContentAssignmentResponseSerializer.Meta):
fields = LearnerContentAssignmentResponseSerializer.Meta.fields + [
'recent_action',
'learner_state',
'error_reason',
]
read_only_fields = fields

@extend_schema_field(serializers.CharField)
def get_error_reason(self, assignment):
adamstankiewicz marked this conversation as resolved.
Show resolved Hide resolved
"""
Resolves the error reason for the assignment, if any, for display purposes based on
any associated actions.
"""
# If the assignment is not in an errored state, there should be no error reason.
if assignment.state != LearnerContentAssignmentStateChoices.ERRORED:
return None

# Assignment is an errored state, so determine the appropriate error reason so clients don't need to.
related_actions_with_error = assignment.actions.filter(error_reason__isnull=False).order_by('-created')
if not related_actions_with_error:
logger.warning(
'LearnerContentAssignment with UUID %s is in an errored state, but has no related '
'actions in an error state.',
assignment.uuid,
)
return None

# Get the most recently errored action.
return related_actions_with_error.first().error_reason
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

import ddt
from django.core.cache import cache as django_cache
from django.core.exceptions import ValidationError
Copy link
Member Author

Choose a reason for hiding this comment

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

make quality was complaining about a duplicate import.

from rest_framework import status
from rest_framework.reverse import reverse
from rest_framework.serializers import ValidationError
Expand Down
15 changes: 10 additions & 5 deletions enterprise_access/apps/api/v1/tests/test_assignment_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,13 +342,16 @@ def test_retrieve(self, role_context_dict):
'transaction_uuid': self.assignment_allocated_pre_link.transaction_uuid,
'actions': [
{
'created': action.created.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
'modified': action.modified.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
'uuid': str(action.uuid),
'action_type': action.action_type,
'completed_at': action.completed_at.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
'error_reason': None,
'error_reason': action.error_reason,
}
for action in self.assignment_allocated_pre_link.actions.order_by('completed_at')
for action in self.assignment_allocated_pre_link.actions.order_by('created')
],
'error_reason': None,
'recent_action': {
'action_type': AssignmentRecentActionTypes.ASSIGNED,
'timestamp': self.assignment_allocated_pre_link.created.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
Expand Down Expand Up @@ -588,12 +591,14 @@ def test_retrieve(self, role_context_dict):
'transaction_uuid': str(self.requester_assignment_accepted.transaction_uuid),
'actions': [
{
'created': action.created.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
'modified': action.modified.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
'uuid': str(action.uuid),
'action_type': action.action_type,
'completed_at': str(action.completed_at.strftime('%Y-%m-%dT%H:%M:%S.%fZ')),
'error_reason': None,
'completed_at': action.completed_at.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
'error_reason': action.error_reason,
}
for action in self.requester_assignment_accepted.actions.order_by('completed_at')
for action in self.requester_assignment_accepted.actions.order_by('created')
],
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Generated by Django 3.2.21 on 2023-11-01 20:35

from django.db import migrations, models
import django.db.models.deletion


def delete_actions_without_assignment(apps, schema_editor):
"""
Deletes Actions without Assignments. This is a workaround for a bug where at least one Assignment was deleted
before the associated Actions were, resulting in Django Admin for Actions throwing an error.

By running a data migration to delete the Actions without an associated Assignment, this error should be avoided.

This issue will be prevented in the future by also setting `on_delete=CASCADE` such that when an Assignment is
deleted, its associated Actions will be deleted as well.
"""
LearnerContentAssignmentAction = apps.get_model('content_assignments', 'LearnerContentAssignmentAction')
LearnerContentAssignmentAction.objects.filter(assignment__isnull=True).delete()


class Migration(migrations.Migration):

dependencies = [
('content_assignments', '0009_historicallearnercontentassignment_content_title_and_more'),
]

operations = [
migrations.RunPython(delete_actions_without_assignment, migrations.RunPython.noop),
Copy link
Member Author

Choose a reason for hiding this comment

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

Run data migration before altering the assignments field below.

migrations.AlterField(
model_name='learnercontentassignmentaction',
name='assignment',
field=models.ForeignKey(help_text='The LearnerContentAssignment on which this action was performed.', on_delete=django.db.models.deletion.CASCADE, related_name='actions', to='content_assignments.learnercontentassignment'),
),
]
14 changes: 10 additions & 4 deletions enterprise_access/apps/content_assignments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,8 +323,8 @@ def annotate_dynamic_fields_onto_queryset(cls, queryset):
QuerySet: LearnerContentAssignment queryset, same objects but with extra fields annotated.
"""
# Annotate a derived field ``recent_action_time`` using pure ORM so that we can order_by() it later.
# ``recent_action_time`` is defined as the time of the most recent reminder, and falls back to assignment
# creation time if there are no reminders.
# ``recent_action_time`` is defined as the time of the most recent successful reminder, and falls
# back to assignment creation time if there are no successful reminders.
new_queryset = queryset.annotate(
recent_action_time=Coalesce(
# Time of most recent reminder.
Expand All @@ -342,6 +342,8 @@ def annotate_dynamic_fields_onto_queryset(cls, queryset):
LearnerContentAssignmentAction.objects.filter(
assignment=OuterRef('uuid'),
action_type=AssignmentActions.REMINDED,
error_reason__isnull=True,
completed_at__isnull=False,
)
)
).annotate(
Expand All @@ -361,6 +363,8 @@ def annotate_dynamic_fields_onto_queryset(cls, queryset):
LearnerContentAssignmentAction.objects.filter(
assignment=OuterRef('uuid'),
action_type=AssignmentActions.NOTIFIED,
error_reason__isnull=True,
completed_at__isnull=False,
)
)
).annotate(
Expand Down Expand Up @@ -418,8 +422,7 @@ class LearnerContentAssignmentAction(TimeStampedModel):
assignment = models.ForeignKey(
LearnerContentAssignment,
related_name="actions",
on_delete=models.SET_NULL,
null=True,
on_delete=models.CASCADE,
help_text="The LearnerContentAssignment on which this action was performed.",
)
action_type = models.CharField(
Expand Down Expand Up @@ -452,6 +455,9 @@ class LearnerContentAssignmentAction(TimeStampedModel):

history = HistoricalRecords()

class Meta:
ordering = ['created']

def __str__(self):
return (
f'uuid={self.uuid}, action_type={self.action_type}, error_reason={self.error_reason}'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 3.2.21 on 2023-11-01 20:35
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 migration is generated with make migrations on the main branch without any code changes... Should it be committed?

Copy link
Contributor

Choose a reason for hiding this comment

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

I feel like this missing migration removes the field-level unique constraint that was supposed to be removed as part of #258. I'd say keeping this migration makes the database constraints more closely match the field definition.

Of course now we also know that mysql doesn't actually support the unique_together that was supposed to replace the field-level constraint. But maybe that's a problem for a separate ticket.

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh, hmm, weird. FWIW, MySQL does support unique_together, it just doesn't support conditional unique constraints.

Copy link
Contributor

Choose a reason for hiding this comment

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

Here's the current UNIQUE keys on the table in our production environment:

| subsidy_access_policy_subsidyaccesspolicy | CREATE TABLE `subsidy_access_policy_subsidyaccesspolicy` (
  `created` datetime(6) NOT NULL,
  `modified` datetime(6) NOT NULL,
  `policy_type` varchar(64) NOT NULL,
  `uuid` char(32) NOT NULL,
  ...
  PRIMARY KEY (`uuid`),
  UNIQUE KEY `assignment_configuration_id` (`assignment_configuration_id`),
  UNIQUE KEY `subsidy_access_policy_su_active_assignment_config_f6b904ce_uniq` (`active`,`assignment_configuration_id`),
  ...

So the composite key is there, but so is the single-column unique key, which we don't want. So yes, follow Troy's suggestion and let this migration go in.

Copy link
Member Author

Choose a reason for hiding this comment

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

Sounds good, thanks for the sanity check! Will keep this migration.


from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('content_assignments', '0010_alter_learnercontentassignmentaction_assignment'),
('subsidy_access_policy', '0017_active_assignment_config_unique'),
]

operations = [
migrations.AlterField(
model_name='subsidyaccesspolicy',
name='assignment_configuration',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='subsidy_access_policy', to='content_assignments.assignmentconfiguration'),
),
]
2 changes: 1 addition & 1 deletion enterprise_access/apps/subsidy_access_policy/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ def __new__(cls, *args, **kwargs):
except Exception: # pylint: disable=broad-except
pass

return super().__new__(proxy_class) # pylint: disable=lost-exception
return super().__new__(proxy_class)
Copy link
Member Author

Choose a reason for hiding this comment

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

make quality suggested this pylint ignore comment wasn't necessary.


def subsidy_record(self):
"""
Expand Down
8 changes: 7 additions & 1 deletion enterprise_access/settings/devstack.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,18 @@

# CORS CONFIG
CORS_ORIGIN_WHITELIST = [
'http://localhost:1991', # frontend-admin-portal
'http://localhost:1991', # frontend-app-admin-portal
'http://localhost:8734', # frontend-app-learner-portal-enterprise
'http://localhost:18450', # frontend-app-support-tools
]
# END CORS

# CSRF CONFIG
CSRF_TRUSTED_ORIGINS = [
'http://localhost:1991', # frontend-app-admin-portal
]
# END CSRF CONFIG

ECOMMERCE_URL = 'http://edx.devstack.ecommerce:18130'
LICENSE_MANAGER_URL = 'http://license-manager.app:18170'
LMS_URL = 'http://edx.devstack.lms:18000'
Expand Down
Loading