refactor: Refactor upstream links summary to add the top-level parent logic [FC-0097] (#37208)
- Refactor upstream links summary to add the top-level parent logic - Update the `filter_links` function to annotate each result with `ready_to_sync_from_children`
This commit is contained in:
@@ -7,8 +7,8 @@ from itertools import chain
|
||||
|
||||
from config_models.models import ConfigurationModel
|
||||
from django.db import models
|
||||
from django.db.models import Count, F, Q, QuerySet, Max
|
||||
from django.db.models.fields import IntegerField, TextField
|
||||
from django.db.models import QuerySet, OuterRef, Case, When, Exists, Value, ExpressionWrapper
|
||||
from django.db.models.fields import IntegerField, TextField, BooleanField
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models.lookups import GreaterThan
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -111,6 +111,20 @@ class EntityLinkBase(models.Model):
|
||||
created = manual_date_time_field()
|
||||
updated = manual_date_time_field()
|
||||
|
||||
@property
|
||||
def upstream_context_title(self) -> str:
|
||||
"""
|
||||
Returns upstream context title.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def published_at(self) -> str | None:
|
||||
"""
|
||||
Returns the published date of the entity
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@@ -157,6 +171,15 @@ class ComponentLink(EntityLinkBase):
|
||||
"""
|
||||
return self.upstream_block.publishable_entity.learning_package.title
|
||||
|
||||
@property
|
||||
def published_at(self) -> str | None:
|
||||
"""
|
||||
Returns the published date of the component
|
||||
"""
|
||||
if self.upstream_block.publishable_entity.published is None:
|
||||
raise AttributeError(_("The component must be published to access `published_at`"))
|
||||
return self.upstream_block.publishable_entity.published.publish_log_record.publish_log.published_at
|
||||
|
||||
@classmethod
|
||||
def filter_links(
|
||||
cls,
|
||||
@@ -189,7 +212,9 @@ class ComponentLink(EntityLinkBase):
|
||||
Coalesce("upstream_block__publishable_entity__published__version__version_num", 0),
|
||||
Coalesce("version_declined", 0)
|
||||
)
|
||||
)
|
||||
),
|
||||
# This is alwys False, the components doens't have children
|
||||
ready_to_sync_from_children=Value(False, output_field=BooleanField())
|
||||
)
|
||||
if ready_to_sync is not None:
|
||||
result = result.filter(ready_to_sync=ready_to_sync)
|
||||
@@ -216,40 +241,6 @@ class ComponentLink(EntityLinkBase):
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def summarize_by_downstream_context(cls, downstream_context_key: CourseKey) -> QuerySet:
|
||||
"""
|
||||
Returns a summary of links by upstream context for given downstream_context_key.
|
||||
Example:
|
||||
[
|
||||
{
|
||||
"upstream_context_title": "CS problems 3",
|
||||
"upstream_context_key": "lib:OpenedX:CSPROB3",
|
||||
"ready_to_sync_count": 11,
|
||||
"total_count": 14,
|
||||
"last_published_at": "2025-05-02T20:20:44.989042Z"
|
||||
},
|
||||
{
|
||||
"upstream_context_title": "CS problems 2",
|
||||
"upstream_context_key": "lib:OpenedX:CSPROB2",
|
||||
"ready_to_sync_count": 15,
|
||||
"total_count": 24,
|
||||
"last_published_at": "2025-05-03T21:20:44.989042Z"
|
||||
},
|
||||
]
|
||||
"""
|
||||
result = cls.filter_links(downstream_context_key=downstream_context_key).values(
|
||||
"upstream_context_key",
|
||||
upstream_context_title=F("upstream_block__publishable_entity__learning_package__title"),
|
||||
).annotate(
|
||||
ready_to_sync_count=Count("id", Q(ready_to_sync=True)),
|
||||
total_count=Count("id"),
|
||||
last_published_at=Max(
|
||||
"upstream_block__publishable_entity__published__publish_log_record__publish_log__published_at"
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def update_or_create(
|
||||
cls,
|
||||
@@ -351,6 +342,15 @@ class ContainerLink(EntityLinkBase):
|
||||
"""
|
||||
return self.upstream_container.publishable_entity.learning_package.title
|
||||
|
||||
@property
|
||||
def published_at(self) -> str | None:
|
||||
"""
|
||||
Returns the published date of the container
|
||||
"""
|
||||
if self.upstream_container.publishable_entity.published is None:
|
||||
raise AttributeError(_("The container must be published to access `published_at`"))
|
||||
return self.upstream_container.publishable_entity.published.publish_log_record.publish_log.published_at
|
||||
|
||||
@classmethod
|
||||
def filter_links(
|
||||
cls,
|
||||
@@ -402,6 +402,54 @@ class ContainerLink(EntityLinkBase):
|
||||
|
||||
@classmethod
|
||||
def _annotate_query_with_ready_to_sync(cls, query_set: QuerySet["EntityLinkBase"]) -> QuerySet["EntityLinkBase"]:
|
||||
"""
|
||||
Adds ready to sync related values to the query set:
|
||||
* `ready_to_sync`: When the container is ready to sync.
|
||||
* `ready_to_sync_from_children`: When any children is ready to sync.
|
||||
"""
|
||||
# SubQuery to verify if some container children (associated with top-level parent)
|
||||
# needs sync.
|
||||
subq_container = cls.objects.filter(
|
||||
top_level_parent=OuterRef('pk')
|
||||
).annotate(
|
||||
child_ready=Case(
|
||||
When(
|
||||
GreaterThan(
|
||||
Coalesce("upstream_container__publishable_entity__published__version__version_num", 0),
|
||||
Coalesce("version_synced", 0)
|
||||
) & GreaterThan(
|
||||
Coalesce("upstream_container__publishable_entity__published__version__version_num", 0),
|
||||
Coalesce("version_declined", 0)
|
||||
),
|
||||
then=1
|
||||
),
|
||||
default=0,
|
||||
output_field=models.IntegerField()
|
||||
)
|
||||
).filter(child_ready=1)
|
||||
|
||||
# SubQuery to verify if some component children (assisiated with top-level parent)
|
||||
# needs sync.
|
||||
subq_components = ComponentLink.objects.filter(
|
||||
top_level_parent=OuterRef('pk')
|
||||
).annotate(
|
||||
child_ready=Case(
|
||||
When(
|
||||
GreaterThan(
|
||||
Coalesce("upstream_block__publishable_entity__published__version__version_num", 0),
|
||||
Coalesce("version_synced", 0)
|
||||
) & GreaterThan(
|
||||
Coalesce("upstream_block__publishable_entity__published__version__version_num", 0),
|
||||
Coalesce("version_declined", 0)
|
||||
),
|
||||
then=1
|
||||
),
|
||||
default=0,
|
||||
output_field=models.IntegerField()
|
||||
)
|
||||
).filter(child_ready=1)
|
||||
|
||||
# TODO: is there a way to run `subq_container` or `subq_components` depending on the container type?
|
||||
return query_set.annotate(
|
||||
ready_to_sync=(
|
||||
GreaterThan(
|
||||
@@ -411,43 +459,13 @@ class ContainerLink(EntityLinkBase):
|
||||
Coalesce("upstream_container__publishable_entity__published__version__version_num", 0),
|
||||
Coalesce("version_declined", 0)
|
||||
)
|
||||
)
|
||||
),
|
||||
ready_to_sync_from_children=ExpressionWrapper(
|
||||
Exists(subq_container) | Exists(subq_components),
|
||||
output_field=BooleanField(),
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def summarize_by_downstream_context(cls, downstream_context_key: CourseKey) -> QuerySet:
|
||||
"""
|
||||
Returns a summary of links by upstream context for given downstream_context_key.
|
||||
Example:
|
||||
[
|
||||
{
|
||||
"upstream_context_title": "CS problems 3",
|
||||
"upstream_context_key": "lib:OpenedX:CSPROB3",
|
||||
"ready_to_sync_count": 11,
|
||||
"total_count": 14,
|
||||
"last_published_at": "2025-05-02T20:20:44.989042Z"
|
||||
},
|
||||
{
|
||||
"upstream_context_title": "CS problems 2",
|
||||
"upstream_context_key": "lib:OpenedX:CSPROB2",
|
||||
"ready_to_sync_count": 15,
|
||||
"total_count": 24,
|
||||
"last_published_at": "2025-05-03T21:20:44.989042Z"
|
||||
},
|
||||
]
|
||||
"""
|
||||
result = cls.filter_links(downstream_context_key=downstream_context_key).values(
|
||||
"upstream_context_key",
|
||||
upstream_context_title=F("upstream_container__publishable_entity__learning_package__title"),
|
||||
).annotate(
|
||||
ready_to_sync_count=Count("id", Q(ready_to_sync=True)),
|
||||
total_count=Count('id'),
|
||||
last_published_at=Max(
|
||||
"upstream_container__publishable_entity__published__publish_log_record__publish_log__published_at"
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def update_or_create(
|
||||
cls,
|
||||
|
||||
@@ -14,6 +14,7 @@ class ComponentLinksSerializer(serializers.ModelSerializer):
|
||||
upstream_context_title = serializers.CharField(read_only=True)
|
||||
upstream_version = serializers.IntegerField(read_only=True, source="upstream_version_num")
|
||||
ready_to_sync = serializers.BooleanField()
|
||||
ready_to_sync_from_children = serializers.BooleanField()
|
||||
top_level_parent_usage_key = serializers.CharField(
|
||||
source='top_level_parent.downstream_usage_key',
|
||||
read_only=True,
|
||||
@@ -43,6 +44,7 @@ class ContainerLinksSerializer(serializers.ModelSerializer):
|
||||
upstream_context_title = serializers.CharField(read_only=True)
|
||||
upstream_version = serializers.IntegerField(read_only=True, source="upstream_version_num")
|
||||
ready_to_sync = serializers.BooleanField()
|
||||
ready_to_sync_from_children = serializers.BooleanField()
|
||||
top_level_parent_usage_key = serializers.CharField(
|
||||
source='top_level_parent.downstream_usage_key',
|
||||
read_only=True,
|
||||
|
||||
@@ -239,6 +239,8 @@ class DownstreamListView(DeveloperErrorViewMixin, APIView):
|
||||
raise ValidationError(detail=f"Malformed key: {upstream_key}") from exc
|
||||
links: list[EntityLinkBase] | QuerySet[EntityLinkBase] = []
|
||||
if item_type is None or item_type == 'all':
|
||||
# itertools.chain() efficiently concatenates multiple iterables into one iterator,
|
||||
# yielding items from each in sequence without creating intermediate lists.
|
||||
links = list(chain(
|
||||
ComponentLink.filter_links(**link_filter),
|
||||
ContainerLink.filter_links(**link_filter)
|
||||
@@ -346,34 +348,84 @@ class DownstreamSummaryView(DeveloperErrorViewMixin, APIView):
|
||||
course_key = CourseKey.from_string(course_key_string)
|
||||
except InvalidKeyError as exc:
|
||||
raise ValidationError(detail=f"Malformed course key: {course_key_string}") from exc
|
||||
component_links = ComponentLink.summarize_by_downstream_context(downstream_context_key=course_key)
|
||||
container_links = ContainerLink.summarize_by_downstream_context(downstream_context_key=course_key)
|
||||
|
||||
merged = {}
|
||||
if not has_studio_read_access(request.user, course_key):
|
||||
raise PermissionDenied
|
||||
|
||||
def process_list(lst):
|
||||
"""
|
||||
Process a list to merge it with values in `merged`
|
||||
"""
|
||||
for item in lst:
|
||||
key = item["upstream_context_key"]
|
||||
if key not in merged:
|
||||
merged[key] = item.copy()
|
||||
else:
|
||||
merged[key]["ready_to_sync_count"] += item["ready_to_sync_count"]
|
||||
merged[key]["total_count"] += item["total_count"]
|
||||
if item["last_published_at"] > merged[key]["last_published_at"]:
|
||||
merged[key]["last_published_at"] = item["last_published_at"]
|
||||
# Gets all links of the Course, using the
|
||||
# top-level parents filter (see `filter_links()` for more info about top-level parents).
|
||||
# `itertools.chain()` efficiently concatenates multiple iterables into one iterator,
|
||||
# yielding items from each in sequence without creating intermediate lists.
|
||||
links = list(chain(
|
||||
ComponentLink.filter_links(
|
||||
downstream_context_key=course_key,
|
||||
use_top_level_parents=True,
|
||||
),
|
||||
ContainerLink.filter_links(
|
||||
downstream_context_key=course_key,
|
||||
use_top_level_parents=True,
|
||||
),
|
||||
))
|
||||
|
||||
# Merge `component_links` and `container_links` by adding the values of
|
||||
# `ready_to_sync_count` and `total_count` of each library.
|
||||
process_list(component_links)
|
||||
process_list(container_links)
|
||||
# Delete duplicates. From `ComponentLink` and `ContainerLink`
|
||||
# repeated containers may come in this case:
|
||||
# If we have a `Unit A` and a `Component B`, if you update and publish
|
||||
# both, form `ComponentLink` and `ContainerLink` you get the same `Unit A`.
|
||||
links = self._remove_duplicates(links)
|
||||
result = {}
|
||||
|
||||
links = list(merged.values())
|
||||
serializer = PublishableEntityLinksSummarySerializer(links, many=True)
|
||||
for link in links:
|
||||
# We iterate each list to do the counting by Library (`context_key`)
|
||||
context_key = link.upstream_context_key
|
||||
|
||||
if context_key not in result:
|
||||
result[context_key] = {
|
||||
"upstream_context_key": context_key,
|
||||
"upstream_context_title": link.upstream_context_title,
|
||||
"ready_to_sync_count": 0,
|
||||
"total_count": 0,
|
||||
"last_published_at": None,
|
||||
}
|
||||
|
||||
# Total count
|
||||
result[context_key]["total_count"] += 1
|
||||
|
||||
# Ready to sync count, it also checks if the container has
|
||||
# descendants that need sync (`ready_to_sync_from_children`).
|
||||
if link.ready_to_sync or link.ready_to_sync_from_children: # type: ignore[attr-defined]
|
||||
result[context_key]["ready_to_sync_count"] += 1
|
||||
|
||||
# The Max `published_at` value
|
||||
# An AttributeError may be thrown if copied/pasted an unpublished item from library to course.
|
||||
# That case breaks all the course library sync page.
|
||||
# TODO: Delete this `try` after avoid copy/paster unpublished items.
|
||||
try:
|
||||
published_at = link.published_at
|
||||
except AttributeError:
|
||||
published_at = None
|
||||
if published_at is not None and (
|
||||
result[context_key]["last_published_at"] is None
|
||||
or result[context_key]["last_published_at"] < published_at
|
||||
):
|
||||
result[context_key]["last_published_at"] = published_at
|
||||
|
||||
serializer = PublishableEntityLinksSummarySerializer(list(result.values()), many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
def _remove_duplicates(self, links: list[EntityLinkBase]) -> list[EntityLinkBase]:
|
||||
"""
|
||||
Remove duplicates based on `EntityLinkBase.downstream_usage_key`
|
||||
"""
|
||||
seen_keys = set()
|
||||
unique_links = []
|
||||
|
||||
for link in links:
|
||||
if link.downstream_usage_key not in seen_keys:
|
||||
seen_keys.add(link.downstream_usage_key)
|
||||
unique_links.append(link)
|
||||
|
||||
return unique_links
|
||||
|
||||
|
||||
@view_auth_classes(is_authenticated=True)
|
||||
class DownstreamView(DeveloperErrorViewMixin, APIView):
|
||||
|
||||
@@ -340,6 +340,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 2,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_html1,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
@@ -356,6 +357,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 2,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_problem1,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
@@ -372,6 +374,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 2,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_problem2,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
@@ -388,6 +391,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 2,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_unit["locator"],
|
||||
'downstream_context_key': str(self.course.id),
|
||||
@@ -484,6 +488,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 2,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_html1,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
@@ -500,6 +505,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 3, # <--- updated
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_problem1,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
@@ -516,6 +522,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 2,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_problem2,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
@@ -532,6 +539,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 2, # <--- not updated since we didn't directly modify the unit
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_unit["locator"],
|
||||
'downstream_context_key': str(self.course.id),
|
||||
@@ -626,6 +634,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 2,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_html1,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
@@ -642,6 +651,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 3,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_problem1,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
@@ -658,6 +668,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 2,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_problem3,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
@@ -674,6 +685,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 4, # <--- updated
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_unit["locator"],
|
||||
'downstream_context_key': str(self.course.id),
|
||||
@@ -750,6 +762,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 2,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_html1,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
@@ -766,6 +779,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 3,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_problem1,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
@@ -782,6 +796,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 2,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_problem3,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
@@ -798,6 +813,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 5, # <--- updated
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_unit["locator"],
|
||||
'downstream_context_key': str(self.course.id),
|
||||
|
||||
@@ -96,6 +96,7 @@ class _BaseDownstreamViewTestMixin:
|
||||
|
||||
# Creating container to test the top-level parent
|
||||
self.top_level_unit_id = self._create_container(self.library_id, "unit", "unit-2", "Unit 2")["id"]
|
||||
self.top_level_unit_id_2 = self._create_container(self.library_id, "unit", "unit-3", "Unit 3")["id"]
|
||||
self.top_level_subsection_id = self._create_container(
|
||||
self.library_id,
|
||||
"subsection",
|
||||
@@ -114,6 +115,7 @@ class _BaseDownstreamViewTestMixin:
|
||||
self._publish_container(self.subsection_id)
|
||||
self._publish_container(self.section_id)
|
||||
self._publish_container(self.top_level_unit_id)
|
||||
self._publish_container(self.top_level_unit_id_2)
|
||||
self._publish_container(self.top_level_subsection_id)
|
||||
self._publish_container(self.top_level_section_id)
|
||||
self.mock_upstream_link = f"{settings.COURSE_AUTHORING_MICROFRONTEND_URL}/library/{self.library_id}/components?usageKey={self.video_lib_id}" # pylint: disable=line-too-long # noqa: E501
|
||||
@@ -140,6 +142,24 @@ class _BaseDownstreamViewTestMixin:
|
||||
).usage_key
|
||||
|
||||
# Creating Blocks with top-level-parents
|
||||
# Unit created as a top-level parent
|
||||
self.top_level_downstream_unit = BlockFactory.create(
|
||||
category='vertical',
|
||||
parent=sequential,
|
||||
upstream=self.top_level_unit_id,
|
||||
upstream_version=1,
|
||||
)
|
||||
self.top_level_downstream_html_key = BlockFactory.create(
|
||||
category='html',
|
||||
parent=self.top_level_downstream_unit,
|
||||
upstream=self.html_lib_id_2,
|
||||
upstream_version=1,
|
||||
top_level_downstream_parent_key=get_block_key_dict(
|
||||
self.top_level_downstream_unit.usage_key,
|
||||
)
|
||||
).usage_key
|
||||
|
||||
# Section created as a top-level parent
|
||||
self.top_level_downstream_chapter = BlockFactory.create(
|
||||
category='chapter', parent=self.course, upstream=self.top_level_section_id, upstream_version=1,
|
||||
)
|
||||
@@ -152,27 +172,18 @@ class _BaseDownstreamViewTestMixin:
|
||||
self.top_level_downstream_chapter.usage_key,
|
||||
),
|
||||
)
|
||||
self.top_level_downstream_unit = BlockFactory.create(
|
||||
self.top_level_downstream_unit_2 = BlockFactory.create(
|
||||
category='vertical',
|
||||
parent=self.top_level_downstream_sequential,
|
||||
upstream=self.top_level_unit_id,
|
||||
upstream=self.top_level_unit_id_2,
|
||||
upstream_version=1,
|
||||
top_level_downstream_parent_key=get_block_key_dict(
|
||||
self.top_level_downstream_sequential.usage_key,
|
||||
)
|
||||
self.top_level_downstream_chapter.usage_key,
|
||||
),
|
||||
)
|
||||
self.top_level_downstream_html_key = BlockFactory.create(
|
||||
category='html',
|
||||
parent=self.top_level_downstream_unit,
|
||||
upstream=self.html_lib_id_2,
|
||||
upstream_version=1,
|
||||
top_level_downstream_parent_key=get_block_key_dict(
|
||||
self.top_level_downstream_unit.usage_key,
|
||||
)
|
||||
).usage_key
|
||||
self.top_level_downstream_video_key = BlockFactory.create(
|
||||
category='video',
|
||||
parent=self.top_level_downstream_unit,
|
||||
parent=self.top_level_downstream_unit_2,
|
||||
upstream=self.video_lib_id_2,
|
||||
upstream_version=1,
|
||||
top_level_downstream_parent_key=get_block_key_dict(
|
||||
@@ -589,6 +600,7 @@ class GetUpstreamViewTest(
|
||||
'downstream_usage_key': str(self.downstream_video_key),
|
||||
'id': 1,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
@@ -605,6 +617,7 @@ class GetUpstreamViewTest(
|
||||
'downstream_usage_key': str(self.downstream_html_key),
|
||||
'id': 2,
|
||||
'ready_to_sync': True,
|
||||
'ready_to_sync_from_children': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
@@ -621,6 +634,7 @@ class GetUpstreamViewTest(
|
||||
'downstream_usage_key': str(self.top_level_downstream_html_key),
|
||||
'id': 3,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
@@ -637,6 +651,7 @@ class GetUpstreamViewTest(
|
||||
'downstream_usage_key': str(self.top_level_downstream_video_key),
|
||||
'id': 4,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
@@ -653,6 +668,7 @@ class GetUpstreamViewTest(
|
||||
'downstream_usage_key': str(self.downstream_chapter_key),
|
||||
'id': 1,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
@@ -669,6 +685,7 @@ class GetUpstreamViewTest(
|
||||
'downstream_usage_key': str(self.downstream_sequential_key),
|
||||
'id': 2,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
@@ -685,6 +702,7 @@ class GetUpstreamViewTest(
|
||||
'downstream_usage_key': str(self.downstream_unit_key),
|
||||
'id': 3,
|
||||
'ready_to_sync': True,
|
||||
'ready_to_sync_from_children': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
@@ -698,9 +716,27 @@ class GetUpstreamViewTest(
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.top_level_downstream_chapter.usage_key),
|
||||
'downstream_usage_key': str(self.top_level_downstream_unit.usage_key),
|
||||
'id': 4,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_key': self.top_level_unit_id,
|
||||
'upstream_type': 'container',
|
||||
'upstream_version': 1,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': None,
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.top_level_downstream_chapter.usage_key),
|
||||
'id': 5,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
@@ -715,8 +751,9 @@ class GetUpstreamViewTest(
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.top_level_downstream_sequential.usage_key),
|
||||
'id': 5,
|
||||
'id': 6,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
@@ -730,22 +767,23 @@ class GetUpstreamViewTest(
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.top_level_downstream_unit.usage_key),
|
||||
'id': 6,
|
||||
'downstream_usage_key': str(self.top_level_downstream_unit_2.usage_key),
|
||||
'id': 7,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_key': self.top_level_unit_id,
|
||||
'upstream_key': self.top_level_unit_id_2,
|
||||
'upstream_type': 'container',
|
||||
'upstream_version': 1,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': str(self.top_level_downstream_sequential.usage_key),
|
||||
'top_level_parent_usage_key': str(self.top_level_downstream_chapter.usage_key),
|
||||
},
|
||||
]
|
||||
self.assertListEqual(data["results"], expected)
|
||||
self.assertEqual(data["count"], 10)
|
||||
self.assertEqual(data["count"], 11)
|
||||
|
||||
def test_permission_denied_with_course_filter(self):
|
||||
self.client.login(username="simple_user", password="password")
|
||||
@@ -771,6 +809,7 @@ class GetUpstreamViewTest(
|
||||
'downstream_usage_key': str(self.downstream_video_key),
|
||||
'id': 1,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
@@ -787,6 +826,7 @@ class GetUpstreamViewTest(
|
||||
'downstream_usage_key': str(self.downstream_html_key),
|
||||
'id': 2,
|
||||
'ready_to_sync': True,
|
||||
'ready_to_sync_from_children': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
@@ -803,6 +843,7 @@ class GetUpstreamViewTest(
|
||||
'downstream_usage_key': str(self.top_level_downstream_html_key),
|
||||
'id': 3,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
@@ -819,6 +860,7 @@ class GetUpstreamViewTest(
|
||||
'downstream_usage_key': str(self.top_level_downstream_video_key),
|
||||
'id': 4,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
@@ -852,6 +894,7 @@ class GetUpstreamViewTest(
|
||||
'downstream_usage_key': str(self.downstream_chapter_key),
|
||||
'id': 1,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
@@ -868,6 +911,7 @@ class GetUpstreamViewTest(
|
||||
'downstream_usage_key': str(self.downstream_sequential_key),
|
||||
'id': 2,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
@@ -884,6 +928,7 @@ class GetUpstreamViewTest(
|
||||
'downstream_usage_key': str(self.downstream_unit_key),
|
||||
'id': 3,
|
||||
'ready_to_sync': True,
|
||||
'ready_to_sync_from_children': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
@@ -897,9 +942,27 @@ class GetUpstreamViewTest(
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.top_level_downstream_chapter.usage_key),
|
||||
'downstream_usage_key': str(self.top_level_downstream_unit.usage_key),
|
||||
'id': 4,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_key': self.top_level_unit_id,
|
||||
'upstream_type': 'container',
|
||||
'upstream_version': 1,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': None,
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.top_level_downstream_chapter.usage_key),
|
||||
'id': 5,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
@@ -914,8 +977,9 @@ class GetUpstreamViewTest(
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.top_level_downstream_sequential.usage_key),
|
||||
'id': 5,
|
||||
'id': 6,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
@@ -929,22 +993,23 @@ class GetUpstreamViewTest(
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.top_level_downstream_unit.usage_key),
|
||||
'id': 6,
|
||||
'downstream_usage_key': str(self.top_level_downstream_unit_2.usage_key),
|
||||
'id': 7,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync_from_children': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_key': self.top_level_unit_id,
|
||||
'upstream_key': self.top_level_unit_id_2,
|
||||
'upstream_type': 'container',
|
||||
'upstream_version': 1,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': str(self.top_level_downstream_sequential.usage_key),
|
||||
'top_level_parent_usage_key': str(self.top_level_downstream_chapter.usage_key),
|
||||
},
|
||||
]
|
||||
self.assertListEqual(data["results"], expected)
|
||||
self.assertEqual(data["count"], 6)
|
||||
self.assertEqual(data["count"], 7)
|
||||
|
||||
@ddt.data(
|
||||
('all', 2),
|
||||
@@ -1027,9 +1092,27 @@ class GetUpstreamViewTest(
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.top_level_downstream_chapter.usage_key),
|
||||
'downstream_usage_key': str(self.top_level_downstream_unit.usage_key),
|
||||
'id': 4,
|
||||
'ready_to_sync': False,
|
||||
'ready_to_sync': False, # <-- It's False because the container doesn't have changes
|
||||
'ready_to_sync_from_children': True, # <-- It's True because a child has changes
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_key': self.top_level_unit_id,
|
||||
'upstream_type': 'container',
|
||||
'upstream_version': 1,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': None,
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.top_level_downstream_chapter.usage_key),
|
||||
'id': 5,
|
||||
'ready_to_sync': False, # <-- It's False because the container doesn't have changes
|
||||
'ready_to_sync_from_children': True, # <-- It's True because a child has changes
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
@@ -1040,28 +1123,13 @@ class GetUpstreamViewTest(
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': None,
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.top_level_downstream_unit.usage_key),
|
||||
'id': 6,
|
||||
'ready_to_sync': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_key': self.top_level_unit_id,
|
||||
'upstream_type': 'container',
|
||||
'upstream_version': 1,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': str(self.top_level_downstream_sequential.usage_key),
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.downstream_html_key),
|
||||
'id': 2,
|
||||
'ready_to_sync': True,
|
||||
'ready_to_sync_from_children': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
@@ -1078,6 +1146,7 @@ class GetUpstreamViewTest(
|
||||
'downstream_usage_key': str(self.downstream_unit_key),
|
||||
'id': 3,
|
||||
'ready_to_sync': True,
|
||||
'ready_to_sync_from_children': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
@@ -1089,6 +1158,8 @@ class GetUpstreamViewTest(
|
||||
'top_level_parent_usage_key': None,
|
||||
},
|
||||
]
|
||||
print(data["results"])
|
||||
print(expected)
|
||||
self.assertListEqual(data["results"], expected)
|
||||
|
||||
def test_200_get_ready_to_sync_top_level_parents_with_containers(self):
|
||||
@@ -1121,6 +1192,7 @@ class GetUpstreamViewTest(
|
||||
'downstream_usage_key': str(self.downstream_html_key),
|
||||
'id': 2,
|
||||
'ready_to_sync': True,
|
||||
'ready_to_sync_from_children': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
@@ -1137,6 +1209,7 @@ class GetUpstreamViewTest(
|
||||
'downstream_usage_key': str(self.downstream_unit_key),
|
||||
'id': 3,
|
||||
'ready_to_sync': True,
|
||||
'ready_to_sync_from_children': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
@@ -1151,8 +1224,9 @@ class GetUpstreamViewTest(
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.top_level_downstream_chapter.usage_key),
|
||||
'id': 4,
|
||||
'ready_to_sync': False,
|
||||
'id': 5,
|
||||
'ready_to_sync': False, # <-- It's False because the container doesn't have changes
|
||||
'ready_to_sync_from_children': True, # <-- It's True because a child has changes
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
@@ -1203,8 +1277,9 @@ class GetUpstreamViewTest(
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.top_level_downstream_chapter.usage_key),
|
||||
'id': 4,
|
||||
'ready_to_sync': True,
|
||||
'id': 5,
|
||||
'ready_to_sync': True, # <-- It's True because the section has changes
|
||||
'ready_to_sync_from_children': True, # <-- It's True because a child has changes
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
@@ -1221,6 +1296,7 @@ class GetUpstreamViewTest(
|
||||
'downstream_usage_key': str(self.downstream_html_key),
|
||||
'id': 2,
|
||||
'ready_to_sync': True,
|
||||
'ready_to_sync_from_children': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
@@ -1237,6 +1313,7 @@ class GetUpstreamViewTest(
|
||||
'downstream_usage_key': str(self.downstream_unit_key),
|
||||
'id': 3,
|
||||
'ready_to_sync': True,
|
||||
'ready_to_sync_from_children': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
@@ -1281,11 +1358,52 @@ class GetDownstreamSummaryViewTest(
|
||||
response = self.call_api(str(self.course.id))
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# The `total_count` is 7 because the top-level logic:
|
||||
# * The `section-2`, that is the top-level parent of `subsection-2`, `unit-3`, `html-baz-2`
|
||||
# * The `unit-2`, that is the top-level parent of `video-baz-2`
|
||||
# * The `section-1`
|
||||
# * The `subsection-1`
|
||||
# * The `unit-1`
|
||||
# * The `html-baz-1`
|
||||
# * The `video-baz-1`
|
||||
expected = [{
|
||||
'upstream_context_title': 'Test Library 1',
|
||||
'upstream_context_key': self.library_id,
|
||||
'ready_to_sync_count': 2,
|
||||
'total_count': 10,
|
||||
'total_count': 7,
|
||||
'last_published_at': self.now.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
|
||||
}]
|
||||
self.assertListEqual(data, expected)
|
||||
|
||||
# Publish Subsection
|
||||
self._update_container(self.top_level_subsection_id, display_name="Subsection 3")
|
||||
self._publish_container(self.top_level_subsection_id)
|
||||
|
||||
response = self.call_api(str(self.course.id))
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
expected = [{
|
||||
'upstream_context_title': 'Test Library 1',
|
||||
'upstream_context_key': self.library_id,
|
||||
'ready_to_sync_count': 3, # <-- + the section (top-level parent of subsection)
|
||||
'total_count': 7,
|
||||
'last_published_at': self.now.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
|
||||
}]
|
||||
self.assertListEqual(data, expected)
|
||||
|
||||
# Publish Section
|
||||
self._update_container(self.top_level_section_id, display_name="Section 3")
|
||||
self._publish_container(self.top_level_section_id)
|
||||
|
||||
response = self.call_api(str(self.course.id))
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
expected = [{
|
||||
'upstream_context_title': 'Test Library 1',
|
||||
'upstream_context_key': self.library_id,
|
||||
'ready_to_sync_count': 3, # <-- is the same value because the section is the top-level parent
|
||||
'total_count': 7,
|
||||
'last_published_at': self.now.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
|
||||
}]
|
||||
self.assertListEqual(data, expected)
|
||||
|
||||
Reference in New Issue
Block a user