drf-to-mkdoc 0.1.9__py3-none-any.whl → 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of drf-to-mkdoc might be problematic. Click here for more details.

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