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

feat: adding pathway status #2710

Merged
merged 2 commits into from
Feb 27, 2025
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
15 changes: 12 additions & 3 deletions credentials/apps/catalog/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,18 @@ class ProgramAdmin(admin.ModelAdmin):

@admin.register(Pathway)
class PathwayAdmin(admin.ModelAdmin):
list_display = ("name", "org_name", "pathway_type", "email", "uuid")
list_filter = ("site",)
readonly_fields = ("name", "org_name", "pathway_type", "email", "uuid", "site", "programs")
list_display = ("name", "org_name", "pathway_type", "status", "email", "uuid")
list_filter = ("site", "status")
readonly_fields = (
"name",
"org_name",
"pathway_type",
"email",
"uuid",
"site",
"programs",
"status",
)
search_fields = ("name", "uuid")


Expand Down
6 changes: 6 additions & 0 deletions credentials/apps/catalog/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,9 @@ class ProgramStatus(Enum):
RETIRED = "retired"
DELETED = "deleted"
UNPUBLISHED = "unpublished"


class PathwayStatus(Enum):
RETIRED = "retired"
UNPUBLISHED = "unpublished"
PUBLISHED = "published"
14 changes: 14 additions & 0 deletions credentials/apps/catalog/docs/decisions/0002-pathway-status.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
Pathway Status
==============

Status
------
Accepted

Background
----------
Course discovery is now allowing pathways to be retired. The `course-discovery` API will continue to expose retired pathways, and it will rely on consumers to expose or process retired pathways as appropriate.

Decision
--------
The catalog app will now synchronize the `Pathway`'s `status` attribute. In the absence of a populated `status` attribute, a pathway will be considered to have the status of `published`.
18 changes: 18 additions & 0 deletions credentials/apps/catalog/migrations/0016_pathway_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.19 on 2025-02-25 20:14

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("catalog", "0015_auto_20201130_2041"),
]

operations = [
migrations.AddField(
model_name="pathway",
name="status",
field=models.CharField(max_length=24, null=True),
),
]
4 changes: 4 additions & 0 deletions credentials/apps/catalog/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@ class Pathway(TimeStampedModel):
email = models.EmailField()
programs = SortedManyToManyField(Program, related_name="pathways")

# We're not migration-creating a status of all the old Pathways,
# we're just treating them as inherently 'published'.
status = models.CharField(max_length=24, null=True, blank=False)

class Meta:
unique_together = (("site", "uuid"),)

Expand Down
2 changes: 2 additions & 0 deletions credentials/apps/catalog/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from pytz import UTC
from slugify import slugify

from credentials.apps.catalog.data import PathwayStatus
from credentials.apps.catalog.models import Course, CourseRun, Organization, Pathway, Program
from credentials.apps.core.tests.factories import SiteFactory

Expand Down Expand Up @@ -90,6 +91,7 @@ class Meta:
name = FuzzyText(prefix="Test Pathway ")
org_name = FuzzyText()
email = factory.Faker("safe_email")
status = PathwayStatus.PUBLISHED.value

@factory.post_generation
def programs(self, create, extracted):
Expand Down
3 changes: 3 additions & 0 deletions credentials/apps/catalog/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ class SynchronizerTests(TestCase):
"name": "First Pathway",
"org_name": "First Org Name",
"email": "test@example.com",
"status": "published",
"programs": [FIRST_PROGRAM],
"pathway_type": PathwayType.INDUSTRY.value,
} # Check type is industry since type defaults to credit
Expand Down Expand Up @@ -150,6 +151,7 @@ def test_fetch_data_create(self):
# Check pathway
pathway = Pathway.objects.all().first()
assert str(pathway.uuid) == self.FIRST_PATHWAY["uuid"]
assert pathway.status == self.FIRST_PATHWAY["status"]
assert list(pathway.programs.all()) == [program]

def test_fetch_data_update(self):
Expand Down Expand Up @@ -202,6 +204,7 @@ def test_fetch_data_update(self):
# Check pathway still exists and has updated program
pathway = Pathway.objects.all().first()
assert str(pathway.uuid) == self.FIRST_PATHWAY["uuid"]
assert pathway.status == self.FIRST_PATHWAY["status"]
assert list(pathway.programs.all()) == [program]

def test_remove_obsolete_data(self):
Expand Down
15 changes: 10 additions & 5 deletions credentials/apps/catalog/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from django.db import transaction

from credentials.apps.catalog.data import PathwayStatus
from credentials.apps.catalog.models import Course, CourseRun, Organization, Pathway, Program


