wagtail-write-api 0.2.2__tar.gz → 0.3.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.
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/PKG-INFO +1 -1
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/docs/api/pages.md +2 -2
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/docs/guides/rich-text.md +6 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/testapp/management/commands/seed_demo.py +28 -1
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/pyproject.toml +1 -1
- wagtail_write_api-0.3.0/src/wagtail_write_api/__init__.py +1 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/converters/rich_text.py +24 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/endpoints/pages.py +17 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/endpoints/schema_discovery.py +13 -0
- wagtail_write_api-0.3.0/tests/test_pages_read.py +247 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/tests/test_pages_write.py +72 -52
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/tests/test_rich_text.py +48 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/tests/test_schema_generation.py +16 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/tests/test_streamfield.py +68 -37
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/uv.lock +1 -1
- wagtail_write_api-0.2.2/src/wagtail_write_api/__init__.py +0 -1
- wagtail_write_api-0.2.2/tests/test_pages_read.py +0 -210
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/.github/workflows/docs.yml +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/.github/workflows/publish.yml +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/.gitignore +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/CLAUDE.md +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/LICENSE +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/Makefile +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/README.md +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/docs/api/authentication.md +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/docs/api/images.md +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/docs/api/schema-discovery.md +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/docs/development/contributing.md +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/docs/development/example-app.md +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/docs/getting-started/configuration.md +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/docs/getting-started/installation.md +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/docs/getting-started/quickstart.md +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/docs/guides/permissions.md +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/docs/guides/streamfield.md +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/docs/guides/workflow.md +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/docs/index.md +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/Makefile +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/example_project/__init__.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/example_project/settings.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/example_project/urls.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/example_project/wsgi.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/manage.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/testapp/__init__.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/testapp/management/__init__.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/testapp/management/commands/__init__.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/testapp/migrations/0001_initial.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/testapp/migrations/__init__.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/testapp/models.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/testapp/wagtail_hooks.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/api.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/apps.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/auth.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/converters/__init__.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/endpoints/__init__.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/endpoints/images.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/management/__init__.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/management/commands/__init__.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/management/commands/create_api_token.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/migrations/0001_initial.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/migrations/__init__.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/models.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/permissions.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/schema/__init__.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/schema/fields.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/schema/generator.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/schema/registry.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/settings.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/urls.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/utils.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/tests/__init__.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/tests/conftest.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/tests/test_auth.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/tests/test_images_api.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/tests/test_pages_workflow.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/tests/test_smoke.py +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/wagtail-write-api-plan.md +0 -0
- {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/zensical.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wagtail-write-api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A read/write REST API for Wagtail CMS
|
|
5
5
|
Project-URL: Homepage, https://tomdyson.github.io/wagtail-write-api/
|
|
6
6
|
Project-URL: Repository, https://github.com/tomdyson/wagtail-write-api
|
|
@@ -34,8 +34,8 @@ GET /pages/
|
|
|
34
34
|
| Parameter | Type | Description |
|
|
35
35
|
|-----------|------|-------------|
|
|
36
36
|
| `type` | string | Filter by page type, e.g. `blog.BlogPage` |
|
|
37
|
-
| `parent` | int | Direct children of this page ID |
|
|
38
|
-
| `descendant_of` | int | All descendants of this page ID |
|
|
37
|
+
| `parent` | int or string | Direct children of this page ID or URL path (e.g. `5` or `/blog/`) |
|
|
38
|
+
| `descendant_of` | int or string | All descendants of this page ID or URL path |
|
|
39
39
|
| `status` | string | `draft`, `live`, or `live+draft` |
|
|
40
40
|
| `slug` | string | Exact slug match (may return multiple if slug exists under different parents) |
|
|
41
41
|
| `path` | string | Exact URL path match, e.g. `/blog/my-post/` (always 0 or 1 result) |
|
|
@@ -92,6 +92,12 @@ WAGTAIL_WRITE_API = {
|
|
|
92
92
|
!!! tip "For CMS editors"
|
|
93
93
|
If your client needs to read content, edit it, and write it back, use `"wagtail"` format. This preserves the internal link references (page IDs) through the round trip. With `"html"` format, internal links are expanded to URL paths, which can't be losslessly converted back.
|
|
94
94
|
|
|
95
|
+
## StreamField blocks sent to a RichTextField
|
|
96
|
+
|
|
97
|
+
If a client accidentally sends StreamField-style blocks (a list of `{"type": ..., "value": ...}` dicts) to a `RichTextField`, the API extracts the HTML content from the blocks rather than storing a stringified list. Paragraph and text block values are concatenated, and heading blocks are converted to HTML heading tags.
|
|
98
|
+
|
|
99
|
+
This is a convenience fallback — for best results, send one of the documented input formats above.
|
|
100
|
+
|
|
95
101
|
## Rich text in StreamField
|
|
96
102
|
|
|
97
103
|
`RichTextBlock` values in StreamField accept the same input format:
|
{wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/testapp/management/commands/seed_demo.py
RENAMED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import io
|
|
1
2
|
import json
|
|
2
3
|
import uuid
|
|
3
4
|
|
|
4
5
|
from django.contrib.auth.models import Group, Permission, User
|
|
6
|
+
from django.core.files.images import ImageFile
|
|
5
7
|
from django.core.management.base import BaseCommand
|
|
6
|
-
from
|
|
8
|
+
from wagtail.images.models import Image
|
|
7
9
|
from wagtail.models import GroupPagePermission, Page, Site
|
|
10
|
+
from wagtail_write_api.models import ApiToken
|
|
8
11
|
|
|
9
12
|
from testapp.models import (
|
|
10
13
|
BlogIndexPage,
|
|
@@ -124,6 +127,19 @@ class Command(BaseCommand):
|
|
|
124
127
|
)
|
|
125
128
|
moderator.groups.add(mods_group)
|
|
126
129
|
|
|
130
|
+
# Images
|
|
131
|
+
Image.objects.all().delete()
|
|
132
|
+
image_titles = ["Hero background", "Blog header", "Team photo"]
|
|
133
|
+
for i, title in enumerate(image_titles, 1):
|
|
134
|
+
img = self._create_placeholder_image(100 * i, 80 * i)
|
|
135
|
+
Image.objects.create(
|
|
136
|
+
title=title,
|
|
137
|
+
file=ImageFile(img, name=f"demo-{i}.png"),
|
|
138
|
+
uploaded_by_user=admin,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
self.stdout.write(f"Created {len(image_titles)} images")
|
|
142
|
+
|
|
127
143
|
# Print tokens
|
|
128
144
|
self.stdout.write("\n--- API Tokens ---")
|
|
129
145
|
for user in [admin, editor, moderator, reviewer]:
|
|
@@ -133,6 +149,17 @@ class Command(BaseCommand):
|
|
|
133
149
|
self.stdout.write(f"\nCreated {Page.objects.count()} pages")
|
|
134
150
|
self.stdout.write(self.style.SUCCESS("Done!"))
|
|
135
151
|
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _create_placeholder_image(width, height):
|
|
154
|
+
"""Create a minimal solid-colour PNG in memory."""
|
|
155
|
+
from PIL import Image as PILImage
|
|
156
|
+
|
|
157
|
+
img = PILImage.new("RGB", (width, height), color=(200, 200, 200))
|
|
158
|
+
buf = io.BytesIO()
|
|
159
|
+
img.save(buf, format="PNG")
|
|
160
|
+
buf.seek(0)
|
|
161
|
+
return buf
|
|
162
|
+
|
|
136
163
|
def _create_user(self, username, is_superuser=False):
|
|
137
164
|
user, created = User.objects.get_or_create(
|
|
138
165
|
username=username,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
{wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/converters/rich_text.py
RENAMED
|
@@ -14,6 +14,9 @@ def convert_rich_text_input(value) -> str:
|
|
|
14
14
|
if isinstance(value, str):
|
|
15
15
|
return value
|
|
16
16
|
|
|
17
|
+
if isinstance(value, list):
|
|
18
|
+
return _blocks_to_html(value)
|
|
19
|
+
|
|
17
20
|
if not isinstance(value, dict):
|
|
18
21
|
return str(value)
|
|
19
22
|
|
|
@@ -54,3 +57,24 @@ def markdown_to_wagtail(md_text: str) -> str:
|
|
|
54
57
|
# The wagtail links are already in the output as <a linktype="..." id="...">
|
|
55
58
|
# because we replaced them before markdown processing
|
|
56
59
|
return html
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _blocks_to_html(blocks: list) -> str:
|
|
63
|
+
"""Extract HTML from a list of StreamField-style blocks.
|
|
64
|
+
|
|
65
|
+
Handles the case where a client sends block data to a RichTextField —
|
|
66
|
+
we pull out the text content rather than storing a stringified list.
|
|
67
|
+
"""
|
|
68
|
+
parts = []
|
|
69
|
+
for block in blocks:
|
|
70
|
+
if not isinstance(block, dict):
|
|
71
|
+
continue
|
|
72
|
+
value = block.get("value", "")
|
|
73
|
+
block_type = block.get("type", "")
|
|
74
|
+
if block_type == "heading" and isinstance(value, dict):
|
|
75
|
+
size = value.get("size", "h2")
|
|
76
|
+
text = value.get("text", "")
|
|
77
|
+
parts.append(f"<{size}>{text}</{size}>")
|
|
78
|
+
elif isinstance(value, str):
|
|
79
|
+
parts.append(value)
|
|
80
|
+
return "".join(parts)
|
{wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/endpoints/pages.py
RENAMED
|
@@ -103,6 +103,7 @@ def list_pages(
|
|
|
103
103
|
"live": page.live,
|
|
104
104
|
"has_unpublished_changes": page.has_unpublished_changes,
|
|
105
105
|
"parent_id": page.get_parent().id if page.get_parent() else None,
|
|
106
|
+
"url_path": _get_url_path(page),
|
|
106
107
|
},
|
|
107
108
|
}
|
|
108
109
|
)
|
|
@@ -459,6 +460,21 @@ def _resolve_page_by_path(path: str):
|
|
|
459
460
|
return None
|
|
460
461
|
|
|
461
462
|
|
|
463
|
+
def _get_url_path(page):
|
|
464
|
+
"""Return the site-relative URL path for a page (e.g. '/blog/my-post/')."""
|
|
465
|
+
from wagtail.models import Site
|
|
466
|
+
|
|
467
|
+
site = Site.objects.filter(is_default_site=True).first()
|
|
468
|
+
if not site or not page.url_path:
|
|
469
|
+
return None
|
|
470
|
+
|
|
471
|
+
root_path = site.root_page.url_path.rstrip("/")
|
|
472
|
+
url_path = page.url_path
|
|
473
|
+
if url_path.startswith(root_path):
|
|
474
|
+
url_path = url_path[len(root_path):]
|
|
475
|
+
return url_path or "/"
|
|
476
|
+
|
|
477
|
+
|
|
462
478
|
def _apply_fields(page, body, model_class):
|
|
463
479
|
"""Apply request body fields to a page instance."""
|
|
464
480
|
from modelcluster.fields import ParentalKey
|
|
@@ -541,6 +557,7 @@ def _serialize_page(source, page, type_str, user):
|
|
|
541
557
|
"last_published_at": (
|
|
542
558
|
page.last_published_at.isoformat() if page.last_published_at else None
|
|
543
559
|
),
|
|
560
|
+
"url_path": _get_url_path(page),
|
|
544
561
|
"parent_id": parent.id if parent else None,
|
|
545
562
|
"parent_type": (
|
|
546
563
|
f"{parent_specific._meta.app_label}.{parent_specific.__class__.__name__}"
|
|
@@ -50,6 +50,7 @@ def get_page_type_schema(request, type_str: str):
|
|
|
50
50
|
|
|
51
51
|
model_class = _resolve_model(type_str)
|
|
52
52
|
streamfield_meta = _get_streamfield_meta(model_class) if model_class else {}
|
|
53
|
+
richtext_fields = _get_richtext_fields(model_class) if model_class else []
|
|
53
54
|
|
|
54
55
|
return {
|
|
55
56
|
"type": type_str,
|
|
@@ -57,6 +58,7 @@ def get_page_type_schema(request, type_str: str):
|
|
|
57
58
|
"patch_schema": patch_schema.model_json_schema(),
|
|
58
59
|
"read_schema": read_schema.model_json_schema(),
|
|
59
60
|
"streamfield_blocks": streamfield_meta,
|
|
61
|
+
"richtext_fields": richtext_fields,
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
|
|
@@ -70,6 +72,17 @@ def _resolve_model(type_str):
|
|
|
70
72
|
return None
|
|
71
73
|
|
|
72
74
|
|
|
75
|
+
def _get_richtext_fields(model_class):
|
|
76
|
+
"""Return a list of field names that are RichTextFields."""
|
|
77
|
+
from wagtail.fields import RichTextField
|
|
78
|
+
|
|
79
|
+
return [
|
|
80
|
+
field.name
|
|
81
|
+
for field in model_class._meta.get_fields()
|
|
82
|
+
if isinstance(field, RichTextField)
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
73
86
|
def _get_streamfield_meta(model_class):
|
|
74
87
|
"""Introspect StreamField definitions and return block type schemas."""
|
|
75
88
|
from wagtail.fields import StreamField
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from django.test import Client, TestCase
|
|
3
|
+
|
|
4
|
+
from testapp.models import BlogIndexPage, BlogPage, SimplePage
|
|
5
|
+
from wagtail.models import Page, Site
|
|
6
|
+
from django.contrib.auth.models import User
|
|
7
|
+
from wagtail_write_api.models import ApiToken
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _PageTreeMixin:
|
|
11
|
+
"""Shared setUpTestData for read-only page tests.
|
|
12
|
+
|
|
13
|
+
Django's setUpTestData creates data once per class and wraps each test
|
|
14
|
+
in a savepoint, so the page tree is built once (~0.5 s) rather than
|
|
15
|
+
once per test method.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def setUpTestData(cls):
|
|
20
|
+
cls.admin = User.objects.create_superuser("admin", "admin@test.com", "pw")
|
|
21
|
+
token, _ = ApiToken.objects.get_or_create(user=cls.admin)
|
|
22
|
+
cls.auth = {"HTTP_AUTHORIZATION": f"Bearer {token.key}"}
|
|
23
|
+
|
|
24
|
+
root = Page.objects.filter(depth=1).first()
|
|
25
|
+
cls.home = root.add_child(title="Home", slug="home-test")
|
|
26
|
+
Site.objects.update_or_create(
|
|
27
|
+
is_default_site=True,
|
|
28
|
+
defaults={"hostname": "localhost", "root_page": cls.home},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
cls.simple = cls.home.add_child(
|
|
32
|
+
instance=SimplePage(title="Simple", slug="simple", body="Hello")
|
|
33
|
+
)
|
|
34
|
+
cls.simple.save_revision()
|
|
35
|
+
|
|
36
|
+
cls.blog_index = cls.home.add_child(
|
|
37
|
+
instance=BlogIndexPage(title="Blog", slug="blog", intro="Blog intro")
|
|
38
|
+
)
|
|
39
|
+
cls.blog_index.save_revision()
|
|
40
|
+
|
|
41
|
+
cls.blog1 = cls.blog_index.add_child(
|
|
42
|
+
instance=BlogPage(title="First Post", slug="first-post", published_date="2026-01-01")
|
|
43
|
+
)
|
|
44
|
+
cls.blog1.save_revision()
|
|
45
|
+
cls.blog1.get_latest_revision().publish()
|
|
46
|
+
|
|
47
|
+
cls.blog2 = cls.blog_index.add_child(
|
|
48
|
+
instance=BlogPage(title="Draft Post", slug="draft-post", published_date="2026-02-01")
|
|
49
|
+
)
|
|
50
|
+
cls.blog2.save_revision()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TestListPages(_PageTreeMixin, TestCase):
|
|
54
|
+
def test_list_returns_items(self):
|
|
55
|
+
response = self.client.get("/api/write/v1/pages/", **self.auth)
|
|
56
|
+
assert response.status_code == 200
|
|
57
|
+
data = response.json()
|
|
58
|
+
assert "items" in data
|
|
59
|
+
assert "meta" in data
|
|
60
|
+
assert data["meta"]["total_count"] > 0
|
|
61
|
+
|
|
62
|
+
def test_list_requires_auth(self):
|
|
63
|
+
response = self.client.get("/api/write/v1/pages/")
|
|
64
|
+
assert response.status_code == 401
|
|
65
|
+
|
|
66
|
+
def test_filter_by_type(self):
|
|
67
|
+
response = self.client.get("/api/write/v1/pages/?type=testapp.BlogPage", **self.auth)
|
|
68
|
+
assert response.status_code == 200
|
|
69
|
+
data = response.json()
|
|
70
|
+
for item in data["items"]:
|
|
71
|
+
assert item["meta"]["type"] == "testapp.BlogPage"
|
|
72
|
+
|
|
73
|
+
def test_filter_by_parent(self):
|
|
74
|
+
parent_id = self.blog_index.id
|
|
75
|
+
response = self.client.get(f"/api/write/v1/pages/?parent={parent_id}", **self.auth)
|
|
76
|
+
assert response.status_code == 200
|
|
77
|
+
data = response.json()
|
|
78
|
+
assert len(data["items"]) == 2
|
|
79
|
+
|
|
80
|
+
def test_filter_by_parent_path(self):
|
|
81
|
+
response = self.client.get("/api/write/v1/pages/?parent=/blog/", **self.auth)
|
|
82
|
+
assert response.status_code == 200
|
|
83
|
+
data = response.json()
|
|
84
|
+
assert len(data["items"]) == 2
|
|
85
|
+
|
|
86
|
+
def test_filter_by_parent_invalid_returns_empty(self):
|
|
87
|
+
response = self.client.get("/api/write/v1/pages/?parent=/nonexistent/", **self.auth)
|
|
88
|
+
assert response.status_code == 200
|
|
89
|
+
data = response.json()
|
|
90
|
+
assert len(data["items"]) == 0
|
|
91
|
+
|
|
92
|
+
def test_filter_by_status_live(self):
|
|
93
|
+
response = self.client.get("/api/write/v1/pages/?status=live", **self.auth)
|
|
94
|
+
assert response.status_code == 200
|
|
95
|
+
data = response.json()
|
|
96
|
+
for item in data["items"]:
|
|
97
|
+
assert item["meta"]["live"] is True
|
|
98
|
+
|
|
99
|
+
def test_filter_by_status_draft(self):
|
|
100
|
+
response = self.client.get("/api/write/v1/pages/?status=draft", **self.auth)
|
|
101
|
+
assert response.status_code == 200
|
|
102
|
+
data = response.json()
|
|
103
|
+
for item in data["items"]:
|
|
104
|
+
assert item["meta"]["live"] is False
|
|
105
|
+
|
|
106
|
+
def test_pagination(self):
|
|
107
|
+
response = self.client.get("/api/write/v1/pages/?offset=0&limit=1", **self.auth)
|
|
108
|
+
assert response.status_code == 200
|
|
109
|
+
data = response.json()
|
|
110
|
+
assert len(data["items"]) == 1
|
|
111
|
+
assert data["meta"]["total_count"] > 1
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class TestDetailPage(_PageTreeMixin, TestCase):
|
|
115
|
+
def test_detail_returns_page(self):
|
|
116
|
+
page_id = self.blog1.id
|
|
117
|
+
response = self.client.get(f"/api/write/v1/pages/{page_id}/", **self.auth)
|
|
118
|
+
assert response.status_code == 200
|
|
119
|
+
data = response.json()
|
|
120
|
+
assert data["id"] == page_id
|
|
121
|
+
assert data["title"] == "First Post"
|
|
122
|
+
|
|
123
|
+
def test_detail_includes_meta(self):
|
|
124
|
+
page_id = self.blog1.id
|
|
125
|
+
response = self.client.get(f"/api/write/v1/pages/{page_id}/", **self.auth)
|
|
126
|
+
data = response.json()
|
|
127
|
+
meta = data["meta"]
|
|
128
|
+
assert "type" in meta
|
|
129
|
+
assert "live" in meta
|
|
130
|
+
assert "has_unpublished_changes" in meta
|
|
131
|
+
assert "parent_id" in meta
|
|
132
|
+
assert "user_permissions" in meta
|
|
133
|
+
|
|
134
|
+
def test_detail_404_for_nonexistent(self):
|
|
135
|
+
response = self.client.get("/api/write/v1/pages/99999/", **self.auth)
|
|
136
|
+
assert response.status_code == 404
|
|
137
|
+
|
|
138
|
+
def test_detail_includes_type_specific_fields(self):
|
|
139
|
+
page_id = self.blog1.id
|
|
140
|
+
response = self.client.get(f"/api/write/v1/pages/{page_id}/", **self.auth)
|
|
141
|
+
data = response.json()
|
|
142
|
+
assert "published_date" in data
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class TestDetailPageDrafts(_PageTreeMixin, TestCase):
|
|
146
|
+
"""Draft-aware reads — these mutate the page tree so they get their own class."""
|
|
147
|
+
|
|
148
|
+
def test_detail_returns_draft_content_by_default(self):
|
|
149
|
+
"""When a page has unpublished changes, detail returns the draft."""
|
|
150
|
+
self.blog1.title = "Updated Title (draft)"
|
|
151
|
+
self.blog1.save_revision()
|
|
152
|
+
|
|
153
|
+
response = self.client.get(f"/api/write/v1/pages/{self.blog1.id}/", **self.auth)
|
|
154
|
+
data = response.json()
|
|
155
|
+
assert data["title"] == "Updated Title (draft)"
|
|
156
|
+
|
|
157
|
+
def test_detail_with_version_live(self):
|
|
158
|
+
"""?version=live returns the published content, not the draft DB row."""
|
|
159
|
+
self.blog1.refresh_from_db()
|
|
160
|
+
self.blog1.title = "Updated Title (draft)"
|
|
161
|
+
self.blog1.save()
|
|
162
|
+
self.blog1.save_revision()
|
|
163
|
+
|
|
164
|
+
response = self.client.get(
|
|
165
|
+
f"/api/write/v1/pages/{self.blog1.id}/?version=live", **self.auth
|
|
166
|
+
)
|
|
167
|
+
data = response.json()
|
|
168
|
+
assert data["title"] == "First Post"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class TestUrlPath(_PageTreeMixin, TestCase):
|
|
172
|
+
def test_list_includes_url_path(self):
|
|
173
|
+
response = self.client.get("/api/write/v1/pages/?slug=first-post", **self.auth)
|
|
174
|
+
data = response.json()
|
|
175
|
+
item = data["items"][0]
|
|
176
|
+
assert item["meta"]["url_path"] == "/blog/first-post/"
|
|
177
|
+
|
|
178
|
+
def test_list_root_page_url_path(self):
|
|
179
|
+
"""The site root page should have url_path '/'."""
|
|
180
|
+
home_id = self.home.id
|
|
181
|
+
response = self.client.get(f"/api/write/v1/pages/{home_id}/", **self.auth)
|
|
182
|
+
data = response.json()
|
|
183
|
+
assert data["meta"]["url_path"] == "/"
|
|
184
|
+
|
|
185
|
+
def test_detail_includes_url_path(self):
|
|
186
|
+
page_id = self.blog1.id
|
|
187
|
+
response = self.client.get(f"/api/write/v1/pages/{page_id}/", **self.auth)
|
|
188
|
+
data = response.json()
|
|
189
|
+
assert data["meta"]["url_path"] == "/blog/first-post/"
|
|
190
|
+
|
|
191
|
+
def test_detail_simple_page_url_path(self):
|
|
192
|
+
page_id = self.simple.id
|
|
193
|
+
response = self.client.get(f"/api/write/v1/pages/{page_id}/", **self.auth)
|
|
194
|
+
data = response.json()
|
|
195
|
+
assert data["meta"]["url_path"] == "/simple/"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class TestFilterBySlug(_PageTreeMixin, TestCase):
|
|
199
|
+
def test_filter_by_slug(self):
|
|
200
|
+
response = self.client.get("/api/write/v1/pages/?slug=first-post", **self.auth)
|
|
201
|
+
assert response.status_code == 200
|
|
202
|
+
data = response.json()
|
|
203
|
+
assert len(data["items"]) == 1
|
|
204
|
+
assert data["items"][0]["slug"] == "first-post"
|
|
205
|
+
|
|
206
|
+
def test_filter_by_slug_no_match(self):
|
|
207
|
+
response = self.client.get("/api/write/v1/pages/?slug=nonexistent-slug", **self.auth)
|
|
208
|
+
assert response.status_code == 200
|
|
209
|
+
data = response.json()
|
|
210
|
+
assert len(data["items"]) == 0
|
|
211
|
+
|
|
212
|
+
def test_filter_by_slug_combined_with_type(self):
|
|
213
|
+
response = self.client.get(
|
|
214
|
+
"/api/write/v1/pages/?slug=first-post&type=testapp.BlogPage", **self.auth
|
|
215
|
+
)
|
|
216
|
+
assert response.status_code == 200
|
|
217
|
+
data = response.json()
|
|
218
|
+
assert len(data["items"]) == 1
|
|
219
|
+
assert data["items"][0]["meta"]["type"] == "testapp.BlogPage"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class TestFilterByPath(_PageTreeMixin, TestCase):
|
|
223
|
+
def test_filter_by_path(self):
|
|
224
|
+
response = self.client.get("/api/write/v1/pages/?path=/blog/first-post/", **self.auth)
|
|
225
|
+
assert response.status_code == 200
|
|
226
|
+
data = response.json()
|
|
227
|
+
assert len(data["items"]) == 1
|
|
228
|
+
assert data["items"][0]["slug"] == "first-post"
|
|
229
|
+
|
|
230
|
+
def test_filter_by_path_no_trailing_slash(self):
|
|
231
|
+
response = self.client.get("/api/write/v1/pages/?path=/blog/first-post", **self.auth)
|
|
232
|
+
assert response.status_code == 200
|
|
233
|
+
data = response.json()
|
|
234
|
+
assert len(data["items"]) == 1
|
|
235
|
+
|
|
236
|
+
def test_filter_by_path_no_match(self):
|
|
237
|
+
response = self.client.get("/api/write/v1/pages/?path=/nonexistent/path/", **self.auth)
|
|
238
|
+
assert response.status_code == 200
|
|
239
|
+
data = response.json()
|
|
240
|
+
assert len(data["items"]) == 0
|
|
241
|
+
|
|
242
|
+
def test_filter_by_path_root_level(self):
|
|
243
|
+
response = self.client.get("/api/write/v1/pages/?path=/simple/", **self.auth)
|
|
244
|
+
assert response.status_code == 200
|
|
245
|
+
data = response.json()
|
|
246
|
+
assert len(data["items"]) == 1
|
|
247
|
+
assert data["items"][0]["slug"] == "simple"
|