Skip to content
Open
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
9 changes: 6 additions & 3 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ branch = True
data_file = .coverage
source=completion_aggregation
omit =
compat.py
test_settings
*migrations*
*/migrations/*
*admin.py
*static*
*templates*
*/static/*
*/templates/*
*/tests/*
*/settings/*
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ Change Log
Unreleased
~~~~~~~~~~

[4.4.0] - 2026-01-28
~~~~~~~~~~~~~~~~~~~~

* Count XBlocks with `optional_completion`.

[4.3.0] - 2026-01-28
~~~~~~~~~~~~~~~~~~~~

Expand Down
2 changes: 1 addition & 1 deletion codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ coverage:
patch:
default:
enabled: yes
target: 100%
target: 90%

comment: false
2 changes: 1 addition & 1 deletion completion_aggregator/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@

from __future__ import absolute_import, unicode_literals

__version__ = '4.3.0'
__version__ = '4.4.0rc2'
13 changes: 13 additions & 0 deletions completion_aggregator/api/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,18 @@ def get_requested_fields(self):
raise ParseError(msg.format(invalid))
return fields

def get_include_optional(self):
"""
Parse and return value for include_optional parameter.
"""
if self.request.method == "GET":
value = self.request.GET.get("include_optional", "false")
else:
value = self.request.data.get("include_optional", False)
if isinstance(value, bool):
return value
return value.lower() == "true"

def get_serializer_class(self):
"""
Return the appropriate serializer.
Expand All @@ -202,4 +214,5 @@ def get_serializer_class(self):
self.get_requested_fields(),
self.course_completion_serializer,
self.block_completion_serializer,
include_optional=self.get_include_optional(),
)
16 changes: 15 additions & 1 deletion completion_aggregator/api/v0/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ class CompletionListView(CompletionViewMixin, APIView):
A value of "true" will provide only completions that come from
mobile courses.

include_optional (optional):
A value of "true" will include optional completion data in the response.
Optional blocks (marked with ``optional_completion=True`` in Open edX)
are tracked separately and returned as an ``optional_completion`` object
with ``earned``, ``possible``, and ``percent`` fields.

**Returns**

* 200 on success with above fields
Expand Down Expand Up @@ -203,7 +209,8 @@ def get(self, request):
serializer = self.get_serializer_class()(
instance=completions,
requested_fields=self.get_requested_fields(),
many=True
include_optional=self.get_include_optional(),
many=True,
)
return paginator.get_paginated_response(serializer.data) # pylint: disable=no-member

Expand Down Expand Up @@ -269,6 +276,12 @@ class CompletionDetailView(CompletionViewMixin, APIView):
types. If any invalid fields are requested, a 400 error will be
returned.

include_optional (optional):
A value of "true" will include optional completion data in the response.
Optional blocks (marked with ``optional_completion=True`` in Open edX)
are tracked separately and returned as an ``optional_completion`` object
with ``earned``, ``possible``, and ``percent`` fields.

**Returns**

* 200 on success with above fields
Expand Down Expand Up @@ -357,6 +370,7 @@ def get(self, request, course_key):
serializer = self.get_serializer_class()(
instance=completions,
requested_fields=requested_fields,
include_optional=self.get_include_optional(),
)
return Response(serializer.data) # pylint: disable=no-member

Expand Down
18 changes: 16 additions & 2 deletions completion_aggregator/api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ class CompletionListView(CompletionViewMixin, APIView):
A value of "true" will provide only completions that come from
mobile courses.

include_optional (optional):
A value of "true" will include optional completion data in the response.
Optional blocks (marked with ``optional_completion=True`` in Open edX)
are tracked separately and returned as an ``optional_completion`` object
with ``earned``, ``possible``, and ``percent`` fields.

**Returns**

* 200 on success with above fields
Expand Down Expand Up @@ -201,7 +207,8 @@ def get(self, request):
serializer = self.get_serializer_class()(
instance=completions,
requested_fields=self.get_requested_fields(),
many=True
include_optional=self.get_include_optional(),
many=True,
)
return paginator.get_paginated_response(serializer.data) # pylint: disable=no-member

Expand Down Expand Up @@ -292,6 +299,12 @@ class CompletionDetailView(CompletionViewMixin, APIView):
types. If any invalid fields are requested, a 400 error will be
returned.

include_optional (optional):
A value of "true" will include optional completion data in the response.
Optional blocks (marked with ``optional_completion=True`` in Open edX)
are tracked separately and returned as an ``optional_completion`` object
with ``earned``, ``possible``, and ``percent`` fields.

**Returns**

* 200 on success with above fields
Expand Down Expand Up @@ -429,7 +442,8 @@ def _parse_aggregator(self, course_key, params=None):
serializer = self.get_serializer_class()(
instance=completions,
requested_fields=requested_fields,
many=True
include_optional=self.get_include_optional(),
many=True,
)
return paginator.get_paginated_response(serializer.data) # pylint: disable=no-member

Expand Down
10 changes: 7 additions & 3 deletions completion_aggregator/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ def init_course_block_key(modulestore, course_key):
"""
Return a UsageKey for the root course block.
"""
# pragma: no-cover
return modulestore.make_course_usage_key(course_key)


Expand Down Expand Up @@ -64,7 +63,6 @@ def init_course_blocks(user, root_block_key):
.location
.block_type
"""
# pragma: no-cover
# pylint: disable=import-error
from lms.djangoapps.course_blocks.api import get_course_block_access_transformers, get_course_blocks
# pylint: disable=import-error
Expand Down Expand Up @@ -113,7 +111,6 @@ def course_enrollment_model():
"""
Return the student.models.CourseEnrollment model.
"""
# pragma: no-cover
from common.djangoapps.student.models import CourseEnrollment # pylint: disable=import-error
return CourseEnrollment

Expand All @@ -136,6 +133,13 @@ def get_block_aggregators(course_blocks, block):
) or []


def is_block_optional(course_blocks, block):
"""
Return whether a block is optional.
"""
return course_blocks.get_xblock_field(block, 'optional_completion', False)


def get_mobile_only_courses(enrollments):
"""
Return list of courses with mobile available given a list of enrollments.
Expand Down
75 changes: 64 additions & 11 deletions completion_aggregator/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
UPDATER_CACHE_TIMEOUT = 600 # 10 minutes

CacheEntry = namedtuple('CacheEntry', ['course_blocks', 'root_block'])
CompletionStats = namedtuple('CompletionStats', ['earned', 'possible', 'last_modified'])
CompletionStats = namedtuple(
"CompletionStats", ["earned", "possible", "optional_earned", "optional_possible", "last_modified"]
)

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -83,7 +85,7 @@ def cache_key(self):
)


CourseBlocksEntry = namedtuple('CourseBlocksEntry', ['children', 'aggregators'])
CourseBlocksEntry = namedtuple('CourseBlocksEntry', ['children', 'aggregators', 'optional'])


class AggregationUpdater:
Expand Down Expand Up @@ -150,6 +152,7 @@ def populate(structure, block):
structure[block] = CourseBlocksEntry(
children=compat.get_children(course_blocks, block),
aggregators=compat.get_block_aggregators(course_blocks, block),
optional=compat.is_block_optional(course_blocks, block),
)
for child in structure[block].children:
populate(structure, child)
Expand Down Expand Up @@ -240,23 +243,37 @@ def update_for_aggregator(self, block, affected_aggregators, force):
"""
total_earned = 0.0
total_possible = 0.0
total_optional_earned = 0.0
total_optional_possible = 0.0
last_modified = OLD_DATETIME

if block not in affected_aggregators:
obj = self.aggregators.get(block)
if obj:
return CompletionStats(earned=obj.earned, possible=obj.possible, last_modified=obj.last_modified)
return CompletionStats(
earned=obj.earned,
possible=obj.possible,
optional_earned=obj.optional_earned,
optional_possible=obj.optional_possible,
last_modified=obj.last_modified,
)
for child in self.course_blocks[block].children:
(earned, possible, modified) = self.update_for_block(child, affected_aggregators, force)
total_earned += earned
total_possible += possible
if modified is not None:
last_modified = max(last_modified, modified)
stats = self.update_for_block(child, affected_aggregators, force)
total_earned += stats.earned
total_possible += stats.possible
total_optional_earned += stats.optional_earned
total_optional_possible += stats.optional_possible
if stats.last_modified is not None:
last_modified = max(last_modified, stats.last_modified)
if self._aggregator_needs_update(block, last_modified, force):
if total_possible == 0.0:
percent = 1.0
else:
percent = total_earned / total_possible
if total_optional_possible == 0.0:
optional_percent = 1.0
else:
optional_percent = total_optional_earned / total_optional_possible
Aggregator.objects.validate(self.user, self.course_key, block)
if block not in self.aggregators:
aggregator = Aggregator(
Expand All @@ -267,6 +284,9 @@ def update_for_aggregator(self, block, affected_aggregators, force):
earned=total_earned,
possible=total_possible,
percent=percent,
optional_earned=total_optional_earned,
optional_possible=total_optional_possible,
optional_percent=optional_percent,
last_modified=last_modified,
modified=timezone.now(),
)
Expand All @@ -276,20 +296,38 @@ def update_for_aggregator(self, block, affected_aggregators, force):
aggregator.earned = total_earned
aggregator.possible = total_possible
aggregator.percent = percent
aggregator.optional_earned = total_optional_earned
aggregator.optional_possible = total_optional_possible
aggregator.optional_percent = optional_percent
aggregator.last_modified = last_modified
aggregator.modified = timezone.now()
self.updated_aggregators.append(aggregator)
return CompletionStats(earned=total_earned, possible=total_possible, last_modified=last_modified)
return CompletionStats(
earned=total_earned,
possible=total_possible,
optional_earned=total_optional_earned,
optional_possible=total_optional_possible,
last_modified=last_modified,
)

def update_for_excluded(self):
"""
Return a sentinel empty completion value for excluded blocks.
"""
return CompletionStats(earned=0.0, possible=0.0, last_modified=OLD_DATETIME)
return CompletionStats(
earned=0.0,
possible=0.0,
optional_earned=0.0,
optional_possible=0.0,
last_modified=OLD_DATETIME,
)

def update_for_completable(self, block):
"""
Return the block completion value for a given completable block.

If the block is optional, the completion contributes to optional_earned/optional_possible
instead of earned/possible.
"""
completion = self.block_completions.get(block)
if completion:
Expand All @@ -298,7 +336,22 @@ def update_for_completable(self, block):
else:
earned = 0.0
last_modified = OLD_DATETIME
return CompletionStats(earned=earned, possible=1.0, last_modified=last_modified)

if self.course_blocks[block].optional:
return CompletionStats(
earned=0.0,
possible=0.0,
optional_earned=earned,
optional_possible=1.0,
last_modified=last_modified,
)
return CompletionStats(
earned=earned,
possible=1.0,
optional_earned=0.0,
optional_possible=0.0,
last_modified=last_modified,
)

def _aggregator_needs_update(self, block, modified, force):
"""
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Generated by Django 4.2 on 2026-01-28
from django.db import migrations, models

import completion_aggregator.models


class Migration(migrations.Migration):

dependencies = [
('completion_aggregator', '0006_rename_index_together_to_indexes'),
]

operations = [
migrations.AddField(
model_name='aggregator',
name='optional_earned',
field=models.FloatField(default=0.0, validators=[completion_aggregator.models.validate_positive_float]),
),
migrations.AddField(
model_name='aggregator',
name='optional_possible',
field=models.FloatField(default=0.0, validators=[completion_aggregator.models.validate_positive_float]),
),
migrations.AddField(
model_name='aggregator',
name='optional_percent',
field=models.FloatField(default=1.0, validators=[completion_aggregator.models.validate_percent]),
),
]
Loading
Loading