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.
- parody_web-0.1.0/PKG-INFO +95 -0
- parody_web-0.1.0/README.md +78 -0
- parody_web-0.1.0/parody_web/__init__.py +0 -0
- parody_web-0.1.0/parody_web/apps.py +7 -0
- parody_web-0.1.0/parody_web/management/__init__.py +0 -0
- parody_web-0.1.0/parody_web/management/commands/__init__.py +0 -0
- parody_web-0.1.0/parody_web/management/commands/import_artifact.py +89 -0
- parody_web-0.1.0/parody_web/migrations/0001_initial.py +65 -0
- parody_web-0.1.0/parody_web/migrations/__init__.py +0 -0
- parody_web-0.1.0/parody_web/models.py +58 -0
- parody_web-0.1.0/parody_web/templates/parody_web/base.html +39 -0
- parody_web-0.1.0/parody_web/templates/parody_web/index.html +31 -0
- parody_web-0.1.0/parody_web/templates/parody_web/section.html +25 -0
- parody_web-0.1.0/parody_web/templates/registration/login.html +15 -0
- parody_web-0.1.0/parody_web/templatetags/__init__.py +0 -0
- parody_web-0.1.0/parody_web/templatetags/parody_web.py +85 -0
- parody_web-0.1.0/parody_web/tests.py +106 -0
- parody_web-0.1.0/parody_web/urls.py +11 -0
- parody_web-0.1.0/parody_web/views.py +63 -0
- parody_web-0.1.0/parody_web.egg-info/PKG-INFO +95 -0
- parody_web-0.1.0/parody_web.egg-info/SOURCES.txt +24 -0
- parody_web-0.1.0/parody_web.egg-info/dependency_links.txt +1 -0
- parody_web-0.1.0/parody_web.egg-info/requires.txt +1 -0
- parody_web-0.1.0/parody_web.egg-info/top_level.txt +1 -0
- parody_web-0.1.0/pyproject.toml +30 -0
- parody_web-0.1.0/setup.cfg +4 -0
|
@@ -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
|
|
File without changes
|
|
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,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
|
+
|
|
@@ -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"]
|