Skip to content

Commit

Permalink
Merge pull request #528: API versioning + session serials
Browse files Browse the repository at this point in the history
  • Loading branch information
encukou committed Feb 27, 2019
2 parents 86f8ab8 + ee9b4bc commit a1965b2
Show file tree
Hide file tree
Showing 39 changed files with 893 additions and 123 deletions.
229 changes: 172 additions & 57 deletions naucse/converters.py

Large diffs are not rendered by default.

90 changes: 59 additions & 31 deletions naucse/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
from arca import Task

from naucse.edit_info import get_local_repo_info, get_repo_info
from naucse.converters import Field, register_model, BaseConverter
from naucse.converters import ListConverter, DictConverter
from naucse.converters import Field, VersionField, register_model
from naucse.converters import BaseConverter, ListConverter, DictConverter
from naucse.converters import KeyAttrDictConverter, ModelConverter
from naucse.converters import dump, load, get_converter, get_schema
from naucse import sanitize
Expand All @@ -20,6 +20,8 @@

import naucse_render

API_VERSION = 0, 1

# XXX: Different timezones?
_TIMEZONE = 'Europe/Prague'

Expand All @@ -32,10 +34,10 @@ class NoURLType(NoURL):


class URLConverter(BaseConverter):
def load(self, data):
def load(self, data, context):
return sanitize.convert_link('href', data)

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

@classmethod
Expand Down Expand Up @@ -156,12 +158,12 @@ class HTMLFragmentConverter(BaseConverter):
def __init__(self, *, sanitizer=None):
self.sanitizer = sanitizer

def load(self, value, parent):
def load(self, value, context, *, parent):
if self.sanitizer is None:
return sanitize.sanitize_html(value)
return self.sanitizer(parent, value)

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

@classmethod
Expand All @@ -187,10 +189,10 @@ class Solution(Model):

class RelativePathConverter(BaseConverter):
"""Converter for a relative path, as string"""
def load(self, data):
def load(self, data, context):
return Path(data)

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

def get_schema(self, context):
Expand All @@ -208,7 +210,7 @@ def get_schema(self, context):
+ "relative to the repository root")

@source_file_field.after_load()
def _edit_info(self):
def _edit_info(self, context):
if self.source_file is None:
self.edit_info = None
else:
Expand All @@ -234,10 +236,10 @@ def get_pks(self):

class PageCSSConverter(BaseConverter):
"""Converter for CSS for a Page"""
def load(self, value):
def load(self, value, context):
return sanitize.sanitize_css(value)

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

@classmethod
Expand All @@ -252,10 +254,10 @@ class LicenseConverter(BaseConverter):
"""Converter for a licence (specified as its slug in JSON)"""
load_arg_names = {'parent'}

def load(self, value, parent):
def load(self, value, context, *, parent):
return parent.root.licenses[value]

def dump(self, value):
def dump(self, value, context):
return value.slug

@classmethod
Expand Down Expand Up @@ -351,7 +353,7 @@ class Material(Model):
doc="Slug of the corresponding lesson")

