Skip to content
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ jobs:
cache: "poetry"

- name: Validate lockfile
run: poetry lock --check
run: poetry check --lock

- name: Install dependencies
run: poetry install --no-interaction
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ jobs:
- name: Install frontend dependencies
run: yarn install --immutable

- name: Set VERSION
run: echo "VERSION=$(./scripts/get_version.sh)" >> $GITHUB_ENV

- name: Build frontend
run: NODE_ENV=production yarn build
env:
Expand All @@ -40,6 +43,8 @@ jobs:
POSTHOG_TIMEOUT_MS: 1000
POSTHOG_PROJECT_ID: ${{ secrets.POSTHOG_PROJECT_ID_PROD }}
POSTHOG_PROJECT_API_KEY: ${{ secrets.POSTHOG_PROJECT_API_KEY_PROD }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN_PROD }}
SENTRY_ENV: ${{ secrets.MITOPEN_ENV_PROD }}
MITOPEN_AXIOS_WITH_CREDENTIALS: true
MITOPEN_API_BASE_URL: https://api.mitopen.odl.mit.edu
MITOPEN_SUPPORT_EMAIL: mitopen-support@mit.edu
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/release-candidate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ jobs:
- name: Install frontend dependencies
run: yarn install --immutable

- name: Set VERSION
run: echo "VERSION=$(./scripts/get_version.sh)" >> $GITHUB_ENV

- name: Build frontend
run: NODE_ENV=production yarn build
env:
Expand All @@ -40,6 +43,8 @@ jobs:
POSTHOG_TIMEOUT_MS: 1000
POSTHOG_PROJECT_ID: ${{ secrets.POSTHOG_PROJECT_ID_RC }}
POSTHOG_PROJECT_API_KEY: ${{ secrets.POSTHOG_PROJECT_API_KEY_RC }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN_RC }}
SENTRY_ENV: ${{ secrets.MITOPEN_ENV_RC }}
MITOPEN_AXIOS_WITH_CREDENTIALS: true
MITOPEN_API_BASE_URL: https://api.mitopen-rc.odl.mit.edu
MITOPEN_SUPPORT_EMAIL: odl-mitopen-rc-support@mit.edu
Expand Down
20 changes: 20 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
Release Notes
=============

Version 0.15.1
--------------

