drf-to-mkdoc 0.1.9__py3-none-any.whl → 0.2.1__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.
Potentially problematic release.
This version of drf-to-mkdoc might be problematic. Click here for more details.
- drf_to_mkdoc/conf/defaults.py +5 -0
- drf_to_mkdoc/conf/settings.py +123 -9
- drf_to_mkdoc/management/commands/build_docs.py +8 -7
- drf_to_mkdoc/management/commands/build_endpoint_docs.py +69 -0
- drf_to_mkdoc/management/commands/build_model_docs.py +50 -0
- drf_to_mkdoc/management/commands/{generate_model_docs.py → extract_model_data.py} +18 -24
- drf_to_mkdoc/static/drf-to-mkdoc/javascripts/try-out-sidebar.js +879 -0
- drf_to_mkdoc/static/drf-to-mkdoc/stylesheets/endpoints/try-out-sidebar.css +728 -0
- drf_to_mkdoc/utils/ai_tools/__init__.py +0 -0
- drf_to_mkdoc/utils/ai_tools/enums.py +13 -0
- drf_to_mkdoc/utils/ai_tools/exceptions.py +19 -0
- drf_to_mkdoc/utils/ai_tools/providers/__init__.py +0 -0
- drf_to_mkdoc/utils/ai_tools/providers/base_provider.py +123 -0
- drf_to_mkdoc/utils/ai_tools/providers/gemini_provider.py +80 -0
- drf_to_mkdoc/utils/ai_tools/types.py +81 -0
- drf_to_mkdoc/utils/commons/__init__.py +0 -0
- drf_to_mkdoc/utils/commons/code_extractor.py +22 -0
- drf_to_mkdoc/utils/commons/file_utils.py +35 -0
- drf_to_mkdoc/utils/commons/model_utils.py +83 -0
- drf_to_mkdoc/utils/commons/operation_utils.py +83 -0
- drf_to_mkdoc/utils/commons/path_utils.py +78 -0
- drf_to_mkdoc/utils/commons/schema_utils.py +230 -0
- drf_to_mkdoc/utils/endpoint_detail_generator.py +16 -35
- drf_to_mkdoc/utils/endpoint_list_generator.py +1 -1
- drf_to_mkdoc/utils/extractors/query_parameter_extractors.py +33 -30
- drf_to_mkdoc/utils/model_detail_generator.py +44 -40
- drf_to_mkdoc/utils/model_list_generator.py +25 -15
- drf_to_mkdoc/utils/schema.py +259 -0
- {drf_to_mkdoc-0.1.9.dist-info → drf_to_mkdoc-0.2.1.dist-info}/METADATA +16 -5
- {drf_to_mkdoc-0.1.9.dist-info → drf_to_mkdoc-0.2.1.dist-info}/RECORD +33 -16
- drf_to_mkdoc/management/commands/generate_docs.py +0 -138
- drf_to_mkdoc/utils/common.py +0 -353
- {drf_to_mkdoc-0.1.9.dist-info → drf_to_mkdoc-0.2.1.dist-info}/WHEEL +0 -0
- {drf_to_mkdoc-0.1.9.dist-info → drf_to_mkdoc-0.2.1.dist-info}/licenses/LICENSE +0 -0
- {drf_to_mkdoc-0.1.9.dist-info → drf_to_mkdoc-0.2.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
from drf_spectacular.generators import SchemaGenerator
|
|
7
|
+
|
|
8
|
+
from drf_to_mkdoc.conf.settings import drf_to_mkdoc_settings
|
|
9
|
+
from drf_to_mkdoc.utils.commons.file_utils import load_json_data
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SchemaValidationError(Exception):
|
|
13
|
+
"""Custom exception for schema validation errors."""
|
|
14
|
+
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class QueryParamTypeError(Exception):
|
|
19
|
+
"""Custom exception for query parameter type errors."""
|
|
20
|
+
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_schema() -> dict[str, Any] | None:
|
|
25
|
+
"""Load the OpenAPI schema from doc-schema.yaml"""
|
|
26
|
+
schema_file = Path(drf_to_mkdoc_settings.CONFIG_DIR) / "doc-schema.yaml"
|
|
27
|
+
if not schema_file.exists():
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
with schema_file.open(encoding="utf-8") as f:
|
|
31
|
+
return yaml.safe_load(f)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_custom_schema():
|
|
35
|
+
custom_schema_data = load_json_data(
|
|
36
|
+
drf_to_mkdoc_settings.CUSTOM_SCHEMA_FILE, raise_not_found=False
|
|
37
|
+
)
|
|
38
|
+
if not custom_schema_data:
|
|
39
|
+
return {}
|
|
40
|
+
|
|
41
|
+
for _operation_id, overrides in custom_schema_data.items():
|
|
42
|
+
parameters = overrides.get("parameters", [])
|
|
43
|
+
if not parameters:
|
|
44
|
+
continue
|
|
45
|
+
for parameter in parameters:
|
|
46
|
+
if {"name", "in", "description", "required", "schema"} - set(parameter.keys()):
|
|
47
|
+
raise SchemaValidationError("Required keys are not passed")
|
|
48
|
+
|
|
49
|
+
if parameter["in"] == "query":
|
|
50
|
+
queryparam_type = parameter.get("queryparam_type")
|
|
51
|
+
if not queryparam_type:
|
|
52
|
+
raise QueryParamTypeError("queryparam_type is required for query")
|
|
53
|
+
|
|
54
|
+
if queryparam_type not in (
|
|
55
|
+
{
|
|
56
|
+
"search_fields",
|
|
57
|
+
"filter_fields",
|
|
58
|
+
"ordering_fields",
|
|
59
|
+
"filter_backends",
|
|
60
|
+
"pagination_fields",
|
|
61
|
+
}
|
|
62
|
+
):
|
|
63
|
+
raise QueryParamTypeError("Invalid queryparam_type")
|
|
64
|
+
|
|
65
|
+
return custom_schema_data
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _merge_parameters(
|
|
69
|
+
base_parameters: list[dict[str, Any]], custom_parameters: list[dict[str, Any]]
|
|
70
|
+
) -> list[dict[str, Any]]:
|
|
71
|
+
"""
|
|
72
|
+
Merge parameters from base and custom schemas, avoiding duplicates.
|
|
73
|
+
|
|
74
|
+
Parameters are considered duplicates if they have the same 'name' and 'in' values.
|
|
75
|
+
Custom parameters will override base parameters with the same (name, in) key.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def _get_param_key(param: dict[str, Any]) -> tuple[str, str] | None:
|
|
79
|
+
"""Extract (name, in) tuple from parameter, return None if invalid."""
|
|
80
|
+
name = param.get("name")
|
|
81
|
+
location = param.get("in")
|
|
82
|
+
return (name, location) if name and location else None
|
|
83
|
+
|
|
84
|
+
param_index = {}
|
|
85
|
+
for param in base_parameters:
|
|
86
|
+
key = _get_param_key(param)
|
|
87
|
+
if key:
|
|
88
|
+
param_index[key] = param
|
|
89
|
+
|
|
90
|
+
for param in custom_parameters:
|
|
91
|
+
key = _get_param_key(param)
|
|
92
|
+
if key:
|
|
93
|
+
param_index[key] = param
|
|
94
|
+
|
|
95
|
+
return list(param_index.values())
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _build_operation_map(base_schema: dict) -> dict[str, tuple[str, str]]:
|
|
99
|
+
"""Build a mapping from operationId → (path, method)."""
|
|
100
|
+
op_map = {}
|
|
101
|
+
HTTP_METHODS = {"get", "post", "put", "patch", "delete", "options", "head", "trace"}
|
|
102
|
+
|
|
103
|
+
for path, actions in base_schema.get("paths", {}).items():
|
|
104
|
+
for method, op_data in actions.items():
|
|
105
|
+
if method.lower() not in HTTP_METHODS or not isinstance(op_data, dict):
|
|
106
|
+
continue
|
|
107
|
+
if not op_data.get("x-metadata"):
|
|
108
|
+
raise ValueError(
|
|
109
|
+
"Missing x-metadata in OpenAPI schema. Please ensure you're using the custom AutoSchema in your REST_FRAMEWORK settings:\n"
|
|
110
|
+
"REST_FRAMEWORK = {\n"
|
|
111
|
+
" 'DEFAULT_SCHEMA_CLASS': 'drf_to_mkdoc.utils.schema.AutoSchema',\n"
|
|
112
|
+
"}\n"
|
|
113
|
+
)
|
|
114
|
+
operation_id = op_data.get("operationId")
|
|
115
|
+
if operation_id:
|
|
116
|
+
op_map[operation_id] = (path, method)
|
|
117
|
+
|
|
118
|
+
return op_map
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _apply_custom_overrides(
|
|
122
|
+
base_schema: dict,
|
|
123
|
+
op_map: dict[str, tuple[str, str]],
|
|
124
|
+
custom_data: dict,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Apply custom overrides to the base schema."""
|
|
127
|
+
allowed_keys = {"description", "parameters", "requestBody", "responses"}
|
|
128
|
+
|
|
129
|
+
for operation_id, overrides in custom_data.items():
|
|
130
|
+
if operation_id not in op_map:
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
append_fields = set(overrides.get("append_fields", []))
|
|
134
|
+
path, method = op_map[operation_id]
|
|
135
|
+
target_schema = base_schema["paths"][path][method]
|
|
136
|
+
|
|
137
|
+
for key in allowed_keys:
|
|
138
|
+
if key not in overrides:
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
custom_value = overrides[key]
|
|
142
|
+
base_value = target_schema.get(key)
|
|
143
|
+
|
|
144
|
+
if key in append_fields:
|
|
145
|
+
if isinstance(base_value, list) and isinstance(custom_value, list):
|
|
146
|
+
if key == "parameters":
|
|
147
|
+
target_schema[key] = _merge_parameters(base_value, custom_value)
|
|
148
|
+
else:
|
|
149
|
+
target_schema[key].extend(custom_value)
|
|
150
|
+
else:
|
|
151
|
+
target_schema[key] = custom_value
|
|
152
|
+
else:
|
|
153
|
+
target_schema[key] = custom_value
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def get_schema():
|
|
157
|
+
base_schema = SchemaGenerator().get_schema(request=None, public=True)
|
|
158
|
+
custom_data = get_custom_schema()
|
|
159
|
+
if not custom_data:
|
|
160
|
+
return base_schema
|
|
161
|
+
|
|
162
|
+
operation_map = _build_operation_map(base_schema)
|
|
163
|
+
_apply_custom_overrides(base_schema, operation_map, custom_data)
|
|
164
|
+
|
|
165
|
+
return base_schema
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class OperationExtractor:
|
|
169
|
+
"""Extracts operation IDs and metadata from OpenAPI schema."""
|
|
170
|
+
|
|
171
|
+
_instance = None
|
|
172
|
+
|
|
173
|
+
def __new__(cls):
|
|
174
|
+
if cls._instance is None:
|
|
175
|
+
cls._instance = super().__new__(cls)
|
|
176
|
+
cls._instance._initialized = False
|
|
177
|
+
return cls._instance
|
|
178
|
+
|
|
179
|
+
def __init__(self):
|
|
180
|
+
if not self._initialized:
|
|
181
|
+
self.schema = get_schema()
|
|
182
|
+
self._operation_map = None
|
|
183
|
+
self._initialized = True
|
|
184
|
+
|
|
185
|
+
def save_operation_map(self) -> None:
|
|
186
|
+
"""Save operation map to file."""
|
|
187
|
+
if not self._operation_map:
|
|
188
|
+
self._operation_map = self._build_operation_map()
|
|
189
|
+
|
|
190
|
+
operation_map_path = Path(drf_to_mkdoc_settings.AI_OPERATION_MAP_FILE)
|
|
191
|
+
# Create parent directories if they don't exist
|
|
192
|
+
operation_map_path.parent.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
|
|
194
|
+
with operation_map_path.open("w", encoding="utf-8") as f:
|
|
195
|
+
json.dump(self._operation_map, f, indent=2)
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def operation_map(self) -> dict[str, dict[str, Any]] | None:
|
|
199
|
+
"""
|
|
200
|
+
Cache and return operation ID mapping.
|
|
201
|
+
Returns dict: operation_id -> {"path": str, ...metadata}
|
|
202
|
+
"""
|
|
203
|
+
if self._operation_map is None:
|
|
204
|
+
# Try to load from file first
|
|
205
|
+
self._operation_map = load_json_data(
|
|
206
|
+
drf_to_mkdoc_settings.AI_OPERATION_MAP_FILE, raise_not_found=False
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# If not found or invalid, build and save
|
|
210
|
+
if self._operation_map is None:
|
|
211
|
+
self._operation_map = self._build_operation_map()
|
|
212
|
+
self.save_operation_map()
|
|
213
|
+
|
|
214
|
+
return self._operation_map
|
|
215
|
+
|
|
216
|
+
def _build_operation_map(self) -> dict[str, dict[str, Any]] | None:
|
|
217
|
+
"""Build mapping of operation IDs to paths and metadata."""
|
|
218
|
+
mapping = {}
|
|
219
|
+
paths = self.schema.get("paths", {})
|
|
220
|
+
|
|
221
|
+
for path, methods in paths.items():
|
|
222
|
+
for _method, operation in methods.items():
|
|
223
|
+
operation_id = operation.get("operationId")
|
|
224
|
+
if not operation_id:
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
metadata = operation.get("x-metadata", {})
|
|
228
|
+
mapping[operation_id] = {"path": path, **metadata}
|
|
229
|
+
|
|
230
|
+
return mapping
|
|
@@ -10,14 +10,14 @@ from django.templatetags.static import static
|
|
|
10
10
|
from rest_framework import serializers
|
|
11
11
|
|
|
12
12
|
from drf_to_mkdoc.conf.settings import drf_to_mkdoc_settings
|
|
13
|
-
from drf_to_mkdoc.utils.
|
|
14
|
-
|
|
13
|
+
from drf_to_mkdoc.utils.commons.file_utils import write_file
|
|
14
|
+
from drf_to_mkdoc.utils.commons.operation_utils import (
|
|
15
15
|
extract_app_from_operation_id,
|
|
16
16
|
extract_viewset_name_from_operation_id,
|
|
17
17
|
format_method_badge,
|
|
18
|
-
get_custom_schema,
|
|
19
|
-
write_file,
|
|
20
18
|
)
|
|
19
|
+
from drf_to_mkdoc.utils.commons.path_utils import create_safe_filename
|
|
20
|
+
from drf_to_mkdoc.utils.commons.schema_utils import get_custom_schema
|
|
21
21
|
from drf_to_mkdoc.utils.extractors.query_parameter_extractors import (
|
|
22
22
|
extract_query_parameters_from_view,
|
|
23
23
|
)
|
|
@@ -32,17 +32,17 @@ def analyze_serializer_method_field_schema(serializer_class, field_name: str) ->
|
|
|
32
32
|
"""Analyze a SerializerMethodField to determine its actual return type schema."""
|
|
33
33
|
method_name = f"get_{field_name}"
|
|
34
34
|
|
|
35
|
-
# Strategy
|
|
35
|
+
# Strategy 1: Check type annotations
|
|
36
36
|
schema_from_annotations = _extract_schema_from_type_hints(serializer_class, method_name)
|
|
37
37
|
if schema_from_annotations:
|
|
38
38
|
return schema_from_annotations
|
|
39
39
|
|
|
40
|
-
# Strategy
|
|
40
|
+
# Strategy 2: Analyze method source code
|
|
41
41
|
schema_from_source = _analyze_method_source_code(serializer_class, method_name)
|
|
42
42
|
if schema_from_source:
|
|
43
43
|
return schema_from_source
|
|
44
44
|
|
|
45
|
-
# Strategy
|
|
45
|
+
# Strategy 3: Runtime analysis (sample execution)
|
|
46
46
|
schema_from_runtime = _analyze_method_runtime(serializer_class, method_name)
|
|
47
47
|
if schema_from_runtime:
|
|
48
48
|
return schema_from_runtime
|
|
@@ -51,33 +51,6 @@ def analyze_serializer_method_field_schema(serializer_class, field_name: str) ->
|
|
|
51
51
|
return {"type": "string"}
|
|
52
52
|
|
|
53
53
|
|
|
54
|
-
def _extract_schema_from_decorator(serializer_class, method_name: str) -> dict:
|
|
55
|
-
"""Extract schema from @extend_schema_field decorator if present."""
|
|
56
|
-
try:
|
|
57
|
-
method = getattr(serializer_class, method_name, None)
|
|
58
|
-
if not method:
|
|
59
|
-
return {}
|
|
60
|
-
|
|
61
|
-
# Check if method has the decorator attribute (drf-spectacular)
|
|
62
|
-
if hasattr(method, "_spectacular_annotation"):
|
|
63
|
-
annotation = method._spectacular_annotation
|
|
64
|
-
# Handle OpenApiTypes
|
|
65
|
-
if hasattr(annotation, "type"):
|
|
66
|
-
return {"type": annotation.type}
|
|
67
|
-
if isinstance(annotation, dict):
|
|
68
|
-
return annotation
|
|
69
|
-
|
|
70
|
-
# Check for drf-yasg decorator
|
|
71
|
-
if hasattr(method, "_swagger_serializer_method"):
|
|
72
|
-
swagger_info = method._swagger_serializer_method
|
|
73
|
-
if hasattr(swagger_info, "many") and hasattr(swagger_info, "child"):
|
|
74
|
-
return {"type": "array", "items": {"type": "object"}}
|
|
75
|
-
|
|
76
|
-
except Exception:
|
|
77
|
-
logger.exception("Failed to extract schema from decorator")
|
|
78
|
-
return {}
|
|
79
|
-
|
|
80
|
-
|
|
81
54
|
def _extract_schema_from_type_hints(serializer_class, method_name: str) -> dict:
|
|
82
55
|
"""Extract schema from method type annotations."""
|
|
83
56
|
try:
|
|
@@ -572,14 +545,22 @@ def _create_endpoint_header(
|
|
|
572
545
|
"stylesheets/endpoints/animations.css",
|
|
573
546
|
"stylesheets/endpoints/accessibility.css",
|
|
574
547
|
"stylesheets/endpoints/loading.css",
|
|
548
|
+
"stylesheets/endpoints/try-out-sidebar.css",
|
|
549
|
+
]
|
|
550
|
+
scripts = [
|
|
551
|
+
"javascripts/try-out-sidebar.js",
|
|
575
552
|
]
|
|
576
553
|
prefix_path = f"{drf_to_mkdoc_settings.PROJECT_NAME}/"
|
|
577
554
|
css_links = "\n".join(
|
|
578
555
|
f'<link rel="stylesheet" href="{static(prefix_path + path)}">' for path in stylesheets
|
|
579
556
|
)
|
|
557
|
+
js_scripts = "\n".join(
|
|
558
|
+
f'<script src="{static(prefix_path + path)}" defer></script>' for path in scripts
|
|
559
|
+
)
|
|
580
560
|
content = f"""
|
|
581
|
-
<!-- inject CSS directly -->
|
|
561
|
+
<!-- inject CSS and JS directly -->
|
|
582
562
|
{css_links}
|
|
563
|
+
{js_scripts}
|
|
583
564
|
"""
|
|
584
565
|
content += f"# {method.upper()} {path}\n\n"
|
|
585
566
|
content += f"{format_method_badge(method)} `{path}`\n\n"
|
|
@@ -4,7 +4,7 @@ from typing import Any
|
|
|
4
4
|
from django.templatetags.static import static
|
|
5
5
|
|
|
6
6
|
from drf_to_mkdoc.conf.settings import drf_to_mkdoc_settings
|
|
7
|
-
from drf_to_mkdoc.utils.
|
|
7
|
+
from drf_to_mkdoc.utils.commons.operation_utils import extract_viewset_from_operation_id
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class EndpointsIndexGenerator:
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
from typing import Any
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
from drf_to_mkdoc.utils.common import extract_viewset_from_operation_id
|
|
3
|
+
from drf_to_mkdoc.utils.commons.operation_utils import extract_viewset_from_operation_id
|
|
6
4
|
|
|
7
5
|
|
|
8
6
|
def extract_query_parameters_from_view(operation_id: str) -> dict[str, Any]:
|
|
@@ -111,35 +109,34 @@ def extract_query_parameters_from_view_pagination_fields(view_class: Any) -> lis
|
|
|
111
109
|
|
|
112
110
|
|
|
113
111
|
def _extract_filterset_fields_from_class_attributes(filterset_class: Any) -> list[str]:
|
|
114
|
-
fields = []
|
|
115
|
-
|
|
116
112
|
try:
|
|
117
|
-
#
|
|
118
|
-
for attr_name in dir(filterset_class):
|
|
119
|
-
# Skip private attributes and known non-filter attributes
|
|
120
|
-
if attr_name.startswith("_") or attr_name in [
|
|
121
|
-
"Meta",
|
|
122
|
-
"form",
|
|
123
|
-
"queryset",
|
|
124
|
-
"request",
|
|
125
|
-
"errors",
|
|
126
|
-
"qs",
|
|
127
|
-
"is_valid",
|
|
128
|
-
]:
|
|
129
|
-
continue
|
|
130
|
-
|
|
131
|
-
try:
|
|
132
|
-
attr = getattr(filterset_class, attr_name)
|
|
133
|
-
if isinstance(attr, django_filters.Filter):
|
|
134
|
-
if attr_name not in fields:
|
|
135
|
-
fields.append(attr_name)
|
|
136
|
-
except (AttributeError, TypeError):
|
|
137
|
-
continue
|
|
138
|
-
|
|
113
|
+
import django_filters # noqa: PLC0415
|
|
139
114
|
except ImportError:
|
|
140
115
|
# django_filters not available, skip this strategy
|
|
141
|
-
|
|
116
|
+
return []
|
|
142
117
|
|
|
118
|
+
fields = []
|
|
119
|
+
# Get all class attributes, including inherited ones
|
|
120
|
+
for attr_name in dir(filterset_class):
|
|
121
|
+
# Skip private attributes and known non-filter attributes
|
|
122
|
+
if attr_name.startswith("_") or attr_name in [
|
|
123
|
+
"Meta",
|
|
124
|
+
"form",
|
|
125
|
+
"queryset",
|
|
126
|
+
"request",
|
|
127
|
+
"errors",
|
|
128
|
+
"qs",
|
|
129
|
+
"is_valid",
|
|
130
|
+
]:
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
attr = getattr(filterset_class, attr_name)
|
|
135
|
+
if isinstance(attr, django_filters.Filter):
|
|
136
|
+
if attr_name not in fields:
|
|
137
|
+
fields.append(attr_name)
|
|
138
|
+
except (AttributeError, TypeError):
|
|
139
|
+
continue
|
|
143
140
|
return fields
|
|
144
141
|
|
|
145
142
|
|
|
@@ -182,7 +179,8 @@ def _extract_filterset_fields_from_internal_attrs(filterset_class: Any) -> list[
|
|
|
182
179
|
|
|
183
180
|
|
|
184
181
|
def _extract_filterset_fields_from_get_fields(filterset_class: Any) -> list[str]:
|
|
185
|
-
|
|
182
|
+
meta = getattr(filterset_class, "_meta", None)
|
|
183
|
+
if not getattr(meta, "model", None):
|
|
186
184
|
# If the Meta class is not defined in the Filter class,
|
|
187
185
|
# the get_fields function is raise error
|
|
188
186
|
return []
|
|
@@ -191,7 +189,12 @@ def _extract_filterset_fields_from_get_fields(filterset_class: Any) -> list[str]
|
|
|
191
189
|
if not hasattr(filterset_class, "get_fields"):
|
|
192
190
|
return []
|
|
193
191
|
|
|
194
|
-
|
|
192
|
+
try:
|
|
193
|
+
filterset_instance = filterset_class()
|
|
194
|
+
except TypeError:
|
|
195
|
+
# Constructor requires args; skip dynamic field discovery
|
|
196
|
+
return []
|
|
197
|
+
|
|
195
198
|
filterset_fields = filterset_instance.get_fields()
|
|
196
199
|
if not (filterset_fields and hasattr(filterset_fields, "keys")):
|
|
197
200
|
return []
|
|
@@ -3,24 +3,31 @@ from typing import Any
|
|
|
3
3
|
from django.templatetags.static import static
|
|
4
4
|
|
|
5
5
|
from drf_to_mkdoc.conf.settings import drf_to_mkdoc_settings
|
|
6
|
-
from drf_to_mkdoc.utils.
|
|
6
|
+
from drf_to_mkdoc.utils.commons.file_utils import write_file
|
|
7
|
+
from drf_to_mkdoc.utils.commons.model_utils import get_model_description
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
def generate_model_docs(models_data: dict[str, Any]) -> None:
|
|
10
11
|
"""Generate model documentation from JSON data"""
|
|
11
|
-
for
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
for app_name, models in models_data.items():
|
|
13
|
+
if not isinstance(models, dict):
|
|
14
|
+
raise TypeError(f"Expected dict for models in app '{app_name}', got {type(models)}")
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
for model_name, model_info in models.items():
|
|
17
|
+
if not isinstance(model_info, dict) or "name" not in model_info:
|
|
18
|
+
raise ValueError(
|
|
19
|
+
f"Model info for '{model_name}' in app '{app_name}' is invalid"
|
|
20
|
+
)
|
|
17
21
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
write_file(file_path, content)
|
|
22
|
+
# Create the model page content
|
|
23
|
+
content = create_model_page(model_info)
|
|
21
24
|
|
|
25
|
+
# Write the file in app subdirectory
|
|
26
|
+
file_path = f"models/{app_name}/{model_info['table_name']}.md"
|
|
27
|
+
write_file(file_path, content)
|
|
22
28
|
|
|
23
|
-
|
|
29
|
+
|
|
30
|
+
def render_column_fields_table(fields: dict[str, Any]) -> str:
|
|
24
31
|
"""Render the fields table for a model."""
|
|
25
32
|
content = "## Fields\n\n"
|
|
26
33
|
content += "| Field | Type | Description | Extra |\n"
|
|
@@ -31,6 +38,10 @@ def render_fields_table(fields: dict[str, Any]) -> str:
|
|
|
31
38
|
verbose_name = field_info.get("verbose_name", field_name)
|
|
32
39
|
help_text = field_info.get("help_text", "")
|
|
33
40
|
|
|
41
|
+
display_name = field_name
|
|
42
|
+
if field_type in ["ForeignKey", "OneToOneField"]:
|
|
43
|
+
display_name = f"{field_name}_id"
|
|
44
|
+
|
|
34
45
|
extra_info = []
|
|
35
46
|
if field_info.get("null"):
|
|
36
47
|
extra_info.append("null=True")
|
|
@@ -51,7 +62,7 @@ def render_fields_table(fields: dict[str, Any]) -> str:
|
|
|
51
62
|
extra_str = ", ".join(extra_info) if extra_info else ""
|
|
52
63
|
description_str = help_text or verbose_name
|
|
53
64
|
|
|
54
|
-
content += f"| `{
|
|
65
|
+
content += f"| `{display_name}` | {field_type} | {description_str} | {extra_str} |\n"
|
|
55
66
|
|
|
56
67
|
return content
|
|
57
68
|
|
|
@@ -121,35 +132,29 @@ def _create_model_header(name: str, app_label: str, table_name: str, description
|
|
|
121
132
|
|
|
122
133
|
def _add_fields_section(model_info: dict[str, Any]) -> str:
|
|
123
134
|
"""Add the fields section to the model documentation."""
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
name: info
|
|
127
|
-
for name, info in fields.items()
|
|
128
|
-
if info.get("type", "") not in ["ForeignKey", "OneToOneField", "ManyToManyField"]
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
if not non_relationship_fields:
|
|
135
|
+
column_fields = model_info.get("column_fields", {})
|
|
136
|
+
if not column_fields:
|
|
132
137
|
return ""
|
|
133
138
|
|
|
134
|
-
content =
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
139
|
+
content = ""
|
|
140
|
+
|
|
141
|
+
column_fields_content = render_column_fields_table(column_fields)
|
|
142
|
+
if column_fields_content:
|
|
143
|
+
content += column_fields_content
|
|
144
|
+
content += "\n"
|
|
145
|
+
|
|
146
|
+
choices_content = render_choices_tables(column_fields)
|
|
147
|
+
if choices_content:
|
|
148
|
+
content += choices_content
|
|
149
|
+
content += "\n"
|
|
150
|
+
|
|
138
151
|
return content
|
|
139
152
|
|
|
140
153
|
|
|
141
154
|
def _add_relationships_section(model_info: dict[str, Any]) -> str:
|
|
142
155
|
"""Add the relationships section to the model documentation."""
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
relationship_fields = {
|
|
147
|
-
name: info
|
|
148
|
-
for name, info in fields.items()
|
|
149
|
-
if info.get("type", "") in ["ForeignKey", "OneToOneField", "ManyToManyField"]
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if not (relationships or relationship_fields):
|
|
156
|
+
relationship_fields = model_info.get("relationships", {})
|
|
157
|
+
if not relationship_fields:
|
|
153
158
|
return ""
|
|
154
159
|
|
|
155
160
|
content = "## Relationships\n\n"
|
|
@@ -157,7 +162,7 @@ def _add_relationships_section(model_info: dict[str, Any]) -> str:
|
|
|
157
162
|
content += "|-------|------|---------------|\n"
|
|
158
163
|
|
|
159
164
|
content += _render_relationship_fields(relationship_fields)
|
|
160
|
-
content += _render_relationships_from_section(
|
|
165
|
+
content += _render_relationships_from_section(relationship_fields)
|
|
161
166
|
content += "\n"
|
|
162
167
|
|
|
163
168
|
return content
|
|
@@ -183,13 +188,12 @@ def _render_relationships_from_section(relationships: dict[str, Any]) -> str:
|
|
|
183
188
|
content = ""
|
|
184
189
|
for rel_name, rel_info in relationships.items():
|
|
185
190
|
rel_type = rel_info.get("type", "Unknown")
|
|
186
|
-
related_model_full = rel_info.get("related_model", "")
|
|
187
191
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
192
|
+
app_label = rel_info["app_label"]
|
|
193
|
+
table_name = rel_info["table_name"]
|
|
194
|
+
verbose_name = rel_info["verbose_name"]
|
|
195
|
+
|
|
196
|
+
model_link = f"[{verbose_name.capitalize()}](../../{app_label}/{table_name}/)"
|
|
193
197
|
|
|
194
198
|
content += f"| `{rel_name}` | {rel_type} | {model_link} | \n"
|
|
195
199
|
|
|
@@ -1,22 +1,16 @@
|
|
|
1
|
+
from html import escape
|
|
1
2
|
from pathlib import Path
|
|
2
3
|
from typing import Any
|
|
4
|
+
from urllib.parse import quote
|
|
3
5
|
|
|
4
6
|
from django.templatetags.static import static
|
|
5
7
|
|
|
6
8
|
from drf_to_mkdoc.conf.settings import drf_to_mkdoc_settings
|
|
7
|
-
from drf_to_mkdoc.utils.
|
|
9
|
+
from drf_to_mkdoc.utils.commons.model_utils import get_app_descriptions
|
|
8
10
|
|
|
9
11
|
|
|
10
12
|
def create_models_index(models_data: dict[str, Any], docs_dir: Path) -> None:
|
|
11
13
|
"""Create the main models index page that lists all models organized by app."""
|
|
12
|
-
models_by_app = {}
|
|
13
|
-
for model_name, model_info in models_data.items():
|
|
14
|
-
app_name = model_info.get("app_label", model_name.split(".")[0])
|
|
15
|
-
class_name = model_info.get("name", model_name.split(".")[-1])
|
|
16
|
-
if app_name not in models_by_app:
|
|
17
|
-
models_by_app[app_name] = []
|
|
18
|
-
models_by_app[app_name].append((class_name, model_name, model_info))
|
|
19
|
-
|
|
20
14
|
stylesheets = [
|
|
21
15
|
"stylesheets/models/variables.css",
|
|
22
16
|
"stylesheets/models/base.css",
|
|
@@ -40,17 +34,33 @@ This section contains documentation for all Django models in the system, organiz
|
|
|
40
34
|
|
|
41
35
|
app_descriptions = get_app_descriptions()
|
|
42
36
|
|
|
43
|
-
for app_name in sorted(
|
|
44
|
-
app_desc =
|
|
45
|
-
|
|
37
|
+
for app_name, models in sorted(models_data.items()):
|
|
38
|
+
app_desc = escape(
|
|
39
|
+
app_descriptions.get(
|
|
40
|
+
app_name, f"{app_name.replace('_', ' ').title()} application models"
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
app_title = escape(app_name.replace("_", " ").title())
|
|
44
|
+
content += f'<div class="app-header">{app_title} App</div>\n'
|
|
46
45
|
content += f'<div class="app-description">{app_desc}</div>\n\n'
|
|
47
46
|
|
|
48
47
|
content += '<div class="model-cards">\n'
|
|
49
48
|
|
|
50
|
-
|
|
49
|
+
model_names = sorted(
|
|
50
|
+
[
|
|
51
|
+
(
|
|
52
|
+
str(mi.get("verbose_name") or mk),
|
|
53
|
+
str(mi.get("table_name") or mk),
|
|
54
|
+
)
|
|
55
|
+
for mk, mi in models.items()
|
|
56
|
+
if isinstance(mi, dict)
|
|
57
|
+
],
|
|
58
|
+
key=lambda x: x[0].casefold(),
|
|
59
|
+
)
|
|
60
|
+
for verbose_name, table_name in model_names:
|
|
51
61
|
content += f"""
|
|
52
|
-
<a href="{app_name}/{
|
|
53
|
-
class="model-card">{
|
|
62
|
+
<a href="{quote(app_name, safe="")}/{quote(table_name, safe="")}/"
|
|
63
|
+
class="model-card">{escape(verbose_name)}</a>\n
|
|
54
64
|
"""
|
|
55
65
|
|
|
56
66
|
content += "</div>\n\n"
|