django-cfg 1.4.108__py3-none-any.whl → 1.4.110__py3-none-any.whl
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.
- django_cfg/__init__.py +1 -1
- django_cfg/modules/django_admin/__init__.py +6 -0
- django_cfg/modules/django_admin/base/pydantic_admin.py +91 -0
- django_cfg/modules/django_admin/config/__init__.py +5 -0
- django_cfg/modules/django_admin/config/admin_config.py +7 -0
- django_cfg/modules/django_admin/config/documentation_config.py +406 -0
- django_cfg/modules/django_admin/config/field_config.py +87 -0
- django_cfg/modules/django_admin/templates/django_admin/change_form_docs.html +23 -0
- django_cfg/modules/django_admin/templates/django_admin/change_list_docs.html +23 -0
- django_cfg/modules/django_admin/templates/django_admin/documentation_block.html +297 -0
- django_cfg/modules/django_admin/templates/django_admin/markdown_docs_block.html +37 -0
- django_cfg/modules/django_admin/utils/__init__.py +3 -0
- django_cfg/modules/django_admin/utils/html_builder.py +94 -1
- django_cfg/modules/django_admin/utils/markdown_renderer.py +344 -0
- django_cfg/pyproject.toml +2 -2
- django_cfg/static/frontend/admin.zip +0 -0
- {django_cfg-1.4.108.dist-info → django_cfg-1.4.110.dist-info}/METADATA +2 -1
- {django_cfg-1.4.108.dist-info → django_cfg-1.4.110.dist-info}/RECORD +21 -28
- django_cfg/modules/django_admin/CHANGELOG_CODE_METHODS.md +0 -153
- django_cfg/modules/django_admin/IMPORT_EXPORT_FIX.md +0 -72
- django_cfg/modules/django_admin/RESOURCE_CONFIG_ENHANCEMENT.md +0 -350
- django_cfg/modules/django_client/system/__init__.py +0 -24
- django_cfg/modules/django_client/system/base_generator.py +0 -123
- django_cfg/modules/django_client/system/generate_mjs_clients.py +0 -176
- django_cfg/modules/django_client/system/mjs_generator.py +0 -219
- django_cfg/modules/django_client/system/schema_parser.py +0 -199
- django_cfg/modules/django_client/system/templates/api_client.js.j2 +0 -87
- django_cfg/modules/django_client/system/templates/app_index.js.j2 +0 -13
- django_cfg/modules/django_client/system/templates/base_client.js.j2 +0 -166
- django_cfg/modules/django_client/system/templates/main_index.js.j2 +0 -80
- django_cfg/modules/django_client/system/templates/types.js.j2 +0 -24
- {django_cfg-1.4.108.dist-info → django_cfg-1.4.110.dist-info}/WHEEL +0 -0
- {django_cfg-1.4.108.dist-info → django_cfg-1.4.110.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.4.108.dist-info → django_cfg-1.4.110.dist-info}/licenses/LICENSE +0 -0
django_cfg/__init__.py
CHANGED
|
@@ -51,9 +51,11 @@ from .config import (
|
|
|
51
51
|
BooleanField,
|
|
52
52
|
CurrencyField,
|
|
53
53
|
DateTimeField,
|
|
54
|
+
DocumentationConfig,
|
|
54
55
|
FieldConfig,
|
|
55
56
|
FieldsetConfig,
|
|
56
57
|
ImageField,
|
|
58
|
+
MarkdownField,
|
|
57
59
|
ResourceConfig,
|
|
58
60
|
TextField,
|
|
59
61
|
UserField,
|
|
@@ -73,6 +75,7 @@ from .icons import IconCategories, Icons
|
|
|
73
75
|
from .utils import (
|
|
74
76
|
CounterBadge,
|
|
75
77
|
DateTimeDisplay,
|
|
78
|
+
MarkdownRenderer,
|
|
76
79
|
MoneyDisplay,
|
|
77
80
|
ProgressBadge,
|
|
78
81
|
StatusBadge,
|
|
@@ -105,12 +108,14 @@ __all__ = [
|
|
|
105
108
|
"ActionConfig",
|
|
106
109
|
"ResourceConfig",
|
|
107
110
|
"BackgroundTaskConfig",
|
|
111
|
+
"DocumentationConfig",
|
|
108
112
|
# Specialized Field Types
|
|
109
113
|
"BadgeField",
|
|
110
114
|
"BooleanField",
|
|
111
115
|
"CurrencyField",
|
|
112
116
|
"DateTimeField",
|
|
113
117
|
"ImageField",
|
|
118
|
+
"MarkdownField",
|
|
114
119
|
"TextField",
|
|
115
120
|
"UserField",
|
|
116
121
|
# Advanced
|
|
@@ -125,6 +130,7 @@ __all__ = [
|
|
|
125
130
|
"StatusBadge",
|
|
126
131
|
"ProgressBadge",
|
|
127
132
|
"CounterBadge",
|
|
133
|
+
"MarkdownRenderer",
|
|
128
134
|
# Decorators
|
|
129
135
|
"computed_field",
|
|
130
136
|
"badge_field",
|
|
@@ -3,6 +3,7 @@ PydanticAdmin - Declarative admin base class.
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
+
from pathlib import Path
|
|
6
7
|
from typing import Any, List, Optional
|
|
7
8
|
|
|
8
9
|
from django.utils.safestring import mark_safe
|
|
@@ -152,6 +153,56 @@ class PydanticAdminMixin:
|
|
|
152
153
|
|
|
153
154
|
cls.changelist_view = changelist_view_with_import_export
|
|
154
155
|
|
|
156
|
+
# Documentation configuration
|
|
157
|
+
if config.documentation:
|
|
158
|
+
cls._setup_documentation(config)
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def _setup_documentation(cls, config: AdminConfig):
|
|
162
|
+
"""
|
|
163
|
+
Setup documentation using unfold's template hooks.
|
|
164
|
+
|
|
165
|
+
Uses unfold's built-in hooks:
|
|
166
|
+
- list_before_template: Shows documentation before changelist table
|
|
167
|
+
- change_form_before_template: Shows documentation before fieldsets
|
|
168
|
+
"""
|
|
169
|
+
doc_config = config.documentation
|
|
170
|
+
|
|
171
|
+
# Set unfold template hooks
|
|
172
|
+
if doc_config.show_on_changelist:
|
|
173
|
+
cls.list_before_template = "django_admin/documentation_block.html"
|
|
174
|
+
|
|
175
|
+
if doc_config.show_on_changeform:
|
|
176
|
+
cls.change_form_before_template = "django_admin/documentation_block.html"
|
|
177
|
+
|
|
178
|
+
# Store documentation config for access in views
|
|
179
|
+
cls.documentation_config = doc_config
|
|
180
|
+
|
|
181
|
+
def _get_app_path(self) -> Optional[Path]:
|
|
182
|
+
"""
|
|
183
|
+
Detect the app path for relative file resolution.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Path to the app directory or None
|
|
187
|
+
"""
|
|
188
|
+
if not self.model:
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
# Get app label from model
|
|
193
|
+
app_label = self.model._meta.app_label
|
|
194
|
+
|
|
195
|
+
# Try to get app config
|
|
196
|
+
from django.apps import apps
|
|
197
|
+
app_config = apps.get_app_config(app_label)
|
|
198
|
+
|
|
199
|
+
if app_config and hasattr(app_config, 'path'):
|
|
200
|
+
return Path(app_config.path)
|
|
201
|
+
except Exception as e:
|
|
202
|
+
logger.warning(f"Could not detect app path for {self.model}: {e}")
|
|
203
|
+
|
|
204
|
+
return None
|
|
205
|
+
|
|
155
206
|
@classmethod
|
|
156
207
|
def _generate_resource_class(cls, config: AdminConfig):
|
|
157
208
|
"""
|
|
@@ -452,6 +503,46 @@ class PydanticAdminMixin:
|
|
|
452
503
|
|
|
453
504
|
return tuple(filtered_fieldsets)
|
|
454
505
|
|
|
506
|
+
def changelist_view(self, request, extra_context=None):
|
|
507
|
+
"""Override to add documentation context to changelist."""
|
|
508
|
+
if extra_context is None:
|
|
509
|
+
extra_context = {}
|
|
510
|
+
|
|
511
|
+
# Add documentation context if configured
|
|
512
|
+
if hasattr(self, 'documentation_config') and self.documentation_config:
|
|
513
|
+
doc_config = self.documentation_config
|
|
514
|
+
app_path = self._get_app_path()
|
|
515
|
+
|
|
516
|
+
if doc_config.show_on_changelist:
|
|
517
|
+
extra_context['documentation_config'] = doc_config
|
|
518
|
+
extra_context['documentation_sections'] = doc_config.get_sections(app_path)
|
|
519
|
+
|
|
520
|
+
# Add management commands if enabled
|
|
521
|
+
if doc_config.show_management_commands:
|
|
522
|
+
extra_context['management_commands'] = doc_config._discover_management_commands(app_path)
|
|
523
|
+
|
|
524
|
+
return super().changelist_view(request, extra_context)
|
|
525
|
+
|
|
526
|
+
def changeform_view(self, request, object_id=None, form_url='', extra_context=None):
|
|
527
|
+
"""Override to add documentation context to changeform."""
|
|
528
|
+
if extra_context is None:
|
|
529
|
+
extra_context = {}
|
|
530
|
+
|
|
531
|
+
# Add documentation context if configured
|
|
532
|
+
if hasattr(self, 'documentation_config') and self.documentation_config:
|
|
533
|
+
doc_config = self.documentation_config
|
|
534
|
+
app_path = self._get_app_path()
|
|
535
|
+
|
|
536
|
+
if doc_config.show_on_changeform:
|
|
537
|
+
extra_context['documentation_config'] = doc_config
|
|
538
|
+
extra_context['documentation_sections'] = doc_config.get_sections(app_path)
|
|
539
|
+
|
|
540
|
+
# Add management commands if enabled
|
|
541
|
+
if doc_config.show_management_commands:
|
|
542
|
+
extra_context['management_commands'] = doc_config._discover_management_commands(app_path)
|
|
543
|
+
|
|
544
|
+
return super().changeform_view(request, object_id, form_url, extra_context)
|
|
545
|
+
|
|
455
546
|
def formfield_for_dbfield(self, db_field, request, **kwargs):
|
|
456
547
|
"""
|
|
457
548
|
Override form field for specific database field types.
|
|
@@ -5,6 +5,7 @@ Configuration models for declarative Django Admin.
|
|
|
5
5
|
from .action_config import ActionConfig
|
|
6
6
|
from .admin_config import AdminConfig
|
|
7
7
|
from .background_task_config import BackgroundTaskConfig
|
|
8
|
+
from .documentation_config import DocumentationConfig, DocumentationSection
|
|
8
9
|
from .field_config import (
|
|
9
10
|
FieldConfig,
|
|
10
11
|
BadgeField,
|
|
@@ -12,6 +13,7 @@ from .field_config import (
|
|
|
12
13
|
CurrencyField,
|
|
13
14
|
DateTimeField,
|
|
14
15
|
ImageField,
|
|
16
|
+
MarkdownField,
|
|
15
17
|
TextField,
|
|
16
18
|
UserField,
|
|
17
19
|
)
|
|
@@ -25,12 +27,15 @@ __all__ = [
|
|
|
25
27
|
"ActionConfig",
|
|
26
28
|
"ResourceConfig",
|
|
27
29
|
"BackgroundTaskConfig",
|
|
30
|
+
"DocumentationConfig",
|
|
31
|
+
"DocumentationSection",
|
|
28
32
|
# Specialized Field Types
|
|
29
33
|
"BadgeField",
|
|
30
34
|
"BooleanField",
|
|
31
35
|
"CurrencyField",
|
|
32
36
|
"DateTimeField",
|
|
33
37
|
"ImageField",
|
|
38
|
+
"MarkdownField",
|
|
34
39
|
"TextField",
|
|
35
40
|
"UserField",
|
|
36
41
|
]
|
|
@@ -9,6 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
|
|
9
9
|
|
|
10
10
|
from .action_config import ActionConfig
|
|
11
11
|
from .background_task_config import BackgroundTaskConfig
|
|
12
|
+
from .documentation_config import DocumentationConfig
|
|
12
13
|
from .field_config import FieldConfig
|
|
13
14
|
from .fieldset_config import FieldsetConfig
|
|
14
15
|
from .resource_config import ResourceConfig
|
|
@@ -141,6 +142,12 @@ class AdminConfig(BaseModel):
|
|
|
141
142
|
description="Configuration for background task processing"
|
|
142
143
|
)
|
|
143
144
|
|
|
145
|
+
# Documentation
|
|
146
|
+
documentation: Optional[DocumentationConfig] = Field(
|
|
147
|
+
None,
|
|
148
|
+
description="Markdown documentation configuration"
|
|
149
|
+
)
|
|
150
|
+
|
|
144
151
|
def get_display_field_config(self, field_name: str) -> Optional[FieldConfig]:
|
|
145
152
|
"""Get FieldConfig for a specific field."""
|
|
146
153
|
for field_config in self.display_fields:
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Documentation configuration for Django Admin.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Dict, List, Optional, Union
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DocumentationSection(BaseModel):
|
|
13
|
+
"""Single documentation section with title and content."""
|
|
14
|
+
|
|
15
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
16
|
+
|
|
17
|
+
title: str = Field(..., description="Section title")
|
|
18
|
+
content: str = Field(..., description="Rendered HTML content")
|
|
19
|
+
file_path: Optional[Path] = Field(None, description="Source file path")
|
|
20
|
+
default_open: bool = Field(False, description="Open by default")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DocumentationConfig(BaseModel):
|
|
24
|
+
"""
|
|
25
|
+
Configuration for markdown documentation in Django Admin.
|
|
26
|
+
|
|
27
|
+
Supports three modes:
|
|
28
|
+
|
|
29
|
+
1. **Directory mode** (recommended):
|
|
30
|
+
Automatically discovers all .md files in directory recursively.
|
|
31
|
+
Each file becomes a collapsible section.
|
|
32
|
+
|
|
33
|
+
DocumentationConfig(
|
|
34
|
+
source_dir="docs", # Relative to app
|
|
35
|
+
title="Documentation",
|
|
36
|
+
max_height="600px"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
2. **Single file mode**:
|
|
40
|
+
Displays single markdown file.
|
|
41
|
+
|
|
42
|
+
DocumentationConfig(
|
|
43
|
+
source_file="docs/README.md",
|
|
44
|
+
title="Documentation"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
3. **String content mode**:
|
|
48
|
+
Direct markdown content.
|
|
49
|
+
|
|
50
|
+
DocumentationConfig(
|
|
51
|
+
source_content="# Hello\\nWorld",
|
|
52
|
+
title="Documentation"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
Path resolution:
|
|
56
|
+
- Relative: "docs" → current app's docs/
|
|
57
|
+
- Project: "apps/crypto/docs" → project root
|
|
58
|
+
- Absolute: "/full/path/to/docs"
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
model_config = ConfigDict(validate_assignment=True, extra="forbid")
|
|
62
|
+
|
|
63
|
+
# Content source (one of these must be provided)
|
|
64
|
+
source_dir: Optional[Union[str, Path]] = Field(
|
|
65
|
+
None,
|
|
66
|
+
description="Path to directory with .md files (scans recursively)"
|
|
67
|
+
)
|
|
68
|
+
source_file: Optional[Union[str, Path]] = Field(
|
|
69
|
+
None,
|
|
70
|
+
description="Path to single markdown file"
|
|
71
|
+
)
|
|
72
|
+
source_content: Optional[str] = Field(
|
|
73
|
+
None,
|
|
74
|
+
description="Markdown content as string"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Display options
|
|
78
|
+
title: str = Field("Documentation", description="Main title for documentation block")
|
|
79
|
+
collapsible: bool = Field(True, description="Make sections collapsible")
|
|
80
|
+
default_open: bool = Field(False, description="Open first section by default")
|
|
81
|
+
max_height: Optional[str] = Field("600px", description="Max height with scrolling per section")
|
|
82
|
+
|
|
83
|
+
# Placement
|
|
84
|
+
show_on_changelist: bool = Field(True, description="Show on list page (above table)")
|
|
85
|
+
show_on_changeform: bool = Field(True, description="Show on edit/add page (before fieldsets)")
|
|
86
|
+
|
|
87
|
+
# Markdown rendering
|
|
88
|
+
enable_plugins: bool = Field(True, description="Enable mistune plugins")
|
|
89
|
+
|
|
90
|
+
# Sorting
|
|
91
|
+
sort_sections: bool = Field(True, description="Sort sections alphabetically by title")
|
|
92
|
+
|
|
93
|
+
# Management commands discovery
|
|
94
|
+
show_management_commands: bool = Field(
|
|
95
|
+
True,
|
|
96
|
+
description="Auto-discover and display management commands from app"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
@field_validator('source_dir', 'source_file', 'source_content')
|
|
100
|
+
@classmethod
|
|
101
|
+
def validate_source(cls, v, info):
|
|
102
|
+
"""Ensure at least one source is provided."""
|
|
103
|
+
return v
|
|
104
|
+
|
|
105
|
+
def _resolve_path(self, path: Union[str, Path], app_path: Optional[Path] = None) -> Optional[Path]:
|
|
106
|
+
"""
|
|
107
|
+
Resolve file or directory path with support for:
|
|
108
|
+
- Relative to app: "docs"
|
|
109
|
+
- Relative to project: "apps/myapp/docs"
|
|
110
|
+
- Absolute: "/full/path/to/docs"
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
path: Path to resolve
|
|
114
|
+
app_path: Path to the app directory (auto-detected from model)
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Resolved absolute path or None
|
|
118
|
+
"""
|
|
119
|
+
if not path:
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
path_obj = Path(path)
|
|
123
|
+
|
|
124
|
+
# If absolute path, return as is
|
|
125
|
+
if path_obj.is_absolute():
|
|
126
|
+
return path_obj if path_obj.exists() else None
|
|
127
|
+
|
|
128
|
+
# Try project root first (for paths like "apps/crypto/docs")
|
|
129
|
+
from django.conf import settings
|
|
130
|
+
base_dir = Path(settings.BASE_DIR)
|
|
131
|
+
|
|
132
|
+
# Try relative to project root
|
|
133
|
+
project_path = base_dir / path_obj
|
|
134
|
+
if project_path.exists():
|
|
135
|
+
return project_path
|
|
136
|
+
|
|
137
|
+
# Try relative to app if provided
|
|
138
|
+
if app_path:
|
|
139
|
+
app_path_resolved = app_path / path_obj
|
|
140
|
+
if app_path_resolved.exists():
|
|
141
|
+
return app_path_resolved
|
|
142
|
+
|
|
143
|
+
# Try to find in any app's directory
|
|
144
|
+
if hasattr(settings, 'INSTALLED_APPS'):
|
|
145
|
+
for app in settings.INSTALLED_APPS:
|
|
146
|
+
try:
|
|
147
|
+
# Get app module
|
|
148
|
+
import importlib
|
|
149
|
+
app_module = importlib.import_module(app.split('.')[0])
|
|
150
|
+
if hasattr(app_module, '__path__'):
|
|
151
|
+
app_dir = Path(app_module.__path__[0])
|
|
152
|
+
app_file = app_dir / path_obj
|
|
153
|
+
if app_file.exists():
|
|
154
|
+
return app_file
|
|
155
|
+
except (ImportError, AttributeError, IndexError):
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
def _scan_markdown_files(self, directory: Path) -> List[Path]:
|
|
161
|
+
"""
|
|
162
|
+
Recursively scan directory for markdown files.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
directory: Directory to scan
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
List of markdown file paths
|
|
169
|
+
"""
|
|
170
|
+
md_files = []
|
|
171
|
+
|
|
172
|
+
if not directory.is_dir():
|
|
173
|
+
return md_files
|
|
174
|
+
|
|
175
|
+
# Recursively find all .md files
|
|
176
|
+
for md_file in directory.rglob("*.md"):
|
|
177
|
+
if md_file.is_file():
|
|
178
|
+
md_files.append(md_file)
|
|
179
|
+
|
|
180
|
+
return md_files
|
|
181
|
+
|
|
182
|
+
def _get_section_title(self, file_path: Path, base_dir: Path) -> str:
|
|
183
|
+
"""
|
|
184
|
+
Generate section title from file path.
|
|
185
|
+
|
|
186
|
+
Strategies:
|
|
187
|
+
1. Extract first H1 from content if available
|
|
188
|
+
2. If README.md → use parent directory name
|
|
189
|
+
3. If nested → use "Parent / Filename"
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
file_path: Path to markdown file
|
|
193
|
+
base_dir: Base documentation directory
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Section title
|
|
197
|
+
"""
|
|
198
|
+
# Try to extract H1 from file
|
|
199
|
+
try:
|
|
200
|
+
content = file_path.read_text(encoding='utf-8')
|
|
201
|
+
lines = content.split('\n')
|
|
202
|
+
for line in lines:
|
|
203
|
+
line = line.strip()
|
|
204
|
+
if line.startswith('# '):
|
|
205
|
+
return line[2:].strip()
|
|
206
|
+
except Exception:
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
# Fallback to filename
|
|
210
|
+
relative_path = file_path.relative_to(base_dir)
|
|
211
|
+
|
|
212
|
+
# If README.md, use parent directory name
|
|
213
|
+
if file_path.stem.lower() == 'readme':
|
|
214
|
+
if relative_path.parent != Path('.'):
|
|
215
|
+
return str(relative_path.parent).replace('/', ' / ').replace('_', ' ').title()
|
|
216
|
+
return "Overview"
|
|
217
|
+
|
|
218
|
+
# Build title from path
|
|
219
|
+
parts = []
|
|
220
|
+
if relative_path.parent != Path('.'):
|
|
221
|
+
parts.append(str(relative_path.parent).replace('/', ' / ').replace('_', ' ').title())
|
|
222
|
+
|
|
223
|
+
# Add filename without extension
|
|
224
|
+
parts.append(file_path.stem.replace('_', ' ').replace('-', ' ').title())
|
|
225
|
+
|
|
226
|
+
return ' / '.join(parts)
|
|
227
|
+
|
|
228
|
+
def get_sections(self, app_path: Optional[Path] = None) -> List[DocumentationSection]:
|
|
229
|
+
"""
|
|
230
|
+
Get all documentation sections.
|
|
231
|
+
|
|
232
|
+
Returns list of sections based on mode:
|
|
233
|
+
- Directory mode: One section per .md file
|
|
234
|
+
- Single file mode: One section
|
|
235
|
+
- String content mode: One section
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
app_path: Optional path to app directory for relative path resolution
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
List of DocumentationSection objects
|
|
242
|
+
"""
|
|
243
|
+
from django_cfg.modules.django_admin.utils import MarkdownRenderer
|
|
244
|
+
|
|
245
|
+
sections = []
|
|
246
|
+
|
|
247
|
+
# Directory mode
|
|
248
|
+
if self.source_dir:
|
|
249
|
+
resolved_dir = self._resolve_path(self.source_dir, app_path)
|
|
250
|
+
if resolved_dir and resolved_dir.is_dir():
|
|
251
|
+
md_files = self._scan_markdown_files(resolved_dir)
|
|
252
|
+
|
|
253
|
+
for idx, md_file in enumerate(md_files):
|
|
254
|
+
try:
|
|
255
|
+
content = md_file.read_text(encoding='utf-8')
|
|
256
|
+
rendered = MarkdownRenderer.render_markdown(
|
|
257
|
+
content,
|
|
258
|
+
enable_plugins=self.enable_plugins
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
title = self._get_section_title(md_file, resolved_dir)
|
|
262
|
+
|
|
263
|
+
sections.append(DocumentationSection(
|
|
264
|
+
title=title,
|
|
265
|
+
content=rendered,
|
|
266
|
+
file_path=md_file,
|
|
267
|
+
default_open=(idx == 0 and self.default_open)
|
|
268
|
+
))
|
|
269
|
+
except Exception as e:
|
|
270
|
+
# Skip files that can't be read
|
|
271
|
+
continue
|
|
272
|
+
|
|
273
|
+
# Single file mode
|
|
274
|
+
elif self.source_file:
|
|
275
|
+
resolved_file = self._resolve_path(self.source_file, app_path)
|
|
276
|
+
if resolved_file and resolved_file.is_file():
|
|
277
|
+
try:
|
|
278
|
+
content = resolved_file.read_text(encoding='utf-8')
|
|
279
|
+
rendered = MarkdownRenderer.render_markdown(
|
|
280
|
+
content,
|
|
281
|
+
enable_plugins=self.enable_plugins
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
title = self._get_section_title(resolved_file, resolved_file.parent)
|
|
285
|
+
|
|
286
|
+
sections.append(DocumentationSection(
|
|
287
|
+
title=title,
|
|
288
|
+
content=rendered,
|
|
289
|
+
file_path=resolved_file,
|
|
290
|
+
default_open=self.default_open
|
|
291
|
+
))
|
|
292
|
+
except Exception:
|
|
293
|
+
pass
|
|
294
|
+
|
|
295
|
+
# String content mode
|
|
296
|
+
elif self.source_content:
|
|
297
|
+
rendered = MarkdownRenderer.render_markdown(
|
|
298
|
+
self.source_content,
|
|
299
|
+
enable_plugins=self.enable_plugins
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
sections.append(DocumentationSection(
|
|
303
|
+
title=self.title,
|
|
304
|
+
content=rendered,
|
|
305
|
+
default_open=self.default_open
|
|
306
|
+
))
|
|
307
|
+
|
|
308
|
+
# Sort sections if requested
|
|
309
|
+
if self.sort_sections and len(sections) > 1:
|
|
310
|
+
sections.sort(key=lambda s: s.title.lower())
|
|
311
|
+
|
|
312
|
+
return sections
|
|
313
|
+
|
|
314
|
+
def _discover_management_commands(self, app_path: Optional[Path] = None) -> List[Dict[str, any]]:
|
|
315
|
+
"""
|
|
316
|
+
Discover management commands in app's management/commands directory.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
app_path: Path to the app directory
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
List of command dictionaries with name, help text, and arguments
|
|
323
|
+
"""
|
|
324
|
+
commands = []
|
|
325
|
+
|
|
326
|
+
if not app_path:
|
|
327
|
+
return commands
|
|
328
|
+
|
|
329
|
+
commands_dir = app_path / "management" / "commands"
|
|
330
|
+
if not commands_dir.exists() or not commands_dir.is_dir():
|
|
331
|
+
return commands
|
|
332
|
+
|
|
333
|
+
# Find all command files
|
|
334
|
+
for cmd_file in commands_dir.glob("*.py"):
|
|
335
|
+
if cmd_file.stem.startswith('_'):
|
|
336
|
+
continue
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
# Import the command module
|
|
340
|
+
import importlib.util
|
|
341
|
+
spec = importlib.util.spec_from_file_location(
|
|
342
|
+
f"command_{cmd_file.stem}", cmd_file
|
|
343
|
+
)
|
|
344
|
+
if spec and spec.loader:
|
|
345
|
+
module = importlib.util.module_from_spec(spec)
|
|
346
|
+
spec.loader.exec_module(module)
|
|
347
|
+
|
|
348
|
+
# Get the Command class
|
|
349
|
+
if hasattr(module, 'Command'):
|
|
350
|
+
cmd_class = module.Command
|
|
351
|
+
cmd_instance = cmd_class()
|
|
352
|
+
|
|
353
|
+
# Extract command info
|
|
354
|
+
command_info = {
|
|
355
|
+
'name': cmd_file.stem,
|
|
356
|
+
'help': getattr(cmd_instance, 'help', 'No description available'),
|
|
357
|
+
'arguments': []
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
# Try to extract arguments if add_arguments method exists
|
|
361
|
+
if hasattr(cmd_instance, 'add_arguments'):
|
|
362
|
+
# Create a mock parser to extract arguments
|
|
363
|
+
import argparse
|
|
364
|
+
parser = argparse.ArgumentParser()
|
|
365
|
+
try:
|
|
366
|
+
cmd_instance.add_arguments(parser)
|
|
367
|
+
# Extract arguments from parser
|
|
368
|
+
for action in parser._actions:
|
|
369
|
+
if action.dest != 'help':
|
|
370
|
+
arg_info = {
|
|
371
|
+
'name': '/'.join(action.option_strings) if action.option_strings else action.dest,
|
|
372
|
+
'help': action.help or '',
|
|
373
|
+
'required': action.required if hasattr(action, 'required') else False,
|
|
374
|
+
'default': action.default if action.default != argparse.SUPPRESS else None
|
|
375
|
+
}
|
|
376
|
+
command_info['arguments'].append(arg_info)
|
|
377
|
+
except Exception:
|
|
378
|
+
pass
|
|
379
|
+
|
|
380
|
+
commands.append(command_info)
|
|
381
|
+
|
|
382
|
+
except Exception:
|
|
383
|
+
# Skip commands that can't be imported
|
|
384
|
+
continue
|
|
385
|
+
|
|
386
|
+
# Sort commands alphabetically
|
|
387
|
+
commands.sort(key=lambda c: c['name'])
|
|
388
|
+
|
|
389
|
+
return commands
|
|
390
|
+
|
|
391
|
+
def get_content(self, app_path: Optional[Path] = None) -> Optional[str]:
|
|
392
|
+
"""
|
|
393
|
+
Get rendered markdown content (legacy single-section method).
|
|
394
|
+
|
|
395
|
+
For multi-section support, use get_sections() instead.
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
app_path: Optional path to app directory for relative path resolution
|
|
399
|
+
|
|
400
|
+
Returns:
|
|
401
|
+
Rendered HTML string or None
|
|
402
|
+
"""
|
|
403
|
+
sections = self.get_sections(app_path)
|
|
404
|
+
if sections:
|
|
405
|
+
return sections[0].content
|
|
406
|
+
return None
|