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.
Files changed (77) hide show
  1. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/PKG-INFO +1 -1
  2. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/docs/api/pages.md +2 -2
  3. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/docs/guides/rich-text.md +6 -0
  4. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/testapp/management/commands/seed_demo.py +28 -1
  5. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/pyproject.toml +1 -1
  6. wagtail_write_api-0.3.0/src/wagtail_write_api/__init__.py +1 -0
  7. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/converters/rich_text.py +24 -0
  8. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/endpoints/pages.py +17 -0
  9. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/endpoints/schema_discovery.py +13 -0
  10. wagtail_write_api-0.3.0/tests/test_pages_read.py +247 -0
  11. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/tests/test_pages_write.py +72 -52
  12. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/tests/test_rich_text.py +48 -0
  13. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/tests/test_schema_generation.py +16 -0
  14. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/tests/test_streamfield.py +68 -37
  15. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/uv.lock +1 -1
  16. wagtail_write_api-0.2.2/src/wagtail_write_api/__init__.py +0 -1
  17. wagtail_write_api-0.2.2/tests/test_pages_read.py +0 -210
  18. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/.github/workflows/docs.yml +0 -0
  19. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/.github/workflows/publish.yml +0 -0
  20. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/.gitignore +0 -0
  21. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/CLAUDE.md +0 -0
  22. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/LICENSE +0 -0
  23. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/Makefile +0 -0
  24. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/README.md +0 -0
  25. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/docs/api/authentication.md +0 -0
  26. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/docs/api/images.md +0 -0
  27. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/docs/api/schema-discovery.md +0 -0
  28. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/docs/development/contributing.md +0 -0
  29. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/docs/development/example-app.md +0 -0
  30. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/docs/getting-started/configuration.md +0 -0
  31. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/docs/getting-started/installation.md +0 -0
  32. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/docs/getting-started/quickstart.md +0 -0
  33. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/docs/guides/permissions.md +0 -0
  34. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/docs/guides/streamfield.md +0 -0
  35. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/docs/guides/workflow.md +0 -0
  36. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/docs/index.md +0 -0
  37. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/Makefile +0 -0
  38. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/example_project/__init__.py +0 -0
  39. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/example_project/settings.py +0 -0
  40. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/example_project/urls.py +0 -0
  41. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/example_project/wsgi.py +0 -0
  42. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/manage.py +0 -0
  43. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/testapp/__init__.py +0 -0
  44. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/testapp/management/__init__.py +0 -0
  45. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/testapp/management/commands/__init__.py +0 -0
  46. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/testapp/migrations/0001_initial.py +0 -0
  47. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/testapp/migrations/__init__.py +0 -0
  48. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/testapp/models.py +0 -0
  49. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/example/testapp/wagtail_hooks.py +0 -0
  50. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/api.py +0 -0
  51. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/apps.py +0 -0
  52. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/auth.py +0 -0
  53. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/converters/__init__.py +0 -0
  54. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/endpoints/__init__.py +0 -0
  55. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/endpoints/images.py +0 -0
  56. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/management/__init__.py +0 -0
  57. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/management/commands/__init__.py +0 -0
  58. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/management/commands/create_api_token.py +0 -0
  59. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/migrations/0001_initial.py +0 -0
  60. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/migrations/__init__.py +0 -0
  61. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/models.py +0 -0
  62. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/permissions.py +0 -0
  63. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/schema/__init__.py +0 -0
  64. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/schema/fields.py +0 -0
  65. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/schema/generator.py +0 -0
  66. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/schema/registry.py +0 -0
  67. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/settings.py +0 -0
  68. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/urls.py +0 -0
  69. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/src/wagtail_write_api/utils.py +0 -0
  70. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/tests/__init__.py +0 -0
  71. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/tests/conftest.py +0 -0
  72. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/tests/test_auth.py +0 -0
  73. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/tests/test_images_api.py +0 -0
  74. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/tests/test_pages_workflow.py +0 -0
  75. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/tests/test_smoke.py +0 -0
  76. {wagtail_write_api-0.2.2 → wagtail_write_api-0.3.0}/wagtail-write-api-plan.md +0 -0
  77. {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.2.2
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:
@@ -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 wagtail_write_api.models import ApiToken
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,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "wagtail-write-api"
7
- version = "0.2.2"
7
+ version = "0.3.0"
8
8
  description = "A read/write REST API for Wagtail CMS"
9
9
  readme = "README.md"
10
10
  license = "BSD-3-Clause"
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -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)
@@ -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"