- Update dependency redis to v5 (#1244)
- sort before comparing (#1372)
- Rename my 0008 to 0009 to prevent conflict (#1374)
- Add Manufacturing topic; update upserter to make adding child topics easier (#1364)
- Publish topic channels based on resource count (#1349)
- Update dependency opensearch-dsl to v2 (#1242)
- Update dependency opensearch-py to v2 (#1243)
- Fix issue with User List cards not updating on edit (#1361)
- Update dependency django-anymail to v11 (#1207)
- Update CI to check data migrations for conflicts (#1368)
- Fix frontend sentry configuration (#1362)
- Migrate config renovate.json (#1367)
- Update dependency sentry-sdk to v2 [SECURITY] (#1366)
- add a bullet about collecting demographics to PrivacyPage.tsx (#1355)
- user list UI updates (#1348)
- Subscription email template updates (#1311)

Version 0.15.0 (Released August 05, 2024)
--------------

Expand Down
10 changes: 10 additions & 0 deletions channels/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class ChannelFactory(DjangoModelFactory):

name = factory.fuzzy.FuzzyText(length=21)
title = factory.Faker("text", max_nb_chars=50)
published = True
public_description = factory.Faker("text", max_nb_chars=50)
channel_type = factory.fuzzy.FuzzyChoice(ChannelType.names())

Expand Down Expand Up @@ -116,6 +117,9 @@ class ChannelTopicDetailFactory(DjangoModelFactory):
class Meta:
model = ChannelTopicDetail

class Params:
is_unpublished = factory.Trait(channel__published=False)


class ChannelDepartmentDetailFactory(DjangoModelFactory):
"""Factory for a channels.models.ChannelDepartmentDetail object"""
Expand All @@ -128,6 +132,9 @@ class ChannelDepartmentDetailFactory(DjangoModelFactory):
class Meta:
model = ChannelDepartmentDetail

class Params:
is_unpublished = factory.Trait(channel__published=False)


class ChannelUnitDetailFactory(DjangoModelFactory):
"""Factory for a channels.models.ChannelUnitDetail object"""
Expand All @@ -138,6 +145,9 @@ class ChannelUnitDetailFactory(DjangoModelFactory):
class Meta:
model = ChannelUnitDetail

class Params:
is_unpublished = factory.Trait(channel__published=False)


class ChannelPathwayDetailFactory(DjangoModelFactory):
"""Factory for a channels.models.ChannelPathwayDetail object"""
Expand Down
17 changes: 17 additions & 0 deletions channels/migrations/0015_channel_published.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 4.2.14 on 2024-08-06 14:39

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("channels", "0014_dept_detail_related_name"),
]

operations = [
migrations.AddField(
model_name="channel",
name="published",
field=models.BooleanField(db_index=True, default=True),
),
]
27 changes: 18 additions & 9 deletions channels/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from django.contrib.auth.models import Group
from django.core.validators import RegexValidator
from django.db import models
from django.db.models import JSONField, deletion
from django.db.models import Case, JSONField, When, deletion
from django.db.models.functions import Concat
from imagekit.models import ImageSpecField, ProcessedImageField
from imagekit.processors import ResizeToFit
Expand All @@ -32,12 +32,18 @@ class ChannelQuerySet(TimestampedModelQuerySet):
def annotate_channel_url(self):
"""Annotate the channel for serialization"""
return self.annotate(
channel_url=Concat(
models.Value(frontend_absolute_url("/c/")),
"channel_type",
models.Value("/"),
"name",
models.Value("/"),
channel_url=Case(
When(
published=True,
then=Concat(
models.Value(frontend_absolute_url("/c/")),
"channel_type",
models.Value("/"),
"name",
models.Value("/"),
),
),
default=None,
)
)

Expand Down Expand Up @@ -95,6 +101,7 @@ class Channel(TimestampedModel):
configuration = models.JSONField(null=True, default=dict, blank=True)
search_filter = models.CharField(max_length=2048, blank=True, default="")
public_description = models.TextField(blank=True, default="")
published = models.BooleanField(default=True, db_index=True)

featured_list = models.ForeignKey(
LearningResource, null=True, blank=True, on_delete=deletion.SET_NULL
Expand All @@ -115,9 +122,11 @@ def __str__(self):
return self.title

@cached_property
def channel_url(self) -> str:
def channel_url(self) -> str | None:
"""Return the channel url"""
return frontend_absolute_url(f"/c/{self.channel_type}/{self.name}/")
if self.published:
return frontend_absolute_url(f"/c/{self.channel_type}/{self.name}/")
return None

class Meta:
unique_together = ("name", "channel_type")
Expand Down
39 changes: 39 additions & 0 deletions channels/models_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from urllib.parse import urlparse

import pytest

from channels.constants import ChannelType
from channels.factories import (
ChannelDepartmentDetailFactory,
ChannelTopicDetailFactory,
ChannelUnitDetailFactory,
)

pytestmark = [pytest.mark.django_db]


@pytest.mark.parametrize("published", [True, False])
@pytest.mark.parametrize(
(
"channel_type",
"detail_factory",
),
[
(ChannelType.department, ChannelDepartmentDetailFactory),
(ChannelType.topic, ChannelTopicDetailFactory),
(ChannelType.unit, ChannelUnitDetailFactory),
],
)
def test_channel_url_for_departments(published, channel_type, detail_factory):
"""Test that the channel URL is correct for department channels"""
channel = detail_factory.create(
channel__published=published,
).channel

if published:
assert (
urlparse(channel.channel_url).path
== f"/c/{channel_type.name}/{channel.name}/"
)
else:
assert channel.channel_url is None
49 changes: 49 additions & 0 deletions channels/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,29 @@
ChannelTopicDetail,
ChannelUnitDetail,
)
from learning_resources.models import LearningResource


def unpublish_topics_for_resource(resource):
"""
Unpublish channels for topics that are used exclusively by the resource

Args:
resource(LearningResource): The resource that was unpublished
"""
other_published = LearningResource.objects.filter(
published=True,
).exclude(id=resource.id)

channels = Channel.objects.filter(
topic_detail__topic__in=resource.topics.all(),
channel_type=ChannelType.topic.name, # Redundant, but left for clarity
published=True,
).exclude(topic_detail__topic__learningresource__in=other_published)

for channel in channels:
channel.published = False
channel.save()


class ChannelPlugin:
Expand Down Expand Up @@ -140,3 +163,29 @@ def offeror_delete(self, offeror):
"""
Channel.objects.filter(unit_detail__unit=offeror).delete()
offeror.delete()

@hookimpl
def resource_upserted(self, resource, percolate): # noqa: ARG002
"""
Publish channels for the resource's topics
"""
channels = Channel.objects.filter(
topic_detail__topic__in=resource.topics.all(), published=False
)
for channel in channels:
channel.published = True
channel.save()

@hookimpl
def resource_before_delete(self, resource):
"""
Unpublish channels for the resource's topics
"""
unpublish_topics_for_resource(resource)

@hookimpl
def resource_unpublished(self, resource):
"""
Unpublish channels for the resource's topics
"""
unpublish_topics_for_resource(resource)
87 changes: 86 additions & 1 deletion channels/plugins_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@
import pytest

from channels.constants import ChannelType
from channels.factories import ChannelDepartmentDetailFactory, ChannelFactory
from channels.factories import (
ChannelDepartmentDetailFactory,
ChannelFactory,
ChannelTopicDetailFactory,
)
from channels.models import Channel
from channels.plugins import ChannelPlugin
from learning_resources.factories import (
LearningResourceDepartmentFactory,
LearningResourceFactory,
LearningResourceOfferorFactory,
LearningResourceSchoolFactory,
LearningResourceTopicFactory,
Expand Down Expand Up @@ -140,3 +145,83 @@ def test_search_index_plugin_offeror_delete():
ChannelPlugin().offeror_delete(offeror)
assert Channel.objects.filter(id=channel.id).exists() is False
assert LearningResourceOfferor.objects.filter(code=offeror.code).exists() is False


@pytest.mark.parametrize("action", ["delete", "unpublish"])
@pytest.mark.parametrize(
("published_resources", "to_remove", "expect_channel_published"),
[
(2, 0, True), # 2 published resources remain
(2, 1, True), # 1 published resources remain
(2, 2, False), # 0 published resource remains
],
)
@pytest.mark.django_db()
def test_resource_before_delete_and_resource_unpublish(
action, published_resources, to_remove, expect_channel_published
):
"""
Test that topic channels are unpublished when they no longer have any resources
remaining.
"""
topic1 = LearningResourceTopicFactory.create() # for to-be-deleted resources
topic2 = LearningResourceTopicFactory.create() # for to-be-deleted & others
topic3 = LearningResourceTopicFactory.create() # for to-be-deleted resources
detail1 = ChannelTopicDetailFactory.create(topic=topic1)
detail2 = ChannelTopicDetailFactory.create(topic=topic2)
detail3 = ChannelTopicDetailFactory.create(topic=topic3)
channel1, channel2, channel3 = detail1.channel, detail2.channel, detail3.channel

resources_in_play = LearningResourceFactory.create_batch(
published_resources,
topics=[topic1, topic2, topic3],
)

# Create extra published + unpublished resources to ensure topic2 sticks around
LearningResourceFactory.create(topics=[topic2]) # extra resources

assert channel1.published
assert channel2.published
assert channel3.published

for resource in resources_in_play[:to_remove]:
if action == "delete":
ChannelPlugin().resource_before_delete(resource)
resource.delete()
elif action == "unpublish":
resource.published = False
resource.save()
ChannelPlugin().resource_unpublished(resource)
else:
msg = ValueError(f"Invalid action {action}")
raise msg

channel1.refresh_from_db()
channel2.refresh_from_db()
channel3.refresh_from_db()
assert channel1.published is expect_channel_published
assert channel2.published is True
assert channel3.published is expect_channel_published


@pytest.mark.django_db()
def test_resource_upserted():
"""
Test that channels are published when a resource is created or updated
"""
channel1 = ChannelFactory.create(is_topic=True, published=False)
channel2 = ChannelFactory.create(is_topic=True, published=False)
channel3 = ChannelFactory.create(is_topic=True, published=False)

resource = LearningResourceFactory.create(
topics=[channel1.topic_detail.topic, channel2.topic_detail.topic]
)
ChannelPlugin().resource_upserted(resource, None)

channel1.refresh_from_db()
channel2.refresh_from_db()
channel3.refresh_from_db()

assert channel1.published is True
assert channel2.published is True
assert channel3.published is False
2 changes: 1 addition & 1 deletion channels/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ def get_channel_url(self, instance) -> str:

class Meta:
model = Channel
exclude = []
exclude = ["published"]


class ChannelTopicDetailSerializer(serializers.ModelSerializer):
Expand Down
Loading