Skip to content

Commit 5ef8945

Browse files
Publish topic channels based on resource count (#1349)
* add channel published field * perform deletion during resource_delete_actions * make channel_url null if not published * fix bad rebase :( * add a data migration * return 404 for unpublished channels * add another test * fix typo (haha pun) * rename migration function * bump migration number * bump migration
1 parent 7aaaa40 commit 5ef8945

File tree

22 files changed

+324
-42
lines changed

22 files changed

+324
-42
lines changed

channels/factories.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class ChannelFactory(DjangoModelFactory):
2626

2727
name = factory.fuzzy.FuzzyText(length=21)
2828
title = factory.Faker("text", max_nb_chars=50)
29+
published = True
2930
public_description = factory.Faker("text", max_nb_chars=50)
3031
channel_type = factory.fuzzy.FuzzyChoice(ChannelType.names())
3132

@@ -116,6 +117,9 @@ class ChannelTopicDetailFactory(DjangoModelFactory):
116117
class Meta:
117118
model = ChannelTopicDetail
118119

120+
class Params:
121+
is_unpublished = factory.Trait(channel__published=False)
122+
119123

120124
class ChannelDepartmentDetailFactory(DjangoModelFactory):
121125
"""Factory for a channels.models.ChannelDepartmentDetail object"""
@@ -128,6 +132,9 @@ class ChannelDepartmentDetailFactory(DjangoModelFactory):
128132
class Meta:
129133
model = ChannelDepartmentDetail
130134

135+
class Params:
136+
is_unpublished = factory.Trait(channel__published=False)
137+
131138

132139
class ChannelUnitDetailFactory(DjangoModelFactory):
133140
"""Factory for a channels.models.ChannelUnitDetail object"""
@@ -138,6 +145,9 @@ class ChannelUnitDetailFactory(DjangoModelFactory):
138145
class Meta:
139146
model = ChannelUnitDetail
140147

148+
class Params:
149+
is_unpublished = factory.Trait(channel__published=False)
150+
141151

142152
class ChannelPathwayDetailFactory(DjangoModelFactory):
143153
"""Factory for a channels.models.ChannelPathwayDetail object"""
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Generated by Django 4.2.14 on 2024-08-06 14:39
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("channels", "0014_dept_detail_related_name"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="channel",
14+
name="published",
15+
field=models.BooleanField(db_index=True, default=True),
16+
),
17+
]

channels/models.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from django.contrib.auth.models import Group
66
from django.core.validators import RegexValidator
77
from django.db import models
8-
from django.db.models import JSONField, deletion
8+
from django.db.models import Case, JSONField, When, deletion
99
from django.db.models.functions import Concat
1010
from imagekit.models import ImageSpecField, ProcessedImageField
1111
from imagekit.processors import ResizeToFit
@@ -32,12 +32,18 @@ class ChannelQuerySet(TimestampedModelQuerySet):
3232
def annotate_channel_url(self):
3333
"""Annotate the channel for serialization"""
3434
return self.annotate(
35-
channel_url=Concat(
36-
models.Value(frontend_absolute_url("/c/")),
37-
"channel_type",
38-
models.Value("/"),
39-
"name",
40-
models.Value("/"),
35+
channel_url=Case(
36+
When(
37+
published=True,
38+
then=Concat(
39+
models.Value(frontend_absolute_url("/c/")),
40+
"channel_type",
41+
models.Value("/"),
42+
"name",
43+
models.Value("/"),
44+
),
45+
),
46+
default=None,
4147
)
4248
)
4349

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

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

117124
@cached_property
118-
def channel_url(self) -> str:
125+
def channel_url(self) -> str | None:
119126
"""Return the channel url"""
120-
return frontend_absolute_url(f"/c/{self.channel_type}/{self.name}/")
127+
if self.published:
128+
return frontend_absolute_url(f"/c/{self.channel_type}/{self.name}/")
129+
return None
121130

122131
class Meta:
123132
unique_together = ("name", "channel_type")

channels/models_test.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from urllib.parse import urlparse
2+
3+
import pytest
4+
5+
from channels.constants import ChannelType
6+
from channels.factories import (
7+
ChannelDepartmentDetailFactory,
8+
ChannelTopicDetailFactory,
9+
ChannelUnitDetailFactory,
10+
)
11+
12+
pytestmark = [pytest.mark.django_db]
13+
14+
15+
@pytest.mark.parametrize("published", [True, False])
16+
@pytest.mark.parametrize(
17+
(
18+
"channel_type",
19+
"detail_factory",
20+
),
21+
[
22+
(ChannelType.department, ChannelDepartmentDetailFactory),
23+
(ChannelType.topic, ChannelTopicDetailFactory),
24+
(ChannelType.unit, ChannelUnitDetailFactory),
25+
],
26+
)
27+
def test_channel_url_for_departments(published, channel_type, detail_factory):
28+
"""Test that the channel URL is correct for department channels"""
29+
channel = detail_factory.create(
30+
channel__published=published,
31+
).channel
32+
33+
if published:
34+
assert (
35+
urlparse(channel.channel_url).path
36+
== f"/c/{channel_type.name}/{channel.name}/"
37+
)
38+
else:
39+
assert channel.channel_url is None

channels/plugins.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,29 @@
1212
ChannelTopicDetail,
1313
ChannelUnitDetail,
1414
)
15+
from learning_resources.models import LearningResource
16+
17+
18+
def unpublish_topics_for_resource(resource):
19+
"""
20+
Unpublish channels for topics that are used exclusively by the resource
21+
22+
Args:
23+
resource(LearningResource): The resource that was unpublished
24+
"""
25+
other_published = LearningResource.objects.filter(
26+
published=True,
27+
).exclude(id=resource.id)
28+
29+
channels = Channel.objects.filter(
30+
topic_detail__topic__in=resource.topics.all(),
31+
channel_type=ChannelType.topic.name, # Redundant, but left for clarity
32+
published=True,
33+
).exclude(topic_detail__topic__learningresource__in=other_published)
34+
35+
for channel in channels:
36+
channel.published = False
37+
channel.save()
1538

1639

1740
class ChannelPlugin:
@@ -140,3 +163,29 @@ def offeror_delete(self, offeror):
140163
"""
141164
Channel.objects.filter(unit_detail__unit=offeror).delete()
142165
offeror.delete()
166+
167+
@hookimpl
168+
def resource_upserted(self, resource, percolate): # noqa: ARG002
169+
"""
170+
Publish channels for the resource's topics
171+
"""
172+
channels = Channel.objects.filter(
173+
topic_detail__topic__in=resource.topics.all(), published=False
174+
)
175+
for channel in channels:
176+
channel.published = True
177+
channel.save()
178+
179+
@hookimpl
180+
def resource_before_delete(self, resource):
181+
"""
182+
Unpublish channels for the resource's topics
183+
"""
184+
unpublish_topics_for_resource(resource)
185+
186+
@hookimpl
187+
def resource_unpublished(self, resource):
188+
"""
189+
Unpublish channels for the resource's topics
190+
"""
191+
unpublish_topics_for_resource(resource)

channels/plugins_test.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,16 @@
33
import pytest
44

55
from channels.constants import ChannelType
6-
from channels.factories import ChannelDepartmentDetailFactory, ChannelFactory
6+
from channels.factories import (
7+
ChannelDepartmentDetailFactory,
8+
ChannelFactory,
9+
ChannelTopicDetailFactory,
10+
)
711
from channels.models import Channel
812
from channels.plugins import ChannelPlugin
913
from learning_resources.factories import (
1014
LearningResourceDepartmentFactory,
15+
LearningResourceFactory,
1116
LearningResourceOfferorFactory,
1217
LearningResourceSchoolFactory,
1318
LearningResourceTopicFactory,
@@ -140,3 +145,83 @@ def test_search_index_plugin_offeror_delete():
140145
ChannelPlugin().offeror_delete(offeror)
141146
assert Channel.objects.filter(id=channel.id).exists() is False
142147
assert LearningResourceOfferor.objects.filter(code=offeror.code).exists() is False
148+
149+
150+
@pytest.mark.parametrize("action", ["delete", "unpublish"])
151+
@pytest.mark.parametrize(
152+
("published_resources", "to_remove", "expect_channel_published"),
153+
[
154+
(2, 0, True), # 2 published resources remain
155+
(2, 1, True), # 1 published resources remain
156+
(2, 2, False), # 0 published resource remains
157+
],
158+
)
159+
@pytest.mark.django_db()
160+
def test_resource_before_delete_and_resource_unpublish(
161+
action, published_resources, to_remove, expect_channel_published
162+
):
163+
"""
164+
Test that topic channels are unpublished when they no longer have any resources
165+
remaining.
166+
"""
167+
topic1 = LearningResourceTopicFactory.create() # for to-be-deleted resources
168+
topic2 = LearningResourceTopicFactory.create() # for to-be-deleted & others
169+
topic3 = LearningResourceTopicFactory.create() # for to-be-deleted resources
170+
detail1 = ChannelTopicDetailFactory.create(topic=topic1)
171+
detail2 = ChannelTopicDetailFactory.create(topic=topic2)
172+
detail3 = ChannelTopicDetailFactory.create(topic=topic3)
173+
channel1, channel2, channel3 = detail1.channel, detail2.channel, detail3.channel
174+
175+
resources_in_play = LearningResourceFactory.create_batch(
176+
published_resources,
177+
topics=[topic1, topic2, topic3],
178+
)
179+
180+
# Create extra published + unpublished resources to ensure topic2 sticks around
181+
LearningResourceFactory.create(topics=[topic2]) # extra resources
182+
183+
assert channel1.published
184+
assert channel2.published
185+
assert channel3.published
186+
187+
for resource in resources_in_play[:to_remove]:
188+
if action == "delete":
189+
ChannelPlugin().resource_before_delete(resource)
190+
resource.delete()
191+
elif action == "unpublish":
192+
resource.published = False
193+
resource.save()
194+
ChannelPlugin().resource_unpublished(resource)
195+
else:
196+
msg = ValueError(f"Invalid action {action}")
197+
raise msg
198+
199+
channel1.refresh_from_db()
200+
channel2.refresh_from_db()
201+
channel3.refresh_from_db()
202+
assert channel1.published is expect_channel_published
203+
assert channel2.published is True
204+
assert channel3.published is expect_channel_published
205+
206+
207+
@pytest.mark.django_db()
208+
def test_resource_upserted():
209+
"""
210+
Test that channels are published when a resource is created or updated
211+
"""
212+
channel1 = ChannelFactory.create(is_topic=True, published=False)
213+
channel2 = ChannelFactory.create(is_topic=True, published=False)
214+
channel3 = ChannelFactory.create(is_topic=True, published=False)
215+
216+
resource = LearningResourceFactory.create(
217+
topics=[channel1.topic_detail.topic, channel2.topic_detail.topic]
218+
)
219+
ChannelPlugin().resource_upserted(resource, None)
220+
221+
channel1.refresh_from_db()
222+
channel2.refresh_from_db()
223+
channel3.refresh_from_db()
224+
225+
assert channel1.published is True
226+
assert channel2.published is True
227+
assert channel3.published is False

channels/serializers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ def get_channel_url(self, instance) -> str:
157157

158158
class Meta:
159159
model = Channel
160-
exclude = []
160+
exclude = ["published"]
161161

162162

163163
class ChannelTopicDetailSerializer(serializers.ModelSerializer):

channels/views.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ def get_serializer_context(self):
8888
def get_queryset(self):
8989
"""Return a queryset"""
9090
return (
91-
Channel.objects.prefetch_related(
91+
Channel.objects.filter(published=True)
92+
.prefetch_related(
9293
Prefetch(
9394
"lists",
9495
queryset=ChannelList.objects.prefetch_related(
@@ -144,6 +145,7 @@ def get_object(self):
144145
Channel,
145146
channel_type=self.kwargs["channel_type"],
146147
name=self.kwargs["name"],
148+
published=True,
147149
)
148150

149151

channels/views_test.py

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,14 @@
2828

2929
def test_list_channels(user_client):
3030
"""Test that all channels are returned"""
31-
channels = sorted(ChannelFactory.create_batch(10), key=lambda f: f.id)
31+
ChannelFactory.create_batch(2, published=False) # should be filtered out
32+
channels = sorted(ChannelFactory.create_batch(3), key=lambda f: f.id)
33+
ChannelFactory.create_batch(2, published=False) # should be filtered out
34+
3235
url = reverse("channels:v0:channels_api-list")
3336
channel_list = sorted(user_client.get(url).json()["results"], key=lambda f: f["id"])
34-
assert len(channel_list) == 10
35-
for idx, channel in enumerate(channels[:10]):
37+
assert len(channel_list) == 3
38+
for idx, channel in enumerate(channels[:3]):
3639
assert channel_list[idx] == ChannelSerializer(instance=channel).data
3740

3841

@@ -206,20 +209,26 @@ def test_patch_channel_image(client, channel, attribute):
206209
assert len(size_image.read()) > 0
207210

208211

209-
def test_channel_by_type_name_detail(user_client):
212+
@pytest.mark.parametrize(
213+
("published", "requested_type", "response_status"),
214+
[
215+
(True, ChannelType.topic, 200),
216+
(False, ChannelType.topic, 404),
217+
(True, ChannelType.department, 404),
218+
(False, ChannelType.department, 404),
219+
],
220+
)
221+
def test_channel_by_type_name_detail(
222+
user_client, published, requested_type, response_status
223+
):
210224
"""ChannelByTypeNameDetailView should return expected result"""
211-
channel = ChannelFactory.create(is_topic=True)
225+
channel = ChannelFactory.create(is_topic=True, published=published)
212226
url = reverse(
213227
"channels:v0:channel_by_type_name_api-detail",
214-
kwargs={"channel_type": ChannelType.topic.name, "name": channel.name},
215-
)
216-
response = user_client.get(url)
217-
assert response.json() == ChannelSerializer(instance=channel).data
218-
Channel.objects.filter(id=channel.id).update(
219-
channel_type=ChannelType.department.name
228+
kwargs={"channel_type": requested_type.name, "name": channel.name},
220229
)
221230
response = user_client.get(url)
222-
assert response.status_code == 404
231+
assert response.status_code == response_status
223232

224233

225234
def test_update_channel_forbidden(channel, user_client):

0 commit comments

Comments
 (0)