forked from openedx/edx-platform
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathtabs.py
652 lines (530 loc) · 23 KB
/
tabs.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
"""
Implement CourseTab
"""
import logging
from abc import ABCMeta
from django.core.files.storage import get_storage_class
from xblock.fields import List
from edx_django_utils.plugins import PluginError
log = logging.getLogger("edx.courseware")
# Make '_' a no-op so we can scrape strings. Using lambda instead of
# `django.utils.translation.ugettext_noop` because Django cannot be imported in this file
_ = lambda text: text
# A list of attributes on course tabs that can not be updated
READ_ONLY_COURSE_TAB_ATTRIBUTES = ['type']
class CourseTab(metaclass=ABCMeta):
"""
The Course Tab class is a data abstraction for all tabs (i.e., course navigation links) within a course.
It is an abstract class - to be inherited by various tab types.
Derived classes are expected to override methods as needed.
When a new tab class is created, it should define the type and add it in this class' factory method.
"""
# Class property that specifies the type of the tab. It is generally a constant value for a
# subclass, shared by all instances of the subclass.
type = ''
# The title of the tab, which should be internationalized using
# ugettext_noop since the user won't be available in this context.
title = None
# HTML class to add to the tab page's body, or None if no class it to be added
body_class = None
# Token to identify the online help URL, or None if no help is provided
online_help_token = None
# Class property that specifies whether the tab can be hidden for a particular course
is_hideable = False
# Class property that specifies whether the tab is hidden for a particular course
is_hidden = False
# The relative priority of this view that affects the ordering (lower numbers shown first)
priority = None
# Class property that specifies whether the tab can be moved within a course's list of tabs
is_movable = False
# Class property that specifies whether the tab is a collection of other tabs
is_collection = False
# True if this tab is dynamically added to the list of tabs
is_dynamic = False
# True if this tab is a default for the course (when enabled)
is_default = True
# True if this tab can be included more than once for a course.
allow_multiple = False
# If there is a single view associated with this tab, this is the name of it
view_name = None
# True if this tab should be displayed only for instructors
course_staff_only = False
# True if this tab supports showing staff users a preview menu
supports_preview_menu = False
def __init__(self, tab_dict):
"""
Initializes class members with values passed in by subclasses.
Args:
tab_dict (dict) - a dictionary of parameters used to build the tab.
"""
super().__init__()
self.name = tab_dict.get('name', self.title)
self.tab_id = tab_dict.get('tab_id', getattr(self, 'tab_id', self.type))
self.course_staff_only = tab_dict.get('course_staff_only', False)
self.is_hidden = tab_dict.get('is_hidden', False)
self.tab_dict = tab_dict
@property
def link_func(self):
"""
Returns a function that takes a course and reverse function and will
compute the course URL for this tab.
"""
return self.tab_dict.get('link_func', course_reverse_func(self.view_name))
@classmethod
def is_enabled(cls, course, user=None):
"""Returns true if this course tab is enabled in the course.
Args:
course (CourseBlock): the course using the feature
user (User): an optional user interacting with the course (defaults to None)
"""
raise NotImplementedError()
def get(self, key, default=None):
"""
Akin to the get method on Python dictionary objects, gracefully returns the value associated with the
given key, or the default if key does not exist.
"""
try:
return self[key]
except KeyError:
return default
def __getitem__(self, key):
"""
This method allows callers to access CourseTab members with the d[key] syntax as is done with
Python dictionary objects.
"""
if hasattr(self, key):
return getattr(self, key, None)
else:
raise KeyError(f'Key {key} not present in tab {self.to_json()}')
def __setitem__(self, key, value):
"""
This method allows callers to change CourseTab members with the d[key]=value syntax as is done with
Python dictionary objects. For example: course_tab['name'] = new_name
Note: the 'type' member can be 'get', but not 'set'.
"""
if hasattr(self, key) and key not in READ_ONLY_COURSE_TAB_ATTRIBUTES:
setattr(self, key, value)
else:
raise KeyError(f'Key {key} cannot be set in tab {self.to_json()}')
def __eq__(self, other):
"""
Overrides the equal operator to check equality of member variables rather than the object's address.
Also allows comparison with dict-type tabs (needed to support callers implemented before this class
was implemented).
"""
if isinstance(other, dict) and not self.validate(other, raise_error=False):
# 'other' is a dict-type tab and did not validate
return False
# allow tabs without names; if a name is required, its presence was checked in the validator.
name_is_eq = (other.get('name') is None or self.name == other['name'])
# only compare the persisted/serialized members: 'type' and 'name'
return self.type == other.get('type') and name_is_eq
def __ne__(self, other):
"""
Overrides the not equal operator as a partner to the equal operator.
"""
return not self == other
def __hash__(self):
""" Return a hash representation of Tab Object. """
return hash(repr(self))
@classmethod
def validate(cls, tab_dict, raise_error=True):
"""
Validates the given dict-type tab object to ensure it contains the expected keys.
This method should be overridden by subclasses that require certain keys to be persisted in the tab.
"""
return key_checker(['type'])(tab_dict, raise_error)
@classmethod
def load(cls, type_name, **kwargs):
"""
Constructs a tab of the given type_name.
Args:
type_name (str) - the type of tab that should be constructed
**kwargs - any other keyword arguments needed for constructing this tab
Returns:
an instance of the CourseTab subclass that matches the type_name
"""
json_dict = kwargs.copy()
json_dict['type'] = type_name
return cls.from_json(json_dict)
def to_json(self):
"""
Serializes the necessary members of the CourseTab object to a json-serializable representation.
This method is overridden by subclasses that have more members to serialize.
Returns:
a dictionary with keys for the properties of the CourseTab object.
"""
to_json_val = {'type': self.type, 'name': self.name, 'course_staff_only': self.course_staff_only}
if self.is_hidden:
to_json_val.update({'is_hidden': True})
return to_json_val
@staticmethod
def from_json(tab_dict):
"""
Deserializes a CourseTab from a json-like representation.
The subclass that is instantiated is determined by the value of the 'type' key in the
given dict-type tab. The given dict-type tab is validated before instantiating the CourseTab object.
If the tab_type is not recognized, then an exception is logged and None is returned.
The intention is that the user should still be able to use the course even if a
particular tab is not found for some reason.
Args:
tab: a dictionary with keys for the properties of the tab.
Raises:
InvalidTabsException if the given tab doesn't have the right keys.
"""
# TODO: don't import openedx capabilities from common
from openedx.core.lib.course_tabs import CourseTabPluginManager
tab_type_name = tab_dict.get('type')
if tab_type_name is None:
log.error('No type included in tab_dict: %r', tab_dict)
return None
try:
tab_type = CourseTabPluginManager.get_plugin(tab_type_name)
except PluginError:
log.exception(
"Unknown tab type %r Known types: %r.",
tab_type_name,
CourseTabPluginManager.get_tab_types()
)
return None
tab_type.validate(tab_dict)
return tab_type(tab_dict=tab_dict)
class TabFragmentViewMixin:
"""
A mixin for tabs that render themselves as web fragments.
"""
fragment_view_name = None
def __init__(self, tab_dict):
super().__init__(tab_dict)
self._fragment_view = None
@property
def link_func(self):
""" Returns a function that returns the course tab's URL. """
# If a view_name is specified, then use the default link function
if self.view_name:
return super().link_func
# If not, then use the generic course tab URL
def link_func(course, reverse_func):
""" Returns a function that returns the course tab's URL. """
return reverse_func("course_tab_view", args=[str(course.id), self.type])
return link_func
@property
def url_slug(self):
"""
Returns the slug to be included in this tab's URL.
"""
return "tab/" + self.type
@property
def fragment_view(self):
"""
Returns the view that will be used to render the fragment.
"""
if not self._fragment_view:
self._fragment_view = get_storage_class(self.fragment_view_name)()
return self._fragment_view
def render_to_fragment(self, request, course, **kwargs):
"""
Renders this tab to a web fragment.
"""
return self.fragment_view.render_to_fragment(request, course_id=str(course.id), **kwargs)
def __hash__(self):
""" Return a hash representation of Tab Object. """
return hash(repr(self))
class StaticTab(CourseTab):
"""
A custom tab.
"""
type = 'static_tab'
is_default = False # A static tab is never added to a course by default
is_movable = True
allow_multiple = True
priority = 100
def __init__(self, tab_dict=None, name=None, url_slug=None):
def link_func(course, reverse_func):
""" Returns a function that returns the static tab's URL. """
return reverse_func(self.type, args=[str(course.id), self.url_slug])
self.url_slug = tab_dict.get('url_slug') if tab_dict else url_slug
if tab_dict is None:
tab_dict = {}
if name is not None:
tab_dict['name'] = name
tab_dict['link_func'] = link_func
tab_dict['tab_id'] = f'static_tab_{self.url_slug}'
super().__init__(tab_dict)
@classmethod
def is_enabled(cls, course, user=None):
"""
Static tabs are viewable to everyone, even anonymous users.
"""
return True
@classmethod
def validate(cls, tab_dict, raise_error=True):
"""
Ensures that the specified tab_dict is valid.
"""
return (super().validate(tab_dict, raise_error)
and key_checker(['name', 'url_slug'])(tab_dict, raise_error))
def __getitem__(self, key):
if key == 'url_slug':
return self.url_slug
else:
return super().__getitem__(key)
def __setitem__(self, key, value):
if key == 'url_slug':
self.url_slug = value
else:
super().__setitem__(key, value)
def to_json(self):
""" Return a dictionary representation of this tab. """
to_json_val = super().to_json()
to_json_val.update({'url_slug': self.url_slug})
return to_json_val
def __eq__(self, other):
if not super().__eq__(other):
return False
return self.url_slug == other.get('url_slug')
def __hash__(self):
""" Return a hash representation of Tab Object. """
return hash(repr(self))
class CourseTabList(List):
"""
An XBlock field class that encapsulates a collection of Tabs in a course.
It is automatically created and can be retrieved through a CourseBlock object: course.tabs
"""
# TODO: Ideally, we'd like for this list of tabs to be dynamically
# generated by the tabs plugin code. For now, we're leaving it like this to
# preserve backwards compatibility.
@staticmethod
def initialize_default(course):
"""
An explicit initialize method is used to set the default values, rather than implementing an
__init__ method. This is because the default values are dependent on other information from
within the course.
"""
course_tabs = [
CourseTab.load('courseware')
]
# Presence of syllabus tab is indicated by a course attribute
if hasattr(course, 'syllabus_present') and course.syllabus_present:
course_tabs.append(CourseTab.load('syllabus'))
# If the course has a discussion link specified, use that even if we feature
# flag discussions off. Disabling that is mostly a server safety feature
# at this point, and we don't need to worry about external sites.
if course.discussion_link:
discussion_tab = CourseTab.load(
'external_discussion', name=_('External Discussion'), link=course.discussion_link
)
else:
discussion_tab = CourseTab.load('discussion')
course_tabs.extend([
CourseTab.load('textbooks'),
discussion_tab,
CourseTab.load('wiki'),
CourseTab.load('progress'),
CourseTab.load('dates'),
])
# Cross reference existing slugs with slugs this method would add to not add duplicates.
existing_tab_slugs = {tab.type for tab in course.tabs if course.tabs}
tabs_to_add = []
for tab in course_tabs:
if tab.type not in existing_tab_slugs:
tabs_to_add.append(tab)
if tabs_to_add:
tabs_to_add.extend(course.tabs)
tabs_to_add.sort(key=lambda tab: tab.priority or float('inf'))
course.tabs = tabs_to_add
@staticmethod
def get_discussion(course):
"""
Returns the discussion tab for the given course. It can be either of type 'discussion'
or 'external_discussion'. The returned tab object is self-aware of the 'link' that it corresponds to.
"""
# the discussion_link setting overrides everything else, even if there is a discussion tab in the course tabs
if course.discussion_link:
return CourseTab.load(
'external_discussion', name=_('External Discussion'), link=course.discussion_link
)
# find one of the discussion tab types in the course tabs
for tab in course.tabs:
if tab.type in ('discussion', 'external_discussion'):
return tab
return None
@staticmethod
def get_tab_by_slug(tab_list, url_slug):
"""
Look for a tab with the specified 'url_slug'. Returns the tab or None if not found.
"""
return next((tab for tab in tab_list if tab.get('url_slug') == url_slug), None)
@staticmethod
def get_tab_by_type(tab_list, tab_type):
"""
Look for a tab with the specified type. Returns the first matching tab.
"""
return next((tab for tab in tab_list if tab.type == tab_type), None)
@staticmethod
def get_tab_by_id(tab_list, tab_id):
"""
Look for a tab with the specified tab_id. Returns the first matching tab.
"""
return next((tab for tab in tab_list if tab.tab_id == tab_id), None)
@staticmethod
def iterate_displayable(course, user=None, inline_collections=True, include_hidden=False):
"""
Generator method for iterating through all tabs that can be displayed for the given course and
the given user with the provided access settings.
"""
for tab in course.tabs:
if tab.is_enabled(course, user=user) and (include_hidden or not (user and tab.is_hidden)):
if tab.is_collection:
# If rendering inline that add each item in the collection,
# else just show the tab itself as long as it is not empty.
if inline_collections:
yield from tab.items(course)
elif len(list(tab.items(course))) > 0:
yield tab
else:
yield tab
@classmethod
def upgrade_tabs(cls, tabs):
"""
Remove course_info tab, and rename courseware tab to Course if needed.
"""
if tabs and len(tabs) > 1:
# Reverse them so that course_info is first, and rename courseware to Course
if tabs[0].get('type') == 'courseware' and tabs[1].get('type') == 'course_info':
tabs[0], tabs[1] = tabs[1], tabs[0]
tabs[1]['name'] = _('Course')
# NOTE: this check used for legacy courses containing the course_info tab. course_info
# should be removed according to https://github.com/openedx/public-engineering/issues/56.
if tabs[0].get('type') == 'course_info':
tabs.pop(0)
return tabs
@classmethod
def validate_tabs(cls, tabs):
"""
Check that the tabs set for the specified course is valid. If it
isn't, raise InvalidTabsException with the complaint.
Specific rules checked:
- if no tabs specified, that's fine
- if tabs specified, first must have type 'courseware'.
"""
if tabs is None or len(tabs) == 0:
return
if tabs[0].get('type') != 'courseware':
raise InvalidTabsException(
f"Expected first tab to have type 'courseware'. tabs: '{tabs}'")
# the following tabs should appear only once
# TODO: don't import openedx capabilities from common
from openedx.core.lib.course_tabs import CourseTabPluginManager
for tab_type in CourseTabPluginManager.get_tab_types():
if not tab_type.allow_multiple:
cls._validate_num_tabs_of_type(tabs, tab_type.type, 1)
@staticmethod
def _validate_num_tabs_of_type(tabs, tab_type, max_num):
"""
Check that the number of times that the given 'tab_type' appears in 'tabs' is less than or equal to 'max_num'.
"""
count = sum(1 for tab in tabs if tab.get('type') == tab_type)
if count > max_num:
msg = (
"Tab of type '{type}' appears {count} time(s). "
"Expected maximum of {max} time(s)."
).format(
type=tab_type, count=count, max=max_num,
)
raise InvalidTabsException(msg)
def to_json(self, values): # lint-amnesty, pylint: disable=arguments-differ
"""
Overrides the to_json method to serialize all the CourseTab objects to a json-serializable representation.
"""
json_data = []
if values:
for val in values:
if isinstance(val, CourseTab):
json_data.append(val.to_json())
elif isinstance(val, dict):
json_data.append(val)
else:
continue
return json_data
def from_json(self, values): # lint-amnesty, pylint: disable=arguments-differ
"""
Overrides the from_json method to de-serialize the CourseTab objects from a json-like representation.
"""
self.upgrade_tabs(values)
self.validate_tabs(values)
tabs = []
for tab_dict in values:
tab = CourseTab.from_json(tab_dict)
if tab:
tabs.append(tab)
return tabs
# Validators
# A validator takes a dict and raises InvalidTabsException if required fields are missing or otherwise wrong.
# (e.g. "is there a 'name' field?). Validators can assume that the type field is valid.
def key_checker(expected_keys):
"""
Returns a function that checks that specified keys are present in a dict.
"""
def check(actual_dict, raise_error=True):
"""
Function that checks whether all keys in the expected_keys object is in the given actual_dict object.
"""
missing = set(expected_keys) - set(actual_dict.keys())
if not missing:
return True
if raise_error: # lint-amnesty, pylint: disable=no-else-raise
raise InvalidTabsException(
f"Expected keys '{expected_keys}' are not present in the given dict: {actual_dict}"
)
else:
return False
return check
def course_reverse_func(reverse_name):
"""
Returns a function that will determine a course URL for the provided
reverse_name.
See documentation for course_reverse_func_from_name_func. This function
simply calls course_reverse_func_from_name_func after wrapping reverse_name
in a function.
"""
return course_reverse_func_from_name_func(lambda course: reverse_name)
def course_reverse_func_from_name_func(reverse_name_func):
"""
Returns a function that will determine a course URL for the provided
reverse_name_func.
Use this when the calculation of the reverse_name is dependent on the
course. Otherwise, use the simpler course_reverse_func.
This can be used to generate the url for a CourseTab without having
immediate access to Django's reverse function.
Arguments:
reverse_name_func (function): A function that takes a single argument
(Course) and returns the name to be used with the reverse function.
Returns:
A function that takes in two arguments:
course (Course): the course in question.
reverse_url_func (function): a reverse function for a course URL
that uses the course ID in the url.
When called, the returned function will return the course URL as
determined by calling reverse_url_func with the reverse_name and the
course's ID.
"""
return lambda course, reverse_url_func: reverse_url_func(
reverse_name_func(course),
args=[str(course.id)]
)
def need_name(dictionary, raise_error=True):
"""
Returns whether the 'name' key exists in the given dictionary.
"""
return key_checker(['name'])(dictionary, raise_error)
class InvalidTabsException(Exception):
"""
A complaint about invalid tabs.
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
class UnequalTabsException(Exception):
"""
A complaint about tab lists being unequal
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass