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
Exemple #2
0
class License(Model):
    """A license for content or code
    """
    init_arg_names = {'parent', 'slug'}
    pk_name = 'slug'

    url = Field(str)
    title = Field(str)

    def get_url(self, *args, **kwargs):
        # A Licence always has an external URL
        return self.url
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 #4
0
class Material(Model):
    """Teaching material, usually a link to a lesson or external page
    """
    parent_attrs = 'session', 'course'
    pk_name = 'slug'

    slug = Field(str, optional=True)
    title = Field(str, optional=True, doc="Human-readable title")
    type = Field(
        str,
        doc="Type of the material (e.g. lesson, homework, cheatsheet, link, "
            + "special). Used for the icon in material lists.")
    external_url = Field(
        URLConverter(), optional=True,
        doc="URL for a link to content that's not a naucse lesson")
    lesson_slug = Field(
        str, optional=True,
        doc="Slug of the corresponding lesson")

    @lesson_slug.after_load()
    def _validate_lesson_slug(self, context):
        if self.lesson_slug and self.external_url:
            raise ValueError(
                'external_url and lesson_slug are incompatible'
            )

    @property
    def lesson(self):
        """Lesson for this Material, or None"""
        if self.lesson_slug is not None:
            return self.course.lessons[self.lesson_slug]

    def get_url(self, url_type='web', **kwargs):
        # The material has no URL itself; it refers to a lesson, an external
        # resource, or to nothing.
        if self.lesson_slug:
            return self.course.get_lesson_url(self.lesson_slug)
        if url_type != 'web':
            raise NoURLType(url_type)
        if self.external_url:
            return self.external_url
        raise NoURL(self)

    def url_or_none(self, *args, **kwargs):
        try:
            return self.get_url(*args, **kwargs)
        except NoURL:
            return None
Exemple #5
0
 def __init_subclass__(cls):
     try:
         slug = cls.model_slug
     except AttributeError:
         slug = re.sub('([A-Z])', r'-\1', cls.__name__).lower().lstrip('-')
     cls.model_slug = slug
     models[slug] = cls
     if not hasattr(cls, '_naucse__converter'):
         converter = ModelConverter(
             cls,
             load_arg_names=cls.init_arg_names,
             slug=slug,
             extra_fields=[
                 Field(
                     URLConverter(),
                     name='_url',
                     data_key='url',
                     input=False,
                     optional=True,
                     doc="URL for a user-facing page on naucse",
                 )
             ],
         )
         converter.get_schema_url = _get_schema_url
         register_model(cls, converter)
Exemple #6
0
class Solution(Model):
    """Solution to a problem on a Page
    """
    init_arg_names = {'parent', 'index'}
    pk_name = 'index'
    parent_attrs = 'page', 'lesson', 'course'

    content = Field(CourseHTMLFragmentConverter(),
                    output=False,
                    doc="The right solution, as HTML")
Exemple #7
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 #8
0
class SessionPage(Model):
    """Session-specific page, e.g. the front cover
    """
    init_arg_names = {'parent', 'slug'}
    pk_name = 'slug'
    parent_attrs = 'session', 'course'

    content = Field(HTMLFragmentConverter(),
                    factory=str,
                    doc='Content, as HTML')

    def get_pks(self):
        return {**self.parent.get_pks(), 'page_slug': self.slug}
Exemple #9
0
class StaticFile(Model):
    """Static file specific to a Lesson
    """
    init_arg_names = {'parent', 'filename'}
    pk_name = 'filename'
    parent_attrs = 'lesson', 'course'

    def get_pks(self):
        return {**self.parent.get_pks(), 'filename': self.filename}

    def get_path_or_file(self):
        return self.course.renderer.get_path_or_file(self.path)

    path = Field(RelativePathConverter(), doc="Relative path of the file")
Exemple #10
0
class Page(Model):
    """One page of teaching text
    """
    init_arg_names = {'parent', 'slug'}
    pk_name = 'slug'
    parent_attrs = 'lesson', 'course'

    title = Field(str, doc='Human-readable title')

    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')
