drf-to-mkdoc 0.1.0__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/__init__.py +7 -0
- drf_to_mkdoc/apps.py +15 -0
- drf_to_mkdoc/conf/__init__.py +0 -0
- drf_to_mkdoc/conf/defaults.py +11 -0
- drf_to_mkdoc/conf/settings.py +44 -0
- drf_to_mkdoc/management/__init__.py +0 -0
- drf_to_mkdoc/management/commands/__init__.py +0 -0
- drf_to_mkdoc/management/commands/build_docs.py +76 -0
- drf_to_mkdoc/management/commands/generate_doc_json.py +512 -0
- drf_to_mkdoc/management/commands/generate_docs.py +138 -0
- drf_to_mkdoc/management/commands/generate_model_docs.py +327 -0
- drf_to_mkdoc/management/commands/update_doc_schema.py +53 -0
- drf_to_mkdoc/utils/__init__.py +3 -0
- drf_to_mkdoc/utils/common.py +292 -0
- drf_to_mkdoc/utils/endpoint_generator.py +945 -0
- drf_to_mkdoc/utils/extractors/__init__.py +3 -0
- drf_to_mkdoc/utils/extractors/query_parameter_extractors.py +229 -0
- drf_to_mkdoc/utils/md_generators/__init__.py +0 -0
- drf_to_mkdoc/utils/md_generators/query_parameters_generators.py +72 -0
- drf_to_mkdoc/utils/model_generator.py +269 -0
- drf_to_mkdoc-0.1.0.dist-info/METADATA +247 -0
- drf_to_mkdoc-0.1.0.dist-info/RECORD +25 -0
- drf_to_mkdoc-0.1.0.dist-info/WHEEL +5 -0
- drf_to_mkdoc-0.1.0.dist-info/licenses/LICENSE +21 -0
- drf_to_mkdoc-0.1.0.dist-info/top_level.txt +1 -0
drf_to_mkdoc/__init__.py
ADDED
drf_to_mkdoc/apps.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DrfToMkdocConfig(AppConfig):
|
|
5
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
6
|
+
name = "drf_to_mkdoc"
|
|
7
|
+
verbose_name = "DRF to MkDocs Documentation Generator"
|
|
8
|
+
|
|
9
|
+
def ready(self):
|
|
10
|
+
"""Initialize the app when Django starts."""
|
|
11
|
+
# Import management commands to register them
|
|
12
|
+
try:
|
|
13
|
+
import drf_to_mkdoc.management.commands # noqa
|
|
14
|
+
except ImportError:
|
|
15
|
+
pass
|
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
DEFAULTS = {
|
|
2
|
+
# Path configurations with defaults
|
|
3
|
+
"DOCS_DIR": "docs", # Directory where docs will be generated
|
|
4
|
+
"CONFIG_DIR": "docs/configs", # Directory for configuration files
|
|
5
|
+
"MODEL_DOCS_FILE": "docs/model-docs.json", # Path to model documentation JSON file
|
|
6
|
+
"DOC_CONFIG_FILE": "docs/configs/doc_config.json", # Path to documentation configuration file
|
|
7
|
+
"CUSTOM_SCHEMA_FILE": "docs/configs/custom_schema.json", # Path to custom schema file
|
|
8
|
+
|
|
9
|
+
# Django apps - required, no default
|
|
10
|
+
"DJANGO_APPS": None, # List of Django app names to process
|
|
11
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
from drf_to_mkdoc.conf.defaults import DEFAULTS
|
|
3
|
+
|
|
4
|
+
class DRFToMkDocSettings:
|
|
5
|
+
required_settings = ["DJANGO_APPS"]
|
|
6
|
+
|
|
7
|
+
def __init__(self, user_settings_key="DRF_TO_MKDOC", defaults=None):
|
|
8
|
+
self.user_settings_key = user_settings_key
|
|
9
|
+
self._user_settings = getattr(settings, user_settings_key, {})
|
|
10
|
+
self.defaults = defaults or {}
|
|
11
|
+
|
|
12
|
+
def get(self, key):
|
|
13
|
+
if key not in self.defaults:
|
|
14
|
+
raise AttributeError(f"Invalid DRF_TO_MKDOC setting: '{key}'")
|
|
15
|
+
|
|
16
|
+
value = self._user_settings.get(key, self.defaults[key])
|
|
17
|
+
|
|
18
|
+
if value is None and key in self.required_settings:
|
|
19
|
+
raise ValueError(
|
|
20
|
+
f"DRF_TO_MKDOC setting '{key}' is required but not configured. "
|
|
21
|
+
f"Please add it to your Django settings under {self.user_settings_key}."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
def __getattr__(self, key):
|
|
27
|
+
return self.get(key)
|
|
28
|
+
|
|
29
|
+
def validate_required_settings(self):
|
|
30
|
+
missing_settings = []
|
|
31
|
+
|
|
32
|
+
for setting in self.required_settings:
|
|
33
|
+
try:
|
|
34
|
+
self.get(setting)
|
|
35
|
+
except ValueError:
|
|
36
|
+
missing_settings.append(setting)
|
|
37
|
+
|
|
38
|
+
if missing_settings:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
f"Missing required settings: {', '.join(missing_settings)}. "
|
|
41
|
+
f"Please configure these in your Django settings under {self.user_settings_key}."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
drf_to_mkdoc_settings = DRFToMkDocSettings(defaults=DEFAULTS)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
6
|
+
from django.apps import apps
|
|
7
|
+
from drf_to_mkdoc.conf.settings import drf_to_mkdoc_settings
|
|
8
|
+
from django.core.management import call_command
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Command(BaseCommand):
|
|
12
|
+
help = "Build MkDocs documentation"
|
|
13
|
+
|
|
14
|
+
def handle(self, *args, **options):
|
|
15
|
+
drf_to_mkdoc_settings.validate_required_settings()
|
|
16
|
+
self.stdout.write(self.style.SUCCESS("✅ DRF_TO_MKDOC settings validated."))
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
apps.check_apps_ready()
|
|
20
|
+
except Exception as e:
|
|
21
|
+
raise CommandError(f"Django apps not properly configured: {e}")
|
|
22
|
+
|
|
23
|
+
base_dir = Path(settings.BASE_DIR)
|
|
24
|
+
site_dir = base_dir / "site"
|
|
25
|
+
mkdocs_config = base_dir / "mkdocs.yml"
|
|
26
|
+
mkdocs_config_alt = base_dir / "mkdocs.yaml"
|
|
27
|
+
|
|
28
|
+
if not mkdocs_config.exists() and not mkdocs_config_alt.exists():
|
|
29
|
+
raise CommandError(
|
|
30
|
+
"MkDocs configuration file not found. Please create either 'mkdocs.yml' or 'mkdocs.yaml' "
|
|
31
|
+
"in your project root directory."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
# Generate the model documentation JSON first
|
|
36
|
+
self.stdout.write("Generating model documentation...")
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
call_command(
|
|
40
|
+
"generate_model_docs", "--pretty"
|
|
41
|
+
)
|
|
42
|
+
self.stdout.write(self.style.SUCCESS("Model documentation generated."))
|
|
43
|
+
except Exception as e:
|
|
44
|
+
self.stdout.write(self.style.WARNING(f"Failed to generate model docs: {e}"))
|
|
45
|
+
|
|
46
|
+
# Generate the documentation content
|
|
47
|
+
self.stdout.write("Generating documentation content...")
|
|
48
|
+
try:
|
|
49
|
+
call_command("generate_docs")
|
|
50
|
+
self.stdout.write(self.style.SUCCESS("Documentation content generated."))
|
|
51
|
+
except Exception as e:
|
|
52
|
+
self.stdout.write(self.style.ERROR(f"Failed to generate docs: {e}"))
|
|
53
|
+
raise
|
|
54
|
+
|
|
55
|
+
# Build the MkDocs site
|
|
56
|
+
self.stdout.write("Building MkDocs site...")
|
|
57
|
+
result = subprocess.run(
|
|
58
|
+
["mkdocs", "build", "--clean"],
|
|
59
|
+
check=False,
|
|
60
|
+
cwd=base_dir,
|
|
61
|
+
capture_output=True,
|
|
62
|
+
text=True,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if result.returncode != 0:
|
|
66
|
+
raise CommandError(f"MkDocs build failed: {result.stderr}")
|
|
67
|
+
|
|
68
|
+
self.stdout.write(self.style.SUCCESS("Documentation built successfully!"))
|
|
69
|
+
self.stdout.write(f"Site built in: {site_dir}")
|
|
70
|
+
|
|
71
|
+
except FileNotFoundError as e:
|
|
72
|
+
raise CommandError(
|
|
73
|
+
"MkDocs not found. Please install it with: pip install mkdocs mkdocs-material"
|
|
74
|
+
) from e
|
|
75
|
+
except Exception as e:
|
|
76
|
+
raise CommandError(f"Error building documentation: {e!s}") from e
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
from django.conf import settings
|
|
8
|
+
from django.core.management.base import BaseCommand
|
|
9
|
+
from django.urls import get_resolver
|
|
10
|
+
from django.urls.resolvers import URLPattern, URLResolver
|
|
11
|
+
from drf_spectacular.generators import SchemaGenerator
|
|
12
|
+
from drf_spectacular.views import SpectacularAPIView
|
|
13
|
+
from rest_framework.viewsets import ViewSetMixin
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_view_class(view):
|
|
17
|
+
"""Extract the actual view class from a view callback."""
|
|
18
|
+
# Check if it's a class-based view (Django CBV)
|
|
19
|
+
if hasattr(view, "view_class"):
|
|
20
|
+
return view.view_class
|
|
21
|
+
|
|
22
|
+
# Check if it's a ViewSet (DRF)
|
|
23
|
+
if hasattr(view, "cls"):
|
|
24
|
+
return view.cls
|
|
25
|
+
|
|
26
|
+
# Check if it's a regular class-based view
|
|
27
|
+
if hasattr(view, "__self__") and isinstance(view.__self__, type):
|
|
28
|
+
return view.__self__
|
|
29
|
+
|
|
30
|
+
# For function-based views, return the function itself
|
|
31
|
+
return view
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_url_view_mapping(urlconf=None):
|
|
35
|
+
"""
|
|
36
|
+
Generate a mapping from URL regex patterns to view classes/functions.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
urlconf: URL configuration module (defaults to ROOT_URLCONF)
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
dict: Mapping of URL regex patterns to view classes
|
|
43
|
+
"""
|
|
44
|
+
resolver = get_resolver(urlconf)
|
|
45
|
+
url_mapping = {}
|
|
46
|
+
|
|
47
|
+
def extract_views(url_patterns, prefix=""):
|
|
48
|
+
for pattern in url_patterns:
|
|
49
|
+
if isinstance(pattern, URLResolver):
|
|
50
|
+
# Handle included URL patterns (like include())
|
|
51
|
+
new_prefix = prefix + pattern.pattern.regex.pattern.rstrip("$").rstrip("^")
|
|
52
|
+
extract_views(pattern.url_patterns, new_prefix)
|
|
53
|
+
elif isinstance(pattern, URLPattern):
|
|
54
|
+
# Handle individual URL patterns
|
|
55
|
+
full_pattern = prefix + pattern.pattern.regex.pattern
|
|
56
|
+
view = pattern.callback
|
|
57
|
+
|
|
58
|
+
# Get the actual view class
|
|
59
|
+
view_class = get_view_class(view)
|
|
60
|
+
url_mapping[full_pattern] = view_class
|
|
61
|
+
|
|
62
|
+
extract_views(resolver.url_patterns)
|
|
63
|
+
return url_mapping
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def convert_regex_to_openapi_path(regex_pattern):
|
|
67
|
+
"""Convert Django URL regex pattern to OpenAPI path format."""
|
|
68
|
+
# Remove start/end anchors
|
|
69
|
+
path = regex_pattern.strip("^$")
|
|
70
|
+
|
|
71
|
+
# Remove any remaining ^ and $ anchors throughout the pattern
|
|
72
|
+
path = path.replace("^", "").replace("$", "")
|
|
73
|
+
|
|
74
|
+
# Remove \Z end-of-string anchors
|
|
75
|
+
path = path.replace("\\Z", "")
|
|
76
|
+
|
|
77
|
+
# Convert named groups to OpenAPI parameters
|
|
78
|
+
# Pattern like (?P<clinic_id>[^/]+) becomes {clinic_id}
|
|
79
|
+
path = re.sub(r"\(\?P<([^>]+)>[^)]+\)", r"{\1}", path)
|
|
80
|
+
|
|
81
|
+
# Convert simple groups to generic parameters
|
|
82
|
+
path = re.sub(r"\([^)]+\)", r"{param}", path)
|
|
83
|
+
|
|
84
|
+
# Clean up any remaining regex artifacts
|
|
85
|
+
path = path.replace("\\", "")
|
|
86
|
+
|
|
87
|
+
# Fix multiple slashes
|
|
88
|
+
path = re.sub(r"/+", "/", path)
|
|
89
|
+
|
|
90
|
+
# Handle escaped hyphens
|
|
91
|
+
path = path.replace("\\-", "-")
|
|
92
|
+
|
|
93
|
+
# Remove any remaining regex quantifiers and special characters
|
|
94
|
+
path = re.sub(r"[+*?]", "", path)
|
|
95
|
+
|
|
96
|
+
# Ensure it starts with /
|
|
97
|
+
if not path.startswith("/"):
|
|
98
|
+
path = "/" + path
|
|
99
|
+
|
|
100
|
+
# Ensure it ends with / for consistency with OpenAPI schema
|
|
101
|
+
if not path.endswith("/"):
|
|
102
|
+
path += "/"
|
|
103
|
+
|
|
104
|
+
return path
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def extract_model_references_from_serializer(serializer_class):
|
|
108
|
+
"""Extract model references from a serializer class."""
|
|
109
|
+
models = set()
|
|
110
|
+
|
|
111
|
+
# Check if serializer has a Meta class with model
|
|
112
|
+
if hasattr(serializer_class, "Meta") and hasattr(serializer_class.Meta, "model"):
|
|
113
|
+
models.add(serializer_class.Meta.model)
|
|
114
|
+
|
|
115
|
+
# Check fields for related serializers that might have models
|
|
116
|
+
if hasattr(serializer_class, "_declared_fields"):
|
|
117
|
+
for field in serializer_class._declared_fields.values():
|
|
118
|
+
if (
|
|
119
|
+
hasattr(field, "child")
|
|
120
|
+
and hasattr(field.child, "Meta")
|
|
121
|
+
and hasattr(field.child.Meta, "model")
|
|
122
|
+
):
|
|
123
|
+
models.add(field.child.Meta.model)
|
|
124
|
+
|
|
125
|
+
return models
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def extract_model_references_from_view(view_class):
|
|
129
|
+
"""Extract model references from a view class."""
|
|
130
|
+
models = set()
|
|
131
|
+
|
|
132
|
+
# Check if view has a model or queryset
|
|
133
|
+
if hasattr(view_class, "model") and view_class.model:
|
|
134
|
+
models.add(view_class.model)
|
|
135
|
+
elif hasattr(view_class, "queryset") and view_class.queryset is not None:
|
|
136
|
+
models.add(view_class.queryset.model)
|
|
137
|
+
|
|
138
|
+
# Check serializer_class
|
|
139
|
+
if hasattr(view_class, "serializer_class"):
|
|
140
|
+
models.update(extract_model_references_from_serializer(view_class.serializer_class))
|
|
141
|
+
|
|
142
|
+
return models
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class Command(BaseCommand):
|
|
146
|
+
help = "Generates a JSON file with context for new API endpoints to be documented."
|
|
147
|
+
|
|
148
|
+
def __init__(self, *args, **kwargs):
|
|
149
|
+
super().__init__(*args, **kwargs)
|
|
150
|
+
self.url_mapping = get_url_view_mapping()
|
|
151
|
+
|
|
152
|
+
# --- LOGGING ---
|
|
153
|
+
with open("url_log.txt", "w") as f:
|
|
154
|
+
f.write("--- Discovered Django URL Patterns ---\n")
|
|
155
|
+
for regex_pattern, view_class in sorted(self.url_mapping.items()):
|
|
156
|
+
class_name = getattr(view_class, "__name__", str(view_class))
|
|
157
|
+
module_name = getattr(view_class, "__module__", "unknown")
|
|
158
|
+
openapi_path = convert_regex_to_openapi_path(regex_pattern)
|
|
159
|
+
f.write(
|
|
160
|
+
f"{regex_pattern} -> {module_name}.{class_name} (OpenAPI: {openapi_path})\n"
|
|
161
|
+
)
|
|
162
|
+
# --- END LOGGING ---
|
|
163
|
+
|
|
164
|
+
def _find_view_for_path(self, schema_path):
|
|
165
|
+
"""
|
|
166
|
+
Finds the corresponding view for a given schema path by converting
|
|
167
|
+
Django regex patterns to OpenAPI format and comparing them.
|
|
168
|
+
"""
|
|
169
|
+
# Normalize schema path
|
|
170
|
+
normalized_schema_path = schema_path
|
|
171
|
+
if not normalized_schema_path.startswith("/"):
|
|
172
|
+
normalized_schema_path = "/" + normalized_schema_path
|
|
173
|
+
if not normalized_schema_path.endswith("/"):
|
|
174
|
+
normalized_schema_path += "/"
|
|
175
|
+
|
|
176
|
+
with open("url_log.txt", "a") as f:
|
|
177
|
+
f.write(f"\n--- Attempting to Match Schema Path: '{normalized_schema_path}' ---\n")
|
|
178
|
+
|
|
179
|
+
for regex_pattern, view_class in self.url_mapping.items():
|
|
180
|
+
openapi_path = convert_regex_to_openapi_path(regex_pattern)
|
|
181
|
+
|
|
182
|
+
with open("url_log.txt", "a") as f:
|
|
183
|
+
f.write(f" Comparing with: '{openapi_path}' (Regex: {regex_pattern})\n")
|
|
184
|
+
|
|
185
|
+
# Direct match
|
|
186
|
+
if openapi_path == normalized_schema_path:
|
|
187
|
+
with open("url_log.txt", "a") as f:
|
|
188
|
+
f.write(" !!!!!! EXACT MATCH FOUND !!!!!!\n")
|
|
189
|
+
return view_class
|
|
190
|
+
|
|
191
|
+
# Pattern match (handling parameter name differences like pk vs id)
|
|
192
|
+
if self._paths_match_pattern(openapi_path, normalized_schema_path):
|
|
193
|
+
with open("url_log.txt", "a") as f:
|
|
194
|
+
f.write(" !!!!!! PATTERN MATCH FOUND !!!!!!\n")
|
|
195
|
+
return view_class
|
|
196
|
+
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
def _paths_match_pattern(self, django_path, openapi_path):
|
|
200
|
+
"""
|
|
201
|
+
Check if paths match by comparing structure, ignoring parameter name differences.
|
|
202
|
+
E.g., /path/{pk}/ matches /path/{id}/
|
|
203
|
+
"""
|
|
204
|
+
# Split paths into segments
|
|
205
|
+
django_segments = [s for s in django_path.split("/") if s]
|
|
206
|
+
openapi_segments = [s for s in openapi_path.split("/") if s]
|
|
207
|
+
|
|
208
|
+
# Must have same number of segments
|
|
209
|
+
if len(django_segments) != len(openapi_segments):
|
|
210
|
+
return False
|
|
211
|
+
|
|
212
|
+
# Compare each segment
|
|
213
|
+
for django_seg, openapi_seg in zip(django_segments, openapi_segments, strict=False):
|
|
214
|
+
# If both are parameters (start with {), they match
|
|
215
|
+
if (
|
|
216
|
+
django_seg.startswith("{") and openapi_seg.startswith("{")
|
|
217
|
+
) or django_seg == openapi_seg:
|
|
218
|
+
continue
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
return True
|
|
222
|
+
|
|
223
|
+
def _determine_action_from_path(self, view_class, method, path):
|
|
224
|
+
"""
|
|
225
|
+
Determine the ViewSet action based on the HTTP method and URL path.
|
|
226
|
+
Handles both standard CRUD actions and custom @action decorators.
|
|
227
|
+
"""
|
|
228
|
+
# Check for custom actions first
|
|
229
|
+
for attr_name in dir(view_class):
|
|
230
|
+
attr = getattr(view_class, attr_name)
|
|
231
|
+
if hasattr(attr, "mapping") and hasattr(attr, "url_path"):
|
|
232
|
+
# This is a custom action with @action decorator
|
|
233
|
+
action_url_path = attr.url_path
|
|
234
|
+
action_methods = (
|
|
235
|
+
list(attr.mapping.keys()) if hasattr(attr.mapping, "keys") else []
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Debug logging
|
|
239
|
+
with open("url_log.txt", "a") as f:
|
|
240
|
+
f.write(
|
|
241
|
+
f" Checking action {attr_name}:"
|
|
242
|
+
f" url_path='{action_url_path}', "
|
|
243
|
+
f"methods={action_methods}, path='{path}'\n"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Check if the path ends with this action's url_path and method matches
|
|
247
|
+
if path.rstrip("/").endswith(action_url_path) and method.lower() in [
|
|
248
|
+
m.lower() for m in action_methods
|
|
249
|
+
]:
|
|
250
|
+
with open("url_log.txt", "a") as f:
|
|
251
|
+
f.write(f" !!! ACTION MATCH: {attr_name} !!!\n")
|
|
252
|
+
return attr_name
|
|
253
|
+
|
|
254
|
+
# Fall back to standard CRUD actions
|
|
255
|
+
action_map = {
|
|
256
|
+
"get": "list" if "{id}" not in path and "{pk}" not in path else "retrieve",
|
|
257
|
+
"post": "create",
|
|
258
|
+
"put": "update",
|
|
259
|
+
"patch": "partial_update",
|
|
260
|
+
"delete": "destroy",
|
|
261
|
+
}
|
|
262
|
+
action = action_map.get(method.lower(), "list")
|
|
263
|
+
|
|
264
|
+
with open("url_log.txt", "a") as f:
|
|
265
|
+
f.write(f" Using standard action: {action} for {method} {path}\n")
|
|
266
|
+
|
|
267
|
+
return action
|
|
268
|
+
|
|
269
|
+
def _analyze_endpoint(self, method, path):
|
|
270
|
+
self.stdout.write(f"Analyzing {method} {path}...")
|
|
271
|
+
try:
|
|
272
|
+
view_class = self._find_view_for_path(path)
|
|
273
|
+
|
|
274
|
+
if not view_class:
|
|
275
|
+
self.stderr.write(self.style.ERROR(f"Could not resolve view for path: {path}"))
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
# Skip function-based views
|
|
279
|
+
if not inspect.isclass(view_class):
|
|
280
|
+
self.stdout.write(
|
|
281
|
+
self.style.WARNING(f"Skipping function-based view for {path}")
|
|
282
|
+
)
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
if issubclass(view_class, SpectacularAPIView):
|
|
286
|
+
self.stdout.write(
|
|
287
|
+
self.style.WARNING(f"Skipping documentation-generator view: {path}")
|
|
288
|
+
)
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
from rest_framework.test import APIRequestFactory
|
|
292
|
+
|
|
293
|
+
factory = APIRequestFactory()
|
|
294
|
+
request = getattr(factory, method.lower())(path)
|
|
295
|
+
|
|
296
|
+
view_instance = None
|
|
297
|
+
action = None
|
|
298
|
+
if issubclass(view_class, ViewSetMixin):
|
|
299
|
+
# For ViewSets, we need to determine the action
|
|
300
|
+
action = self._determine_action_from_path(view_class, method, path)
|
|
301
|
+
view_instance = view_class()
|
|
302
|
+
view_instance.action = action # Set the action explicitly
|
|
303
|
+
else:
|
|
304
|
+
view_instance = view_class()
|
|
305
|
+
|
|
306
|
+
if not view_instance:
|
|
307
|
+
self.stdout.write(self.style.WARNING(f"Could not instantiate view for {path}"))
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
from django.contrib.auth.models import AnonymousUser
|
|
311
|
+
|
|
312
|
+
request.user = AnonymousUser()
|
|
313
|
+
|
|
314
|
+
# Mock kwargs from path
|
|
315
|
+
mock_kwargs = {}
|
|
316
|
+
for param in re.findall(r"{([^}]+)}", path):
|
|
317
|
+
if "id" in param.lower():
|
|
318
|
+
mock_kwargs[param] = 1
|
|
319
|
+
else:
|
|
320
|
+
mock_kwargs[param] = "test"
|
|
321
|
+
view_instance.kwargs = mock_kwargs
|
|
322
|
+
|
|
323
|
+
view_instance.setup(request, *(), **mock_kwargs)
|
|
324
|
+
|
|
325
|
+
# For ViewSets, ensure action is set after setup
|
|
326
|
+
if issubclass(view_class, ViewSetMixin) and action:
|
|
327
|
+
view_instance.action = action
|
|
328
|
+
|
|
329
|
+
serializer_class, model_class = None, None
|
|
330
|
+
|
|
331
|
+
# Gracefully handle missing methods/attributes
|
|
332
|
+
try:
|
|
333
|
+
serializer_class = view_instance.get_serializer_class()
|
|
334
|
+
except (AttributeError, AssertionError) as e:
|
|
335
|
+
# For ViewSets with actions, try to get serializer class from action kwargs
|
|
336
|
+
if issubclass(view_class, ViewSetMixin) and action:
|
|
337
|
+
action_method = getattr(view_class, action, None)
|
|
338
|
+
if (
|
|
339
|
+
action_method
|
|
340
|
+
and hasattr(action_method, "kwargs")
|
|
341
|
+
and "serializer_class" in action_method.kwargs
|
|
342
|
+
):
|
|
343
|
+
serializer_class = action_method.kwargs["serializer_class"]
|
|
344
|
+
self.stdout.write(
|
|
345
|
+
self.style.SUCCESS(
|
|
346
|
+
f"Found action-specific serializer for {path}:"
|
|
347
|
+
f" {serializer_class.__name__}"
|
|
348
|
+
)
|
|
349
|
+
)
|
|
350
|
+
else:
|
|
351
|
+
self.stdout.write(
|
|
352
|
+
self.style.WARNING(f"Could not get serializer for {path}: {e}")
|
|
353
|
+
)
|
|
354
|
+
else:
|
|
355
|
+
self.stdout.write(
|
|
356
|
+
self.style.WARNING(f"Could not get serializer for {path}: {e}")
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
try:
|
|
360
|
+
queryset = view_instance.get_queryset()
|
|
361
|
+
if queryset is not None:
|
|
362
|
+
model_class = queryset.model
|
|
363
|
+
except (AttributeError, AssertionError, KeyError) as e:
|
|
364
|
+
# Many action-based ViewSets don't have querysets (auth, profile, etc.)
|
|
365
|
+
# This is normal and expected, so we'll handle it gracefully
|
|
366
|
+
if issubclass(view_class, ViewSetMixin) and action:
|
|
367
|
+
# For action-based ViewSets, not having a queryset is often normal
|
|
368
|
+
pass
|
|
369
|
+
else:
|
|
370
|
+
self.stdout.write(
|
|
371
|
+
self.style.WARNING(f"Could not get queryset for {path}: {e}")
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
view_code = inspect.getsource(view_class)
|
|
375
|
+
serializer_code = (
|
|
376
|
+
inspect.getsource(serializer_class) if serializer_class else "Not available."
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
# Extract model references instead of full model code
|
|
380
|
+
model_references = set()
|
|
381
|
+
|
|
382
|
+
# From view
|
|
383
|
+
model_references.update(extract_model_references_from_view(view_class))
|
|
384
|
+
|
|
385
|
+
# From serializer
|
|
386
|
+
if serializer_class:
|
|
387
|
+
model_references.update(
|
|
388
|
+
extract_model_references_from_serializer(serializer_class)
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
# From queryset model (if available)
|
|
392
|
+
if model_class:
|
|
393
|
+
model_references.add(model_class)
|
|
394
|
+
|
|
395
|
+
# Convert to model names for referencing
|
|
396
|
+
model_names = []
|
|
397
|
+
for model in model_references:
|
|
398
|
+
model_name = f"{model._meta.app_label}.{model.__name__}"
|
|
399
|
+
model_names.append(model_name)
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
"endpoint_info": {
|
|
403
|
+
"path": path,
|
|
404
|
+
"method": method,
|
|
405
|
+
"view_name": view_class.__name__,
|
|
406
|
+
},
|
|
407
|
+
"code_context": {
|
|
408
|
+
"view_code": view_code,
|
|
409
|
+
"serializer_code": serializer_code,
|
|
410
|
+
"model_references": model_names,
|
|
411
|
+
},
|
|
412
|
+
}
|
|
413
|
+
except Exception as e:
|
|
414
|
+
self.stderr.write(
|
|
415
|
+
self.style.ERROR(
|
|
416
|
+
f"A critical error occurred analyzing endpoint {method} {path}: {e}"
|
|
417
|
+
)
|
|
418
|
+
)
|
|
419
|
+
import traceback
|
|
420
|
+
|
|
421
|
+
traceback.print_exc()
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
def handle(self, *args, **options):
|
|
425
|
+
self.stdout.write("Starting the documentation generation process...")
|
|
426
|
+
generator = SchemaGenerator()
|
|
427
|
+
current_schema = generator.get_schema(request=None, public=True)
|
|
428
|
+
self.stdout.write("Successfully generated the current OpenAPI schema.")
|
|
429
|
+
|
|
430
|
+
doc_schema_path = os.path.join(settings.BASE_DIR, "docs/configs/doc-schema.yaml")
|
|
431
|
+
existing_schema = {}
|
|
432
|
+
try:
|
|
433
|
+
with open(doc_schema_path) as f:
|
|
434
|
+
existing_schema = yaml.safe_load(f)
|
|
435
|
+
self.stdout.write(f"Successfully loaded existing schema from {doc_schema_path}")
|
|
436
|
+
except FileNotFoundError:
|
|
437
|
+
self.stdout.write(
|
|
438
|
+
self.style.WARNING(
|
|
439
|
+
f"'{doc_schema_path}' not found. Assuming this is the first run."
|
|
440
|
+
)
|
|
441
|
+
)
|
|
442
|
+
except yaml.YAMLError as e:
|
|
443
|
+
self.stderr.write(self.style.ERROR(f"Error parsing '{doc_schema_path}': {e}"))
|
|
444
|
+
return
|
|
445
|
+
|
|
446
|
+
current_paths = current_schema.get("paths", {})
|
|
447
|
+
existing_paths = existing_schema.get("paths", {})
|
|
448
|
+
new_endpoints = []
|
|
449
|
+
|
|
450
|
+
for path, methods in current_paths.items():
|
|
451
|
+
if path not in existing_paths:
|
|
452
|
+
for method in methods:
|
|
453
|
+
new_endpoints.append({"method": method.upper(), "path": path})
|
|
454
|
+
else:
|
|
455
|
+
for method in methods:
|
|
456
|
+
if method not in existing_paths[path]:
|
|
457
|
+
new_endpoints.append({"method": method.upper(), "path": path})
|
|
458
|
+
|
|
459
|
+
if not new_endpoints:
|
|
460
|
+
self.stdout.write(
|
|
461
|
+
self.style.SUCCESS("No new endpoints found. Documentation is up-to-date.")
|
|
462
|
+
)
|
|
463
|
+
return
|
|
464
|
+
|
|
465
|
+
self.stdout.write(self.style.NOTICE("Found new endpoints to document:"))
|
|
466
|
+
for endpoint in new_endpoints:
|
|
467
|
+
self.stdout.write(f"- {endpoint['method']} {endpoint['path']}")
|
|
468
|
+
|
|
469
|
+
# Build endpoint contexts and model registry
|
|
470
|
+
endpoint_contexts = []
|
|
471
|
+
model_registry = {}
|
|
472
|
+
all_model_references = set()
|
|
473
|
+
|
|
474
|
+
for endpoint in new_endpoints:
|
|
475
|
+
context = self._analyze_endpoint(endpoint["method"], endpoint["path"])
|
|
476
|
+
if context:
|
|
477
|
+
endpoint_contexts.append(context)
|
|
478
|
+
# Collect all model references
|
|
479
|
+
for model_name in context["code_context"]["model_references"]:
|
|
480
|
+
all_model_references.add(model_name)
|
|
481
|
+
|
|
482
|
+
# Build model registry with actual model code
|
|
483
|
+
for model_name in all_model_references:
|
|
484
|
+
try:
|
|
485
|
+
app_label, model_class_name = model_name.split(".", 1)
|
|
486
|
+
from django.apps import apps
|
|
487
|
+
|
|
488
|
+
model_class = apps.get_model(app_label, model_class_name)
|
|
489
|
+
model_registry[model_name] = inspect.getsource(model_class)
|
|
490
|
+
except Exception as e:
|
|
491
|
+
self.stdout.write(
|
|
492
|
+
self.style.WARNING(f"Could not get source for model {model_name}: {e}")
|
|
493
|
+
)
|
|
494
|
+
model_registry[model_name] = "Not available."
|
|
495
|
+
|
|
496
|
+
if endpoint_contexts:
|
|
497
|
+
# Build final output with model registry
|
|
498
|
+
output = {"models": model_registry, "endpoints": endpoint_contexts}
|
|
499
|
+
|
|
500
|
+
output_filename = "ai-doc-input.json"
|
|
501
|
+
with open(output_filename, "w") as f:
|
|
502
|
+
json.dump(output, f, indent=4)
|
|
503
|
+
self.stdout.write(
|
|
504
|
+
self.style.SUCCESS(
|
|
505
|
+
f"Successfully generated '{output_filename}'"
|
|
506
|
+
f" with {len(endpoint_contexts)} "
|
|
507
|
+
f"endpoints and {len(model_registry)} unique models."
|
|
508
|
+
)
|
|
509
|
+
)
|
|
510
|
+
self.stdout.write("Please copy the contents of this file and provide it to the AI.")
|
|
511
|
+
|
|
512
|
+
self.stdout.write(self.style.SUCCESS("Process finished."))
|