diff --git a/README.md b/README.md index c05231803..68469ff52 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # datamodel-code-generator -This code generator creates [pydantic v1 and v2](https://docs.pydantic.dev/) model, [dataclasses.dataclass](https://docs.python.org/3/library/dataclasses.html), [typing.TypedDict](https://docs.python.org/3/library/typing.html#typing.TypedDict) +This code generator creates [pydantic v1 and v2](https://docs.pydantic.dev/) model, [dataclasses.dataclass](https://docs.python.org/3/library/dataclasses.html), [typing.TypedDict](https://docs.python.org/3/library/typing.html#typing.TypedDict) and [msgspec.Struct](https://github.com/jcrist/msgspec) from an openapi file and others. [![PyPI version](https://badge.fury.io/py/datamodel-code-generator.svg)](https://pypi.python.org/pypi/datamodel-code-generator) @@ -246,6 +246,7 @@ class Apis(BaseModel): - [dataclasses.dataclass](https://docs.python.org/3/library/dataclasses.html); - [typing.TypedDict](https://docs.python.org/3/library/typing.html#typing.TypedDict); - [msgspec.Struct](https://github.com/jcrist/msgspec); +- [django.Model](https://docs.djangoproject.com/en/stable/topics/db/models/); - Custom type from your [jinja2](https://jinja.palletsprojects.com/en/3.1.x/) template; ## Sponsors @@ -273,14 +274,14 @@ class Apis(BaseModel): ## Projects that use datamodel-code-generator - -These OSS projects use datamodel-code-generator to generate many models. + +These OSS projects use datamodel-code-generator to generate many models. See the following linked projects for real world examples and inspiration. - [airbytehq/airbyte](https://github.com/airbytehq/airbyte) - *[Generate Python, Java/Kotlin, and Typescript protocol models](https://github.com/airbytehq/airbyte-protocol/tree/main/protocol-models/bin)* - [apache/iceberg](https://github.com/apache/iceberg) - - *[Generate Python code](https://github.com/apache/iceberg/blob/d2e1094ee0cc6239d43f63ba5114272f59d605d2/open-api/README.md?plain=1#L39)* + - *[Generate Python code](https://github.com/apache/iceberg/blob/d2e1094ee0cc6239d43f63ba5114272f59d605d2/open-api/README.md?plain=1#L39)* *[`make generate`](https://github.com/apache/iceberg/blob/d2e1094ee0cc6239d43f63ba5114272f59d605d2/open-api/Makefile#L24-L34)* - [argoproj-labs/hera](https://github.com/argoproj-labs/hera) - *[`Makefile`](https://github.com/argoproj-labs/hera/blob/c8cbf0c7a676de57469ca3d6aeacde7a5e84f8b7/Makefile#L53-L62)* @@ -301,7 +302,7 @@ See the following linked projects for real world examples and inspiration. - [open-metadata/OpenMetadata](https://github.com/open-metadata/OpenMetadata) - *[Makefile](https://github.com/open-metadata/OpenMetadata/blob/main/Makefile)* - [PostHog/posthog](https://github.com/PostHog/posthog) - - *[Generate models via `npm run`](https://github.com/PostHog/posthog/blob/e1a55b9cb38d01225224bebf8f0c1e28faa22399/package.json#L41)* + - *[Generate models via `npm run`](https://github.com/PostHog/posthog/blob/e1a55b9cb38d01225224bebf8f0c1e28faa22399/package.json#L41)* - [SeldonIO/MLServer](https://github.com/SeldonIO/MLServer) - *[generate-types.sh](https://github.com/SeldonIO/MLServer/blob/master/hack/generate-types.sh)* @@ -338,6 +339,47 @@ $ datamodel-codegen --url https:// --output model.py ``` This method needs the [http extra option](#http-extra-option) +### Django Model Generation +You can generate Django models from JSON Schema or OpenAPI specifications: + +```bash +$ datamodel-codegen --input schema.json --output-model-type django.Model --output models.py +``` + +Example input (`user_schema.json`): +```json +{ + "type": "object", + "properties": { + "id": {"type": "integer"}, + "username": {"type": "string"}, + "email": {"type": "string", "format": "email"}, + "is_active": {"type": "boolean", "default": true}, + "created_at": {"type": "string", "format": "date-time"} + }, + "required": ["id", "username", "email"] +} +``` + +Generated Django model: +```python +from django.db import models + +class User(models.Model): + id = models.IntegerField() + username = models.CharField(max_length=255) + email = models.EmailField(max_length=255) + is_active = models.BooleanField(null=True, blank=True, default=True) + created_at = models.DateTimeField(null=True, blank=True) +``` + +The Django model generator automatically: +- Maps JSON Schema types to appropriate Django field types +- Detects email fields and uses `EmailField` +- Detects datetime fields and uses `DateTimeField` +- Handles required vs optional fields with `null=True, blank=True` +- Sets appropriate field options like `max_length` for character fields + ## All Command Options @@ -345,7 +387,7 @@ The `datamodel-codegen` command: ```bash -usage: +usage: datamodel-codegen [options] Generate Python data models from schema definitions or structured data @@ -369,7 +411,7 @@ Options: --input-file-type {auto,openapi,jsonschema,json,yaml,dict,csv,graphql} Input file type (default: auto) --output OUTPUT Output file (default: stdout) - --output-model-type {pydantic.BaseModel,pydantic_v2.BaseModel,dataclasses.dataclass,typing.TypedDict,msgspec.Struct} + --output-model-type {pydantic.BaseModel,pydantic_v2.BaseModel,dataclasses.dataclass,typing.TypedDict,msgspec.Struct,django.Model} Output model type (default: pydantic.BaseModel) --url URL Input file URL. `--input` is ignored when `--url` is used diff --git a/docs/index.md b/docs/index.md index 77dfc8805..ef1bf0661 100644 --- a/docs/index.md +++ b/docs/index.md @@ -242,6 +242,7 @@ class Apis(BaseModel): - [dataclasses.dataclass](https://docs.python.org/3/library/dataclasses.html); - [typing.TypedDict](https://docs.python.org/3/library/typing.html#typing.TypedDict); - [msgspec.Struct](https://github.com/jcrist/msgspec); +- [django.Model](https://docs.djangoproject.com/en/stable/topics/db/models/); - Custom type from your [jinja2](https://jinja.palletsprojects.com/en/3.1.x) template; ## Sponsors @@ -269,13 +270,13 @@ class Apis(BaseModel): ## Projects that use datamodel-code-generator -These OSS projects use datamodel-code-generator to generate many models. +These OSS projects use datamodel-code-generator to generate many models. See the following linked projects for real world examples and inspiration. - [airbytehq/airbyte](https://github.com/airbytehq/airbyte) - *[code-generator/Dockerfile](https://github.com/airbytehq/airbyte/blob/master/tools/code-generator/Dockerfile)* - [apache/iceberg](https://github.com/apache/iceberg) - - *[Generate Python code](https://github.com/apache/iceberg/blob/d2e1094ee0cc6239d43f63ba5114272f59d605d2/open-api/README.md?plain=1#L39)* + - *[Generate Python code](https://github.com/apache/iceberg/blob/d2e1094ee0cc6239d43f63ba5114272f59d605d2/open-api/README.md?plain=1#L39)* *[`make generate`](https://github.com/apache/iceberg/blob/d2e1094ee0cc6239d43f63ba5114272f59d605d2/open-api/Makefile#L24-L34)* - [argoproj-labs/hera](https://github.com/argoproj-labs/hera) - *[`Makefile`](https://github.com/argoproj-labs/hera/blob/c8cbf0c7a676de57469ca3d6aeacde7a5e84f8b7/Makefile#L53-L62)* @@ -294,7 +295,7 @@ See the following linked projects for real world examples and inspiration. - [open-metadata/OpenMetadata](https://github.com/open-metadata/OpenMetadata) - *[Makefile](https://github.com/open-metadata/OpenMetadata/blob/main/Makefile)* - [PostHog/posthog](https://github.com/PostHog/posthog) - - *[Generate models via `npm run`](https://github.com/PostHog/posthog/blob/e1a55b9cb38d01225224bebf8f0c1e28faa22399/package.json#L41)* + - *[Generate models via `npm run`](https://github.com/PostHog/posthog/blob/e1a55b9cb38d01225224bebf8f0c1e28faa22399/package.json#L41)* - [SeldonIO/MLServer](https://github.com/SeldonIO/MLServer) - *[generate-types.sh](https://github.com/SeldonIO/MLServer/blob/master/hack/generate-types.sh)* @@ -337,7 +338,7 @@ This method needs the [http extra option](#http-extra-option) The `datamodel-codegen` command: ```bash -usage: +usage: datamodel-codegen [options] Generate Python data models from schema definitions or structured data diff --git a/src/datamodel_code_generator/__init__.py b/src/datamodel_code_generator/__init__.py index 4e90dbe22..29da09458 100644 --- a/src/datamodel_code_generator/__init__.py +++ b/src/datamodel_code_generator/__init__.py @@ -173,6 +173,7 @@ class DataModelType(Enum): DataclassesDataclass = "dataclasses.dataclass" TypingTypedDict = "typing.TypedDict" MsgspecStruct = "msgspec.Struct" + DjangoModel = "django.Model" class OpenAPIScope(Enum): diff --git a/src/datamodel_code_generator/model/__init__.py b/src/datamodel_code_generator/model/__init__.py index e2e082d5c..e31e85a9c 100644 --- a/src/datamodel_code_generator/model/__init__.py +++ b/src/datamodel_code_generator/model/__init__.py @@ -33,7 +33,7 @@ def get_data_model_types( ) -> DataModelSet: from datamodel_code_generator import DataModelType # noqa: PLC0415 - from . import dataclass, msgspec, pydantic, pydantic_v2, rootmodel, typed_dict # noqa: PLC0415 + from . import dataclass, django, msgspec, pydantic, pydantic_v2, rootmodel, typed_dict # noqa: PLC0415 from .types import DataTypeManager # noqa: PLC0415 if target_datetime_class is None: @@ -83,6 +83,15 @@ def get_data_model_types( dump_resolve_reference_action=None, known_third_party=["msgspec"], ) + if data_model_type == DataModelType.DjangoModel: + return DataModelSet( + data_model=django.DjangoModel, + root_model=rootmodel.RootModel, + field_model=django.DataModelField, + data_type_manager=django.DataTypeManager, + dump_resolve_reference_action=django.dump_resolve_reference_action, + known_third_party=["django"], + ) msg = f"{data_model_type} is unsupported data model type" raise ValueError(msg) # pragma: no cover diff --git a/src/datamodel_code_generator/model/django.py b/src/datamodel_code_generator/model/django.py new file mode 100644 index 000000000..93b405573 --- /dev/null +++ b/src/datamodel_code_generator/model/django.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar, Optional + +from datamodel_code_generator import DatetimeClassType, PythonVersion, PythonVersionMin +from datamodel_code_generator.imports import ( + IMPORT_DATE, + IMPORT_DATETIME, + IMPORT_TIME, + IMPORT_TIMEDELTA, + Import, +) +from datamodel_code_generator.model import DataModel, DataModelFieldBase +from datamodel_code_generator.model.base import UNDEFINED +from datamodel_code_generator.model.types import DataTypeManager as _DataTypeManager +from datamodel_code_generator.model.types import type_map_factory +from datamodel_code_generator.types import DataType, StrictTypes, Types + +if TYPE_CHECKING: + from collections import defaultdict + from collections.abc import Sequence + from pathlib import Path + + from datamodel_code_generator.reference import Reference + +from datamodel_code_generator.model.pydantic.base_model import Constraints # noqa: TC001 + +# Django-specific imports +IMPORT_DJANGO_MODELS = Import.from_full_path("django.db.models") + + +class DjangoModel(DataModel): + TEMPLATE_FILE_PATH: ClassVar[str] = "django.jinja2" + BASE_CLASS: ClassVar[str] = "models.Model" + DEFAULT_IMPORTS: ClassVar[tuple[Import, ...]] = (IMPORT_DJANGO_MODELS,) + + def __init__( # noqa: PLR0913 + self, + *, + reference: Reference, + fields: list[DataModelFieldBase], + decorators: list[str] | None = None, + base_classes: list[Reference] | None = None, + custom_base_class: str | None = None, + custom_template_dir: Path | None = None, + extra_template_data: defaultdict[str, dict[str, Any]] | None = None, + methods: list[str] | None = None, + path: Path | None = None, + description: str | None = None, + default: Any = UNDEFINED, + nullable: bool = False, + keyword_only: bool = False, + frozen: bool = False, + treat_dot_as_module: bool = False, + ) -> None: + super().__init__( + reference=reference, + fields=fields, + decorators=decorators, + base_classes=base_classes, + custom_base_class=custom_base_class, + custom_template_dir=custom_template_dir, + extra_template_data=extra_template_data, + methods=methods, + path=path, + description=description, + default=default, + nullable=nullable, + keyword_only=keyword_only, + frozen=frozen, + treat_dot_as_module=treat_dot_as_module, + ) + + def set_base_class(self) -> None: + """Override to handle Django models base class correctly.""" + base_class = self.custom_base_class or self.BASE_CLASS + if not base_class: + self.base_classes = [] + return + + # For Django models, we don't need to create an additional import + # since we already import django.db.models as models + from datamodel_code_generator.types import DataType # noqa: PLC0415 + + # Create a simple DataType for the base class without additional imports + base_class_data_type = DataType(type=base_class) + self.base_classes = [base_class_data_type] + + +class DataModelField(DataModelFieldBase): + """Django model field implementation.""" + + constraints: Optional[Constraints] = None # noqa: UP045 + + def self_reference(self) -> bool: # pragma: no cover + return isinstance(self.parent, DjangoModel) and self.parent.reference.path in { + d.reference.path for d in self.data_type.all_data_types if d.reference + } + + @property + def django_field_type(self) -> str: + """Map Python types to Django field types.""" + type_hint = self.type_hint + + # Handle basic types + if type_hint == "str": + # Check if this is a datetime field based on field name or format + if hasattr(self, "extras") and self.extras: + format_type = self.extras.get("format") + if format_type == "date-time": + return "models.DateTimeField" + if format_type == "date": + return "models.DateField" + if format_type == "time": + return "models.TimeField" + if format_type == "email": + return "models.EmailField" + + # Check field name patterns + field_name = getattr(self, "name", "").lower() + if "email" in field_name: + return "models.EmailField" + if field_name.endswith(("_at", "_time")) or "date" in field_name: + return "models.DateTimeField" + + return "models.CharField" + if type_hint == "int": + return "models.IntegerField" + if type_hint == "float": + return "models.FloatField" + if type_hint == "bool": + return "models.BooleanField" + if type_hint == "datetime": + return "models.DateTimeField" + if type_hint == "date": + return "models.DateField" + if type_hint == "time": + return "models.TimeField" + if type_hint == "timedelta": + return "models.DurationField" + if type_hint == "Decimal": + return "models.DecimalField" + if type_hint == "bytes": + return "models.BinaryField" + + # Handle Optional types + if type_hint.startswith("Optional["): + inner_type = type_hint[9:-1] # Remove "Optional[" and "]" + return self._get_field_type_for_inner(inner_type) + + # Handle Union types (simplified) + if type_hint.startswith("Union["): + return "models.TextField" # Default to TextField for complex unions + + # Handle List types + if type_hint.startswith(("List[", "list[")): + return "models.JSONField" # Use JSONField for lists + + # Handle Dict types + if type_hint.startswith(("Dict[", "dict[")): + return "models.JSONField" # Use JSONField for dicts + + # Default to TextField for unknown types + return "models.TextField" + + def _get_field_type_for_inner(self, inner_type: str) -> str: + """Get Django field type for inner type (used for Optional types).""" + if inner_type == "str": + # Check if this is a datetime field based on field name or format + if hasattr(self, "extras") and self.extras: + format_type = self.extras.get("format") + if format_type == "date-time": + return "models.DateTimeField" + if format_type == "date": + return "models.DateField" + if format_type == "time": + return "models.TimeField" + if format_type == "email": + return "models.EmailField" + + # Check field name patterns + field_name = getattr(self, "name", "").lower() + if "email" in field_name: + return "models.EmailField" + if field_name.endswith(("_at", "_time")) or "date" in field_name: + return "models.DateTimeField" + + return "models.CharField" + if inner_type == "int": + return "models.IntegerField" + if inner_type == "float": + return "models.FloatField" + if inner_type == "bool": + return "models.BooleanField" + if inner_type == "datetime": + return "models.DateTimeField" + if inner_type == "date": + return "models.DateField" + if inner_type == "time": + return "models.TimeField" + if inner_type == "timedelta": + return "models.DurationField" + if inner_type == "Decimal": + return "models.DecimalField" + if inner_type == "bytes": + return "models.BinaryField" + return "models.TextField" + + @property + def django_field_options(self) -> str: + """Generate Django field options.""" + options = [] + + # Handle nullable fields + if not self.required: + options.extend(("null=True", "blank=True")) + + # Handle max_length for CharField and EmailField + field_type = self.django_field_type + if field_type in {"models.CharField", "models.EmailField"}: + options.append("max_length=255") # Default max_length + + # Handle decimal places for DecimalField + if field_type == "models.DecimalField": + options.extend(("max_digits=10", "decimal_places=2")) + + # Handle default values + if self.default != UNDEFINED and self.default is not None: + if isinstance(self.default, str): + options.append(f"default='{self.default}'") + else: + options.append(f"default={self.default}") + + # Handle help_text from field description + description = self.extras.get("description") + if description: + # Escape single quotes in the description and wrap in single quotes + escaped_description = description.replace("'", "\\'") + options.append(f"help_text='{escaped_description}'") + + return ", ".join(options) + + @property + def field(self) -> str | None: + """Generate the Django field definition.""" + field_type = self.django_field_type + options = self.django_field_options + + if options: + return f"{field_type}({options})" + return f"{field_type}()" + + +class DataTypeManager(_DataTypeManager): + def __init__( # noqa: PLR0913, PLR0917 + self, + python_version: PythonVersion = PythonVersionMin, + use_standard_collections: bool = False, # noqa: FBT001, FBT002 + use_generic_container_types: bool = False, # noqa: FBT001, FBT002 + strict_types: Sequence[StrictTypes] | None = None, + use_non_positive_negative_number_constrained_types: bool = False, # noqa: FBT001, FBT002 + use_union_operator: bool = False, # noqa: FBT001, FBT002 + use_pendulum: bool = False, # noqa: FBT001, FBT002 + target_datetime_class: DatetimeClassType = DatetimeClassType.Datetime, + treat_dot_as_module: bool = False, # noqa: FBT001, FBT002 + ) -> None: + super().__init__( + python_version, + use_standard_collections, + use_generic_container_types, + strict_types, + use_non_positive_negative_number_constrained_types, + use_union_operator, + use_pendulum, + target_datetime_class, + treat_dot_as_module, + ) + + datetime_map = ( + { + Types.time: self.data_type.from_import(IMPORT_TIME), + Types.date: self.data_type.from_import(IMPORT_DATE), + Types.date_time: self.data_type.from_import(IMPORT_DATETIME), + Types.timedelta: self.data_type.from_import(IMPORT_TIMEDELTA), + } + if target_datetime_class is DatetimeClassType.Datetime + else {} + ) + + self.type_map: dict[Types, DataType] = { + **type_map_factory(self.data_type), + **datetime_map, + } + + def get_data_type( + self, + types: Types, + **kwargs: Any, + ) -> DataType: + return self.type_map[types] + + +def dump_resolve_reference_action(class_names: list[str]) -> str: + """Django models don't need forward reference resolution.""" + return "" diff --git a/src/datamodel_code_generator/model/template/django.jinja2 b/src/datamodel_code_generator/model/template/django.jinja2 new file mode 100644 index 000000000..8699985a1 --- /dev/null +++ b/src/datamodel_code_generator/model/template/django.jinja2 @@ -0,0 +1,26 @@ +{% for decorator in decorators -%} +{{ decorator }} +{% endfor -%} +class {{ class_name }}({{ base_class }}):{% if comment is defined %} # {{ comment }}{% endif %} +{%- if description %} + """ + {{ description | indent(4) }} + """ +{%- endif %} +{%- if not fields and not description %} + pass +{%- endif %} +{%- for field in fields %} + {{ field.name }} = {{ field.field }} + {%- if field.docstring %} + """ + {{ field.docstring | indent(4) }} + """ + {%- endif %} +{%- endfor %} +{%- if methods %} +{%- for method in methods %} + + {{ method }} +{%- endfor %} +{%- endif %} diff --git a/tests/model/test_django.py b/tests/model/test_django.py new file mode 100644 index 000000000..073968bae --- /dev/null +++ b/tests/model/test_django.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +from datamodel_code_generator.model.django import DataModelField, DjangoModel +from datamodel_code_generator.reference import Reference +from datamodel_code_generator.types import DataType + + +def test_django_model() -> None: + field = DataModelField(name="name", data_type=DataType(type="str"), required=True) + + django_model = DjangoModel( + fields=[field], + reference=Reference(name="test_model", path="test_model"), + ) + + assert django_model.name == "test_model" + assert django_model.fields == [field] + assert django_model.decorators == [] + assert "class test_model(models.Model):" in django_model.render() + assert "name = models.CharField(max_length=255)" in django_model.render() + + +def test_django_field_types() -> None: + # Test string field + str_field = DataModelField(name="title", data_type=DataType(type="str"), required=True) + assert str_field.django_field_type == "models.CharField" + assert "max_length=255" in str_field.django_field_options + + # Test integer field + int_field = DataModelField(name="count", data_type=DataType(type="int"), required=True) + assert int_field.django_field_type == "models.IntegerField" + + # Test boolean field + bool_field = DataModelField(name="active", data_type=DataType(type="bool"), required=False) + assert bool_field.django_field_type == "models.BooleanField" + assert "null=True" in bool_field.django_field_options + assert "blank=True" in bool_field.django_field_options + + +def test_django_email_field() -> None: + # Test email field detection by name + email_field = DataModelField(name="email", data_type=DataType(type="str"), required=True) + assert email_field.django_field_type == "models.EmailField" + assert "max_length=255" in email_field.django_field_options + + +def test_django_datetime_field() -> None: + # Test datetime field detection by name + datetime_field = DataModelField(name="created_at", data_type=DataType(type="str"), required=False) + assert datetime_field.django_field_type == "models.DateTimeField" + assert "null=True" in datetime_field.django_field_options + assert "blank=True" in datetime_field.django_field_options + + +def test_django_field_generation() -> None: + # Test required field + required_field = DataModelField(name="title", data_type=DataType(type="str"), required=True) + assert required_field.field == "models.CharField(max_length=255)" + + # Test optional field + optional_field = DataModelField(name="description", data_type=DataType(type="str"), required=False) + assert optional_field.field == "models.CharField(null=True, blank=True, max_length=255)" + + +def test_django_field_help_text() -> None: + # Test field with description (help_text) + field_with_description = DataModelField( + name="disposition", + data_type=DataType(type="str"), + required=False, + extras={"description": "The state that a message should be left in after it has been forwarded."}, + ) + assert ( + "help_text='The state that a message should be left in after it has been forwarded.'" + in field_with_description.django_field_options + ) + assert ( + "help_text='The state that a message should be left in after it has been forwarded.'" + in field_with_description.field + ) + + # Test field with description containing single quotes + field_with_quotes = DataModelField( + name="email_address", + data_type=DataType(type="str"), + required=False, + extras={ + "description": "Email address to which all incoming messages are forwarded. This email address must be a verified member of the forwarding addresses." + }, + ) + expected_help_text = "help_text='Email address to which all incoming messages are forwarded. This email address must be a verified member of the forwarding addresses.'" + assert expected_help_text in field_with_quotes.django_field_options + + # Test field with description containing single quotes that need escaping + field_with_escaped_quotes = DataModelField( + name="test_field", + data_type=DataType(type="str"), + required=False, + extras={"description": "This is a 'test' description with quotes."}, + ) + expected_escaped_help_text = "help_text='This is a \\'test\\' description with quotes.'" + assert expected_escaped_help_text in field_with_escaped_quotes.django_field_options + + # Test field without description (no help_text) + field_without_description = DataModelField(name="simple_field", data_type=DataType(type="str"), required=False) + assert "help_text" not in field_without_description.django_field_options + + +def test_django_model_with_help_text() -> None: + # Test complete Django model generation with help_text + disposition_field = DataModelField( + name="disposition", + data_type=DataType(type="str"), + required=False, + extras={"description": "The state that a message should be left in after it has been forwarded."}, + ) + + email_field = DataModelField( + name="email_address", + data_type=DataType(type="str"), + required=False, + extras={ + "description": "Email address to which all incoming messages are forwarded. This email address must be a verified member of the forwarding addresses." + }, + ) + + enabled_field = DataModelField( + name="enabled", + data_type=DataType(type="bool"), + required=False, + extras={"description": "Whether all incoming mail is automatically forwarded to another address."}, + ) + + django_model = DjangoModel( + fields=[disposition_field, email_field, enabled_field], + reference=Reference(name="AutoForwarding", path="AutoForwarding"), + description="Auto-forwarding settings for an account.", + ) + + rendered = django_model.render() + + # Check that the model class and description are rendered correctly + assert "class AutoForwarding(models.Model):" in rendered + assert "Auto-forwarding settings for an account." in rendered + + # Check that fields with help_text are rendered correctly + assert ( + "disposition = models.CharField(null=True, blank=True, max_length=255, help_text='The state that a message should be left in after it has been forwarded.')" + in rendered + ) + assert ( + "email_address = models.EmailField(null=True, blank=True, max_length=255, help_text='Email address to which all incoming messages are forwarded. This email address must be a verified member of the forwarding addresses.')" + in rendered + ) + assert ( + "enabled = models.BooleanField(null=True, blank=True, help_text='Whether all incoming mail is automatically forwarded to another address.')" + in rendered + )