Exemple #11
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 #12
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 #13
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()
Exemple #14
0
class Root(Model):
    """Data for the naucse website

    Contains a collection of courses plus additional metadata.
    """

    # Also responsible for loading the courses from (meta)data on disk.

    def __init__(
        self,
        *,
        url_factories=None,
        schema_url_factory=None,
        renderers={},
        repo_info=None,
    ):
        self.root = self
        self.url_factories = url_factories or {}
        self.schema_url_factory = schema_url_factory
        super().__init__(parent=self)
        self.renderers = renderers

        self.courses = {}
        self.run_years = {}
        self.licenses = {}
        self.self_study_courses = {}

        self._repo_info_override = repo_info

    pk_name = None

    # If true, show a pretty homepage rather than a list of courses.
    aggregates_courses = False

    self_study_courses = Field(
        AbbreviatedDictConverter(Course),
        doc="""Links to "canonical" courses – ones without a time span""")
    run_years = Field(AbbreviatedDictConverter(RunYear),
                      doc="""Links to courses by year""")
    licenses = Field(DictConverter(License), doc="""Allowed licenses""")

    def set_repo_info(self, repo_info):
        self.repo_info = repo_info

        self.edit_info = self.repo_info.get_edit_info('.')
        self.runs_edit_info = self.repo_info.get_edit_info('runs')
        self.course_edit_info = self.repo_info.get_edit_info('courses')

    def load_local_courses(self, path):
        """Load local courses and lessons from the given path

        Note: Licenses should be loaded before calling load_local_courses,
        otherwise lessons will have no licences to choose from
        """
        self.set_repo_info(get_local_repo_info(path))

        def _load_local_course(slug, **renderer_kwargs):
            renderer = local_renderer.LocalRenderer(
                path=path,
                slug=slug,
                repo_info=self.repo_info,
                **renderer_kwargs,
            )
            course = Course.from_renderer(parent=self, renderer=renderer)
            self.add_course(course)

        for slug in local_renderer.get_course_slugs(path=path):
            print(path, slug)
            _load_local_course(slug)

        for link_path in chain(
                path.glob('courses/**/link.yml'),
                path.glob('runs/**/link.yml'),
        ):
            raise ValueError(
                '"link.yml" files are not supported since naucse 5.0')

        compiled_path = path / 'courses.yml'
        fetcher = compiled_renderer.Fetcher()
        featured_courses = []
        if compiled_path.exists():
            self.aggregates_courses = True
            with compiled_path.open() as f:
                courses_info = yaml.safe_load(f)
            for slug, course_info in courses_info.items():
                renderer = compiled_renderer.CompiledRenderer(
                    slug,
                    course_info,
                    fetcher=fetcher,
                )
                course = Course.from_renderer(
                    renderer=renderer,
                    parent=self,
                    canonical=course_info.get('canonical', False),
                )
                self.add_course(course)
                feature_index = course_info.get('featured', None)
                if feature_index is not None:
                    featured_courses.append((feature_index, slug, course))
        # Sort featured courses by their index
        self.featured_courses = [c for i, s, c in sorted(featured_courses)]

    def add_course(self, course):
        slug = course.slug
        if slug in self.courses:
            # XXX: Replacing courses is untested
            old = self.courses[slug]
            if old.start_date:
                for year in range(old.start_date.year, old.end_date.year + 1):
                    del self.run_years[year][slug]
            else:
                del self.self_study_courses[slug]

        self.courses[slug] = course
        if course.start_date:
            for year in range(course.start_date.year,
                              course.end_date.year + 1):
                if year not in self.run_years:
                    run_year = RunYear(year=year, parent=self)
                    self.run_years[year] = run_year
                self.run_years[year][slug] = course
        else:
            self.self_study_courses[slug] = course

    def freeze(self):
        for course in self.courses.values():
            course.freeze()

    def load_licenses(self, path):
        """Add licenses from files in the given path to the model"""
        for licence_path in path.iterdir():
            with (licence_path / 'info.yml').open() as f:
                info = yaml.safe_load(f)
            slug = licence_path.name
            license = load(
                License,
                {
                    'api_version': [0, 0],
                    'license': info
                },
                parent=self,
                slug=slug,
            )
            self.licenses[slug] = license

    def get_course(self, slug):
        # XXX: RunYears shouldn't be necessary
        if slug == 'lessons':
            return self.courses[slug]
        year, identifier = slug.split('/')
        if year == 'courses':
            return self.courses[slug]
        else:
            return self.run_years[int(year)][slug]

    def get_pks(self):
        return {}

    def _url_for(self, obj_type, pks, url_type='web', *, external=False):
        try:
            urls = self.url_factories[url_type]
        except KeyError:
            raise NoURLType(url_type)
        if obj_type is None:
            obj_type = type(obj)
        try:
            url_for = urls[obj_type]
        except KeyError:
            raise NoURL(obj_type)
        return url_for(**pks, _external=external)
Exemple #15
0
class RelativePathConverter(BaseConverter):
    """Converter for a relative path, as string"""
    def load(self, data, context):
        return Path(data)

    def dump(self, value, context):
        return str(value)

    def get_schema(self, context):
        return {'type': 'string', 'pattern': '^[^./][^/]*(/[^./][^/]*)*$'}


source_file_field = Field(
    RelativePathConverter(),
    name='source_file',
    optional=True,
    doc="Path to a source file containing the page's text, " +
    "relative to the repository root")


@source_file_field.after_load()
def _edit_info(self, context):
    if self.source_file is None:
        self.edit_info = None
    else:
        self.edit_info = self.course.repo_info.get_edit_info(self.source_file)


class StaticFile(Model):
    """Static file specific to a Lesson
    """
Exemple #16
0
class Root(Model):
    """Data for the naucse website

    Contains a collection of courses plus additional metadata.
    """
    def __init__(
        self, *,
        url_factories=None,
        schema_url_factory=None,
        arca=None,
        trusted_repo_patterns=(),
        repo_info=None,
    ):
        self.root = self
        self.url_factories = url_factories or {}
        self.schema_url_factory = schema_url_factory
        super().__init__(parent=self)
        self.arca = arca
        self.trusted_repo_patterns = trusted_repo_patterns

        self.courses = {}
        self.run_years = {}
        self.licenses = {}
        self.self_study_courses = {}

        self.set_repo_info(repo_info or get_local_repo_info('.'))

        # For pagination of runs
        # XXX: This shouldn't be necessary
        self.explicit_run_years = set()

    pk_name = None

    self_study_courses = Field(
        AbbreviatedDictConverter(Course),
        doc="""Links to "canonical" courses – ones without a time span""")
    run_years = Field(
        AbbreviatedDictConverter(RunYear),
        doc="""Links to courses by year""")
    licenses = Field(
        DictConverter(License),
        doc="""Allowed licenses""")

    def set_repo_info(self, repo_info):
        self.repo_info = repo_info

        self.edit_info = self.repo_info.get_edit_info('.')
        self.runs_edit_info = self.repo_info.get_edit_info('runs')
        self.course_edit_info = self.repo_info.get_edit_info('courses')

    def load_local_courses(self, path):
        """Load local courses and lessons from the given path

        Note: Licenses should be loaded before calling load_local_courses,
        otherwise lessons will have no licences to choose from
        """
        self.set_repo_info(get_local_repo_info(path))

        self_study_course_path = path / 'courses'
        run_path = path / 'runs'
        lesson_path = path / 'lessons'

        def _load_local_course(course_path, slug, canonical_if_local=False):
            link_path = course_path / 'link.yml'
            if link_path.is_file():
                with link_path.open() as f:
                    link_info = yaml.safe_load(f)
                checked_url = '{repo}#{branch}'.format(**link_info)
                if any(
                    fnmatch(checked_url, l) for l in self.trusted_repo_patterns
                ):
                    course = Course.load_remote(
                        slug, parent=self, link_info=link_info,
                    )
                    self.add_course(course)
                else:
                    logger.debug(f'Untrusted repo: {checked_url}')
            if (course_path / 'info.yml').is_file():
                course = Course.load_local(
                    slug, parent=self, repo_info=self.repo_info, path=path,
                    canonical=canonical_if_local,
                )
                self.add_course(course)

        if self_study_course_path.exists():
            for course_path in self_study_course_path.iterdir():
                slug = 'courses/' + course_path.name
                _load_local_course(course_path, slug, canonical_if_local=True)
        else:
            logger.warning(f'No courses at {self_study_course_path}')

        if run_path.exists():
            for year_path in sorted(run_path.iterdir()):
                if year_path.is_dir():
                    self.explicit_run_years.add(int(year_path.name))
                    for course_path in year_path.iterdir():
                        slug = f'{year_path.name}/{course_path.name}'
                        _load_local_course(course_path, slug)

        if lesson_path.exists():
            self.add_course(Course.load_local(
                'lessons',
                repo_info=self.repo_info,
                canonical=True,
                parent=self,
                path=path,
            ))
        else:
            logger.warning(f'No lessons at {lesson_path}')

        self_study_order_path = self_study_course_path / 'info.yml'
        if self_study_order_path.exists():
            with (path / 'courses/info.yml').open() as f:
                course_info = yaml.safe_load(f)
            self.featured_courses = [
                self.courses[f'courses/{n}'] for n in course_info['order']
            ]
        else:
            logger.warning(f'No featured courses at {self_study_order_path}')
            self.featured_courses = list(self.courses.values())

    def add_course(self, course):
        slug = course.slug
        if slug in self.courses:
            # XXX: Replacing courses is untested
            old = self.courses[slug]
            if old.start_date:
                for year in range(old.start_date.year, old.end_date.year+1):
                    del self.run_years[year][slug]
            else:
                del self.self_study_courses[slug]

        self.courses[slug] = course
        if course.start_date:
            for year in range(course.start_date.year, course.end_date.year+1):
                if year not in self.run_years:
                    run_year = RunYear(year=year, parent=self)
                    self.run_years[year] = run_year
                self.run_years[year][slug] = course
        else:
            self.self_study_courses[slug] = course

    def freeze(self):
        for course in self.courses.values():
            course.freeze()

    def load_licenses(self, path):
        """Add licenses from files in the given path to the model"""
        for licence_path in path.iterdir():
            with (licence_path / 'info.yml').open() as f:
                info = yaml.safe_load(f)
            slug = licence_path.name
            license = load(
                License,
                {'api_version': [0, 0], 'license': info},
                parent=self, slug=slug,
            )
            self.licenses[slug] = license

    def get_course(self, slug):
        # XXX: RunYears shouldn't be necessary
        if slug == 'lessons':
            return self.courses[slug]
        year, identifier = slug.split('/')
        if year == 'courses':
            return self.courses[slug]
        else:
            return self.run_years[int(year)][slug]

    def get_pks(self):
        return {}

    def _url_for(self, obj_type, pks, url_type='web', *, external=False):
        try:
            urls = self.url_factories[url_type]
        except KeyError:
            raise NoURLType(url_type)
        if obj_type is None:
            obj_type = type(obj)
        try:
            url_for = urls[obj_type]
        except KeyError:
            raise NoURL(obj_type)
        return url_for(**pks, _external=external)
Exemple #17
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")

    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)

    @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
Exemple #18
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)",
    )

    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):
        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):
        if not self.pages:
            self.pages = {}
        for slug in 'front', 'back':
            if slug not in self.pages:
                page = get_converter(SessionPage).load(
                    {},
                    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):
        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