drf-to-mkdoc 0.1.0__py3-none-any.whl → 0.1.3__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.

@@ -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."))