Skip to content

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.py
  • sections.py
  • callouts.py
  • chapters.py
  • manuals.py
  • media.py
  • pdf.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

  1. load current media IDs attached to the section
  2. collect media IDs referenced in the incoming document
  3. delete orphaned media rows
  4. remove stale inline media nodes from the JSON
  5. 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

  • merge
  • replace

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]

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

  1. serializer validates manual payload
  2. service creates or updates the Manual
  3. service delegates nested sync to:
  4. sync_chapters(...)
  5. sync_manual_callouts(...)
  6. 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]

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
  • PDF
  • 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.