class Course(Model): """Collection of sessions """ pk_name = 'slug' def __init__( self, *, parent, slug, repo_info, base_path=None, is_meta=False, canonical=False, ): super().__init__(parent=parent) self.repo_info = repo_info self.slug = slug self.base_path = base_path self.is_meta = is_meta self.course = self self._frozen = False self.canonical = canonical self._lessons = {} self._requested_lessons = set() lessons = Field( DictConverter(Lesson), input=False, doc="""Lessons""") @lessons.default_factory() def _default_lessons(self): return _LessonsDict(self) title = Field(str, doc="""Human-readable title""") subtitle = Field( str, optional=True, doc="Human-readable subtitle, mainly used to distinguish several " + "runs of same-named courses.") description = Field( str, optional=True, doc="Short description of the course (about one line).") long_description = Field( HTMLFragmentConverter(), factory=str, doc="Long description of the course (up to several paragraphs).") vars = Field( AnyDictConverter(), factory=dict, doc="Defaults for additional values used for rendering pages") place = Field( str, optional=True, doc="Human-readable description of the venue") time_description = Field( str, optional=True, doc="Human-readable description of the time the course takes place " + "(e.g. 'Wednesdays')") default_time = Field( TimeIntervalConverter(), optional=True, doc="Default start and end time for sessions") timezone = VersionField({ (0, 3): Field( ZoneInfoConverter(), data_key='timezone', optional=True, doc="Timezone for times specified without a timezone (i.e. as " + "HH:MM (rather than HH:MM+ZZZZ). " + "Mandatory if such times appear in the course." ) }) @timezone.after_load() def set_timezone(self, context): if self.timezone is None and context.version < (0, 3): self.timezone = _OLD_DEFAULT_TIMEZONE sessions = Field( KeyAttrDictConverter(Session, key_attr='slug', index_arg='index'), doc="Individual sessions") @sessions.after_load() def _sessions_after_load(self, context): set_prev_next(self.sessions.values()) for session in self.sessions.values(): for material in session.materials: if material.lesson_slug: self._requested_lessons.add(material.lesson_slug) if context.version < (0, 1) and len(self.sessions) > 1: # Assign serials to sessions (numbering from 1) for serial, session in enumerate(self.sessions.values(), start=1): session.serial = str(serial) source_file = source_file_field start_date = Field( DateConverter(), doc='Date when this course starts, or None') @start_date.default_factory() def _construct(self): dates = [getattr(s, 'date', None) for s in self.sessions.values()] return min((d for d in dates if d), default=None) end_date = Field( DateConverter(), doc='Date when this course ends, or None') @end_date.default_factory() def _construct(self): dates = [getattr(s, 'date', None) for s in self.sessions.values()] return max((d for d in dates if d), default=None) etag = Field( str, optional=True, doc="Optional string that should change when the course's content " + "changes, similar to the HTTP ETag.\n" + "If missing from the input course, the etag may be " + "generated by the naucse server." ) @classmethod def load_local( cls, slug, *, parent, repo_info, path='.', canonical=False, renderer=naucse_render ): path = Path(path).resolve() data = renderer.get_course(slug, version=1, path=path) is_meta = (slug == 'courses/meta') result = load( cls, data, slug=slug, repo_info=repo_info, parent=parent, base_path=path, is_meta=is_meta, canonical=canonical, ) result.repo_info = repo_info result.renderer = renderer return result @classmethod def load_remote(cls, slug, *, parent, link_info): url = link_info['repo'] branch = link_info.get('branch', 'master') renderer = arca_renderer.Renderer(parent.arca, url, branch) return cls.load_local( slug, parent=parent, repo_info=get_repo_info(url, branch), path=renderer.worktree_path, renderer=renderer, ) # XXX: Is course derivation useful? derives = Field( str, optional=True, doc="Slug of the course this derives from (deprecated)") @derives.after_load() def _set_base_course(self, context): key = f'courses/{self.derives}' try: self.base_course = self.root.courses[key] except KeyError: self.base_course = None def get_recent_derived_runs(self): result = [] if self.canonical: today = datetime.date.today() cutoff = today - datetime.timedelta(days=2*30) for course in self.root.courses.values(): if ( course.start_date and course.base_course == self and course.end_date > cutoff ): result.append(course) result.sort(key=lambda course: course.start_date, reverse=True) return result def get_lesson_url(self, slug, *, page='index', **kw): if slug in self._lessons: return self._lessons[slug].get_url(**kw) if self._frozen: return KeyError(slug) self._requested_lessons.add(slug) return self.root._url_for( Page, pks={'page_slug': page, 'lesson_slug': slug, **self.get_pks()} ) def load_lessons(self, slugs): if self._frozen: raise Exception('course is frozen') slugs = set(slugs) - set(self._lessons) rendered = self.course.renderer.get_lessons( slugs, vars=self.vars, path=self.base_path, ) new_lessons = load( DictConverter(Lesson, key_arg='slug'), rendered, parent=self, ) for slug in slugs: try: lesson = new_lessons[slug] except KeyError: raise ValueError(f'{slug} missing from rendered lessons') self._lessons[slug] = lesson self._requested_lessons.discard(slug) def load_all_lessons(self): if self._frozen: return self._requested_lessons.difference_update(self._lessons) link_depth = 50 while self._requested_lessons: self._requested_lessons.difference_update(self._lessons) if not self._requested_lessons: break self.load_lessons(self._requested_lessons) link_depth -= 1 if link_depth < 0: # Avoid infinite loops in lessons raise ValueError( f'Lessons in course {self.slug} are linked too deeply') def _has_lesson(self, slug): # HACK for getting "canonical lesson" info return ( slug in self.course._lessons or slug in self.course._requested_lessons ) def freeze(self): if self._frozen: return self.load_all_lessons() self._frozen = True
class Session(Model): """A smaller collection of teaching materials Usually used for one meeting of an in-preson course or a self-contained section of a longer workshop. """ init_arg_names = {'parent', 'index'} pk_name = 'slug' parent_attrs = ('course', ) slug = Field(str) title = Field(str, doc="A human-readable session title") date = Field( DateConverter(), optional=True, doc="The date when this session occurs (if it has a set time)", ) serial = VersionField({ (0, 1): Field(str, optional=True, doc=""" Human-readable string identifying the session's position in the course. The serial is usually numeric: `1`, `2`, `3`, ..., but, for example, i, ii, iii... can be used for appendices. Some courses start numbering sessions from 0. """), # For API version 0.0, serial is generated in # Course._sessions_after_load. }) description = Field(HTMLFragmentConverter(), optional=True, doc="Short description of the session.") source_file = source_file_field materials = Field( ListConverter(Material), factory=list, doc="The session's materials", ) @materials.after_load() def _index_materials(self, context): set_prev_next(m for m in self.materials if m.lesson_slug) pages = Field(DictConverter(SessionPage, key_arg='slug'), optional=True, doc="The session's cover pages") @pages.after_load() def _set_pages(self, context): if not self.pages: self.pages = {} for slug in 'front', 'back': if slug not in self.pages: page = load( SessionPage, { 'api_version': [0, 0], 'session-page': {} }, slug=slug, parent=self, ) self.pages[slug] = page time = Field(DictConverter(SessionTimeConverter(), required=['start', 'end']), optional=True, doc="Time when this session takes place.") @time.after_load() def _fix_time(self, context): if self.time is None: self.time = {} else: if set(self.time) != {'start', 'end'}: raise ValueError('Session time may must have start and end') result = {} for kind in 'start', 'end': time = self.time.get(kind, None) if isinstance(time, datetime.datetime): result[kind] = time elif isinstance(time, datetime.time): if self.date: result[kind] = datetime.datetime.combine(self.date, time) else: self.time = None return elif time is None: if self.date and self.course.default_time: result[kind] = datetime.datetime.combine( self.date, self.course.default_time[kind], ) else: self.time = None return else: raise TypeError(time) self.time = result
class Page(Model): """One page of teaching text """ init_arg_names = {'parent', 'slug'} pk_name = 'slug' parent_attrs = 'lesson', 'course' subtitle = VersionField({ (0, 2): Field( str, optional=True, doc="""Human-readable subpage title. Required for index subpages other than "index" (unless "title" is given). """ ), }) title = VersionField({ (0, 2): Field( str, optional=True, doc="""Human-readable page title. Deprecated since API version 0.2: use lesson.title (and, for subpages other than index, page.subtitle) """ ), (0, 0): Field(str, doc='Human-readable title'), }) @title.after_load() def _generate_title(self, context): if self.title is None: if self.slug == 'index': self.title = self.lesson.title else: if self.subtitle is None: raise ValueError('Either title or subtitle is required') self.title = f'{self.lesson.title} – {self.subtitle}' attribution = Field(ListConverter(HTMLFragmentConverter()), doc='Lines of attribution, as HTML fragments') license = Field( LicenseConverter(), doc='License slugs. Only approved licenses are allowed.') license_code = Field( LicenseConverter(), optional=True, doc='Slug of licence for code snippets.') source_file = source_file_field css = Field( PageCSSConverter(), optional=True, doc="CSS specific to this page. (Subject to restrictions which " + "aren't yet finalized.)") solutions = Field( ListConverter(Solution, index_arg='index'), factory=list, doc="Solutions to problems that appear on the page.") modules = Field( DictConverter(str), factory=dict, doc='Additional modules as a dict with `slug` key and version values') content = Field( HTMLFragmentConverter(sanitizer=_sanitize_page_content), output=False, doc='Content, as HTML')