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.
Files changed (53) hide show
  1. django_flashdeck-0.1.0.dist-info/METADATA +215 -0
  2. django_flashdeck-0.1.0.dist-info/RECORD +53 -0
  3. django_flashdeck-0.1.0.dist-info/WHEEL +4 -0
  4. django_flashdeck-0.1.0.dist-info/licenses/LICENSE +28 -0
  5. flashdeck/__init__.py +1 -0
  6. flashdeck/admin.py +68 -0
  7. flashdeck/apps.py +8 -0
  8. flashdeck/bundles.py +346 -0
  9. flashdeck/conf.py +19 -0
  10. flashdeck/images.py +85 -0
  11. flashdeck/management/__init__.py +0 -0
  12. flashdeck/management/commands/__init__.py +0 -0
  13. flashdeck/management/commands/flashdeck_export.py +23 -0
  14. flashdeck/management/commands/flashdeck_import.py +29 -0
  15. flashdeck/manifest.py +33 -0
  16. flashdeck/marking.py +107 -0
  17. flashdeck/migrations/0001_initial.py +146 -0
  18. flashdeck/migrations/0002_card_options.py +44 -0
  19. flashdeck/migrations/0003_reference.py +28 -0
  20. flashdeck/migrations/__init__.py +0 -0
  21. flashdeck/models.py +325 -0
  22. flashdeck/practice.py +232 -0
  23. flashdeck/reference.py +108 -0
  24. flashdeck/scheduling.py +75 -0
  25. flashdeck/static/flashdeck/flashdeck.css +241 -0
  26. flashdeck/static/flashdeck/flashdeck.js +137 -0
  27. flashdeck/stats.py +248 -0
  28. flashdeck/templates/flashdeck/_account.html +1 -0
  29. flashdeck/templates/flashdeck/_assets.html +2 -0
  30. flashdeck/templates/flashdeck/_crumbs.html +1 -0
  31. flashdeck/templates/flashdeck/_crumbs_root.html +1 -0
  32. flashdeck/templates/flashdeck/_forms.html +3 -0
  33. flashdeck/templates/flashdeck/_prompt.html +5 -0
  34. flashdeck/templates/flashdeck/_reference_dialog.html +8 -0
  35. flashdeck/templates/flashdeck/_search.html +5 -0
  36. flashdeck/templates/flashdeck/admin/bundles.html +24 -0
  37. flashdeck/templates/flashdeck/fragment.html +1 -0
  38. flashdeck/templates/flashdeck/index.html +29 -0
  39. flashdeck/templates/flashdeck/nothing_due.html +21 -0
  40. flashdeck/templates/flashdeck/page.html +23 -0
  41. flashdeck/templates/flashdeck/progress.html +80 -0
  42. flashdeck/templates/flashdeck/question.html +46 -0
  43. flashdeck/templates/flashdeck/reference_deck.html +28 -0
  44. flashdeck/templates/flashdeck/reference_item.html +18 -0
  45. flashdeck/templates/flashdeck/reference_subject.html +19 -0
  46. flashdeck/templates/flashdeck/result.html +56 -0
  47. flashdeck/templates/flashdeck/settings.html +44 -0
  48. flashdeck/templatetags/__init__.py +0 -0
  49. flashdeck/templatetags/flashdeck_tags.py +52 -0
  50. flashdeck/urls.py +31 -0
  51. flashdeck/views.py +392 -0
  52. flashdeck/wagtail_hooks.py +109 -0
  53. 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