drf-to-mkdoc 0.1.0__py3-none-any.whl → 0.1.2__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 +6 -6
- drf_to_mkdoc/apps.py +14 -14
- drf_to_mkdoc/conf/settings.py +44 -44
- drf_to_mkdoc/management/commands/build_docs.py +76 -76
- drf_to_mkdoc/management/commands/generate_doc_json.py +512 -512
- drf_to_mkdoc/management/commands/generate_docs.py +138 -138
- drf_to_mkdoc/management/commands/generate_model_docs.py +327 -327
- drf_to_mkdoc/management/commands/update_doc_schema.py +53 -53
- drf_to_mkdoc/utils/__init__.py +3 -3
- drf_to_mkdoc/utils/endpoint_generator.py +945 -945
- drf_to_mkdoc/utils/extractors/__init__.py +3 -3
- drf_to_mkdoc/utils/extractors/query_parameter_extractors.py +229 -229
- drf_to_mkdoc/utils/md_generators/query_parameters_generators.py +72 -72
- drf_to_mkdoc/utils/model_generator.py +269 -269
- {drf_to_mkdoc-0.1.0.dist-info → drf_to_mkdoc-0.1.2.dist-info}/METADATA +247 -247
- drf_to_mkdoc-0.1.2.dist-info/RECORD +25 -0
- {drf_to_mkdoc-0.1.0.dist-info → drf_to_mkdoc-0.1.2.dist-info}/licenses/LICENSE +21 -21
- drf_to_mkdoc-0.1.0.dist-info/RECORD +0 -25
- {drf_to_mkdoc-0.1.0.dist-info → drf_to_mkdoc-0.1.2.dist-info}/WHEEL +0 -0
- {drf_to_mkdoc-0.1.0.dist-info → drf_to_mkdoc-0.1.2.dist-info}/top_level.txt +0 -0
|
@@ -1,512 +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."))
|
|
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."))
|