From e7ee4cf691f617d388a0f5371c91276a4f4e80c9 Mon Sep 17 00:00:00 2001 From: Ali Tavallaie Date: Sat, 25 May 2024 19:32:54 +0330 Subject: [PATCH] WIP: cli fixed, not sure. --- djangowiz/app.py | 89 +++++++++ djangowiz/cli.py | 146 +++++++------- djangowiz/core.py | 179 ------------------ djangowiz/core/__init__.py | 4 - djangowiz/core/base_generator.py | 10 +- djangowiz/core/project_generator.py | 37 +++- djangowiz/repo/__init__.py | 0 djangowiz/repo/generators.yaml | 54 ++++++ djangowiz/repo/generators/__init__.py | 2 + .../repo/generators/serializer_generator.py | 25 ++- .../repo/templates/single/serializers.py.j2 | 5 +- djangowiz/test_app/serializers.py | 27 +++ djangowiz/tests/models.py | 38 ++++ 13 files changed, 339 insertions(+), 277 deletions(-) create mode 100644 djangowiz/app.py delete mode 100644 djangowiz/core.py create mode 100644 djangowiz/repo/__init__.py create mode 100644 djangowiz/repo/generators.yaml create mode 100644 djangowiz/test_app/serializers.py create mode 100644 djangowiz/tests/models.py diff --git a/djangowiz/app.py b/djangowiz/app.py new file mode 100644 index 0000000..22ae2e3 --- /dev/null +++ b/djangowiz/app.py @@ -0,0 +1,89 @@ +# djangowiz/app.py + +import typer +import os +from typing import Optional, Dict, Any +import yaml +from djangowiz.core.project_generator import ProjectGenerator +from djangowiz.core.model_extractor import ModelExtractor + + +def create_app(): + app = typer.Typer() + generate_app = typer.Typer() + app.add_typer(generate_app, name="generate") + + def load_generators(config_file: str) -> Dict[str, Any]: + print(f"Loading configuration from {config_file}") + with open(config_file, "r") as file: + return yaml.safe_load(file) + + def get_command_function(generator_name: str, option: str): + def command_function( + app_name: str, + project_name: str, + model_file: str, + template_dir: Optional[str] = typer.Option( + None, help="Custom template directory" + ), + config_file: Optional[str] = typer.Option(None, help="Custom config file"), + repo_dir: Optional[str] = typer.Option(None, help="Custom repo directory"), + overwrite: bool = typer.Option(False, help="Overwrite existing files"), + ): + model_names = ModelExtractor.extract_model_names(model_file) + project_generator = ProjectGenerator( + app_name, project_name, model_names, template_dir, config_file, repo_dir + ) + project_generator.generate([generator_name], option, overwrite) + + return command_function + + def register_commands(config_file: str): + generators = load_generators(config_file) + # Handle the nested 'generators' key + generator_configs = generators.get("generators", {}) + + for generator_name, generator_config in generator_configs.items(): + for option, config in generator_config.get("options", {}).items(): + command_function = get_command_function(generator_name, option) + option_name = f"{generator_name}-{option}" + generate_app.command(name=option_name.replace("_", "-"))( + command_function + ) + + @app.command() + def generate_files( + app_name: str, + project_name: str, + model_file: str, + template_dir: Optional[str] = typer.Option(None), + config_file: Optional[str] = typer.Option(None), + repo_dir: Optional[str] = typer.Option(None), + single_file: bool = typer.Option(False), + overwrite: bool = typer.Option(False), + ): + model_names = ModelExtractor.extract_model_names(model_file) + project_generator = ProjectGenerator( + app_name, project_name, model_names, template_dir, config_file, repo_dir + ) + project_generator.generate( + [ + "serializers", + "viewsets", + "urls", + "routes", + "dockerfile", + "docker_compose", + "env_files", + ], + "single" if single_file else "multi", + overwrite, + ) + + register_commands( + os.path.join(os.path.dirname(__file__), "repo", "generators.yaml") + ) + return app + + +app = create_app() diff --git a/djangowiz/cli.py b/djangowiz/cli.py index 1b0499f..ba6e687 100644 --- a/djangowiz/cli.py +++ b/djangowiz/cli.py @@ -1,104 +1,90 @@ +# djangowiz/app.py + import typer -from djangowiz.core import ModelExtractor, ProjectGenerator +import os +from typing import Optional, Dict, Any, List +import yaml +from djangowiz.core.project_generator import ProjectGenerator +from djangowiz.core.model_extractor import ModelExtractor app = typer.Typer() +generate_app = typer.Typer() +app.add_typer(generate_app, name="generate") +generators_config = {} -@app.command() -def generate_files( - app_name: str, - project_name: str, - model_file: str, - single_file: bool = False, - overwrite: bool = False, - template_dir: str = None, -): - model_names = ModelExtractor.extract_model_names(model_file) - generator = ProjectGenerator(app_name, project_name, model_names, template_dir) - generator.generate_all(single_file, overwrite) - print( - f"Files for {len(model_names)} models have been generated in the {app_name} directory." - ) +def load_generators(config_file: str) -> Dict[str, Any]: + print(f"Loading configuration from {config_file}") + with open(config_file, "r") as file: + return yaml.safe_load(file) -@app.command() -def generate_core_files( - app_name: str, - project_name: str, - model_file: str, - single_file: bool = False, - overwrite: bool = False, - template_dir: str = None, -): - model_names = ModelExtractor.extract_model_names(model_file) - generator = ProjectGenerator(app_name, project_name, model_names, template_dir) - generator.generate_core_files(single_file, overwrite) - print( - f"Serializers, viewsets, and routes for {len(model_names)} models have been generated in the {app_name} directory." - ) +def create_command_function(generator_name: str, options: Dict[str, Any]): + def command_function( + app_name: str, + project_name: str, + model_file: str, + overwrite: bool = typer.Option(False, help="Overwrite existing files"), + option: str = typer.Option( + ..., help=f"Specify the generation option ({', '.join(options.keys())})" + ), + template_dir: Optional[str] = typer.Option( + None, help="Custom template directory" + ), + config_file: Optional[str] = typer.Option(None, help="Custom config file"), + repo_dir: Optional[str] = typer.Option(None, help="Custom repo directory"), + ): + model_names = ModelExtractor.extract_model_names(model_file) + project_generator = ProjectGenerator( + app_name, project_name, model_names, template_dir, config_file, repo_dir + ) + project_generator.generate([generator_name], option, overwrite) -@app.command() -def generate_serializers( - app_name: str, - project_name: str, - model_file: str, - single_file: bool = False, - overwrite: bool = False, - template_dir: str = None, -): - model_names = ModelExtractor.extract_model_names(model_file) - generator = ProjectGenerator(app_name, project_name, model_names, template_dir) - generator.generate_serializers(single_file, overwrite) - print( - f"Serializers for {len(model_names)} models have been generated in the {app_name} directory." - ) + return command_function -@app.command() -def generate_viewsets( - app_name: str, - project_name: str, - model_file: str, - single_file: bool = False, - overwrite: bool = False, - template_dir: str = None, -): - model_names = ModelExtractor.extract_model_names(model_file) - generator = ProjectGenerator(app_name, project_name, model_names, template_dir) - generator.generate_viewsets(single_file, overwrite) - print( - f"Viewsets for {len(model_names)} models have been generated in the {app_name} directory." - ) +def register_commands(config_file: str): + global generators_config + generators = load_generators(config_file) + generators_config = generators.get("generators", {}) + for generator_name, generator_config in generators_config.items(): + options = generator_config.get("options", {}) + command_function = create_command_function(generator_name, options) + generate_app.command(name=generator_name)(command_function) @app.command() -def generate_urls( +def generate_files( app_name: str, project_name: str, model_file: str, - overwrite: bool = False, - template_dir: str = None, + overwrite: bool = typer.Option(False), + option: str = typer.Option(..., help="Specify the generation option"), + template_dir: Optional[str] = typer.Option(None), + config_file: Optional[str] = typer.Option(None), + repo_dir: Optional[str] = typer.Option(None), ): model_names = ModelExtractor.extract_model_names(model_file) - generator = ProjectGenerator(app_name, project_name, model_names, template_dir) - generator.generate_urls(overwrite) - print(f"URLs have been generated in the {app_name} directory.") - + project_generator = ProjectGenerator( + app_name, project_name, model_names, template_dir, config_file, repo_dir + ) + project_generator.generate( + [ + "serializers", + "viewsets", + "urls", + "routes", + "dockerfile", + "docker_compose", + "env_files", + ], + option, + overwrite, + ) -@app.command() -def generate_routes( - app_name: str, - project_name: str, - model_file: str, - overwrite: bool = False, - template_dir: str = None, -): - model_names = ModelExtractor.extract_model_names(model_file) - generator = ProjectGenerator(app_name, project_name, model_names, template_dir) - generator.generate_routes(overwrite) - print(f"Routes have been generated in the {app_name} directory.") +register_commands(os.path.join(os.path.dirname(__file__), "repo", "generators.yaml")) if __name__ == "__main__": app() diff --git a/djangowiz/core.py b/djangowiz/core.py deleted file mode 100644 index 08eba82..0000000 --- a/djangowiz/core.py +++ /dev/null @@ -1,179 +0,0 @@ -import os -import ast -from jinja2 import Environment, FileSystemLoader, ChoiceLoader -from typing import List - - -class ModelExtractor: - @staticmethod - def extract_model_names(file_path: str) -> List[str]: - model_names = [] - with open(file_path, "r") as file: - tree = ast.parse(file.read(), filename=file_path) - for node in ast.walk(tree): - if isinstance(node, ast.ClassDef): - bases = [ - base.id if isinstance(base, ast.Name) else base.attr - for base in node.bases - ] - if "Model" in bases or any(base for base in bases): - model_names.append(node.name) - return model_names - - -class ProjectGenerator: - def __init__( - self, - app_name: str, - project_name: str, - model_names: List[str], - template_dir: str = None, - ): - self.app_name = app_name - self.project_name = project_name - self.model_names = model_names - - # Use default template directory if no custom template directory is provided - default_template_dir = os.path.join(os.path.dirname(__file__), "templates") - loaders = [FileSystemLoader(default_template_dir)] - if template_dir: - loaders.insert(0, FileSystemLoader(template_dir)) - self.env = Environment(loader=ChoiceLoader(loaders)) - - def write_file(self, file_path: str, content: str, overwrite: bool = False): - if not overwrite and os.path.exists(file_path): - print(f"Skipping existing file: {file_path}") - return - os.makedirs(os.path.dirname(file_path), exist_ok=True) - with open(file_path, "w") as file: - file.write(content) - print(f"Generated file: {file_path}") - - def generate_serializers(self, single_file: bool, overwrite: bool = False): - if single_file: - imports = f'from rest_framework import serializers\nfrom {self.app_name}.models import {", ".join(self.model_names)}\n\n' - template = self.env.get_template("single/serializers.py.j2") - content = imports + template.render( - app_name=self.app_name, model_names=self.model_names - ) - self.write_file( - os.path.join(self.app_name, "serializers.py"), content, overwrite - ) - else: - for model_name in self.model_names: - template = self.env.get_template("multi/serializers.py.j2") - content = template.render(app_name=self.app_name, model_name=model_name) - self.write_file( - os.path.join( - self.app_name, "serializers", f"{model_name.lower()}.py" - ), - content, - overwrite, - ) - serializer_init_content = "\n".join( - [ - f"from .{model_name.lower()} import {model_name}Serializer" - for model_name in self.model_names - ] - ) - self.write_file( - os.path.join(self.app_name, "serializers", "__init__.py"), - serializer_init_content, - overwrite, - ) - - def generate_viewsets(self, single_file: bool, overwrite: bool = False): - if single_file: - model_imports = f'from rest_framework import generics\nfrom {self.app_name}.models import {", ".join(self.model_names)}\n' - serializer_imports = f'from {self.app_name}.serializers import {", ".join([model_name + "Serializer" for model_name in self.model_names])}\n\n' - template = self.env.get_template("single/viewsets.py.j2") - content = ( - model_imports - + serializer_imports - + template.render(app_name=self.app_name, model_names=self.model_names) - ) - self.write_file( - os.path.join(self.app_name, "viewsets.py"), content, overwrite - ) - else: - for model_name in self.model_names: - template = self.env.get_template("multi/viewsets.py.j2") - content = template.render(app_name=self.app_name, model_name=model_name) - self.write_file( - os.path.join(self.app_name, "viewsets", f"{model_name.lower()}.py"), - content, - overwrite, - ) - viewset_init_content = "\n".join( - [ - f"from .{model_name.lower()} import {model_name}ListCreateAPIView, {model_name}RetrieveUpdateDestroyAPIView" - for model_name in self.model_names - ] - ) - self.write_file( - os.path.join(self.app_name, "viewsets", "__init__.py"), - viewset_init_content, - overwrite, - ) - - def generate_urls(self, overwrite: bool = False): - template = self.env.get_template("urls.py.j2") - content = template.render(app_name=self.app_name, model_names=self.model_names) - self.write_file(os.path.join(self.app_name, "urls.py"), content, overwrite) - - def generate_routes(self, overwrite: bool = False): - template = self.env.get_template("routes.py.j2") - content = template.render(app_name=self.app_name, model_names=self.model_names) - self.write_file(os.path.join(self.app_name, "routes.py"), content, overwrite) - - def generate_dockerfile(self, overwrite: bool = False): - template = self.env.get_template("Dockerfile.j2") - content = template.render(project_name=self.project_name) - self.write_file("Dockerfile", content, overwrite) - - def generate_docker_compose( - self, db_name: str, db_user: str, db_password: str, overwrite: bool = False - ): - for env in ["dev", "prod"]: - template = self.env.get_template(f"docker-compose.{env}.yml.j2") - content = template.render( - db_name=db_name, - db_user=db_user, - db_password=db_password, - project_name=self.project_name, - ) - self.write_file(f"docker-compose.{env}.yml", content, overwrite) - - def generate_env_files( - self, db_name: str, db_user: str, db_password: str, overwrite: bool = False - ): - for env in ["dev", "prod"]: - template = self.env.get_template(f"env.{env}.j2") - content = template.render( - db_name=db_name, db_user=db_user, db_password=db_password - ) - self.write_file(f".env.{env}", content, overwrite) - - def generate_all(self, single_file: bool, overwrite: bool = False): - self.generate_serializers(single_file, overwrite) - self.generate_viewsets(single_file, overwrite) - self.generate_urls(overwrite) - self.generate_routes(overwrite) - self.generate_dockerfile(overwrite) - self.generate_docker_compose( - db_name="your_db", - db_user="your_user", - db_password="your_password", - overwrite=overwrite, - ) - self.generate_env_files( - db_name="your_db", - db_user="your_user", - db_password="your_password", - overwrite=overwrite, - ) - - def generate_core_files(self, single_file: bool, overwrite: bool = False): - self.generate_serializers(single_file, overwrite) - self.generate_viewsets(single_file, overwrite) - self.generate_routes(overwrite) diff --git a/djangowiz/core/__init__.py b/djangowiz/core/__init__.py index 16dbbbd..c3b66db 100644 --- a/djangowiz/core/__init__.py +++ b/djangowiz/core/__init__.py @@ -3,7 +3,3 @@ from djangowiz.core.io_handler import IOHandler from djangowiz.core.project_generator import ProjectGenerator from djangowiz.core.project_io_handler import ProjectIOHandler - -from .custom_generator import CustomGenerator -from .serializer_generator import SerializerGenerator -from .viewset_generator import ViewsetGenerator diff --git a/djangowiz/core/base_generator.py b/djangowiz/core/base_generator.py index ef32579..fc3394f 100644 --- a/djangowiz/core/base_generator.py +++ b/djangowiz/core/base_generator.py @@ -1,6 +1,8 @@ -import os -from jinja2 import Environment, FileSystemLoader +# djangowiz/core/base_generator.py + from typing import List +from jinja2 import Environment, FileSystemLoader +import os class BaseGenerator: @@ -21,6 +23,10 @@ def __init__( def generate(self, overwrite: bool = False, template: str = None, **kwargs): raise NotImplementedError("Subclasses must implement this method") + def render_template(self, template_name: str, context: dict) -> str: + template = self.env.get_template(template_name) + return template.render(context) + def write_file(self, file_path: str, content: str, overwrite: bool = False): if not overwrite and os.path.exists(file_path): print(f"Skipping existing file: {file_path}") diff --git a/djangowiz/core/project_generator.py b/djangowiz/core/project_generator.py index a2bec4a..b0a77ac 100644 --- a/djangowiz/core/project_generator.py +++ b/djangowiz/core/project_generator.py @@ -1,6 +1,7 @@ import os import importlib from typing import List, Dict, Any +from jinja2 import Environment, FileSystemLoader, ChoiceLoader from djangowiz.core.io_handler import IOHandler from djangowiz.core.project_io_handler import ProjectIOHandler @@ -51,6 +52,15 @@ def __init__( self.generator_dir, ) + self.env = Environment( + loader=ChoiceLoader( + [ + FileSystemLoader(self.template_dir), + FileSystemLoader(self.default_template_dir), + ] + ) + ) + self.load_generators(self.config_file) def load_generators(self, config_file: str): @@ -68,19 +78,26 @@ def load_generators(self, config_file: str): else: self.generators[name] = generator_config - for name, generator_config in self.generators.items(): + # Use a copy of the dictionary to avoid runtime error + generators_copy = self.generators.copy() + for name, generator_config in generators_copy.items(): for option, config in generator_config.get("options", {}).items(): self.load_generator(name, option, config) def load_generator(self, name: str, option: str, config: Dict[str, Any]): class_path = config["class"] - module_path, class_name = class_path.rsplit(".", 1) - if self.generator_dir not in module_path: - module_path = os.path.join(self.generator_dir, module_path) - module = importlib.import_module(module_path) - generator_class = getattr(module, class_name) - template_path = config.get("template", "") + module_name, class_name = class_path.rsplit(".", 1) + + print(f"Attempting to load module: {module_name}, class: {class_name}") + try: + module = importlib.import_module(module_name) + generator_class = getattr(module, class_name) + print(f"Successfully loaded {class_path}") + except (ImportError, AttributeError) as e: + print(f"Error loading {class_path}: {e}") + return + template_path = config.get("template", "") if not os.path.exists(os.path.join(self.template_dir, template_path)): template_path = os.path.join(self.default_template_dir, template_path) @@ -211,7 +228,11 @@ def generate( if generator_key in self.generators: generator = self.generators[generator_key]["class"] template = self.generators[generator_key]["template"] - generator.generate(overwrite=overwrite, template=template, **kwargs) + print(f"Generating {generator_key} using template {template}") + output = generator.generate( + overwrite=overwrite, template=template, **kwargs + ) + print(f"Generated output for {generator_key}: {output}") def export_config(self, export_path: str): self.io_handler.export_config(export_path) diff --git a/djangowiz/repo/__init__.py b/djangowiz/repo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/djangowiz/repo/generators.yaml b/djangowiz/repo/generators.yaml new file mode 100644 index 0000000..4a19934 --- /dev/null +++ b/djangowiz/repo/generators.yaml @@ -0,0 +1,54 @@ +generators: + serializers: + options: + single: + class: repo.generators.serializer_generator.SerializerGenerator + template: single/serializers.py.j2 + multi: + class: repo.generators.serializer_generator.SerializerGenerator + template: multi/serializers.py.j2 + viewsets: + options: + single: + class: repo.generators.viewset_generator.ViewsetGenerator + template: single/viewsets.py.j2 + multi: + class: repo.generators.viewset_generator.ViewsetGenerator + template: multi/viewsets.py.j2 + # urls: + # options: + # single: + # class: repo.generators.custom_generator.CustomGenerator + # template: urls.py.j2 + # multi: + # class: repo.generators.custom_generator.CustomGenerator + # template: urls.py.j2 + # # routes: + # options: + # single: + # class: repo.generators.custom_generator.CustomGenerator + # template: routes.py.j2 + # multi: + # class: repo.generators.custom_generator.CustomGenerator + # template: routes.py.j2 + # dockerfile: + # options: + # default: + # class: repo.generators.custom_generator.CustomGenerator + # template: Dockerfile.j2 + # docker_compose: + # options: + # dev: + # class: repo.generators.custom_generator.CustomGenerator + # template: docker-compose.dev.yml.j2 + # prod: + # class: repo.generators.custom_generator.CustomGenerator + # template: docker-compose.prod.yml.j2 + # env_files: + # options: + # dev: + # class: repo.generators.custom_generator.CustomGenerator + # template: env.dev.j2 + # prod: + # class: repo.generators.custom_generator.CustomGenerator + # template: env.prod.j2 diff --git a/djangowiz/repo/generators/__init__.py b/djangowiz/repo/generators/__init__.py index e69de29..4659992 100644 --- a/djangowiz/repo/generators/__init__.py +++ b/djangowiz/repo/generators/__init__.py @@ -0,0 +1,2 @@ +from .serializer_generator import SerializerGenerator +from .viewset_generator import ViewsetGenerator diff --git a/djangowiz/repo/generators/serializer_generator.py b/djangowiz/repo/generators/serializer_generator.py index 39a8a65..97583b6 100644 --- a/djangowiz/repo/generators/serializer_generator.py +++ b/djangowiz/repo/generators/serializer_generator.py @@ -1,10 +1,29 @@ +# djangowiz/repo/generators/serializer_generator.py + from djangowiz.core.base_generator import BaseGenerator +import os + +# djangowiz/repo/generators/serializer_generator.py class SerializerGenerator(BaseGenerator): def generate(self, overwrite: bool = False, template: str = None, **kwargs): - template = self.env.get_template(template) + if "single" in template: + self.generate_single_file(overwrite, template) + else: + self.generate_multi_file(overwrite, template) + + def generate_single_file(self, overwrite: bool, template: str): + context = {"app_name": self.app_name, "model_names": self.model_names} + content = self.render_template(template, context) + file_path = os.path.join(self.app_name, "serializers.py") + self.write_file(file_path, content, overwrite) + + def generate_multi_file(self, overwrite: bool, template: str): for model_name in self.model_names: - content = template.render(app_name=self.app_name, model_name=model_name) - file_path = f"{self.app_name}/serializers/{model_name.lower()}.py" + context = {"app_name": self.app_name, "model_name": model_name} + content = self.render_template(template, context) + file_path = os.path.join( + self.app_name, "serializers", f"{model_name.lower()}_serializer.py" + ) self.write_file(file_path, content, overwrite) diff --git a/djangowiz/repo/templates/single/serializers.py.j2 b/djangowiz/repo/templates/single/serializers.py.j2 index 975befe..5cde4bc 100644 --- a/djangowiz/repo/templates/single/serializers.py.j2 +++ b/djangowiz/repo/templates/single/serializers.py.j2 @@ -1,7 +1,10 @@ +from rest_framework import serializers +from {{ app_name }}.models import {{ ', '.join(model_names) }} + {% for model_name in model_names %} class {{ model_name }}Serializer(serializers.ModelSerializer): class Meta: model = {{ model_name }} fields = '__all__' -{% endfor %} \ No newline at end of file +{% endfor %} diff --git a/djangowiz/test_app/serializers.py b/djangowiz/test_app/serializers.py new file mode 100644 index 0000000..f38ea17 --- /dev/null +++ b/djangowiz/test_app/serializers.py @@ -0,0 +1,27 @@ +from rest_framework import serializers +from test_app.models import TimeStampMixin, BuyableType, Buyable, Property + + +class TimeStampMixinSerializer(serializers.ModelSerializer): + class Meta: + model = TimeStampMixin + fields = '__all__' + + +class BuyableTypeSerializer(serializers.ModelSerializer): + class Meta: + model = BuyableType + fields = '__all__' + + +class BuyableSerializer(serializers.ModelSerializer): + class Meta: + model = Buyable + fields = '__all__' + + +class PropertySerializer(serializers.ModelSerializer): + class Meta: + model = Property + fields = '__all__' + diff --git a/djangowiz/tests/models.py b/djangowiz/tests/models.py new file mode 100644 index 0000000..7997735 --- /dev/null +++ b/djangowiz/tests/models.py @@ -0,0 +1,38 @@ +from django.db import models + + +# Non-model class for testing +class NonModelClass: + def __init__(self, name): + self.name = name + + +class TimeStampMixin(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + + +class BuyableType(models.Model): + name = models.CharField(max_length=100) + description = models.TextField() + + +class Buyable(TimeStampMixin): + name = models.CharField(max_length=100) + price = models.DecimalField(max_digits=10, decimal_places=2) + type = models.ForeignKey(BuyableType, on_delete=models.CASCADE) + + +class Property(models.Model): + address = models.CharField(max_length=255) + owner = models.CharField(max_length=100) + value = models.DecimalField(max_digits=15, decimal_places=2) + + +# Another non-model class for testing +class UtilityClass: + def utility_method(self): + return "This is a utility method"