Expand Down Expand Up @@ -315,9 +316,11 @@ def _parse_course_run(self, course, data):
@transaction.atomic
def _parse_pathway(self, data):
"""
Creates or updates a pathway and links it to connected programs
Creates or updates a pathway and links it to connected programs.

Assumes that the associated programs were parsed before this is run.
* Assumes that the associated programs were parsed before this is run.
* Always re-creates the foreign keys between Pathway and Program on modification.
If the Pathway is retired or unpublished, no relationship is created

Arguments:
data (dict): The pathway data pulled from the API
Expand All @@ -332,15 +335,17 @@ def _parse_pathway(self, data):
"name": data["name"],
"email": data["email"],
"org_name": data["org_name"],
"status": data["status"],
"pathway_type": data["pathway_type"],
},
)

self.add_item(self.PATHWAY, str(pathway.uuid))

pathway.programs.clear()
for program_data in data["programs"]:
program = Program.objects.get(site=self.site, uuid=program_data["uuid"])
pathway.programs.add(program)
if pathway.status in ("", PathwayStatus.PUBLISHED.value):
for program_data in data["programs"]:
program = Program.objects.get(site=self.site, uuid=program_data["uuid"])
pathway.programs.add(program)

return pathway
7 changes: 6 additions & 1 deletion credentials/apps/records/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,12 @@ def _get_transformed_pathway_data(program, user):
program_pathways_set = frozenset(program_pathways)

user_credit_pathways = (
UserCreditPathway.objects.select_related("pathway").filter(user=user, pathway__in=program_pathways_set).all()
UserCreditPathway.objects.select_related("pathway")
.filter(
user=user,
pathway__in=program_pathways_set,
)
.all()
Comment on lines +103 to +108
Copy link
Contributor

Choose a reason for hiding this comment

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

Would we want to add a check on the status to be empty or published here?

I believe this would result is not including unpublished or retired pathways to a learner when fetching their program record details.

Copy link
Member Author

Choose a reason for hiding this comment

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

So the way I made the changes to copy_catalog, a retired or unpublished pathway is no longer linked to a program at all, which means that it can't make a UserCreditPathway. it doesn't delete old UserCreditPathway objects, but I don't think it should, because those are effectively audit logs, as I understand it. I suppose technically there could be a race condition where:

  1. The user has their learner record open so they can see the button to send the record to an un-retired pathway
  2. We retire the pathway while that page is open
  3. They press the button and the query comes back and returns no longer valid UserCreditPathway objects.

But I'm not sure I'm that worried about that race condition? What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think I'm worried about it too much, it seems like a narrow edge case. This sounds like a case that could only occur when the copy catalog mgmt command is running, as this is the only time Credentials currently updates its Discovery data.

)
# maps a learner's pathway status to a pathway
user_credit_pathways_dict = {user_pathway.pathway: user_pathway.status for user_pathway in user_credit_pathways}
Expand Down
16 changes: 16 additions & 0 deletions credentials/apps/records/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from django.test.utils import override_settings
from django.urls import reverse

from credentials.apps.catalog.data import PathwayStatus
from credentials.apps.catalog.tests.factories import (
CourseFactory,
CourseRunFactory,
Expand Down Expand Up @@ -105,6 +106,7 @@ def test_pcr_already_exists(self):
self.assertEqual(url1, url2)


@ddt.ddt
@override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend")
class ProgramSendTests(SiteMixin, TestCase):
USERNAME = "test-user"
Expand Down Expand Up @@ -208,6 +210,20 @@ def test_email_content_complete(self):
self.assertEqual(self.user.email, email.reply_to[0])
self.assertListEqual([self.pathway.email], email.to)

@ddt.data(
(PathwayStatus.PUBLISHED.value, 200),
(PathwayStatus.UNPUBLISHED.value, 404),
(PathwayStatus.RETIRED.value, 404),
("", 200),
)
@ddt.unpack
def test_pathway_must_be_published(self, pathway_status, http_status):
"""Verify a pathway only sends if its status is published or empty"""
self.pathway.status = pathway_status
self.pathway.save()
response = self.post()
self.assertEqual(response.status_code, http_status)

def test_email_content_incomplete(self):
"""Verify an email is actually sent"""
self.user_credential.delete()
Expand Down
2 changes: 2 additions & 0 deletions credentials/apps/records/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from edx_ace import Recipient, ace
from segment.analytics.client import Client as SegmentClient

from credentials.apps.catalog.data import PathwayStatus
from credentials.apps.catalog.models import Pathway, Program
from credentials.apps.core.api import get_user_by_username
from credentials.apps.core.views import ThemeViewMixin
Expand Down Expand Up @@ -150,6 +151,7 @@ def post(self, request, **kwargs):
Pathway,
id=pathway_id,
programs__uuid=program_uuid,
status__in=(PathwayStatus.PUBLISHED.value, ""),
pathway_type=PathwayType.CREDIT.value,
)
certificate = get_object_or_404(ProgramCertificate, program_uuid=program_uuid, site=request.site)
Expand Down