Manuals — Services¶
Overview¶
The services layer in the manuals app owns the write-side and orchestration logic that should not live in:
- serializers
- views
- signals
- models
It is responsible for:
- nested manual synchronization
- chapter and item synchronization
- step synchronization
- section inline-media cleanup
- manual-level callout synchronization
- media cleanup helpers
- PDF rendering
- shared normalization helpers
It is not responsible for:
- request parsing
- permission enforcement
- response serialization
- low-level file lifecycle signals
Service package structure¶
The services package is split by domain concern:
helpers.pysections.pycallouts.pychapters.pymanuals.pymedia.pypdf.py
This makes the service layer easier to test and extend.
High-level service responsibilities¶
flowchart TD
ManualSerializer --> ManualsService
ManualsService --> ChaptersService
ManualsService --> CalloutsService
ChaptersService --> SectionsService
ChaptersService --> CalloutsService
MediaView --> MediaService
ManualView --> PDFService
Helpers --> ManualsService
Helpers --> ChaptersService
Helpers --> CalloutsService
helpers.py¶
Purpose¶
Contains shared utility helpers used by multiple service modules.
Typical responsibilities¶
- integer normalization
- rich-text JSON normalization
- JSON-ish payload parsing
Key helpers¶
as_int_or_none(...)¶
Converts a value to int when possible.
Returns:
- integer value
- or None
Used throughout nested sync logic for IDs.
normalize_tiptap_doc(...)¶
Normalizes rich-text JSON for section content.
Ensures:
- object shape
- type == "doc"
- content list exists
Used before section inline-media cleanup and persistence.
parse_jsonish_field(...)¶
Parses request payload fields that may arrive either as:
- already parsed objects
- JSON strings
This is useful for multipart form submissions where nested arrays are stringified.
sections.py¶
Purpose¶
Owns section-rich-text cleanup behavior, especially around inline media references.
This module is important because Section.description_json is currently the rich-text source of truth.
Key responsibilities¶
- collect referenced manual media IDs from TipTap JSON
- remove stale inline media blocks
- synchronize section JSON after media changes
Key helpers¶
collect_manual_media_ids_from_tiptap(...)¶
Traverses a TipTap JSON tree and extracts:
- referenced mediaId values from manualMediaBlock nodes
Used to determine which media rows are still in use by a section.
remove_stale_inline_media_json(...)¶
Traverses a TipTap JSON tree and removes media nodes that reference invalid media IDs.
This keeps section content consistent after media deletion.
sync_section_inline_media(section, next_doc)¶
Main section cleanup helper.
Flow¶
- load current media IDs attached to the section
- collect media IDs referenced in the incoming document
- delete orphaned media rows
- remove stale inline media nodes from the JSON
- return cleaned JSON document
Section media cleanup flow¶
flowchart TD
A[Incoming section description_json] --> B[Collect referenced media IDs]
B --> C[Load current media attached to section]
C --> D[Compute orphan media IDs]
D --> E[Delete orphan ManualMedia rows]
E --> F[Remove stale media blocks from JSON]
F --> G[Return cleaned TipTap doc]
callouts.py¶
Purpose¶
Owns callout synchronization logic.
This currently focuses on manual-level callout synchronization, but the module gives you a clean place for future callout workflows too.
Key responsibility¶
sync_manual_callouts(manual, callouts, strategy="merge")¶
Synchronizes callouts attached directly to a manual.
Behavior:
- upserts callouts from payload
- attaches them only at manual level
- clears chapter/section/step parent references
- optionally deletes missing callouts when strategy is
replace
Supported strategies¶
mergereplace
Why this belongs in services¶
This logic is:
- write-side
- nested
- stateful
So it should not live inside the serializer.
chapters.py¶
Purpose¶
This is the heaviest orchestration module in the app.
It owns synchronization of:
- chapters
- chapter items
- sections
- notes
- step groups
- steps
- inline nested content updates
Main responsibilities¶
sync_steps(step_group, steps, strategy="merge")¶
Synchronizes the ordered steps inside a step group.
Behavior:
- upsert by explicit ID when present
- fallback match by
(step_group, title, order)when needed - delete missing rows when strategy is
replace
sync_items(chapter, items, strategy="merge")¶
Synchronizes chapter display items.
This is the core write-side function for chapter content composition.
Supported kinds:
- section
- note
- callout
- step_group
- media_grid
Behavior:
- normalizes kind
- resolves or creates target content block
- updates inline nested fields where provided
- creates or updates
ChapterItem - deletes missing items when strategy is
replace
This is where most nested content orchestration happens.
sync_chapters(manual, chapters_payload, strategy="merge")¶
Synchronizes the manual’s chapters.
Behavior:
- optionally deletes missing chapters for
replace - temporarily bumps chapter orders to avoid uniqueness collisions
- upserts chapters
- delegates item sync to
sync_items(...) - densifies final chapter ordering
This function is the main entry point for chapter-level nested writes.
Chapter sync flow¶
flowchart TD
A[chapters_write payload] --> B[sync_chapters]
B --> C[Resolve merge vs replace]
C --> D[Delete missing chapters if replace]
C --> E[Keep existing chapters if merge]
D --> F[Temp bump existing chapter orders]
E --> F
F --> G[Upsert chapters]
G --> H[For each chapter call sync_items]
H --> I[Create/update sections notes callouts step groups]
I --> J[Create/update ChapterItem rows]
J --> K[Re-densify final chapter order]
K --> L[Done]
¶
flowchart TD
A[chapters_write payload] --> B[sync_chapters]
B --> C[Resolve merge vs replace]
C --> D[Delete missing chapters if replace]
C --> E[Keep existing chapters if merge]
D --> F[Temp bump existing chapter orders]
E --> F
F --> G[Upsert chapters]
G --> H[For each chapter call sync_items]
H --> I[Create/update sections notes callouts step groups]
I --> J[Create/update ChapterItem rows]
J --> K[Re-densify final chapter order]
K --> L[Done]Why chapter sync is in services¶
This logic is too rich for serializers because it involves:
- multiple models
- strategy-dependent behavior
- object creation
- item-kind dispatch
- nested inline updates
- ordering reconciliation
It belongs squarely in the service layer.
manuals.py¶
Purpose¶
Owns top-level manual orchestration.
This module coordinates manual create/update with nested content synchronization.
Typical responsibility¶
- create manual
- update manual
- call chapter sync
- call manual callout sync
- refresh instance state after nested updates
Even if the serializer still initiates the process, this module is the correct place for top-level write orchestration.
Expected workflow¶
- serializer validates manual payload
- service creates or updates the Manual
- service delegates nested sync to:
- sync_chapters(...)
- sync_manual_callouts(...)
- service refreshes final instance
media.py¶
Purpose¶
Owns media-related helper logic outside pure serializer validation.
Responsibilities include¶
- parent ownership validation
- media delete cleanup
- section JSON cleanup after media deletion
validate_media_parent_in_org(org, data)¶
Ensures the requested media parent belongs to the current org.
Supported parents¶
- manual
- chapter
- section
- step_group
- step
Returns¶
- True when parent is valid and inside org
- False otherwise
This keeps media attachment safe in multi-tenant contexts.
cleanup_after_media_delete(media=...)¶
Handles cleanup after a media row is deleted.
Current behavior¶
- delete the media row
- if the media belonged to a section:
- load the section rich-text document
- recalculate the remaining valid media IDs for that section
- remove only stale inline media nodes from
description_json - preserve other still-valid inline media references
This is important because deleting one media row should not remove unrelated inline media blocks from the same section.
Media delete cleanup flow¶
flowchart TD
A[Delete ManualMedia] --> B[Capture parent section]
B --> C[Delete media row]
C --> D{Attached to section?}
D -->|No| E[Done]
D -->|Yes| F[Load section description_json]
F --> G[Load remaining valid section media IDs]
G --> H[Remove only stale inline media nodes]
H --> I[Save cleaned description_json]
¶
flowchart TD
A[Delete ManualMedia] --> B[Capture parent section]
B --> C[Delete media row]
C --> D{Attached to section?}
D -->|No| E[Done]
D -->|Yes| F[Load section description_json]
F --> G[Load remaining valid section media IDs]
G --> H[Remove only stale inline media nodes]
H --> I[Save cleaned description_json]pdf.py¶
Purpose¶
Owns manual PDF generation.
This keeps rendering logic out of the view layer.
Main responsibility¶
render_manual_pdf(...)
Typical behavior:
- render manual data into HTML template
- apply base print CSS
- generate PDF bytes via WeasyPrint
- return PDF bytes to the caller
The view should only:
- fetch the manual
- serialize it
- call the PDF service
- return an HTTP response
PDF export flow¶
flowchart TD
A[ManualView export endpoint] --> B[Serialize manual detail]
B --> C[Call render_manual_pdf]
C --> D[Render HTML template]
D --> E[Apply print CSS]
E --> F[Generate PDF bytes]
F --> G[Return HTTP response]
Service-layer boundaries¶
Services own¶
- nested synchronization
- stateful write orchestration
- cross-model coordination
- rich content cleanup
- PDF generation
- org-safe parent validation
Views own¶
- request handling
- permissions
- serializer invocation
- HTTP responses
Serializers own¶
- field definitions
- payload validation
- lightweight normalization
Signals own¶
- low-level file cleanup only
Why the split matters¶
Before cleanup, the manuals app placed too much orchestration inside serializers and views.
That made the code:
- difficult to test
- tightly coupled
- hard to reuse
- hard to extend safely
After the split:
- services do the real work
- serializers validate and shape data
- views become thin orchestration endpoints
This is a much healthier architecture.
Design principles¶
Service functions should be explicit¶
Service methods should describe domain intent clearly, such as:
- sync chapters
- sync items
- sync steps
- sync section inline media
- validate parent in org
- render manual PDF
Keep services focused by domain¶
Each service module should own one conceptual area:
- sections
- chapters
- manuals
- media
- callouts
This reduces coupling and keeps files manageable.
Keep infrastructure separate¶
File lifecycle cleanup remains in signals, not services.
That is because it should run regardless of whether changes come from:
- API
- admin
- shell
- tests
Keep write-side complexity out of serializers¶
Nested sync logic is difficult enough on its own.
Putting it inside serializers makes it harder to maintain.
The service split fixes that.
Best practices¶
- keep nested orchestration in services
- use small helpers for shared normalization
- test merge and replace strategies separately
- keep service modules domain-focused
- do not leak request/response logic into services
- keep PDF rendering isolated from HTTP concerns
Future extensions¶
The current service split gives you strong room to expand.
Possible future additions include:
- explicit publish/unpublish service methods
- manual duplication / cloning
- chapter reorder service
- chapter item reorder service
- media-grid specific service logic
- richer validation services for parent consistency
- background PDF generation if export becomes expensive
Summary¶
The manuals service layer is the core write-side engine of the app.
It provides structured orchestration for:
- manuals
- chapters
- items
- steps
- callouts
- media cleanup
- PDF rendering
It is designed to keep:
- serializers thin
- views clean
- domain logic centralized
The key idea is:
services own synchronization and orchestration; other layers stay focused on their own responsibilities.