parody-web 0.1.0__tar.gz

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.
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: parody-web
3
+ Version: 0.1.0
4
+ Summary: Django app that serves a parody build artifact as a public book site, with section-level auth gating (public = online-only subset; owner = full book).
5
+ Author-email: Rico Picone <dr@ricopic.one>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/ricopicone/parody-web
8
+ Project-URL: Issues, https://github.com/ricopicone/parody-web/issues
9
+ Keywords: parody,django,publishing,book,textbook
10
+ Classifier: Framework :: Django
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: Django<6.0,>=5.0
17
+
18
+ # parody-web
19
+
20
+ A reusable **Django app** that serves a [parody](https://github.com/ricopicone/parody)
21
+ build artifact as a public book site, with section-level auth gating. It's the
22
+ *web* surface of the parody ecosystem:
23
+
24
+ - **`parody`** — core: builds the artifact (and the print/LaTeX side, for now)
25
+ - **`parody-web`** — *this*: renders an artifact as a website
26
+ - *(future)* `parody-print` — if/when the LaTeX side is split out of core
27
+
28
+ You `pip install parody-web` into a thin per-book Django project; the package is
29
+ generic, so one codebase serves every book site. Each book differs only by
30
+ config + content. First site: the partial *Real-Time Computing* book at
31
+ [rtcbook.org](https://rtcbook.org).
32
+
33
+ ## Access model
34
+
35
+ The site imports the **full** artifact and gates by section: the public sees
36
+ only `online_only` sections (the openly-licensed subset); the **private** parts
37
+ require login, and only the owner has an account. One deployment serves both the
38
+ public partial book and — to the owner — the whole thing. (If you'd rather the
39
+ full text never touch the database, import the partial `parody build
40
+ --online-only` artifact instead — then there are no private sections.)
41
+
42
+ ## Why a Django app (not a static site)
43
+
44
+ Parody artifact `html` is Django-template-flavored — it embeds `{% media %}`,
45
+ `{% static %}`, `{% cite %}` etc. This app renders it **natively** through the
46
+ Django template engine (`parody_web.templatetags.parody_web.render_book`), so
47
+ there's no second, lossy tag-resolution renderer to maintain.
48
+
49
+ ## Use it in a project
50
+
51
+ ```python
52
+ # settings.py
53
+ INSTALLED_APPS = [..., "parody_web"]
54
+ BOOK_SLUG = "real-time-computing" # which imported book is the site root
55
+ ```
56
+ ```python
57
+ # urls.py
58
+ urlpatterns = [path("", include("parody_web.urls")), ...]
59
+ ```
60
+ ```bash
61
+ pip install parody-web
62
+ python manage.py migrate
63
+ python manage.py createsuperuser # the owner (only account)
64
+ python manage.py import_artifact rtc.json --slug real-time-computing
65
+ ```
66
+
67
+ Provides: `Book`/`Chapter`/`Section` models, the `import_artifact` command
68
+ (upsert-by-slug, idempotent), index + section views, templates (override them in
69
+ your project), and the rendering template tags. Reads `settings.BOOK_SLUG`,
70
+ `MEDIA_URL`, `LOGIN_URL`.
71
+
72
+ A ready-to-copy thin project — settings, urls, Procfile, and AWS/SSM deploy glue
73
+ — lives in [`example_site/`](example_site/); generate a new book site from it.
74
+
75
+ ## Develop
76
+
77
+ ```bash
78
+ pip install -e .
79
+ python runtests.py # standalone test suite (tests/settings.py)
80
+ ```
81
+
82
+ ## Deploy
83
+
84
+ AWS via SSM + GitHub Actions, designed for **reuse across book projects**: each
85
+ book site is a small repo (copied from `example_site/`) that pins this package
86
+ and calls the shared reusable workflow (`deploy-reusable.yml`) — improve the
87
+ renderer or the deploy once, every site picks it up. Runbook:
88
+ [`example_site/deploy/AWS.md`](example_site/deploy/AWS.md).
89
+
90
+ ## Status
91
+
92
+ `0.x` — interfaces may change. Renders the rtc artifact end to end with
93
+ section-level auth gating; 9 tests. `{% cite %}` currently renders `[key]`
94
+ (wire citeproc/a .bib for full citations). Not yet published to PyPI or
95
+ deployed to rtcbook.org (owner steps).
@@ -0,0 +1,78 @@
1
+ # parody-web
2
+
3
+ A reusable **Django app** that serves a [parody](https://github.com/ricopicone/parody)
4
+ build artifact as a public book site, with section-level auth gating. It's the
5
+ *web* surface of the parody ecosystem:
6
+
7
+ - **`parody`** — core: builds the artifact (and the print/LaTeX side, for now)
8
+ - **`parody-web`** — *this*: renders an artifact as a website
9
+ - *(future)* `parody-print` — if/when the LaTeX side is split out of core
10
+
11
+ You `pip install parody-web` into a thin per-book Django project; the package is
12
+ generic, so one codebase serves every book site. Each book differs only by
13
+ config + content. First site: the partial *Real-Time Computing* book at
14
+ [rtcbook.org](https://rtcbook.org).
15
+
16
+ ## Access model
17
+
18
+ The site imports the **full** artifact and gates by section: the public sees
19
+ only `online_only` sections (the openly-licensed subset); the **private** parts
20
+ require login, and only the owner has an account. One deployment serves both the
21
+ public partial book and — to the owner — the whole thing. (If you'd rather the
22
+ full text never touch the database, import the partial `parody build
23
+ --online-only` artifact instead — then there are no private sections.)
24
+
25
+ ## Why a Django app (not a static site)
26
+
27
+ Parody artifact `html` is Django-template-flavored — it embeds `{% media %}`,
28
+ `{% static %}`, `{% cite %}` etc. This app renders it **natively** through the
29
+ Django template engine (`parody_web.templatetags.parody_web.render_book`), so
30
+ there's no second, lossy tag-resolution renderer to maintain.
31
+
32
+ ## Use it in a project
33
+
34
+ ```python
35
+ # settings.py
36
+ INSTALLED_APPS = [..., "parody_web"]
37
+ BOOK_SLUG = "real-time-computing" # which imported book is the site root
38
+ ```
39
+ ```python
40
+ # urls.py
41
+ urlpatterns = [path("", include("parody_web.urls")), ...]
42
+ ```
43
+ ```bash
44
+ pip install parody-web
45
+ python manage.py migrate
46
+ python manage.py createsuperuser # the owner (only account)
47
+ python manage.py import_artifact rtc.json --slug real-time-computing
48
+ ```
49
+
50
+ Provides: `Book`/`Chapter`/`Section` models, the `import_artifact` command
51
+ (upsert-by-slug, idempotent), index + section views, templates (override them in
52
+ your project), and the rendering template tags. Reads `settings.BOOK_SLUG`,
53
+ `MEDIA_URL`, `LOGIN_URL`.
54
+
55
+ A ready-to-copy thin project — settings, urls, Procfile, and AWS/SSM deploy glue
56
+ — lives in [`example_site/`](example_site/); generate a new book site from it.
57
+
58
+ ## Develop
59
+
60
+ ```bash
61
+ pip install -e .
62
+ python runtests.py # standalone test suite (tests/settings.py)
63
+ ```
64
+
65
+ ## Deploy
66
+
67
+ AWS via SSM + GitHub Actions, designed for **reuse across book projects**: each
68
+ book site is a small repo (copied from `example_site/`) that pins this package
69
+ and calls the shared reusable workflow (`deploy-reusable.yml`) — improve the
70
+ renderer or the deploy once, every site picks it up. Runbook:
71
+ [`example_site/deploy/AWS.md`](example_site/deploy/AWS.md).
72
+
73
+ ## Status
74
+
75
+ `0.x` — interfaces may change. Renders the rtc artifact end to end with
76
+ section-level auth gating; 9 tests. `{% cite %}` currently renders `[key]`
77
+ (wire citeproc/a .bib for full citations). Not yet published to PyPI or
78
+ deployed to rtcbook.org (owner steps).
File without changes
@@ -0,0 +1,7 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class ParodyWebConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "parody_web"
7
+ verbose_name = "Parody Web"
File without changes
@@ -0,0 +1,89 @@
1
+ """Import a parody artifact JSON into the book-host DB.
2
+
3
+ Feed it the partial (``parody build --online-only``) artifact for a public
4
+ copyright-restricted book: the full text never enters this database. Upsert by
5
+ slug so re-imports are idempotent and stable.
6
+ """
7
+ import json
8
+
9
+ from django.core.management.base import BaseCommand, CommandError
10
+ from django.db import transaction
11
+
12
+ from parody_web.models import Book, Chapter, Section
13
+
14
+
15
+ class Command(BaseCommand):
16
+ help = "Import a parody artifact JSON into the book-host database."
17
+
18
+ def add_arguments(self, parser):
19
+ parser.add_argument("artifact", help="path to the parody artifact JSON")
20
+ parser.add_argument(
21
+ "--slug", help="book slug (defaults to the artifact's slug or filename)"
22
+ )
23
+
24
+ def handle(self, *args, **opts):
25
+ path = opts["artifact"]
26
+ try:
27
+ with open(path, encoding="utf-8") as f:
28
+ data = json.load(f)
29
+ except (OSError, ValueError) as e:
30
+ raise CommandError(f"could not read artifact {path}: {e}")
31
+
32
+ version = data.get("schema_version", 0)
33
+ if not isinstance(version, int) or version < 2:
34
+ self.stderr.write(self.style.WARNING(
35
+ f"artifact schema_version {version!r}: this host targets v2 "
36
+ "(textbooks); importing what it can."))
37
+
38
+ slug = opts.get("slug") or data.get("slug") or path.rsplit("/", 1)[-1][:-5]
39
+
40
+ with transaction.atomic():
41
+ self._import(slug, data)
42
+
43
+ def _import(self, slug, data):
44
+ book, _ = Book.objects.update_or_create(slug=slug, defaults={
45
+ "title": data.get("title", slug),
46
+ "description": data.get("description", ""),
47
+ "authors": data.get("author", []),
48
+ "book_metadata": data.get("book"),
49
+ "videos": data.get("videos"),
50
+ "apocrypha": data.get("apocrypha"),
51
+ "source_commit": data.get("source_commit") or "",
52
+ "built_at": data.get("built_at") or "",
53
+ })
54
+
55
+ seen_ch, seen_sec = set(), set()
56
+ for ci, ch in enumerate(data.get("chapters", [])):
57
+ chapter, _ = Chapter.objects.update_or_create(
58
+ book=book, slug=ch["slug"], defaults={
59
+ "title": ch.get("title", ""),
60
+ "order": ci + 1,
61
+ "hash": ch.get("hash", ""),
62
+ "appendix": bool(ch.get("appendix", False)),
63
+ })
64
+ seen_ch.add(chapter.slug)
65
+ for si, sec in enumerate(ch.get("sections", [])):
66
+ Section.objects.update_or_create(
67
+ book=book, chapter=chapter, slug=sec["slug"], defaults={
68
+ "title": sec.get("title", ""),
69
+ "order": si + 1,
70
+ "hash": sec.get("hash", ""),
71
+ "html": sec.get("html", ""),
72
+ "online_resources": sec.get("online_resources", ""),
73
+ "online_only": bool(sec.get("online_only", False)),
74
+ "anchors": sec.get("anchors", []),
75
+ })
76
+ seen_sec.add((chapter.slug, sec["slug"]))
77
+
78
+ # prune rows no longer in the artifact
79
+ for sec in book.sections.all():
80
+ if (sec.chapter.slug, sec.slug) not in seen_sec:
81
+ sec.delete()
82
+ for ch in book.chapters.all():
83
+ if ch.slug not in seen_ch:
84
+ ch.delete()
85
+
86
+ n_sec = book.sections.count()
87
+ self.stdout.write(self.style.SUCCESS(
88
+ f"imported '{book.title}' ({slug}): {book.chapters.count()} chapters, "
89
+ f"{n_sec} sections"))
@@ -0,0 +1,65 @@
1
+ # Generated by Django 5.2.4 on 2026-06-15 17:59
2
+
3
+ import django.db.models.deletion
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ initial = True
10
+
11
+ dependencies = [
12
+ ]
13
+
14
+ operations = [
15
+ migrations.CreateModel(
16
+ name='Book',
17
+ fields=[
18
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19
+ ('slug', models.SlugField(unique=True)),
20
+ ('title', models.CharField(max_length=300)),
21
+ ('description', models.TextField(blank=True)),
22
+ ('authors', models.JSONField(blank=True, default=list)),
23
+ ('book_metadata', models.JSONField(blank=True, null=True)),
24
+ ('videos', models.JSONField(blank=True, null=True)),
25
+ ('apocrypha', models.JSONField(blank=True, null=True)),
26
+ ('source_commit', models.CharField(blank=True, max_length=64)),
27
+ ('built_at', models.CharField(blank=True, max_length=40)),
28
+ ],
29
+ ),
30
+ migrations.CreateModel(
31
+ name='Chapter',
32
+ fields=[
33
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
34
+ ('slug', models.SlugField(max_length=120)),
35
+ ('title', models.CharField(max_length=300)),
36
+ ('order', models.PositiveIntegerField(default=0)),
37
+ ('hash', models.CharField(blank=True, default='', max_length=100)),
38
+ ('appendix', models.BooleanField(default=False)),
39
+ ('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chapters', to='parody_web.book')),
40
+ ],
41
+ options={
42
+ 'ordering': ['order'],
43
+ 'unique_together': {('book', 'slug')},
44
+ },
45
+ ),
46
+ migrations.CreateModel(
47
+ name='Section',
48
+ fields=[
49
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
50
+ ('slug', models.SlugField(max_length=120)),
51
+ ('title', models.CharField(max_length=300)),
52
+ ('order', models.PositiveIntegerField(default=0)),
53
+ ('hash', models.CharField(blank=True, default='', max_length=100)),
54
+ ('html', models.TextField(blank=True)),
55
+ ('online_resources', models.TextField(blank=True)),
56
+ ('online_only', models.BooleanField(default=False)),
57
+ ('anchors', models.JSONField(blank=True, default=list)),
58
+ ('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='parody_web.book')),
59
+ ('chapter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='parody_web.chapter')),
60
+ ],
61
+ options={
62
+ 'ordering': ['order'],
63
+ },
64
+ ),
65
+ ]
File without changes
@@ -0,0 +1,58 @@
1
+ """Models mirroring a parody schema-v2 artifact, slimmed for a public,
2
+ read-only book site (no solution gating, enrollment, or annotations — those
3
+ are ricopic.one concerns). A Book holds the bibliographic/web metadata; a
4
+ Section stores the rendered (Django-template-flavored) html and any per-section
5
+ online-resources addenda."""
6
+
7
+ from django.db import models
8
+
9
+
10
+ class Book(models.Model):
11
+ slug = models.SlugField(unique=True)
12
+ title = models.CharField(max_length=300)
13
+ description = models.TextField(blank=True)
14
+ authors = models.JSONField(default=list, blank=True)
15
+ # parody artifact top-level metadata (book-defs / videos / apocrypha)
16
+ book_metadata = models.JSONField(null=True, blank=True)
17
+ videos = models.JSONField(null=True, blank=True)
18
+ apocrypha = models.JSONField(null=True, blank=True)
19
+ source_commit = models.CharField(max_length=64, blank=True)
20
+ built_at = models.CharField(max_length=40, blank=True)
21
+
22
+ def __str__(self):
23
+ return self.title
24
+
25
+
26
+ class Chapter(models.Model):
27
+ book = models.ForeignKey(Book, related_name="chapters", on_delete=models.CASCADE)
28
+ slug = models.SlugField(max_length=120)
29
+ title = models.CharField(max_length=300)
30
+ order = models.PositiveIntegerField(default=0)
31
+ hash = models.CharField(max_length=100, blank=True, default="")
32
+ appendix = models.BooleanField(default=False)
33
+
34
+ class Meta:
35
+ unique_together = ("book", "slug")
36
+ ordering = ["order"]
37
+
38
+ def __str__(self):
39
+ return f"{self.book.slug} – {self.title}"
40
+
41
+
42
+ class Section(models.Model):
43
+ book = models.ForeignKey(Book, related_name="sections", on_delete=models.CASCADE)
44
+ chapter = models.ForeignKey(Chapter, related_name="sections", on_delete=models.CASCADE)
45
+ slug = models.SlugField(max_length=120)
46
+ title = models.CharField(max_length=300)
47
+ order = models.PositiveIntegerField(default=0)
48
+ hash = models.CharField(max_length=100, blank=True, default="")
49
+ html = models.TextField(blank=True)
50
+ online_resources = models.TextField(blank=True)
51
+ online_only = models.BooleanField(default=False)
52
+ anchors = models.JSONField(default=list, blank=True)
53
+
54
+ class Meta:
55
+ ordering = ["order"]
56
+
57
+ def __str__(self):
58
+ return f"{self.book.slug} – {self.title}"
@@ -0,0 +1,39 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>{% block title %}{{ book.title }}{% endblock %}</title>
7
+ <style>
8
+ body { max-width: 46rem; margin: 2rem auto; padding: 0 1rem;
9
+ font: 1.05rem/1.6 Georgia, 'Times New Roman', serif; color: #1a1a1a; }
10
+ nav.crumbs { font-family: system-ui, sans-serif; font-size: .85rem; margin-bottom: 2rem; }
11
+ nav.pager { display: flex; justify-content: space-between;
12
+ font-family: system-ui, sans-serif; font-size: .9rem; margin-top: 3rem; }
13
+ h1, h2, h3 { font-family: system-ui, sans-serif; line-height: 1.25; }
14
+ img { max-width: 100%; }
15
+ pre { background: #f6f6f4; padding: .8rem 1rem; overflow-x: auto; font-size: .85rem; }
16
+ table { border-collapse: collapse; } th, td { border: 1px solid #ccc; padding: .3rem .6rem; }
17
+ details { margin: .4rem 0; } summary { cursor: pointer; }
18
+ .version-list-item { margin: .15rem 0 .15rem 1rem; font-size: .95rem; }
19
+ section.online-resources { border-top: 1px solid #ddd; margin-top: 2.5rem; }
20
+ .get-cell-placeholder { color: #999; }
21
+ a.download-button { display: inline-block; font-family: system-ui, sans-serif;
22
+ font-size: .9rem; border: 1px solid #7a6ff0; border-radius: 4px;
23
+ padding: .25rem .7rem; text-decoration: none; }
24
+ .note { color:#999; font-size:.8rem; }
25
+ </style>
26
+ <script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js" async></script>
27
+ </head>
28
+ <body>
29
+ <div style="font-family:system-ui,sans-serif;font-size:.8rem;text-align:right;color:#999">
30
+ {% if user.is_authenticated %}
31
+ owner view ({{ user.get_username }}) ·
32
+ <form action="{% url 'logout' %}" method="post" style="display:inline">{% csrf_token %}<button type="submit" style="border:none;background:none;color:#7a6ff0;cursor:pointer;font:inherit;padding:0">sign out</button></form>
33
+ {% else %}
34
+ <a href="{% url 'login' %}">owner sign in</a>
35
+ {% endif %}
36
+ </div>
37
+ {% block body %}{% endblock %}
38
+ </body>
39
+ </html>
@@ -0,0 +1,31 @@
1
+ {% extends "parody_web/base.html" %}
2
+ {% block body %}
3
+ <h1>{{ book.title }}</h1>
4
+ {% if book.authors %}<p><em>{{ book.authors|join:", " }}</em></p>{% endif %}
5
+ {% if book.description %}<p>{{ book.description }}</p>{% endif %}
6
+ {% if book.book_metadata.editions %}
7
+ <p class="note">
8
+ {% with ed=book.book_metadata.editions.0 %}
9
+ {% if ed.publisher %}{{ ed.publisher }}{% endif %}
10
+ {% if ed.year %}({{ ed.year }}){% endif %}
11
+ {% if ed.isbn %}· ISBN {{ ed.isbn }}{% endif %}
12
+ {% endwith %}
13
+ {% if book.book_metadata.companion_url %}
14
+ · <a href="{{ book.book_metadata.companion_url }}">companion</a>{% endif %}
15
+ </p>
16
+ {% endif %}
17
+
18
+ {% for chapter, sections in chapters %}
19
+ <h2>{{ chapter.title }}{% if chapter.appendix %} <span class="note">(appendix)</span>{% endif %}</h2>
20
+ <ul>
21
+ {% for section in sections %}
22
+ <li><a href="{% url 'parody_web:section' chapter.slug section.slug %}">{{ section.title }}</a>
23
+ {% if not section.online_only and section.online_resources %}<span class="note">— online resources</span>{% endif %}
24
+ </li>
25
+ {% endfor %}
26
+ </ul>
27
+ {% endfor %}
28
+
29
+ <p class="note">A partial web edition. Published from a parody artifact
30
+ {% if book.built_at %}({{ book.built_at }}){% endif %}.</p>
31
+ {% endblock %}
@@ -0,0 +1,25 @@
1
+ {% extends "parody_web/base.html" %}
2
+ {% load parody_web %}
3
+ {% block title %}{{ section.title }} — {{ book.title }}{% endblock %}
4
+ {% block body %}
5
+ <nav class="crumbs"><a href="{% url 'parody_web:index' %}">{{ book.title }}</a>
6
+ › {{ chapter.title }}</nav>
7
+
8
+ <h1>{{ section.title }}</h1>
9
+
10
+ {% if section.html %}
11
+ {{ section.html|render_book }}
12
+ {% endif %}
13
+
14
+ {% if section.online_resources %}
15
+ <section class="online-resources">
16
+ <h2>Online resources</h2>
17
+ {{ section.online_resources|render_book }}
18
+ </section>
19
+ {% endif %}
20
+
21
+ <nav class="pager">
22
+ {% if prev %}<a href="{% url 'parody_web:section' prev.chapter.slug prev.slug %}">← {{ prev.title }}</a>{% else %}<span></span>{% endif %}
23
+ {% if next %}<a href="{% url 'parody_web:section' next.chapter.slug next.slug %}">{{ next.title }} →</a>{% else %}<span></span>{% endif %}
24
+ </nav>
25
+ {% endblock %}
@@ -0,0 +1,15 @@
1
+ {% extends "parody_web/base.html" %}
2
+ {% block title %}Sign in{% endblock %}
3
+ {% block body %}
4
+ <h1>Sign in</h1>
5
+ <p class="note">The full book is available to the owner. The public edition
6
+ shows only the openly-licensed sections.</p>
7
+ {% if form.errors %}<p style="color:#b00">Incorrect username or password.</p>{% endif %}
8
+ <form method="post">
9
+ {% csrf_token %}
10
+ <p><label>Username <input type="text" name="username" autofocus></label></p>
11
+ <p><label>Password <input type="password" name="password"></label></p>
12
+ <input type="hidden" name="next" value="{{ next }}">
13
+ <p><button type="submit">Sign in</button></p>
14
+ </form>
15
+ {% endblock %}
File without changes
@@ -0,0 +1,85 @@
1
+ """Template tags that render parody's Django-template-flavored section html.
2
+
3
+ The artifact's ``html`` embeds ``{% media %}``, ``{% static %}``, ``{% cite %}``
4
+ etc. — the same tags ricopic.one resolves at view time. We resolve them here so
5
+ the book-host renders the stored html natively (no lossy regex re-resolution).
6
+
7
+ The ``render_book`` filter compiles a stored html blob as a Django template
8
+ with this library loaded and renders it; ``{% csrf_token %}`` is stripped first
9
+ (server-form-only, and it collides with the builtin), and a malformed tag
10
+ degrades to escaped output rather than 500-ing the page.
11
+ """
12
+ import re
13
+
14
+ from django import template
15
+ from django.conf import settings
16
+ from django.template import Context, Template, TemplateSyntaxError
17
+ from django.utils.html import conditional_escape
18
+ from django.utils.safestring import mark_safe
19
+
20
+ register = template.Library()
21
+
22
+
23
+ @register.simple_tag
24
+ def media(path):
25
+ return settings.MEDIA_URL + str(path).lstrip("/")
26
+
27
+
28
+ @register.simple_tag
29
+ def static(path):
30
+ return settings.STATIC_URL + str(path).lstrip("/")
31
+
32
+
33
+ def _cite(key):
34
+ return mark_safe(f'<span class="citation">[{conditional_escape(key)}]</span>')
35
+
36
+
37
+ @register.simple_tag
38
+ def cite(key, *args, **kwargs):
39
+ return _cite(key)
40
+
41
+
42
+ @register.simple_tag
43
+ def cite_many(*keys, **kwargs):
44
+ return mark_safe("; ".join(_cite(k) for k in keys))
45
+
46
+
47
+ @register.simple_tag
48
+ def url(*args, **kwargs):
49
+ # server-side routes don't exist on the standalone book site
50
+ return "#"
51
+
52
+
53
+ @register.simple_tag
54
+ def get_cell(*args, **kwargs):
55
+ return mark_safe(
56
+ '<span class="get-cell-placeholder" '
57
+ 'title="interactive table cell (site-only feature)">—</span>'
58
+ )
59
+
60
+
61
+ @register.simple_tag
62
+ def auth_button(*args, href="", label="Download", **kwargs):
63
+ return mark_safe(
64
+ f'<a class="download-button" href="{media(href)}">'
65
+ f"{conditional_escape(label)}</a>"
66
+ )
67
+
68
+
69
+ _CSRF_RE = re.compile(r"\{%\s*csrf_token\s*%\}")
70
+
71
+
72
+ @register.filter(is_safe=True)
73
+ def render_book(html):
74
+ """Render stored Django-flavored html (defaults to '' for empty fields)."""
75
+ if not html:
76
+ return ""
77
+ source = "{% load parody_web %}" + _CSRF_RE.sub("", html)
78
+ try:
79
+ return mark_safe(Template(source).render(Context({})))
80
+ except TemplateSyntaxError as exc:
81
+ # Don't take the page down over one unexpected tag; surface in a comment.
82
+ return mark_safe(
83
+ f"<!-- book-host: unresolved template content: "
84
+ f"{conditional_escape(str(exc))} -->" + html
85
+ )
@@ -0,0 +1,106 @@
1
+ """Book-host: artifact import, native Django rendering, and auth gating.
2
+
3
+ The host imports the FULL artifact; the public sees only online-only sections,
4
+ the private parts are gated behind login (only the owner has an account)."""
5
+ import json
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ from django.contrib.auth import get_user_model
10
+ from django.core.management import call_command
11
+ from django.test import Client, TestCase, override_settings
12
+
13
+ from parody_web.models import Book, Section
14
+ from parody_web.templatetags.parody_web import render_book
15
+
16
+ ARTIFACT = {
17
+ "schema_version": 2,
18
+ "slug": "demo-book",
19
+ "title": "Demo Book",
20
+ "author": ["A. Author"],
21
+ "book": {"name": "Demo", "editions": [{"id": "0", "isbn": "978-test"}]},
22
+ "chapters": [{
23
+ "title": "Hardware", "slug": "hardware", "hash": "h1",
24
+ "sections": [
25
+ {"title": "Specific T1 (public)", "slug": "specific-t1", "hash": "ef",
26
+ "online_only": True,
27
+ "html": '<p>The myRIO via {% media \'notebooks/x.jpg\' %}.</p>'
28
+ '<details><summary>Specs</summary>'
29
+ '<div class="version-list-item">SoC: Xilinx</div></details>',
30
+ "online_resources": '<p>Extra: {% cite \'knuth1997\' %}.</p>'},
31
+ {"title": "Licensed Chapter (private)", "slug": "licensed", "hash": "lz",
32
+ "html": "<p>Copyrighted prose.</p>"},
33
+ ],
34
+ }],
35
+ }
36
+
37
+
38
+ def _import(slug="demo-book"):
39
+ with tempfile.TemporaryDirectory() as d:
40
+ p = Path(d, "a.json")
41
+ p.write_text(json.dumps(ARTIFACT))
42
+ call_command("import_artifact", str(p), "--slug", slug)
43
+
44
+
45
+ class RenderBookFilterTests(TestCase):
46
+ def test_resolves_media_and_strips_unknown(self):
47
+ out = render_book("<img src=\"{% media 'a/b.png' %}\"> {% csrf_token %}")
48
+ self.assertIn("/media/a/b.png", out)
49
+ self.assertNotIn("{%", out)
50
+
51
+ def test_cite_and_details_pass_through(self):
52
+ out = render_book("{% cite 'k' %} <details><summary>s</summary>x</details>")
53
+ self.assertIn('class="citation"', out)
54
+ self.assertIn("<details>", out)
55
+
56
+
57
+ @override_settings(BOOK_SLUG="demo-book")
58
+ class BookHostGatingTests(TestCase):
59
+ def setUp(self):
60
+ _import()
61
+ self.owner = get_user_model().objects.create_superuser(
62
+ "owner", "owner@example.com", "pw")
63
+ self.anon = Client()
64
+ self.signed_in = Client()
65
+ self.signed_in.force_login(self.owner)
66
+
67
+ def test_import_stored_fields(self):
68
+ book = Book.objects.get(slug="demo-book")
69
+ self.assertEqual(book.book_metadata["editions"][0]["isbn"], "978-test")
70
+ self.assertTrue(Section.objects.get(slug="specific-t1").online_only)
71
+ self.assertFalse(Section.objects.get(slug="licensed").online_only)
72
+
73
+ def test_public_index_lists_only_online_only(self):
74
+ r = self.anon.get("/")
75
+ self.assertEqual(r.status_code, 200)
76
+ self.assertContains(r, "Specific T1 (public)")
77
+ self.assertNotContains(r, "Licensed Chapter (private)")
78
+
79
+ def test_owner_index_lists_everything(self):
80
+ r = self.signed_in.get("/")
81
+ self.assertContains(r, "Specific T1 (public)")
82
+ self.assertContains(r, "Licensed Chapter (private)")
83
+
84
+ def test_public_section_renders_with_tags_resolved(self):
85
+ r = self.anon.get("/hardware/specific-t1/")
86
+ self.assertEqual(r.status_code, 200)
87
+ html = r.content.decode()
88
+ self.assertIn("/media/notebooks/x.jpg", html)
89
+ self.assertNotIn("{%", html)
90
+ self.assertIn("<details>", html)
91
+ self.assertIn("Online resources", html) # online_resources rendered
92
+
93
+ def test_private_section_redirects_anonymous_to_login(self):
94
+ r = self.anon.get("/hardware/licensed/")
95
+ self.assertEqual(r.status_code, 302)
96
+ self.assertIn("/accounts/login", r["Location"])
97
+
98
+ def test_owner_sees_private_section(self):
99
+ r = self.signed_in.get("/hardware/licensed/")
100
+ self.assertEqual(r.status_code, 200)
101
+ self.assertContains(r, "Copyrighted prose")
102
+
103
+ def test_reimport_is_idempotent(self):
104
+ before = Section.objects.count()
105
+ _import()
106
+ self.assertEqual(Section.objects.count(), before)
@@ -0,0 +1,11 @@
1
+ from django.urls import path
2
+
3
+ from . import views
4
+
5
+ app_name = "parody_web"
6
+
7
+ urlpatterns = [
8
+ path("", views.index, name="index"),
9
+ path("<slug:chapter_slug>/<slug:section_slug>/", views.section_detail,
10
+ name="section"),
11
+ ]
@@ -0,0 +1,63 @@
1
+ """Views for the book site.
2
+
3
+ Public visitors see only the online-only sections (the licensed public subset);
4
+ the private parts of the book are gated behind login — only the owner has an
5
+ account. This lets one deployment hold the full artifact yet expose only the
6
+ permitted subset publicly.
7
+ """
8
+
9
+ from django.conf import settings
10
+ from django.contrib.auth.views import redirect_to_login
11
+ from django.http import Http404
12
+ from django.shortcuts import get_object_or_404, render
13
+
14
+ from .models import Book, Section
15
+
16
+
17
+ def _current_book():
18
+ if getattr(settings, "BOOK_SLUG", ""):
19
+ return get_object_or_404(Book, slug=getattr(settings, "BOOK_SLUG", ""))
20
+ book = Book.objects.first()
21
+ if book is None:
22
+ raise Http404("no book imported")
23
+ return book
24
+
25
+
26
+ def _visible_sections(book, user):
27
+ """All sections for the owner; only online-only for the public."""
28
+ qs = Section.objects.filter(book=book).select_related("chapter")
29
+ if not user.is_authenticated:
30
+ qs = qs.filter(online_only=True)
31
+ return list(qs)
32
+
33
+
34
+ def index(request):
35
+ book = _current_book()
36
+ public = not request.user.is_authenticated
37
+ chapters = []
38
+ for ch in book.chapters.all():
39
+ sections = list(ch.sections.all())
40
+ if public:
41
+ sections = [s for s in sections if s.online_only]
42
+ if sections:
43
+ chapters.append((ch, sections))
44
+ return render(request, "parody_web/index.html", {
45
+ "book": book, "chapters": chapters, "public": public})
46
+
47
+
48
+ def section_detail(request, chapter_slug, section_slug):
49
+ book = _current_book()
50
+ section = get_object_or_404(
51
+ Section, book=book, chapter__slug=chapter_slug, slug=section_slug)
52
+ # private (non-online-only) sections are owner-only
53
+ if not section.online_only and not request.user.is_authenticated:
54
+ return redirect_to_login(request.get_full_path())
55
+
56
+ flat = _visible_sections(book, request.user)
57
+ idx = next((i for i, s in enumerate(flat) if s.pk == section.pk), None)
58
+ prev_s = flat[idx - 1] if idx else None
59
+ next_s = flat[idx + 1] if idx is not None and idx + 1 < len(flat) else None
60
+ return render(request, "parody_web/section.html", {
61
+ "book": book, "section": section, "chapter": section.chapter,
62
+ "prev": prev_s, "next": next_s,
63
+ })
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: parody-web
3
+ Version: 0.1.0
4
+ Summary: Django app that serves a parody build artifact as a public book site, with section-level auth gating (public = online-only subset; owner = full book).
5
+ Author-email: Rico Picone <dr@ricopic.one>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/ricopicone/parody-web
8
+ Project-URL: Issues, https://github.com/ricopicone/parody-web/issues
9
+ Keywords: parody,django,publishing,book,textbook
10
+ Classifier: Framework :: Django
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: Django<6.0,>=5.0
17
+
18
+ # parody-web
19
+
20
+ A reusable **Django app** that serves a [parody](https://github.com/ricopicone/parody)
21
+ build artifact as a public book site, with section-level auth gating. It's the
22
+ *web* surface of the parody ecosystem:
23
+
24
+ - **`parody`** — core: builds the artifact (and the print/LaTeX side, for now)
25
+ - **`parody-web`** — *this*: renders an artifact as a website
26
+ - *(future)* `parody-print` — if/when the LaTeX side is split out of core
27
+
28
+ You `pip install parody-web` into a thin per-book Django project; the package is
29
+ generic, so one codebase serves every book site. Each book differs only by
30
+ config + content. First site: the partial *Real-Time Computing* book at
31
+ [rtcbook.org](https://rtcbook.org).
32
+
33
+ ## Access model
34
+
35
+ The site imports the **full** artifact and gates by section: the public sees
36
+ only `online_only` sections (the openly-licensed subset); the **private** parts
37
+ require login, and only the owner has an account. One deployment serves both the
38
+ public partial book and — to the owner — the whole thing. (If you'd rather the
39
+ full text never touch the database, import the partial `parody build
40
+ --online-only` artifact instead — then there are no private sections.)
41
+
42
+ ## Why a Django app (not a static site)
43
+
44
+ Parody artifact `html` is Django-template-flavored — it embeds `{% media %}`,
45
+ `{% static %}`, `{% cite %}` etc. This app renders it **natively** through the
46
+ Django template engine (`parody_web.templatetags.parody_web.render_book`), so
47
+ there's no second, lossy tag-resolution renderer to maintain.
48
+
49
+ ## Use it in a project
50
+
51
+ ```python
52
+ # settings.py
53
+ INSTALLED_APPS = [..., "parody_web"]
54
+ BOOK_SLUG = "real-time-computing" # which imported book is the site root
55
+ ```
56
+ ```python
57
+ # urls.py
58
+ urlpatterns = [path("", include("parody_web.urls")), ...]
59
+ ```
60
+ ```bash
61
+ pip install parody-web
62
+ python manage.py migrate
63
+ python manage.py createsuperuser # the owner (only account)
64
+ python manage.py import_artifact rtc.json --slug real-time-computing
65
+ ```
66
+
67
+ Provides: `Book`/`Chapter`/`Section` models, the `import_artifact` command
68
+ (upsert-by-slug, idempotent), index + section views, templates (override them in
69
+ your project), and the rendering template tags. Reads `settings.BOOK_SLUG`,
70
+ `MEDIA_URL`, `LOGIN_URL`.
71
+
72
+ A ready-to-copy thin project — settings, urls, Procfile, and AWS/SSM deploy glue
73
+ — lives in [`example_site/`](example_site/); generate a new book site from it.
74
+
75
+ ## Develop
76
+
77
+ ```bash
78
+ pip install -e .
79
+ python runtests.py # standalone test suite (tests/settings.py)
80
+ ```
81
+
82
+ ## Deploy
83
+
84
+ AWS via SSM + GitHub Actions, designed for **reuse across book projects**: each
85
+ book site is a small repo (copied from `example_site/`) that pins this package
86
+ and calls the shared reusable workflow (`deploy-reusable.yml`) — improve the
87
+ renderer or the deploy once, every site picks it up. Runbook:
88
+ [`example_site/deploy/AWS.md`](example_site/deploy/AWS.md).
89
+
90
+ ## Status
91
+
92
+ `0.x` — interfaces may change. Renders the rtc artifact end to end with
93
+ section-level auth gating; 9 tests. `{% cite %}` currently renders `[key]`
94
+ (wire citeproc/a .bib for full citations). Not yet published to PyPI or
95
+ deployed to rtcbook.org (owner steps).
@@ -0,0 +1,24 @@
1
+ README.md
2
+ pyproject.toml
3
+ parody_web/__init__.py
4
+ parody_web/apps.py
5
+ parody_web/models.py
6
+ parody_web/tests.py
7
+ parody_web/urls.py
8
+ parody_web/views.py
9
+ parody_web.egg-info/PKG-INFO
10
+ parody_web.egg-info/SOURCES.txt
11
+ parody_web.egg-info/dependency_links.txt
12
+ parody_web.egg-info/requires.txt
13
+ parody_web.egg-info/top_level.txt
14
+ parody_web/management/__init__.py
15
+ parody_web/management/commands/__init__.py
16
+ parody_web/management/commands/import_artifact.py
17
+ parody_web/migrations/0001_initial.py
18
+ parody_web/migrations/__init__.py
19
+ parody_web/templates/parody_web/base.html
20
+ parody_web/templates/parody_web/index.html
21
+ parody_web/templates/parody_web/section.html
22
+ parody_web/templates/registration/login.html
23
+ parody_web/templatetags/__init__.py
24
+ parody_web/templatetags/parody_web.py
@@ -0,0 +1 @@
1
+ Django<6.0,>=5.0
@@ -0,0 +1 @@
1
+ parody_web
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "parody-web"
7
+ version = "0.1.0"
8
+ description = "Django app that serves a parody build artifact as a public book site, with section-level auth gating (public = online-only subset; owner = full book)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Rico Picone", email = "dr@ricopic.one" }]
13
+ keywords = ["parody", "django", "publishing", "book", "textbook"]
14
+ classifiers = [
15
+ "Framework :: Django",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Development Status :: 3 - Alpha",
19
+ ]
20
+ dependencies = ["Django>=5.0,<6.0"]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/ricopicone/parody-web"
24
+ Issues = "https://github.com/ricopicone/parody-web/issues"
25
+
26
+ [tool.setuptools.packages.find]
27
+ include = ["parody_web*"]
28
+
29
+ [tool.setuptools.package-data]
30
+ parody_web = ["templates/parody_web/*.html", "templates/registration/*.html"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+