|
| 1 | +""" |
| 2 | +Synchronize content and settings from upstream blocks (in content libraries) to their |
| 3 | +downstream usages (in courses, etc.) |
| 4 | +
|
| 5 | +At the time of writing, upstream blocks are assumed to come from content libraries. |
| 6 | +However, the XBlock fields are designed to be agnostic to their upstream's source context, |
| 7 | +so this assumption could be relaxed in the future if there is a need for upstreams from |
| 8 | +other kinds of learning contexts. |
| 9 | +""" |
| 10 | +import json |
| 11 | + |
| 12 | +from django.contrib.auth import get_user_model |
| 13 | +from opaque_keys import InvalidKeyError |
| 14 | +from opaque_keys.edx.keys import UsageKey |
| 15 | +from opaque_keys.edx.locator import LibraryUsageLocatorV2 |
| 16 | +from xblock.fields import Scope, String, Integer, List, Dict |
| 17 | +from xblock.core import XBlockMixin, XBlock |
| 18 | +from webob import Request, Response |
| 19 | + |
| 20 | +from openedx.core.djangoapps.content_libraries.api import ( |
| 21 | + get_library_block, |
| 22 | + LibraryXBlockMetadata, |
| 23 | + ContentLibraryBlockNotFound, |
| 24 | +) |
| 25 | +from openedx.core.djangoapps.xblock.api import load_block, NotFound as XBlockNotFound |
| 26 | + |
| 27 | + |
| 28 | +class UpstreamSyncMixin(XBlockMixin): |
| 29 | + """ |
| 30 | + @@TODO docstring |
| 31 | + """ |
| 32 | + |
| 33 | + upstream = String( |
| 34 | + scope=Scope.settings, |
| 35 | + help=( |
| 36 | + "The usage key of a block (generally within a Content Library) which serves as a source of upstream " |
| 37 | + "updates for this block, or None if there is no such upstream. Please note: It is valid for upstream_block " |
| 38 | + "to hold a usage key for a block that does not exist (or does not *yet* exist) on this instance, " |
| 39 | + "particularly if this block was imported from a different instance." |
| 40 | + ), |
| 41 | + hidden=True, |
| 42 | + default=None, |
| 43 | + enforce_type=True, |
| 44 | + ) |
| 45 | + upstream_version = Integer( |
| 46 | + scope=Scope.settings, |
| 47 | + help=( |
| 48 | + "The upstream_block's version number, at the time this block was created from it. " |
| 49 | + "If this version is older than the upstream_block's latest version, then CMS will " |
| 50 | + "allow this block to fetch updated content from upstream_block." |
| 51 | + ), |
| 52 | + hidden=True, |
| 53 | + default=None, |
| 54 | + enforce_type=True, |
| 55 | + ) |
| 56 | + upstream_overridden = List( |
| 57 | + scope=Scope.settings, |
| 58 | + help=( |
| 59 | + "@@TODO helptext" |
| 60 | + ), |
| 61 | + hidden=True, |
| 62 | + default=[], |
| 63 | + enforce_type=True, |
| 64 | + ) |
| 65 | + upstream_settings = Dict( |
| 66 | + scope=Scope.settings, |
| 67 | + help=( |
| 68 | + "@@TODO helptext" |
| 69 | + ), |
| 70 | + hidden=True, |
| 71 | + default={}, |
| 72 | + enforce_type=True, |
| 73 | + ) |
| 74 | + |
| 75 | + def save(self, *args, **kwargs): |
| 76 | + """ |
| 77 | + @@TODO docstring |
| 78 | + @@TODO use is_dirty instead of getattr for efficiency? |
| 79 | + """ |
| 80 | + for field_name, value in self.upstream_settings.items(): |
| 81 | + if field_name not in self.upstream_overridden: |
| 82 | + if value != getattr(self, field_name): |
| 83 | + self.upstream_overridden.append(field_name) |
| 84 | + super().save() |
| 85 | + |
| 86 | + @XBlock.handler |
| 87 | + def assign_upstream(self, request: Request, upstream_key: LibraryUsageLocatorV2, user_id: int) -> Response: |
| 88 | + """ |
| 89 | + Assign an upstream to this block and fetch upstream settings. |
| 90 | +
|
| 91 | + Does not save block; caller must do so. |
| 92 | +
|
| 93 | + @@TODO params |
| 94 | + """ |
| 95 | + if request.method != "POST": |
| 96 | + return Response(status_code=405) |
| 97 | + old_upstream = self.upstream |
| 98 | + self.upstream = str(upstream_key) |
| 99 | + try: |
| 100 | + self._sync_with_upstream(user_id=user_id, apply_updates=False) |
| 101 | + except BadUpstream as exc: |
| 102 | + self.upstream = old_upstream |
| 103 | + return Response(str(exc), status_code=400) |
| 104 | + self.save() |
| 105 | + return Response(status_code=204) |
| 106 | + |
| 107 | + @XBlock.handler |
| 108 | + def upstream_info(self, request: Request, _suffix=None) -> Response: |
| 109 | + """ |
| 110 | + @@TODO docstring |
| 111 | + @@TODO more data? |
| 112 | + """ |
| 113 | + if request.method != "GET": |
| 114 | + return Response(status_code=405) |
| 115 | + try: |
| 116 | + upstream_block, upstream_meta = self._load_upstream(request.user.id) |
| 117 | + except BadUpstream as exc: |
| 118 | + return Response(str(exc), status_code=400) |
| 119 | + return Response( |
| 120 | + json.dumps( |
| 121 | + { |
| 122 | + "usage_key": self.upstream, |
| 123 | + "version_current": self.upstream_version, |
| 124 | + "version_latest": upstream_meta.version_num if upstream_meta else None, |
| 125 | + }, |
| 126 | + ), |
| 127 | + indent=4, |
| 128 | + ) |
| 129 | + |
| 130 | + @XBlock.handler |
| 131 | + def update_from_upstream(self, request: Request, suffix=None) -> Response: |
| 132 | + """ |
| 133 | + @@TODO docstring |
| 134 | + """ |
| 135 | + if request.method != "POST": |
| 136 | + return Response(status_code=405) |
| 137 | + try: |
| 138 | + user_id = request.user.id if request and request.user else 0 |
| 139 | + self._sync_with_upstream(user_id=user_id, apply_updates=True) |
| 140 | + except BadUpstream as exc: |
| 141 | + return Response(str(exc), status_code=400) |
| 142 | + self.save() |
| 143 | + return Response(status_code=204) |
| 144 | + |
| 145 | + def _sync_with_upstream(self, *, user_id: int, apply_updates: bool) -> None: |
| 146 | + """ |
| 147 | + @@TODO docstring |
| 148 | +
|
| 149 | + Does not save block; caller must do so. |
| 150 | +
|
| 151 | + Can raise NoUpstream or BadUpstream. |
| 152 | + """ |
| 153 | + upstream, upstream_meta = self._load_upstream(user_id) |
| 154 | + self.upstream_settings = {} |
| 155 | + self.upstream_version = upstream_meta.version_num |
| 156 | + for field_name, field in upstream.fields.items(): |
| 157 | + if field.scope not in [Scope.settings, Scope.content]: |
| 158 | + continue |
| 159 | + value = getattr(upstream, field_name) |
| 160 | + if field.scope == Scope.settings: |
| 161 | + self.upstream_settings[field_name] = value |
| 162 | + if field_name in self.upstream_overridden: |
| 163 | + continue |
| 164 | + if not apply_updates: |
| 165 | + continue |
| 166 | + setattr(self, field_name, value) |
| 167 | + |
| 168 | + def _load_upstream(self, user_id: int) -> tuple[XBlock, LibraryXBlockMetadata]: |
| 169 | + """ |
| 170 | + This this block's upstream from a content library. |
| 171 | +
|
| 172 | + Raises BadUpstream if the upstream block could not be loaded for any reason. |
| 173 | + """ |
| 174 | + cannot_load = "Cannot load updates for component at '{self.usage_key}'" |
| 175 | + if not self.upstream: |
| 176 | + raise BadUpstream(f"{cannot_load}: no linked content library item") |
| 177 | + try: |
| 178 | + upstream_key = LibraryUsageLocatorV2.from_string(self.upstream) |
| 179 | + except InvalidKeyError as exc: |
| 180 | + raise BadUpstream( |
| 181 | + f"{cannot_load}: invalid content library item reference '{self.upstream}'" |
| 182 | + ) from exc |
| 183 | + try: |
| 184 | + upstream_meta = get_library_block(upstream_key) |
| 185 | + except ContentLibraryBlockNotFound as exc: |
| 186 | + raise BadUpstream( |
| 187 | + f"{cannot_load}: linked item '{upstream_key}' does not belong to a content library" |
| 188 | + ) from exc |
| 189 | + try: |
| 190 | + upstream = load_block(upstream_key, get_user_model().objects.get(id=user_id)) |
| 191 | + except XBlockNotFound as exc: |
| 192 | + raise BadUpstream( |
| 193 | + f"{cannot_load}: failed to load linked content library item at '{upstream_key}'. " |
| 194 | + "Either the item was deleted, or you lack permission to view its contents." |
| 195 | + ) from exc |
| 196 | + return upstream, upstream_meta |
| 197 | + |
| 198 | + |
| 199 | +class BadUpstream(Exception): |
| 200 | + """ |
| 201 | + Base exception for any content-level problems we can hit while loading a block's upstream. |
| 202 | +
|
| 203 | + Should not represent unexpected internal server errors. |
| 204 | + May appear in API responses, so they should be somewhat user friendly and avoid sensitive info. |
| 205 | + """ |
| 206 | + |
| 207 | + |
| 208 | +def is_valid_upstream(usage_key: UsageKey) -> bool: |
| 209 | + """ |
| 210 | + @@TODO docstring |
| 211 | + """ |
| 212 | + return isinstance(usage_key, LibraryUsageLocatorV2) |
0 commit comments