@lesson_slug.after_load()
def _validate_lesson_slug(self):
def _validate_lesson_slug(self, context):
if self.lesson_slug and self.external_url:
raise ValueError(
'external_url and lesson_slug are incompatible'
Expand Down Expand Up @@ -416,7 +418,7 @@ class SessionTimeConverter(BaseConverter):
to be fixed up using `_combine_session_time`.
Converted to the full datetime on output.
"""
def load(self, data):
def load(self, data, context):
try:
return datetime.datetime.strptime('%Y-%m-%d %H:%M:%S', data)
except ValueError:
Expand All @@ -426,7 +428,7 @@ def load(self, data):
time = datetime.datetime.strptime(data, '%H:%M').time()
return time.replace(tzinfo=dateutil.tz.gettz(_TIMEZONE))

def dump(self, value):
def dump(self, value, context):
return value.strftime('%Y-%m-%d %H:%M:%S')

@classmethod
Expand All @@ -446,10 +448,10 @@ def get_schema(cls, context):

class DateConverter(BaseConverter):
"""Converter for datetime.date values (as 'YYYY-MM-DD' strings in JSON)"""
def load(self, data):
def load(self, data, context):
return datetime.datetime.strptime(data, "%Y-%m-%d").date()

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

def get_schema(self, context):
Expand All @@ -476,6 +478,21 @@ class Session(Model):
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,
Expand All @@ -490,21 +507,23 @@ class Session(Model):
)

@materials.after_load()
def _index_materials(self):
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):
def _set_pages(self, context):
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,
page = load(
SessionPage,
{'api_version': [0, 0], 'session-page': {}},
slug=slug, parent=self,
)
self.pages[slug] = page

Expand All @@ -514,7 +533,7 @@ def _set_pages(self):
doc="Time when this session takes place.")

@time.after_load()
def _fix_time(self):
def _fix_time(self, context):
if self.time is None:
self.time = {}
else:
Expand Down Expand Up @@ -546,10 +565,10 @@ def _fix_time(self):

class AnyDictConverter(BaseConverter):
"""Converter of any JSON-encodable dict"""
def load(self, data):
def load(self, data, context):
return data

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

@classmethod
Expand All @@ -568,13 +587,13 @@ def time_from_string(time_string):

class TimeIntervalConverter(BaseConverter):
"""Converter for a time interval, as a dict with 'start' and 'end'"""
def load(self, data):
def load(self, data, context):
return {
'start': time_from_string(data['start']),
'end': time_from_string(data['end']),
}

def dump(self, value):
def dump(self, value, context):
return {
'start': value['start'].strftime('%H:%M'),
'end': value['end'].strftime('%H:%M'),
Expand Down Expand Up @@ -673,14 +692,19 @@ def _default_lessons(self):
doc="Individual sessions")

@sessions.after_load()
def _sessions_after_load(self):
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(
Expand Down Expand Up @@ -734,7 +758,7 @@ def load_remote(cls, slug, *, parent, link_info):
doc="Slug of the course this derives from (deprecated)")

@derives.after_load()
def _set_base_course(self):
def _set_base_course(self, context):
key = f'courses/{self.derives}'
try:
self.base_course = self.root.courses[key]
Expand Down Expand Up @@ -819,7 +843,7 @@ def freeze(self):

class AbbreviatedDictConverter(DictConverter):
"""Dict that only shows URLs to its items when dumped"""
def dump(self, value):
def dump(self, value, context):
return {
key: {'$ref': v.get_url('api', external=True)}
for key, v in value.items()
Expand Down Expand Up @@ -1034,7 +1058,11 @@ def load_licenses(self, path):
with (licence_path / 'info.yml').open() as f:
info = yaml.safe_load(f)
slug = licence_path.name
license = get_converter(License).load(info, parent=self, slug=slug)
license = load(
License,
{'api_version': [0, 0], 'license': info},
parent=self, slug=slug,
)
self.licenses[slug] = license

def get_course(self, slug):
Expand Down
4 changes: 2 additions & 2 deletions naucse/templates/course.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ <h2>{{ course.subtitle }}</h2>

<div class="section{{ loop.index }}">
<h4>
{% if course.sessions|length > 1 %}
Lekce {{ loop.index }} –
{% if session.serial != None %}
Lekce {{ session.serial }} –
{% endif %}
<a href="{{ session.get_url() }}">
{{ session.title }}
Expand Down
10 changes: 6 additions & 4 deletions naucse/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -399,12 +399,14 @@ def schema(model_slug, is_input):
cls = models.models[model_slug]
except KeyError:
abort(404)
return jsonify(models.get_schema(cls, is_input=is_input))
return jsonify(models.get_schema(
cls, is_input=is_input, version=models.API_VERSION,
))


@app.route('/v0/naucse.json')
def api():
return jsonify(models.dump(g.model))
return jsonify(models.dump(g.model, version=models.API_VERSION))


@app.route('/v0/years/<int:year>.json')
Expand All @@ -413,7 +415,7 @@ def run_year_api(year):
run_year = g.model.run_years[year]
except KeyError:
abort(404)
return jsonify(models.dump(run_year))
return jsonify(models.dump(run_year, version=models.API_VERSION))


@app.route('/v0/<course:course_slug>.json')
Expand All @@ -422,4 +424,4 @@ def course_api(course_slug):
course = g.model.courses[course_slug]
except KeyError:
abort(404)
return jsonify(models.dump(course))
return jsonify(models.dump(course, version=models.API_VERSION))
9 changes: 9 additions & 0 deletions test_naucse/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ def assert_yaml_dump(data, filename):
assert data == expected


@pytest.fixture(params=((0, 0), (0, 1)))
def assert_model_dump(request):
version = request.param
def _assert(model, filename):
filename += '.v{}.{}.yaml'.format(*version)
assert_yaml_dump(models.dump(model, version=version), filename)
return _assert


def add_test_course(model, slug, data):
model.add_course(models.load(
models.Course,
Expand Down
15 changes: 15 additions & 0 deletions test_naucse/fixtures/course-data/course-v0.1.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
course:
api_version: [0, 1]
course:
title: A course loaded from API version 0.1
subtitle: Suitable for testing only.
source_file: courses/complex/info.yml
sessions:
- slug: first
title: First
serial: '1'
- slug: second
title: Second
serial: '2'
- slug: special
title: Special
Loading

0 comments on commit a1965b2

Please sign in to comment.