Exemple #1
0
class Lesson(Model):
    """A lesson – collection of Pages on a single topic
    """
    init_arg_names = {'parent', 'slug'}
    pk_name = 'slug'
    parent_attrs = ('course', )

    title = VersionField({
        (0, 2): Field(str, doc='Human-readable lesson title')
    })

    static_files = Field(
        DictConverter(StaticFile, key_arg='filename'),
        factory=dict,
        doc="Static files the lesson's content may reference")
    pages = Field(
        DictConverter(Page, key_arg='slug', required={'index'}),
        doc="Pages of content. Used for variants (e.g. a page for Linux and "
            + "another for Windows), or non-essential info (e.g. for "
            + "organizers)")

    @pages.after_load()
    def _set_title(self, context):
        if self.title is None:
            self.title = self.pages['index'].title

    @property
    def material(self):
        """The material that contains this page, or None"""
        for session in self.course.sessions.values():
            for material in session.materials:
                if self == material.lesson:
                    return material
class TestModel:
    versioned_field = VersionField({
        # (Versions are out of order to test that VersionField sorts them)
        (0, 1):
        Field(str, optional=True, doc='Introducing new field'),
        (1, 0):
        Field(int, optional=True, doc="Let's make it an int"),
        (0, 5):
        Field(bool, optional=True, doc="Actually it's a bool"),
        (2, 0):
        Field(int, doc='No longer optional'),
    })
Exemple #3
0
class Lesson(Model):
    """A lesson – collection of Pages on a single topic
    """
    init_arg_names = {'parent', 'slug'}
    pk_name = 'slug'
    parent_attrs = ('course', )

    title = VersionField({
        (0, 2): Field(str, doc='Human-readable lesson title')
    })

    static_files = Field(DictConverter(StaticFile, key_arg='filename'),
                         factory=dict,
                         doc="Static files the lesson's content may reference")
    pages = Field(
        DictConverter(Page, key_arg='slug', required={'index'}),
        doc="Pages of content. Used for variants (e.g. a page for Linux and " +
        "another for Windows), or non-essential info (e.g. for " +
        "organizers)")

    @pages.after_load()
    def _set_title(self, context):
        if self.title is None:
            self.title = self.pages['index'].title

    @property
    def material(self):
        """The material that contains this page, or None"""
        for session in self.course.sessions.values():
            for material in session.materials:
                if self == material.lesson:
                    return material

    def freeze(self):
        for page in self.pages.values():
            page.freeze()
        for static_file in self.static_files.values():
            # This should ensure the file exists.
            # (Maybe there should be more efficient API for that.)
            # XXX this can return an open file that isn't closed, but see https://github.com/pyvec/naucse/issues/53
            static_file.get_path_or_file()
Exemple #4
0
class Course(Model):
    """Collection of sessions
    """
    pk_name = 'slug'

    def __init__(
        self,
        *,
        parent,
        slug,
        renderer,
        is_meta=False,
        canonical=False,
    ):
        super().__init__(parent=parent)
        self.slug = slug
        self.renderer = renderer
        self.is_meta = is_meta
        self.course = self
        self._frozen = False
        self._freezing = 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.")
    })

    _edit_info = VersionField({
        (0, 4):
        Field(
            RepoInfoConverter(),
            doc=
            """Information about a repository where the content can be edited""",
            data_key='edit_info',
            optional=True,
        )
    })

    @property
    def repo_info(self):
        return self._edit_info or self.renderer.get_repo_info()

    @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 from_renderer(
        cls,
        *,
        parent,
        renderer,
        canonical=False,
    ):
        data = renderer.get_course()
        slug = renderer.slug
        is_meta = (slug == 'courses/meta')
        result = load(
            cls,
            data,
            slug=slug,
            parent=parent,
            renderer=renderer,
            is_meta=is_meta,
            canonical=canonical,
        )
        return result

    # 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 not in self._lessons:
            if self._frozen:
                raise 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,
        )
        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)
            if self._freezing:
                lesson.freeze()

    def load_all_lessons(self):
        if self._frozen:
            return
        if self._freezing:
            for lesson in self.lessons.values():
                lesson.freeze()
        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 or self._freezing:
            return
        self._freezing = True
        self.load_all_lessons()
        self._frozen = True
Exemple #5
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):
        self.time = fix_session_time(
            self.time,
            self.date,
            self.course.default_time,
            self.course.timezone,
            self.slug,
        )
Exemple #6
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(CourseHTMLFragmentConverter(),
                    output=False,
                    doc='Content, as HTML')

    def freeze(self):
        if self.content:
            self.content.freeze()