Skip to content

Commit a1965b2

Browse files
authored
Merge pull request #528: API versioning + session serials
#528
2 parents 86f8ab8 + ee9b4bc commit a1965b2

39 files changed

+893
-123
lines changed

naucse/converters.py

Lines changed: 172 additions & 57 deletions
Large diffs are not rendered by default.

naucse/models.py

Lines changed: 59 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
from arca import Task
1111

1212
from naucse.edit_info import get_local_repo_info, get_repo_info
13-
from naucse.converters import Field, register_model, BaseConverter
14-
from naucse.converters import ListConverter, DictConverter
13+
from naucse.converters import Field, VersionField, register_model
14+
from naucse.converters import BaseConverter, ListConverter, DictConverter
1515
from naucse.converters import KeyAttrDictConverter, ModelConverter
1616
from naucse.converters import dump, load, get_converter, get_schema
1717
from naucse import sanitize
@@ -20,6 +20,8 @@
2020

2121
import naucse_render
2222

23+
API_VERSION = 0, 1
24+
2325
# XXX: Different timezones?
2426
_TIMEZONE = 'Europe/Prague'
2527

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

3335

3436
class URLConverter(BaseConverter):
35-
def load(self, data):
37+
def load(self, data, context):
3638
return sanitize.convert_link('href', data)
3739

38-
def dump(self, value):
40+
def dump(self, value, context):
3941
return value
4042

4143
@classmethod
@@ -156,12 +158,12 @@ class HTMLFragmentConverter(BaseConverter):
156158
def __init__(self, *, sanitizer=None):
157159
self.sanitizer = sanitizer
158160

159-
def load(self, value, parent):
161+
def load(self, value, context, *, parent):
160162
if self.sanitizer is None:
161163
return sanitize.sanitize_html(value)
162164
return self.sanitizer(parent, value)
163165

164-
def dump(self, value):
166+
def dump(self, value, context):
165167
return str(value)
166168

167169
@classmethod
@@ -187,10 +189,10 @@ class Solution(Model):
187189

188190
class RelativePathConverter(BaseConverter):
189191
"""Converter for a relative path, as string"""
190-
def load(self, data):
192+
def load(self, data, context):
191193
return Path(data)
192194

193-
def dump(self, value):
195+
def dump(self, value, context):
194196
return str(value)
195197

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

210212
@source_file_field.after_load()
211-
def _edit_info(self):
213+
def _edit_info(self, context):
212214
if self.source_file is None:
213215
self.edit_info = None
214216
else:
@@ -234,10 +236,10 @@ def get_pks(self):
234236

235237
class PageCSSConverter(BaseConverter):
236238
"""Converter for CSS for a Page"""
237-
def load(self, value):
239+
def load(self, value, context):
238240
return sanitize.sanitize_css(value)
239241

240-
def dump(self, value):
242+
def dump(self, value, context):
241243
return value
242244

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

255-
def load(self, value, parent):
257+
def load(self, value, context, *, parent):
256258
return parent.root.licenses[value]
257259

258-
def dump(self, value):
260+
def dump(self, value, context):
259261
return value.slug
260262

261263
@classmethod
@@ -351,7 +353,7 @@ class Material(Model):
351353
doc="Slug of the corresponding lesson")
352354

