Planure: Local Node Folder Lifecycle (create on section/group, lock root, delete with content)
Feature Overview
For documentation sources of type LOCAL, the Django admin should keep the
DocumentationNode section/group rows and the real filesystem folders in sync:
- Creating a section or group node creates its folder under the local source root.
- The local source root folder (
LocalDocumentationSource.root_path) cannot be changed once set. - Deleting a node deletes its folder and everything inside it.
Problem: today DocumentationNodeAdmin.save_model only writes a DB row; authors must
create folders by hand, deletes leave orphaned folders/content, and root_path can be
repointed at any time, silently orphaning all node paths.
Success criteria:
- Add a section/group on a LOCAL-source site → folder appears at <root_path>/<node.path>.
- The root_path field is not editable on an existing LocalDocumentationSource.
- Delete a section/group → its folder (and any markdown/subfolders in it) is removed.
- GitHub sources and non-node models are unaffected.
- rebuild_from_provider_tree / sync never create or delete folders.
Locked decisions
| Decision | Choice |
|---|---|
| Hook location | Django admin only (DocumentationNodeAdmin). No model signals. |
| "Main folder" | LocalDocumentationSource.root_path — locked after the local source exists. |
| Create trigger | Add of a section/group node whose site.source.source_type == LOCAL, with non-empty path. |
| Create behavior | mkdir(parents=True, exist_ok=True) at <root>/<path>. Root must already exist. |
| Delete behavior | shutil.rmtree(<root>/<path>) — recursive, content included. |
| Never touch | The root folder itself (refuse when path is empty or resolves to root). |
| Failure handling | Block the DB operation: create errors surface as form errors before save; delete errors abort before the DB row is removed. |
| GitHub sources | No filesystem operations. |
| Schema | No model/migration changes. |
Components & modules involved
Changed files
- apps/documentation/admin.py
- LocalDocumentationSourceForm (NEW) — disable root_path when the instance exists.
- LocalDocumentationSourceInline — use the form.
- DocumentationNodeAdminForm (NEW) — clean() validates + creates the folder on add.
- DocumentationNodeAdmin — form = DocumentationNodeAdminForm; override
delete_model / delete_queryset to delete folders before removing rows.
- Module-level private helpers: _local_root(site), _resolve_target(root, path),
_is_local_node(node), _create_node_folder(node), _delete_node_folder(node).
Note: the project rules prefer
feature_Planure/feature_Planure_local_node_folder_lifecycle.md. That path is blocked by workspace edit permissions, so this plan was saved to the allowed plan location. Copy it there if desired.
Unchanged
- models.py, services/* (providers, structure.py, sync.py), views.py,
templates, DocumentationSearchDocument, search index.
Ordered tasks
1. Local root lock — admin.py
Add a form that renders root_path read-only once the inline row exists:
class LocalDocumentationSourceForm(forms.ModelForm):
class Meta:
model = LocalDocumentationSource
fields = ("root_path",)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance and self.instance.pk:
self.fields["root_path"].disabled = True
Set form = LocalDocumentationSourceForm on LocalDocumentationSourceInline.
(Disabled fields keep the stored value; the field stays editable on the add form.)
2. Path helpers — admin.py (module level)
Mirror the provider's safety rules (services/filesystem.py:14-21):
def _local_root(site):
try:
config = site.source.local
except LocalDocumentationSource.DoesNotExist:
raise ValidationError("Local source has no root path configured.")
return Path(config.root_path).expanduser().resolve()
def _resolve_target(root, path):
clean = (path or "").strip("/\\")
if not clean:
raise ValidationError({"path": "A folder path is required."})
target = (root / clean).resolve()
if target == root or root not in target.parents:
raise ValidationError({"path": "Path escapes the documentation root."})
return target
_is_local_node(node)→node.type in ("section", "group")andnode.site.source.source_type == DocumentationSource.SourceType.LOCAL._create_node_folder(node): root mustis_dir()(elseValidationError);target.mkdir(parents=True, exist_ok=True)._delete_node_folder(node): skip unless_is_local_node; reject empty path /target == root; if anotherDocumentationNodefor the same source resolves to the same folder, skip and warn (shared-source guard); elseshutil.rmtree(target)whentarget.is_dir(), ortarget.unlink()when it is a file; ignore missing.- Imports needed:
Path,shutil,LocalDocumentationSource.DoesNotExist,ValidationError.
3. Create folder on add — admin.py
class DocumentationNodeAdminForm(forms.ModelForm):
class Meta:
model = DocumentationNode
fields = "__all__"
def clean(self):
cleaned = super().clean()
if self.instance.pk is None and cleaned.get("site") is not None:
probe = DocumentationNode(
site=cleaned["site"],
type=cleaned.get("type"),
path=cleaned.get("path") or "",
)
if _is_local_node(probe):
try:
_create_node_folder(probe)
except ValidationError:
raise
except OSError as exc:
raise ValidationError({"path": f"Could not create folder: {exc}"})
return cleaned
Set form = DocumentationNodeAdminForm on DocumentationNodeAdmin.
clean() runs last, so a failure blocks the save and shows the error on path.
4. Delete folder with content — admin.py
def delete_model(self, request, obj):
_delete_node_folder(obj) # raises OSError → atomic delete rolls back
super().delete_model(request, obj)
def delete_queryset(self, request, queryset):
for obj in queryset:
_delete_node_folder(obj) # deletes all folders first
super().delete_queryset(request, queryset)
delete_view runs inside transaction.atomic (Django options.py:2508), so a
filesystem error prevents the DB delete. rebuild_from_provider_tree calls
queryset.delete() directly and never goes through the admin, so structure imports
remain non-destructive.
5. Verify no collateral changes
Confirm import_structure (admin.py:34), sync_source, and the admin sync action
still work unchanged (they do not call delete_model/save_model).
Validation (user-run — the agent does not test)
python manage.py check(no schema change, no migration needed).- Admin → Documentation Sources → create a source with
source_type=LOCALand aroot_pathpointing at the repo'sdocs/. Save. - Reopen it →
root_pathis shown read-only and cannot be changed. - Admin → Documentation Nodes → add a
sectionfor that source's site withpath=Developer/New Section. Save →docs/Developer/New Section/exists. - Add a
groupwithpath=Developer/New Section/Guides→ nested folder is created. - Put a markdown file inside the section folder → it renders as a page.
- Delete the section node → confirmation page appears; on confirm, the folder and the markdown file inside are gone.
- Create a node on a GITHUB-source site → no filesystem changes (admin still saves).
- Re-run
Import navigation structure→ nodes are rebuilt and no folders are deleted. - Attempt an empty path on add → form error, node not saved.
- Attempt
path=../outside→ form error, nothing created.
Risks / notes
- Shared local source: two sites can point at the same
root_path; the delete guard skips filesystem removal when another node resolves to the same folder (removes only that node's DB row). Review the warning message. - Pre-existing folders are reused:
exist_ok=Truemeans an add never fails because the folder already exists; no ownership is tracked, so deleting a node can remove content another author created there. - Partial delete on bulk action:
delete_querysetremoves folders first; a failure midway leaves earlier folders gone while rows remain. Accepted v1. - No cleanup on DB failure after creation: folder creation happens in
clean(); if the DB insert later fails, the empty folder remains. - Root must pre-exist: a missing
root_pathis an error, never auto-created (protects the "main folder"). - Only the admin path is guarded; shell/script edits to nodes or
root_pathbypass this.
Out of scope
- Model signals / global hooks; sync or GitHub folder operations.
- Creating starter
index.mdfiles, moving/renaming folders whenpathchanges. - Rebuilding nodes from the filesystem in the other direction.
- A friendly custom delete error page (failure currently aborts with an admin error).
- Agent-run testing.