Beispiel #1
0
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
Beispiel #2
0
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
Beispiel #3
0
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')