353355
@lesson_slug.after_load()
354-
def _validate_lesson_slug(self):
356+
def _validate_lesson_slug(self, context):
355357
if self.lesson_slug and self.external_url:
356358
raise ValueError(
357359
'external_url and lesson_slug are incompatible'
@@ -416,7 +418,7 @@ class SessionTimeConverter(BaseConverter):
416418
to be fixed up using `_combine_session_time`.
417419
Converted to the full datetime on output.
418420
"""
419-
def load(self, data):
421+
def load(self, data, context):
420422
try:
421423
return datetime.datetime.strptime('%Y-%m-%d %H:%M:%S', data)
422424
except ValueError:
@@ -426,7 +428,7 @@ def load(self, data):
426428
time = datetime.datetime.strptime(data, '%H:%M').time()
427429
return time.replace(tzinfo=dateutil.tz.gettz(_TIMEZONE))
428430

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

432434
@classmethod
@@ -446,10 +448,10 @@ def get_schema(cls, context):
446448

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

452-
def dump(self, value):
454+
def dump(self, value, context):
453455
return str(value)
454456

455457
def get_schema(self, context):
@@ -476,6 +478,21 @@ class Session(Model):
476478
DateConverter(), optional=True,
477479
doc="The date when this session occurs (if it has a set time)",
478480
)
481+
serial = VersionField({
482+
(0, 1): Field(
483+
str,
484+
optional=True,
485+
doc="""
486+
Human-readable string identifying the session's position
487+
in the course.
488+
The serial is usually numeric: `1`, `2`, `3`, ...,
489+
but, for example, i, ii, iii... can be used for appendices.
490+
Some courses start numbering sessions from 0.
491+
"""
492+
),
493+
# For API version 0.0, serial is generated in
494+
# Course._sessions_after_load.
495+
})
479496

480497
description = Field(
481498
HTMLFragmentConverter(), optional=True,
@@ -490,21 +507,23 @@ class Session(Model):
490507
)
491508

492509
@materials.after_load()
493-
def _index_materials(self):
510+
def _index_materials(self, context):
494511
set_prev_next(m for m in self.materials if m.lesson_slug)
495512

496513
pages = Field(
497514
DictConverter(SessionPage, key_arg='slug'),
498515
optional=True,
499516
doc="The session's cover pages")
500517
@pages.after_load()
501-
def _set_pages(self):
518+
def _set_pages(self, context):
502519
if not self.pages:
503520
self.pages = {}
504521
for slug in 'front', 'back':
505522
if slug not in self.pages:
506-
page = get_converter(SessionPage).load(
507-
{}, slug=slug, parent=self,
523+
page = load(
524+
SessionPage,
525+
{'api_version': [0, 0], 'session-page': {}},
526+
slug=slug, parent=self,
508527
)
509528
self.pages[slug] = page
510529

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

516535
@time.after_load()
517-
def _fix_time(self):
536+
def _fix_time(self, context):
518537
if self.time is None:
519538
self.time = {}
520539
else:
@@ -546,10 +565,10 @@ def _fix_time(self):
546565

547566
class AnyDictConverter(BaseConverter):
548567
"""Converter of any JSON-encodable dict"""
549-
def load(self, data):
568+
def load(self, data, context):
550569
return data
551570

552-
def dump(self, value):
571+
def dump(self, value, context):
553572
return value
554573

555574
@classmethod
@@ -568,13 +587,13 @@ def time_from_string(time_string):
568587

569588
class TimeIntervalConverter(BaseConverter):
570589
"""Converter for a time interval, as a dict with 'start' and 'end'"""
571-
def load(self, data):
590+
def load(self, data, context):
572591
return {
573592
'start': time_from_string(data['start']),
574593
'end': time_from_string(data['end']),
575594
}
576595

577-
def dump(self, value):
596+
def dump(self, value, context):
578597
return {
579598
'start': value['start'].strftime('%H:%M'),
580599
'end': value['end'].strftime('%H:%M'),
@@ -673,14 +692,19 @@ def _default_lessons(self):
673692
doc="Individual sessions")
674693

675694
@sessions.after_load()
676-
def _sessions_after_load(self):
695+
def _sessions_after_load(self, context):
677696
set_prev_next(self.sessions.values())
678697

679698
for session in self.sessions.values():
680699
for material in session.materials:
681700
if material.lesson_slug:
682701
self._requested_lessons.add(material.lesson_slug)
683702

703+
if context.version < (0, 1) and len(self.sessions) > 1:
704+
# Assign serials to sessions (numbering from 1)
705+
for serial, session in enumerate(self.sessions.values(), start=1):
706+
session.serial = str(serial)
707+
684708
source_file = source_file_field
685709

686710
start_date = Field(
@@ -734,7 +758,7 @@ def load_remote(cls, slug, *, parent, link_info):
734758
doc="Slug of the course this derives from (deprecated)")
735759

736760
@derives.after_load()
737-
def _set_base_course(self):
761+
def _set_base_course(self, context):
738762
key = f'courses/{self.derives}'
739763
try:
740764
self.base_course = self.root.courses[key]
@@ -819,7 +843,7 @@ def freeze(self):
819843

820844
class AbbreviatedDictConverter(DictConverter):
821845
"""Dict that only shows URLs to its items when dumped"""
822-
def dump(self, value):
846+
def dump(self, value, context):
823847
return {
824848
key: {'$ref': v.get_url('api', external=True)}
825849
for key, v in value.items()
@@ -1034,7 +1058,11 @@ def load_licenses(self, path):
10341058
with (licence_path / 'info.yml').open() as f:
10351059
info = yaml.safe_load(f)
10361060
slug = licence_path.name
1037-
license = get_converter(License).load(info, parent=self, slug=slug)
1061+
license = load(
1062+
License,
1063+
{'api_version': [0, 0], 'license': info},
1064+
parent=self, slug=slug,
1065+
)
10381066
self.licenses[slug] = license
10391067

10401068
def get_course(self, slug):

naucse/templates/course.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ <h2>{{ course.subtitle }}</h2>
2525

2626
<div class="section{{ loop.index }}">
2727
<h4>
28-
{% if course.sessions|length > 1 %}
29-
Lekce {{ loop.index }} –
28+
{% if session.serial != None %}
29+
Lekce {{ session.serial }} –
3030
{% endif %}
3131
<a href="{{ session.get_url() }}">
3232
{{ session.title }}

naucse/views.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -399,12 +399,14 @@ def schema(model_slug, is_input):
399399
cls = models.models[model_slug]
400400
except KeyError:
401401
abort(404)
402-
return jsonify(models.get_schema(cls, is_input=is_input))
402+
return jsonify(models.get_schema(
403+
cls, is_input=is_input, version=models.API_VERSION,
404+
))
403405

404406

405407
@app.route('/v0/naucse.json')
406408
def api():
407-
return jsonify(models.dump(g.model))
409+
return jsonify(models.dump(g.model, version=models.API_VERSION))
408410

409411

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

418420

419421
@app.route('/v0/<course:course_slug>.json')
@@ -422,4 +424,4 @@ def course_api(course_slug):
422424
course = g.model.courses[course_slug]
423425
except KeyError:
424426
abort(404)
425-
return jsonify(models.dump(course))
427+
return jsonify(models.dump(course, version=models.API_VERSION))

test_naucse/conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,15 @@ def assert_yaml_dump(data, filename):
8282
assert data == expected
8383

8484

85+
@pytest.fixture(params=((0, 0), (0, 1)))
86+
def assert_model_dump(request):
87+
version = request.param
88+
def _assert(model, filename):
89+
filename += '.v{}.{}.yaml'.format(*version)
90+
assert_yaml_dump(models.dump(model, version=version), filename)
91+
return _assert
92+
93+
8594
def add_test_course(model, slug, data):
8695
model.add_course(models.load(
8796
models.Course,
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
course:
2+
api_version: [0, 1]
3+
course:
4+
title: A course loaded from API version 0.1
5+
subtitle: Suitable for testing only.
6+
source_file: courses/complex/info.yml
7+
sessions:
8+
- slug: first
9+
title: First
10+
serial: '1'
11+
- slug: second
12+
title: Second
13+
serial: '2'
14+
- slug: special
15+
title: Special

0 commit comments

Comments
 (0)