From 68858ed31f4de81ae217a3f20eee0f5c46ca7e5a Mon Sep 17 00:00:00 2001 From: Quitterie Lucas Date: Thu, 4 Aug 2022 18:20:23 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8(converter)=20add=20edx=20to=20xap?= =?UTF-8?q?i=20video=20converter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit edX video events can be converted to xAPI for most of them. The interaction events can still not be converted. --- CHANGELOG.md | 4 + src/ralph/models/converter.py | 4 +- .../models/edx/converters/xapi/__init__.py | 7 + src/ralph/models/edx/converters/xapi/video.py | 215 +++++++++++++ src/ralph/models/xapi/__init__.py | 4 +- src/ralph/models/xapi/fields/verbs.py | 8 +- src/ralph/models/xapi/video/constants.py | 4 +- .../models/xapi/video/fields/contexts.py | 115 ++++--- src/ralph/models/xapi/video/fields/objects.py | 2 +- src/ralph/models/xapi/video/fields/results.py | 103 +++--- src/ralph/models/xapi/video/statements.py | 85 +++-- tests/fixtures/hypothesis_strategies.py | 8 +- .../edx/converters/xapi/test_navigational.py | 6 +- .../models/edx/converters/xapi/test_server.py | 6 +- .../models/edx/converters/xapi/test_video.py | 292 ++++++++++++++++++ tests/models/xapi/test_video.py | 52 +++- 16 files changed, 780 insertions(+), 135 deletions(-) create mode 100644 src/ralph/models/edx/converters/xapi/video.py create mode 100644 tests/models/edx/converters/xapi/test_video.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f439b46a..de99cf049 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to ## [Unreleased] +### Added + +- EdX to xAPI converters for video events + ### Changed - Improve Ralph's library integration by unpinning dependencies (and prefer diff --git a/src/ralph/models/converter.py b/src/ralph/models/converter.py index 361363ce7..c569fc500 100644 --- a/src/ralph/models/converter.py +++ b/src/ralph/models/converter.py @@ -40,9 +40,9 @@ def __init__(self, dest: str, src=None, transformers=lambda _: _, raw_input=Fals Args: dest (str): The destination path where to place the converted value. src (str or None): The source from where the value to convert is fetched. - - When `src` is a path (ex. `context__user_id`) - the value is the item + When `src` is a path (ex. `context__user_id`), the value is the item of the event at the path. - - When `src` is `None` - the value is the whole event. + When `src` is `None`, the value is the whole event. transformers (function or tuple of functions): The function(s) to apply on the source value. raw_input (bool): Flag indicating whether `get_value` will receive a raw diff --git a/src/ralph/models/edx/converters/xapi/__init__.py b/src/ralph/models/edx/converters/xapi/__init__.py index 39b301616..588c5117d 100644 --- a/src/ralph/models/edx/converters/xapi/__init__.py +++ b/src/ralph/models/edx/converters/xapi/__init__.py @@ -4,3 +4,10 @@ from .navigational import UIPageCloseToPageTerminated from .server import ServerEventToPageViewed +from .video import ( + UILoadVideoToVideoInitialized, + UIPauseVideoToVideoPaused, + UIPlayVideoToVideoPlayed, + UISeekVideoToVideoSeeked, + UIStopVideoToVideoTerminated, +) diff --git a/src/ralph/models/edx/converters/xapi/video.py b/src/ralph/models/edx/converters/xapi/video.py new file mode 100644 index 000000000..3b3f04f24 --- /dev/null +++ b/src/ralph/models/edx/converters/xapi/video.py @@ -0,0 +1,215 @@ +"""Video event xAPI Converter""" + +from ralph.models.converter import ConversionItem +from ralph.models.edx.video.statements import ( + UILoadVideo, + UIPauseVideo, + UIPlayVideo, + UISeekVideo, + UIStopVideo, +) +from ralph.models.xapi.constants import LANG_EN_US_DISPLAY +from ralph.models.xapi.video.constants import ( + VIDEO_EXTENSION_LENGTH, + VIDEO_EXTENSION_PROGRESS, + VIDEO_EXTENSION_SESSION_ID, + VIDEO_EXTENSION_TIME, + VIDEO_EXTENSION_TIME_FROM, + VIDEO_EXTENSION_TIME_TO, + VIDEO_EXTENSION_USER_AGENT, +) +from ralph.models.xapi.video.statements import ( + VideoInitialized, + VideoPaused, + VideoPlayed, + VideoSeeked, + VideoTerminated, +) + +from .base import BaseXapiConverter + + +class VideoBaseXapiConverter(BaseXapiConverter): + """Base Video xAPI Converter.""" + + def _get_conversion_items(self): + """Returns a set of ConversionItems used for conversion.""" + + conversion_items = super()._get_conversion_items() + return conversion_items.union( + { + ConversionItem( + "object__definition__name", + "event__id", + lambda id: {LANG_EN_US_DISPLAY.__args__[0]: id}, + ), + ConversionItem( + "object__id", + None, + lambda event: self.platform_url + + "/xblock/block-v1:" + + event["context"]["course_id"] + + "-course-v1:+type@video+block@" + + event["event"]["id"], + ), + ConversionItem( + "context__extensions__" + VIDEO_EXTENSION_SESSION_ID, + "session", + ), + }, + ) + + +class UILoadVideoToVideoInitialized(VideoBaseXapiConverter): + """Converts a common edX `load_video` event to xAPI.""" + + __src__ = UILoadVideo + __dest__ = VideoInitialized + + def _get_conversion_items(self): + """Returns a set of ConversionItems used for conversion.""" + + conversion_items = super()._get_conversion_items() + return conversion_items.union( + { + ConversionItem( + "context__extensions__" + VIDEO_EXTENSION_LENGTH, + None, + # Set the video length to null by default. + # This information is mandatory in the xAPI template + # and does not exist in the edX `load_video` event model. + lambda _: 0.0, + ), + ConversionItem( + "context__extensions__" + VIDEO_EXTENSION_SESSION_ID, + "session", + ), + ConversionItem( + "context__extensions__" + VIDEO_EXTENSION_USER_AGENT, "agent" + ), + }, + ) + + +class UIPlayVideoToVideoPlayed(VideoBaseXapiConverter): + """Converts a common edX `play_video` event to xAPI.""" + + __src__ = UIPlayVideo + __dest__ = VideoPlayed + + def _get_conversion_items(self): + """Returns a set of ConversionItems used for conversion.""" + + conversion_items = super()._get_conversion_items() + return conversion_items.union( + { + ConversionItem( + "result__extensions__" + VIDEO_EXTENSION_TIME, + "event__currentTime", + ), + ConversionItem( + "context__extensions__" + VIDEO_EXTENSION_SESSION_ID, + "session", + ), + }, + ) + + +class UIPauseVideoToVideoPaused(VideoBaseXapiConverter): + """Converts a common edX `pause_video` event to xAPI.""" + + __src__ = UIPauseVideo + __dest__ = VideoPaused + + def _get_conversion_items(self): + """Returns a set of ConversionItems used for conversion.""" + + conversion_items = super()._get_conversion_items() + return conversion_items.union( + { + ConversionItem( + "result__extensions__" + VIDEO_EXTENSION_TIME, + "event__currentTime", + ), + ConversionItem( + "context__extensions__" + VIDEO_EXTENSION_LENGTH, + None, + # Set the video length to null by default. + # This information is mandatory in the xAPI template + # and does not exist in the edX `pause_video` event model. + lambda _: 0.0, + ), + ConversionItem( + "context__extensions__" + VIDEO_EXTENSION_SESSION_ID, + "session", + ), + }, + ) + + +class UIStopVideoToVideoTerminated(VideoBaseXapiConverter): + """Converts a common edX `stop_video` event to xAPI.""" + + __src__ = UIStopVideo + __dest__ = VideoTerminated + + def _get_conversion_items(self): + """Returns a set of ConversionItems used for conversion.""" + + conversion_items = super()._get_conversion_items() + return conversion_items.union( + { + ConversionItem( + "result__extensions__" + VIDEO_EXTENSION_TIME, + "event__currentTime", + ), + ConversionItem( + "result__extensions__" + VIDEO_EXTENSION_PROGRESS, + None, + # Set the video progress to null by default. + # This information is mandatory in the xAPI template + # and does not exist in the edX `stop_video` event model. + lambda _: 0.0, + ), + ConversionItem( + "context__extensions__" + VIDEO_EXTENSION_LENGTH, + None, + # Set the video length to null by default. + # This information is mandatory in the xAPI template + # and does not exist in the edX `stop_video` event model. + lambda _: 0.0, + ), + ConversionItem( + "context__extensions__" + VIDEO_EXTENSION_SESSION_ID, + "session", + ), + }, + ) + + +class UISeekVideoToVideoSeeked(VideoBaseXapiConverter): + """Converts a common edX `seek_video` event to xAPI.""" + + __src__ = UISeekVideo + __dest__ = VideoSeeked + + def _get_conversion_items(self): + """Returns a set of ConversionItems used for conversion.""" + + conversion_items = super()._get_conversion_items() + return conversion_items.union( + { + ConversionItem( + "result__extensions__" + VIDEO_EXTENSION_TIME_FROM, + "event__old_time", + ), + ConversionItem( + "result__extensions__" + VIDEO_EXTENSION_TIME_TO, + "event__new_time", + ), + ConversionItem( + "context__extensions__" + VIDEO_EXTENSION_SESSION_ID, + "session", + ), + }, + ) diff --git a/src/ralph/models/xapi/__init__.py b/src/ralph/models/xapi/__init__.py index 9def676d2..b1d45b516 100644 --- a/src/ralph/models/xapi/__init__.py +++ b/src/ralph/models/xapi/__init__.py @@ -5,10 +5,12 @@ from .navigation.statements import PageTerminated, PageViewed from .video.statements import ( VideoCompleted, + VideoEnableClosedCaptioning, VideoInitialized, - VideoInteracted, VideoPaused, VideoPlayed, + VideoScreenChangeInteraction, VideoSeeked, VideoTerminated, + VideoVolumeChangeInteraction, ) diff --git a/src/ralph/models/xapi/fields/verbs.py b/src/ralph/models/xapi/fields/verbs.py index b6ca4b82d..57998472a 100644 --- a/src/ralph/models/xapi/fields/verbs.py +++ b/src/ralph/models/xapi/fields/verbs.py @@ -29,8 +29,8 @@ class ViewedVerbField(VerbField): """Represents the `verb` xAPI Field for the action `viewed`. Attributes: - id (str): Consists of the value `http://id.tincanapi.com/verb/viewed`. - display (dict): Consists of the dictionary `{"en-US": "viewed"}`. + id (str): Consists of the value `http://id.tincanapi.com/verb/viewed`. + display (dict): Consists of the dictionary `{"en-US": "viewed"}`. """ id: VERB_VIEWED_ID = VERB_VIEWED_ID.__args__[0] @@ -43,8 +43,8 @@ class TerminatedVerbField(VerbField): """Represents the `verb` xAPI Field for the action `terminated`. Attributes: - id (str): Consists of the value `http://adlnet.gov/expapi/verbs/terminated`. - display (dict): Consists of the dictionary `{"en-US": "terminated"}`. + id (str): Consists of the value `http://adlnet.gov/expapi/verbs/terminated`. + display (dict): Consists of the dictionary `{"en-US": "terminated"}`. """ id: VERB_TERMINATED_ID = VERB_TERMINATED_ID.__args__[0] diff --git a/src/ralph/models/xapi/video/constants.py b/src/ralph/models/xapi/video/constants.py index 499b0f2e1..4f82312a0 100644 --- a/src/ralph/models/xapi/video/constants.py +++ b/src/ralph/models/xapi/video/constants.py @@ -6,9 +6,7 @@ VIDEO_EXTENSION_CC_SUBTITLE_LANG = ( "https://w3id.org/xapi/video/extensions/cc-subtitle-lang" ) -VIDEO_EXTENSION_CC_SUBTITLE_ENABLED = ( - "https://w3id.org/xapi/video/extensions/cc-subtitle-enabled" -) +VIDEO_EXTENSION_CC_ENABLED = "https://w3id.org/xapi/video/extensions/cc-enabled" VIDEO_EXTENSION_COMPLETION_THRESHOLD = ( "https://w3id.org/xapi/video/extensions/completion-threshold" ) diff --git a/src/ralph/models/xapi/video/fields/contexts.py b/src/ralph/models/xapi/video/fields/contexts.py index baf0ba6ec..52b295848 100644 --- a/src/ralph/models/xapi/video/fields/contexts.py +++ b/src/ralph/models/xapi/video/fields/contexts.py @@ -1,24 +1,22 @@ """Video xAPI events context fields definitions""" from typing import Literal, Optional +from uuid import UUID -from pydantic import UUID4, Field +from pydantic import Field, NonNegativeFloat from ...base import BaseModelWithConfig from ...fields.contexts import ContextActivitiesContextField, ContextField from ..constants import ( VIDEO_CONTEXT_CATEGORY, - VIDEO_EXTENSION_CC_SUBTITLE_ENABLED, + VIDEO_EXTENSION_CC_ENABLED, VIDEO_EXTENSION_CC_SUBTITLE_LANG, VIDEO_EXTENSION_COMPLETION_THRESHOLD, - VIDEO_EXTENSION_FRAME_RATE, VIDEO_EXTENSION_FULL_SCREEN, VIDEO_EXTENSION_LENGTH, - VIDEO_EXTENSION_QUALITY, VIDEO_EXTENSION_SCREEN_SIZE, VIDEO_EXTENSION_SESSION_ID, VIDEO_EXTENSION_SPEED, - VIDEO_EXTENSION_TRACK, VIDEO_EXTENSION_USER_AGENT, VIDEO_EXTENSION_VIDEO_PLAYBACK_SIZE, VIDEO_EXTENSION_VOLUME, @@ -52,10 +50,10 @@ class VideoContextExtensionsField(BaseModelWithConfig): """Represents the common context.extensions field for video xAPI statement. Attributes: - session (uuid4): Consists of the ID of the active session. + session (uuid): Consists of the ID of the active session. """ - session: Optional[UUID4] = Field(alias=VIDEO_EXTENSION_SESSION_ID) + session_id: Optional[UUID] = Field(alias=VIDEO_EXTENSION_SESSION_ID) class VideoInitializedContextExtensionsField(VideoContextExtensionsField): @@ -67,15 +65,12 @@ class VideoInitializedContextExtensionsField(VideoContextExtensionsField): enabled. ccSubtitleLanguage (str): Consists of the language of subtitle or closed captioning. - frameRate (float): Consists of the frame rate or frames per second of a video. fullScreen (bool): Indicates whether the video is played in full screen mode. - quality (str): Consists of the video resolution or quality. screenSize (str): Consists of the device playback screen size or the maximum available screen size for Video playback. videoPlaybackSize (str): Consists of the size in Width x Height of the video as viewed by the user. speed (str): Consists of the play back speed. - track (str): Consists of the name of the audio track in a media object. userAgent (str): Consists of the User Agent string of the browser, if the video is launched in browser. volume (int): Consists of the volume of the video. @@ -83,16 +78,13 @@ class VideoInitializedContextExtensionsField(VideoContextExtensionsField): consumed to trigger a completion. """ - length: Optional[float] = Field(alias=VIDEO_EXTENSION_LENGTH) - ccSubtitleEnabled: Optional[bool] = Field(alias=VIDEO_EXTENSION_CC_SUBTITLE_ENABLED) - ccSubtitleLanguage: Optional[str] = Field(alias=VIDEO_EXTENSION_CC_SUBTITLE_LANG) - frameRate: Optional[float] = Field(alias=VIDEO_EXTENSION_FRAME_RATE) + length: NonNegativeFloat = Field(alias=VIDEO_EXTENSION_LENGTH) + ccSubtitleEnabled: Optional[bool] = Field(alias=VIDEO_EXTENSION_CC_ENABLED) + ccSubtitleLang: Optional[str] = Field(alias=VIDEO_EXTENSION_CC_SUBTITLE_LANG) fullScreen: Optional[bool] = Field(alias=VIDEO_EXTENSION_FULL_SCREEN) - quality: Optional[str] = Field(alias=VIDEO_EXTENSION_QUALITY) screenSize: Optional[str] = Field(alias=VIDEO_EXTENSION_SCREEN_SIZE) videoPlaybackSize: Optional[str] = Field(alias=VIDEO_EXTENSION_VIDEO_PLAYBACK_SIZE) speed: Optional[str] = Field(alias=VIDEO_EXTENSION_SPEED) - track: Optional[str] = Field(alias=VIDEO_EXTENSION_TRACK) userAgent: Optional[str] = Field(alias=VIDEO_EXTENSION_USER_AGENT) volume: Optional[int] = Field(alias=VIDEO_EXTENSION_VOLUME) completionThreshold: Optional[float] = Field( @@ -111,40 +103,48 @@ class VideoBrowsingContextExtensionsField(VideoContextExtensionsField): length (float): Consists of the length of the video. """ - length: Optional[float] = Field(alias=VIDEO_EXTENSION_LENGTH) + length: NonNegativeFloat = Field(alias=VIDEO_EXTENSION_LENGTH) completionThreshold: Optional[float] = Field( alias=VIDEO_EXTENSION_COMPLETION_THRESHOLD ) -class VideoInteractedContextExtensionsField(VideoContextExtensionsField): +class VideoEnableClosedCaptioningContextExtensionsField(VideoContextExtensionsField): """Represents the context.extensions field for video `interacted` xAPI statement. Attributes: - length (float): Consists of the length of the video. - ccSubtitleEnabled (bool): Indicates whether subtitle or closed captioning is - enabled. ccSubtitleLanguage (str): Consists of the language of subtitle or closed captioning. - frameRate (float): Consists of the frame rate or frames per second of a video. + """ + + ccSubtitleLanguage: str = Field(alias=VIDEO_EXTENSION_CC_SUBTITLE_LANG) + + +class VideoVolumeChangeInteractionContextExtensionsField(VideoContextExtensionsField): + """Represents the context.extensions field for video volume change interaction xAPI + statement. + + Attributes: + volume (int): Consists of the volume of the video. + """ + + volume: int = Field(alias=VIDEO_EXTENSION_VOLUME) + + +class VideoScreenChangeInteractionContextExtensionsField(VideoContextExtensionsField): + """Represents the context.extensions field for video screen change interaction xAPI + statement. + + Attributes: fullScreen (bool): Indicates whether the video is played in full screen mode. - quality (str): Consists of the video resolution or quality. + screenSize (str): Expresses the total available screen size for Video playback. videoPlaybackSize (str): Consists of the size in Width x Height of the video as viewed by the user. - speed (str): Consists of the play back speed. - track (str): Consists of the name of the audio track in a media object. - volume (int): Consists of the volume of the video. """ - ccSubtitleEnabled: Optional[bool] = Field(alias=VIDEO_EXTENSION_CC_SUBTITLE_ENABLED) - ccSubtitleLanguage: Optional[str] = Field(alias=VIDEO_EXTENSION_CC_SUBTITLE_LANG) - frameRate: Optional[float] = Field(alias=VIDEO_EXTENSION_FRAME_RATE) - fullScreen: Optional[bool] = Field(alias=VIDEO_EXTENSION_FULL_SCREEN) - quality: Optional[str] = Field(alias=VIDEO_EXTENSION_QUALITY) - videoPlaybackSize: Optional[str] = Field(alias=VIDEO_EXTENSION_VIDEO_PLAYBACK_SIZE) - speed: Optional[str] = Field(alias=VIDEO_EXTENSION_SPEED) - track: Optional[str] = Field(alias=VIDEO_EXTENSION_TRACK) - volume: Optional[int] = Field(alias=VIDEO_EXTENSION_VOLUME) + fullScreen: bool = Field(alias=VIDEO_EXTENSION_FULL_SCREEN) + screenSize: str = Field(alias=VIDEO_EXTENSION_SCREEN_SIZE) + videoPlaybackSize: str = Field(alias=VIDEO_EXTENSION_VIDEO_PLAYBACK_SIZE) class VideoInitializedContextField(BaseVideoContextField): @@ -152,10 +152,9 @@ class VideoInitializedContextField(BaseVideoContextField): Attributes: extensions (dict): See VideoInitializedContextExtensionsField. - contextActivities (dict): See VideoContextActivitiesField. """ - extensions: Optional[VideoInitializedContextExtensionsField] + extensions: VideoInitializedContextExtensionsField class VideoPlayedContextField(BaseVideoContextField): @@ -163,7 +162,6 @@ class VideoPlayedContextField(BaseVideoContextField): Attributes: extensions (dict): See VideoContextExtensionsField. - contextActivities (dict): See VideoContextActivitiesField. """ extensions: Optional[VideoContextExtensionsField] @@ -174,10 +172,9 @@ class VideoPausedContextField(BaseVideoContextField): Attributes: extensions (dict): See VideoBrowsingContextExtensionsField. - contextActivities (dict): See VideoContextActivitiesField. """ - extensions: Optional[VideoBrowsingContextExtensionsField] + extensions: VideoBrowsingContextExtensionsField class VideoSeekedContextField(BaseVideoContextField): @@ -185,7 +182,6 @@ class VideoSeekedContextField(BaseVideoContextField): Attributes: extensions (dict): See VideoContextExtensionsField. - contextActivities (dict): See VideoContextActivitiesField. """ extensions: Optional[VideoContextExtensionsField] @@ -196,10 +192,9 @@ class VideoCompletedContextField(BaseVideoContextField): Attributes: extensions (dict): See VideoBrowsingContextExtensionsField. - contextActivities (dict): See VideoContextActivitiesField. """ - extensions: Optional[VideoBrowsingContextExtensionsField] + extensions: VideoBrowsingContextExtensionsField class VideoTerminatedContextField(BaseVideoContextField): @@ -207,18 +202,38 @@ class VideoTerminatedContextField(BaseVideoContextField): Attributes: extensions (dict): See VideoBrowsingContextExtensionsField. - contextActivities (dict): See VideoContextActivitiesField. """ - extensions: Optional[VideoBrowsingContextExtensionsField] + extensions: VideoBrowsingContextExtensionsField + + +class VideoEnableClosedCaptioningContextField(BaseVideoContextField): + """Represents the context field for video enable closed captioning xAPI statement. + + Attributes: + extensions (dict): See VideoEnableClosedCaptioningContextExtensionsField. + """ + + extensions: VideoEnableClosedCaptioningContextExtensionsField + + +class VideoVolumeChangeInteractionContextField(BaseVideoContextField): + """Represents the context field for video volume change interaction xAPI + statement. + + Attributes: + extensions (dict): See VideoVolumeChangeInteractionContextExtensionsField. + """ + + extensions: VideoVolumeChangeInteractionContextExtensionsField -class VideoInteractedContextField(BaseVideoContextField): - """Represents the context field for video `interacted` xAPI statement. +class VideoScreenChangeInteractionContextField(BaseVideoContextField): + """Represents the context field for video screen change interaction xAPI + statement. Attributes: - extensions (dict): See VideoInteractedContextExtensionsField. - contextActivities (dict): See VideoContextActivitiesField. + extensions (dict): See VideoScreenChangeInteractionContextExtensionsField. """ - extensions: Optional[VideoInteractedContextExtensionsField] + extensions: VideoScreenChangeInteractionContextExtensionsField diff --git a/src/ralph/models/xapi/video/fields/objects.py b/src/ralph/models/xapi/video/fields/objects.py index 48a9fbf43..7df2a116d 100644 --- a/src/ralph/models/xapi/video/fields/objects.py +++ b/src/ralph/models/xapi/video/fields/objects.py @@ -33,4 +33,4 @@ class VideoObjectField(ActivityObjectField): """ name: Optional[dict[LANG_EN_US_DISPLAY, str]] - definition: VideoObjectDefinitionField + definition: VideoObjectDefinitionField = VideoObjectDefinitionField() diff --git a/src/ralph/models/xapi/video/fields/results.py b/src/ralph/models/xapi/video/fields/results.py index e2e9ceb4e..2864c5fb7 100644 --- a/src/ralph/models/xapi/video/fields/results.py +++ b/src/ralph/models/xapi/video/fields/results.py @@ -3,12 +3,12 @@ from datetime import timedelta from typing import Literal, Optional -from pydantic import Field +from pydantic import Field, NonNegativeFloat from ...base import BaseModelWithConfig from ...fields.results import ResultField from ..constants import ( - VIDEO_EXTENSION_LENGTH, + VIDEO_EXTENSION_CC_ENABLED, VIDEO_EXTENSION_PLAYED_SEGMENTS, VIDEO_EXTENSION_PROGRESS, VIDEO_EXTENSION_TIME, @@ -17,28 +17,28 @@ ) -class VideoActionResultExtensionsField(BaseModelWithConfig): +class VideoResultExtensionsField(BaseModelWithConfig): """Represents the result.extensions field for video `played` xAPI statement. Attributes: + playedSegments (str): Consists of parts of the video the actor watched during + current registration in chronological order (for example, + "0[.]5[,]12[.]22[,]15[.]55[,]55[.]99.33[,]99.33"). time (float): Consists of the video time code when the event was emitted. """ - time: Optional[float] = Field(alias=VIDEO_EXTENSION_TIME) + time: NonNegativeFloat = Field(alias=VIDEO_EXTENSION_TIME) + playedSegments: Optional[str] = Field(alias=VIDEO_EXTENSION_PLAYED_SEGMENTS) -class VideoPausedResultExtensionsField(VideoActionResultExtensionsField): - """Represents the result.extensions field for video `played` xAPI statement. +class VideoPausedResultExtensionsField(VideoResultExtensionsField): + """Represents the result.extensions field for video `paused` xAPI statement. Attributes: - playedSegments (str): Consists of parts of the video the actor watched during - current registration in chronological order (for example, - "0[.]5[,]12[.]22[,]15[.]55[,]55[.]99.33[,]99.33"). progress (float): Consists of the ratio of media consumed by the actor. """ - playedSegments: Optional[str] = Field(alias=VIDEO_EXTENSION_PLAYED_SEGMENTS) - progress: Optional[float] = Field(alias=VIDEO_EXTENSION_PROGRESS) + progress: Optional[NonNegativeFloat] = Field(alias=VIDEO_EXTENSION_PROGRESS) class Config: # pylint: disable=missing-class-docstring min_anystr_length = 0 @@ -52,49 +52,50 @@ class VideoSeekedResultExtensionsField(BaseModelWithConfig): media object during a seek operation. timeTo (float): Consists of the point in time the actor changed to in a media object during a seek operation. - length (float): Consists of the actual length of the media in seconds. - playedSegments (str): Consists of parts of the video the actor watched during - current registration in chronological order. - progress (float): Consists of the percentage of media consumed by the actor. """ - timeFrom: Optional[float] = Field(alias=VIDEO_EXTENSION_TIME_FROM) - timeTo: Optional[float] = Field(alias=VIDEO_EXTENSION_TIME_TO) - length: Optional[float] = Field(alias=VIDEO_EXTENSION_LENGTH) - playedSegments: Optional[str] = Field(alias=VIDEO_EXTENSION_PLAYED_SEGMENTS) - progress: Optional[float] = Field(alias=VIDEO_EXTENSION_PROGRESS) + timeFrom: NonNegativeFloat = Field(alias=VIDEO_EXTENSION_TIME_FROM) + timeTo: NonNegativeFloat = Field(alias=VIDEO_EXTENSION_TIME_TO) class Config: # pylint: disable=missing-class-docstring min_anystr_length = 0 -class VideoCompletedResultExtensionsField(VideoActionResultExtensionsField): +class VideoCompletedResultExtensionsField(VideoResultExtensionsField): """Represents the result.extensions field for video `completed` xAPI statement. Attributes: - playedSegments (str): Consists of parts of the video the actor watched during - current registration in chronological order. progress (float): Consists of the percentage of media consumed by the actor. """ - playedSegments: Optional[str] = Field(alias=VIDEO_EXTENSION_PLAYED_SEGMENTS) - progress: Optional[float] = Field(alias=VIDEO_EXTENSION_PROGRESS) + progress: NonNegativeFloat = Field(alias=VIDEO_EXTENSION_PROGRESS) class Config: # pylint: disable=missing-class-docstring min_anystr_length = 0 -class VideoTerminatedResultExtensionsField(VideoActionResultExtensionsField): +class VideoTerminatedResultExtensionsField(VideoResultExtensionsField): """Represents the result.extensions field for video `terminated` xAPI statement. Attributes: - playedSegments (str): Consists of parts of the video the actor watched during - current registration in chronological order. progress (float): Consists of the percentage of media consumed by the actor. """ - playedSegments: Optional[str] = Field(alias=VIDEO_EXTENSION_PLAYED_SEGMENTS) - progress: Optional[float] = Field(alias=VIDEO_EXTENSION_PROGRESS) + progress: NonNegativeFloat = Field(alias=VIDEO_EXTENSION_PROGRESS) + + class Config: # pylint: disable=missing-class-docstring + min_anystr_length = 0 + + +class VideoEnableClosedCaptioningResultExtensionsField(VideoResultExtensionsField): + """Represents the result.extensions field for video enable closed captioning + `interacted` xAPI statement. + + Attributes: + ccEnabled (bool): Indicates whether subtitles are enabled. + """ + + ccEnabled: bool = Field(alias=VIDEO_EXTENSION_CC_ENABLED) class Config: # pylint: disable=missing-class-docstring min_anystr_length = 0 @@ -104,10 +105,10 @@ class VideoPlayedResultField(ResultField): """Represents the result field for video `played` xAPI statement. Attributes: - extensions (dict): See VideoActionResultExtensionsField. + extensions (dict): See VideoResultExtensionsField. """ - extensions: Optional[VideoActionResultExtensionsField] + extensions: VideoResultExtensionsField class VideoPausedResultField(ResultField): @@ -117,7 +118,7 @@ class VideoPausedResultField(ResultField): extensions (dict): See VideoPausedResultExtensionsField. """ - extensions: Optional[VideoPausedResultExtensionsField] + extensions: VideoPausedResultExtensionsField class VideoSeekedResultField(ResultField): @@ -127,7 +128,7 @@ class VideoSeekedResultField(ResultField): extensions (dict): See VideoSeekedResultExtensionsField. """ - extensions: Optional[VideoSeekedResultExtensionsField] + extensions: VideoSeekedResultExtensionsField class VideoCompletedResultField(ResultField): @@ -140,7 +141,7 @@ class VideoCompletedResultField(ResultField): current registration. """ - extensions: Optional[VideoCompletedResultExtensionsField] + extensions: VideoCompletedResultExtensionsField completion: Optional[Literal[True]] duration: Optional[timedelta] @@ -152,14 +153,36 @@ class VideoTerminatedResultField(ResultField): extensions (dict): See VideoTerminatedResultExtensionsField. """ - extensions: Optional[VideoTerminatedResultExtensionsField] + extensions: VideoTerminatedResultExtensionsField -class VideoInteractedResultField(ResultField): - """Represents the result field for video `terminated` xAPI statement. +class VideoEnableClosedCaptioningResultField(ResultField): + """Represents the result field for video enable closed captioning + `interacted` xAPI statement. + + Attributes: + extensions (dict): See VideoEnableClosedCaptioningResultExtensionsField. + """ + + extensions: VideoEnableClosedCaptioningResultExtensionsField + + +class VideoVolumeChangeInteractionResultField(ResultField): + """Represents the result field for video volume change + `interacted` xAPI statement. + + Attributes: + extensions (dict): See VideoResultExtensionsField. + """ + + extensions: VideoResultExtensionsField + + +class VideoScreenChangeInteractionResultField(ResultField): + """Represents the result field for video screen change `interacted` xAPI statement. Attributes: - extensions (dict): See VideoActionResultExtensionsField. + extensions (dict): See VideoResultExtensionsField. """ - extensions: Optional[VideoActionResultExtensionsField] + extensions: VideoResultExtensionsField diff --git a/src/ralph/models/xapi/video/statements.py b/src/ralph/models/xapi/video/statements.py index c7532349a..c8bbdbd01 100644 --- a/src/ralph/models/xapi/video/statements.py +++ b/src/ralph/models/xapi/video/statements.py @@ -1,24 +1,30 @@ """Video xAPI event definitions""" +from typing import Optional + from ...selector import selector from ..base import BaseXapiModel from .fields.contexts import ( VideoCompletedContextField, + VideoEnableClosedCaptioningContextField, VideoInitializedContextField, - VideoInteractedContextField, VideoPausedContextField, VideoPlayedContextField, + VideoScreenChangeInteractionContextField, VideoSeekedContextField, VideoTerminatedContextField, + VideoVolumeChangeInteractionContextField, ) from .fields.objects import VideoObjectField from .fields.results import ( VideoCompletedResultField, - VideoInteractedResultField, + VideoEnableClosedCaptioningResultField, VideoPausedResultField, VideoPlayedResultField, + VideoScreenChangeInteractionResultField, VideoSeekedResultField, VideoTerminatedResultField, + VideoVolumeChangeInteractionResultField, ) from .fields.verbs import ( VideoCompletedVerbField, @@ -56,7 +62,7 @@ class VideoInitialized(BaseVideoStatement): verb__id="http://adlnet.gov/expapi/verbs/initialized", ) - verb: VideoInitializedVerbField + verb: VideoInitializedVerbField = VideoInitializedVerbField() context: VideoInitializedContextField @@ -76,9 +82,9 @@ class VideoPlayed(BaseVideoStatement): verb__id="https://w3id.org/xapi/video/verbs/played", ) - verb: VideoPlayedVerbField + verb: VideoPlayedVerbField = VideoPlayedVerbField() result: VideoPlayedResultField - context: VideoPlayedContextField + context: Optional[VideoPlayedContextField] class VideoPaused(BaseVideoStatement): @@ -97,7 +103,7 @@ class VideoPaused(BaseVideoStatement): verb__id="https://w3id.org/xapi/video/verbs/paused", ) - verb: VideoPausedVerbField + verb: VideoPausedVerbField = VideoPausedVerbField() result: VideoPausedResultField context: VideoPausedContextField @@ -119,9 +125,9 @@ class VideoSeeked(BaseVideoStatement): verb__id="https://w3id.org/xapi/video/verbs/seeked", ) - verb: VideoSeekedVerbField + verb: VideoSeekedVerbField = VideoSeekedVerbField() result: VideoSeekedResultField - context: VideoSeekedContextField + context: Optional[VideoSeekedContextField] class VideoCompleted(BaseVideoStatement): @@ -140,7 +146,7 @@ class VideoCompleted(BaseVideoStatement): verb__id="http://adlnet.gov/expapi/verbs/completed", ) - verb: VideoCompletedVerbField + verb: VideoCompletedVerbField = VideoCompletedVerbField() result: VideoCompletedResultField context: VideoCompletedContextField @@ -161,21 +167,62 @@ class VideoTerminated(BaseVideoStatement): verb__id="http://adlnet.gov/expapi/verbs/terminated", ) - verb: VideoTerminatedVerbField + verb: VideoTerminatedVerbField = VideoTerminatedVerbField() result: VideoTerminatedResultField context: VideoTerminatedContextField -class VideoInteracted(BaseVideoStatement): - """Represents a video terminated xAPI statement. +class VideoEnableClosedCaptioning(BaseVideoStatement): + """Represents a video enable closed captioning xAPI statement. + + Example: John interacted with the player to enable closed captioning. + + Attributes: + verb (dict): See VideoInteractedVerbField. + result (dict): See VideoEnableClosedCaptioningResultField. + context (dict): See VideoEnableClosedCaptioningContextField. + """ + + __selector__ = selector( + object__definition__type="https://w3id.org/xapi/video/activity-type/video", + verb__id="http://adlnet.gov/expapi/verbs/interacted", + ) + + verb: VideoInteractedVerbField = VideoInteractedVerbField() + result: VideoEnableClosedCaptioningResultField + context: VideoEnableClosedCaptioningContextField + + +class VideoVolumeChangeInteraction(BaseVideoStatement): + """Represents a video volume change interaction xAPI statement. + + Example: John interacted with the player to change the volume. + + Attributes: + verb (dict): See VideoInteractedVerbField. + result (dict): See VideoVolumeChangeInteractionResultField. + context (dict): See VideoVolumeChangeInteractionContextField. + """ + + __selector__ = selector( + object__definition__type="https://w3id.org/xapi/video/activity-type/video", + verb__id="http://adlnet.gov/expapi/verbs/interacted", + ) + + verb: VideoInteractedVerbField = VideoInteractedVerbField() + result: VideoVolumeChangeInteractionResultField + context: VideoVolumeChangeInteractionContextField + + +class VideoScreenChangeInteraction(BaseVideoStatement): + """Represents a video screen change interaction xAPI statement. - Example: John interacted with the player (except play, pause, seek). e.g. mute, - unmute, change resolution, change player size, etc. + Example: John interacted with the player to activate or deactivate full screen. Attributes: verb (dict): See VideoInteractedVerbField. - result (dict): See VideoInteractedResultField. - context (dict): See VideoInteractedContextField. + result (dict): See VideoScreenChangeInteractionResultField. + context (dict): See VideoScreenChangeInteractionContextField. """ __selector__ = selector( @@ -183,6 +230,6 @@ class VideoInteracted(BaseVideoStatement): verb__id="http://adlnet.gov/expapi/verbs/interacted", ) - verb: VideoInteractedVerbField - result: VideoInteractedResultField - context: VideoInteractedContextField + verb: VideoInteractedVerbField = VideoInteractedVerbField() + result: VideoScreenChangeInteractionResultField + context: VideoScreenChangeInteractionContextField diff --git a/tests/fixtures/hypothesis_strategies.py b/tests/fixtures/hypothesis_strategies.py index f9c09a43b..0798a13c2 100644 --- a/tests/fixtures/hypothesis_strategies.py +++ b/tests/fixtures/hypothesis_strategies.py @@ -12,7 +12,7 @@ from ralph.models.xapi.fields.contexts import ContextField from ralph.models.xapi.fields.results import ScoreResultField -OVERWRITTEN_STATEGIES = {} +OVERWRITTEN_STRATEGIES = {} def is_base_model(klass): @@ -62,7 +62,7 @@ def custom_builds( present (True) or omitted (False) in the generated model. """ - for special_class, special_kwargs in OVERWRITTEN_STATEGIES.items(): + for special_class, special_kwargs in OVERWRITTEN_STRATEGIES.items(): if issubclass(klass, special_class): kwargs = special_kwargs | kwargs break @@ -93,8 +93,8 @@ def custom_given(*args: Union[st.SearchStrategy, BaseModel], **kwargs): return given(*strategies, **kwargs) -OVERWRITTEN_STATEGIES = { - UISeqPrev: { # pylint: disable=unhashable-member +OVERWRITTEN_STRATEGIES = { + UISeqPrev: { "event": custom_builds(NavigationalEventField, old=st.just(1), new=st.just(0)) }, UISeqNext: { # pylint: disable=unhashable-member diff --git a/tests/models/edx/converters/xapi/test_navigational.py b/tests/models/edx/converters/xapi/test_navigational.py index ef15640b1..08feaf19c 100644 --- a/tests/models/edx/converters/xapi/test_navigational.py +++ b/tests/models/edx/converters/xapi/test_navigational.py @@ -1,4 +1,4 @@ -"""Tests for the server event xAPI converter""" +"""Tests for the navigational event xAPI converter""" import json from uuid import UUID, uuid5 @@ -18,8 +18,8 @@ def test_navigational_ui_page_close_to_page_terminated( uuid_namespace, event, platform_url ): - """Tests that ServerEventToPageViewed.convert returns a JSON string with a - constant UUID. + """Tests that converting with UIPageCloseToPageTerminated returns the expected xAPI + statement. """ event.context.course_id = "" diff --git a/tests/models/edx/converters/xapi/test_server.py b/tests/models/edx/converters/xapi/test_server.py index bad6891b4..0ef911d84 100644 --- a/tests/models/edx/converters/xapi/test_server.py +++ b/tests/models/edx/converters/xapi/test_server.py @@ -18,7 +18,7 @@ def test_models_edx_converters_xapi_server_server_event_to_xapi_convert_constant_uuid( uuid_namespace, event, platform_url ): - """Tests that ServerEventToPageViewed.convert returns a JSON string with a + """Tests that `ServerEventToPageViewed.convert` returns a JSON string with a constant UUID. """ @@ -39,7 +39,7 @@ def test_models_edx_converters_xapi_server_server_event_to_xapi_convert_constant def test_models_edx_converters_xapi_server_server_event_to_xapi_convert_with_valid_event( # noqa uuid_namespace, event, platform_url ): - """Tests that converting with ServerEventToPageViewed returns the expected xAPI + """Tests that converting with `ServerEventToPageViewed` returns the expected xAPI statement. """ @@ -80,7 +80,7 @@ def test_models_edx_converters_xapi_server_server_event_to_xapi_convert_with_val def test_models_edx_converters_xapi_server_server_event_to_xapi_convert_with_anonymous_user( # noqa uuid_namespace, event, platform_url ): - """Tests that anonymous usernames are replaced with with `anonymous`.""" + """Tests that anonymous usernames are replaced with `anonymous`.""" event.context.user_id = "" event_str = event.json() diff --git a/tests/models/edx/converters/xapi/test_video.py b/tests/models/edx/converters/xapi/test_video.py new file mode 100644 index 000000000..1b871e853 --- /dev/null +++ b/tests/models/edx/converters/xapi/test_video.py @@ -0,0 +1,292 @@ +"""Tests for the video event xAPI converter""" + +import json +from uuid import UUID, uuid5 + +import pytest +from hypothesis import provisional + +from ralph.models.converter import convert_dict_event +from ralph.models.edx.converters.xapi.video import ( + UILoadVideoToVideoInitialized, + UIPauseVideoToVideoPaused, + UIPlayVideoToVideoPlayed, + UISeekVideoToVideoSeeked, + UIStopVideoToVideoTerminated, +) +from ralph.models.edx.video.statements import ( + UILoadVideo, + UIPauseVideo, + UIPlayVideo, + UISeekVideo, + UIStopVideo, +) + +from tests.fixtures.hypothesis_strategies import custom_given + + +@custom_given(UILoadVideo, provisional.urls()) +@pytest.mark.parametrize("uuid_namespace", ["ee241f8b-174f-5bdb-bae9-c09de5fe017f"]) +def test_ui_load_video_to_video_initialized(uuid_namespace, event, platform_url): + """Tests that converting with `UILoadVideoToVideoInitialized` returns the + expected xAPI statement. + """ + + event.context.course_id = "" + event.context.org_id = "" + event.context.user_id = "1" + event_str = event.json() + event = json.loads(event_str) + xapi_event = convert_dict_event( + event, event_str, UILoadVideoToVideoInitialized(uuid_namespace, platform_url) + ) + xapi_event_dict = json.loads(xapi_event.json(exclude_none=True, by_alias=True)) + + assert xapi_event_dict == { + "id": str(uuid5(UUID(uuid_namespace), event_str)), + "actor": {"account": {"homePage": platform_url, "name": "1"}}, + "verb": { + "id": "http://adlnet.gov/expapi/verbs/initialized", + "display": {"en-US": "initialized"}, + }, + "context": { + "extensions": { + "https://w3id.org/xapi/video/extensions/length": 0.0, + "https://w3id.org/xapi/video/extensions/session-id": str( + UUID(event["session"]) + ), + "https://w3id.org/xapi/video/extensions/user-agent": event["agent"], + } + }, + "object": { + "id": platform_url + + "/xblock/block-v1:" + + event["context"]["course_id"] + + "-course-v1:+type@video+block@" + + event["event"]["id"], + "definition": { + "type": "https://w3id.org/xapi/video/activity-type/video", + "name": {"en-US": event["event"]["id"]}, + }, + }, + "timestamp": event["time"], + "version": "1.0.0", + } + + +@custom_given(UIPlayVideo, provisional.urls()) +@pytest.mark.parametrize("uuid_namespace", ["ee241f8b-174f-5bdb-bae9-c09de5fe017f"]) +def test_ui_play_video_to_video_played(uuid_namespace, event, platform_url): + """Tests that converting with `UIPlayVideoToVideoPlayed` returns the expected + xAPI statement. + """ + + event.context.course_id = "" + event.context.org_id = "" + event.context.user_id = "1" + event_str = event.json() + event = json.loads(event_str) + xapi_event = convert_dict_event( + event, event_str, UIPlayVideoToVideoPlayed(uuid_namespace, platform_url) + ) + xapi_event_dict = json.loads(xapi_event.json(exclude_none=True, by_alias=True)) + assert xapi_event_dict == { + "id": str(uuid5(UUID(uuid_namespace), event_str)), + "actor": {"account": {"homePage": platform_url, "name": "1"}}, + "verb": { + "id": "https://w3id.org/xapi/video/verbs/played", + "display": {"en-US": "played"}, + }, + "object": { + "id": platform_url + + "/xblock/block-v1:" + + event["context"]["course_id"] + + "-course-v1:+type@video+block@" + + event["event"]["id"], + "definition": { + "type": "https://w3id.org/xapi/video/activity-type/video", + "name": {"en-US": event["event"]["id"]}, + }, + }, + "result": { + "extensions": { + "https://w3id.org/xapi/video/extensions/time": event["event"][ + "currentTime" + ] + } + }, + "context": { + "extensions": { + "https://w3id.org/xapi/video/extensions/session-id": str( + UUID(event["session"]) + ), + }, + }, + "timestamp": event["time"], + "version": "1.0.0", + } + + +@custom_given(UIPauseVideo, provisional.urls()) +@pytest.mark.parametrize("uuid_namespace", ["ee241f8b-174f-5bdb-bae9-c09de5fe017f"]) +def test_ui_pause_video_to_video_paused(uuid_namespace, event, platform_url): + """Tests that converting with `UIPauseVideoToVideoPaused` returns the expected xAPI + statement. + """ + + event.context.course_id = "" + event.context.org_id = "" + event.context.user_id = "1" + event_str = event.json() + event = json.loads(event_str) + xapi_event = convert_dict_event( + event, event_str, UIPauseVideoToVideoPaused(uuid_namespace, platform_url) + ) + xapi_event_dict = json.loads(xapi_event.json(exclude_none=True, by_alias=True)) + assert xapi_event_dict == { + "id": str(uuid5(UUID(uuid_namespace), event_str)), + "actor": {"account": {"homePage": platform_url, "name": "1"}}, + "verb": { + "id": "https://w3id.org/xapi/video/verbs/paused", + "display": {"en-US": "paused"}, + }, + "object": { + "id": platform_url + + "/xblock/block-v1:" + + event["context"]["course_id"] + + "-course-v1:+type@video+block@" + + event["event"]["id"], + "definition": { + "type": "https://w3id.org/xapi/video/activity-type/video", + "name": {"en-US": event["event"]["id"]}, + }, + }, + "context": { + "extensions": { + "https://w3id.org/xapi/video/extensions/length": 0.0, + "https://w3id.org/xapi/video/extensions/session-id": str( + UUID(event["session"]) + ), + } + }, + "result": { + "extensions": { + "https://w3id.org/xapi/video/extensions/time": event["event"][ + "currentTime" + ] + } + }, + "timestamp": event["time"], + "version": "1.0.0", + } + + +@custom_given(UIStopVideo, provisional.urls()) +@pytest.mark.parametrize("uuid_namespace", ["ee241f8b-174f-5bdb-bae9-c09de5fe017f"]) +def test_ui_stop_video_to_video_terminated(uuid_namespace, event, platform_url): + """Tests that converting with `UIStopVideoToVideoTerminated` returns the expected + xAPI statement. + """ + + event.context.course_id = "" + event.context.org_id = "" + event.context.user_id = "1" + event_str = event.json() + event = json.loads(event_str) + xapi_event = convert_dict_event( + event, event_str, UIStopVideoToVideoTerminated(uuid_namespace, platform_url) + ) + xapi_event_dict = json.loads(xapi_event.json(exclude_none=True, by_alias=True)) + assert xapi_event_dict == { + "id": str(uuid5(UUID(uuid_namespace), event_str)), + "actor": {"account": {"homePage": platform_url, "name": "1"}}, + "verb": { + "id": "http://adlnet.gov/expapi/verbs/terminated", + "display": {"en-US": "terminated"}, + }, + "object": { + "id": platform_url + + "/xblock/block-v1:" + + event["context"]["course_id"] + + "-course-v1:+type@video+block@" + + event["event"]["id"], + "definition": { + "type": "https://w3id.org/xapi/video/activity-type/video", + "name": {"en-US": event["event"]["id"]}, + }, + }, + "context": { + "extensions": { + "https://w3id.org/xapi/video/extensions/length": 0.0, + "https://w3id.org/xapi/video/extensions/session-id": str( + UUID(event["session"]) + ), + } + }, + "result": { + "extensions": { + "https://w3id.org/xapi/video/extensions/time": event["event"][ + "currentTime" + ], + "https://w3id.org/xapi/video/extensions/progress": 0.0, + } + }, + "timestamp": event["time"], + "version": "1.0.0", + } + + +@custom_given(UISeekVideo, provisional.urls()) +@pytest.mark.parametrize("uuid_namespace", ["ee241f8b-174f-5bdb-bae9-c09de5fe017f"]) +def test_ui_seek_video_to_video_seeked(uuid_namespace, event, platform_url): + """Tests that converting with `UISeekVideoToVideoSeeked` returns the expected + xAPI statement. + """ + + event.context.course_id = "" + event.context.org_id = "" + event.context.user_id = "1" + event_str = event.json() + event = json.loads(event_str) + xapi_event = convert_dict_event( + event, event_str, UISeekVideoToVideoSeeked(uuid_namespace, platform_url) + ) + xapi_event_dict = json.loads(xapi_event.json(exclude_none=True, by_alias=True)) + assert xapi_event_dict == { + "id": str(uuid5(UUID(uuid_namespace), event_str)), + "actor": {"account": {"homePage": platform_url, "name": "1"}}, + "verb": { + "id": "https://w3id.org/xapi/video/verbs/seeked", + "display": {"en-US": "seeked"}, + }, + "object": { + "id": platform_url + + "/xblock/block-v1:" + + event["context"]["course_id"] + + "-course-v1:+type@video+block@" + + event["event"]["id"], + "definition": { + "type": "https://w3id.org/xapi/video/activity-type/video", + "name": {"en-US": event["event"]["id"]}, + }, + }, + "result": { + "extensions": { + "https://w3id.org/xapi/video/extensions/time-from": event["event"][ + "old_time" + ], + "https://w3id.org/xapi/video/extensions/time-to": event["event"][ + "new_time" + ], + } + }, + "context": { + "extensions": { + "https://w3id.org/xapi/video/extensions/session-id": str( + UUID(event["session"]) + ), + }, + }, + "timestamp": event["time"], + "version": "1.0.0", + } diff --git a/tests/models/xapi/test_video.py b/tests/models/xapi/test_video.py index c756ef842..0688b5930 100644 --- a/tests/models/xapi/test_video.py +++ b/tests/models/xapi/test_video.py @@ -6,14 +6,17 @@ from hypothesis import strategies as st from ralph.models.selector import ModelSelector +from ralph.models.validator import Validator from ralph.models.xapi.video.statements import ( VideoCompleted, + VideoEnableClosedCaptioning, VideoInitialized, - VideoInteracted, VideoPaused, VideoPlayed, + VideoScreenChangeInteraction, VideoSeeked, VideoTerminated, + VideoVolumeChangeInteraction, ) from tests.fixtures.hypothesis_strategies import custom_builds, custom_given @@ -24,7 +27,6 @@ [ VideoCompleted, VideoInitialized, - VideoInteracted, VideoPaused, VideoPlayed, VideoSeeked, @@ -41,6 +43,29 @@ def test_models_xapi_video_selectors_with_valid_statements(class_, data): model = ModelSelector(module="ralph.models.xapi").get_first_model(statement) assert model is class_ +@pytest.mark.parametrize( + "class_", + [ + VideoVolumeChangeInteraction, + VideoEnableClosedCaptioning, + VideoScreenChangeInteraction, + ], +) +@custom_given(st.data()) +def test_models_xapi_video_interaction_validator_with_valid_statements(class_, data): + """Tests given a valid video interaction xAPI statement the `get_first_valid_model` + validator method should return the expected model. + """ + + statement = json.loads( + data.draw(custom_builds(class_)).json(exclude_none=True, by_alias=True) + ) + + model = Validator(ModelSelector(module="ralph.models.xapi")).get_first_valid_model( + statement + ) + + assert isinstance(model, class_) @custom_given(VideoInitialized) def test_models_xapi_video_initialized_with_valid_statement(statement): @@ -84,8 +109,25 @@ def test_models_xapi_video_terminated_with_valid_statement(statement): assert statement.verb.id == "http://adlnet.gov/expapi/verbs/terminated" -@custom_given(VideoInteracted) -def test_models_xapi_video_interacted_with_valid_statement(statement): - """Tests that a video interacted statement has the expected verb.id.""" +@custom_given(VideoEnableClosedCaptioning) +def test_models_xapi_video_enable_closed_captioning_with_valid_statement(statement): + """Tests that a video enable closed captioning statement has the expected + verb.id.""" + + assert statement.verb.id == "http://adlnet.gov/expapi/verbs/interacted" + + +@custom_given(VideoVolumeChangeInteraction) +def test_models_xapi_video_volume_change_interaction_with_valid_statement(statement): + """Tests that a video volume change interaction statement has the expected + verb.id.""" + + assert statement.verb.id == "http://adlnet.gov/expapi/verbs/interacted" + + +@custom_given(VideoScreenChangeInteraction) +def test_models_xapi_video_screen_change_interaction_with_valid_statement(statement): + """Tests that a video screen change interaction statement has the expected + verb.id.""" assert statement.verb.id == "http://adlnet.gov/expapi/verbs/interacted" From ad2967529edf97cff37116803cef60f2eb781c49 Mon Sep 17 00:00:00 2001 From: Quitterie Lucas Date: Mon, 31 Oct 2022 11:56:29 +0100 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=9A=9A(models)=20restructure=20tests?= =?UTF-8?q?=20files=20for=20edx=20models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When statements and events fields have to be tested for the edx models, two separate files are created for statements and events fields tests respectively. --- tests/models/edx/navigational/__init__.py | 0 tests/models/edx/navigational/test_events.py | 66 +++++++++ .../test_statements.py} | 0 .../edx/textbook_interaction/__init__.py | 0 .../edx/textbook_interaction/test_events.py | 136 ++++++++++++++++++ .../test_statements.py} | 128 ----------------- tests/models/xapi/test_video.py | 4 +- 7 files changed, 205 insertions(+), 129 deletions(-) create mode 100644 tests/models/edx/navigational/__init__.py create mode 100644 tests/models/edx/navigational/test_events.py rename tests/models/edx/{test_navigational.py => navigational/test_statements.py} (100%) create mode 100644 tests/models/edx/textbook_interaction/__init__.py create mode 100644 tests/models/edx/textbook_interaction/test_events.py rename tests/models/edx/{test_textbook_interaction.py => textbook_interaction/test_statements.py} (63%) diff --git a/tests/models/edx/navigational/__init__.py b/tests/models/edx/navigational/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/models/edx/navigational/test_events.py b/tests/models/edx/navigational/test_events.py new file mode 100644 index 000000000..9c14a6ab3 --- /dev/null +++ b/tests/models/edx/navigational/test_events.py @@ -0,0 +1,66 @@ +"""Tests for the navigational models event fields""" + +import json +import re + +import pytest +from pydantic.error_wrappers import ValidationError + +from ralph.models.edx.navigational.fields.events import NavigationalEventField + +from tests.fixtures.hypothesis_strategies import custom_given + + +@custom_given(NavigationalEventField) +def test_fields_edx_navigational_events_event_field_with_valid_content(field): + """Tests that a valid `NavigationalEventField` does not raise a + `ValidationError`. + """ + + assert re.match( + ( + r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+" + r"type@sequential\+block@[a-f0-9]{32}$" + ), + field.id, + ) + + +@pytest.mark.parametrize( + "id", + [ + ( + "block-v2:orgX=CS111+20_T1+type@sequential" + "+block@d0d4a647742943e3951b45d9db8a0ea1" + ), + ( + "block-v1:orgX=CS11120_T1+type@sequential" + "+block@d0d4a647742943e3951b45d9db8a0ea1" + ), + ( + "block-v1:orgX=CS111=20_T1+tipe@sequential" + "+block@d0d4a647742943e3951b45d9db8a0ea1" + ), + "block-v1:orgX=CS111=20_T1+", + "type@sequentialblock@d0d4a647742943e3951b45d9db8a0ea1", + ( + "block-v1:orgX=CS111=20_T1+type@sequential" + "+block@d0d4a647742943z3951b45d9db8a0ea1" + ), + ( + "block-v1:orgX=CS111=20_T1+type@sequential" + "+block@d0d4a647742943e3951b45d9db8a0ea13" + ), + ], # pylint: disable=invalid-name +) +@custom_given(NavigationalEventField) +def test_fields_edx_navigational_events_event_field_with_invalid_content( + id, field # pylint: disable=redefined-builtin, invalid-name +): + """Tests that an invalid `NavigationalEventField` raises a `ValidationError`.""" + + invalid_field = json.loads(field.json()) + invalid_field["id"] = id + + with pytest.raises(ValidationError, match="id\n string does not match regex"): + NavigationalEventField(**invalid_field) diff --git a/tests/models/edx/test_navigational.py b/tests/models/edx/navigational/test_statements.py similarity index 100% rename from tests/models/edx/test_navigational.py rename to tests/models/edx/navigational/test_statements.py diff --git a/tests/models/edx/textbook_interaction/__init__.py b/tests/models/edx/textbook_interaction/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/models/edx/textbook_interaction/test_events.py b/tests/models/edx/textbook_interaction/test_events.py new file mode 100644 index 000000000..823bac767 --- /dev/null +++ b/tests/models/edx/textbook_interaction/test_events.py @@ -0,0 +1,136 @@ +"""Tests for textbook interaction models event fields""" + +import json +import re + +import pytest +from pydantic.error_wrappers import ValidationError + +from ralph.models.edx.textbook_interaction.fields.events import ( + TextbookInteractionBaseEventField, + TextbookPdfChapterNavigatedEventField, +) + +from tests.fixtures.hypothesis_strategies import custom_given + + +@custom_given(TextbookInteractionBaseEventField) +def test_fields_edx_textbook_interaction_base_event_field_with_valid_content(field): + """Tests that a valid `TextbookInteractionBaseEventField` does not raise + a `ValidationError`. + """ + + assert re.match( + (r"^\/asset-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+type@asset\+block.+$"), + field.chapter, + ) + + +@pytest.mark.parametrize( + "chapter", + ( + ( + "block-v2:orgX=CS11+20_T1+type@sequential" + "+block@d0d4a647742943e3951b45d9db8a0ea1file.pdf" + ), + ( + "block-v1:orgX=CS1120_T1+type@sequential" + "+block@d0d4a647742943e3951b45d9db8a0ea1file.pdf" + ), + ( + "block-v1:orgX=CS11=20_T1+tipe@sequential" + "+block@d0d4a647742943e3951b45d9db8a0ea1file.pdf" + ), + "block-v1:orgX=CS11+20_T1+", + "type@sequentialblock@d0d4a647742943e3951b45d9db8a0ea1file.pdf", + ( + "block-v1:orgX=CS11+20_T1+type@sequential" + "+block@d0d4a647742943z3951b45d9db8a0ea1file.pdf" + ), + ( + "block-v1:orgX=CS11+20_T1+type@sequential" + "+block@d0d4a647742943e3951b45d9db8a0ea13file.pdf" + ), + ( + "block-v1:orgX=CS11+20_T1+type@sequential" + "+block@d0d4a647742943e3951b45d9db8a0ea1file" + ), + ( + "block-v1:orgX=CS11+20_T1+type@sequential" + "+block@d0d4a647742943e3951b45d9db8a0ea1file.jpg" + ), + ), +) +@custom_given(TextbookInteractionBaseEventField) +def test_fields_edx_textbook_interaction_base_event_field_with_invalid_content( + chapter, field +): + """Tests that an invalid `TextbookInteractionBaseEventField` raises a + `ValidationError`. + """ + + invalid_field = json.loads(field.json()) + invalid_field["chapter"] = chapter + + with pytest.raises(ValidationError, match="chapter\n string does not match regex"): + TextbookInteractionBaseEventField(**invalid_field) + + +@custom_given(TextbookPdfChapterNavigatedEventField) +def test_fields_edx_textbook_pdf_chapter_navigated_event_field_with_valid_content( + field, +): + """Tests that a valid `TextbookPdfChapterNavigatedEventField` does not raise a + `ValidationError`. + """ + + assert re.match( + (r"^\/asset-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+type@asset\+block.+$"), + field.chapter, + ) + + +@pytest.mark.parametrize( + "chapter", + ( + ( + "asset-v2:orgX=CS11+20_T1+type@asset+" + "block@d0d4a647742943e3951b45d9db8a0ea1file.pdf" + ), + ( + "asset-v1:orgX=CS1120_T1+type@asset+" + "block@d0d4a647742943e3951b45d9db8a0ea1file.pdf" + ), + ( + "asset-v1:orgX=CS11=20_T1+tipe@asset+" + "block@d0d4a647742943e3951b45d9db8a0ea1file.pdf" + ), + "asset-v1:orgX=CS11+20_T1+", + "type@assetblock@d0d4a647742943e3951b45d9db8a0ea1file.pdf", + ( + "asset-v1:orgX=CS11+20_T1+type@asset+" + "block@d0d4a647742943z3951b45d9db8a0ea1file.pdf" + ), + ( + "asset-v1:orgX=CS11+20_T1+type@asset+" + "block@d0d4a647742943e3951b45d9db8a0ea13file.pdf" + ), + ( + "asset-v1:orgX=CS11+20_T1+type@asset+" + "block@d0d4a647742943e3951b45d9db8a0ea1file" + ), + ), +) +@custom_given(TextbookPdfChapterNavigatedEventField) +def test_fields_edx_textbook_pdf_chapter_navigated_event_field_with_invalid_content( + chapter, field +): + """Tests that an invalid `TextbookPdfChapterNavigatedEventField` raises a + `ValidationError`. + """ + + invalid_field = json.loads(field.json()) + invalid_field["chapter"] = chapter + + with pytest.raises(ValidationError, match="chapter\n string does not match regex"): + TextbookPdfChapterNavigatedEventField(**invalid_field) diff --git a/tests/models/edx/test_textbook_interaction.py b/tests/models/edx/textbook_interaction/test_statements.py similarity index 63% rename from tests/models/edx/test_textbook_interaction.py rename to tests/models/edx/textbook_interaction/test_statements.py index 83d2ca320..08e8e1e41 100644 --- a/tests/models/edx/test_textbook_interaction.py +++ b/tests/models/edx/textbook_interaction/test_statements.py @@ -1,16 +1,10 @@ """Tests for the textbook interaction event models""" import json -import re import pytest from hypothesis import strategies as st -from pydantic.error_wrappers import ValidationError -from ralph.models.edx.textbook_interaction.fields.events import ( - TextbookInteractionBaseEventField, - TextbookPdfChapterNavigatedEventField, -) from ralph.models.edx.textbook_interaction.statements import ( UIBook, UITextbookPdfChapterNavigated, @@ -64,128 +58,6 @@ def test_models_edx_ui_textbook_interaction_selectors_with_valid_statements( assert model is class_ -@custom_given(TextbookInteractionBaseEventField) -def test_fields_edx_textbook_interaction_base_event_field_with_valid_content(field): - """Tests that a valid `TextbookInteractionBaseEventField` does not raise - a `ValidationError`. - """ - - assert re.match( - (r"^\/asset-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+type@asset\+block.+$"), - field.chapter, - ) - - -@pytest.mark.parametrize( - "chapter", - ( - ( - "block-v2:orgX=CS11+20_T1+type@sequential" - "+block@d0d4a647742943e3951b45d9db8a0ea1file.pdf" - ), - ( - "block-v1:orgX=CS1120_T1+type@sequential" - "+block@d0d4a647742943e3951b45d9db8a0ea1file.pdf" - ), - ( - "block-v1:orgX=CS11=20_T1+tipe@sequential" - "+block@d0d4a647742943e3951b45d9db8a0ea1file.pdf" - ), - "block-v1:orgX=CS11+20_T1+", - "type@sequentialblock@d0d4a647742943e3951b45d9db8a0ea1file.pdf", - ( - "block-v1:orgX=CS11+20_T1+type@sequential" - "+block@d0d4a647742943z3951b45d9db8a0ea1file.pdf" - ), - ( - "block-v1:orgX=CS11+20_T1+type@sequential" - "+block@d0d4a647742943e3951b45d9db8a0ea13file.pdf" - ), - ( - "block-v1:orgX=CS11+20_T1+type@sequential" - "+block@d0d4a647742943e3951b45d9db8a0ea1file" - ), - ( - "block-v1:orgX=CS11+20_T1+type@sequential" - "+block@d0d4a647742943e3951b45d9db8a0ea1file.jpg" - ), - ), -) -@custom_given(TextbookInteractionBaseEventField) -def test_fields_edx_textbook_interaction_base_event_field_with_invalid_content( - chapter, field -): - """Tests that an invalid `TextbookInteractionBaseEventField` raises a - `ValidationError`. - """ - - invalid_field = json.loads(field.json()) - invalid_field["chapter"] = chapter - - with pytest.raises(ValidationError, match="chapter\n string does not match regex"): - TextbookInteractionBaseEventField(**invalid_field) - - -@custom_given(TextbookPdfChapterNavigatedEventField) -def test_fields_edx_textbook_pdf_chapter_navigated_event_field_with_valid_content( - field, -): - """Tests that a valid `TextbookPdfChapterNavigatedEventField` does not raise a - `ValidationError`. - """ - - assert re.match( - (r"^\/asset-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+type@asset\+block.+$"), - field.chapter, - ) - - -@pytest.mark.parametrize( - "chapter", - ( - ( - "asset-v2:orgX=CS11+20_T1+type@asset+" - "block@d0d4a647742943e3951b45d9db8a0ea1file.pdf" - ), - ( - "asset-v1:orgX=CS1120_T1+type@asset+" - "block@d0d4a647742943e3951b45d9db8a0ea1file.pdf" - ), - ( - "asset-v1:orgX=CS11=20_T1+tipe@asset+" - "block@d0d4a647742943e3951b45d9db8a0ea1file.pdf" - ), - "asset-v1:orgX=CS11+20_T1+", - "type@assetblock@d0d4a647742943e3951b45d9db8a0ea1file.pdf", - ( - "asset-v1:orgX=CS11+20_T1+type@asset+" - "block@d0d4a647742943z3951b45d9db8a0ea1file.pdf" - ), - ( - "asset-v1:orgX=CS11+20_T1+type@asset+" - "block@d0d4a647742943e3951b45d9db8a0ea13file.pdf" - ), - ( - "asset-v1:orgX=CS11+20_T1+type@asset+" - "block@d0d4a647742943e3951b45d9db8a0ea1file" - ), - ), -) -@custom_given(TextbookPdfChapterNavigatedEventField) -def test_fields_edx_textbook_pdf_chapter_navigated_event_field_with_invalid_content( - chapter, field -): - """Tests that an invalid `TextbookPdfChapterNavigatedEventField` raises a - `ValidationError`. - """ - - invalid_field = json.loads(field.json()) - invalid_field["chapter"] = chapter - - with pytest.raises(ValidationError, match="chapter\n string does not match regex"): - TextbookPdfChapterNavigatedEventField(**invalid_field) - - @custom_given(UIBook) def test_models_edx_ui_book_with_valid_statement(statement): """Tests that a `book` statement has the expected `event_type` and `name`.""" diff --git a/tests/models/xapi/test_video.py b/tests/models/xapi/test_video.py index 0688b5930..453dc866d 100644 --- a/tests/models/xapi/test_video.py +++ b/tests/models/xapi/test_video.py @@ -43,11 +43,12 @@ def test_models_xapi_video_selectors_with_valid_statements(class_, data): model = ModelSelector(module="ralph.models.xapi").get_first_model(statement) assert model is class_ + @pytest.mark.parametrize( "class_", [ VideoVolumeChangeInteraction, - VideoEnableClosedCaptioning, + VideoEnableClosedCaptioning, VideoScreenChangeInteraction, ], ) @@ -67,6 +68,7 @@ def test_models_xapi_video_interaction_validator_with_valid_statements(class_, d assert isinstance(model, class_) + @custom_given(VideoInitialized) def test_models_xapi_video_initialized_with_valid_statement(statement): """Tests that a video initialized statement has the expected verb.id.""" From 2d94ecd6f9d70fcfd96551bd386d9cf8bb4e2615 Mon Sep 17 00:00:00 2001 From: Quitterie Lucas Date: Wed, 2 Nov 2022 16:37:52 +0100 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=9A=A8(project)=20fix=20pylint=20warn?= =?UTF-8?q?ings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `unhashable-member` pylint warning has to be disabled for some parts of the code. --- tests/fixtures/hypothesis_strategies.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fixtures/hypothesis_strategies.py b/tests/fixtures/hypothesis_strategies.py index 0798a13c2..3e3878619 100644 --- a/tests/fixtures/hypothesis_strategies.py +++ b/tests/fixtures/hypothesis_strategies.py @@ -94,7 +94,7 @@ def custom_given(*args: Union[st.SearchStrategy, BaseModel], **kwargs): OVERWRITTEN_STRATEGIES = { - UISeqPrev: { + UISeqPrev: { # pylint: disable=unhashable-member "event": custom_builds(NavigationalEventField, old=st.just(1), new=st.just(0)) }, UISeqNext: { # pylint: disable=unhashable-member