django-flashdeck 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_flashdeck-0.1.0.dist-info/METADATA +215 -0
- django_flashdeck-0.1.0.dist-info/RECORD +53 -0
- django_flashdeck-0.1.0.dist-info/WHEEL +4 -0
- django_flashdeck-0.1.0.dist-info/licenses/LICENSE +28 -0
- flashdeck/__init__.py +1 -0
- flashdeck/admin.py +68 -0
- flashdeck/apps.py +8 -0
- flashdeck/bundles.py +346 -0
- flashdeck/conf.py +19 -0
- flashdeck/images.py +85 -0
- flashdeck/management/__init__.py +0 -0
- flashdeck/management/commands/__init__.py +0 -0
- flashdeck/management/commands/flashdeck_export.py +23 -0
- flashdeck/management/commands/flashdeck_import.py +29 -0
- flashdeck/manifest.py +33 -0
- flashdeck/marking.py +107 -0
- flashdeck/migrations/0001_initial.py +146 -0
- flashdeck/migrations/0002_card_options.py +44 -0
- flashdeck/migrations/0003_reference.py +28 -0
- flashdeck/migrations/__init__.py +0 -0
- flashdeck/models.py +325 -0
- flashdeck/practice.py +232 -0
- flashdeck/reference.py +108 -0
- flashdeck/scheduling.py +75 -0
- flashdeck/static/flashdeck/flashdeck.css +241 -0
- flashdeck/static/flashdeck/flashdeck.js +137 -0
- flashdeck/stats.py +248 -0
- flashdeck/templates/flashdeck/_account.html +1 -0
- flashdeck/templates/flashdeck/_assets.html +2 -0
- flashdeck/templates/flashdeck/_crumbs.html +1 -0
- flashdeck/templates/flashdeck/_crumbs_root.html +1 -0
- flashdeck/templates/flashdeck/_forms.html +3 -0
- flashdeck/templates/flashdeck/_prompt.html +5 -0
- flashdeck/templates/flashdeck/_reference_dialog.html +8 -0
- flashdeck/templates/flashdeck/_search.html +5 -0
- flashdeck/templates/flashdeck/admin/bundles.html +24 -0
- flashdeck/templates/flashdeck/fragment.html +1 -0
- flashdeck/templates/flashdeck/index.html +29 -0
- flashdeck/templates/flashdeck/nothing_due.html +21 -0
- flashdeck/templates/flashdeck/page.html +23 -0
- flashdeck/templates/flashdeck/progress.html +80 -0
- flashdeck/templates/flashdeck/question.html +46 -0
- flashdeck/templates/flashdeck/reference_deck.html +28 -0
- flashdeck/templates/flashdeck/reference_item.html +18 -0
- flashdeck/templates/flashdeck/reference_subject.html +19 -0
- flashdeck/templates/flashdeck/result.html +56 -0
- flashdeck/templates/flashdeck/settings.html +44 -0
- flashdeck/templatetags/__init__.py +0 -0
- flashdeck/templatetags/flashdeck_tags.py +52 -0
- flashdeck/urls.py +31 -0
- flashdeck/views.py +392 -0
- flashdeck/wagtail_hooks.py +109 -0
- flashdeck/wagtail_views.py +106 -0
flashdeck/bundles.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Bundles: a subject's decks and cards, with their images, as a directory or zip
|
|
2
|
+
file holding cards.yaml and the image files it names. Bundles are how decks are
|
|
3
|
+
written, generated, moved between sites and passed on; the README describes the
|
|
4
|
+
format.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import io
|
|
8
|
+
import zipfile
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from pathlib import Path, PurePosixPath
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
from django.core.exceptions import ValidationError
|
|
14
|
+
from django.core.files import File
|
|
15
|
+
from django.core.validators import validate_slug
|
|
16
|
+
from django.db import transaction
|
|
17
|
+
|
|
18
|
+
from .images import prepare_image
|
|
19
|
+
from .manifest import FORMAT, MANIFEST, dump_manifest
|
|
20
|
+
from .marking import normalise
|
|
21
|
+
from .models import RESERVED_SLUGS, Answer, Card, Deck, Subject
|
|
22
|
+
|
|
23
|
+
# The largest file a bundle may hold, uncompressed.
|
|
24
|
+
MAX_FILE_SIZE = 20 * 1024 * 1024
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BundleError(Exception):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _Source:
|
|
32
|
+
"""Reads files from a bundle directory or zip file."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, path):
|
|
35
|
+
self.path = Path(path)
|
|
36
|
+
self.zip = None
|
|
37
|
+
self.prefix = PurePosixPath()
|
|
38
|
+
if self.path.is_file():
|
|
39
|
+
if not zipfile.is_zipfile(self.path):
|
|
40
|
+
raise BundleError(f"{self.path} is neither a directory nor a zip file.")
|
|
41
|
+
self.zip = zipfile.ZipFile(self.path)
|
|
42
|
+
# Allow the bundle to be one directory down, as when a directory is
|
|
43
|
+
# zipped as a whole.
|
|
44
|
+
manifests = [
|
|
45
|
+
PurePosixPath(n)
|
|
46
|
+
for n in self.zip.namelist()
|
|
47
|
+
if PurePosixPath(n).name == MANIFEST
|
|
48
|
+
]
|
|
49
|
+
if not manifests:
|
|
50
|
+
raise BundleError(f"There is no {MANIFEST} in {self.path}.")
|
|
51
|
+
self.prefix = min(manifests, key=lambda p: len(p.parts)).parent
|
|
52
|
+
elif not self.path.is_dir():
|
|
53
|
+
raise BundleError(f"There is no bundle at {self.path}.")
|
|
54
|
+
|
|
55
|
+
def read(self, name):
|
|
56
|
+
path = PurePosixPath(str(name))
|
|
57
|
+
if path.is_absolute() or ".." in path.parts or not path.parts:
|
|
58
|
+
raise BundleError(f"{name!r} is not a path within the bundle.")
|
|
59
|
+
try:
|
|
60
|
+
if self.zip:
|
|
61
|
+
info = self.zip.getinfo(str(self.prefix / path))
|
|
62
|
+
if info.file_size > MAX_FILE_SIZE:
|
|
63
|
+
raise BundleError(f"{name} is larger than 20 MB.")
|
|
64
|
+
return self.zip.read(info)
|
|
65
|
+
return self.path.joinpath(*path.parts).read_bytes()
|
|
66
|
+
except (KeyError, FileNotFoundError, IsADirectoryError) as error:
|
|
67
|
+
raise BundleError(f"{name} is not in the bundle.") from error
|
|
68
|
+
|
|
69
|
+
def close(self):
|
|
70
|
+
if self.zip:
|
|
71
|
+
self.zip.close()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def import_bundle(path, *, update=False):
|
|
75
|
+
"""Imports a bundle, adding the subject, decks and cards that are not on the
|
|
76
|
+
site (matched by slug and by key). With `update`, existing ones are updated
|
|
77
|
+
too, except that answers accepted on the site are kept, an image is replaced
|
|
78
|
+
only if it has changed, and whether a deck is public is left as it is. Returns
|
|
79
|
+
counts of what was done."""
|
|
80
|
+
source = _Source(path)
|
|
81
|
+
try:
|
|
82
|
+
try:
|
|
83
|
+
data = yaml.safe_load(source.read(MANIFEST))
|
|
84
|
+
except yaml.YAMLError as error:
|
|
85
|
+
raise BundleError(f"{MANIFEST} is not valid YAML: {error}") from error
|
|
86
|
+
if not isinstance(data, dict) or data.get("format") != FORMAT:
|
|
87
|
+
raise BundleError(f"{MANIFEST} must start with “format: {FORMAT}”.")
|
|
88
|
+
with transaction.atomic():
|
|
89
|
+
return _import(data, source, update)
|
|
90
|
+
finally:
|
|
91
|
+
source.close()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _import(data, source, update):
|
|
95
|
+
counts = Counter()
|
|
96
|
+
fields = _mapping(data.get("subject"), "subject")
|
|
97
|
+
slug = _slug(fields.get("slug"), "subject", page_name=True)
|
|
98
|
+
characters = fields.get("characters") or ""
|
|
99
|
+
if isinstance(characters, list):
|
|
100
|
+
characters = " ".join(_text(c, "subject characters") for c in characters)
|
|
101
|
+
values = {
|
|
102
|
+
"title": _text(fields.get("title"), "subject title", required=True),
|
|
103
|
+
"description": _text(fields.get("description"), "subject description"),
|
|
104
|
+
"reference": _text(fields.get("reference"), "subject reference"),
|
|
105
|
+
"language": _text(fields.get("language"), "subject language"),
|
|
106
|
+
"characters": _text(characters, "subject characters"),
|
|
107
|
+
"case_sensitive": bool(fields.get("case_sensitive", False)),
|
|
108
|
+
}
|
|
109
|
+
subject, created = Subject.objects.get_or_create(slug=slug, defaults=values)
|
|
110
|
+
_count(counts, "subject", created, update, subject, values)
|
|
111
|
+
|
|
112
|
+
for position, deck_data in enumerate(data.get("decks") or []):
|
|
113
|
+
deck_data = _mapping(deck_data, f"deck {position + 1}")
|
|
114
|
+
slug = _slug(deck_data.get("slug"), f"deck {position + 1}", page_name=True)
|
|
115
|
+
where = f"deck {slug}"
|
|
116
|
+
values = {
|
|
117
|
+
"title": _text(deck_data.get("title"), f"{where}: title", required=True),
|
|
118
|
+
"description": _text(deck_data.get("description"), f"{where}: description"),
|
|
119
|
+
"reference": _text(deck_data.get("reference"), f"{where}: reference"),
|
|
120
|
+
"is_public": bool(deck_data.get("public", False)),
|
|
121
|
+
"position": position,
|
|
122
|
+
}
|
|
123
|
+
deck, created = Deck.objects.get_or_create(
|
|
124
|
+
subject=subject, slug=slug, defaults=values
|
|
125
|
+
)
|
|
126
|
+
# Whether a deck is public is for the site to decide, so an update leaves
|
|
127
|
+
# it as it is.
|
|
128
|
+
values.pop("is_public")
|
|
129
|
+
_count(counts, "deck", created, update, deck, values)
|
|
130
|
+
for card_position, card_data in enumerate(deck_data.get("cards") or []):
|
|
131
|
+
_import_card(deck, card_data, card_position, source, update, counts)
|
|
132
|
+
return counts
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _count(counts, kind, created, update, obj, values):
|
|
136
|
+
if created:
|
|
137
|
+
counts[f"{kind}s created"] += 1
|
|
138
|
+
elif update:
|
|
139
|
+
for name, value in values.items():
|
|
140
|
+
setattr(obj, name, value)
|
|
141
|
+
obj.save()
|
|
142
|
+
counts[f"{kind}s updated"] += 1
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _import_card(deck, data, position, source, update, counts):
|
|
146
|
+
data = _mapping(data, f"deck {deck.slug}: card {position + 1}")
|
|
147
|
+
key = _slug(data.get("key"), f"deck {deck.slug}: card {position + 1}")
|
|
148
|
+
where = f"deck {deck.slug}: card {key}"
|
|
149
|
+
answers = _answers(data, where)
|
|
150
|
+
card = Card.objects.filter(deck=deck, key=key).first()
|
|
151
|
+
if card is not None and not update:
|
|
152
|
+
counts["cards unchanged"] += 1
|
|
153
|
+
return
|
|
154
|
+
new = card is None
|
|
155
|
+
if new:
|
|
156
|
+
card = Card(deck=deck, key=key)
|
|
157
|
+
card.prompt = _text(data.get("prompt"), f"{where}: prompt")
|
|
158
|
+
card.notes = _text(data.get("notes"), f"{where}: notes")
|
|
159
|
+
card.image_alt = _text(data.get("image_alt"), f"{where}: image_alt")
|
|
160
|
+
card.image_credit = _text(data.get("image_credit"), f"{where}: image_credit")
|
|
161
|
+
card.image_licence = _text(data.get("image_licence"), f"{where}: image_licence")
|
|
162
|
+
card.position = position
|
|
163
|
+
image = data.get("image")
|
|
164
|
+
if image:
|
|
165
|
+
content = source.read(image)
|
|
166
|
+
if not card.image or _image_changed(card, content, image):
|
|
167
|
+
card.image = File(io.BytesIO(content), name=PurePosixPath(image).name)
|
|
168
|
+
if not card.prompt and not card.image:
|
|
169
|
+
raise BundleError(f"{where}: a card needs a prompt, an image or both.")
|
|
170
|
+
try:
|
|
171
|
+
card.full_clean(exclude=["deck"])
|
|
172
|
+
except ValidationError as error:
|
|
173
|
+
raise BundleError(f"{where}: {'; '.join(error.messages)}") from error
|
|
174
|
+
card.save()
|
|
175
|
+
|
|
176
|
+
existing = [] if new else list(card.answers.all())
|
|
177
|
+
for sort_order, values in enumerate(answers):
|
|
178
|
+
if sort_order < len(existing):
|
|
179
|
+
answer = existing[sort_order]
|
|
180
|
+
kept = answer.accepted()[1:]
|
|
181
|
+
else:
|
|
182
|
+
answer, kept = Answer(card=card), []
|
|
183
|
+
answer.label = values["label"]
|
|
184
|
+
answer.text = values["text"]
|
|
185
|
+
answer.alternatives = "\n".join(
|
|
186
|
+
_distinct(values["alternatives"] + kept, exclude=values["text"])
|
|
187
|
+
)
|
|
188
|
+
answer.sort_order = sort_order
|
|
189
|
+
answer.save()
|
|
190
|
+
for answer in existing[len(answers) :]:
|
|
191
|
+
answer.delete()
|
|
192
|
+
counts["cards created" if new else "cards updated"] += 1
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _image_changed(card, content, name):
|
|
196
|
+
"""Whether an image in a bundle differs from the card's, as it would be stored
|
|
197
|
+
(reduced and stripped of metadata), so that importing the same image again
|
|
198
|
+
leaves the card's file alone."""
|
|
199
|
+
prepared = prepare_image(io.BytesIO(content), name).read()
|
|
200
|
+
try:
|
|
201
|
+
with card.image.open("rb") as stored:
|
|
202
|
+
return stored.read() != prepared
|
|
203
|
+
except FileNotFoundError:
|
|
204
|
+
return True
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _distinct(forms, exclude):
|
|
208
|
+
seen = {normalise(exclude)}
|
|
209
|
+
out = []
|
|
210
|
+
for form in forms:
|
|
211
|
+
key = normalise(form)
|
|
212
|
+
if key not in seen:
|
|
213
|
+
seen.add(key)
|
|
214
|
+
out.append(form)
|
|
215
|
+
return out
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _answers(data, where):
|
|
219
|
+
raw = data.get("answers")
|
|
220
|
+
if raw is None and "answer" in data:
|
|
221
|
+
raw = [data["answer"]]
|
|
222
|
+
if not isinstance(raw, list) or not raw:
|
|
223
|
+
raise BundleError(f"{where}: a card needs at least one answer.")
|
|
224
|
+
answers = []
|
|
225
|
+
for item in raw:
|
|
226
|
+
if not isinstance(item, dict):
|
|
227
|
+
item = {"text": item}
|
|
228
|
+
alternatives = item.get("alternatives") or []
|
|
229
|
+
if not isinstance(alternatives, list):
|
|
230
|
+
alternatives = [alternatives]
|
|
231
|
+
answers.append(
|
|
232
|
+
{
|
|
233
|
+
"label": _text(item.get("label"), f"{where}: label"),
|
|
234
|
+
"text": _text(item.get("text"), f"{where}: answer", required=True),
|
|
235
|
+
"alternatives": [
|
|
236
|
+
t
|
|
237
|
+
for t in (_text(a, f"{where}: alternative") for a in alternatives)
|
|
238
|
+
if t
|
|
239
|
+
],
|
|
240
|
+
}
|
|
241
|
+
)
|
|
242
|
+
return answers
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _mapping(value, where):
|
|
246
|
+
if not isinstance(value, dict):
|
|
247
|
+
raise BundleError(f"{where}: expected a mapping of names to values.")
|
|
248
|
+
return value
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _slug(value, where, *, page_name=False):
|
|
252
|
+
"""A slug or key; a subject's or deck's slug (`page_name`), which is part of
|
|
253
|
+
its page's address, must not be the name of one of the app's own pages."""
|
|
254
|
+
value = _text(value, f"{where}: slug or key", required=True)
|
|
255
|
+
try:
|
|
256
|
+
validate_slug(value)
|
|
257
|
+
except ValidationError as error:
|
|
258
|
+
raise BundleError(
|
|
259
|
+
f"{where}: {value!r} must be letters, numbers, hyphens and underscores."
|
|
260
|
+
) from error
|
|
261
|
+
if page_name and value in RESERVED_SLUGS:
|
|
262
|
+
raise BundleError(f"{where}: {value!r} is the name of one of the app's pages.")
|
|
263
|
+
return value
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _text(value, where, *, required=False):
|
|
267
|
+
"""A YAML value as text. YAML reads an unquoted yes, no, on or off as true or
|
|
268
|
+
false, which is never meant here (on is a French pronoun)."""
|
|
269
|
+
if isinstance(value, bool):
|
|
270
|
+
raise BundleError(f"{where}: put {str(value).lower()!r} in quotation marks.")
|
|
271
|
+
text = "" if value is None else str(value).strip()
|
|
272
|
+
if required and not text:
|
|
273
|
+
raise BundleError(f"{where}: missing.")
|
|
274
|
+
return text
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def export_bundle(subject, path):
|
|
278
|
+
"""Writes a subject's decks and cards, with their images, as a bundle: a zip
|
|
279
|
+
file if `path` ends in .zip, otherwise a directory. Returns the number of
|
|
280
|
+
cards."""
|
|
281
|
+
path = Path(path)
|
|
282
|
+
files = {}
|
|
283
|
+
decks = []
|
|
284
|
+
count = 0
|
|
285
|
+
for deck in subject.decks.all():
|
|
286
|
+
cards = []
|
|
287
|
+
for card in deck.cards.prefetch_related("answers"):
|
|
288
|
+
entry = {"key": card.key}
|
|
289
|
+
if card.prompt:
|
|
290
|
+
entry["prompt"] = card.prompt
|
|
291
|
+
if card.image:
|
|
292
|
+
name = f"images/{deck.slug}/{card.key}{PurePosixPath(card.image.name).suffix}"
|
|
293
|
+
with card.image.open("rb") as image:
|
|
294
|
+
files[name] = image.read()
|
|
295
|
+
entry["image"] = name
|
|
296
|
+
for field in ("image_alt", "image_credit", "image_licence"):
|
|
297
|
+
if getattr(card, field):
|
|
298
|
+
entry[field] = getattr(card, field)
|
|
299
|
+
entry["answers"] = [_export_answer(a) for a in card.answers.all()]
|
|
300
|
+
if card.notes:
|
|
301
|
+
entry["notes"] = card.notes
|
|
302
|
+
cards.append(entry)
|
|
303
|
+
count += 1
|
|
304
|
+
deck_entry = {"slug": deck.slug, "title": deck.title}
|
|
305
|
+
if deck.description:
|
|
306
|
+
deck_entry["description"] = deck.description
|
|
307
|
+
if deck.reference:
|
|
308
|
+
deck_entry["reference"] = deck.reference
|
|
309
|
+
deck_entry["public"] = deck.is_public
|
|
310
|
+
deck_entry["cards"] = cards
|
|
311
|
+
decks.append(deck_entry)
|
|
312
|
+
subject_entry = {"slug": subject.slug, "title": subject.title}
|
|
313
|
+
for field in ("description", "language", "characters", "reference"):
|
|
314
|
+
if getattr(subject, field):
|
|
315
|
+
subject_entry[field] = getattr(subject, field)
|
|
316
|
+
if subject.case_sensitive:
|
|
317
|
+
subject_entry["case_sensitive"] = True
|
|
318
|
+
manifest = dump_manifest(
|
|
319
|
+
{"format": FORMAT, "subject": subject_entry, "decks": decks}
|
|
320
|
+
)
|
|
321
|
+
if path.suffix == ".zip":
|
|
322
|
+
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as bundle:
|
|
323
|
+
bundle.writestr(MANIFEST, manifest)
|
|
324
|
+
for name, content in files.items():
|
|
325
|
+
bundle.writestr(name, content)
|
|
326
|
+
else:
|
|
327
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
328
|
+
(path / MANIFEST).write_text(manifest, encoding="utf-8")
|
|
329
|
+
for name, content in files.items():
|
|
330
|
+
target = path.joinpath(*PurePosixPath(name).parts)
|
|
331
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
332
|
+
target.write_bytes(content)
|
|
333
|
+
return count
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _export_answer(answer):
|
|
337
|
+
alternatives = answer.accepted()[1:]
|
|
338
|
+
if not answer.label and not alternatives:
|
|
339
|
+
return answer.text
|
|
340
|
+
entry = {}
|
|
341
|
+
if answer.label:
|
|
342
|
+
entry["label"] = answer.label
|
|
343
|
+
entry["text"] = answer.text
|
|
344
|
+
if alternatives:
|
|
345
|
+
entry["alternatives"] = alternatives
|
|
346
|
+
return entry
|
flashdeck/conf.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""The app's settings. A site overrides one by setting FLASHDECK_<NAME>."""
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
|
|
5
|
+
DEFAULTS = {
|
|
6
|
+
# New cards introduced to each user per day, across all subjects.
|
|
7
|
+
"NEW_CARDS_PER_DAY": 20,
|
|
8
|
+
# Card images are reduced to fit a square of this size (in pixels) on upload.
|
|
9
|
+
"IMAGE_MAX_SIZE": 1200,
|
|
10
|
+
# The directory, within the site's media storage, that holds card images.
|
|
11
|
+
"UPLOAD_DIR": "flashdeck",
|
|
12
|
+
# The URL (or URL name) to log out at, by POST, for a link on the index; blank
|
|
13
|
+
# for none. Logging in uses Django's LOGIN_URL.
|
|
14
|
+
"LOGOUT_URL": "",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def setting(name):
|
|
19
|
+
return getattr(settings, f"FLASHDECK_{name}", DEFAULTS[name])
|
flashdeck/images.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Card images: checked on upload, reduced in size and stripped of metadata.
|
|
2
|
+
|
|
3
|
+
Photographs often carry a caption or keywords (in EXIF, XMP or a JPEG comment)
|
|
4
|
+
that name what they show, which would give the answer away to anyone who looks
|
|
5
|
+
at the file, so only the pixels (and the colour profile) are kept.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import io
|
|
9
|
+
from pathlib import PurePosixPath
|
|
10
|
+
|
|
11
|
+
from django.core.exceptions import ValidationError
|
|
12
|
+
from django.core.files.base import ContentFile
|
|
13
|
+
from PIL import Image, ImageOps, UnidentifiedImageError
|
|
14
|
+
|
|
15
|
+
from .conf import setting
|
|
16
|
+
|
|
17
|
+
IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "svg"]
|
|
18
|
+
_FORMATS = {".png": "PNG", ".jpg": "JPEG", ".jpeg": "JPEG", ".webp": "WEBP"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def validate_image(value):
|
|
22
|
+
"""Checks that a newly uploaded image is of the type its extension says.
|
|
23
|
+
|
|
24
|
+
An SVG file is only checked for an <svg> element: as it can contain scripts,
|
|
25
|
+
only trusted editors should be able to upload one.
|
|
26
|
+
"""
|
|
27
|
+
if getattr(value, "_committed", True):
|
|
28
|
+
return # already stored, so checked when it was uploaded
|
|
29
|
+
suffix = PurePosixPath(value.name).suffix.lower()
|
|
30
|
+
value.seek(0)
|
|
31
|
+
try:
|
|
32
|
+
if suffix == ".svg":
|
|
33
|
+
head = value.read(4096)
|
|
34
|
+
if isinstance(head, bytes):
|
|
35
|
+
head = head.decode("utf-8", "replace")
|
|
36
|
+
if "<svg" not in head:
|
|
37
|
+
raise ValidationError("This file is not an SVG image.")
|
|
38
|
+
return
|
|
39
|
+
try:
|
|
40
|
+
with Image.open(value) as image:
|
|
41
|
+
found = image.format
|
|
42
|
+
image.verify()
|
|
43
|
+
except (UnidentifiedImageError, OSError, SyntaxError) as error:
|
|
44
|
+
raise ValidationError(
|
|
45
|
+
"This file is not an image that can be read."
|
|
46
|
+
) from error
|
|
47
|
+
if found != _FORMATS.get(suffix):
|
|
48
|
+
raise ValidationError(
|
|
49
|
+
f"This is a {found} image, but its name ends in {suffix}."
|
|
50
|
+
)
|
|
51
|
+
finally:
|
|
52
|
+
value.seek(0)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def prepare_image(file, name: str) -> ContentFile:
|
|
56
|
+
"""The image to store: reduced to fit IMAGE_MAX_SIZE, turned the right way up,
|
|
57
|
+
and without metadata. SVG images are stored as they are."""
|
|
58
|
+
suffix = PurePosixPath(name).suffix.lower()
|
|
59
|
+
file.seek(0)
|
|
60
|
+
data = file.read()
|
|
61
|
+
if suffix == ".svg":
|
|
62
|
+
return ContentFile(data, name="image.svg")
|
|
63
|
+
with Image.open(io.BytesIO(data)) as original:
|
|
64
|
+
fmt = original.format
|
|
65
|
+
icc_profile = original.info.get("icc_profile")
|
|
66
|
+
transparency = original.info.get("transparency")
|
|
67
|
+
image = ImageOps.exif_transpose(original)
|
|
68
|
+
image.info = {} if transparency is None else {"transparency": transparency}
|
|
69
|
+
size = setting("IMAGE_MAX_SIZE")
|
|
70
|
+
image.thumbnail((size, size))
|
|
71
|
+
options = {}
|
|
72
|
+
if fmt == "JPEG":
|
|
73
|
+
if image.mode not in ("RGB", "L"):
|
|
74
|
+
image = image.convert("RGB")
|
|
75
|
+
icc_profile = None # it described the old colour space
|
|
76
|
+
options.update(quality=85, optimize=True)
|
|
77
|
+
elif fmt == "WEBP":
|
|
78
|
+
options.update(quality=85)
|
|
79
|
+
elif fmt == "PNG":
|
|
80
|
+
options.update(optimize=True)
|
|
81
|
+
if icc_profile:
|
|
82
|
+
options["icc_profile"] = icc_profile
|
|
83
|
+
out = io.BytesIO()
|
|
84
|
+
image.save(out, fmt, **options)
|
|
85
|
+
return ContentFile(out.getvalue(), name=f"image{suffix}")
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
2
|
+
|
|
3
|
+
from flashdeck.bundles import export_bundle
|
|
4
|
+
from flashdeck.models import Subject
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Command(BaseCommand):
|
|
8
|
+
help = (
|
|
9
|
+
"Exports a subject's decks and cards, with their images, as a bundle: a zip "
|
|
10
|
+
"file if the name ends in .zip, otherwise a directory."
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
def add_arguments(self, parser):
|
|
14
|
+
parser.add_argument("subject", help="The subject's slug.")
|
|
15
|
+
parser.add_argument("bundle", help="The directory or zip file to write.")
|
|
16
|
+
|
|
17
|
+
def handle(self, subject, bundle, **options):
|
|
18
|
+
try:
|
|
19
|
+
subject = Subject.objects.get(slug=subject)
|
|
20
|
+
except Subject.DoesNotExist as error:
|
|
21
|
+
raise CommandError(f"There is no subject {subject!r}.") from error
|
|
22
|
+
count = export_bundle(subject, bundle)
|
|
23
|
+
self.stdout.write(f"{count} cards written to {bundle}.")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
2
|
+
|
|
3
|
+
from flashdeck.bundles import BundleError, import_bundle
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Command(BaseCommand):
|
|
7
|
+
help = (
|
|
8
|
+
"Imports a bundle of flashcards: a directory or zip file holding cards.yaml "
|
|
9
|
+
"and its images. Cards not on the site are added; existing ones are left "
|
|
10
|
+
"alone unless --update is given."
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
def add_arguments(self, parser):
|
|
14
|
+
parser.add_argument("bundle", help="The bundle's directory or zip file.")
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"--update",
|
|
17
|
+
action="store_true",
|
|
18
|
+
help="Also update existing subjects, decks and cards from the bundle. "
|
|
19
|
+
"Answers accepted on the site are kept, an image is replaced only if it "
|
|
20
|
+
"has changed, and whether a deck is public is left as it is.",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
def handle(self, bundle, update, **options):
|
|
24
|
+
try:
|
|
25
|
+
counts = import_bundle(bundle, update=update)
|
|
26
|
+
except BundleError as error:
|
|
27
|
+
raise CommandError(str(error)) from error
|
|
28
|
+
summary = ", ".join(f"{n} {what}" for what, n in counts.items())
|
|
29
|
+
self.stdout.write(summary or "Nothing to import.")
|
flashdeck/manifest.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""The bundle format's manifest, cards.yaml. This module needs no Django set-up, so
|
|
2
|
+
that scripts which generate bundles can use it."""
|
|
3
|
+
|
|
4
|
+
import yaml
|
|
5
|
+
|
|
6
|
+
FORMAT = "flashdeck/1"
|
|
7
|
+
MANIFEST = "cards.yaml"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _Dumper(yaml.SafeDumper):
|
|
11
|
+
"""Writes text of more than one line as a literal block (|-)."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _represent_text(dumper, text):
|
|
15
|
+
style = "|" if "\n" in text else None
|
|
16
|
+
return dumper.represent_scalar("tag:yaml.org,2002:str", text, style=style)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_Dumper.add_representer(str, _represent_text)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def dump_manifest(data):
|
|
23
|
+
"""The text of cards.yaml for a bundle's data, written to be easy to read and
|
|
24
|
+
edit: short lists and mappings on one line, and text of more than one line as
|
|
25
|
+
a literal block."""
|
|
26
|
+
return yaml.dump(
|
|
27
|
+
data,
|
|
28
|
+
Dumper=_Dumper,
|
|
29
|
+
allow_unicode=True,
|
|
30
|
+
sort_keys=False,
|
|
31
|
+
default_flow_style=None,
|
|
32
|
+
width=88,
|
|
33
|
+
)
|
flashdeck/marking.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Marking typed answers as right, nearly right or wrong.
|
|
2
|
+
|
|
3
|
+
An answer is *right* if it matches an accepted answer once both are normalised (see
|
|
4
|
+
`normalise`), and *nearly right* if it differs from one only in accents, capitals
|
|
5
|
+
or punctuation (see `loosen`). Nothing else is forgiven.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import unicodedata
|
|
10
|
+
from collections.abc import Iterable, Sequence
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from django.db import models
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Verdict(models.TextChoices):
|
|
17
|
+
RIGHT = "right", "Right"
|
|
18
|
+
NEARLY = "nearly", "Nearly right"
|
|
19
|
+
WRONG = "wrong", "Wrong"
|
|
20
|
+
# Marked nearly right or wrong, then claimed as right by the user.
|
|
21
|
+
CLAIMED = "claimed", "Claimed as right"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Typographic variants that one keyboard produces and another does not.
|
|
25
|
+
_EQUIVALENTS = str.maketrans(
|
|
26
|
+
{
|
|
27
|
+
"’": "'", # right single quotation mark, the French apostrophe
|
|
28
|
+
"‘": "'", # left single quotation mark
|
|
29
|
+
"ʼ": "'", # modifier letter apostrophe
|
|
30
|
+
"“": '"', # left double quotation mark
|
|
31
|
+
"”": '"', # right double quotation mark
|
|
32
|
+
" ": " ", # no-break space
|
|
33
|
+
" ": " ", # narrow no-break space, before ; : ! ? in French
|
|
34
|
+
"‑": "-", # non-breaking hyphen
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
_SPACE_BEFORE_PUNCTUATION = re.compile(r"\s+([;:!?])")
|
|
38
|
+
_LETTERS_SPELT_OUT = str.maketrans(
|
|
39
|
+
{"ß": "ss", "ẞ": "ss", "æ": "ae", "Æ": "ae", "œ": "oe", "Œ": "oe"}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def normalise(text: str, *, case_sensitive: bool = False) -> str:
|
|
44
|
+
"""The form of an answer that is compared for a right mark.
|
|
45
|
+
|
|
46
|
+
Differences in Unicode composition, typographic apostrophes and quotation
|
|
47
|
+
marks, runs of white space, a space before ; : ! or ? (which French typography
|
|
48
|
+
puts there), and a final full stop, exclamation mark or question mark are all
|
|
49
|
+
ignored, as are capitals unless `case_sensitive` is set.
|
|
50
|
+
"""
|
|
51
|
+
text = unicodedata.normalize("NFC", text).translate(_EQUIVALENTS)
|
|
52
|
+
text = " ".join(text.split())
|
|
53
|
+
text = _SPACE_BEFORE_PUNCTUATION.sub(r"\1", text)
|
|
54
|
+
text = text.rstrip(".!?").rstrip()
|
|
55
|
+
# lower(), not casefold(), which would turn ß into ss.
|
|
56
|
+
return text if case_sensitive else text.lower()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def loosen(text: str) -> str:
|
|
60
|
+
"""The form of an answer that is compared for a nearly-right mark.
|
|
61
|
+
|
|
62
|
+
As `normalise`, but also without accents, capitals or punctuation (each mark
|
|
63
|
+
of punctuation counts as a space), and with ß, æ and œ spelt out.
|
|
64
|
+
"""
|
|
65
|
+
text = normalise(text).translate(_LETTERS_SPELT_OUT)
|
|
66
|
+
text = "".join(
|
|
67
|
+
c for c in unicodedata.normalize("NFD", text) if not unicodedata.combining(c)
|
|
68
|
+
)
|
|
69
|
+
text = "".join(c if c.isalnum() or c.isspace() else " " for c in text)
|
|
70
|
+
return " ".join(text.split())
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class Mark:
|
|
75
|
+
verdict: Verdict
|
|
76
|
+
# The accepted answer that was matched or nearly matched, or else the first
|
|
77
|
+
# (the model answer).
|
|
78
|
+
expected: str
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def mark(given: str, accepted: Sequence[str], *, case_sensitive: bool = False) -> Mark:
|
|
82
|
+
"""Marks one answer against the answers accepted for it."""
|
|
83
|
+
if not accepted:
|
|
84
|
+
raise ValueError("No accepted answers")
|
|
85
|
+
strict = normalise(given, case_sensitive=case_sensitive)
|
|
86
|
+
if not strict:
|
|
87
|
+
return Mark(Verdict.WRONG, accepted[0])
|
|
88
|
+
for form in accepted:
|
|
89
|
+
if normalise(form, case_sensitive=case_sensitive) == strict:
|
|
90
|
+
return Mark(Verdict.RIGHT, form)
|
|
91
|
+
loose = loosen(given)
|
|
92
|
+
if loose:
|
|
93
|
+
for form in accepted:
|
|
94
|
+
if loosen(form) == loose:
|
|
95
|
+
return Mark(Verdict.NEARLY, form)
|
|
96
|
+
return Mark(Verdict.WRONG, accepted[0])
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def combine(verdicts: Iterable[str]) -> Verdict:
|
|
100
|
+
"""The verdict on a card from the verdicts on its answers: wrong if any is
|
|
101
|
+
wrong, otherwise nearly right if any is nearly right, otherwise right."""
|
|
102
|
+
verdicts = set(verdicts)
|
|
103
|
+
if Verdict.WRONG in verdicts:
|
|
104
|
+
return Verdict.WRONG
|
|
105
|
+
if Verdict.NEARLY in verdicts:
|
|
106
|
+
return Verdict.NEARLY
|
|
107
|
+
return Verdict.RIGHT
|