From 462251059c65056b44cb1b8fc423b45f04faeec3 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Sun, 2 Nov 2025 13:41:30 +0545 Subject: [PATCH 01/15] refactor: improve project directory handling and subprocess execution for compatibility --- djinit/cli.py | 6 +- djinit/scripts/app_generator.py | 4 +- djinit/scripts/input_handler.py | 211 +++++++++++++++++++++++++++----- djinit/scripts/project.py | 9 +- djinit/utils.py | 4 +- pyproject.toml | 1 + 6 files changed, 196 insertions(+), 39 deletions(-) diff --git a/djinit/cli.py b/djinit/cli.py index a6773c3..bf648fe 100644 --- a/djinit/cli.py +++ b/djinit/cli.py @@ -22,7 +22,11 @@ class Cli: self.primary_app = primary_app self.app_names = app_names self.metadata = metadata - self.project_root = os.path.join(os.getcwd(), project_dir) + # Handle '.' for current directory + if project_dir == ".": + self.project_root = os.getcwd() + else: + self.project_root = os.path.join(os.getcwd(), project_dir) # Initialize managers self.project_manager = ProjectManager(project_dir, project_name, app_names, metadata) diff --git a/djinit/scripts/app_generator.py b/djinit/scripts/app_generator.py index 117a30d..10e2296 100644 --- a/djinit/scripts/app_generator.py +++ b/djinit/scripts/app_generator.py @@ -5,6 +5,7 @@ Handles creation of Django apps and updating settings. import os import subprocess +import sys from typing import Optional from djinit.scripts.console_ui import UIFormatter @@ -61,8 +62,9 @@ class AppManager: return False # Create app in the correct location + # Use 'python -m django' instead of 'django-admin' for better compatibility with change_cwd(apps_base_dir): - subprocess.run(["django-admin", "startapp", self.app_name], capture_output=True, text=True, check=True) + subprocess.run([sys.executable, "-m", "django", "startapp", self.app_name], capture_output=True, text=True, check=True) UIFormatter.print_success(f"Created Django app '{self.app_name}' in {apps_base_dir}") return True diff --git a/djinit/scripts/input_handler.py b/djinit/scripts/input_handler.py index a245c76..22aa8ac 100644 --- a/djinit/scripts/input_handler.py +++ b/djinit/scripts/input_handler.py @@ -183,11 +183,14 @@ class InputCollector: console.print(f" [{UIColors.SUCCESS}]{option.key}[/{UIColors.SUCCESS}] {option.description}") console.print() - console.print(f"[{UIColors.HIGHLIGHT}]Your choice: [/{UIColors.HIGHLIGHT}]", end="") - sys.stdout.flush() - - choice = self.char_reader.get_char() - console.print(choice.upper()) + # Get valid keys from CICDOption enum + valid_keys = [option.key for option in CICDOption] + + choice = CharReader.get_cicd_choice( + f"[{UIColors.HIGHLIGHT}]Your choice: [/{UIColors.HIGHLIGHT}]", + valid_keys=valid_keys, + default=CICDOption.NONE.key + ) # Parse choice if choice == CICDOption.BOTH.key: @@ -196,7 +199,7 @@ class InputCollector: return True, False elif choice == CICDOption.GITLAB.key: return False, True - else: # Default to none for any unrecognized input + else: # Default to none (should not happen due to validation, but kept as fallback) return False, False def get_nested_apps_config(self) -> Tuple[bool, str | None]: @@ -211,10 +214,10 @@ class InputCollector: ) console.print() - console.print(f"[{UIColors.HIGHLIGHT}]Nested Django apps? (y/N):[/{UIColors.HIGHLIGHT}] ", end="") - sys.stdout.flush() - choice = self.char_reader.get_char() - console.print(choice.upper()) + choice = CharReader.get_yes_no( + f"[{UIColors.HIGHLIGHT}]Nested Django apps? (y/N):[/{UIColors.HIGHLIGHT}]", + default="n" + ) if choice != "y": return False, None @@ -240,13 +243,13 @@ class InputCollector: console.print(f"[{UIColors.MUTED}]postgres://user:password@host:port/database[/{UIColors.MUTED}]") console.print() - console.print(f"[{UIColors.HIGHLIGHT}]Use DATABASE_URL? (Y/n):[/{UIColors.HIGHLIGHT}] ", end="") - sys.stdout.flush() - choice = self.char_reader.get_char() - console.print(choice.upper()) + choice = CharReader.get_yes_no( + f"[{UIColors.HIGHLIGHT}]Use DATABASE_URL? (Y/n):[/{UIColors.HIGHLIGHT}]", + default="y" + ) - # Default to True (use DATABASE_URL) if user presses Enter or Y - return choice.lower() != "n" + # Return True if choice is 'y', False if 'n' + return choice == "y" class CharReader: @@ -258,6 +261,117 @@ class CharReader: return CharReader._get_char_windows() return CharReader._get_char_unix() + @staticmethod + def get_yes_no(prompt: str, default: str = "n") -> str: + max_attempts = 3 + attempt = 0 + + while attempt < max_attempts: + try: + console.print(f"{prompt} ", end="") + sys.stdout.flush() + + reader = CharReader() + response = reader.get_char() + + # Handle Enter key (default) - in raw mode, Enter is '\r' (carriage return) + # Also handle empty string or other control characters as Enter + if not response or response == '\r' or response == '\n' or ord(response) < 32: + console.print(default.upper()) + return default.lower() + + response = response.lower() + + # Validate response + if response in ('y', 'n'): + console.print(response.upper()) + return response + + # Invalid input - show error (only show if it's a printable character) + console.print() + if response.isprintable(): + UIFormatter.print_error(f"Invalid input '{response}'. Please enter 'y' or 'n'.") + attempt += 1 + if attempt < max_attempts: + console.print(f"[{UIColors.MUTED}]Please try again ({attempt}/{max_attempts}).[/{UIColors.MUTED}]") + console.print() + else: + # Control character entered, treat as Enter + console.print(default.upper()) + return default.lower() + + except KeyboardInterrupt: + console.print() + UIFormatter.print_info("\nOperation cancelled by user.") + raise + + # Max attempts reached - use default + console.print() + UIFormatter.print_warning(f"Maximum attempts reached. Using default: '{default.upper()}'.") + return default.lower() + + @staticmethod + def get_cicd_choice(prompt: str, valid_keys: list[str], default: str = "n") -> str: + """ + Get CI/CD choice input with validation and retries. + + Args: + prompt: The prompt message to display + valid_keys: List of valid key characters (e.g., ['g', 'l', 'b', 'n']) + default: Default value if Enter is pressed + + Returns: + Valid key (lowercase) + """ + max_attempts = 3 + attempt = 0 + valid_keys_lower = [k.lower() for k in valid_keys] + + while attempt < max_attempts: + try: + console.print(f"{prompt} ", end="") + sys.stdout.flush() + + reader = CharReader() + response = reader.get_char() + + # Handle Enter key (default) - in raw mode, Enter is '\r' (carriage return) + # Also handle empty string or other control characters as Enter + if not response or response == '\r' or response == '\n' or ord(response) < 32: + console.print(default.upper()) + return default.lower() + + response = response.lower() + + # Validate response + if response in valid_keys_lower: + console.print(response.upper()) + return response + + # Invalid input - show error (only show if it's a printable character) + console.print() + if response.isprintable(): + valid_keys_str = ", ".join(f"'{k}'" for k in valid_keys) + UIFormatter.print_error(f"Invalid input '{response}'. Please enter one of: {valid_keys_str}.") + attempt += 1 + if attempt < max_attempts: + console.print(f"[{UIColors.MUTED}]Please try again ({attempt}/{max_attempts}).[/{UIColors.MUTED}]") + console.print() + else: + # Control character entered, treat as Enter + console.print(default.upper()) + return default.lower() + + except KeyboardInterrupt: + console.print() + UIFormatter.print_info("\nOperation cancelled by user.") + raise + + # Max attempts reached - use default + console.print() + UIFormatter.print_warning(f"Maximum attempts reached. Using default: '{default.upper()}'.") + return default.lower() + @staticmethod def _get_char_windows() -> str: import msvcrt @@ -299,17 +413,49 @@ def get_user_input() -> Tuple[str, str, str, list, dict]: console.print(f"[{UIColors.HIGHLIGHT}]Project Directory[/{UIColors.HIGHLIGHT}]") console.print(f"[{UIColors.MUTED}]Where your project files will be created[/{UIColors.MUTED}]") - project_dir = collector.get_validated_input( - "Enter project directory name", validate_project_name, "project directory name" - ) - - # Check if directory exists and ask for alternative - while os.path.exists(project_dir): - UIFormatter.print_error(f"Directory '{project_dir}' already exists.") - UIFormatter.print_info("Please choose a different project directory name.") - project_dir = collector.get_validated_input( - "Enter project directory name", validate_project_name, "project directory name" - ) + console.print(f"[{UIColors.MUTED}]Press Enter or enter '.' to create in current directory[/{UIColors.MUTED}]") + + # Get project directory with special handling for '.' and empty input + attempt = 0 + project_dir = None + + while attempt < collector.MAX_ATTEMPTS and project_dir is None: + try: + user_input = console.input(f"[{UIColors.HIGHLIGHT}]Enter project directory name:[/{UIColors.HIGHLIGHT}] ").strip() + + # Handle empty input or '.' - use current directory + if not user_input or user_input == ".": + project_dir = "." + UIFormatter.print_info(f"Creating project in current directory: {os.getcwd()}") + break + + # Validate the input + is_valid, error_msg = validate_project_name(user_input) + if not is_valid: + UIFormatter.print_error(error_msg) + attempt += 1 + collector._show_retry_message(attempt) + continue + + # Check if directory exists and ask for alternative + if os.path.exists(user_input): + UIFormatter.print_error(f"Directory '{user_input}' already exists.") + UIFormatter.print_info("Please choose a different project directory name (or press Enter for current directory).") + attempt += 1 + collector._show_retry_message(attempt) + continue + + project_dir = user_input + break + + except KeyboardInterrupt: + UIFormatter.print_info("\nSetup cancelled by user.") + sys.exit(0) + + if project_dir is None: + UIFormatter.print_error("Maximum attempts reached for project directory name. Exiting.") + sys.exit(1) + console.print() console.print(f"[{UIColors.HIGHLIGHT}]Django Project Name[/{UIColors.HIGHLIGHT}]") @@ -377,13 +523,10 @@ def confirm_setup(project_dir: str, project_name: str, app_names: list, metadata # Get confirmation try: - console.print(f"[{UIColors.WARNING}]Proceed with setup? (y/N):[/{UIColors.WARNING}] ", end="") - sys.stdout.flush() - - reader = CharReader() - response = reader.get_char() - console.print(response.upper()) - + response = CharReader.get_yes_no( + f"[{UIColors.WARNING}]Proceed with setup? (y/N):[/{UIColors.WARNING}]", + default="n" + ) return response == "y" except KeyboardInterrupt: diff --git a/djinit/scripts/project.py b/djinit/scripts/project.py index c0b346c..a02e301 100644 --- a/djinit/scripts/project.py +++ b/djinit/scripts/project.py @@ -18,7 +18,11 @@ class ProjectManager: self.project_name = project_name self.app_names = app_names self.metadata = metadata - self.project_root = os.path.join(os.getcwd(), project_dir) + # Handle '.' for current directory + if project_dir == ".": + self.project_root = os.getcwd() + else: + self.project_root = os.path.join(os.getcwd(), project_dir) def create_project(self) -> bool: self._ensure_django_installed() @@ -27,8 +31,9 @@ class ProjectManager: os.makedirs(self.project_root, exist_ok=True) # Create Django project inside the directory + # Use 'python -m django' instead of 'django-admin' for better compatibility subprocess.run( - ["django-admin", "startproject", self.project_name, self.project_root], + [sys.executable, "-m", "django", "startproject", self.project_name, self.project_root], check=True, ) diff --git a/djinit/utils.py b/djinit/utils.py index ac02bbe..dfbd7c9 100644 --- a/djinit/utils.py +++ b/djinit/utils.py @@ -5,6 +5,7 @@ Contains common operations used across multiple managers. import os import subprocess +import sys from contextlib import contextmanager from djinit.scripts.console_ui import UIFormatter @@ -12,7 +13,8 @@ from djinit.scripts.console_ui import UIFormatter def format_file(filename: str) -> None: """Format Python file using Ruff formatter.""" - subprocess.run(["ruff", "format", filename], check=False, capture_output=True) + # Use 'python -m ruff' instead of 'ruff' for better compatibility + subprocess.run([sys.executable, "-m", "ruff", "format", filename], check=False, capture_output=True) def create_file_with_content(filename: str, content: str, success_message: str, should_format: bool = False) -> bool: diff --git a/pyproject.toml b/pyproject.toml index b053ef7..0c1fccc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ dependencies = [ "rich>=14.2.0", "ruff>=0.14.2", "jinja2>=3.1.0", + "django>=4.2,<6.0", ] [project.urls] -- 2.43.0 From f7c9a0764fce449b1929864f13404c92961465af Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Sun, 2 Nov 2025 13:47:12 +0545 Subject: [PATCH 02/15] style: standardize prompt formatting and input handling --- djinit/scripts/app_generator.py | 4 +- djinit/scripts/input_handler.py | 89 +++++++++++++++++---------------- 2 files changed, 50 insertions(+), 43 deletions(-) diff --git a/djinit/scripts/app_generator.py b/djinit/scripts/app_generator.py index 10e2296..02b69a2 100644 --- a/djinit/scripts/app_generator.py +++ b/djinit/scripts/app_generator.py @@ -64,7 +64,9 @@ class AppManager: # Create app in the correct location # Use 'python -m django' instead of 'django-admin' for better compatibility with change_cwd(apps_base_dir): - subprocess.run([sys.executable, "-m", "django", "startapp", self.app_name], capture_output=True, text=True, check=True) + subprocess.run( + [sys.executable, "-m", "django", "startapp", self.app_name], capture_output=True, text=True, check=True + ) UIFormatter.print_success(f"Created Django app '{self.app_name}' in {apps_base_dir}") return True diff --git a/djinit/scripts/input_handler.py b/djinit/scripts/input_handler.py index 22aa8ac..af035a2 100644 --- a/djinit/scripts/input_handler.py +++ b/djinit/scripts/input_handler.py @@ -185,11 +185,11 @@ class InputCollector: # Get valid keys from CICDOption enum valid_keys = [option.key for option in CICDOption] - + choice = CharReader.get_cicd_choice( f"[{UIColors.HIGHLIGHT}]Your choice: [/{UIColors.HIGHLIGHT}]", valid_keys=valid_keys, - default=CICDOption.NONE.key + default=CICDOption.NONE.key, ) # Parse choice @@ -215,8 +215,7 @@ class InputCollector: console.print() choice = CharReader.get_yes_no( - f"[{UIColors.HIGHLIGHT}]Nested Django apps? (y/N):[/{UIColors.HIGHLIGHT}]", - default="n" + f"[{UIColors.HIGHLIGHT}]Nested Django apps? (y/N):[/{UIColors.HIGHLIGHT}]", default="n" ) if choice != "y": @@ -244,8 +243,7 @@ class InputCollector: console.print() choice = CharReader.get_yes_no( - f"[{UIColors.HIGHLIGHT}]Use DATABASE_URL? (Y/n):[/{UIColors.HIGHLIGHT}]", - default="y" + f"[{UIColors.HIGHLIGHT}]Use DATABASE_URL? (Y/n):[/{UIColors.HIGHLIGHT}]", default="y" ) # Return True if choice is 'y', False if 'n' @@ -265,46 +263,48 @@ class CharReader: def get_yes_no(prompt: str, default: str = "n") -> str: max_attempts = 3 attempt = 0 - + while attempt < max_attempts: try: console.print(f"{prompt} ", end="") sys.stdout.flush() - + reader = CharReader() response = reader.get_char() - + # Handle Enter key (default) - in raw mode, Enter is '\r' (carriage return) # Also handle empty string or other control characters as Enter - if not response or response == '\r' or response == '\n' or ord(response) < 32: + if not response or response == "\r" or response == "\n" or ord(response) < 32: console.print(default.upper()) return default.lower() - + response = response.lower() - + # Validate response - if response in ('y', 'n'): + if response in ("y", "n"): console.print(response.upper()) return response - + # Invalid input - show error (only show if it's a printable character) console.print() if response.isprintable(): UIFormatter.print_error(f"Invalid input '{response}'. Please enter 'y' or 'n'.") attempt += 1 if attempt < max_attempts: - console.print(f"[{UIColors.MUTED}]Please try again ({attempt}/{max_attempts}).[/{UIColors.MUTED}]") + console.print( + f"[{UIColors.MUTED}]Please try again ({attempt}/{max_attempts}).[/{UIColors.MUTED}]" + ) console.print() else: # Control character entered, treat as Enter console.print(default.upper()) return default.lower() - + except KeyboardInterrupt: console.print() UIFormatter.print_info("\nOperation cancelled by user.") raise - + # Max attempts reached - use default console.print() UIFormatter.print_warning(f"Maximum attempts reached. Using default: '{default.upper()}'.") @@ -314,40 +314,40 @@ class CharReader: def get_cicd_choice(prompt: str, valid_keys: list[str], default: str = "n") -> str: """ Get CI/CD choice input with validation and retries. - + Args: prompt: The prompt message to display valid_keys: List of valid key characters (e.g., ['g', 'l', 'b', 'n']) default: Default value if Enter is pressed - + Returns: Valid key (lowercase) """ max_attempts = 3 attempt = 0 valid_keys_lower = [k.lower() for k in valid_keys] - + while attempt < max_attempts: try: console.print(f"{prompt} ", end="") sys.stdout.flush() - + reader = CharReader() response = reader.get_char() - + # Handle Enter key (default) - in raw mode, Enter is '\r' (carriage return) # Also handle empty string or other control characters as Enter - if not response or response == '\r' or response == '\n' or ord(response) < 32: + if not response or response == "\r" or response == "\n" or ord(response) < 32: console.print(default.upper()) return default.lower() - + response = response.lower() - + # Validate response if response in valid_keys_lower: console.print(response.upper()) return response - + # Invalid input - show error (only show if it's a printable character) console.print() if response.isprintable(): @@ -355,18 +355,20 @@ class CharReader: UIFormatter.print_error(f"Invalid input '{response}'. Please enter one of: {valid_keys_str}.") attempt += 1 if attempt < max_attempts: - console.print(f"[{UIColors.MUTED}]Please try again ({attempt}/{max_attempts}).[/{UIColors.MUTED}]") + console.print( + f"[{UIColors.MUTED}]Please try again ({attempt}/{max_attempts}).[/{UIColors.MUTED}]" + ) console.print() else: # Control character entered, treat as Enter console.print(default.upper()) return default.lower() - + except KeyboardInterrupt: console.print() UIFormatter.print_info("\nOperation cancelled by user.") raise - + # Max attempts reached - use default console.print() UIFormatter.print_warning(f"Maximum attempts reached. Using default: '{default.upper()}'.") @@ -414,21 +416,23 @@ def get_user_input() -> Tuple[str, str, str, list, dict]: console.print(f"[{UIColors.HIGHLIGHT}]Project Directory[/{UIColors.HIGHLIGHT}]") console.print(f"[{UIColors.MUTED}]Where your project files will be created[/{UIColors.MUTED}]") console.print(f"[{UIColors.MUTED}]Press Enter or enter '.' to create in current directory[/{UIColors.MUTED}]") - + # Get project directory with special handling for '.' and empty input attempt = 0 project_dir = None - + while attempt < collector.MAX_ATTEMPTS and project_dir is None: try: - user_input = console.input(f"[{UIColors.HIGHLIGHT}]Enter project directory name:[/{UIColors.HIGHLIGHT}] ").strip() - + user_input = console.input( + f"[{UIColors.HIGHLIGHT}]Enter project directory name:[/{UIColors.HIGHLIGHT}] " + ).strip() + # Handle empty input or '.' - use current directory if not user_input or user_input == ".": project_dir = "." UIFormatter.print_info(f"Creating project in current directory: {os.getcwd()}") break - + # Validate the input is_valid, error_msg = validate_project_name(user_input) if not is_valid: @@ -436,26 +440,28 @@ def get_user_input() -> Tuple[str, str, str, list, dict]: attempt += 1 collector._show_retry_message(attempt) continue - + # Check if directory exists and ask for alternative if os.path.exists(user_input): UIFormatter.print_error(f"Directory '{user_input}' already exists.") - UIFormatter.print_info("Please choose a different project directory name (or press Enter for current directory).") + UIFormatter.print_info( + "Please choose a different project directory name (or press Enter for current directory)." + ) attempt += 1 collector._show_retry_message(attempt) continue - + project_dir = user_input break - + except KeyboardInterrupt: UIFormatter.print_info("\nSetup cancelled by user.") sys.exit(0) - + if project_dir is None: UIFormatter.print_error("Maximum attempts reached for project directory name. Exiting.") sys.exit(1) - + console.print() console.print(f"[{UIColors.HIGHLIGHT}]Django Project Name[/{UIColors.HIGHLIGHT}]") @@ -524,8 +530,7 @@ def confirm_setup(project_dir: str, project_name: str, app_names: list, metadata # Get confirmation try: response = CharReader.get_yes_no( - f"[{UIColors.WARNING}]Proceed with setup? (y/N):[/{UIColors.WARNING}]", - default="n" + f"[{UIColors.WARNING}]Proceed with setup? (y/N):[/{UIColors.WARNING}]", default="n" ) return response == "y" -- 2.43.0 From 022a5a90aff1d9668af9e842a9e62700cdccacce Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Mon, 3 Nov 2025 00:20:21 +0545 Subject: [PATCH 03/15] refactor: streamline project setup and file generation --- djinit/cli.py | 32 +- djinit/generator.py | 8 - djinit/scripts/app_generator.py | 204 ++---------- djinit/scripts/cicd_config_generator.py | 41 --- djinit/scripts/command_handler.py | 22 +- djinit/scripts/deployment_files_generator.py | 118 ------- djinit/scripts/django_helper.py | 123 +++++++ djinit/scripts/files.py | 152 ++++----- djinit/scripts/input_handler.py | 309 +++++++----------- djinit/scripts/project.py | 119 ++++--- djinit/scripts/settings.py | 86 ----- djinit/scripts/template_engine.py | 1 - djinit/templates/app/admin.j2 | 4 + djinit/templates/app/apps.j2 | 7 + djinit/templates/app/migrations_init.j2 | 0 djinit/templates/app/models.j2 | 4 + djinit/templates/{ => app}/routes.j2 | 0 djinit/templates/{ => app}/serializers.j2 | 0 djinit/templates/app/tests.j2 | 4 + djinit/templates/{app_urls.j2 => app/urls.j2} | 0 djinit/templates/app/views.j2 | 4 + djinit/templates/{ => project}/asgi.j2 | 0 djinit/templates/{ => project}/manage_py.j2 | 0 .../templates/{ => project}/project_urls.j2 | 0 .../templates/{ => project}/settings_base.j2 | 0 .../{ => project}/settings_development.j2 | 0 .../{ => project}/settings_production.j2 | 0 djinit/templates/project/urls.j2 | 23 ++ djinit/templates/{ => project}/wsgi.j2 | 0 djinit/templates/{ => shared}/env_sample.j2 | 0 .../{ => shared}/github_actions_ci.j2 | 0 djinit/templates/{ => shared}/gitignore.j2 | 0 djinit/templates/{ => shared}/gitlab_ci.j2 | 0 djinit/templates/shared/init.j2 | 0 djinit/templates/{ => shared}/justfile.j2 | 0 djinit/templates/{ => shared}/procfile.j2 | 0 .../templates/{ => shared}/pyproject_toml.j2 | 0 djinit/templates/{ => shared}/readme.j2 | 0 djinit/templates/{ => shared}/requirements.j2 | 2 +- djinit/templates/{ => shared}/runtime_txt.j2 | 0 djinit/utils.py | 232 ++++++++++++- pyproject.toml | 1 - 42 files changed, 695 insertions(+), 801 deletions(-) delete mode 100644 djinit/scripts/cicd_config_generator.py delete mode 100644 djinit/scripts/deployment_files_generator.py create mode 100644 djinit/scripts/django_helper.py delete mode 100644 djinit/scripts/settings.py create mode 100644 djinit/templates/app/admin.j2 create mode 100644 djinit/templates/app/apps.j2 create mode 100644 djinit/templates/app/migrations_init.j2 create mode 100644 djinit/templates/app/models.j2 rename djinit/templates/{ => app}/routes.j2 (100%) rename djinit/templates/{ => app}/serializers.j2 (100%) create mode 100644 djinit/templates/app/tests.j2 rename djinit/templates/{app_urls.j2 => app/urls.j2} (100%) create mode 100644 djinit/templates/app/views.j2 rename djinit/templates/{ => project}/asgi.j2 (100%) rename djinit/templates/{ => project}/manage_py.j2 (100%) rename djinit/templates/{ => project}/project_urls.j2 (100%) rename djinit/templates/{ => project}/settings_base.j2 (100%) rename djinit/templates/{ => project}/settings_development.j2 (100%) rename djinit/templates/{ => project}/settings_production.j2 (100%) create mode 100644 djinit/templates/project/urls.j2 rename djinit/templates/{ => project}/wsgi.j2 (100%) rename djinit/templates/{ => shared}/env_sample.j2 (100%) rename djinit/templates/{ => shared}/github_actions_ci.j2 (100%) rename djinit/templates/{ => shared}/gitignore.j2 (100%) rename djinit/templates/{ => shared}/gitlab_ci.j2 (100%) create mode 100644 djinit/templates/shared/init.j2 rename djinit/templates/{ => shared}/justfile.j2 (100%) rename djinit/templates/{ => shared}/procfile.j2 (100%) rename djinit/templates/{ => shared}/pyproject_toml.j2 (100%) rename djinit/templates/{ => shared}/readme.j2 (100%) rename djinit/templates/{ => shared}/requirements.j2 (92%) rename djinit/templates/{ => shared}/runtime_txt.j2 (100%) diff --git a/djinit/cli.py b/djinit/cli.py index bf648fe..f17d3a3 100644 --- a/djinit/cli.py +++ b/djinit/cli.py @@ -6,13 +6,10 @@ Coordinates between different managers to create a complete Django project. import os from typing import Callable, List, Tuple -from djinit.scripts.cicd_config_generator import CICDConfigGenerator from djinit.scripts.console_ui import UIFormatter -from djinit.scripts.deployment_files_generator import DeploymentConfigGenerator from djinit.scripts.files import FileManager from djinit.scripts.project import ProjectManager from djinit.scripts.secretkey_generator import generate_secret_command -from djinit.scripts.settings import SettingsManager class Cli: @@ -22,39 +19,24 @@ class Cli: self.primary_app = primary_app self.app_names = app_names self.metadata = metadata - # Handle '.' for current directory - if project_dir == ".": + if project_dir == ".": # handle '.' for current directory self.project_root = os.getcwd() else: self.project_root = os.path.join(os.getcwd(), project_dir) - # Initialize managers self.project_manager = ProjectManager(project_dir, project_name, app_names, metadata) - self.settings_manager = SettingsManager(self.project_root, project_name, app_names, metadata) self.file_manager = FileManager(self.project_root, project_name, app_names, metadata) - self.deployment_manager = DeploymentConfigGenerator(self.project_root, project_name) - self.cicd_manager = CICDConfigGenerator(self.project_root, project_name) def run_setup(self) -> bool: steps: List[Tuple[str, Callable[[], bool]]] = [ ("Creating Django project", self.project_manager.create_project), ("Creating Django apps", self.project_manager.create_apps), + ("Creating project URLs", self.file_manager.create_project_urls), ("Validating project structure", self.project_manager.validate_project_structure), - ("Setting up project structure", self.settings_manager.create_settings_structure), - ("Configuring base settings", self.settings_manager.update_base_settings), - ("Configuring development settings", self.settings_manager.update_development_settings), - ("Configuring production settings", self.settings_manager.update_production_settings), ("Creating utility files", self._create_utility_files), - ("Setting up app URLs", self.file_manager.create_app_urls), - ("Creating app serializers", self.file_manager.create_app_serializers), - ("Creating app routes", self.file_manager.create_app_routes), - ("Configuring comprehensive URLs", self.file_manager.update_project_urls), - ("Updating WSGI configuration", self.file_manager.update_wsgi_file), - ("Updating ASGI configuration", self.file_manager.update_asgi_file), - ("Updating manage.py", self.file_manager.update_manage_py), - ("Creating Justfile", self.deployment_manager.create_justfile), - ("Creating Procfile", self.deployment_manager.create_procfile), - ("Creating runtime.txt", lambda: self.deployment_manager.create_runtime_txt("3.13")), + ("Creating Procfile", self.file_manager.create_procfile), + ("Creating Justfile", self.file_manager.create_justfile), + ("Creating runtime.txt", self.file_manager.create_runtime_txt), ("Creating CI/CD pipelines", self._create_cicd_pipelines), ] @@ -97,12 +79,12 @@ class Cli: def _create_cicd_pipelines(self) -> bool: if self.metadata.get("use_github_actions", True): - result = self.cicd_manager.create_github_actions() + result = self.file_manager.create_github_actions() if not result: return False if self.metadata.get("use_gitlab_ci", True): - result = self.cicd_manager.create_gitlab_ci() + result = self.file_manager.create_gitlab_ci() if not result: return False diff --git a/djinit/generator.py b/djinit/generator.py index 2c97a5b..2a6691d 100644 --- a/djinit/generator.py +++ b/djinit/generator.py @@ -25,7 +25,6 @@ def clear_screen() -> None: def main() -> None: - # Parse command line arguments args = parse_arguments() # Handle secret command @@ -39,7 +38,6 @@ def main() -> None: return # Default to setup command - # Clear screen clear_screen() # Display welcome screen @@ -47,15 +45,12 @@ def main() -> None: console.print(UIFormatter.create_welcome_panel()) console.print() - # Get user input project_dir, project_name, primary_app, app_names, metadata = get_user_input() - # Confirm setup if not confirm_setup(project_dir, project_name, app_names, metadata): UIFormatter.print_info("Setup cancelled by user.") return - # Run setup console.print() UIFormatter.print_info("Starting Django project setup...") console.print() @@ -63,10 +58,7 @@ def main() -> None: django_cli = Cli(project_dir, project_name, primary_app, app_names, metadata) success = django_cli.run_setup() - # Display completion summary UIFormatter.create_summary_panel(project_dir, project_name, app_names, True) - - # Exit with appropriate code sys.exit(0 if success else 1) diff --git a/djinit/scripts/app_generator.py b/djinit/scripts/app_generator.py index 02b69a2..5ed1037 100644 --- a/djinit/scripts/app_generator.py +++ b/djinit/scripts/app_generator.py @@ -4,13 +4,19 @@ Handles creation of Django apps and updating settings. """ import os -import subprocess -import sys from typing import Optional from djinit.scripts.console_ui import UIFormatter -from djinit.scripts.template_engine import template_engine -from djinit.utils import change_cwd, create_file_with_content +from djinit.scripts.django_helper import DjangoHelper +from djinit.utils import ( + calculate_app_module_path, + detect_nested_structure_from_settings, + extract_existing_apps, + find_project_dir, + find_settings_path, + insert_apps_into_user_defined_apps, + is_django_project, +) class AppManager: @@ -36,43 +42,36 @@ class AppManager: if not self._add_to_installed_apps(): return False - if not self._create_additional_files(): - return False - UIFormatter.print_success(f"Django app '{self.app_name}' created and configured successfully!") return True def _is_django_project(self) -> bool: - return os.path.exists(self.manage_py_path) + return is_django_project(self.current_dir) def _app_exists(self) -> bool: - # Detect project structure to check in correct location is_nested, nested_dir, apps_base_dir = self._detect_project_structure() app_path = os.path.join(apps_base_dir, self.app_name) return os.path.exists(app_path) def _create_django_app(self) -> bool: - # Detect project structure + # Find where to create the app (flat or nested structure) is_nested, nested_dir, apps_base_dir = self._detect_project_structure() - # manage.py is always in the project root (current_dir), not in the project directory + # Verify manage.py exists in project root manage_py_path = os.path.join(self.current_dir, "manage.py") if not os.path.exists(manage_py_path): UIFormatter.print_error("Could not find manage.py file in project root") return False - # Create app in the correct location - # Use 'python -m django' instead of 'django-admin' for better compatibility - with change_cwd(apps_base_dir): - subprocess.run( - [sys.executable, "-m", "django", "startapp", self.app_name], capture_output=True, text=True, check=True - ) - - UIFormatter.print_success(f"Created Django app '{self.app_name}' in {apps_base_dir}") - return True + success = DjangoHelper.startapp(self.app_name, apps_base_dir) + if success: + UIFormatter.print_success(f"Created Django app '{self.app_name}' in {apps_base_dir}") + else: + UIFormatter.print_error(f"Failed to create Django app '{self.app_name}'") + return success def _add_to_installed_apps(self) -> bool: - settings_path = self._find_settings_path() + settings_path = find_settings_path(self.current_dir) if not settings_path: UIFormatter.print_error("Could not find Django settings directory") return False @@ -85,30 +84,13 @@ class AppManager: with open(base_settings_path) as f: content = f.read() - # Detect project structure to determine the correct app module path + # Get the app's module path (e.g., "apps.users" or just "users") is_nested, nested_dir, apps_base_dir = self._detect_project_structure() + app_module_path = calculate_app_module_path(self.app_name, is_nested, nested_dir) - # Determine the correct module path for the app - if is_nested and nested_dir: - app_module_path = f"{nested_dir}.{self.app_name}" - else: - app_module_path = self.app_name - - # Check if app is already in USER_DEFINED_APPS section specifically - lines = content.split("\n") - in_user_apps = False - app_already_exists = False - - for line in lines: - if "USER_DEFINED_APPS" in line and "=" in line: - in_user_apps = True - elif in_user_apps and line.strip() == "]": - in_user_apps = False - elif in_user_apps and (f'"{app_module_path}"' in line or f"'{app_module_path}'" in line): - app_already_exists = True - break - - if app_already_exists: + # Check if app is already in USER_DEFINED_APPS + existing_apps = extract_existing_apps(content) + if app_module_path in existing_apps: UIFormatter.print_success(f"App '{app_module_path}' already configured in USER_DEFINED_APPS") return True @@ -116,143 +98,23 @@ class AppManager: UIFormatter.print_error("Could not find USER_DEFINED_APPS section in base.py") return False - lines = content.split("\n") - new_lines = [] - in_user_apps = False - apps_added = False - - for line in lines: - if "USER_DEFINED_APPS" in line and "=" in line: - in_user_apps = True - new_lines.append(line) - elif in_user_apps and line.strip() == "]": - new_lines.append(f' "{app_module_path}",') - new_lines.append(line) - in_user_apps = False - apps_added = True - else: - new_lines.append(line) - - if not apps_added: - UIFormatter.print_error("Could not find USER_DEFINED_APPS section in base.py") + # Add app to USER_DEFINED_APPS list in settings file + updated_content = insert_apps_into_user_defined_apps(content, [app_module_path]) + if not updated_content: return False with open(base_settings_path, "w") as f: - f.write("\n".join(new_lines)) + f.write(updated_content) UIFormatter.print_success(f"Added '{app_module_path}' to USER_DEFINED_APPS in base.py") return True - def _find_settings_path(self) -> Optional[str]: - # Look for project directories that contain settings - for item in os.listdir(self.current_dir): - if os.path.isdir(item) and not item.startswith(".") and item != "__pycache__": - # Check if this directory contains Django project files - project_path = os.path.join(self.current_dir, item) - settings_path = os.path.join(project_path, "settings") - - # Check if settings directory exists with base.py - if os.path.exists(settings_path) and os.path.exists(os.path.join(settings_path, "base.py")): - return settings_path - - # Fallback: look for settings in current directory - settings_path = os.path.join(self.current_dir, "settings") - if os.path.exists(settings_path) and os.path.exists(os.path.join(settings_path, "base.py")): - return settings_path - - return None - def _detect_project_structure(self) -> tuple[bool, Optional[str], str]: - # Step 1: find project directory containing settings/base.py - project_dir: Optional[str] = None - settings_base_path: Optional[str] = None - for item in os.listdir(self.current_dir): - if os.path.isdir(item) and not item.startswith(".") and item != "__pycache__": - candidate = os.path.join(self.current_dir, item) - base_py = os.path.join(candidate, "settings", "base.py") - if os.path.exists(base_py): - project_dir = candidate - settings_base_path = base_py - break - - # Fallback: current_dir/settings/base.py - if project_dir is None: - base_py = os.path.join(self.current_dir, "settings", "base.py") - if os.path.exists(base_py): - project_dir = self.current_dir - settings_base_path = base_py + # Find project directory with settings/base.py + project_dir, settings_base_path = find_project_dir(self.current_dir) if project_dir is None: - # Not a Django project structure return False, None, self.current_dir - # Step 2: try to infer nested_dir from USER_DEFINED_APPS in base.py - try: - if settings_base_path and os.path.exists(settings_base_path): - with open(settings_base_path) as f: - base_content = f.read() - # Find lines inside USER_DEFINED_APPS and extract first dotted app - in_user_apps = False - for line in base_content.splitlines(): - if "USER_DEFINED_APPS" in line and "=" in line: - in_user_apps = True - continue - if in_user_apps and "]" in line: - break - if in_user_apps: - line_stripped = line.strip().strip(",") - if line_stripped.startswith('"') or line_stripped.startswith("'"): - app_label = line_stripped.strip("\"'") - if "." in app_label: - prefix = app_label.split(".", 1)[0] - nested_path = os.path.join(self.current_dir, prefix) - if os.path.isdir(nested_path): - return True, prefix, nested_path - except Exception: - # Ignore parsing issues and continue with heuristics - pass - - # Step 3: look for existing package directories in project root - preferred = ["apps", "modules", "packages"] - for name in preferred: - nested_path = os.path.join(self.current_dir, name) - if os.path.isdir(nested_path) and os.path.exists(os.path.join(nested_path, "__init__.py")): - return True, name, nested_path - - # Any other directory with __init__.py qualifies as a package - for item in os.listdir(self.current_dir): - if item in ("__pycache__",) or item.startswith("."): - continue - nested_path = os.path.join(self.current_dir, item) - if os.path.isdir(nested_path) and os.path.exists(os.path.join(nested_path, "__init__.py")): - return True, item, nested_path - - # Step 4: fallback - return False, None, self.current_dir - - def _create_additional_files(self) -> bool: - """Create additional files for the app (serializers.py, routes.py).""" - # Detect project structure to get correct app path - is_nested, nested_dir, apps_base_dir = self._detect_project_structure() - app_path = os.path.join(apps_base_dir, self.app_name) - - # Create serializers.py - context = {"app_name": self.app_name} - serializers_content = template_engine.render_template("serializers.j2", context) - create_file_with_content( - os.path.join(app_path, "serializers.py"), - serializers_content, - f"Created {self.app_name}/serializers.py", - should_format=True, - ) - - # Create routes.py - routes_content = template_engine.render_template("routes.j2", context) - create_file_with_content( - os.path.join(app_path, "routes.py"), - routes_content, - f"Created {self.app_name}/routes.py", - should_format=True, - ) - - return True + # Detect nested structure from settings file + return detect_nested_structure_from_settings(settings_base_path, self.current_dir) diff --git a/djinit/scripts/cicd_config_generator.py b/djinit/scripts/cicd_config_generator.py deleted file mode 100644 index df5b7b9..0000000 --- a/djinit/scripts/cicd_config_generator.py +++ /dev/null @@ -1,41 +0,0 @@ -""" -CI/CD pipeline manager for djinit -Handles creation of Github Actions and GitLab CI onfigurations -""" - -import os - -from djinit.scripts.template_engine import template_engine -from djinit.utils import change_cwd, create_file_with_content - - -class CICDConfigGenerator: - def __init__(self, project_root: str, project_name: str): - self.project_root = project_root - self.project_name = project_name - - def create_github_actions(self) -> bool: - with change_cwd(self.project_root): - github_dir = os.path.join(self.project_root, ".github", "workflows") - os.makedirs(github_dir, exist_ok=True) - - context = {"project_name": self.project_name} - workflow_content = template_engine.render_template("github_actions_ci.j2", context) - workflow_file = os.path.join(github_dir, "ci.yml") - create_file_with_content( - workflow_file, - workflow_content, - "Created Github Actions workflow (ci.yml)", - ) - return True - - def create_gitlab_ci(self) -> bool: - with change_cwd(self.project_root): - context = {"project_name": self.project_name} - gitlab_ci_content = template_engine.render_template("gitlab_ci.j2", context) - create_file_with_content( - ".gitlab-ci.yml", - gitlab_ci_content, - "Created GitLab CI configuration (.gitlab-ci.yml)", - ) - return True diff --git a/djinit/scripts/command_handler.py b/djinit/scripts/command_handler.py index 9f2901a..06d0c56 100644 --- a/djinit/scripts/command_handler.py +++ b/djinit/scripts/command_handler.py @@ -6,8 +6,13 @@ Handles different commands like secret key generation and app creation. import argparse import sys +from rich.panel import Panel +from rich.text import Text + +from djinit.scripts.app_generator import AppManager from djinit.scripts.console_ui import UIColors, UIFormatter, console from djinit.scripts.name_validator import validate_app_name +from djinit.scripts.secretkey_generator import display_secret_keys, generate_multiple_keys def parse_arguments() -> argparse.Namespace: @@ -43,19 +48,10 @@ def parse_arguments() -> argparse.Namespace: def handle_secret_command(args: argparse.Namespace) -> None: - from djinit.scripts.secretkey_generator import display_secret_keys, generate_multiple_keys - - # Generate keys keys = generate_multiple_keys(args.count, args.length) - - # Display results UIFormatter.print_info("") display_secret_keys(keys) - # Show usage instructions - from rich.panel import Panel - from rich.text import Text - instructions = Text() instructions.append("📋 Usage Instructions:\n", style=UIColors.ACCENT) instructions.append("1. Copy the appropriate secret key for your environment\n") @@ -70,9 +66,6 @@ def handle_secret_command(args: argparse.Namespace) -> None: def handle_app_command(args: argparse.Namespace) -> None: - from djinit.scripts.app_generator import AppManager - - # Display welcome message UIFormatter.print_info("") UIFormatter.print_header("Django App Creation") UIFormatter.print_info("") @@ -103,7 +96,6 @@ def handle_app_command(args: argparse.Namespace) -> None: deduped_names.append(name) seen.add(name) - # Validate all names first for name in deduped_names: is_valid, error_msg = validate_app_name(name) if not is_valid: @@ -124,10 +116,6 @@ def handle_app_command(args: argparse.Namespace) -> None: if any_failure: sys.exit(1) - # Show next steps - from rich.panel import Panel - from rich.text import Text - instructions = Text() instructions.append("🚀 Next Steps:\n", style=UIColors.ACCENT) instructions.append("1. The app(s) have been added to INSTALLED_APPS in settings/base.py\n") diff --git a/djinit/scripts/deployment_files_generator.py b/djinit/scripts/deployment_files_generator.py deleted file mode 100644 index 5bf0d95..0000000 --- a/djinit/scripts/deployment_files_generator.py +++ /dev/null @@ -1,118 +0,0 @@ -""" -Deployment file generator for djinit. -Handles creation of Justfile, Procfile, and deployment-related files. -""" - -from djinit.scripts.template_engine import template_engine -from djinit.utils import change_cwd, create_file_with_content - - -class DeploymentConfigGenerator: - def __init__(self, project_root: str, project_name: str): - self.project_root = project_root - self.project_name = project_name - - def create_justfile(self) -> bool: - with change_cwd(self.project_root): - context = {"project_name": self.project_name} - justfile_content = template_engine.render_template("justfile.j2", context) - create_file_with_content( - "justfile", - justfile_content, - "Created justfile with Django development tasks", - ) - return True - - def create_procfile(self) -> bool: - with change_cwd(self.project_root): - context = {"project_name": self.project_name} - procfile_content = template_engine.render_template("procfile.j2", context) - create_file_with_content( - "Procfile", - procfile_content, - "Created Procfile with Gunicorn configuration", - ) - return True - - def create_gunicorn_config(self) -> bool: - with change_cwd(self.project_root): - gunicorn_config_content = f"""# Gunicorn configuration file for {self.project_name} -# Usage: gunicorn -c gunicorn_config.py {self.project_name}.wsgi:application - -import multiprocessing - -# Server socket -bind = "0.0.0.0:8000" -backlog = 2048 - -# Worker processes -workers = multiprocessing.cpu_count() * 2 + 1 -worker_class = "sync" -worker_connections = 1000 -timeout = 120 -keepalive = 5 - -# Logging -accesslog = "-" -errorlog = "-" -loglevel = "info" -access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' - -# Process naming -proc_name = "{self.project_name}" - -# Server mechanics -daemon = False -pidfile = None -umask = 0 -user = None -group = None -tmp_upload_dir = None - -# SSL (uncomment and configure if needed) -# keyfile = "/path/to/key.pem" -# certfile = "/path/to/cert.pem" -""" - - create_file_with_content( - "gunicorn_config.py", - gunicorn_config_content, - "Created gunicorn_config.py with Gunicorn configuration", - ) - return True - - def create_runtime_txt(self, python_version: str = "3.13") -> bool: - with change_cwd(self.project_root): - context = {"python_version": python_version} - runtime_content = template_engine.render_template("runtime_txt.j2", context) - create_file_with_content( - "runtime.txt", - runtime_content, - "Created runtime.txt with Python version specification", - ) - return True - - def create_nixpacks_toml(self) -> bool: - with change_cwd(self.project_root): - nixpacks_content = f"""# Nixpacks configuration for {self.project_name} -# Used by Railway, Render, and other platforms that support Nixpacks - -[phases.setup] -nixPkgs = ["python311"] - -[phases.install] -cmds = ["pip install --upgrade pip", "pip install -r requirements.txt"] - -[phases.build] -cmds = ["python manage.py collectstatic --noinput"] - -[start] -cmd = "gunicorn {self.project_name}.wsgi:application --bind 0.0.0.0:$PORT" -""" - - create_file_with_content( - "nixpacks.toml", - nixpacks_content, - "Created nixpacks.toml for Nixpacks deployment", - ) - return True diff --git a/djinit/scripts/django_helper.py b/djinit/scripts/django_helper.py new file mode 100644 index 0000000..c28d781 --- /dev/null +++ b/djinit/scripts/django_helper.py @@ -0,0 +1,123 @@ +""" +Django helper module that replicates Django's startproject and startapp functionality. + +This module provides functions to create Django projects and apps without requiring Django. +""" + +import os + +from djinit.scripts.console_ui import UIFormatter +from djinit.utils import create_file_from_template, create_init_file + + +class DjangoHelper: + # Django version to use in generated files (matches Django 5.2) + DJANGO_VERSION = "5.2" + + @staticmethod + def startproject(project_name: str, directory: str) -> bool: + try: + os.makedirs(directory, exist_ok=True) + + # Create manage.py + manage_py_path = os.path.join(directory, "manage.py") + create_file_from_template(manage_py_path, "project/manage_py.j2", {}, "Created manage.py") + os.chmod(manage_py_path, 0o755) + + # Create project config directory + project_config_dir = os.path.join(directory, project_name) + os.makedirs(project_config_dir, exist_ok=True) + create_init_file(project_config_dir, f"Created {project_name}/__init__.py") + + # Create settings directory and files + settings_dir = os.path.join(project_config_dir, "settings") + os.makedirs(settings_dir, exist_ok=True) + create_init_file(settings_dir, f"Created {project_name}/settings/__init__.py") + + base_context = {"project_name": project_name, "app_names": []} + create_file_from_template( + os.path.join(settings_dir, "base.py"), + "project/settings_base.j2", + base_context, + f"Created {project_name}/settings/base.py", + ) + create_file_from_template( + os.path.join(settings_dir, "development.py"), + "project/settings_development.j2", + {}, + f"Created {project_name}/settings/development.py", + ) + create_file_from_template( + os.path.join(settings_dir, "production.py"), + "project/settings_production.j2", + {}, + f"Created {project_name}/settings/production.py", + ) + + # Create project-level files + urls_context = {"project_name": project_name, "django_version": DjangoHelper.DJANGO_VERSION} + create_file_from_template( + os.path.join(project_config_dir, "urls.py"), + "project/urls.j2", + urls_context, + f"Created {project_name}/urls.py", + ) + create_file_from_template( + os.path.join(project_config_dir, "wsgi.py"), + "project/wsgi.j2", + {}, + f"Created {project_name}/wsgi.py", + ) + create_file_from_template( + os.path.join(project_config_dir, "asgi.py"), + "project/asgi.j2", + {}, + f"Created {project_name}/asgi.py", + ) + + return True + + except Exception as e: + UIFormatter.print_error(f"Error creating Django project: {e}") + return False + + @staticmethod + def startapp(app_name: str, directory: str) -> bool: + try: + app_dir = os.path.join(directory, app_name) + os.makedirs(app_dir, exist_ok=True) + + context = {"app_name": app_name, "django_version": DjangoHelper.DJANGO_VERSION} + + # Create app files + create_init_file(app_dir, f"Created {app_name}/__init__.py") + + app_files = [ + ("apps.py", "app/apps.j2"), + ("models.py", "app/models.j2"), + ("views.py", "app/views.j2"), + ("admin.py", "app/admin.j2"), + ("urls.py", "app/urls.j2"), + ("serializers.py", "app/serializers.j2"), + ("routes.py", "app/routes.j2"), + ("tests.py", "app/tests.j2"), + ] + + for filename, template_path in app_files: + create_file_from_template( + os.path.join(app_dir, filename), + template_path, + context, + f"Created {app_name}/{filename}", + ) + + # Create migrations directory and __init__.py + migrations_dir = os.path.join(app_dir, "migrations") + os.makedirs(migrations_dir, exist_ok=True) + create_init_file(migrations_dir, f"Created {app_name}/migrations/__init__.py") + + return True + + except Exception as e: + UIFormatter.print_error(f"Error creating Django app: {e}") + return False diff --git a/djinit/scripts/files.py b/djinit/scripts/files.py index ca00806..8c0dda4 100644 --- a/djinit/scripts/files.py +++ b/djinit/scripts/files.py @@ -6,7 +6,11 @@ Handles creation and modification of project files. import os from djinit.scripts.template_engine import template_engine -from djinit.utils import change_cwd, create_file_with_content +from djinit.utils import ( + calculate_app_module_paths, + change_cwd, + create_file_with_content, +) class FileManager: @@ -21,7 +25,7 @@ class FileManager: def create_gitignore(self) -> bool: with change_cwd(self.project_root): - gitignore_content = template_engine.render_template("gitignore.j2", {}) + gitignore_content = template_engine.render_template("shared/gitignore.j2", {}) return create_file_with_content( ".gitignore", gitignore_content, @@ -31,7 +35,7 @@ class FileManager: def create_requirements(self) -> bool: with change_cwd(self.project_root): context = {"use_database_url": self.metadata.get("use_database_url", True)} - requirements_content = template_engine.render_template("requirements.j2", context) + requirements_content = template_engine.render_template("shared/requirements.j2", context) return create_file_with_content( "requirements.txt", requirements_content, @@ -41,7 +45,7 @@ class FileManager: def create_readme(self) -> bool: with change_cwd(self.project_root): context = {"project_name": self.project_name, "app_names": self.app_names} - readme_content = template_engine.render_template("readme.j2", context) + readme_content = template_engine.render_template("shared/readme.j2", context) return create_file_with_content( "README.md", readme_content, @@ -55,7 +59,7 @@ class FileManager: "project_name": self.project_name, "use_database_url": self.metadata.get("use_database_url", True), } - env_content = template_engine.render_template("env_sample.j2", context) + env_content = template_engine.render_template("shared/env_sample.j2", context) return create_file_with_content( ".env.sample", env_content, @@ -65,99 +69,85 @@ class FileManager: def create_pyproject_toml(self, metadata: dict) -> bool: with change_cwd(self.project_root): context = {"package_name": metadata["package_name"], "project_name": self.project_name} - pyproject_content = template_engine.render_template("pyproject_toml.j2", context) + pyproject_content = template_engine.render_template("shared/pyproject_toml.j2", context) return create_file_with_content( "pyproject.toml", pyproject_content, "Created pyproject.toml file for uv", ) - def _get_apps_base_dir(self) -> str: - """Get the base directory for apps based on project structure.""" - if self.metadata.get("nested_apps") and self.metadata.get("nested_dir"): - return os.path.join(self.project_root, self.metadata.get("nested_dir")) - return self.project_root - - def _create_app_file(self, apps_base_dir: str, app_name: str, template_name: str, filename: str) -> None: - """Helper method to create app files.""" - app_path = os.path.join(apps_base_dir, app_name) - with change_cwd(app_path): - context = {"app_name": app_name} - content = template_engine.render_template(template_name, context) - create_file_with_content( - filename, - content, - f"Created {app_name}/{filename}", - should_format=True, - ) - - def create_app_urls(self) -> bool: - apps_base_dir = self._get_apps_base_dir() - for app_name in self.app_names: - self._create_app_file(apps_base_dir, app_name, "app_urls.j2", "urls.py") - return True - - def create_app_serializers(self) -> bool: - """Create serializers.py file for each app.""" - apps_base_dir = self._get_apps_base_dir() - for app_name in self.app_names: - self._create_app_file(apps_base_dir, app_name, "serializers.j2", "serializers.py") - return True - - def create_app_routes(self) -> bool: - """Create routes.py file for each app.""" - apps_base_dir = self._get_apps_base_dir() - for app_name in self.app_names: - self._create_app_file(apps_base_dir, app_name, "routes.j2", "routes.py") - return True - - def update_project_urls(self) -> bool: + def create_project_urls(self) -> bool: + """Create project urls.py using project_urls.j2 template (replaces update_project_urls).""" with change_cwd(self.project_configs): - nested = bool(self.metadata.get("nested_apps")) - nested_dir = self.metadata.get("nested_dir") - effective_app_modules = [ - f"{nested_dir}.{name}" if nested and nested_dir else name for name in self.app_names - ] + effective_app_modules = calculate_app_module_paths(self.app_names, self.metadata) context = { "project_name": self.project_name, "app_names": effective_app_modules, } - urls_content = template_engine.render_template("project_urls.j2", context) + urls_content = template_engine.render_template("project/project_urls.j2", context) result = create_file_with_content( "urls.py", urls_content, - "Updated project urls.py with comprehensive URL configuration", + "Created project urls.py with comprehensive URL configuration", should_format=True, ) return result - def update_wsgi_file(self) -> bool: - with change_cwd(self.project_configs): - wsgi_content = template_engine.render_template("wsgi.j2", {}) - return create_file_with_content( - "wsgi.py", - wsgi_content, - "Updated wsgi.py with custom configuration", - should_format=True, - ) - - def update_asgi_file(self) -> bool: - with change_cwd(self.project_configs): - asgi_content = template_engine.render_template("asgi.j2", {}) - return create_file_with_content( - "asgi.py", - asgi_content, - "Updated asgi.py with custom configuration", - should_format=True, - ) - - def update_manage_py(self) -> bool: - """Update manage.py with custom configuration.""" + def create_justfile(self) -> bool: with change_cwd(self.project_root): - manage_content = template_engine.render_template("manage_py.j2", {}) - return create_file_with_content( - "manage.py", - manage_content, - "Updated manage.py with custom configuration", - should_format=True, + context = {"project_name": self.project_name} + justfile_content = template_engine.render_template("shared/justfile.j2", context) + create_file_with_content( + "justfile", + justfile_content, + "Created justfile with Django development tasks", ) + return True + + def create_procfile(self) -> bool: + with change_cwd(self.project_root): + context = {"project_name": self.project_name} + procfile_content = template_engine.render_template("shared/procfile.j2", context) + create_file_with_content( + "Procfile", + procfile_content, + "Created Procfile with Gunicorn configuration", + ) + return True + + def create_runtime_txt(self) -> bool: + with change_cwd(self.project_root): + context = {"python_version": "3.13"} + runtime_content = template_engine.render_template("shared/runtime_txt.j2", context) + create_file_with_content( + "runtime.txt", + runtime_content, + "Created runtime.txt with Python version specification", + ) + return True + + def create_github_actions(self) -> bool: + with change_cwd(self.project_root): + github_dir = os.path.join(self.project_root, ".github", "workflows") + os.makedirs(github_dir, exist_ok=True) + + context = {"project_name": self.project_name} + workflow_content = template_engine.render_template("shared/github_actions_ci.j2", context) + workflow_file = os.path.join(github_dir, "ci.yml") + create_file_with_content( + workflow_file, + workflow_content, + "Created Github Actions workflow (ci.yml)", + ) + return True + + def create_gitlab_ci(self) -> bool: + with change_cwd(self.project_root): + context = {"project_name": self.project_name} + gitlab_ci_content = template_engine.render_template("shared/gitlab_ci.j2", context) + create_file_with_content( + ".gitlab-ci.yml", + gitlab_ci_content, + "Created GitLab CI configuration (.gitlab-ci.yml)", + ) + return True diff --git a/djinit/scripts/input_handler.py b/djinit/scripts/input_handler.py index af035a2..85b8abc 100644 --- a/djinit/scripts/input_handler.py +++ b/djinit/scripts/input_handler.py @@ -36,7 +36,6 @@ class ProjectMetadata: use_database_url: bool = False def to_dict(self) -> dict: - """Convert to dictionary format.""" return { "package_name": self.package_name, "use_github_actions": self.use_github_actions, @@ -49,8 +48,6 @@ class ProjectMetadata: @dataclass class ProjectSetup: - """Complete project setup configuration.""" - project_dir: str project_name: str primary_app: str @@ -58,13 +55,10 @@ class ProjectSetup: metadata: ProjectMetadata def to_tuple(self) -> Tuple[str, str, str, list, dict]: - """Convert to legacy tuple format for backward compatibility.""" return (self.project_dir, self.project_name, self.primary_app, self.app_names, self.metadata.to_dict()) class InputCollector: - """Handles collection of user inputs with validation.""" - MAX_ATTEMPTS = 3 def __init__(self): @@ -107,41 +101,42 @@ class InputCollector: UIFormatter.print_error(f"Maximum attempts reached for {input_type}. Exiting.") sys.exit(1) - def _show_retry_message(self, attempt: int) -> None: - if attempt < self.MAX_ATTEMPTS: - console.print(f"[{UIColors.MUTED}]Please try again ({attempt}/{self.MAX_ATTEMPTS}).[/{UIColors.MUTED}]") + def _show_retry_message(self, attempt: int, max_attempts: int = None) -> None: + max_attempts = max_attempts or self.MAX_ATTEMPTS + if attempt < max_attempts: + console.print(f"[{UIColors.MUTED}]Please try again ({attempt}/{max_attempts}).[/{UIColors.MUTED}]") def get_app_names(self) -> list[str]: console.print(f"[{UIColors.HIGHLIGHT}]App Names[/{UIColors.HIGHLIGHT}]") - console.print( - f"[{UIColors.MUTED}]Enter app names separated by commas (no interactive prompts)[/{UIColors.MUTED}]" - ) + console.print(f"[{UIColors.MUTED}]Enter app names separated by commas[/{UIColors.MUTED}]") console.print(f"[{UIColors.MUTED}]Example: users, products, orders[/{UIColors.MUTED}]") user_input = console.input( f"[{UIColors.HIGHLIGHT}]Enter app names (comma-separated or single):[/{UIColors.HIGHLIGHT}] " ) - # Empty input - re-prompt (no interactive flow) if not user_input.strip(): UIFormatter.print_error("At least one app name is required") return self.get_app_names() - # Comma-separated input if "," in user_input: return self._parse_comma_separated_apps(user_input) - # Single app (no additional prompts) return self._get_apps_starting_with(user_input.strip()) def _parse_comma_separated_apps(self, user_input: str) -> list[str]: - app_list = [app.strip() for app in user_input.split(",")] - app_list = [app for app in app_list if app] # Remove empty strings + app_list = [app.strip() for app in user_input.split(",") if app.strip()] if not app_list: UIFormatter.print_error("At least one app name is required") return self.get_app_names() + return self._validate_app_list(app_list) + + def _get_apps_starting_with(self, first_app: str) -> list[str]: + return self._validate_app_list([first_app]) + + def _validate_app_list(self, app_list: list[str]) -> list[str]: invalid_apps = [] for app in app_list: is_valid, error_msg = validate_app_name(app) @@ -155,22 +150,43 @@ class InputCollector: return app_list - def _get_apps_starting_with(self, first_app: str) -> list[str]: - is_valid, error_msg = validate_app_name(first_app) - if not is_valid: - UIFormatter.print_error(error_msg) - return self.get_app_names() + def _get_project_directory(self) -> str | None: + attempt = 0 - return [first_app] + while attempt < self.MAX_ATTEMPTS: + try: + user_input = console.input( + f"[{UIColors.HIGHLIGHT}]Enter project directory name:[/{UIColors.HIGHLIGHT}] " + ).strip() - def _get_apps_interactive(self) -> list[str]: - """Deprecated: interactive flow disabled. Kept for compatibility.""" - UIFormatter.print_warning("Interactive app addition is disabled. Please enter names comma-separated.") - return self.get_app_names() + # Handle empty input or '.' - use current directory + if not user_input or user_input == ".": + UIFormatter.print_info(f"Creating project in current directory: {os.getcwd()}") + return "." - def _prompt_for_additional_apps(self) -> list[str]: - """Deprecated: interactive flow disabled. Always returns empty list.""" - return [] + is_valid, error_msg = validate_project_name(user_input) + if not is_valid: + UIFormatter.print_error(error_msg) + attempt += 1 + self._show_retry_message(attempt) + continue + + if os.path.exists(user_input): + UIFormatter.print_error(f"Directory '{user_input}' already exists.") + UIFormatter.print_info( + "Please choose a different project directory name (or press Enter for current directory)." + ) + attempt += 1 + self._show_retry_message(attempt) + continue + + return user_input + + except KeyboardInterrupt: + UIFormatter.print_info("\nSetup cancelled by user.") + sys.exit(0) + + return None def get_cicd_choice(self) -> Tuple[bool, bool]: UIFormatter.print_separator() @@ -178,12 +194,10 @@ class InputCollector: console.print(f"[{UIColors.MUTED}]Choose CI/CD pipeline for your project[/{UIColors.MUTED}]") console.print() - # Show options for option in CICDOption: console.print(f" [{UIColors.SUCCESS}]{option.key}[/{UIColors.SUCCESS}] {option.description}") console.print() - # Get valid keys from CICDOption enum valid_keys = [option.key for option in CICDOption] choice = CharReader.get_cicd_choice( @@ -192,31 +206,25 @@ class InputCollector: default=CICDOption.NONE.key, ) - # Parse choice - if choice == CICDOption.BOTH.key: - return True, True - elif choice == CICDOption.GITHUB.key: - return True, False - elif choice == CICDOption.GITLAB.key: - return False, True - else: # Default to none (should not happen due to validation, but kept as fallback) - return False, False + choice_map = { + CICDOption.BOTH.key: (True, True), + CICDOption.GITHUB.key: (True, False), + CICDOption.GITLAB.key: (False, True), + } + return choice_map.get(choice, (False, False)) def get_nested_apps_config(self) -> Tuple[bool, str | None]: - """Ask whether to create nested Django apps and get directory name if yes.""" UIFormatter.print_separator() console.print(f"\n[{UIColors.INFO}]Apps Layout[/{UIColors.INFO}]\n") console.print( - f"[{UIColors.MUTED}]Do you want to place apps inside a package directory (e.g., 'apps/')?[/{UIColors.MUTED}]" + f"[{UIColors.MUTED}]Do you want to place apps inside a package directory (e.g., 'src/')?[/{UIColors.MUTED}]" ) console.print( f"[{UIColors.MUTED}]If yes, we'll create that directory as a Python package and generate apps inside it.[/{UIColors.MUTED}]" ) console.print() - choice = CharReader.get_yes_no( - f"[{UIColors.HIGHLIGHT}]Nested Django apps? (y/N):[/{UIColors.HIGHLIGHT}]", default="n" - ) + choice = CharReader.get_yes_no(f"[{UIColors.HIGHLIGHT}]Nested Django apps? (y/N):[/{UIColors.HIGHLIGHT}]") if choice != "y": return False, None @@ -230,7 +238,6 @@ class InputCollector: return True, dir_name def get_database_config_choice(self) -> bool: - """Ask user for database configuration preference.""" UIFormatter.print_separator() console.print(f"\n[{UIColors.INFO}]Database Configuration[/{UIColors.INFO}]\n") console.print(f"[{UIColors.MUTED}]Choose how to configure your database in production:[/{UIColors.MUTED}]") @@ -242,16 +249,11 @@ class InputCollector: console.print(f"[{UIColors.MUTED}]postgres://user:password@host:port/database[/{UIColors.MUTED}]") console.print() - choice = CharReader.get_yes_no( - f"[{UIColors.HIGHLIGHT}]Use DATABASE_URL? (Y/n):[/{UIColors.HIGHLIGHT}]", default="y" - ) - - # Return True if choice is 'y', False if 'n' - return choice == "y" + return CharReader.get_yes_no(f"[{UIColors.HIGHLIGHT}]Use DATABASE_URL? (Y/n):[/{UIColors.HIGHLIGHT}]") == "y" class CharReader: - """Handles single character input across platforms.""" + MAX_ATTEMPTS = 3 @staticmethod def get_char() -> str: @@ -260,45 +262,59 @@ class CharReader: return CharReader._get_char_unix() @staticmethod - def get_yes_no(prompt: str, default: str = "n") -> str: - max_attempts = 3 + def _is_enter_key(response: str) -> bool: + """Check if response represents Enter key or control character.""" + return not response or response in ("\r", "\n") or ord(response) < 32 + + @staticmethod + def _get_char_input(prompt: str) -> str: + console.print(f"{prompt} ", end="") + sys.stdout.flush() + return CharReader().get_char() + + @staticmethod + def _handle_default(default: str) -> str: + console.print(default.upper()) + return default.lower() + + @staticmethod + def _get_validated_char_input( + prompt: str, + valid_keys: list[str], + default: str, + error_message: str, + ) -> str: attempt = 0 + valid_keys_lower = [k.lower() for k in valid_keys] - while attempt < max_attempts: + while attempt < CharReader.MAX_ATTEMPTS: try: - console.print(f"{prompt} ", end="") - sys.stdout.flush() + response = CharReader._get_char_input(prompt) - reader = CharReader() - response = reader.get_char() - - # Handle Enter key (default) - in raw mode, Enter is '\r' (carriage return) - # Also handle empty string or other control characters as Enter - if not response or response == "\r" or response == "\n" or ord(response) < 32: - console.print(default.upper()) - return default.lower() + # Handle Enter key (default) + if CharReader._is_enter_key(response): + return CharReader._handle_default(default) response = response.lower() # Validate response - if response in ("y", "n"): + if response in valid_keys_lower: console.print(response.upper()) return response - # Invalid input - show error (only show if it's a printable character) + # Invalid input - show error console.print() if response.isprintable(): - UIFormatter.print_error(f"Invalid input '{response}'. Please enter 'y' or 'n'.") + UIFormatter.print_error(error_message.format(response=response)) attempt += 1 - if attempt < max_attempts: + if attempt < CharReader.MAX_ATTEMPTS: console.print( - f"[{UIColors.MUTED}]Please try again ({attempt}/{max_attempts}).[/{UIColors.MUTED}]" + f"[{UIColors.MUTED}]Please try again ({attempt}/{CharReader.MAX_ATTEMPTS}).[/{UIColors.MUTED}]" ) console.print() else: # Control character entered, treat as Enter - console.print(default.upper()) - return default.lower() + return CharReader._handle_default(default) except KeyboardInterrupt: console.print() @@ -310,69 +326,34 @@ class CharReader: UIFormatter.print_warning(f"Maximum attempts reached. Using default: '{default.upper()}'.") return default.lower() + @staticmethod + def get_yes_no(prompt: str, default: str = None) -> str: + # Auto-detect default from prompt format (capital letter indicates default) + if default is None: + if "(Y/n)" in prompt or "(Y/N)" in prompt: + default = "y" + elif "(y/N)" in prompt: + default = "n" + else: + # Default to 'y' for yes/no prompts + default = "y" + + return CharReader._get_validated_char_input( + prompt=prompt, + valid_keys=["y", "n"], + default=default, + error_message="Invalid input '{response}'. Please enter 'y' or 'n'.", + ) + @staticmethod def get_cicd_choice(prompt: str, valid_keys: list[str], default: str = "n") -> str: - """ - Get CI/CD choice input with validation and retries. - - Args: - prompt: The prompt message to display - valid_keys: List of valid key characters (e.g., ['g', 'l', 'b', 'n']) - default: Default value if Enter is pressed - - Returns: - Valid key (lowercase) - """ - max_attempts = 3 - attempt = 0 - valid_keys_lower = [k.lower() for k in valid_keys] - - while attempt < max_attempts: - try: - console.print(f"{prompt} ", end="") - sys.stdout.flush() - - reader = CharReader() - response = reader.get_char() - - # Handle Enter key (default) - in raw mode, Enter is '\r' (carriage return) - # Also handle empty string or other control characters as Enter - if not response or response == "\r" or response == "\n" or ord(response) < 32: - console.print(default.upper()) - return default.lower() - - response = response.lower() - - # Validate response - if response in valid_keys_lower: - console.print(response.upper()) - return response - - # Invalid input - show error (only show if it's a printable character) - console.print() - if response.isprintable(): - valid_keys_str = ", ".join(f"'{k}'" for k in valid_keys) - UIFormatter.print_error(f"Invalid input '{response}'. Please enter one of: {valid_keys_str}.") - attempt += 1 - if attempt < max_attempts: - console.print( - f"[{UIColors.MUTED}]Please try again ({attempt}/{max_attempts}).[/{UIColors.MUTED}]" - ) - console.print() - else: - # Control character entered, treat as Enter - console.print(default.upper()) - return default.lower() - - except KeyboardInterrupt: - console.print() - UIFormatter.print_info("\nOperation cancelled by user.") - raise - - # Max attempts reached - use default - console.print() - UIFormatter.print_warning(f"Maximum attempts reached. Using default: '{default.upper()}'.") - return default.lower() + valid_keys_str = ", ".join(f"'{k}'" for k in valid_keys) + return CharReader._get_validated_char_input( + prompt=prompt, + valid_keys=valid_keys, + default=default, + error_message=f"Invalid input '{{response}}'. Please enter one of: {valid_keys_str}.", + ) @staticmethod def _get_char_windows() -> str: @@ -418,45 +399,7 @@ def get_user_input() -> Tuple[str, str, str, list, dict]: console.print(f"[{UIColors.MUTED}]Press Enter or enter '.' to create in current directory[/{UIColors.MUTED}]") # Get project directory with special handling for '.' and empty input - attempt = 0 - project_dir = None - - while attempt < collector.MAX_ATTEMPTS and project_dir is None: - try: - user_input = console.input( - f"[{UIColors.HIGHLIGHT}]Enter project directory name:[/{UIColors.HIGHLIGHT}] " - ).strip() - - # Handle empty input or '.' - use current directory - if not user_input or user_input == ".": - project_dir = "." - UIFormatter.print_info(f"Creating project in current directory: {os.getcwd()}") - break - - # Validate the input - is_valid, error_msg = validate_project_name(user_input) - if not is_valid: - UIFormatter.print_error(error_msg) - attempt += 1 - collector._show_retry_message(attempt) - continue - - # Check if directory exists and ask for alternative - if os.path.exists(user_input): - UIFormatter.print_error(f"Directory '{user_input}' already exists.") - UIFormatter.print_info( - "Please choose a different project directory name (or press Enter for current directory)." - ) - attempt += 1 - collector._show_retry_message(attempt) - continue - - project_dir = user_input - break - - except KeyboardInterrupt: - UIFormatter.print_info("\nSetup cancelled by user.") - sys.exit(0) + project_dir = collector._get_project_directory() if project_dir is None: UIFormatter.print_error("Maximum attempts reached for project directory name. Exiting.") @@ -509,17 +452,14 @@ def confirm_setup(project_dir: str, project_name: str, app_names: list, metadata console.print(f"[{UIColors.INFO}]Setup Summary[/{UIColors.INFO}]") console.print() - # Display configuration console.print(f"[{UIColors.HIGHLIGHT}]Project Directory:[/{UIColors.HIGHLIGHT}] {project_dir}") console.print(f"[{UIColors.HIGHLIGHT}]Django Project:[/{UIColors.HIGHLIGHT}] {project_name}") console.print(f"[{UIColors.HIGHLIGHT}]Apps:[/{UIColors.HIGHLIGHT}] {', '.join(app_names)}") console.print(f"[{UIColors.HIGHLIGHT}]Package:[/{UIColors.HIGHLIGHT}] {metadata['package_name']}") - # Show CI/CD choices cicd_choices = _get_cicd_display(metadata) console.print(f"[{UIColors.HIGHLIGHT}]CI/CD:[/{UIColors.HIGHLIGHT}] {cicd_choices}") - # Show database configuration db_config = "DATABASE_URL" if metadata.get("use_database_url", True) else "Individual parameters" console.print(f"[{UIColors.HIGHLIGHT}]Database Config:[/{UIColors.HIGHLIGHT}] {db_config}") @@ -527,11 +467,8 @@ def confirm_setup(project_dir: str, project_name: str, app_names: list, metadata UIFormatter.print_separator() console.print() - # Get confirmation try: - response = CharReader.get_yes_no( - f"[{UIColors.WARNING}]Proceed with setup? (y/N):[/{UIColors.WARNING}]", default="n" - ) + response = CharReader.get_yes_no(f"[{UIColors.WARNING}]Proceed with setup? (y/N):[/{UIColors.WARNING}]") return response == "y" except KeyboardInterrupt: @@ -540,14 +477,12 @@ def confirm_setup(project_dir: str, project_name: str, app_names: list, metadata def _get_cicd_display(metadata: dict) -> str: - cicd_choices = [] - + choices = [] if metadata.get("use_github_actions", False): - cicd_choices.append("GitHub Actions") + choices.append("GitHub Actions") if metadata.get("use_gitlab_ci", False): - cicd_choices.append("GitLab CI") - - return ", ".join(cicd_choices) if cicd_choices else "None" + choices.append("GitLab CI") + return ", ".join(choices) if choices else "None" get_char = CharReader.get_char diff --git a/djinit/scripts/project.py b/djinit/scripts/project.py index a02e301..d963c12 100644 --- a/djinit/scripts/project.py +++ b/djinit/scripts/project.py @@ -3,13 +3,17 @@ Project management for djinit. Handles creation of Django projects and apps. """ -import importlib.util import os -import subprocess -import sys from djinit.scripts.console_ui import UIFormatter -from djinit.utils import change_cwd +from djinit.scripts.django_helper import DjangoHelper +from djinit.utils import ( + calculate_app_module_paths, + extract_existing_apps, + get_base_settings_path, + insert_apps_into_user_defined_apps, + read_base_settings, +) class ProjectManager: @@ -18,30 +22,19 @@ class ProjectManager: self.project_name = project_name self.app_names = app_names self.metadata = metadata - # Handle '.' for current directory - if project_dir == ".": - self.project_root = os.getcwd() - else: - self.project_root = os.path.join(os.getcwd(), project_dir) + self.project_root = os.getcwd() if project_dir == "." else os.path.join(os.getcwd(), project_dir) def create_project(self) -> bool: - self._ensure_django_installed() - - # Create the project directory first os.makedirs(self.project_root, exist_ok=True) - # Create Django project inside the directory - # Use 'python -m django' instead of 'django-admin' for better compatibility - subprocess.run( - [sys.executable, "-m", "django", "startproject", self.project_name, self.project_root], - check=True, - ) - - UIFormatter.print_success(f"Django project '{self.project_name}' created successfully!") - return True + success = DjangoHelper.startproject(self.project_name, self.project_root) + if success: + UIFormatter.print_success(f"Django project '{self.project_name}' created successfully!") + else: + UIFormatter.print_error(f"Failed to create Django project '{self.project_name}'") + return success def create_apps(self) -> bool: - # Determine base directory for apps apps_base_dir = self.project_root nested = bool(self.metadata.get("nested_apps")) nested_dir_name = self.metadata.get("nested_dir") if nested else None @@ -49,47 +42,69 @@ class ProjectManager: if nested and nested_dir_name: apps_base_dir = os.path.join(self.project_root, nested_dir_name) os.makedirs(apps_base_dir, exist_ok=True) - # Make it a Python package init_file = os.path.join(apps_base_dir, "__init__.py") if not os.path.exists(init_file): open(init_file, "a").close() - with change_cwd(apps_base_dir): - # Create each app - for app_name in self.app_names: - subprocess.run( - [ - sys.executable, - os.path.join(self.project_root, "manage.py"), - "startapp", - app_name, - ], - check=True, - ) - UIFormatter.print_success(f"Django app '{app_name}' created successfully!") + for app_name in self.app_names: + success = DjangoHelper.startapp(app_name, apps_base_dir) + if not success: + UIFormatter.print_error(f"Failed to create Django app '{app_name}'") + return False + UIFormatter.print_success(f"Django app '{app_name}' created successfully!") + + # Add all apps to USER_DEFINED_APPS in settings + if not self._add_apps_to_settings(): + return False return True - def _ensure_django_installed(self) -> None: - if importlib.util.find_spec("django") is None: - subprocess.run( - [sys.executable, "-m", "pip", "install", "--upgrade", "django", "-q"], - check=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) + def _add_apps_to_settings(self) -> bool: + """Add all apps to USER_DEFINED_APPS in base.py settings file.""" + base_settings_path = get_base_settings_path(self.project_root, self.project_name) + + if not os.path.exists(base_settings_path): + UIFormatter.print_error("Could not find base.py settings file") + return False + + content = read_base_settings(self.project_root, self.project_name) + if content is None: + UIFormatter.print_error("Could not read base.py settings file") + return False + + app_module_paths = calculate_app_module_paths(self.app_names, self.metadata) + + existing_apps = extract_existing_apps(content) + apps_to_add = [app for app in app_module_paths if app not in existing_apps] + + if not apps_to_add: + UIFormatter.print_success("All apps already configured in USER_DEFINED_APPS") + return True + + updated_content = insert_apps_into_user_defined_apps(content, apps_to_add) + if not updated_content: + return False + + with open(base_settings_path, "w") as f: + f.write(updated_content) + + added_apps_str = ", ".join(apps_to_add) + UIFormatter.print_success(f"Added apps to USER_DEFINED_APPS: {added_apps_str}") + return True def validate_project_structure(self) -> bool: required_files = [ os.path.join(self.project_root, "manage.py"), os.path.join(self.project_root, self.project_name, "__init__.py"), - os.path.join(self.project_root, self.project_name, "settings.py"), + os.path.join(self.project_root, self.project_name, "settings", "__init__.py"), + os.path.join(self.project_root, self.project_name, "settings", "base.py"), + os.path.join(self.project_root, self.project_name, "settings", "development.py"), + os.path.join(self.project_root, self.project_name, "settings", "production.py"), os.path.join(self.project_root, self.project_name, "urls.py"), os.path.join(self.project_root, self.project_name, "wsgi.py"), os.path.join(self.project_root, self.project_name, "asgi.py"), ] - # Add required files for each app apps_base_dir = self.project_root if self.metadata.get("nested_apps") and self.metadata.get("nested_dir"): apps_base_dir = os.path.join(self.project_root, self.metadata.get("nested_dir")) @@ -100,11 +115,21 @@ class ProjectManager: os.path.join(apps_base_dir, app_name, "apps.py"), os.path.join(apps_base_dir, app_name, "models.py"), os.path.join(apps_base_dir, app_name, "views.py"), + os.path.join(apps_base_dir, app_name, "serializers.py"), + os.path.join(apps_base_dir, app_name, "routes.py"), + os.path.join(apps_base_dir, app_name, "tests.py"), + os.path.join(apps_base_dir, app_name, "migrations"), os.path.join(apps_base_dir, app_name, "admin.py"), ] required_files.extend(app_files) - missing_files = [file_path for file_path in required_files if not os.path.exists(file_path)] + missing_files = [] + for file_path in required_files: + if file_path.endswith("settings"): + if not os.path.isdir(file_path): + missing_files.append(file_path) + elif not os.path.exists(file_path): + missing_files.append(file_path) if not missing_files: UIFormatter.print_success("Project structure validation passed") diff --git a/djinit/scripts/settings.py b/djinit/scripts/settings.py deleted file mode 100644 index 96ce0de..0000000 --- a/djinit/scripts/settings.py +++ /dev/null @@ -1,86 +0,0 @@ -""" -Settings management for djinit. -Handles creation and configuration of Django settings files. -""" - -import os - -from djinit.scripts.console_ui import UIFormatter -from djinit.scripts.secretkey_generator import generate_secret_key -from djinit.scripts.template_engine import template_engine -from djinit.utils import change_cwd, create_file_with_content - - -class SettingsManager: - def __init__(self, project_root: str, project_name: str, app_names: list, metadata: dict): - self.project_root = project_root - self.project_name = project_name - self.app_names = app_names - self.metadata = metadata - self.project_configs = os.path.join(project_root, project_name) - self.settings_folder = os.path.join(self.project_configs, "settings") - self.settings_file = os.path.join(self.project_configs, "settings.py") - self.secret_key = generate_secret_key(50) - - def create_settings_structure(self) -> bool: - # Change to project configs directory - with change_cwd(self.project_configs): - # Create settings folder - os.makedirs("settings", exist_ok=True) - - # Move settings.py to settings/base.py - if os.path.exists(self.settings_file): - os.rename(self.settings_file, os.path.join(self.settings_folder, "base.py")) - - # Create __init__.py files - with change_cwd(self.settings_folder): - open("__init__.py", "a").close() - open("development.py", "a").close() - open("production.py", "a").close() - - UIFormatter.print_success("Created Django settings folder structure") - return True - - def update_base_settings(self) -> bool: - with change_cwd(self.settings_folder): - nested = bool(self.metadata.get("nested_apps")) - nested_dir = self.metadata.get("nested_dir") - effective_app_names = [f"{nested_dir}.{name}" if nested and nested_dir else name for name in self.app_names] - context = { - "project_name": self.project_name, - "app_names": effective_app_names, - } - base_content = template_engine.render_template("settings_base.j2", context) - create_file_with_content( - "base.py", - base_content, - "Updated settings/base.py with comprehensive configuration", - should_format=True, - ) - return True - - def update_development_settings(self) -> bool: - """Update development.py with development-specific settings.""" - with change_cwd(self.settings_folder): - context = {"secret_key": self.secret_key} - dev_content = template_engine.render_template("settings_development.j2", context) - create_file_with_content( - "development.py", - dev_content, - "Updated settings/development.py", - should_format=True, - ) - return True - - def update_production_settings(self) -> bool: - """Update production.py with production-specific settings.""" - with change_cwd(self.settings_folder): - context = {"use_database_url": self.metadata.get("use_database_url", True)} - prod_content = template_engine.render_template("settings_production.j2", context) - create_file_with_content( - "production.py", - prod_content, - "Updated settings/production.py", - should_format=True, - ) - return True diff --git a/djinit/scripts/template_engine.py b/djinit/scripts/template_engine.py index 629687d..30d71e4 100644 --- a/djinit/scripts/template_engine.py +++ b/djinit/scripts/template_engine.py @@ -76,5 +76,4 @@ class TemplateEngine: return os.path.exists(template_path) -# Global template engine instance template_engine = TemplateEngine() diff --git a/djinit/templates/app/admin.j2 b/djinit/templates/app/admin.j2 new file mode 100644 index 0000000..ec50db8 --- /dev/null +++ b/djinit/templates/app/admin.j2 @@ -0,0 +1,4 @@ +from django.contrib import admin + +# Register your models here. + diff --git a/djinit/templates/app/apps.j2 b/djinit/templates/app/apps.j2 new file mode 100644 index 0000000..70a33a1 --- /dev/null +++ b/djinit/templates/app/apps.j2 @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class {{ app_name|title }}Config(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = '{{ app_name }}' + diff --git a/djinit/templates/app/migrations_init.j2 b/djinit/templates/app/migrations_init.j2 new file mode 100644 index 0000000..e69de29 diff --git a/djinit/templates/app/models.j2 b/djinit/templates/app/models.j2 new file mode 100644 index 0000000..b096caa --- /dev/null +++ b/djinit/templates/app/models.j2 @@ -0,0 +1,4 @@ +from django.db import models + +# Create your models here. + diff --git a/djinit/templates/routes.j2 b/djinit/templates/app/routes.j2 similarity index 100% rename from djinit/templates/routes.j2 rename to djinit/templates/app/routes.j2 diff --git a/djinit/templates/serializers.j2 b/djinit/templates/app/serializers.j2 similarity index 100% rename from djinit/templates/serializers.j2 rename to djinit/templates/app/serializers.j2 diff --git a/djinit/templates/app/tests.j2 b/djinit/templates/app/tests.j2 new file mode 100644 index 0000000..0b4501e --- /dev/null +++ b/djinit/templates/app/tests.j2 @@ -0,0 +1,4 @@ +from django.test import TestCase + +# Create your tests here. + diff --git a/djinit/templates/app_urls.j2 b/djinit/templates/app/urls.j2 similarity index 100% rename from djinit/templates/app_urls.j2 rename to djinit/templates/app/urls.j2 diff --git a/djinit/templates/app/views.j2 b/djinit/templates/app/views.j2 new file mode 100644 index 0000000..27cdb63 --- /dev/null +++ b/djinit/templates/app/views.j2 @@ -0,0 +1,4 @@ +from django.shortcuts import render + +# Create your views here. + diff --git a/djinit/templates/asgi.j2 b/djinit/templates/project/asgi.j2 similarity index 100% rename from djinit/templates/asgi.j2 rename to djinit/templates/project/asgi.j2 diff --git a/djinit/templates/manage_py.j2 b/djinit/templates/project/manage_py.j2 similarity index 100% rename from djinit/templates/manage_py.j2 rename to djinit/templates/project/manage_py.j2 diff --git a/djinit/templates/project_urls.j2 b/djinit/templates/project/project_urls.j2 similarity index 100% rename from djinit/templates/project_urls.j2 rename to djinit/templates/project/project_urls.j2 diff --git a/djinit/templates/settings_base.j2 b/djinit/templates/project/settings_base.j2 similarity index 100% rename from djinit/templates/settings_base.j2 rename to djinit/templates/project/settings_base.j2 diff --git a/djinit/templates/settings_development.j2 b/djinit/templates/project/settings_development.j2 similarity index 100% rename from djinit/templates/settings_development.j2 rename to djinit/templates/project/settings_development.j2 diff --git a/djinit/templates/settings_production.j2 b/djinit/templates/project/settings_production.j2 similarity index 100% rename from djinit/templates/settings_production.j2 rename to djinit/templates/project/settings_production.j2 diff --git a/djinit/templates/project/urls.j2 b/djinit/templates/project/urls.j2 new file mode 100644 index 0000000..c99fb11 --- /dev/null +++ b/djinit/templates/project/urls.j2 @@ -0,0 +1,23 @@ +""" +URL configuration for {{ project_name }} project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/{{ django_version }}/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path('admin/', admin.site.urls), +] + diff --git a/djinit/templates/wsgi.j2 b/djinit/templates/project/wsgi.j2 similarity index 100% rename from djinit/templates/wsgi.j2 rename to djinit/templates/project/wsgi.j2 diff --git a/djinit/templates/env_sample.j2 b/djinit/templates/shared/env_sample.j2 similarity index 100% rename from djinit/templates/env_sample.j2 rename to djinit/templates/shared/env_sample.j2 diff --git a/djinit/templates/github_actions_ci.j2 b/djinit/templates/shared/github_actions_ci.j2 similarity index 100% rename from djinit/templates/github_actions_ci.j2 rename to djinit/templates/shared/github_actions_ci.j2 diff --git a/djinit/templates/gitignore.j2 b/djinit/templates/shared/gitignore.j2 similarity index 100% rename from djinit/templates/gitignore.j2 rename to djinit/templates/shared/gitignore.j2 diff --git a/djinit/templates/gitlab_ci.j2 b/djinit/templates/shared/gitlab_ci.j2 similarity index 100% rename from djinit/templates/gitlab_ci.j2 rename to djinit/templates/shared/gitlab_ci.j2 diff --git a/djinit/templates/shared/init.j2 b/djinit/templates/shared/init.j2 new file mode 100644 index 0000000..e69de29 diff --git a/djinit/templates/justfile.j2 b/djinit/templates/shared/justfile.j2 similarity index 100% rename from djinit/templates/justfile.j2 rename to djinit/templates/shared/justfile.j2 diff --git a/djinit/templates/procfile.j2 b/djinit/templates/shared/procfile.j2 similarity index 100% rename from djinit/templates/procfile.j2 rename to djinit/templates/shared/procfile.j2 diff --git a/djinit/templates/pyproject_toml.j2 b/djinit/templates/shared/pyproject_toml.j2 similarity index 100% rename from djinit/templates/pyproject_toml.j2 rename to djinit/templates/shared/pyproject_toml.j2 diff --git a/djinit/templates/readme.j2 b/djinit/templates/shared/readme.j2 similarity index 100% rename from djinit/templates/readme.j2 rename to djinit/templates/shared/readme.j2 diff --git a/djinit/templates/requirements.j2 b/djinit/templates/shared/requirements.j2 similarity index 92% rename from djinit/templates/requirements.j2 rename to djinit/templates/shared/requirements.j2 index 365c06d..235b528 100644 --- a/djinit/templates/requirements.j2 +++ b/djinit/templates/shared/requirements.j2 @@ -1,6 +1,6 @@ Django python-dotenv -django-jazzmin> +django-jazzmin djangorestframework djangorestframework_simplejwt drf-spectacular diff --git a/djinit/templates/runtime_txt.j2 b/djinit/templates/shared/runtime_txt.j2 similarity index 100% rename from djinit/templates/runtime_txt.j2 rename to djinit/templates/shared/runtime_txt.j2 diff --git a/djinit/utils.py b/djinit/utils.py index dfbd7c9..77263d6 100644 --- a/djinit/utils.py +++ b/djinit/utils.py @@ -9,27 +9,16 @@ import sys from contextlib import contextmanager from djinit.scripts.console_ui import UIFormatter +from djinit.scripts.template_engine import template_engine def format_file(filename: str) -> None: """Format Python file using Ruff formatter.""" - # Use 'python -m ruff' instead of 'ruff' for better compatibility subprocess.run([sys.executable, "-m", "ruff", "format", filename], check=False, capture_output=True) +# create a file with content, optionally format it def create_file_with_content(filename: str, content: str, success_message: str, should_format: bool = False) -> bool: - """ - Create a file with content, optionally format it, and print success message. - - Args: - filename: Name of the file to create - content: Content to write to the file - success_message: Message to print on success - should_format: Whether to format the file with ruff - - Returns: - True if successful - """ with open(filename, "w") as file: file.write(content) @@ -40,15 +29,224 @@ def create_file_with_content(filename: str, content: str, success_message: str, return True +# create a file from a template +def create_file_from_template( + file_path: str, + template_path: str, + context: dict, + success_message: str, + should_format: bool = True, +) -> None: + content = template_engine.render_template(template_path, context) + create_file_with_content(file_path, content, success_message, should_format=should_format) + + +# create __init__.py +def create_init_file(directory: str, success_message: str) -> None: + init_path = os.path.join(directory, "__init__.py") + create_file_from_template(init_path, "shared/init.j2", {}, success_message, should_format=False) + + +# change the current working dir tempporary @contextmanager def change_cwd(path: str): - """Temporarily change the current working directory. - - Ensures the original directory is restored even if an exception occurs. - """ original_cwd = os.getcwd() os.chdir(path) try: yield finally: os.chdir(original_cwd) + + +# get app names from USER_DEFINED_APPS section in project_name/settings/base.py +def extract_existing_apps(content: str) -> set: + lines = content.split("\n") + existing_apps = set() + in_user_apps = False + + for line in lines: + if "USER_DEFINED_APPS" in line and "=" in line: + in_user_apps = True + elif in_user_apps and line.strip() == "]": + break + elif in_user_apps: + line_stripped = line.strip().strip(",").strip() + for quote in ('"', "'"): + if line_stripped.startswith(quote) and line_stripped.endswith(quote): + existing_apps.add(line_stripped.strip(quote)) + break + + return existing_apps + + +# format app names as list entries with proper indentation +def format_app_entries(apps: list) -> list: + return [f' "{app}",' for app in apps] + + +# check if line starts a new APP section +def is_app_section_start(line: str) -> bool: + return "=" in line and any(keyword in line for keyword in ("BUILT_IN_APPS", "THIRD_PARTY_APPS", "INSTALLED_APPS")) + + +# check if closing bracket belongs to USER_DEFINED_APPS by looking ahead +def is_user_apps_closing_bracket(lines: list, current_idx: int) -> bool: + for next_idx in range(current_idx + 1, min(current_idx + 3, len(lines))): + next_line = lines[next_idx].strip() + if next_line and "BUILT_IN_APPS" in next_line: + return True + return True # Default to True if we're inside USER_DEFINED_APPS + + +# split line with closing bracnket and insert apps +def split_bracket_line(line: str, apps_to_add: list) -> list: + bracket_pos = line.rfind("]") + before_bracket = line[:bracket_pos].rstrip() if bracket_pos > 0 else line.rstrip() + after_bracket = line[bracket_pos:] if bracket_pos > 0 else "]" + + result = [before_bracket] + result.extend(format_app_entries(apps_to_add)) + result.append(after_bracket) + return result + + +# insert apps into USER_DEFINED_APPS section before the closing bracket. +def insert_apps_into_user_defined_apps(content: str, apps_to_add: list) -> str: + lines = content.split("\n") + new_lines = [] + in_user_apps = False + apps_added = False + + for i, line in enumerate(lines): + # Entering USER_DEFINED_APPS section + if "USER_DEFINED_APPS" in line and "=" in line and not apps_added: + in_user_apps = True + + # Handle closing bracket on same line + if "]" in line: + new_lines.extend(split_bracket_line(line, apps_to_add)) + apps_added = True + in_user_apps = False + else: + new_lines.append(line) + + # Closing bracket on separate line + elif in_user_apps and line.strip() == "]" and not apps_added: + if is_user_apps_closing_bracket(lines, i): + new_lines.extend(format_app_entries(apps_to_add)) + new_lines.append(line) + apps_added = True + in_user_apps = False + else: + new_lines.append(line) + + # Safety: hit another APP section without closing bracket + elif in_user_apps and is_app_section_start(line) and not apps_added: + new_lines.extend(format_app_entries(apps_to_add)) + new_lines.append("]") + new_lines.append(line) + apps_added = True + in_user_apps = False + + else: + new_lines.append(line) + + if not apps_added: + UIFormatter.print_error("Could not find or update USER_DEFINED_APPS section in base.py") + return None + + return "\n".join(new_lines) + + +# calculate app module paths based on nested structure +def calculate_app_module_paths(app_names: list, metadata: dict) -> list: + nested = bool(metadata.get("nested_apps")) + nested_dir_name = metadata.get("nested_dir") if nested else None + return [f"{nested_dir_name}.{app_name}" if nested and nested_dir_name else app_name for app_name in app_names] + + +# calculate app module path for a single app +def calculate_app_module_path(app_name: str, nested: bool, nested_dir: str | None) -> str: + return f"{nested_dir}.{app_name}" if nested and nested_dir else app_name + + +# check if a dir is a Django project by looking for manage.py +def is_django_project(directory: str = None) -> bool: + if directory is None: + directory = os.getcwd() + manage_py_path = os.path.join(directory, "manage.py") + return os.path.exists(manage_py_path) + + +# search the project dir that contains settings/base.py +def find_project_dir(search_dir: str = None) -> tuple[str | None, str | None]: + if search_dir is None: + search_dir = os.getcwd() + + for item in os.listdir(search_dir): + if os.path.isdir(item) and not item.startswith(".") and item != "__pycache__": + candidate = os.path.join(search_dir, item) + base_py = os.path.join(candidate, "settings", "base.py") + if os.path.exists(base_py): + return candidate, base_py + return None, None + + +# search the project_name/settings dir path +def find_settings_path(search_dir: str = None) -> str | None: + project_dir, settings_base_path = find_project_dir(search_dir) + if settings_base_path: + return os.path.dirname(settings_base_path) + return None + + +# get the path to project_name/settings/base.py +def get_base_settings_path(project_root: str, project_name: str) -> str: + settings_dir = os.path.join(project_root, project_name, "settings") + return os.path.join(settings_dir, "base.py") + + +# read the content of project_name/settings/base.py +def read_base_settings(project_root: str, project_name: str) -> str | None: + base_settings_path = get_base_settings_path(project_root, project_name) + if not os.path.exists(base_settings_path): + return None + + with open(base_settings_path) as f: + return f.read() + + +# check for nested app structure(e.g: "apps.users" means nested) from project_name/settings/base.py +def detect_nested_structure_from_settings( + base_settings_path: str, search_dir: str = None +) -> tuple[bool, str | None, str]: + if search_dir is None: + search_dir = os.getcwd() + + if not os.path.exists(base_settings_path): + return False, None, search_dir + + try: + with open(base_settings_path) as f: + base_content = f.read() + + in_user_apps = False + for line in base_content.splitlines(): + if "USER_DEFINED_APPS" in line and "=" in line: + in_user_apps = True + continue + if in_user_apps and "]" in line: + break + if in_user_apps: + line_stripped = line.strip().strip(",") + if line_stripped.startswith('"') or line_stripped.startswith("'"): + app_label = line_stripped.strip("\"'") + if "." in app_label: + nested_dir_name = app_label.split(".", 1)[0] + nested_path = os.path.join(search_dir, nested_dir_name) + if os.path.isdir(nested_path): + return True, nested_dir_name, nested_path + except Exception: + pass + + return False, None, search_dir diff --git a/pyproject.toml b/pyproject.toml index 0c1fccc..b053ef7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,6 @@ dependencies = [ "rich>=14.2.0", "ruff>=0.14.2", "jinja2>=3.1.0", - "django>=4.2,<6.0", ] [project.urls] -- 2.43.0 From 5a6e51f52f2f501c853edd2a409f3561f2ff4337 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Mon, 3 Nov 2025 00:25:46 +0545 Subject: [PATCH 04/15] style: remove trailing whitespace from templates --- djinit/templates/app/admin.j2 | 1 - djinit/templates/app/apps.j2 | 1 - djinit/templates/app/models.j2 | 1 - djinit/templates/app/tests.j2 | 1 - djinit/templates/app/views.j2 | 1 - djinit/templates/project/urls.j2 | 1 - 6 files changed, 6 deletions(-) diff --git a/djinit/templates/app/admin.j2 b/djinit/templates/app/admin.j2 index ec50db8..8c38f3f 100644 --- a/djinit/templates/app/admin.j2 +++ b/djinit/templates/app/admin.j2 @@ -1,4 +1,3 @@ from django.contrib import admin # Register your models here. - diff --git a/djinit/templates/app/apps.j2 b/djinit/templates/app/apps.j2 index 70a33a1..891c804 100644 --- a/djinit/templates/app/apps.j2 +++ b/djinit/templates/app/apps.j2 @@ -4,4 +4,3 @@ from django.apps import AppConfig class {{ app_name|title }}Config(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = '{{ app_name }}' - diff --git a/djinit/templates/app/models.j2 b/djinit/templates/app/models.j2 index b096caa..71a8362 100644 --- a/djinit/templates/app/models.j2 +++ b/djinit/templates/app/models.j2 @@ -1,4 +1,3 @@ from django.db import models # Create your models here. - diff --git a/djinit/templates/app/tests.j2 b/djinit/templates/app/tests.j2 index 0b4501e..7ce503c 100644 --- a/djinit/templates/app/tests.j2 +++ b/djinit/templates/app/tests.j2 @@ -1,4 +1,3 @@ from django.test import TestCase # Create your tests here. - diff --git a/djinit/templates/app/views.j2 b/djinit/templates/app/views.j2 index 27cdb63..91ea44a 100644 --- a/djinit/templates/app/views.j2 +++ b/djinit/templates/app/views.j2 @@ -1,4 +1,3 @@ from django.shortcuts import render # Create your views here. - diff --git a/djinit/templates/project/urls.j2 b/djinit/templates/project/urls.j2 index c99fb11..011b908 100644 --- a/djinit/templates/project/urls.j2 +++ b/djinit/templates/project/urls.j2 @@ -20,4 +20,3 @@ from django.urls import path urlpatterns = [ path('admin/', admin.site.urls), ] - -- 2.43.0 From f45bacf6827191c5397c85481d2a0a01f7f0b86a Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Mon, 3 Nov 2025 00:53:49 +0545 Subject: [PATCH 05/15] fix(ui): improve clarity of user prompts during initialization --- djinit/scripts/input_handler.py | 28 ++++++---------------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/djinit/scripts/input_handler.py b/djinit/scripts/input_handler.py index 85b8abc..a4915f6 100644 --- a/djinit/scripts/input_handler.py +++ b/djinit/scripts/input_handler.py @@ -107,9 +107,9 @@ class InputCollector: console.print(f"[{UIColors.MUTED}]Please try again ({attempt}/{max_attempts}).[/{UIColors.MUTED}]") def get_app_names(self) -> list[str]: - console.print(f"[{UIColors.HIGHLIGHT}]App Names[/{UIColors.HIGHLIGHT}]") - console.print(f"[{UIColors.MUTED}]Enter app names separated by commas[/{UIColors.MUTED}]") - console.print(f"[{UIColors.MUTED}]Example: users, products, orders[/{UIColors.MUTED}]") + console.print( + f"[{UIColors.MUTED}]Enter app names separated by commas(e.g. users, products, orders)[/{UIColors.MUTED}]" + ) user_input = console.input( f"[{UIColors.HIGHLIGHT}]Enter app names (comma-separated or single):[/{UIColors.HIGHLIGHT}] " @@ -191,7 +191,6 @@ class InputCollector: def get_cicd_choice(self) -> Tuple[bool, bool]: UIFormatter.print_separator() console.print(f"\n[{UIColors.INFO}]Step 3: CI/CD Pipeline[/{UIColors.INFO}]\n") - console.print(f"[{UIColors.MUTED}]Choose CI/CD pipeline for your project[/{UIColors.MUTED}]") console.print() for option in CICDOption: @@ -215,12 +214,8 @@ class InputCollector: def get_nested_apps_config(self) -> Tuple[bool, str | None]: UIFormatter.print_separator() - console.print(f"\n[{UIColors.INFO}]Apps Layout[/{UIColors.INFO}]\n") console.print( - f"[{UIColors.MUTED}]Do you want to place apps inside a package directory (e.g., 'src/')?[/{UIColors.MUTED}]" - ) - console.print( - f"[{UIColors.MUTED}]If yes, we'll create that directory as a Python package and generate apps inside it.[/{UIColors.MUTED}]" + f"[{UIColors.MUTED}]Do you want to place apps inside a package directory (e.g. 'src/')?[/{UIColors.MUTED}]" ) console.print() @@ -229,9 +224,8 @@ class InputCollector: if choice != "y": return False, None - # Ask for directory name dir_name = self.get_validated_input( - "Enter directory name for apps package (e.g., apps)", + "Enter directory name for apps package (e.g. src)", validate_project_name, "apps package name", ) @@ -240,14 +234,10 @@ class InputCollector: def get_database_config_choice(self) -> bool: UIFormatter.print_separator() console.print(f"\n[{UIColors.INFO}]Database Configuration[/{UIColors.INFO}]\n") - console.print(f"[{UIColors.MUTED}]Choose how to configure your database in production:[/{UIColors.MUTED}]") console.print() console.print(f" [{UIColors.SUCCESS}]Y[/{UIColors.SUCCESS}] Use DATABASE_URL (recommended for production)") console.print(f" [{UIColors.SUCCESS}]N[/{UIColors.SUCCESS}] Use individual database parameters") console.print() - console.print(f"[{UIColors.MUTED}]DATABASE_URL is a single environment variable like:[/{UIColors.MUTED}]") - console.print(f"[{UIColors.MUTED}]postgres://user:password@host:port/database[/{UIColors.MUTED}]") - console.print() return CharReader.get_yes_no(f"[{UIColors.HIGHLIGHT}]Use DATABASE_URL? (Y/n):[/{UIColors.HIGHLIGHT}]") == "y" @@ -393,12 +383,8 @@ def get_user_input() -> Tuple[str, str, str, list, dict]: # Section 1: Project Setup UIFormatter.print_separator() console.print(f"\n[{UIColors.INFO}]Step 1: Project Setup[/{UIColors.INFO}]\n") - - console.print(f"[{UIColors.HIGHLIGHT}]Project Directory[/{UIColors.HIGHLIGHT}]") - console.print(f"[{UIColors.MUTED}]Where your project files will be created[/{UIColors.MUTED}]") console.print(f"[{UIColors.MUTED}]Press Enter or enter '.' to create in current directory[/{UIColors.MUTED}]") - # Get project directory with special handling for '.' and empty input project_dir = collector._get_project_directory() if project_dir is None: @@ -407,9 +393,7 @@ def get_user_input() -> Tuple[str, str, str, list, dict]: console.print() - console.print(f"[{UIColors.HIGHLIGHT}]Django Project Name[/{UIColors.HIGHLIGHT}]") - console.print(f"[{UIColors.MUTED}]Name used in 'django-admin startproject' command[/{UIColors.MUTED}]") - console.print(f"[{UIColors.MUTED}]Common names: config, core, settings, project_name[/{UIColors.MUTED}]") + console.print(f"[{UIColors.MUTED}]Common names: config, core, settings[/{UIColors.MUTED}]") project_name = collector.get_validated_input( "Enter Django project name", validate_project_name, "Django project name" ) -- 2.43.0 From 5da43ab3a5caba78728968171ff6907ec2a4c0c3 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Mon, 3 Nov 2025 01:08:21 +0545 Subject: [PATCH 06/15] feat: add "generated with djinit" comment to template files --- djinit/templates/app/admin.j2 | 2 ++ djinit/templates/app/apps.j2 | 2 ++ djinit/templates/app/models.j2 | 2 ++ djinit/templates/app/routes.j2 | 2 ++ djinit/templates/app/serializers.j2 | 3 ++- djinit/templates/app/tests.j2 | 2 ++ djinit/templates/app/urls.j2 | 2 ++ djinit/templates/app/views.j2 | 2 ++ djinit/templates/project/asgi.j2 | 2 ++ djinit/templates/project/manage_py.j2 | 4 ++++ djinit/templates/project/project_urls.j2 | 2 ++ djinit/templates/project/settings_base.j2 | 1 + djinit/templates/project/settings_development.j2 | 2 ++ djinit/templates/project/settings_production.j2 | 2 ++ djinit/templates/project/urls.j2 | 16 +--------------- djinit/templates/project/wsgi.j2 | 2 ++ djinit/templates/shared/gitignore.j2 | 4 ++++ djinit/templates/shared/requirements.j2 | 4 ++++ 18 files changed, 40 insertions(+), 16 deletions(-) diff --git a/djinit/templates/app/admin.j2 b/djinit/templates/app/admin.j2 index 8c38f3f..dc9bfc6 100644 --- a/djinit/templates/app/admin.j2 +++ b/djinit/templates/app/admin.j2 @@ -1,3 +1,5 @@ +"""generated with djinit""" + from django.contrib import admin # Register your models here. diff --git a/djinit/templates/app/apps.j2 b/djinit/templates/app/apps.j2 index 891c804..bc3adcc 100644 --- a/djinit/templates/app/apps.j2 +++ b/djinit/templates/app/apps.j2 @@ -1,3 +1,5 @@ +"""generated with djinit""" + from django.apps import AppConfig diff --git a/djinit/templates/app/models.j2 b/djinit/templates/app/models.j2 index 71a8362..980c8e5 100644 --- a/djinit/templates/app/models.j2 +++ b/djinit/templates/app/models.j2 @@ -1,3 +1,5 @@ +"""generated with djinit""" + from django.db import models # Create your models here. diff --git a/djinit/templates/app/routes.j2 b/djinit/templates/app/routes.j2 index c6ea465..3831c00 100644 --- a/djinit/templates/app/routes.j2 +++ b/djinit/templates/app/routes.j2 @@ -1,4 +1,6 @@ """ +generated with djinit + API routes for {{ app_name }} app. """ diff --git a/djinit/templates/app/serializers.j2 b/djinit/templates/app/serializers.j2 index 7cf7f1a..e4b115d 100644 --- a/djinit/templates/app/serializers.j2 +++ b/djinit/templates/app/serializers.j2 @@ -1,5 +1,6 @@ """ -Serializers for {{ app_name }} app. +generated with djinit +serializers for {{ app_name }} app. """ from rest_framework import serializers diff --git a/djinit/templates/app/tests.j2 b/djinit/templates/app/tests.j2 index 7ce503c..7fdd344 100644 --- a/djinit/templates/app/tests.j2 +++ b/djinit/templates/app/tests.j2 @@ -1,3 +1,5 @@ +"""generated with djinit""" + from django.test import TestCase # Create your tests here. diff --git a/djinit/templates/app/urls.j2 b/djinit/templates/app/urls.j2 index a31f082..501e65a 100644 --- a/djinit/templates/app/urls.j2 +++ b/djinit/templates/app/urls.j2 @@ -1,3 +1,5 @@ +"""generated with djinit""" + from django.urls import path from . import views diff --git a/djinit/templates/app/views.j2 b/djinit/templates/app/views.j2 index 91ea44a..7df6a0d 100644 --- a/djinit/templates/app/views.j2 +++ b/djinit/templates/app/views.j2 @@ -1,3 +1,5 @@ +"""generated with djinit""" + from django.shortcuts import render # Create your views here. diff --git a/djinit/templates/project/asgi.j2 b/djinit/templates/project/asgi.j2 index 6380cd7..03b888c 100644 --- a/djinit/templates/project/asgi.j2 +++ b/djinit/templates/project/asgi.j2 @@ -1,3 +1,5 @@ +"""generated with djinit""" + import os from dotenv import load_dotenv from django.core.asgi import get_asgi_application diff --git a/djinit/templates/project/manage_py.j2 b/djinit/templates/project/manage_py.j2 index a616ada..422a555 100644 --- a/djinit/templates/project/manage_py.j2 +++ b/djinit/templates/project/manage_py.j2 @@ -1,7 +1,11 @@ #!/usr/bin/env python + +"""generated with djinit""" + """Django's command-line utility for administrative tasks.""" import os import sys + from dotenv import load_dotenv diff --git a/djinit/templates/project/project_urls.j2 b/djinit/templates/project/project_urls.j2 index 7395a4a..87da515 100644 --- a/djinit/templates/project/project_urls.j2 +++ b/djinit/templates/project/project_urls.j2 @@ -1,3 +1,5 @@ +"""generated with djinit""" + from django.contrib import admin from django.urls import path, include from django.conf import settings diff --git a/djinit/templates/project/settings_base.j2 b/djinit/templates/project/settings_base.j2 index 63cc238..d711d08 100644 --- a/djinit/templates/project/settings_base.j2 +++ b/djinit/templates/project/settings_base.j2 @@ -1,4 +1,5 @@ """ +generated with djinit Common settings shared between development and production environment """ diff --git a/djinit/templates/project/settings_development.j2 b/djinit/templates/project/settings_development.j2 index cb5aa6e..4761347 100644 --- a/djinit/templates/project/settings_development.j2 +++ b/djinit/templates/project/settings_development.j2 @@ -1,3 +1,5 @@ +"""generated with djinit""" + from .base import * SECRET_KEY = "{{ secret_key }}" diff --git a/djinit/templates/project/settings_production.j2 b/djinit/templates/project/settings_production.j2 index 6d45aa1..d85ba12 100644 --- a/djinit/templates/project/settings_production.j2 +++ b/djinit/templates/project/settings_production.j2 @@ -1,3 +1,5 @@ +"""generated with djinit""" + from .base import * import os import dj_database_url diff --git a/djinit/templates/project/urls.j2 b/djinit/templates/project/urls.j2 index 011b908..ab41ddb 100644 --- a/djinit/templates/project/urls.j2 +++ b/djinit/templates/project/urls.j2 @@ -1,19 +1,5 @@ -""" -URL configuration for {{ project_name }} project. +"""generated with djinit""" -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/{{ django_version }}/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) -""" from django.contrib import admin from django.urls import path diff --git a/djinit/templates/project/wsgi.j2 b/djinit/templates/project/wsgi.j2 index d414bb0..fa32c6a 100644 --- a/djinit/templates/project/wsgi.j2 +++ b/djinit/templates/project/wsgi.j2 @@ -1,3 +1,5 @@ +"""generated with djinit""" + import os from dotenv import load_dotenv from django.core.wsgi import get_wsgi_application diff --git a/djinit/templates/shared/gitignore.j2 b/djinit/templates/shared/gitignore.j2 index 51c5a35..2b5243c 100644 --- a/djinit/templates/shared/gitignore.j2 +++ b/djinit/templates/shared/gitignore.j2 @@ -1,3 +1,7 @@ +#--------------------------------------------------# +# The following was generated by djinit: # +#--------------------------------------------------# + # Python *.py[cod] *$py.class diff --git a/djinit/templates/shared/requirements.j2 b/djinit/templates/shared/requirements.j2 index 235b528..5eac586 100644 --- a/djinit/templates/shared/requirements.j2 +++ b/djinit/templates/shared/requirements.j2 @@ -1,3 +1,7 @@ +#--------------------------------------------------# +# The following was generated by djinit: # +#--------------------------------------------------# + Django python-dotenv django-jazzmin -- 2.43.0 From 105cd755959c34cf666c42fcbb099048c97f8b21 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Fri, 7 Nov 2025 10:48:01 +0545 Subject: [PATCH 07/15] feat: add predefined project structure option with core and users apps --- djinit/cli.py | 46 +++++-- djinit/generator.py | 2 +- djinit/scripts/app_generator.py | 100 ++++++++++++++ djinit/scripts/console_ui.py | 14 +- djinit/scripts/files.py | 127 +++++++++++++++++- djinit/scripts/input_handler.py | 47 ++++++- djinit/scripts/project.py | 57 ++++---- djinit/templates/custom/api/urls.j2 | 9 ++ djinit/templates/custom/api/v1_urls.j2 | 9 ++ djinit/templates/custom/app/models_generic.j2 | 10 ++ .../custom/app/serializers_generic.j2 | 11 ++ .../templates/custom/app/services_generic.j2 | 12 ++ djinit/templates/custom/app/tests_generic.j2 | 14 ++ djinit/templates/custom/app/urls_generic.j2 | 16 +++ djinit/templates/custom/app/views_generic.j2 | 11 ++ djinit/templates/custom/core/exceptions.j2 | 7 + .../custom/core/middleware_request_logger.j2 | 16 +++ .../custom/core/mixins_timestamped_model.j2 | 11 ++ .../templates/custom/core/utils_responses.j2 | 11 ++ djinit/templates/custom/project_urls_api.j2 | 9 ++ djinit/templates/custom/users/models_user.j2 | 11 ++ .../custom/users/serializers_user.j2 | 11 ++ .../templates/custom/users/services_user.j2 | 12 ++ .../templates/custom/users/tests_user_api.j2 | 14 ++ djinit/templates/custom/users/urls.j2 | 16 +++ djinit/templates/custom/users/views_user.j2 | 15 +++ .../templates/project/settings_production.j2 | 2 + djinit/templates/shared/env_sample.j2 | 2 + djinit/templates/shared/github_actions_ci.j2 | 8 +- djinit/templates/shared/gitlab_ci.j2 | 10 +- djinit/templates/shared/readme.j2 | 48 +++++++ 31 files changed, 637 insertions(+), 51 deletions(-) create mode 100644 djinit/templates/custom/api/urls.j2 create mode 100644 djinit/templates/custom/api/v1_urls.j2 create mode 100644 djinit/templates/custom/app/models_generic.j2 create mode 100644 djinit/templates/custom/app/serializers_generic.j2 create mode 100644 djinit/templates/custom/app/services_generic.j2 create mode 100644 djinit/templates/custom/app/tests_generic.j2 create mode 100644 djinit/templates/custom/app/urls_generic.j2 create mode 100644 djinit/templates/custom/app/views_generic.j2 create mode 100644 djinit/templates/custom/core/exceptions.j2 create mode 100644 djinit/templates/custom/core/middleware_request_logger.j2 create mode 100644 djinit/templates/custom/core/mixins_timestamped_model.j2 create mode 100644 djinit/templates/custom/core/utils_responses.j2 create mode 100644 djinit/templates/custom/project_urls_api.j2 create mode 100644 djinit/templates/custom/users/models_user.j2 create mode 100644 djinit/templates/custom/users/serializers_user.j2 create mode 100644 djinit/templates/custom/users/services_user.j2 create mode 100644 djinit/templates/custom/users/tests_user_api.j2 create mode 100644 djinit/templates/custom/users/urls.j2 create mode 100644 djinit/templates/custom/users/views_user.j2 diff --git a/djinit/cli.py b/djinit/cli.py index f17d3a3..a706956 100644 --- a/djinit/cli.py +++ b/djinit/cli.py @@ -28,17 +28,41 @@ class Cli: self.file_manager = FileManager(self.project_root, project_name, app_names, metadata) def run_setup(self) -> bool: - steps: List[Tuple[str, Callable[[], bool]]] = [ - ("Creating Django project", self.project_manager.create_project), - ("Creating Django apps", self.project_manager.create_apps), - ("Creating project URLs", self.file_manager.create_project_urls), - ("Validating project structure", self.project_manager.validate_project_structure), - ("Creating utility files", self._create_utility_files), - ("Creating Procfile", self.file_manager.create_procfile), - ("Creating Justfile", self.file_manager.create_justfile), - ("Creating runtime.txt", self.file_manager.create_runtime_txt), - ("Creating CI/CD pipelines", self._create_cicd_pipelines), - ] + # If predefined structure is enabled, adjust metadata defaults + if self.metadata.get("predefined_structure"): + # Default module name to 'config' for conventional layout + self.metadata.setdefault("project_module_name", "config") + # Ensure nested apps live under 'apps' + self.metadata.setdefault("nested_apps", True) + self.metadata.setdefault("nested_dir", "apps") + # Default apps for predefined structure + if not self.app_names: + self.app_names = ["users", "core"] + # propagate to managers + self.project_manager.app_names = self.app_names + + steps: List[Tuple[str, Callable[[], bool]]] = [] + + steps.append(("Creating Django project", self.project_manager.create_project)) + + if self.metadata.get("predefined_structure"): + # Build custom tree and then inject apps into settings + steps.append(("Creating predefined structure", self.file_manager.create_predefined_structure)) + steps.append(("Adding apps to settings", self.project_manager.add_apps_to_settings)) + else: + steps.append(("Creating Django apps", self.project_manager.create_apps)) + steps.append(("Creating project URLs", self.file_manager.create_project_urls)) + + steps.extend( + [ + ("Validating project structure", self.project_manager.validate_project_structure), + ("Creating utility files", self._create_utility_files), + ("Creating Procfile", self.file_manager.create_procfile), + ("Creating Justfile", self.file_manager.create_justfile), + ("Creating runtime.txt", self.file_manager.create_runtime_txt), + ("Creating CI/CD pipelines", self._create_cicd_pipelines), + ] + ) total_steps = len(steps) success = True diff --git a/djinit/generator.py b/djinit/generator.py index 2a6691d..f1e9754 100644 --- a/djinit/generator.py +++ b/djinit/generator.py @@ -58,7 +58,7 @@ def main() -> None: django_cli = Cli(project_dir, project_name, primary_app, app_names, metadata) success = django_cli.run_setup() - UIFormatter.create_summary_panel(project_dir, project_name, app_names, True) + UIFormatter.create_summary_panel(project_dir, project_name, app_names, True, metadata=metadata) sys.exit(0 if success else 1) diff --git a/djinit/scripts/app_generator.py b/djinit/scripts/app_generator.py index 5ed1037..0b319df 100644 --- a/djinit/scripts/app_generator.py +++ b/djinit/scripts/app_generator.py @@ -17,6 +17,8 @@ from djinit.utils import ( insert_apps_into_user_defined_apps, is_django_project, ) +from djinit.scripts.template_engine import template_engine +from djinit.utils import create_file_with_content, create_init_file class AppManager: @@ -63,6 +65,11 @@ class AppManager: UIFormatter.print_error("Could not find manage.py file in project root") return False + # If predefined structure detected (apps/ exists), scaffold nested layout using custom templates + if self._is_predefined_structure(): + return self._create_predefined_app(os.path.join(self.current_dir, "apps")) + + # Otherwise, use standard flat app generation success = DjangoHelper.startapp(self.app_name, apps_base_dir) if success: UIFormatter.print_success(f"Created Django app '{self.app_name}' in {apps_base_dir}") @@ -118,3 +125,96 @@ class AppManager: # Detect nested structure from settings file return detect_nested_structure_from_settings(settings_base_path, self.current_dir) + + def _is_predefined_structure(self) -> bool: + # Heuristic: presence of 'apps' directory at project root (where manage.py lives) and 'api' dir + apps_dir = os.path.join(self.current_dir, "apps") + api_dir = os.path.join(self.current_dir, "api") + return os.path.isdir(apps_dir) and os.path.isdir(api_dir) + + def _create_predefined_app(self, apps_dir: str) -> bool: + """Create an app following the predefined nested structure with custom templates.""" + app_dir = os.path.join(apps_dir, self.app_name) + os.makedirs(app_dir, exist_ok=True) + create_init_file(app_dir, f"Created apps/{self.app_name}/__init__.py") + + # apps.py using standard template + apps_py_content = template_engine.render_template("app/apps.j2", {"app_name": self.app_name}) + create_file_with_content(os.path.join(app_dir, "apps.py"), apps_py_content, f"Created apps/{self.app_name}/apps.py") + + # Compute names for generic templates + model_class_name = "".join([part.capitalize() for part in self.app_name.split("_")]) + app_module = f"apps.{self.app_name}" + + # Subfolders + subfolders = { + "models": [(f"{self.app_name}.py", "custom/app/models_generic.j2")], + "serializers": [(f"{self.app_name}_serializer.py", "custom/app/serializers_generic.j2")], + "services": [(f"{self.app_name}_service.py", "custom/app/services_generic.j2")], + "views": [(f"{self.app_name}_view.py", "custom/app/views_generic.j2")], + "tests": [(f"test_{self.app_name}_api.py", "custom/app/tests_generic.j2")], + } + for folder, files in subfolders.items(): + folder_path = os.path.join(app_dir, folder) + os.makedirs(folder_path, exist_ok=True) + create_init_file(folder_path, f"Created apps/{self.app_name}/{folder}/__init__.py") + for filename, tpl in files: + content = template_engine.render_template( + tpl, + {"app_name": self.app_name, "app_module": app_module, "model_class_name": model_class_name}, + ) + create_file_with_content( + os.path.join(folder_path, filename), + content, + f"Created apps/{self.app_name}/{folder}/{filename}", + ) + + # urls.py at app root + urls_content = template_engine.render_template( + "custom/app/urls_generic.j2", {"app_name": self.app_name, "app_module": app_module} + ) + create_file_with_content(os.path.join(app_dir, "urls.py"), urls_content, f"Created apps/{self.app_name}/urls.py") + + # Add route include to api/v1/urls.py if present + self._add_to_api_v1_urls(self.app_name) + + UIFormatter.print_success(f"Created Django app '{self.app_name}' with predefined structure in {apps_dir}") + return True + + def _add_to_api_v1_urls(self, app_name: str) -> None: + api_v1_urls = os.path.join(self.current_dir, "api", "v1", "urls.py") + if not os.path.exists(api_v1_urls): + return + try: + with open(api_v1_urls, "r", encoding="utf-8") as f: + content = f.read() + + import_line = "from django.urls import include, path" + if import_line not in content: + # ensure base import exists (fallback) + content = import_line + "\n\n" + content + + include_stmt = f"path(\"{app_name}/\", include(\"apps.{app_name}.urls\"))," + if include_stmt in content: + # already present + return + + # Insert into urlpatterns list before closing ] + if "urlpatterns = [" in content: + parts = content.split("urlpatterns = [", 1) + before = parts[0] + rest = parts[1] + if "]" in rest: + idx = rest.rfind("]") + new_rest = rest[:idx] + # ensure newline and indentation + if not new_rest.endswith("\n"): + new_rest += "\n" + new_rest += f" {include_stmt}\n" + rest[idx:] + content = before + "urlpatterns = [" + new_rest + + with open(api_v1_urls, "w", encoding="utf-8") as f: + f.write(content) + except Exception: + # Non-fatal; leave API routes unchanged if edit fails + return diff --git a/djinit/scripts/console_ui.py b/djinit/scripts/console_ui.py index 7346822..b0d796e 100644 --- a/djinit/scripts/console_ui.py +++ b/djinit/scripts/console_ui.py @@ -234,6 +234,7 @@ class UIFormatter: app_names: List[str], success: bool, duration: Optional[float] = None, + metadata: Optional[Dict[str, Any]] = None, ): """Create a clean completion summary with optional duration""" console.print("\n") @@ -253,7 +254,18 @@ class UIFormatter: console.print(" [dim]│[/dim]") console.print(f" [dim]├─[/dim] [bold]Directory:[/bold] [white]{project_dir}[/white]") console.print(f" [dim]├─[/dim] [bold]Project:[/bold] [white]{project_name}[/white]") - console.print(f" [dim]└─[/dim] [bold]Apps:[/bold] [white]{', '.join(app_names)}[/white]") + # Predefined structure adjustments + if metadata and metadata.get("predefined_structure"): + module_name = metadata.get("project_module_name") or project_name + console.print(f" [dim]├─[/dim] [bold]Module:[/bold] [white]{module_name}[/white]") + console.print(f" [dim]├─[/dim] [bold]Structure:[/bold] [white]Predefined[/white]") + # Hide empty apps list in predefined quick flow + if app_names: + console.print(f" [dim]└─[/dim] [bold]Apps:[/bold] [white]{', '.join(app_names)}[/white]") + else: + console.print(f" [dim]└─[/dim] [bold]Apps:[/bold] [white]users, core[/white]") + else: + console.print(f" [dim]└─[/dim] [bold]Apps:[/bold] [white]{', '.join(app_names)}[/white]") console.print() # Next steps diff --git a/djinit/scripts/files.py b/djinit/scripts/files.py index 8c0dda4..a47355b 100644 --- a/djinit/scripts/files.py +++ b/djinit/scripts/files.py @@ -10,6 +10,7 @@ from djinit.utils import ( calculate_app_module_paths, change_cwd, create_file_with_content, + create_init_file, ) @@ -19,7 +20,10 @@ class FileManager: self.project_name = project_name self.app_names = app_names self.metadata = metadata - self.project_configs = os.path.join(project_root, project_name) + # Use module name (e.g., 'config') for Django package paths if provided + # Fall back to project_name if key present but None + self.module_name = metadata.get("project_module_name") or project_name + self.project_configs = os.path.join(project_root, self.module_name) self.settings_folder = os.path.join(self.project_configs, "settings") self.settings_file = os.path.join(self.project_configs, "settings.py") @@ -44,7 +48,12 @@ class FileManager: def create_readme(self) -> bool: with change_cwd(self.project_root): - context = {"project_name": self.project_name, "app_names": self.app_names} + context = { + "project_name": self.project_name, + "app_names": self.app_names, + "predefined_structure": bool(self.metadata.get("predefined_structure")), + "module_name": self.module_name, + } readme_content = template_engine.render_template("shared/readme.j2", context) return create_file_with_content( "README.md", @@ -104,6 +113,116 @@ class FileManager: ) return True + def create_predefined_structure(self) -> bool: + """Create the predefined project structure under apps/ and api/ using custom templates. + + Expected layout: + - apps/users with nested folders (models, serializers, services, views, tests) + - apps/core with utils, mixins, exceptions, middleware + - api/ with versioned urls + - overwrite project urls to include api urls + """ + # Ensure apps package + apps_dir = os.path.join(self.project_root, "apps") + os.makedirs(apps_dir, exist_ok=True) + create_init_file(apps_dir, "Created apps/__init__.py") + + # users app + users_dir = os.path.join(apps_dir, "users") + os.makedirs(users_dir, exist_ok=True) + create_init_file(users_dir, "Created apps/users/__init__.py") + + # apps.py can reuse standard app template + users_context = {"app_name": "users"} + users_apps_py = template_engine.render_template("app/apps.j2", users_context) + create_file_with_content(os.path.join(users_dir, "apps.py"), users_apps_py, "Created apps/users/apps.py") + + # Nested folders and files for users + users_subfolders = { + "models": [ + ("user.py", "custom/users/models_user.j2"), + ], + "serializers": [ + ("user_serializer.py", "custom/users/serializers_user.j2"), + ], + "services": [ + ("user_service.py", "custom/users/services_user.j2"), + ], + "views": [ + ("user_view.py", "custom/users/views_user.j2"), + ], + "tests": [ + ("test_user_api.py", "custom/users/tests_user_api.j2"), + ], + } + for folder, files in users_subfolders.items(): + folder_path = os.path.join(users_dir, folder) + os.makedirs(folder_path, exist_ok=True) + create_init_file(folder_path, f"Created apps/users/{folder}/__init__.py") + for filename, tpl in files: + content = template_engine.render_template(tpl, {"app_module": "apps.users"}) + create_file_with_content( + os.path.join(folder_path, filename), + content, + f"Created apps/users/{folder}/{filename}", + ) + + # users urls.py at app root + users_urls_content = template_engine.render_template("custom/users/urls.j2", {"app_module": "apps.users"}) + create_file_with_content(os.path.join(users_dir, "urls.py"), users_urls_content, "Created apps/users/urls.py") + + # core app + core_dir = os.path.join(apps_dir, "core") + os.makedirs(core_dir, exist_ok=True) + create_init_file(core_dir, "Created apps/core/__init__.py") + + # core subfolders and files + core_subfolders = { + "utils": [("responses.py", "custom/core/utils_responses.j2")], + "mixins": [("timestamped_model.py", "custom/core/mixins_timestamped_model.j2")], + "middleware": [("request_logger.py", "custom/core/middleware_request_logger.j2")], + } + for folder, files in core_subfolders.items(): + folder_path = os.path.join(core_dir, folder) + os.makedirs(folder_path, exist_ok=True) + create_init_file(folder_path, f"Created apps/core/{folder}/__init__.py") + for filename, tpl in files: + content = template_engine.render_template(tpl, {}) + create_file_with_content( + os.path.join(folder_path, filename), + content, + f"Created apps/core/{folder}/{filename}", + ) + + # core/exceptions.py + exceptions_content = template_engine.render_template("custom/core/exceptions.j2", {}) + create_file_with_content(os.path.join(core_dir, "exceptions.py"), exceptions_content, "Created apps/core/exceptions.py") + + # api package + api_dir = os.path.join(self.project_root, "api") + os.makedirs(api_dir, exist_ok=True) + create_init_file(api_dir, "Created api/__init__.py") + api_urls_content = template_engine.render_template("custom/api/urls.j2", {}) + create_file_with_content(os.path.join(api_dir, "urls.py"), api_urls_content, "Created api/urls.py") + + # api/v1 + api_v1_dir = os.path.join(api_dir, "v1") + os.makedirs(api_v1_dir, exist_ok=True) + create_init_file(api_v1_dir, "Created api/v1/__init__.py") + api_v1_urls_content = template_engine.render_template("custom/api/v1_urls.j2", {}) + create_file_with_content(os.path.join(api_v1_dir, "urls.py"), api_v1_urls_content, "Created api/v1/urls.py") + + # overwrite project urls to include api + project_urls_content = template_engine.render_template("custom/project_urls_api.j2", {"project_module": self.module_name}) + create_file_with_content( + os.path.join(self.project_configs, "urls.py"), + project_urls_content, + "Updated project urls.py to include API routes", + should_format=True, + ) + + return True + def create_procfile(self) -> bool: with change_cwd(self.project_root): context = {"project_name": self.project_name} @@ -131,7 +250,7 @@ class FileManager: github_dir = os.path.join(self.project_root, ".github", "workflows") os.makedirs(github_dir, exist_ok=True) - context = {"project_name": self.project_name} + context = {"project_name": self.project_name, "module_name": self.module_name} workflow_content = template_engine.render_template("shared/github_actions_ci.j2", context) workflow_file = os.path.join(github_dir, "ci.yml") create_file_with_content( @@ -143,7 +262,7 @@ class FileManager: def create_gitlab_ci(self) -> bool: with change_cwd(self.project_root): - context = {"project_name": self.project_name} + context = {"project_name": self.project_name, "module_name": self.module_name} gitlab_ci_content = template_engine.render_template("shared/gitlab_ci.j2", context) create_file_with_content( ".gitlab-ci.yml", diff --git a/djinit/scripts/input_handler.py b/djinit/scripts/input_handler.py index a4915f6..c3afd5b 100644 --- a/djinit/scripts/input_handler.py +++ b/djinit/scripts/input_handler.py @@ -34,6 +34,9 @@ class ProjectMetadata: nested_apps: bool = False nested_dir: str | None = None use_database_url: bool = False + # predefined structure support + predefined_structure: bool = False + project_module_name: str | None = None def to_dict(self) -> dict: return { @@ -43,6 +46,8 @@ class ProjectMetadata: "nested_apps": self.nested_apps, "nested_dir": self.nested_dir, "use_database_url": self.use_database_url, + "predefined_structure": self.predefined_structure, + "project_module_name": self.project_module_name, } @@ -380,7 +385,47 @@ def get_user_input() -> Tuple[str, str, str, list, dict]: collector = InputCollector() console.print() - # Section 1: Project Setup + # Pre-step: Ask for predefined structure first + UIFormatter.print_separator() + console.print(f"\n[{UIColors.INFO}]Choose Structure[/{UIColors.INFO}]\n") + use_predefined = CharReader.get_yes_no( + f"[{UIColors.HIGHLIGHT}]Use predefined structure? (Y/n):[/{UIColors.HIGHLIGHT}]" + ) + + if use_predefined == "y": + UIFormatter.print_separator() + console.print(f"\n[{UIColors.INFO}]Step 1: Project Setup[/{UIColors.INFO}]\n") + console.print( + f"[{UIColors.MUTED}]Press Enter or enter '.' to create in current directory[/{UIColors.MUTED}]" + ) + project_dir = collector._get_project_directory() + if project_dir is None: + UIFormatter.print_error("Maximum attempts reached for project directory name. Exiting.") + sys.exit(1) + + # Minimal inputs: project_dir only, then ask for CI/CD choice. + project_name = project_dir + app_names: list[str] = [] + + # Ask for workflows so shared templates for CI/CD can be added + use_github, use_gitlab = collector.get_cicd_choice() + + # Ask database configuration for predefined structure as well + use_database_url = collector.get_database_config_choice() + metadata = ProjectMetadata( + package_name=project_dir, + use_github_actions=use_github, + use_gitlab_ci=use_gitlab, + nested_apps=True, + nested_dir="apps", + use_database_url=use_database_url, + predefined_structure=True, + project_module_name="config", + ) + + return project_dir, project_name, "", app_names, metadata.to_dict() + + # Section 1: Project Setup (standard flow) UIFormatter.print_separator() console.print(f"\n[{UIColors.INFO}]Step 1: Project Setup[/{UIColors.INFO}]\n") console.print(f"[{UIColors.MUTED}]Press Enter or enter '.' to create in current directory[/{UIColors.MUTED}]") diff --git a/djinit/scripts/project.py b/djinit/scripts/project.py index d963c12..29ba578 100644 --- a/djinit/scripts/project.py +++ b/djinit/scripts/project.py @@ -23,11 +23,14 @@ class ProjectManager: self.app_names = app_names self.metadata = metadata self.project_root = os.getcwd() if project_dir == "." else os.path.join(os.getcwd(), project_dir) + # Support custom module name for the Django config package (e.g., 'config') + # Fall back to project_name if key present but None + self.module_name = metadata.get("project_module_name") or self.project_name def create_project(self) -> bool: os.makedirs(self.project_root, exist_ok=True) - success = DjangoHelper.startproject(self.project_name, self.project_root) + success = DjangoHelper.startproject(self.module_name, self.project_root) if success: UIFormatter.print_success(f"Django project '{self.project_name}' created successfully!") else: @@ -61,13 +64,13 @@ class ProjectManager: def _add_apps_to_settings(self) -> bool: """Add all apps to USER_DEFINED_APPS in base.py settings file.""" - base_settings_path = get_base_settings_path(self.project_root, self.project_name) + base_settings_path = get_base_settings_path(self.project_root, self.module_name) if not os.path.exists(base_settings_path): UIFormatter.print_error("Could not find base.py settings file") return False - content = read_base_settings(self.project_root, self.project_name) + content = read_base_settings(self.project_root, self.module_name) if content is None: UIFormatter.print_error("Could not read base.py settings file") return False @@ -92,36 +95,42 @@ class ProjectManager: UIFormatter.print_success(f"Added apps to USER_DEFINED_APPS: {added_apps_str}") return True + # Public wrapper to allow external callers to add apps based on current state + def add_apps_to_settings(self) -> bool: + return self._add_apps_to_settings() + def validate_project_structure(self) -> bool: required_files = [ os.path.join(self.project_root, "manage.py"), - os.path.join(self.project_root, self.project_name, "__init__.py"), - os.path.join(self.project_root, self.project_name, "settings", "__init__.py"), - os.path.join(self.project_root, self.project_name, "settings", "base.py"), - os.path.join(self.project_root, self.project_name, "settings", "development.py"), - os.path.join(self.project_root, self.project_name, "settings", "production.py"), - os.path.join(self.project_root, self.project_name, "urls.py"), - os.path.join(self.project_root, self.project_name, "wsgi.py"), - os.path.join(self.project_root, self.project_name, "asgi.py"), + os.path.join(self.project_root, self.module_name, "__init__.py"), + os.path.join(self.project_root, self.module_name, "settings", "__init__.py"), + os.path.join(self.project_root, self.module_name, "settings", "base.py"), + os.path.join(self.project_root, self.module_name, "settings", "development.py"), + os.path.join(self.project_root, self.module_name, "settings", "production.py"), + os.path.join(self.project_root, self.module_name, "urls.py"), + os.path.join(self.project_root, self.module_name, "wsgi.py"), + os.path.join(self.project_root, self.module_name, "asgi.py"), ] apps_base_dir = self.project_root if self.metadata.get("nested_apps") and self.metadata.get("nested_dir"): apps_base_dir = os.path.join(self.project_root, self.metadata.get("nested_dir")) - for app_name in self.app_names: - app_files = [ - os.path.join(apps_base_dir, app_name, "__init__.py"), - os.path.join(apps_base_dir, app_name, "apps.py"), - os.path.join(apps_base_dir, app_name, "models.py"), - os.path.join(apps_base_dir, app_name, "views.py"), - os.path.join(apps_base_dir, app_name, "serializers.py"), - os.path.join(apps_base_dir, app_name, "routes.py"), - os.path.join(apps_base_dir, app_name, "tests.py"), - os.path.join(apps_base_dir, app_name, "migrations"), - os.path.join(apps_base_dir, app_name, "admin.py"), - ] - required_files.extend(app_files) + # In predefined structure, apps layout differs; skip strict per-app validation + if not self.metadata.get("predefined_structure"): + for app_name in self.app_names: + app_files = [ + os.path.join(apps_base_dir, app_name, "__init__.py"), + os.path.join(apps_base_dir, app_name, "apps.py"), + os.path.join(apps_base_dir, app_name, "models.py"), + os.path.join(apps_base_dir, app_name, "views.py"), + os.path.join(apps_base_dir, app_name, "serializers.py"), + os.path.join(apps_base_dir, app_name, "routes.py"), + os.path.join(apps_base_dir, app_name, "tests.py"), + os.path.join(apps_base_dir, app_name, "migrations"), + os.path.join(apps_base_dir, app_name, "admin.py"), + ] + required_files.extend(app_files) missing_files = [] for file_path in required_files: diff --git a/djinit/templates/custom/api/urls.j2 b/djinit/templates/custom/api/urls.j2 new file mode 100644 index 0000000..69585ec --- /dev/null +++ b/djinit/templates/custom/api/urls.j2 @@ -0,0 +1,9 @@ +from django.urls import include, path + +app_name = "api" + +urlpatterns = [ + path("v1/", include("api.v1.urls")), +] + + diff --git a/djinit/templates/custom/api/v1_urls.j2 b/djinit/templates/custom/api/v1_urls.j2 new file mode 100644 index 0000000..40ebdea --- /dev/null +++ b/djinit/templates/custom/api/v1_urls.j2 @@ -0,0 +1,9 @@ +from django.urls import include, path + +app_name = "v1" + +urlpatterns = [ + path("users/", include("apps.users.urls")), +] + + diff --git a/djinit/templates/custom/app/models_generic.j2 b/djinit/templates/custom/app/models_generic.j2 new file mode 100644 index 0000000..8067069 --- /dev/null +++ b/djinit/templates/custom/app/models_generic.j2 @@ -0,0 +1,10 @@ +from django.db import models + + +class {{ model_class_name }}(models.Model): + """Generated model for {{ app_name }} app.""" + + # Add fields here + pass + + diff --git a/djinit/templates/custom/app/serializers_generic.j2 b/djinit/templates/custom/app/serializers_generic.j2 new file mode 100644 index 0000000..ef60bcb --- /dev/null +++ b/djinit/templates/custom/app/serializers_generic.j2 @@ -0,0 +1,11 @@ +from rest_framework import serializers + +from {{ app_module }}.models.{{ app_name }} import {{ model_class_name }} + + +class {{ model_class_name }}Serializer(serializers.ModelSerializer): + class Meta: + model = {{ model_class_name }} + fields = "__all__" + + diff --git a/djinit/templates/custom/app/services_generic.j2 b/djinit/templates/custom/app/services_generic.j2 new file mode 100644 index 0000000..eb75f90 --- /dev/null +++ b/djinit/templates/custom/app/services_generic.j2 @@ -0,0 +1,12 @@ +from typing import Optional + +from {{ app_module }}.models.{{ app_name }} import {{ model_class_name }} + + +def get_{{ app_name }}_by_id(object_id: int) -> Optional[{{ model_class_name }}]: + try: + return {{ model_class_name }}.objects.get(id=object_id) + except {{ model_class_name }}.DoesNotExist: + return None + + diff --git a/djinit/templates/custom/app/tests_generic.j2 b/djinit/templates/custom/app/tests_generic.j2 new file mode 100644 index 0000000..910bad8 --- /dev/null +++ b/djinit/templates/custom/app/tests_generic.j2 @@ -0,0 +1,14 @@ +import pytest +from django.urls import reverse +from rest_framework.test import APIClient + + +@pytest.mark.django_db +def test_{{ app_name }}_list_empty(): + client = APIClient() + url = reverse("v1:{{ app_name }}-list") + response = client.get(url) + assert response.status_code == 200 + assert response.json() == [] + + diff --git a/djinit/templates/custom/app/urls_generic.j2 b/djinit/templates/custom/app/urls_generic.j2 new file mode 100644 index 0000000..68578e9 --- /dev/null +++ b/djinit/templates/custom/app/urls_generic.j2 @@ -0,0 +1,16 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from {{ app_module }}.views.{{ app_name }}_view import {{ model_class_name }}ViewSet + + +app_name = "{{ app_name }}" + +router = DefaultRouter() +router.register(r"{{ app_name }}", {{ model_class_name }}ViewSet, basename="{{ app_name }}") + +urlpatterns = [ + path("", include(router.urls)), +] + + diff --git a/djinit/templates/custom/app/views_generic.j2 b/djinit/templates/custom/app/views_generic.j2 new file mode 100644 index 0000000..af3545c --- /dev/null +++ b/djinit/templates/custom/app/views_generic.j2 @@ -0,0 +1,11 @@ +from rest_framework import viewsets + +from {{ app_module }}.models.{{ app_name }} import {{ model_class_name }} +from {{ app_module }}.serializers.{{ app_name }}_serializer import {{ model_class_name }}Serializer + + +class {{ model_class_name }}ViewSet(viewsets.ModelViewSet): + queryset = {{ model_class_name }}.objects.all() + serializer_class = {{ model_class_name }}Serializer + + diff --git a/djinit/templates/custom/core/exceptions.j2 b/djinit/templates/custom/core/exceptions.j2 new file mode 100644 index 0000000..3a2f7fa --- /dev/null +++ b/djinit/templates/custom/core/exceptions.j2 @@ -0,0 +1,7 @@ +class ApplicationError(Exception): + """Base application exception.""" + + def __init__(self, message: str): + super().__init__(message) + + diff --git a/djinit/templates/custom/core/middleware_request_logger.j2 b/djinit/templates/custom/core/middleware_request_logger.j2 new file mode 100644 index 0000000..00ed0fe --- /dev/null +++ b/djinit/templates/custom/core/middleware_request_logger.j2 @@ -0,0 +1,16 @@ +import logging + + +logger = logging.getLogger(__name__) + + +class RequestLoggerMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + logger.info("%s %s", request.method, request.path) + response = self.get_response(request) + return response + + diff --git a/djinit/templates/custom/core/mixins_timestamped_model.j2 b/djinit/templates/custom/core/mixins_timestamped_model.j2 new file mode 100644 index 0000000..3d27df1 --- /dev/null +++ b/djinit/templates/custom/core/mixins_timestamped_model.j2 @@ -0,0 +1,11 @@ +from django.db import models + + +class TimestampedModel(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + + diff --git a/djinit/templates/custom/core/utils_responses.j2 b/djinit/templates/custom/core/utils_responses.j2 new file mode 100644 index 0000000..fc6bf7a --- /dev/null +++ b/djinit/templates/custom/core/utils_responses.j2 @@ -0,0 +1,11 @@ +from rest_framework.response import Response + + +def success(data=None, status_code=200): + return Response({"success": True, "data": data}, status=status_code) + + +def error(message, status_code=400): + return Response({"success": False, "message": message}, status=status_code) + + diff --git a/djinit/templates/custom/project_urls_api.j2 b/djinit/templates/custom/project_urls_api.j2 new file mode 100644 index 0000000..459de61 --- /dev/null +++ b/djinit/templates/custom/project_urls_api.j2 @@ -0,0 +1,9 @@ +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), + path("api/", include("api.urls")), +] + + diff --git a/djinit/templates/custom/users/models_user.j2 b/djinit/templates/custom/users/models_user.j2 new file mode 100644 index 0000000..556619b --- /dev/null +++ b/djinit/templates/custom/users/models_user.j2 @@ -0,0 +1,11 @@ +from django.contrib.auth.models import AbstractUser +from django.db import models + + +class User(AbstractUser): + """Custom user model extending Django's AbstractUser.""" + + # Add custom fields here if needed + pass + + diff --git a/djinit/templates/custom/users/serializers_user.j2 b/djinit/templates/custom/users/serializers_user.j2 new file mode 100644 index 0000000..ca7a68e --- /dev/null +++ b/djinit/templates/custom/users/serializers_user.j2 @@ -0,0 +1,11 @@ +from rest_framework import serializers + +from apps.users.models.user import User + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ["id", "username", "email", "first_name", "last_name"] + + diff --git a/djinit/templates/custom/users/services_user.j2 b/djinit/templates/custom/users/services_user.j2 new file mode 100644 index 0000000..5888378 --- /dev/null +++ b/djinit/templates/custom/users/services_user.j2 @@ -0,0 +1,12 @@ +from typing import Optional + +from apps.users.models.user import User + + +def get_user_by_id(user_id: int) -> Optional[User]: + try: + return User.objects.get(id=user_id) + except User.DoesNotExist: + return None + + diff --git a/djinit/templates/custom/users/tests_user_api.j2 b/djinit/templates/custom/users/tests_user_api.j2 new file mode 100644 index 0000000..3bdb897 --- /dev/null +++ b/djinit/templates/custom/users/tests_user_api.j2 @@ -0,0 +1,14 @@ +import pytest +from django.urls import reverse +from rest_framework.test import APIClient + + +@pytest.mark.django_db +def test_users_list_empty(): + client = APIClient() + url = reverse("v1:users-list") + response = client.get(url) + assert response.status_code == 200 + assert response.json() == [] + + diff --git a/djinit/templates/custom/users/urls.j2 b/djinit/templates/custom/users/urls.j2 new file mode 100644 index 0000000..98e7d7a --- /dev/null +++ b/djinit/templates/custom/users/urls.j2 @@ -0,0 +1,16 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from apps.users.views.user_view import UserViewSet + + +app_name = "users" + +router = DefaultRouter() +router.register(r"users", UserViewSet, basename="users") + +urlpatterns = [ + path("", include(router.urls)), +] + + diff --git a/djinit/templates/custom/users/views_user.j2 b/djinit/templates/custom/users/views_user.j2 new file mode 100644 index 0000000..660fcc7 --- /dev/null +++ b/djinit/templates/custom/users/views_user.j2 @@ -0,0 +1,15 @@ +from rest_framework import status, viewsets +from rest_framework.response import Response + +from apps.users.models.user import User +from apps.users.serializers.user_serializer import UserSerializer + + +class UserViewSet(viewsets.ReadOnlyModelViewSet): + queryset = User.objects.all() + serializer_class = UserSerializer + + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + + diff --git a/djinit/templates/project/settings_production.j2 b/djinit/templates/project/settings_production.j2 index d85ba12..4f7d346 100644 --- a/djinit/templates/project/settings_production.j2 +++ b/djinit/templates/project/settings_production.j2 @@ -37,6 +37,8 @@ EMAIL_PORT = int(os.getenv('EMAIL_PORT', 587)) EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS', 'True').lower() == 'true' EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER') EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD') +DEFAULT_FROM_EMAIL = os.environ.get("EMAIL_HOST_USER") +SERVER_EMAIL = os.environ.get("EMAIL_HOST_USER") # Security settings for production SECURE_SSL_REDIRECT = True diff --git a/djinit/templates/shared/env_sample.j2 b/djinit/templates/shared/env_sample.j2 index 135d06b..0a4e621 100644 --- a/djinit/templates/shared/env_sample.j2 +++ b/djinit/templates/shared/env_sample.j2 @@ -21,3 +21,5 @@ EMAIL_PORT= EMAIL_USE_TLS= EMAIL_HOST_USER= EMAIL_HOST_PASSWORD= +DEFAULT_FROM_EMAIL= +SERVER_EMAIL= diff --git a/djinit/templates/shared/github_actions_ci.j2 b/djinit/templates/shared/github_actions_ci.j2 index 52082de..6200b9c 100644 --- a/djinit/templates/shared/github_actions_ci.j2 +++ b/djinit/templates/shared/github_actions_ci.j2 @@ -31,10 +31,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} + {% raw %}- name: Set up Python ${{ matrix.python-version }}{% endraw %} uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} + {% raw %}python-version: ${{ matrix.python-version }}{% endraw %} - name: Install uv uses: astral-sh/setup-uv@v4 @@ -62,7 +62,7 @@ jobs: - name: Run Django tests env: - DJANGO_SETTINGS_MODULE: {{ project_name }}.settings.development + DJANGO_SETTINGS_MODULE: {{ module_name }}.settings.development SECRET_KEY: test-secret-key-for-ci ALLOWED_HOSTS: localhost DB_NAME: test_db @@ -76,7 +76,7 @@ jobs: - name: Check migrations env: - DJANGO_SETTINGS_MODULE: {{ project_name }}.settings.development + DJANGO_SETTINGS_MODULE: {{ module_name }}.settings.development SECRET_KEY: test-secret-key-for-ci ALLOWED_HOSTS: localhost DB_NAME: test_db diff --git a/djinit/templates/shared/gitlab_ci.j2 b/djinit/templates/shared/gitlab_ci.j2 index 4fcedd6..ff6ef5b 100644 --- a/djinit/templates/shared/gitlab_ci.j2 +++ b/djinit/templates/shared/gitlab_ci.j2 @@ -36,7 +36,7 @@ test: stage: test image: python:3.13 variables: - DJANGO_SETTINGS_MODULE: {{ project_name }}.settings.development + DJANGO_SETTINGS_MODULE: {{ module_name }}.settings.development SECRET_KEY: test-secret-key-for-ci ALLOWED_HOSTS: localhost script: @@ -52,7 +52,7 @@ test:python310: stage: test image: python:3.10 variables: - DJANGO_SETTINGS_MODULE: {{ project_name }}.settings.development + DJANGO_SETTINGS_MODULE: {{ module_name }}.settings.development SECRET_KEY: test-secret-key-for-ci ALLOWED_HOSTS: localhost script: @@ -69,7 +69,7 @@ test:python311: stage: test image: python:3.11 variables: - DJANGO_SETTINGS_MODULE: {{ project_name }}.settings.development + DJANGO_SETTINGS_MODULE: {{ module_name }}.settings.development SECRET_KEY: test-secret-key-for-ci ALLOWED_HOSTS: localhost script: @@ -86,7 +86,7 @@ test:python312: stage: test image: python:3.12 variables: - DJANGO_SETTINGS_MODULE: {{ project_name }}.settings.development + DJANGO_SETTINGS_MODULE: {{ module_name }}.settings.development SECRET_KEY: test-secret-key-for-ci ALLOWED_HOSTS: localhost script: @@ -103,7 +103,7 @@ test:python313: stage: test image: python:3.13 variables: - DJANGO_SETTINGS_MODULE: {{ project_name }}.settings.development + DJANGO_SETTINGS_MODULE: {{ module_name }}.settings.development SECRET_KEY: test-secret-key-for-ci ALLOWED_HOSTS: localhost script: diff --git a/djinit/templates/shared/readme.j2 b/djinit/templates/shared/readme.j2 index 956aa49..50a5e31 100644 --- a/djinit/templates/shared/readme.j2 +++ b/djinit/templates/shared/readme.j2 @@ -36,6 +36,53 @@ A Django project created with django-setup. ## Project Structure ``` +{% if predefined_structure %} +{{ project_name }}/ +├── {{ module_name }}/ +│ ├── settings/ +│ │ ├── base.py +│ │ ├── development.py +│ │ └── production.py +│ ├── urls.py +│ ├── wsgi.py +│ └── asgi.py +├── apps/ +│ ├── users/ +│ │ ├── apps.py +│ │ ├── models/ +│ │ │ └── user.py +│ │ ├── serializers/ +│ │ │ └── user_serializer.py +│ │ ├── services/ +│ │ │ └── user_service.py +│ │ ├── views/ +│ │ │ └── user_view.py +│ │ ├── urls.py +│ │ └── tests/ +│ │ └── test_user_api.py +│ └── core/ +│ ├── utils/ +│ │ └── responses.py +│ ├── mixins/ +│ │ └── timestamped_model.py +│ ├── exceptions.py +│ └── middleware/ +│ └── request_logger.py +├── api/ +│ ├── __init__.py +│ ├── urls.py +│ └── v1/ +│ ├── __init__.py +│ └── urls.py +├── manage.py +├── requirements.txt +├── pyproject.toml +├── .env.sample +├── justfile +├── Procfile +├── runtime.txt +└── README.md +{% else %} {{ project_name }}/ ├── {{ project_name }}/ │ ├── settings/ @@ -51,6 +98,7 @@ A Django project created with django-setup. ├── pyproject.toml ├── .env.sample └── README.md +{% endif %} ``` ## API Documentation -- 2.43.0 From eb59e0c487852abf1675b6024d220f06b1f0449a Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Sat, 8 Nov 2025 21:43:20 +0545 Subject: [PATCH 08/15] fix(justfile): escape Jinja2 variables to prevent premature evaluation --- djinit/templates/shared/justfile.j2 | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/djinit/templates/shared/justfile.j2 b/djinit/templates/shared/justfile.j2 index 93cde70..a4e0052 100644 --- a/djinit/templates/shared/justfile.j2 +++ b/djinit/templates/shared/justfile.j2 @@ -12,7 +12,7 @@ dev: # Run development server with custom host and port dev-server HOST="0.0.0.0" PORT="8000": - uv run python manage.py runserver {{HOST}}:{{PORT}} + uv run python manage.py runserver {{ '{{' }}HOST{{ '}}' }}:{{ '{{' }}PORT{{ '}}' }} # Create migrations for all apps makemigrations: @@ -20,7 +20,7 @@ makemigrations: # Create migrations for specific app makemigrations-app APP: - uv run python manage.py makemigrations {{APP}} + uv run python manage.py makemigrations {{ '{{' }}APP{{ '}}' }} # Run all migrations migrate: @@ -112,7 +112,7 @@ server: # Start production server with gunicorn workers server-prod WORKERS="4": - uv run gunicorn {{ project_name }}.wsgi:application --bind 0.0.0.0:8000 --workers {{WORKERS}} + uv run gunicorn {{ project_name }}.wsgi:application --bind 0.0.0.0:8000 --workers {{ '{{' }}WORKERS{{ '}}' }} # Start production server with gunicorn and reload on file changes server-watch: @@ -143,12 +143,12 @@ setup-complete: # Install a new package install PACKAGE: - uv add {{PACKAGE}} + uv add {{ '{{' }}PACKAGE{{ '}}' }} # Install a dev package install-dev PACKAGE: - uv add --dev {{PACKAGE}} + uv add --dev {{ '{{' }}PACKAGE{{ '}}' }} # Remove a package remove PACKAGE: - uv remove {{PACKAGE}} + uv remove {{ '{{' }}PACKAGE{{ '}}' }} -- 2.43.0 From 21823fcc61278dee4378049993b1c52b5adf00a3 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Sat, 8 Nov 2025 21:46:35 +0545 Subject: [PATCH 09/15] fix(cli): set default package name to 'backend' when project dir is '.' or empty --- djinit/scripts/files.py | 6 +++++- djinit/scripts/input_handler.py | 8 ++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/djinit/scripts/files.py b/djinit/scripts/files.py index a47355b..6dc3afe 100644 --- a/djinit/scripts/files.py +++ b/djinit/scripts/files.py @@ -77,7 +77,11 @@ class FileManager: def create_pyproject_toml(self, metadata: dict) -> bool: with change_cwd(self.project_root): - context = {"package_name": metadata["package_name"], "project_name": self.project_name} + # Default package_name to "backend" if it's "." or empty + package_name = metadata.get("package_name", "backend") + if package_name == "." or not package_name: + package_name = "backend" + context = {"package_name": package_name, "project_name": self.project_name} pyproject_content = template_engine.render_template("shared/pyproject_toml.j2", context) return create_file_with_content( "pyproject.toml", diff --git a/djinit/scripts/input_handler.py b/djinit/scripts/input_handler.py index c3afd5b..c1502c4 100644 --- a/djinit/scripts/input_handler.py +++ b/djinit/scripts/input_handler.py @@ -412,8 +412,10 @@ def get_user_input() -> Tuple[str, str, str, list, dict]: # Ask database configuration for predefined structure as well use_database_url = collector.get_database_config_choice() + # Default package_name to "backend" if project_dir is "." or empty + package_name = "backend" if project_dir == "." or not project_dir else project_dir metadata = ProjectMetadata( - package_name=project_dir, + package_name=package_name, use_github_actions=use_github, use_gitlab_ci=use_gitlab, nested_apps=True, @@ -459,8 +461,10 @@ def get_user_input() -> Tuple[str, str, str, list, dict]: use_database_url = collector.get_database_config_choice() # Create metadata + # Default package_name to "backend" if project_dir is "." or empty + package_name = "backend" if project_dir == "." or not project_dir else project_dir metadata = ProjectMetadata( - package_name=project_dir, + package_name=package_name, use_github_actions=use_github, use_gitlab_ci=use_gitlab, nested_apps=nested, -- 2.43.0 From e34faae3a7c248f188293ef861e7edb1df254a2f Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Sat, 8 Nov 2025 21:51:18 +0545 Subject: [PATCH 10/15] feat: enhance project setup with .env.sample, improved .gitignore, and app structure support --- .env.sample | 20 ++++ .gitignore | 206 ++++++++++---------------------- djinit/scripts/app_generator.py | 16 ++- pyproject.toml | 1 + 4 files changed, 94 insertions(+), 149 deletions(-) create mode 100644 .env.sample diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..03e6634 --- /dev/null +++ b/.env.sample @@ -0,0 +1,20 @@ +# Django settings +DJANGO_SETTINGS_MODULE=..settings.development +SECRET_KEY= your-secret-key +ALLOWED_HOSTS=api.your-domain.com,www.your-domain.com + +# Database (individual parameters) +DB_NAME= +DB_USER= +DB_PASSWORD= +DB_HOST= +DB_PORT= + +# Email settings +EMAIL_HOST= +EMAIL_PORT= +EMAIL_USE_TLS= +EMAIL_HOST_USER= +EMAIL_HOST_PASSWORD= +DEFAULT_FROM_EMAIL= +SERVER_EMAIL= diff --git a/.gitignore b/.gitignore index f1576c8..2b5243c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,130 +1,26 @@ #--------------------------------------------------# -# The following was generated with gitignore.nvim: # +# The following was generated by djinit: # #--------------------------------------------------# -# Gitignore for the following technologies: Python -# Byte-compiled / optimized / DLL files -__pycache__/ +# Python *.py[cod] *$py.class - -# C extensions *.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ +__pycache__/ *.egg-info/ -.installed.cfg +dist/ +build/ +.eggs/ *.egg -MANIFEST -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: +# Django *.log local_settings.py db.sqlite3 db.sqlite3-journal +*.sqlite3 -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/#use-with-ide -.pdm.toml - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments +# Environment .env .venv env/ @@ -133,45 +29,65 @@ ENV/ env.bak/ venv.bak/ -# Spyder project settings -.spyderproject -.spyproject +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ +.project +.pydevproject +.settings/ +.ropeproject/ -# Rope project settings -.ropeproject +# OS +.DS_Store +*.DS_Store +Thumbs.db +desktop.ini -# mkdocs documentation -/site +# Django specific +media/ +staticfiles/ +staticfiles/ +static_root/ +/static/ -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json +# Testing +.coverage +htmlcov/ +.pytest_cache/ +.tox/ +coverage.xml +*.cover +.hypothesis/ +.cache -# Pyre type checker -.pyre/ +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* -# pytype static type analyzer -.pytype/ +# Node modules (if using frontend tools) +node_modules/ -# Cython debug symbols -cython_debug/ +# Secret keys +secret.txt +secrets.json -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +# Jupyter Notebook +.ipynb_checkpoints -# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration -poetry.toml +# Backup files +*.bak +*.tmp +*~ -# ruff -.ruff_cache/ +# Database +*.db +*.sql -# LSP config files -pyrightconfig.json - -djinit.egg-info/ -djinit.egg-info/* +# Cloud +.cloud diff --git a/djinit/scripts/app_generator.py b/djinit/scripts/app_generator.py index 0b319df..9500aef 100644 --- a/djinit/scripts/app_generator.py +++ b/djinit/scripts/app_generator.py @@ -51,8 +51,12 @@ class AppManager: return is_django_project(self.current_dir) def _app_exists(self) -> bool: - is_nested, nested_dir, apps_base_dir = self._detect_project_structure() - app_path = os.path.join(apps_base_dir, self.app_name) + # For predefined structure, check in apps/ directory + if self._is_predefined_structure(): + app_path = os.path.join(self.current_dir, "apps", self.app_name) + else: + is_nested, nested_dir, apps_base_dir = self._detect_project_structure() + app_path = os.path.join(apps_base_dir, self.app_name) return os.path.exists(app_path) def _create_django_app(self) -> bool: @@ -92,8 +96,12 @@ class AppManager: content = f.read() # Get the app's module path (e.g., "apps.users" or just "users") - is_nested, nested_dir, apps_base_dir = self._detect_project_structure() - app_module_path = calculate_app_module_path(self.app_name, is_nested, nested_dir) + # For predefined structure, use "apps" as nested_dir + if self._is_predefined_structure(): + app_module_path = f"apps.{self.app_name}" + else: + is_nested, nested_dir, apps_base_dir = self._detect_project_structure() + app_module_path = calculate_app_module_path(self.app_name, is_nested, nested_dir) # Check if app is already in USER_DEFINED_APPS existing_apps = extract_existing_apps(content) diff --git a/pyproject.toml b/pyproject.toml index b053ef7..0c1fccc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ dependencies = [ "rich>=14.2.0", "ruff>=0.14.2", "jinja2>=3.1.0", + "django>=4.2,<6.0", ] [project.urls] -- 2.43.0 From e6ce03fa3c965f228161c567e3c9b1a23fcef1ad Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Sat, 8 Nov 2025 21:53:20 +0545 Subject: [PATCH 11/15] docs(ui): update command instructions to reflect just commands --- djinit/scripts/command_handler.py | 4 ++-- djinit/scripts/console_ui.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/djinit/scripts/command_handler.py b/djinit/scripts/command_handler.py index 06d0c56..ec03879 100644 --- a/djinit/scripts/command_handler.py +++ b/djinit/scripts/command_handler.py @@ -120,8 +120,8 @@ def handle_app_command(args: argparse.Namespace) -> None: instructions.append("🚀 Next Steps:\n", style=UIColors.ACCENT) instructions.append("1. The app(s) have been added to INSTALLED_APPS in settings/base.py\n") instructions.append("2. Create your models in each app's models.py\n") - instructions.append("3. Run migrations: python manage.py makemigrations\n") - instructions.append("4. Apply migrations: python manage.py migrate\n") + instructions.append("3. Run migrations: just makemigrations\n") + instructions.append("4. Apply migrations: just migrate\n") instructions.append("5. Create views, serializers and routes(URLs) for your app(s)\n") console.print(Panel(instructions, title="💡 What's Next", border_style="green")) diff --git a/djinit/scripts/console_ui.py b/djinit/scripts/console_ui.py index b0d796e..da60336 100644 --- a/djinit/scripts/console_ui.py +++ b/djinit/scripts/console_ui.py @@ -273,11 +273,11 @@ class UIFormatter: console.print(" [dim]│[/dim]") console.print(" [dim]├─[/dim] [dim]1.[/dim] Navigate to your project directory") console.print(" [dim]├─[/dim] [dim]2.[/dim] Set environment variables in .env file") - console.print(" [dim]├─[/dim] [dim]3.[/dim] Run migrations: [cyan]python manage.py migrate[/cyan]") + console.print(" [dim]├─[/dim] [dim]3.[/dim] Run migrations: [cyan]just migrate[/cyan]") console.print( - " [dim]├─[/dim] [dim]4.[/dim] Create superuser: [cyan]python manage.py createsuperuser[/cyan]" + " [dim]├─[/dim] [dim]4.[/dim] Create superuser: [cyan]just createsuperuser[/cyan]" ) - console.print(" [dim]└─[/dim] [dim]5.[/dim] Start server: [cyan]python manage.py runserver[/cyan]") + console.print(" [dim]└─[/dim] [dim]5.[/dim] Start server: [cyan]just dev[/cyan]") console.print() # Useful URLs -- 2.43.0 From 079dfff8a5ab2deea6cffadf1fdcadd39fcba4b8 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Sat, 8 Nov 2025 21:58:46 +0545 Subject: [PATCH 12/15] chore: remove UIFormatter call to prevent premature output --- djinit/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djinit/utils.py b/djinit/utils.py index 77263d6..05cfd07 100644 --- a/djinit/utils.py +++ b/djinit/utils.py @@ -25,7 +25,7 @@ def create_file_with_content(filename: str, content: str, success_message: str, if should_format: format_file(filename) - UIFormatter.print_success(success_message) + # UIFormatter.print_success(success_message) return True -- 2.43.0 From 33e87e7764ca3237f4f8875d863e844f98e70252 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Sat, 8 Nov 2025 23:01:37 +0545 Subject: [PATCH 13/15] feat: add unified project structure option --- djinit/cli.py | 18 +- djinit/scripts/app_generator.py | 31 +- djinit/scripts/console_ui.py | 8 +- djinit/scripts/django_helper.py | 32 +- djinit/scripts/files.py | 445 ++++++++++-------- djinit/scripts/input_handler.py | 61 ++- djinit/scripts/project.py | 7 +- djinit/templates/{app => base}/admin.j2 | 0 djinit/templates/{app => base}/apps.j2 | 0 .../{app => base}/migrations_init.j2 | 0 djinit/templates/{app => base}/models.j2 | 0 djinit/templates/{app => base}/routes.j2 | 0 djinit/templates/{app => base}/serializers.j2 | 0 djinit/templates/{app => base}/tests.j2 | 0 djinit/templates/{app => base}/urls.j2 | 0 djinit/templates/{app => base}/views.j2 | 0 djinit/templates/custom/project_urls_api.j2 | 9 - .../{custom => predefined}/api/urls.j2 | 0 .../v1_urls.j2 => predefined/api/v1/urls.j2} | 0 .../apps/generic/models.j2} | 0 .../apps/generic/serializers.j2} | 0 .../apps/generic/services.j2} | 0 .../apps/generic/tests.j2} | 0 .../apps/generic/urls.j2} | 0 .../apps/generic/views.j2} | 0 .../apps/users/models.j2} | 0 .../apps/users/serializers.j2} | 0 .../apps/users/services.j2} | 0 .../apps/users/tests.j2} | 0 .../{custom => predefined/apps}/users/urls.j2 | 0 .../apps/users/views.j2} | 0 .../{custom => predefined}/core/exceptions.j2 | 0 .../core/middleware/request_logger.j2} | 0 .../core/mixins/timestamped_model.j2} | 0 .../core/utils/responses.j2} | 0 .../{settings_base.j2 => settings/base.j2} | 0 .../development.j2} | 0 .../production.j2} | 0 djinit/templates/project/urls_with_api.j2 | 29 ++ .../{project_urls.j2 => urls_with_apps.j2} | 0 .../github_actions.j2} | 0 djinit/templates/shared/{ => ci}/gitlab_ci.j2 | 0 .../{project => shared}/manage_py.j2 | 0 djinit/templates/shared/readme.j2 | 40 ++ djinit/templates/unified/apps/core/apps.j2 | 9 + .../unified/apps/core/models/__init__.py.j2 | 5 + .../unified/apps/core/models/base.py.j2 | 14 + .../unified/apps/core/models/mixins.py.j2 | 14 + .../unified/apps/core/utils/__init__.py.j2 | 4 + .../unified/apps/core/utils/responses.py.j2 | 14 + yoo/.env.sample | 20 + yoo/.gitignore | 93 ++++ yoo/Procfile | 20 + yoo/README.md | 84 ++++ yoo/apps/__init__.py | 0 yoo/apps/api/__init__.py | 0 yoo/apps/api/admin/__init__.py | 0 yoo/apps/api/apps.py | 8 + yoo/apps/api/models/__init__.py | 0 yoo/apps/api/serializers/__init__.py | 0 yoo/apps/api/services/__init__.py | 0 yoo/apps/api/tests/__init__.py | 0 yoo/apps/api/urls/__init__.py | 0 yoo/apps/api/views/__init__.py | 0 yoo/apps/core/__init__.py | 0 yoo/apps/core/apps.py | 8 + yoo/apps/core/middleware/__init__.py | 0 yoo/apps/core/models/__init__.py | 5 + yoo/apps/core/models/base.py | 13 + yoo/apps/core/models/mixins.py | 13 + yoo/apps/core/permissions/__init__.py | 0 yoo/apps/core/utils/__init__.py | 3 + yoo/apps/core/utils/responses.py | 13 + yoo/core/__init__.py | 0 yoo/core/asgi.py | 11 + yoo/core/settings/__init__.py | 0 yoo/core/settings/base.py | 163 +++++++ yoo/core/settings/development.py | 29 ++ yoo/core/settings/production.py | 47 ++ yoo/core/urls.py | 30 ++ yoo/core/wsgi.py | 11 + yoo/justfile | 154 ++++++ yoo/manage.py | 29 ++ yoo/pyproject.toml | 41 ++ yoo/requirements.txt | 14 + yoo/runtime.txt | 1 + 86 files changed, 1309 insertions(+), 241 deletions(-) rename djinit/templates/{app => base}/admin.j2 (100%) rename djinit/templates/{app => base}/apps.j2 (100%) rename djinit/templates/{app => base}/migrations_init.j2 (100%) rename djinit/templates/{app => base}/models.j2 (100%) rename djinit/templates/{app => base}/routes.j2 (100%) rename djinit/templates/{app => base}/serializers.j2 (100%) rename djinit/templates/{app => base}/tests.j2 (100%) rename djinit/templates/{app => base}/urls.j2 (100%) rename djinit/templates/{app => base}/views.j2 (100%) delete mode 100644 djinit/templates/custom/project_urls_api.j2 rename djinit/templates/{custom => predefined}/api/urls.j2 (100%) rename djinit/templates/{custom/api/v1_urls.j2 => predefined/api/v1/urls.j2} (100%) rename djinit/templates/{custom/app/models_generic.j2 => predefined/apps/generic/models.j2} (100%) rename djinit/templates/{custom/app/serializers_generic.j2 => predefined/apps/generic/serializers.j2} (100%) rename djinit/templates/{custom/app/services_generic.j2 => predefined/apps/generic/services.j2} (100%) rename djinit/templates/{custom/app/tests_generic.j2 => predefined/apps/generic/tests.j2} (100%) rename djinit/templates/{custom/app/urls_generic.j2 => predefined/apps/generic/urls.j2} (100%) rename djinit/templates/{custom/app/views_generic.j2 => predefined/apps/generic/views.j2} (100%) rename djinit/templates/{custom/users/models_user.j2 => predefined/apps/users/models.j2} (100%) rename djinit/templates/{custom/users/serializers_user.j2 => predefined/apps/users/serializers.j2} (100%) rename djinit/templates/{custom/users/services_user.j2 => predefined/apps/users/services.j2} (100%) rename djinit/templates/{custom/users/tests_user_api.j2 => predefined/apps/users/tests.j2} (100%) rename djinit/templates/{custom => predefined/apps}/users/urls.j2 (100%) rename djinit/templates/{custom/users/views_user.j2 => predefined/apps/users/views.j2} (100%) rename djinit/templates/{custom => predefined}/core/exceptions.j2 (100%) rename djinit/templates/{custom/core/middleware_request_logger.j2 => predefined/core/middleware/request_logger.j2} (100%) rename djinit/templates/{custom/core/mixins_timestamped_model.j2 => predefined/core/mixins/timestamped_model.j2} (100%) rename djinit/templates/{custom/core/utils_responses.j2 => predefined/core/utils/responses.j2} (100%) rename djinit/templates/project/{settings_base.j2 => settings/base.j2} (100%) rename djinit/templates/project/{settings_development.j2 => settings/development.j2} (100%) rename djinit/templates/project/{settings_production.j2 => settings/production.j2} (100%) create mode 100644 djinit/templates/project/urls_with_api.j2 rename djinit/templates/project/{project_urls.j2 => urls_with_apps.j2} (100%) rename djinit/templates/shared/{github_actions_ci.j2 => ci/github_actions.j2} (100%) rename djinit/templates/shared/{ => ci}/gitlab_ci.j2 (100%) rename djinit/templates/{project => shared}/manage_py.j2 (100%) create mode 100644 djinit/templates/unified/apps/core/apps.j2 create mode 100644 djinit/templates/unified/apps/core/models/__init__.py.j2 create mode 100644 djinit/templates/unified/apps/core/models/base.py.j2 create mode 100644 djinit/templates/unified/apps/core/models/mixins.py.j2 create mode 100644 djinit/templates/unified/apps/core/utils/__init__.py.j2 create mode 100644 djinit/templates/unified/apps/core/utils/responses.py.j2 create mode 100644 yoo/.env.sample create mode 100644 yoo/.gitignore create mode 100644 yoo/Procfile create mode 100644 yoo/README.md create mode 100644 yoo/apps/__init__.py create mode 100644 yoo/apps/api/__init__.py create mode 100644 yoo/apps/api/admin/__init__.py create mode 100644 yoo/apps/api/apps.py create mode 100644 yoo/apps/api/models/__init__.py create mode 100644 yoo/apps/api/serializers/__init__.py create mode 100644 yoo/apps/api/services/__init__.py create mode 100644 yoo/apps/api/tests/__init__.py create mode 100644 yoo/apps/api/urls/__init__.py create mode 100644 yoo/apps/api/views/__init__.py create mode 100644 yoo/apps/core/__init__.py create mode 100644 yoo/apps/core/apps.py create mode 100644 yoo/apps/core/middleware/__init__.py create mode 100644 yoo/apps/core/models/__init__.py create mode 100644 yoo/apps/core/models/base.py create mode 100644 yoo/apps/core/models/mixins.py create mode 100644 yoo/apps/core/permissions/__init__.py create mode 100644 yoo/apps/core/utils/__init__.py create mode 100644 yoo/apps/core/utils/responses.py create mode 100644 yoo/core/__init__.py create mode 100644 yoo/core/asgi.py create mode 100644 yoo/core/settings/__init__.py create mode 100644 yoo/core/settings/base.py create mode 100644 yoo/core/settings/development.py create mode 100644 yoo/core/settings/production.py create mode 100644 yoo/core/urls.py create mode 100644 yoo/core/wsgi.py create mode 100644 yoo/justfile create mode 100755 yoo/manage.py create mode 100644 yoo/pyproject.toml create mode 100644 yoo/requirements.txt create mode 100644 yoo/runtime.txt diff --git a/djinit/cli.py b/djinit/cli.py index a706956..53dc429 100644 --- a/djinit/cli.py +++ b/djinit/cli.py @@ -41,11 +41,27 @@ class Cli: # propagate to managers self.project_manager.app_names = self.app_names + # If unified structure is enabled, adjust metadata defaults + if self.metadata.get("unified_structure"): + # For unified structure, module name is 'core' + self.metadata.setdefault("project_module_name", "core") + # Ensure nested apps live under 'apps' + self.metadata.setdefault("nested_apps", True) + self.metadata.setdefault("nested_dir", "apps") + # Default apps for unified structure (empty, apps/core and apps/api are created by default) + if not self.app_names: + self.app_names = [] + # propagate to managers + self.project_manager.app_names = self.app_names + steps: List[Tuple[str, Callable[[], bool]]] = [] steps.append(("Creating Django project", self.project_manager.create_project)) - if self.metadata.get("predefined_structure"): + if self.metadata.get("unified_structure"): + # Build unified structure + steps.append(("Creating unified structure", self.file_manager.create_unified_structure)) + elif self.metadata.get("predefined_structure"): # Build custom tree and then inject apps into settings steps.append(("Creating predefined structure", self.file_manager.create_predefined_structure)) steps.append(("Adding apps to settings", self.project_manager.add_apps_to_settings)) diff --git a/djinit/scripts/app_generator.py b/djinit/scripts/app_generator.py index 9500aef..b594e8f 100644 --- a/djinit/scripts/app_generator.py +++ b/djinit/scripts/app_generator.py @@ -8,8 +8,11 @@ from typing import Optional from djinit.scripts.console_ui import UIFormatter from djinit.scripts.django_helper import DjangoHelper +from djinit.scripts.template_engine import template_engine from djinit.utils import ( calculate_app_module_path, + create_file_with_content, + create_init_file, detect_nested_structure_from_settings, extract_existing_apps, find_project_dir, @@ -17,8 +20,6 @@ from djinit.utils import ( insert_apps_into_user_defined_apps, is_django_project, ) -from djinit.scripts.template_engine import template_engine -from djinit.utils import create_file_with_content, create_init_file class AppManager: @@ -147,8 +148,10 @@ class AppManager: create_init_file(app_dir, f"Created apps/{self.app_name}/__init__.py") # apps.py using standard template - apps_py_content = template_engine.render_template("app/apps.j2", {"app_name": self.app_name}) - create_file_with_content(os.path.join(app_dir, "apps.py"), apps_py_content, f"Created apps/{self.app_name}/apps.py") + apps_py_content = template_engine.render_template("base/apps.j2", {"app_name": self.app_name}) + create_file_with_content( + os.path.join(app_dir, "apps.py"), apps_py_content, f"Created apps/{self.app_name}/apps.py" + ) # Compute names for generic templates model_class_name = "".join([part.capitalize() for part in self.app_name.split("_")]) @@ -156,11 +159,11 @@ class AppManager: # Subfolders subfolders = { - "models": [(f"{self.app_name}.py", "custom/app/models_generic.j2")], - "serializers": [(f"{self.app_name}_serializer.py", "custom/app/serializers_generic.j2")], - "services": [(f"{self.app_name}_service.py", "custom/app/services_generic.j2")], - "views": [(f"{self.app_name}_view.py", "custom/app/views_generic.j2")], - "tests": [(f"test_{self.app_name}_api.py", "custom/app/tests_generic.j2")], + "models": [(f"{self.app_name}.py", "predefined/apps/generic/models.j2")], + "serializers": [(f"{self.app_name}_serializer.py", "predefined/apps/generic/serializers.j2")], + "services": [(f"{self.app_name}_service.py", "predefined/apps/generic/services.j2")], + "views": [(f"{self.app_name}_view.py", "predefined/apps/generic/views.j2")], + "tests": [(f"test_{self.app_name}_api.py", "predefined/apps/generic/tests.j2")], } for folder, files in subfolders.items(): folder_path = os.path.join(app_dir, folder) @@ -179,9 +182,11 @@ class AppManager: # urls.py at app root urls_content = template_engine.render_template( - "custom/app/urls_generic.j2", {"app_name": self.app_name, "app_module": app_module} + "predefined/apps/generic/urls.j2", {"app_name": self.app_name, "app_module": app_module} + ) + create_file_with_content( + os.path.join(app_dir, "urls.py"), urls_content, f"Created apps/{self.app_name}/urls.py" ) - create_file_with_content(os.path.join(app_dir, "urls.py"), urls_content, f"Created apps/{self.app_name}/urls.py") # Add route include to api/v1/urls.py if present self._add_to_api_v1_urls(self.app_name) @@ -194,7 +199,7 @@ class AppManager: if not os.path.exists(api_v1_urls): return try: - with open(api_v1_urls, "r", encoding="utf-8") as f: + with open(api_v1_urls, encoding="utf-8") as f: content = f.read() import_line = "from django.urls import include, path" @@ -202,7 +207,7 @@ class AppManager: # ensure base import exists (fallback) content = import_line + "\n\n" + content - include_stmt = f"path(\"{app_name}/\", include(\"apps.{app_name}.urls\"))," + include_stmt = f'path("{app_name}/", include("apps.{app_name}.urls")),' if include_stmt in content: # already present return diff --git a/djinit/scripts/console_ui.py b/djinit/scripts/console_ui.py index da60336..06aac3b 100644 --- a/djinit/scripts/console_ui.py +++ b/djinit/scripts/console_ui.py @@ -258,12 +258,12 @@ class UIFormatter: if metadata and metadata.get("predefined_structure"): module_name = metadata.get("project_module_name") or project_name console.print(f" [dim]├─[/dim] [bold]Module:[/bold] [white]{module_name}[/white]") - console.print(f" [dim]├─[/dim] [bold]Structure:[/bold] [white]Predefined[/white]") + console.print(" [dim]├─[/dim] [bold]Structure:[/bold] [white]Predefined[/white]") # Hide empty apps list in predefined quick flow if app_names: console.print(f" [dim]└─[/dim] [bold]Apps:[/bold] [white]{', '.join(app_names)}[/white]") else: - console.print(f" [dim]└─[/dim] [bold]Apps:[/bold] [white]users, core[/white]") + console.print(" [dim]└─[/dim] [bold]Apps:[/bold] [white]users, core[/white]") else: console.print(f" [dim]└─[/dim] [bold]Apps:[/bold] [white]{', '.join(app_names)}[/white]") console.print() @@ -274,9 +274,7 @@ class UIFormatter: console.print(" [dim]├─[/dim] [dim]1.[/dim] Navigate to your project directory") console.print(" [dim]├─[/dim] [dim]2.[/dim] Set environment variables in .env file") console.print(" [dim]├─[/dim] [dim]3.[/dim] Run migrations: [cyan]just migrate[/cyan]") - console.print( - " [dim]├─[/dim] [dim]4.[/dim] Create superuser: [cyan]just createsuperuser[/cyan]" - ) + console.print(" [dim]├─[/dim] [dim]4.[/dim] Create superuser: [cyan]just createsuperuser[/cyan]") console.print(" [dim]└─[/dim] [dim]5.[/dim] Start server: [cyan]just dev[/cyan]") console.print() diff --git a/djinit/scripts/django_helper.py b/djinit/scripts/django_helper.py index c28d781..b20fc82 100644 --- a/djinit/scripts/django_helper.py +++ b/djinit/scripts/django_helper.py @@ -15,15 +15,21 @@ class DjangoHelper: DJANGO_VERSION = "5.2" @staticmethod - def startproject(project_name: str, directory: str) -> bool: + def startproject(project_name: str, directory: str, unified: bool = False) -> bool: try: os.makedirs(directory, exist_ok=True) # Create manage.py manage_py_path = os.path.join(directory, "manage.py") - create_file_from_template(manage_py_path, "project/manage_py.j2", {}, "Created manage.py") + create_file_from_template(manage_py_path, "shared/manage_py.j2", {}, "Created manage.py") os.chmod(manage_py_path, 0o755) + # For unified structure, the project config is "core", not project_name + # The unified structure is created separately by create_unified_structure + if unified: + # Just create manage.py for unified, structure is created by create_unified_structure + return True + # Create project config directory project_config_dir = os.path.join(directory, project_name) os.makedirs(project_config_dir, exist_ok=True) @@ -37,19 +43,19 @@ class DjangoHelper: base_context = {"project_name": project_name, "app_names": []} create_file_from_template( os.path.join(settings_dir, "base.py"), - "project/settings_base.j2", + "project/settings/base.j2", base_context, f"Created {project_name}/settings/base.py", ) create_file_from_template( os.path.join(settings_dir, "development.py"), - "project/settings_development.j2", + "project/settings/development.j2", {}, f"Created {project_name}/settings/development.py", ) create_file_from_template( os.path.join(settings_dir, "production.py"), - "project/settings_production.j2", + "project/settings/production.j2", {}, f"Created {project_name}/settings/production.py", ) @@ -93,14 +99,14 @@ class DjangoHelper: create_init_file(app_dir, f"Created {app_name}/__init__.py") app_files = [ - ("apps.py", "app/apps.j2"), - ("models.py", "app/models.j2"), - ("views.py", "app/views.j2"), - ("admin.py", "app/admin.j2"), - ("urls.py", "app/urls.j2"), - ("serializers.py", "app/serializers.j2"), - ("routes.py", "app/routes.j2"), - ("tests.py", "app/tests.j2"), + ("apps.py", "base/apps.j2"), + ("models.py", "base/models.j2"), + ("views.py", "base/views.j2"), + ("admin.py", "base/admin.j2"), + ("urls.py", "base/urls.j2"), + ("serializers.py", "base/serializers.j2"), + ("routes.py", "base/routes.j2"), + ("tests.py", "base/tests.j2"), ] for filename, template_path in app_files: diff --git a/djinit/scripts/files.py b/djinit/scripts/files.py index 6dc3afe..313415a 100644 --- a/djinit/scripts/files.py +++ b/djinit/scripts/files.py @@ -27,250 +27,319 @@ class FileManager: self.settings_folder = os.path.join(self.project_configs, "settings") self.settings_file = os.path.join(self.project_configs, "settings.py") + def _render_and_create_file( + self, + filepath: str, + template_path: str, + context: dict, + message: str, + base_dir: str = None, + should_format: bool = False, + ) -> bool: + """Helper to render template and create file.""" + target_dir = base_dir or self.project_root + with change_cwd(target_dir): + content = template_engine.render_template(template_path, context) + return create_file_with_content(filepath, content, message, should_format=should_format) + + def _ensure_directory_with_init(self, dir_path: str, message: str) -> None: + """Helper to create directory and __init__.py file.""" + os.makedirs(dir_path, exist_ok=True) + create_init_file(dir_path, message) + + def _create_files_from_specs(self, base_dir: str, folder_specs: dict, base_context: dict = None) -> None: + """Helper to create multiple folders and files from a specification dict.""" + base_context = base_context or {} + for folder, files in folder_specs.items(): + folder_path = os.path.join(base_dir, folder) + self._ensure_directory_with_init(folder_path, f"Created {folder_path}/__init__.py") + for filename, template_path in files: + file_path = os.path.join(folder_path, filename) + content = template_engine.render_template(template_path, base_context) + create_file_with_content(file_path, content, f"Created {file_path}") + def create_gitignore(self) -> bool: - with change_cwd(self.project_root): - gitignore_content = template_engine.render_template("shared/gitignore.j2", {}) - return create_file_with_content( - ".gitignore", - gitignore_content, - "Created .gitignore file", - ) + return self._render_and_create_file(".gitignore", "shared/gitignore.j2", {}, "Created .gitignore file") def create_requirements(self) -> bool: - with change_cwd(self.project_root): - context = {"use_database_url": self.metadata.get("use_database_url", True)} - requirements_content = template_engine.render_template("shared/requirements.j2", context) - return create_file_with_content( - "requirements.txt", - requirements_content, - "Created requirements.txt with Django dependencies", - ) + context = {"use_database_url": self.metadata.get("use_database_url", True)} + return self._render_and_create_file( + "requirements.txt", + "shared/requirements.j2", + context, + "Created requirements.txt with Django dependencies", + ) def create_readme(self) -> bool: - with change_cwd(self.project_root): - context = { - "project_name": self.project_name, - "app_names": self.app_names, - "predefined_structure": bool(self.metadata.get("predefined_structure")), - "module_name": self.module_name, - } - readme_content = template_engine.render_template("shared/readme.j2", context) - return create_file_with_content( - "README.md", - readme_content, - "Created README.md file", - ) + context = { + "project_name": self.project_name, + "app_names": self.app_names, + "predefined_structure": bool(self.metadata.get("predefined_structure")), + "unified_structure": bool(self.metadata.get("unified_structure")), + "module_name": self.module_name, + } + return self._render_and_create_file("README.md", "shared/readme.j2", context, "Created README.md file") def create_env_file(self) -> bool: """Create .env.sample file with environment variables.""" - with change_cwd(self.project_root): - context = { - "project_name": self.project_name, - "use_database_url": self.metadata.get("use_database_url", True), - } - env_content = template_engine.render_template("shared/env_sample.j2", context) - return create_file_with_content( - ".env.sample", - env_content, - "Created .env.sample file with generated secret key", - ) + context = { + "project_name": self.project_name, + "use_database_url": self.metadata.get("use_database_url", True), + } + return self._render_and_create_file( + ".env.sample", + "shared/env_sample.j2", + context, + "Created .env.sample file with generated secret key", + ) def create_pyproject_toml(self, metadata: dict) -> bool: - with change_cwd(self.project_root): - # Default package_name to "backend" if it's "." or empty - package_name = metadata.get("package_name", "backend") - if package_name == "." or not package_name: - package_name = "backend" - context = {"package_name": package_name, "project_name": self.project_name} - pyproject_content = template_engine.render_template("shared/pyproject_toml.j2", context) - return create_file_with_content( - "pyproject.toml", - pyproject_content, - "Created pyproject.toml file for uv", - ) + # Default package_name to "backend" if it's "." or empty + package_name = metadata.get("package_name", "backend") + if package_name == "." or not package_name: + package_name = "backend" + context = {"package_name": package_name, "project_name": self.project_name} + return self._render_and_create_file( + "pyproject.toml", + "shared/pyproject_toml.j2", + context, + "Created pyproject.toml file for uv", + ) def create_project_urls(self) -> bool: """Create project urls.py using project_urls.j2 template (replaces update_project_urls).""" - with change_cwd(self.project_configs): - effective_app_modules = calculate_app_module_paths(self.app_names, self.metadata) - context = { - "project_name": self.project_name, - "app_names": effective_app_modules, - } - urls_content = template_engine.render_template("project/project_urls.j2", context) - result = create_file_with_content( - "urls.py", - urls_content, - "Created project urls.py with comprehensive URL configuration", - should_format=True, - ) - return result + effective_app_modules = calculate_app_module_paths(self.app_names, self.metadata) + context = { + "project_name": self.project_name, + "app_names": effective_app_modules, + } + return self._render_and_create_file( + "urls.py", + "project/urls_with_apps.j2", + context, + "Created project urls.py with comprehensive URL configuration", + base_dir=self.project_configs, + should_format=True, + ) def create_justfile(self) -> bool: - with change_cwd(self.project_root): - context = {"project_name": self.project_name} - justfile_content = template_engine.render_template("shared/justfile.j2", context) - create_file_with_content( - "justfile", - justfile_content, - "Created justfile with Django development tasks", - ) - return True + context = {"project_name": self.project_name} + return self._render_and_create_file( + "justfile", + "shared/justfile.j2", + context, + "Created justfile with Django development tasks", + ) def create_predefined_structure(self) -> bool: - """Create the predefined project structure under apps/ and api/ using custom templates. - - Expected layout: - - apps/users with nested folders (models, serializers, services, views, tests) - - apps/core with utils, mixins, exceptions, middleware - - api/ with versioned urls - - overwrite project urls to include api urls - """ # Ensure apps package apps_dir = os.path.join(self.project_root, "apps") - os.makedirs(apps_dir, exist_ok=True) - create_init_file(apps_dir, "Created apps/__init__.py") + self._ensure_directory_with_init(apps_dir, "Created apps/__init__.py") # users app users_dir = os.path.join(apps_dir, "users") - os.makedirs(users_dir, exist_ok=True) - create_init_file(users_dir, "Created apps/users/__init__.py") + self._ensure_directory_with_init(users_dir, "Created apps/users/__init__.py") # apps.py can reuse standard app template - users_context = {"app_name": "users"} - users_apps_py = template_engine.render_template("app/apps.j2", users_context) - create_file_with_content(os.path.join(users_dir, "apps.py"), users_apps_py, "Created apps/users/apps.py") + users_apps_path = os.path.join(users_dir, "apps.py") + content = template_engine.render_template("base/apps.j2", {"app_name": "users"}) + create_file_with_content(users_apps_path, content, "Created apps/users/apps.py") # Nested folders and files for users users_subfolders = { - "models": [ - ("user.py", "custom/users/models_user.j2"), - ], - "serializers": [ - ("user_serializer.py", "custom/users/serializers_user.j2"), - ], - "services": [ - ("user_service.py", "custom/users/services_user.j2"), - ], - "views": [ - ("user_view.py", "custom/users/views_user.j2"), - ], - "tests": [ - ("test_user_api.py", "custom/users/tests_user_api.j2"), - ], + "models": [("user.py", "predefined/apps/users/models.j2")], + "serializers": [("user_serializer.py", "predefined/apps/users/serializers.j2")], + "services": [("user_service.py", "predefined/apps/users/services.j2")], + "views": [("user_view.py", "predefined/apps/users/views.j2")], + "tests": [("test_user_api.py", "predefined/apps/users/tests.j2")], } - for folder, files in users_subfolders.items(): - folder_path = os.path.join(users_dir, folder) - os.makedirs(folder_path, exist_ok=True) - create_init_file(folder_path, f"Created apps/users/{folder}/__init__.py") - for filename, tpl in files: - content = template_engine.render_template(tpl, {"app_module": "apps.users"}) - create_file_with_content( - os.path.join(folder_path, filename), - content, - f"Created apps/users/{folder}/{filename}", - ) + self._create_files_from_specs(users_dir, users_subfolders, {"app_module": "apps.users"}) # users urls.py at app root - users_urls_content = template_engine.render_template("custom/users/urls.j2", {"app_module": "apps.users"}) - create_file_with_content(os.path.join(users_dir, "urls.py"), users_urls_content, "Created apps/users/urls.py") + users_urls_path = os.path.join(users_dir, "urls.py") + content = template_engine.render_template("predefined/apps/users/urls.j2", {"app_module": "apps.users"}) + create_file_with_content(users_urls_path, content, "Created apps/users/urls.py") # core app core_dir = os.path.join(apps_dir, "core") - os.makedirs(core_dir, exist_ok=True) - create_init_file(core_dir, "Created apps/core/__init__.py") + self._ensure_directory_with_init(core_dir, "Created apps/core/__init__.py") # core subfolders and files core_subfolders = { - "utils": [("responses.py", "custom/core/utils_responses.j2")], - "mixins": [("timestamped_model.py", "custom/core/mixins_timestamped_model.j2")], - "middleware": [("request_logger.py", "custom/core/middleware_request_logger.j2")], + "utils": [("responses.py", "predefined/core/utils/responses.j2")], + "mixins": [("timestamped_model.py", "predefined/core/mixins/timestamped_model.j2")], + "middleware": [("request_logger.py", "predefined/core/middleware/request_logger.j2")], } - for folder, files in core_subfolders.items(): - folder_path = os.path.join(core_dir, folder) - os.makedirs(folder_path, exist_ok=True) - create_init_file(folder_path, f"Created apps/core/{folder}/__init__.py") - for filename, tpl in files: - content = template_engine.render_template(tpl, {}) - create_file_with_content( - os.path.join(folder_path, filename), - content, - f"Created apps/core/{folder}/{filename}", - ) + self._create_files_from_specs(core_dir, core_subfolders, {}) # core/exceptions.py - exceptions_content = template_engine.render_template("custom/core/exceptions.j2", {}) - create_file_with_content(os.path.join(core_dir, "exceptions.py"), exceptions_content, "Created apps/core/exceptions.py") + exceptions_path = os.path.join(core_dir, "exceptions.py") + content = template_engine.render_template("predefined/core/exceptions.j2", {}) + create_file_with_content(exceptions_path, content, "Created apps/core/exceptions.py") # api package api_dir = os.path.join(self.project_root, "api") - os.makedirs(api_dir, exist_ok=True) - create_init_file(api_dir, "Created api/__init__.py") - api_urls_content = template_engine.render_template("custom/api/urls.j2", {}) - create_file_with_content(os.path.join(api_dir, "urls.py"), api_urls_content, "Created api/urls.py") + self._ensure_directory_with_init(api_dir, "Created api/__init__.py") + api_urls_path = os.path.join(api_dir, "urls.py") + content = template_engine.render_template("predefined/api/urls.j2", {}) + create_file_with_content(api_urls_path, content, "Created api/urls.py") # api/v1 api_v1_dir = os.path.join(api_dir, "v1") - os.makedirs(api_v1_dir, exist_ok=True) - create_init_file(api_v1_dir, "Created api/v1/__init__.py") - api_v1_urls_content = template_engine.render_template("custom/api/v1_urls.j2", {}) - create_file_with_content(os.path.join(api_v1_dir, "urls.py"), api_v1_urls_content, "Created api/v1/urls.py") + self._ensure_directory_with_init(api_v1_dir, "Created api/v1/__init__.py") + api_v1_urls_path = os.path.join(api_v1_dir, "urls.py") + content = template_engine.render_template("predefined/api/v1/urls.j2", {}) + create_file_with_content(api_v1_urls_path, content, "Created api/v1/urls.py") # overwrite project urls to include api - project_urls_content = template_engine.render_template("custom/project_urls_api.j2", {"project_module": self.module_name}) + project_urls_path = os.path.join(self.project_configs, "urls.py") + content = template_engine.render_template("project/urls_with_api.j2", {"api_module": "api"}) create_file_with_content( - os.path.join(self.project_configs, "urls.py"), - project_urls_content, - "Updated project urls.py to include API routes", - should_format=True, + project_urls_path, content, "Updated project urls.py to include API routes", should_format=True ) return True - def create_procfile(self) -> bool: - with change_cwd(self.project_root): - context = {"project_name": self.project_name} - procfile_content = template_engine.render_template("shared/procfile.j2", context) - create_file_with_content( - "Procfile", - procfile_content, - "Created Procfile with Gunicorn configuration", - ) + def create_unified_structure(self) -> bool: + # Create core directory structure + core_dir = os.path.join(self.project_root, "core") + self._ensure_directory_with_init(core_dir, "Created core/__init__.py") + + # Create core/settings directory + settings_dir = os.path.join(core_dir, "settings") + self._ensure_directory_with_init(settings_dir, "Created core/settings/__init__.py") + + # Generate secret key for development settings + from djinit.scripts.secretkey_generator import generate_secret_key + + secret_key = generate_secret_key() + + # Create settings files using project templates with unified context + # For unified structure, project_module_name is "core" + base_context = { + "project_name": "core", # Use "core" as the module name + "app_names": ["apps.core", "apps.api"] + [f"apps.{app}" for app in self.app_names], + "use_database_url": self.metadata.get("use_database_url", True), + } + dev_context = {"secret_key": secret_key} + + # Settings files + for filename, context in [ + ("base.py", base_context), + ("development.py", dev_context), + ("production.py", base_context), + ]: + filepath = os.path.join(settings_dir, filename) + content = template_engine.render_template(f"project/settings/{filename.replace('.py', '')}.j2", context) + create_file_with_content(filepath, content, f"Created core/settings/{filename}", should_format=True) + + # Core project files + core_files = [ + ("urls.py", "project/urls_with_api.j2", {"api_module": "apps.api"}), + ("wsgi.py", "project/wsgi.j2", {}), + ("asgi.py", "project/asgi.j2", {}), + ] + for filename, template, context in core_files: + filepath = os.path.join(core_dir, filename) + content = template_engine.render_template(template, context) + create_file_with_content(filepath, content, f"Created core/{filename}", should_format=True) + + # Create apps directory + apps_dir = os.path.join(self.project_root, "apps") + self._ensure_directory_with_init(apps_dir, "Created apps/__init__.py") + + # Create apps/core + apps_core_dir = os.path.join(apps_dir, "core") + self._ensure_directory_with_init(apps_core_dir, "Created apps/core/__init__.py") + + # Create apps/core/apps.py + apps_py_path = os.path.join(apps_core_dir, "apps.py") + content = template_engine.render_template("base/apps.j2", {}) + create_file_with_content(apps_py_path, content, "Created apps/core/apps.py", should_format=True) + + # Create apps/core/models + models_dir = os.path.join(apps_core_dir, "models") + os.makedirs(models_dir, exist_ok=True) + model_files = [ + ("__init__.py", "base/models.j2"), + ("base.py", "unified/apps/core/models/base.py.j2"), + ("mixins.py", "unified/apps/core/models/mixins.py.j2"), + ] + for filename, template in model_files: + filepath = os.path.join(models_dir, filename) + content = template_engine.render_template(template, {}) + create_file_with_content(filepath, content, f"Created apps/core/models/{filename}", should_format=True) + + # Create apps/core/utils + utils_dir = os.path.join(apps_core_dir, "utils") + os.makedirs(utils_dir, exist_ok=True) + utils_files = [ + ("__init__.py", "unified/apps/core/utils/__init__.py.j2"), + ("responses.py", "unified/apps/core/utils/responses.py.j2"), + ] + for filename, template in utils_files: + filepath = os.path.join(utils_dir, filename) + content = template_engine.render_template(template, {}) + create_file_with_content(filepath, content, f"Created apps/core/utils/{filename}", should_format=True) + + # Create apps/core/permissions and middleware + for subdir in ["permissions", "middleware"]: + subdir_path = os.path.join(apps_core_dir, subdir) + self._ensure_directory_with_init(subdir_path, f"Created apps/core/{subdir}/__init__.py") + + # Create apps/api + apps_api_dir = os.path.join(apps_dir, "api") + self._ensure_directory_with_init(apps_api_dir, "Created apps/api/__init__.py") + + # Create apps/api/apps.py + apps_py_path = os.path.join(apps_api_dir, "apps.py") + content = template_engine.render_template("base/apps.j2", {"app_name": "apps.api"}) + create_file_with_content(apps_py_path, content, "Created apps/api/apps.py", should_format=True) + + # Create apps/api subdirectories + api_subdirs = ["models", "serializers", "views", "services", "tests", "urls", "admin"] + for subdir in api_subdirs: + subdir_path = os.path.join(apps_api_dir, subdir) + self._ensure_directory_with_init(subdir_path, f"Created apps/api/{subdir}/__init__.py") + return True + def create_procfile(self) -> bool: + context = {"project_name": self.project_name} + return self._render_and_create_file( + "Procfile", + "shared/procfile.j2", + context, + "Created Procfile with Gunicorn configuration", + ) + def create_runtime_txt(self) -> bool: - with change_cwd(self.project_root): - context = {"python_version": "3.13"} - runtime_content = template_engine.render_template("shared/runtime_txt.j2", context) - create_file_with_content( - "runtime.txt", - runtime_content, - "Created runtime.txt with Python version specification", - ) - return True + context = {"python_version": "3.13"} + return self._render_and_create_file( + "runtime.txt", + "shared/runtime_txt.j2", + context, + "Created runtime.txt with Python version specification", + ) def create_github_actions(self) -> bool: - with change_cwd(self.project_root): - github_dir = os.path.join(self.project_root, ".github", "workflows") - os.makedirs(github_dir, exist_ok=True) - - context = {"project_name": self.project_name, "module_name": self.module_name} - workflow_content = template_engine.render_template("shared/github_actions_ci.j2", context) - workflow_file = os.path.join(github_dir, "ci.yml") - create_file_with_content( - workflow_file, - workflow_content, - "Created Github Actions workflow (ci.yml)", - ) - return True + github_dir = os.path.join(self.project_root, ".github", "workflows") + os.makedirs(github_dir, exist_ok=True) + context = {"project_name": self.project_name, "module_name": self.module_name} + workflow_file = os.path.join(github_dir, "ci.yml") + return self._render_and_create_file( + workflow_file, + "shared/ci/github_actions.j2", + context, + "Created Github Actions workflow (ci.yml)", + base_dir=self.project_root, + ) def create_gitlab_ci(self) -> bool: - with change_cwd(self.project_root): - context = {"project_name": self.project_name, "module_name": self.module_name} - gitlab_ci_content = template_engine.render_template("shared/gitlab_ci.j2", context) - create_file_with_content( - ".gitlab-ci.yml", - gitlab_ci_content, - "Created GitLab CI configuration (.gitlab-ci.yml)", - ) - return True + context = {"project_name": self.project_name, "module_name": self.module_name} + return self._render_and_create_file( + ".gitlab-ci.yml", + "shared/ci/gitlab_ci.j2", + context, + "Created GitLab CI configuration (.gitlab-ci.yml)", + ) diff --git a/djinit/scripts/input_handler.py b/djinit/scripts/input_handler.py index c1502c4..65f4cc7 100644 --- a/djinit/scripts/input_handler.py +++ b/djinit/scripts/input_handler.py @@ -36,6 +36,7 @@ class ProjectMetadata: use_database_url: bool = False # predefined structure support predefined_structure: bool = False + unified_structure: bool = False project_module_name: str | None = None def to_dict(self) -> dict: @@ -47,6 +48,7 @@ class ProjectMetadata: "nested_dir": self.nested_dir, "use_database_url": self.use_database_url, "predefined_structure": self.predefined_structure, + "unified_structure": self.unified_structure, "project_module_name": self.project_module_name, } @@ -385,19 +387,27 @@ def get_user_input() -> Tuple[str, str, str, list, dict]: collector = InputCollector() console.print() - # Pre-step: Ask for predefined structure first + # Pre-step: Ask for structure type first UIFormatter.print_separator() - console.print(f"\n[{UIColors.INFO}]Choose Structure[/{UIColors.INFO}]\n") - use_predefined = CharReader.get_yes_no( - f"[{UIColors.HIGHLIGHT}]Use predefined structure? (Y/n):[/{UIColors.HIGHLIGHT}]" - ) + console.print(f"\n[{UIColors.INFO}]Choose Structure Type[/{UIColors.INFO}]\n") + console.print(f"[{UIColors.MUTED}]1. Standard structure (default Django layout)[/{UIColors.MUTED}]") + console.print(f"[{UIColors.MUTED}]2. Predefined structure (apps/users, apps/core, api/)[/{UIColors.MUTED}]") + console.print(f"[{UIColors.MUTED}]3. Unified structure (core/, apps/core, apps/api)[/{UIColors.MUTED}]") + console.print() - if use_predefined == "y": + structure_choice = console.input( + f"[{UIColors.HIGHLIGHT}]Choose structure type (1/2/3) [default: 1]:[/{UIColors.HIGHLIGHT}] " + ).strip() + if not structure_choice: + structure_choice = "1" + + use_predefined = structure_choice == "2" + use_unified = structure_choice == "3" + + if use_predefined: UIFormatter.print_separator() console.print(f"\n[{UIColors.INFO}]Step 1: Project Setup[/{UIColors.INFO}]\n") - console.print( - f"[{UIColors.MUTED}]Press Enter or enter '.' to create in current directory[/{UIColors.MUTED}]" - ) + console.print(f"[{UIColors.MUTED}]Press Enter or enter '.' to create in current directory[/{UIColors.MUTED}]") project_dir = collector._get_project_directory() if project_dir is None: UIFormatter.print_error("Maximum attempts reached for project directory name. Exiting.") @@ -427,6 +437,39 @@ def get_user_input() -> Tuple[str, str, str, list, dict]: return project_dir, project_name, "", app_names, metadata.to_dict() + if use_unified: + UIFormatter.print_separator() + console.print(f"\n[{UIColors.INFO}]Step 1: Project Setup[/{UIColors.INFO}]\n") + console.print(f"[{UIColors.MUTED}]Press Enter or enter '.' to create in current directory[/{UIColors.MUTED}]") + project_dir = collector._get_project_directory() + if project_dir is None: + UIFormatter.print_error("Maximum attempts reached for project directory name. Exiting.") + sys.exit(1) + + # Minimal inputs: project_dir only, then ask for CI/CD choice. + project_name = project_dir + app_names: list[str] = [] + + # Ask for workflows so shared templates for CI/CD can be added + use_github, use_gitlab = collector.get_cicd_choice() + + # Ask database configuration for unified structure as well + use_database_url = collector.get_database_config_choice() + # Default package_name to "backend" if project_dir is "." or empty + package_name = "backend" if project_dir == "." or not project_dir else project_dir + metadata = ProjectMetadata( + package_name=package_name, + use_github_actions=use_github, + use_gitlab_ci=use_gitlab, + nested_apps=True, + nested_dir="apps", + use_database_url=use_database_url, + unified_structure=True, + project_module_name="core", + ) + + return project_dir, project_name, "", app_names, metadata.to_dict() + # Section 1: Project Setup (standard flow) UIFormatter.print_separator() console.print(f"\n[{UIColors.INFO}]Step 1: Project Setup[/{UIColors.INFO}]\n") diff --git a/djinit/scripts/project.py b/djinit/scripts/project.py index 29ba578..2105f02 100644 --- a/djinit/scripts/project.py +++ b/djinit/scripts/project.py @@ -30,7 +30,8 @@ class ProjectManager: def create_project(self) -> bool: os.makedirs(self.project_root, exist_ok=True) - success = DjangoHelper.startproject(self.module_name, self.project_root) + unified = self.metadata.get("unified_structure", False) + success = DjangoHelper.startproject(self.module_name, self.project_root, unified=unified) if success: UIFormatter.print_success(f"Django project '{self.project_name}' created successfully!") else: @@ -116,8 +117,8 @@ class ProjectManager: if self.metadata.get("nested_apps") and self.metadata.get("nested_dir"): apps_base_dir = os.path.join(self.project_root, self.metadata.get("nested_dir")) - # In predefined structure, apps layout differs; skip strict per-app validation - if not self.metadata.get("predefined_structure"): + # In predefined or unified structure, apps layout differs; skip strict per-app validation + if not self.metadata.get("predefined_structure") and not self.metadata.get("unified_structure"): for app_name in self.app_names: app_files = [ os.path.join(apps_base_dir, app_name, "__init__.py"), diff --git a/djinit/templates/app/admin.j2 b/djinit/templates/base/admin.j2 similarity index 100% rename from djinit/templates/app/admin.j2 rename to djinit/templates/base/admin.j2 diff --git a/djinit/templates/app/apps.j2 b/djinit/templates/base/apps.j2 similarity index 100% rename from djinit/templates/app/apps.j2 rename to djinit/templates/base/apps.j2 diff --git a/djinit/templates/app/migrations_init.j2 b/djinit/templates/base/migrations_init.j2 similarity index 100% rename from djinit/templates/app/migrations_init.j2 rename to djinit/templates/base/migrations_init.j2 diff --git a/djinit/templates/app/models.j2 b/djinit/templates/base/models.j2 similarity index 100% rename from djinit/templates/app/models.j2 rename to djinit/templates/base/models.j2 diff --git a/djinit/templates/app/routes.j2 b/djinit/templates/base/routes.j2 similarity index 100% rename from djinit/templates/app/routes.j2 rename to djinit/templates/base/routes.j2 diff --git a/djinit/templates/app/serializers.j2 b/djinit/templates/base/serializers.j2 similarity index 100% rename from djinit/templates/app/serializers.j2 rename to djinit/templates/base/serializers.j2 diff --git a/djinit/templates/app/tests.j2 b/djinit/templates/base/tests.j2 similarity index 100% rename from djinit/templates/app/tests.j2 rename to djinit/templates/base/tests.j2 diff --git a/djinit/templates/app/urls.j2 b/djinit/templates/base/urls.j2 similarity index 100% rename from djinit/templates/app/urls.j2 rename to djinit/templates/base/urls.j2 diff --git a/djinit/templates/app/views.j2 b/djinit/templates/base/views.j2 similarity index 100% rename from djinit/templates/app/views.j2 rename to djinit/templates/base/views.j2 diff --git a/djinit/templates/custom/project_urls_api.j2 b/djinit/templates/custom/project_urls_api.j2 deleted file mode 100644 index 459de61..0000000 --- a/djinit/templates/custom/project_urls_api.j2 +++ /dev/null @@ -1,9 +0,0 @@ -from django.contrib import admin -from django.urls import include, path - -urlpatterns = [ - path("admin/", admin.site.urls), - path("api/", include("api.urls")), -] - - diff --git a/djinit/templates/custom/api/urls.j2 b/djinit/templates/predefined/api/urls.j2 similarity index 100% rename from djinit/templates/custom/api/urls.j2 rename to djinit/templates/predefined/api/urls.j2 diff --git a/djinit/templates/custom/api/v1_urls.j2 b/djinit/templates/predefined/api/v1/urls.j2 similarity index 100% rename from djinit/templates/custom/api/v1_urls.j2 rename to djinit/templates/predefined/api/v1/urls.j2 diff --git a/djinit/templates/custom/app/models_generic.j2 b/djinit/templates/predefined/apps/generic/models.j2 similarity index 100% rename from djinit/templates/custom/app/models_generic.j2 rename to djinit/templates/predefined/apps/generic/models.j2 diff --git a/djinit/templates/custom/app/serializers_generic.j2 b/djinit/templates/predefined/apps/generic/serializers.j2 similarity index 100% rename from djinit/templates/custom/app/serializers_generic.j2 rename to djinit/templates/predefined/apps/generic/serializers.j2 diff --git a/djinit/templates/custom/app/services_generic.j2 b/djinit/templates/predefined/apps/generic/services.j2 similarity index 100% rename from djinit/templates/custom/app/services_generic.j2 rename to djinit/templates/predefined/apps/generic/services.j2 diff --git a/djinit/templates/custom/app/tests_generic.j2 b/djinit/templates/predefined/apps/generic/tests.j2 similarity index 100% rename from djinit/templates/custom/app/tests_generic.j2 rename to djinit/templates/predefined/apps/generic/tests.j2 diff --git a/djinit/templates/custom/app/urls_generic.j2 b/djinit/templates/predefined/apps/generic/urls.j2 similarity index 100% rename from djinit/templates/custom/app/urls_generic.j2 rename to djinit/templates/predefined/apps/generic/urls.j2 diff --git a/djinit/templates/custom/app/views_generic.j2 b/djinit/templates/predefined/apps/generic/views.j2 similarity index 100% rename from djinit/templates/custom/app/views_generic.j2 rename to djinit/templates/predefined/apps/generic/views.j2 diff --git a/djinit/templates/custom/users/models_user.j2 b/djinit/templates/predefined/apps/users/models.j2 similarity index 100% rename from djinit/templates/custom/users/models_user.j2 rename to djinit/templates/predefined/apps/users/models.j2 diff --git a/djinit/templates/custom/users/serializers_user.j2 b/djinit/templates/predefined/apps/users/serializers.j2 similarity index 100% rename from djinit/templates/custom/users/serializers_user.j2 rename to djinit/templates/predefined/apps/users/serializers.j2 diff --git a/djinit/templates/custom/users/services_user.j2 b/djinit/templates/predefined/apps/users/services.j2 similarity index 100% rename from djinit/templates/custom/users/services_user.j2 rename to djinit/templates/predefined/apps/users/services.j2 diff --git a/djinit/templates/custom/users/tests_user_api.j2 b/djinit/templates/predefined/apps/users/tests.j2 similarity index 100% rename from djinit/templates/custom/users/tests_user_api.j2 rename to djinit/templates/predefined/apps/users/tests.j2 diff --git a/djinit/templates/custom/users/urls.j2 b/djinit/templates/predefined/apps/users/urls.j2 similarity index 100% rename from djinit/templates/custom/users/urls.j2 rename to djinit/templates/predefined/apps/users/urls.j2 diff --git a/djinit/templates/custom/users/views_user.j2 b/djinit/templates/predefined/apps/users/views.j2 similarity index 100% rename from djinit/templates/custom/users/views_user.j2 rename to djinit/templates/predefined/apps/users/views.j2 diff --git a/djinit/templates/custom/core/exceptions.j2 b/djinit/templates/predefined/core/exceptions.j2 similarity index 100% rename from djinit/templates/custom/core/exceptions.j2 rename to djinit/templates/predefined/core/exceptions.j2 diff --git a/djinit/templates/custom/core/middleware_request_logger.j2 b/djinit/templates/predefined/core/middleware/request_logger.j2 similarity index 100% rename from djinit/templates/custom/core/middleware_request_logger.j2 rename to djinit/templates/predefined/core/middleware/request_logger.j2 diff --git a/djinit/templates/custom/core/mixins_timestamped_model.j2 b/djinit/templates/predefined/core/mixins/timestamped_model.j2 similarity index 100% rename from djinit/templates/custom/core/mixins_timestamped_model.j2 rename to djinit/templates/predefined/core/mixins/timestamped_model.j2 diff --git a/djinit/templates/custom/core/utils_responses.j2 b/djinit/templates/predefined/core/utils/responses.j2 similarity index 100% rename from djinit/templates/custom/core/utils_responses.j2 rename to djinit/templates/predefined/core/utils/responses.j2 diff --git a/djinit/templates/project/settings_base.j2 b/djinit/templates/project/settings/base.j2 similarity index 100% rename from djinit/templates/project/settings_base.j2 rename to djinit/templates/project/settings/base.j2 diff --git a/djinit/templates/project/settings_development.j2 b/djinit/templates/project/settings/development.j2 similarity index 100% rename from djinit/templates/project/settings_development.j2 rename to djinit/templates/project/settings/development.j2 diff --git a/djinit/templates/project/settings_production.j2 b/djinit/templates/project/settings/production.j2 similarity index 100% rename from djinit/templates/project/settings_production.j2 rename to djinit/templates/project/settings/production.j2 diff --git a/djinit/templates/project/urls_with_api.j2 b/djinit/templates/project/urls_with_api.j2 new file mode 100644 index 0000000..e236407 --- /dev/null +++ b/djinit/templates/project/urls_with_api.j2 @@ -0,0 +1,29 @@ +"""generated with djinit""" + +from django.contrib import admin +from django.urls import path, include +from django.conf import settings +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularSwaggerView, +) +from rest_framework_simplejwt.views import ( + TokenObtainPairView, + TokenRefreshView, + TokenBlacklistView, +) + +urlpatterns = [ + path("admin/", admin.site.urls), + # JWT tokens + path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("token/blacklist/", TokenBlacklistView.as_view(), name="token_blacklist"), + path("api/", include("{{ api_module }}.urls")),] + +# Don't show schema in production +if settings.DEBUG: + urlpatterns += [ + path("schema/", SpectacularAPIView.as_view(), name="schema"), + path("docs/", SpectacularSwaggerView.as_view(url_name="schema")), + ] \ No newline at end of file diff --git a/djinit/templates/project/project_urls.j2 b/djinit/templates/project/urls_with_apps.j2 similarity index 100% rename from djinit/templates/project/project_urls.j2 rename to djinit/templates/project/urls_with_apps.j2 diff --git a/djinit/templates/shared/github_actions_ci.j2 b/djinit/templates/shared/ci/github_actions.j2 similarity index 100% rename from djinit/templates/shared/github_actions_ci.j2 rename to djinit/templates/shared/ci/github_actions.j2 diff --git a/djinit/templates/shared/gitlab_ci.j2 b/djinit/templates/shared/ci/gitlab_ci.j2 similarity index 100% rename from djinit/templates/shared/gitlab_ci.j2 rename to djinit/templates/shared/ci/gitlab_ci.j2 diff --git a/djinit/templates/project/manage_py.j2 b/djinit/templates/shared/manage_py.j2 similarity index 100% rename from djinit/templates/project/manage_py.j2 rename to djinit/templates/shared/manage_py.j2 diff --git a/djinit/templates/shared/readme.j2 b/djinit/templates/shared/readme.j2 index 50a5e31..0eb6b32 100644 --- a/djinit/templates/shared/readme.j2 +++ b/djinit/templates/shared/readme.j2 @@ -82,6 +82,46 @@ A Django project created with django-setup. ├── Procfile ├── runtime.txt └── README.md +{% elif unified_structure %} +{{ project_name }}/ +├── core/ +│ ├── settings/ +│ │ ├── base.py +│ │ ├── development.py +│ │ └── production.py +│ ├── urls.py +│ ├── wsgi.py +│ └── asgi.py +├── apps/ +│ ├── core/ +│ │ ├── apps.py +│ │ ├── models/ +│ │ │ ├── __init__.py +│ │ │ ├── base.py +│ │ │ └── mixins.py +│ │ ├── utils/ +│ │ │ ├── __init__.py +│ │ │ └── responses.py +│ │ ├── permissions/ +│ │ ├── middleware/ +│ │ └── __init__.py +│ └── api/ +│ ├── apps.py +│ ├── models/ +│ ├── serializers/ +│ ├── views/ +│ ├── services/ +│ ├── tests/ +│ ├── urls/ +│ └── admin/ +├── manage.py +├── requirements.txt +├── pyproject.toml +├── .env.sample +├── justfile +├── Procfile +├── runtime.txt +└── README.md {% else %} {{ project_name }}/ ├── {{ project_name }}/ diff --git a/djinit/templates/unified/apps/core/apps.j2 b/djinit/templates/unified/apps/core/apps.j2 new file mode 100644 index 0000000..e59efb0 --- /dev/null +++ b/djinit/templates/unified/apps/core/apps.j2 @@ -0,0 +1,9 @@ +"""generated with djinit""" + +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.core" + diff --git a/djinit/templates/unified/apps/core/models/__init__.py.j2 b/djinit/templates/unified/apps/core/models/__init__.py.j2 new file mode 100644 index 0000000..71067ec --- /dev/null +++ b/djinit/templates/unified/apps/core/models/__init__.py.j2 @@ -0,0 +1,5 @@ +from .base import AbstractBaseModel +from .mixins import TimestampedModel + +__all__ = ["AbstractBaseModel", "TimestampedModel"] + diff --git a/djinit/templates/unified/apps/core/models/base.py.j2 b/djinit/templates/unified/apps/core/models/base.py.j2 new file mode 100644 index 0000000..95ed131 --- /dev/null +++ b/djinit/templates/unified/apps/core/models/base.py.j2 @@ -0,0 +1,14 @@ +"""generated with djinit""" + +from django.db import models + + +class AbstractBaseModel(models.Model): + """Abstract base model with common fields.""" + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + diff --git a/djinit/templates/unified/apps/core/models/mixins.py.j2 b/djinit/templates/unified/apps/core/models/mixins.py.j2 new file mode 100644 index 0000000..c456739 --- /dev/null +++ b/djinit/templates/unified/apps/core/models/mixins.py.j2 @@ -0,0 +1,14 @@ +"""generated with djinit""" + +from django.db import models + + +class TimestampedModel(models.Model): + """Mixin for models that need timestamp fields.""" + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + diff --git a/djinit/templates/unified/apps/core/utils/__init__.py.j2 b/djinit/templates/unified/apps/core/utils/__init__.py.j2 new file mode 100644 index 0000000..56fdd17 --- /dev/null +++ b/djinit/templates/unified/apps/core/utils/__init__.py.j2 @@ -0,0 +1,4 @@ +from .responses import success, error + +__all__ = ["success", "error"] + diff --git a/djinit/templates/unified/apps/core/utils/responses.py.j2 b/djinit/templates/unified/apps/core/utils/responses.py.j2 new file mode 100644 index 0000000..7f32fda --- /dev/null +++ b/djinit/templates/unified/apps/core/utils/responses.py.j2 @@ -0,0 +1,14 @@ +"""generated with djinit""" + +from rest_framework.response import Response + + +def success(data=None, status_code=200): + """Return a successful response.""" + return Response({"success": True, "data": data}, status=status_code) + + +def error(message, status_code=400): + """Return an error response.""" + return Response({"success": False, "message": message}, status=status_code) + diff --git a/yoo/.env.sample b/yoo/.env.sample new file mode 100644 index 0000000..8130d94 --- /dev/null +++ b/yoo/.env.sample @@ -0,0 +1,20 @@ +# Django settings +DJANGO_SETTINGS_MODULE=yoo.settings.development +SECRET_KEY= your-secret-key +ALLOWED_HOSTS=api.your-domain.com,www.your-domain.com + +# Database (individual parameters) +DB_NAME= +DB_USER= +DB_PASSWORD= +DB_HOST= +DB_PORT= + +# Email settings +EMAIL_HOST= +EMAIL_PORT= +EMAIL_USE_TLS= +EMAIL_HOST_USER= +EMAIL_HOST_PASSWORD= +DEFAULT_FROM_EMAIL= +SERVER_EMAIL= diff --git a/yoo/.gitignore b/yoo/.gitignore new file mode 100644 index 0000000..2b5243c --- /dev/null +++ b/yoo/.gitignore @@ -0,0 +1,93 @@ +#--------------------------------------------------# +# The following was generated by djinit: # +#--------------------------------------------------# + +# Python +*.py[cod] +*$py.class +*.so +__pycache__/ +*.egg-info/ +dist/ +build/ +.eggs/ +*.egg + +# Django +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal +*.sqlite3 + +# Environment +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ +.project +.pydevproject +.settings/ +.ropeproject/ + +# OS +.DS_Store +*.DS_Store +Thumbs.db +desktop.ini + +# Django specific +media/ +staticfiles/ +staticfiles/ +static_root/ +/static/ + +# Testing +.coverage +htmlcov/ +.pytest_cache/ +.tox/ +coverage.xml +*.cover +.hypothesis/ +.cache + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Node modules (if using frontend tools) +node_modules/ + +# Secret keys +secret.txt +secrets.json + +# Jupyter Notebook +.ipynb_checkpoints + +# Backup files +*.bak +*.tmp +*~ + +# Database +*.db +*.sql + +# Cloud +.cloud diff --git a/yoo/Procfile b/yoo/Procfile new file mode 100644 index 0000000..bffaa10 --- /dev/null +++ b/yoo/Procfile @@ -0,0 +1,20 @@ +# Procfile for yoo +# Used for deploying to PaaS platforms like Heroku, Railway, Render, etc. + +# Web server process (using Gunicorn) +# Adjust workers based on your needs: (2 x CPU cores) + 1 +web: gunicorn yoo.wsgi:application --bind 0.0.0.0:$PORT --workers 2 --timeout 120 --access-logfile - --error-logfile - --log-level info + +# Alternative web server configurations (uncomment to use): +# web: gunicorn yoo.wsgi:application --bind 0.0.0.0:$PORT --workers 4 --threads 2 --timeout 120 +# web: gunicorn yoo.wsgi:application --bind 0.0.0.0:$PORT --workers 2 --worker-class gevent --worker-connections 1000 --timeout 120 + +# Release task (runs migrations before deployment) +release: python manage.py migrate --noinput && python manage.py collectstatic --noinput + +# Uncomment below to run scheduled tasks (if using Celery) +# worker: celery -A yoo worker --loglevel=info +# beat: celery -A yoo beat --loglevel=info + +# Development server (for local testing with Foreman/Overmind) +# web: python manage.py runserver 0.0.0.0:$PORT diff --git a/yoo/README.md b/yoo/README.md new file mode 100644 index 0000000..6d4a03f --- /dev/null +++ b/yoo/README.md @@ -0,0 +1,84 @@ +# yoo + +A Django project created with django-setup. + +## Features + +- Modern project structure with environment-specific settings +- Pre-configured REST API with JWT authentication +- Essential dependencies and utilities +- Production-ready configuration + +## Setup + +1. Install dependencies: + ```bash + pip install -r requirements.txt + ``` + +2. Set environment variables in `.env` file + +3. Run migrations: + ```bash + python manage.py migrate + ``` + +4. Create superuser: + ```bash + python manage.py createsuperuser + ``` + +5. Start development server: + ```bash + python manage.py runserver + ``` + +## Project Structure + +``` +yoo/ +├── core/ +│ ├── settings/ +│ │ ├── base.py +│ │ ├── development.py +│ │ └── production.py +│ ├── urls.py +│ ├── wsgi.py +│ └── asgi.py +├── apps/ +│ ├── core/ +│ │ ├── apps.py +│ │ ├── models/ +│ │ │ ├── __init__.py +│ │ │ ├── base.py +│ │ │ └── mixins.py +│ │ ├── utils/ +│ │ │ ├── __init__.py +│ │ │ └── responses.py +│ │ ├── permissions/ +│ │ ├── middleware/ +│ │ └── __init__.py +│ └── api/ +│ ├── apps.py +│ ├── models/ +│ ├── serializers/ +│ ├── views/ +│ ├── services/ +│ ├── tests/ +│ ├── urls/ +│ └── admin/ +├── manage.py +├── requirements.txt +├── pyproject.toml +├── .env.sample +├── justfile +├── Procfile +├── runtime.txt +└── README.md +``` + +## API Documentation + +When running in development mode, API documentation is available at: +- Swagger UI: http://localhost:8000/docs/ +- Schema: http://localhost:8000/schema/ diff --git a/yoo/apps/__init__.py b/yoo/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/yoo/apps/api/__init__.py b/yoo/apps/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/yoo/apps/api/admin/__init__.py b/yoo/apps/api/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/yoo/apps/api/apps.py b/yoo/apps/api/apps.py new file mode 100644 index 0000000..3ed621b --- /dev/null +++ b/yoo/apps/api/apps.py @@ -0,0 +1,8 @@ +"""generated with djinit""" + +from django.apps import AppConfig + + +class Apps.apiConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.api' diff --git a/yoo/apps/api/models/__init__.py b/yoo/apps/api/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/yoo/apps/api/serializers/__init__.py b/yoo/apps/api/serializers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/yoo/apps/api/services/__init__.py b/yoo/apps/api/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/yoo/apps/api/tests/__init__.py b/yoo/apps/api/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/yoo/apps/api/urls/__init__.py b/yoo/apps/api/urls/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/yoo/apps/api/views/__init__.py b/yoo/apps/api/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/yoo/apps/core/__init__.py b/yoo/apps/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/yoo/apps/core/apps.py b/yoo/apps/core/apps.py new file mode 100644 index 0000000..00c1458 --- /dev/null +++ b/yoo/apps/core/apps.py @@ -0,0 +1,8 @@ +"""generated with djinit""" + +from django.apps import AppConfig + + +class Config(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "" diff --git a/yoo/apps/core/middleware/__init__.py b/yoo/apps/core/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/yoo/apps/core/models/__init__.py b/yoo/apps/core/models/__init__.py new file mode 100644 index 0000000..980c8e5 --- /dev/null +++ b/yoo/apps/core/models/__init__.py @@ -0,0 +1,5 @@ +"""generated with djinit""" + +from django.db import models + +# Create your models here. diff --git a/yoo/apps/core/models/base.py b/yoo/apps/core/models/base.py new file mode 100644 index 0000000..a3970f9 --- /dev/null +++ b/yoo/apps/core/models/base.py @@ -0,0 +1,13 @@ +"""generated with djinit""" + +from django.db import models + + +class AbstractBaseModel(models.Model): + """Abstract base model with common fields.""" + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True diff --git a/yoo/apps/core/models/mixins.py b/yoo/apps/core/models/mixins.py new file mode 100644 index 0000000..fe40544 --- /dev/null +++ b/yoo/apps/core/models/mixins.py @@ -0,0 +1,13 @@ +"""generated with djinit""" + +from django.db import models + + +class TimestampedModel(models.Model): + """Mixin for models that need timestamp fields.""" + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True diff --git a/yoo/apps/core/permissions/__init__.py b/yoo/apps/core/permissions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/yoo/apps/core/utils/__init__.py b/yoo/apps/core/utils/__init__.py new file mode 100644 index 0000000..155f5b4 --- /dev/null +++ b/yoo/apps/core/utils/__init__.py @@ -0,0 +1,3 @@ +from .responses import success, error + +__all__ = ["success", "error"] diff --git a/yoo/apps/core/utils/responses.py b/yoo/apps/core/utils/responses.py new file mode 100644 index 0000000..929c2f6 --- /dev/null +++ b/yoo/apps/core/utils/responses.py @@ -0,0 +1,13 @@ +"""generated with djinit""" + +from rest_framework.response import Response + + +def success(data=None, status_code=200): + """Return a successful response.""" + return Response({"success": True, "data": data}, status=status_code) + + +def error(message, status_code=400): + """Return an error response.""" + return Response({"success": False, "message": message}, status=status_code) diff --git a/yoo/core/__init__.py b/yoo/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/yoo/core/asgi.py b/yoo/core/asgi.py new file mode 100644 index 0000000..03b888c --- /dev/null +++ b/yoo/core/asgi.py @@ -0,0 +1,11 @@ +"""generated with djinit""" + +import os +from dotenv import load_dotenv +from django.core.asgi import get_asgi_application + +load_dotenv() + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", os.getenv("DJANGO_SETTINGS_MODULE")) + +application = get_asgi_application() diff --git a/yoo/core/settings/__init__.py b/yoo/core/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/yoo/core/settings/base.py b/yoo/core/settings/base.py new file mode 100644 index 0000000..9c92304 --- /dev/null +++ b/yoo/core/settings/base.py @@ -0,0 +1,163 @@ +""" +generated with djinit +Common settings shared between development and production environment +""" + +import os +from datetime import timedelta +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv() + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ + + +# Application definition +THIRD_PARTY_APPS = [ + "rest_framework", + "rest_framework_simplejwt", + "rest_framework_simplejwt.token_blacklist", + "corsheaders", + "drf_spectacular", +] + +USER_DEFINED_APPS = [ + "apps.core", + "apps.api", +] + +BUILT_IN_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", +] + +INSTALLED_APPS = BUILT_IN_APPS + THIRD_PARTY_APPS + USER_DEFINED_APPS +INSTALLED_APPS.insert(0, "jazzmin") + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "core.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "core.wsgi.application" + + +# Password validation +# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.2/topics/i18n/ + +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +USE_TZ = True + +# Static files and media files +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" + +MEDIA_URL = "media/" +MEDIA_ROOT = BASE_DIR / "media" + +STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# REST Framework Settings +REST_FRAMEWORK = { + "DEFAULT_RENDERER_CLASSES": [ + "rest_framework.renderers.JSONRenderer", + ], + "DEFAULT_PARSER_CLASSES": [ + "rest_framework.parsers.JSONParser", + ], + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", + "rest_framework.authentication.SessionAuthentication", + ), + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 20, +} + +# DRF Spectacular settings +SPECTACULAR_SETTINGS = { + "TITLE": "core API", + "DESCRIPTION": "API documentation for core", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "COMPONENT_SPLIT_REQUEST": True, + "SCHEMA_PATH_PREFIX": "/api/", +} + +# JWT Settings +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(days=1), + "REFRESH_TOKEN_LIFETIME": timedelta(days=3), + "ROTATE_REFRESH_TOKENS": True, + "BLACKLIST_AFTER_ROTATION": True, +} + +# CORS Settings +CORS_ALLOWED_ORIGINS = [ + "http://localhost:3000", + "http://127.0.0.1:3000", +] + +CORS_ALLOW_CREDENTIALS = True + +# Security Settings +SECURE_BROWSER_XSS_FILTER = True +SECURE_CONTENT_TYPE_NOSNIFF = True +X_FRAME_OPTIONS = "DENY" diff --git a/yoo/core/settings/development.py b/yoo/core/settings/development.py new file mode 100644 index 0000000..f78a937 --- /dev/null +++ b/yoo/core/settings/development.py @@ -0,0 +1,29 @@ +"""generated with djinit""" + +from .base import * + +SECRET_KEY = "4RxQW##Ss0h(8&Q3snx(2Km(qw75kRjGM#-zyV7*KUV&3)rUGZ" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = ["*"] + +# Database +# https://docs.djangoproject.com/en/5.2/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + +# Email backend for development +EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + +# CORS settings for development +CORS_ALLOW_ALL_ORIGINS = True + +# Disable HTTPS redirect in development +SECURE_SSL_REDIRECT = False diff --git a/yoo/core/settings/production.py b/yoo/core/settings/production.py new file mode 100644 index 0000000..e0cd36c --- /dev/null +++ b/yoo/core/settings/production.py @@ -0,0 +1,47 @@ +"""generated with djinit""" + +from .base import * +import os +import dj_database_url + +DEBUG = False +SECRET_KEY = os.getenv("SECRET_KEY") +ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "").split(",") + +# Database +# Using individual database parameters +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.getenv("DB_NAME"), + "USER": os.getenv("DB_USER"), + "PASSWORD": os.getenv("DB_PASSWORD"), + "HOST": os.getenv("DB_HOST"), + "PORT": os.getenv("DB_PORT"), + } +} + + +# Email settings +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" +EMAIL_HOST = os.getenv("EMAIL_HOST") +EMAIL_PORT = int(os.getenv("EMAIL_PORT", 587)) +EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "True").lower() == "true" +EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER") +EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD") +DEFAULT_FROM_EMAIL = os.environ.get("EMAIL_HOST_USER") +SERVER_EMAIL = os.environ.get("EMAIL_HOST_USER") + +# Security settings for production +SECURE_SSL_REDIRECT = True +SECURE_HSTS_SECONDS = 31536000 +SECURE_HSTS_INCLUDE_SUBDOMAINS = True +SECURE_HSTS_PRELOAD = True +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +# Session settings +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True + +# Static files +STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" diff --git a/yoo/core/urls.py b/yoo/core/urls.py new file mode 100644 index 0000000..662a94c --- /dev/null +++ b/yoo/core/urls.py @@ -0,0 +1,30 @@ +"""generated with djinit""" + +from django.contrib import admin +from django.urls import path, include +from django.conf import settings +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularSwaggerView, +) +from rest_framework_simplejwt.views import ( + TokenObtainPairView, + TokenRefreshView, + TokenBlacklistView, +) + +urlpatterns = [ + path("admin/", admin.site.urls), + # JWT tokens + path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("token/blacklist/", TokenBlacklistView.as_view(), name="token_blacklist"), + path("api/", include("apps.api.urls")), +] + +# Don't show schema in production +if settings.DEBUG: + urlpatterns += [ + path("schema/", SpectacularAPIView.as_view(), name="schema"), + path("docs/", SpectacularSwaggerView.as_view(url_name="schema")), + ] diff --git a/yoo/core/wsgi.py b/yoo/core/wsgi.py new file mode 100644 index 0000000..fa32c6a --- /dev/null +++ b/yoo/core/wsgi.py @@ -0,0 +1,11 @@ +"""generated with djinit""" + +import os +from dotenv import load_dotenv +from django.core.wsgi import get_wsgi_application + +load_dotenv() + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", os.getenv("DJANGO_SETTINGS_MODULE")) + +application = get_wsgi_application() diff --git a/yoo/justfile b/yoo/justfile new file mode 100644 index 0000000..2299604 --- /dev/null +++ b/yoo/justfile @@ -0,0 +1,154 @@ +# Justfile for yoo +# Task runner for Django development +# Install just: https://github.com/casey/just + +# Default task: show available commands +default: + @just --list + +# Run development server +dev: + uv run python manage.py runserver + +# Run development server with custom host and port +dev-server HOST="0.0.0.0" PORT="8000": + uv run python manage.py runserver {{HOST}}:{{PORT}} + +# Create migrations for all apps +makemigrations: + uv run python manage.py makemigrations + +# Create migrations for specific app +makemigrations-app APP: + uv run python manage.py makemigrations {{APP}} + +# Run all migrations +migrate: + uv run python manage.py migrate + +# Reset database (delete all migrations) +reset-db: + uv run python manage.py flush --noinput + uv run python manage.py migrate + +# Create superuser +createsuperuser: + uv run python manage.py createsuperuser + +# Collect static files +collectstatic: + uv run python manage.py collectstatic --noinput + +# Run Django shell +shell: + uv run python manage.py shell + +# Run Django shell plus (IPython) +shell-plus: + uv run python manage.py shell_plus + +# Run tests +test: + uv run python manage.py test + +# Run tests with coverage +test-coverage: + uv run pytest --cov=. --cov-report=html --cov-report=term + +# Lint code with ruff +lint: + uv run ruff check . + +# Format code with ruff +format: + uv run ruff format . + +# Lint and format code +style: + uv run ruff check . + uv run ruff format . + +# Check security issues +check: + uv run python manage.py check + +# Check security issues (production settings) +check-deploy: + uv run python manage.py check --deploy + +# Show all database migrations status +showmigrations: + uv run python manage.py showmigrations + +# Sync dependencies (uv equivalent of pip freeze) +requirements: + uv pip compile + +# Run all linting and checks +ci: + uv run ruff check . + uv run ruff format --check . + uv run python manage.py check + +# Start interactive shell +shell-ipython: + uv run python manage.py shell_plus + +# Clear all cache +clear-cache: + uv run python manage.py clear_cache + +# Show Django version +version: + uv run python manage.py version + +# Run database shell +dbshell: + uv run python manage.py dbshell + +# Start production server with gunicorn +server: + uv run gunicorn yoo.wsgi:application --bind 0.0.0.0:8000 + +# Start production server with gunicorn workers +server-prod WORKERS="4": + uv run gunicorn yoo.wsgi:application --bind 0.0.0.0:8000 --workers {{WORKERS}} + +# Start production server with gunicorn and reload on file changes +server-watch: + uv run gunicorn yoo.wsgi:application --bind 0.0.0.0:8000 --reload + +# Clean Python cache files +clean: + find . -type d -name "__pycache__" -exec rm -rf {} + + find . -type f -name "*.pyc" -delete + find . -type f -name "*.pyo" -delete + find . -type f -name ".coverage" -delete + find . -type d -name "*.egg-info" -exec rm -rf {} + + rm -rf htmlcov/ + rm -rf .pytest_cache/ + +# Setup project for development (using uv) +setup: + uv sync + uv run python manage.py migrate + uv run python manage.py createsuperuser + +# Complete setup including static files +setup-complete: + uv sync + uv run python manage.py migrate + uv run python manage.py collectstatic --noinput + uv run python manage.py createsuperuser + +# Install a new package +install PACKAGE: + uv add {{PACKAGE}} + +# Install a dev package +install-dev PACKAGE: + uv add --dev {{PACKAGE}} + +# Remove a package +remove PACKAGE: + uv remove {{PACKAGE}} diff --git a/yoo/manage.py b/yoo/manage.py new file mode 100755 index 0000000..422a555 --- /dev/null +++ b/yoo/manage.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python + +"""generated with djinit""" + +"""Django's command-line utility for administrative tasks.""" +import os +import sys + +from dotenv import load_dotenv + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", os.getenv("DJANGO_SETTINGS_MODULE")) + + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + load_dotenv() + main() diff --git a/yoo/pyproject.toml b/yoo/pyproject.toml new file mode 100644 index 0000000..4e6bf5e --- /dev/null +++ b/yoo/pyproject.toml @@ -0,0 +1,41 @@ +[project] +name = "yoo" +version = "0.1.0" +description = "" +readme = "README.md" +requires-python = ">=3.13" +license = { text = "MIT" } +dependencies = [ + "django", + "python-dotenv", + "django-jazzmin", + "djangorestframework", + "djangorestframework-simplejwt", + "drf-spectacular", + "django-cors-headers", + "whitenoise", + "psycopg2-binary", + "gunicorn", +] + +[tool.ruff] +line-length = 88 +target-version = "py38" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long, handled by formatter + "B008", # do not perform function calls in argument defaults +] + +[tool.ruff.lint.isort] +known-first-party = ["yoo"] diff --git a/yoo/requirements.txt b/yoo/requirements.txt new file mode 100644 index 0000000..5d8a32d --- /dev/null +++ b/yoo/requirements.txt @@ -0,0 +1,14 @@ +#--------------------------------------------------# +# The following was generated by djinit: # +#--------------------------------------------------# + +Django +python-dotenv +django-jazzmin +djangorestframework +djangorestframework_simplejwt +drf-spectacular +django-cors-headers +whitenoise +psycopg2-binary +gunicorn diff --git a/yoo/runtime.txt b/yoo/runtime.txt new file mode 100644 index 0000000..e912aa6 --- /dev/null +++ b/yoo/runtime.txt @@ -0,0 +1 @@ +python-3.13 -- 2.43.0 From 30ac34478bda46e3ca1cb4e9cc247a24e8adf34f Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Sun, 9 Nov 2025 15:10:09 +0545 Subject: [PATCH 14/15] refactor(structure): streamline project structure creation and file generation --- djinit/cli.py | 5 - djinit/scripts/app_generator.py | 92 ++-- djinit/scripts/django_helper.py | 88 ++-- djinit/scripts/files.py | 131 +++--- djinit/scripts/input_handler.py | 418 +++++------------- djinit/scripts/project.py | 50 +-- djinit/templates/predefined/api/urls.j2 | 2 - djinit/templates/predefined/api/v1/urls.j2 | 2 - .../predefined/apps/generic/models.j2 | 2 - .../predefined/apps/generic/serializers.j2 | 2 - .../predefined/apps/generic/services.j2 | 2 - .../predefined/apps/generic/tests.j2 | 2 - .../templates/predefined/apps/generic/urls.j2 | 2 - .../predefined/apps/generic/views.j2 | 2 - .../templates/predefined/apps/users/models.j2 | 2 - .../predefined/apps/users/serializers.j2 | 2 - .../predefined/apps/users/services.j2 | 2 - .../templates/predefined/apps/users/tests.j2 | 2 - .../templates/predefined/apps/users/urls.j2 | 2 - .../templates/predefined/apps/users/views.j2 | 2 - .../templates/predefined/core/exceptions.j2 | 2 - .../core/middleware/request_logger.j2 | 2 - .../core/mixins/timestamped_model.j2 | 2 - .../predefined/core/utils/responses.j2 | 2 - djinit/templates/project/urls_with_api.j2 | 2 +- djinit/templates/unified/apps/core/apps.j2 | 1 - .../unified/apps/core/models/__init__.py.j2 | 1 - .../unified/apps/core/models/base.py.j2 | 1 - .../unified/apps/core/models/mixins.py.j2 | 1 - .../unified/apps/core/utils/__init__.py.j2 | 1 - .../unified/apps/core/utils/responses.py.j2 | 1 - djinit/utils.py | 98 ++-- 32 files changed, 334 insertions(+), 592 deletions(-) diff --git a/djinit/cli.py b/djinit/cli.py index 3644db7..53dc429 100644 --- a/djinit/cli.py +++ b/djinit/cli.py @@ -19,12 +19,7 @@ class Cli: self.primary_app = primary_app self.app_names = app_names self.metadata = metadata -<<<<<<< HEAD if project_dir == ".": # handle '.' for current directory -======= - # Handle '.' for current directory - if project_dir == ".": ->>>>>>> origin/main self.project_root = os.getcwd() else: self.project_root = os.path.join(os.getcwd(), project_dir) diff --git a/djinit/scripts/app_generator.py b/djinit/scripts/app_generator.py index 5b46633..a9111aa 100644 --- a/djinit/scripts/app_generator.py +++ b/djinit/scripts/app_generator.py @@ -4,19 +4,14 @@ Handles creation of Django apps and updating settings. """ import os -<<<<<<< HEAD -======= -import subprocess -import sys ->>>>>>> origin/main from typing import Optional from djinit.scripts.console_ui import UIFormatter from djinit.scripts.django_helper import DjangoHelper -from djinit.scripts.template_engine import template_engine from djinit.utils import ( calculate_app_module_path, - create_file_with_content, + create_directory_with_init, + create_file_from_template, create_init_file, detect_nested_structure_from_settings, extract_existing_apps, @@ -32,6 +27,7 @@ class AppManager: self.app_name = app_name self.current_dir = os.getcwd() self.manage_py_path = os.path.join(self.current_dir, "manage.py") + self._project_structure_cache = None def create_app(self) -> bool: if not self._is_django_project(): @@ -61,34 +57,22 @@ class AppManager: if self._is_predefined_structure(): app_path = os.path.join(self.current_dir, "apps", self.app_name) else: - is_nested, nested_dir, apps_base_dir = self._detect_project_structure() + _, _, apps_base_dir = self._get_project_structure() app_path = os.path.join(apps_base_dir, self.app_name) return os.path.exists(app_path) def _create_django_app(self) -> bool: - # Find where to create the app (flat or nested structure) - is_nested, nested_dir, apps_base_dir = self._detect_project_structure() - # Verify manage.py exists in project root - manage_py_path = os.path.join(self.current_dir, "manage.py") - if not os.path.exists(manage_py_path): + if not os.path.exists(self.manage_py_path): UIFormatter.print_error("Could not find manage.py file in project root") return False -<<<<<<< HEAD # If predefined structure detected (apps/ exists), scaffold nested layout using custom templates if self._is_predefined_structure(): return self._create_predefined_app(os.path.join(self.current_dir, "apps")) -======= - # Create app in the correct location - # Use 'python -m django' instead of 'django-admin' for better compatibility - with change_cwd(apps_base_dir): - subprocess.run( - [sys.executable, "-m", "django", "startapp", self.app_name], capture_output=True, text=True, check=True - ) ->>>>>>> origin/main # Otherwise, use standard flat app generation + _, _, apps_base_dir = self._get_project_structure() success = DjangoHelper.startapp(self.app_name, apps_base_dir) if success: UIFormatter.print_success(f"Created Django app '{self.app_name}' in {apps_base_dir}") @@ -115,7 +99,7 @@ class AppManager: if self._is_predefined_structure(): app_module_path = f"apps.{self.app_name}" else: - is_nested, nested_dir, apps_base_dir = self._detect_project_structure() + is_nested, nested_dir, _ = self._get_project_structure() app_module_path = calculate_app_module_path(self.app_name, is_nested, nested_dir) # Check if app is already in USER_DEFINED_APPS @@ -139,15 +123,20 @@ class AppManager: UIFormatter.print_success(f"Added '{app_module_path}' to USER_DEFINED_APPS in base.py") return True - def _detect_project_structure(self) -> tuple[bool, Optional[str], str]: - # Find project directory with settings/base.py - project_dir, settings_base_path = find_project_dir(self.current_dir) + def _get_project_structure(self) -> tuple[bool, Optional[str], str]: + """Get project structure with caching to avoid repeated detection.""" + if self._project_structure_cache is None: + # Find project directory with settings/base.py + project_dir, settings_base_path = find_project_dir(self.current_dir) - if project_dir is None: - return False, None, self.current_dir - - # Detect nested structure from settings file - return detect_nested_structure_from_settings(settings_base_path, self.current_dir) + if project_dir is None: + self._project_structure_cache = (False, None, self.current_dir) + else: + # Detect nested structure from settings file + self._project_structure_cache = detect_nested_structure_from_settings( + settings_base_path, self.current_dir + ) + return self._project_structure_cache def _is_predefined_structure(self) -> bool: # Heuristic: presence of 'apps' directory at project root (where manage.py lives) and 'api' dir @@ -161,15 +150,18 @@ class AppManager: os.makedirs(app_dir, exist_ok=True) create_init_file(app_dir, f"Created apps/{self.app_name}/__init__.py") - # apps.py using standard template - apps_py_content = template_engine.render_template("base/apps.j2", {"app_name": self.app_name}) - create_file_with_content( - os.path.join(app_dir, "apps.py"), apps_py_content, f"Created apps/{self.app_name}/apps.py" - ) - # Compute names for generic templates model_class_name = "".join([part.capitalize() for part in self.app_name.split("_")]) app_module = f"apps.{self.app_name}" + base_context = {"app_name": self.app_name, "app_module": app_module, "model_class_name": model_class_name} + + # Create apps.py + create_file_from_template( + os.path.join(app_dir, "apps.py"), + "base/apps.j2", + {"app_name": self.app_name}, + f"Created apps/{self.app_name}/apps.py", + ) # Subfolders subfolders = { @@ -181,25 +173,19 @@ class AppManager: } for folder, files in subfolders.items(): folder_path = os.path.join(app_dir, folder) - os.makedirs(folder_path, exist_ok=True) - create_init_file(folder_path, f"Created apps/{self.app_name}/{folder}/__init__.py") - for filename, tpl in files: - content = template_engine.render_template( - tpl, - {"app_name": self.app_name, "app_module": app_module, "model_class_name": model_class_name}, - ) - create_file_with_content( - os.path.join(folder_path, filename), - content, - f"Created apps/{self.app_name}/{folder}/{filename}", + create_directory_with_init(folder_path, f"Created apps/{self.app_name}/{folder}/__init__.py") + for filename, template in files: + file_path = os.path.join(folder_path, filename) + create_file_from_template( + file_path, template, base_context, f"Created apps/{self.app_name}/{folder}/{filename}" ) # urls.py at app root - urls_content = template_engine.render_template( - "predefined/apps/generic/urls.j2", {"app_name": self.app_name, "app_module": app_module} - ) - create_file_with_content( - os.path.join(app_dir, "urls.py"), urls_content, f"Created apps/{self.app_name}/urls.py" + create_file_from_template( + os.path.join(app_dir, "urls.py"), + "predefined/apps/generic/urls.j2", + {"app_name": self.app_name, "app_module": app_module}, + f"Created apps/{self.app_name}/urls.py", ) # Add route include to api/v1/urls.py if present diff --git a/djinit/scripts/django_helper.py b/djinit/scripts/django_helper.py index b20fc82..207c3de 100644 --- a/djinit/scripts/django_helper.py +++ b/djinit/scripts/django_helper.py @@ -7,7 +7,12 @@ This module provides functions to create Django projects and apps without requir import os from djinit.scripts.console_ui import UIFormatter -from djinit.utils import create_file_from_template, create_init_file +from djinit.utils import ( + create_directory_with_init, + create_file_from_template, + create_files_from_templates, + create_init_file, +) class DjangoHelper: @@ -32,54 +37,28 @@ class DjangoHelper: # Create project config directory project_config_dir = os.path.join(directory, project_name) - os.makedirs(project_config_dir, exist_ok=True) - create_init_file(project_config_dir, f"Created {project_name}/__init__.py") + create_directory_with_init(project_config_dir, f"Created {project_name}/__init__.py") # Create settings directory and files settings_dir = os.path.join(project_config_dir, "settings") - os.makedirs(settings_dir, exist_ok=True) - create_init_file(settings_dir, f"Created {project_name}/settings/__init__.py") + create_directory_with_init(settings_dir, f"Created {project_name}/settings/__init__.py") base_context = {"project_name": project_name, "app_names": []} - create_file_from_template( - os.path.join(settings_dir, "base.py"), - "project/settings/base.j2", - base_context, - f"Created {project_name}/settings/base.py", - ) - create_file_from_template( - os.path.join(settings_dir, "development.py"), - "project/settings/development.j2", - {}, - f"Created {project_name}/settings/development.py", - ) - create_file_from_template( - os.path.join(settings_dir, "production.py"), - "project/settings/production.j2", - {}, - f"Created {project_name}/settings/production.py", - ) + settings_files = [ + ("base.py", "project/settings/base.j2", base_context), + ("development.py", "project/settings/development.j2", {}), + ("production.py", "project/settings/production.j2", {}), + ] + create_files_from_templates(settings_dir, settings_files, f"{project_name}/settings/") # Create project-level files urls_context = {"project_name": project_name, "django_version": DjangoHelper.DJANGO_VERSION} - create_file_from_template( - os.path.join(project_config_dir, "urls.py"), - "project/urls.j2", - urls_context, - f"Created {project_name}/urls.py", - ) - create_file_from_template( - os.path.join(project_config_dir, "wsgi.py"), - "project/wsgi.j2", - {}, - f"Created {project_name}/wsgi.py", - ) - create_file_from_template( - os.path.join(project_config_dir, "asgi.py"), - "project/asgi.j2", - {}, - f"Created {project_name}/asgi.py", - ) + project_files = [ + ("urls.py", "project/urls.j2", urls_context), + ("wsgi.py", "project/wsgi.j2", {}), + ("asgi.py", "project/asgi.j2", {}), + ] + create_files_from_templates(project_config_dir, project_files, f"{project_name}/") return True @@ -99,28 +78,21 @@ class DjangoHelper: create_init_file(app_dir, f"Created {app_name}/__init__.py") app_files = [ - ("apps.py", "base/apps.j2"), - ("models.py", "base/models.j2"), - ("views.py", "base/views.j2"), - ("admin.py", "base/admin.j2"), - ("urls.py", "base/urls.j2"), - ("serializers.py", "base/serializers.j2"), - ("routes.py", "base/routes.j2"), - ("tests.py", "base/tests.j2"), + ("apps.py", "base/apps.j2", context), + ("models.py", "base/models.j2", context), + ("views.py", "base/views.j2", context), + ("admin.py", "base/admin.j2", context), + ("urls.py", "base/urls.j2", context), + ("serializers.py", "base/serializers.j2", context), + ("routes.py", "base/routes.j2", context), + ("tests.py", "base/tests.j2", context), ] - for filename, template_path in app_files: - create_file_from_template( - os.path.join(app_dir, filename), - template_path, - context, - f"Created {app_name}/{filename}", - ) + create_files_from_templates(app_dir, app_files, f"{app_name}/") # Create migrations directory and __init__.py migrations_dir = os.path.join(app_dir, "migrations") - os.makedirs(migrations_dir, exist_ok=True) - create_init_file(migrations_dir, f"Created {app_name}/migrations/__init__.py") + create_directory_with_init(migrations_dir, f"Created {app_name}/migrations/__init__.py") return True diff --git a/djinit/scripts/files.py b/djinit/scripts/files.py index 313415a..b3d7fdc 100644 --- a/djinit/scripts/files.py +++ b/djinit/scripts/files.py @@ -9,8 +9,11 @@ from djinit.scripts.template_engine import template_engine from djinit.utils import ( calculate_app_module_paths, change_cwd, + create_directory_with_init, + create_file_from_template, create_file_with_content, - create_init_file, + create_files_from_templates, + get_package_name, ) @@ -42,21 +45,29 @@ class FileManager: content = template_engine.render_template(template_path, context) return create_file_with_content(filepath, content, message, should_format=should_format) - def _ensure_directory_with_init(self, dir_path: str, message: str) -> None: - """Helper to create directory and __init__.py file.""" - os.makedirs(dir_path, exist_ok=True) - create_init_file(dir_path, message) - def _create_files_from_specs(self, base_dir: str, folder_specs: dict, base_context: dict = None) -> None: """Helper to create multiple folders and files from a specification dict.""" base_context = base_context or {} for folder, files in folder_specs.items(): folder_path = os.path.join(base_dir, folder) - self._ensure_directory_with_init(folder_path, f"Created {folder_path}/__init__.py") + create_directory_with_init(folder_path, f"Created {folder_path}/__init__.py") for filename, template_path in files: file_path = os.path.join(folder_path, filename) - content = template_engine.render_template(template_path, base_context) - create_file_with_content(file_path, content, f"Created {file_path}") + create_file_from_template(file_path, template_path, base_context, f"Created {file_path}") + + def _create_apps_py(self, app_dir: str, app_name: str, message: str = None, should_format: bool = False) -> None: + """Helper to create apps.py file for an app.""" + apps_py_path = os.path.join(app_dir, "apps.py") + context = {"app_name": app_name} if app_name else {} + msg = message or f"Created {os.path.basename(app_dir)}/apps.py" + create_file_from_template(apps_py_path, "base/apps.j2", context, msg, should_format=should_format) + + def _create_subdirectories_with_init(self, base_dir: str, subdirs: list, prefix: str = "") -> None: + """Helper to create multiple subdirectories with __init__.py files.""" + for subdir in subdirs: + subdir_path = os.path.join(base_dir, subdir) + message = f"Created {prefix}{subdir}/__init__.py" if prefix else f"Created {subdir}/__init__.py" + create_directory_with_init(subdir_path, message) def create_gitignore(self) -> bool: return self._render_and_create_file(".gitignore", "shared/gitignore.j2", {}, "Created .gitignore file") @@ -96,8 +107,7 @@ class FileManager: def create_pyproject_toml(self, metadata: dict) -> bool: # Default package_name to "backend" if it's "." or empty package_name = metadata.get("package_name", "backend") - if package_name == "." or not package_name: - package_name = "backend" + package_name = get_package_name(package_name) context = {"package_name": package_name, "project_name": self.project_name} return self._render_and_create_file( "pyproject.toml", @@ -134,16 +144,12 @@ class FileManager: def create_predefined_structure(self) -> bool: # Ensure apps package apps_dir = os.path.join(self.project_root, "apps") - self._ensure_directory_with_init(apps_dir, "Created apps/__init__.py") + create_directory_with_init(apps_dir, "Created apps/__init__.py") # users app users_dir = os.path.join(apps_dir, "users") - self._ensure_directory_with_init(users_dir, "Created apps/users/__init__.py") - - # apps.py can reuse standard app template - users_apps_path = os.path.join(users_dir, "apps.py") - content = template_engine.render_template("base/apps.j2", {"app_name": "users"}) - create_file_with_content(users_apps_path, content, "Created apps/users/apps.py") + create_directory_with_init(users_dir, "Created apps/users/__init__.py") + self._create_apps_py(users_dir, "users") # Nested folders and files for users users_subfolders = { @@ -157,12 +163,13 @@ class FileManager: # users urls.py at app root users_urls_path = os.path.join(users_dir, "urls.py") - content = template_engine.render_template("predefined/apps/users/urls.j2", {"app_module": "apps.users"}) - create_file_with_content(users_urls_path, content, "Created apps/users/urls.py") + create_file_from_template( + users_urls_path, "predefined/apps/users/urls.j2", {"app_module": "apps.users"}, "Created apps/users/urls.py" + ) # core app core_dir = os.path.join(apps_dir, "core") - self._ensure_directory_with_init(core_dir, "Created apps/core/__init__.py") + create_directory_with_init(core_dir, "Created apps/core/__init__.py") # core subfolders and files core_subfolders = { @@ -174,28 +181,30 @@ class FileManager: # core/exceptions.py exceptions_path = os.path.join(core_dir, "exceptions.py") - content = template_engine.render_template("predefined/core/exceptions.j2", {}) - create_file_with_content(exceptions_path, content, "Created apps/core/exceptions.py") + create_file_from_template( + exceptions_path, "predefined/core/exceptions.j2", {}, "Created apps/core/exceptions.py" + ) # api package api_dir = os.path.join(self.project_root, "api") - self._ensure_directory_with_init(api_dir, "Created api/__init__.py") + create_directory_with_init(api_dir, "Created api/__init__.py") api_urls_path = os.path.join(api_dir, "urls.py") - content = template_engine.render_template("predefined/api/urls.j2", {}) - create_file_with_content(api_urls_path, content, "Created api/urls.py") + create_file_from_template(api_urls_path, "predefined/api/urls.j2", {}, "Created api/urls.py") # api/v1 api_v1_dir = os.path.join(api_dir, "v1") - self._ensure_directory_with_init(api_v1_dir, "Created api/v1/__init__.py") + create_directory_with_init(api_v1_dir, "Created api/v1/__init__.py") api_v1_urls_path = os.path.join(api_v1_dir, "urls.py") - content = template_engine.render_template("predefined/api/v1/urls.j2", {}) - create_file_with_content(api_v1_urls_path, content, "Created api/v1/urls.py") + create_file_from_template(api_v1_urls_path, "predefined/api/v1/urls.j2", {}, "Created api/v1/urls.py") # overwrite project urls to include api project_urls_path = os.path.join(self.project_configs, "urls.py") - content = template_engine.render_template("project/urls_with_api.j2", {"api_module": "api"}) - create_file_with_content( - project_urls_path, content, "Updated project urls.py to include API routes", should_format=True + create_file_from_template( + project_urls_path, + "project/urls_with_api.j2", + {"api_module": "api"}, + "Updated project urls.py to include API routes", + should_format=True, ) return True @@ -203,11 +212,11 @@ class FileManager: def create_unified_structure(self) -> bool: # Create core directory structure core_dir = os.path.join(self.project_root, "core") - self._ensure_directory_with_init(core_dir, "Created core/__init__.py") + create_directory_with_init(core_dir, "Created core/__init__.py") # Create core/settings directory settings_dir = os.path.join(core_dir, "settings") - self._ensure_directory_with_init(settings_dir, "Created core/settings/__init__.py") + create_directory_with_init(settings_dir, "Created core/settings/__init__.py") # Generate secret key for development settings from djinit.scripts.secretkey_generator import generate_secret_key @@ -230,8 +239,10 @@ class FileManager: ("production.py", base_context), ]: filepath = os.path.join(settings_dir, filename) - content = template_engine.render_template(f"project/settings/{filename.replace('.py', '')}.j2", context) - create_file_with_content(filepath, content, f"Created core/settings/{filename}", should_format=True) + template_path = f"project/settings/{filename.replace('.py', '')}.j2" + create_file_from_template( + filepath, template_path, context, f"Created core/settings/{filename}", should_format=True + ) # Core project files core_files = [ @@ -241,66 +252,54 @@ class FileManager: ] for filename, template, context in core_files: filepath = os.path.join(core_dir, filename) - content = template_engine.render_template(template, context) - create_file_with_content(filepath, content, f"Created core/{filename}", should_format=True) + create_file_from_template(filepath, template, context, f"Created core/{filename}", should_format=True) # Create apps directory apps_dir = os.path.join(self.project_root, "apps") - self._ensure_directory_with_init(apps_dir, "Created apps/__init__.py") + create_directory_with_init(apps_dir, "Created apps/__init__.py") # Create apps/core apps_core_dir = os.path.join(apps_dir, "core") - self._ensure_directory_with_init(apps_core_dir, "Created apps/core/__init__.py") + create_directory_with_init(apps_core_dir, "Created apps/core/__init__.py") # Create apps/core/apps.py - apps_py_path = os.path.join(apps_core_dir, "apps.py") - content = template_engine.render_template("base/apps.j2", {}) - create_file_with_content(apps_py_path, content, "Created apps/core/apps.py", should_format=True) + self._create_apps_py(apps_core_dir, "", "Created apps/core/apps.py", should_format=True) # Create apps/core/models models_dir = os.path.join(apps_core_dir, "models") - os.makedirs(models_dir, exist_ok=True) model_files = [ ("__init__.py", "base/models.j2"), ("base.py", "unified/apps/core/models/base.py.j2"), ("mixins.py", "unified/apps/core/models/mixins.py.j2"), ] - for filename, template in model_files: - filepath = os.path.join(models_dir, filename) - content = template_engine.render_template(template, {}) - create_file_with_content(filepath, content, f"Created apps/core/models/{filename}", should_format=True) + os.makedirs(models_dir, exist_ok=True) + create_files_from_templates( + models_dir, [(f, t, {}) for f, t in model_files], "apps/core/models/", should_format=True + ) # Create apps/core/utils utils_dir = os.path.join(apps_core_dir, "utils") - os.makedirs(utils_dir, exist_ok=True) utils_files = [ ("__init__.py", "unified/apps/core/utils/__init__.py.j2"), ("responses.py", "unified/apps/core/utils/responses.py.j2"), ] - for filename, template in utils_files: - filepath = os.path.join(utils_dir, filename) - content = template_engine.render_template(template, {}) - create_file_with_content(filepath, content, f"Created apps/core/utils/{filename}", should_format=True) + os.makedirs(utils_dir, exist_ok=True) + create_files_from_templates( + utils_dir, [(f, t, {}) for f, t in utils_files], "apps/core/utils/", should_format=True + ) # Create apps/core/permissions and middleware - for subdir in ["permissions", "middleware"]: - subdir_path = os.path.join(apps_core_dir, subdir) - self._ensure_directory_with_init(subdir_path, f"Created apps/core/{subdir}/__init__.py") + self._create_subdirectories_with_init(apps_core_dir, ["permissions", "middleware"], "apps/core/") # Create apps/api apps_api_dir = os.path.join(apps_dir, "api") - self._ensure_directory_with_init(apps_api_dir, "Created apps/api/__init__.py") - - # Create apps/api/apps.py - apps_py_path = os.path.join(apps_api_dir, "apps.py") - content = template_engine.render_template("base/apps.j2", {"app_name": "apps.api"}) - create_file_with_content(apps_py_path, content, "Created apps/api/apps.py", should_format=True) + create_directory_with_init(apps_api_dir, "Created apps/api/__init__.py") + self._create_apps_py(apps_api_dir, "apps.api", "Created apps/api/apps.py", should_format=True) # Create apps/api subdirectories - api_subdirs = ["models", "serializers", "views", "services", "tests", "urls", "admin"] - for subdir in api_subdirs: - subdir_path = os.path.join(apps_api_dir, subdir) - self._ensure_directory_with_init(subdir_path, f"Created apps/api/{subdir}/__init__.py") + self._create_subdirectories_with_init( + apps_api_dir, ["models", "serializers", "views", "services", "tests", "urls", "admin"], "apps/api/" + ) return True diff --git a/djinit/scripts/input_handler.py b/djinit/scripts/input_handler.py index 01ecd6b..c6645ed 100644 --- a/djinit/scripts/input_handler.py +++ b/djinit/scripts/input_handler.py @@ -13,6 +13,7 @@ from typing import Callable, Tuple from djinit.scripts.console_ui import UIColors, UIFormatter, console from djinit.scripts.name_validator import validate_app_name, validate_project_name +from djinit.utils import get_package_name class CICDOption(Enum): @@ -69,7 +70,7 @@ class InputCollector: MAX_ATTEMPTS = 3 def __init__(self): - self.char_reader = CharReader() + pass def get_validated_input( self, prompt: str, validator: Callable[[str], Tuple[bool, str]], input_type: str, allow_empty: bool = False @@ -204,10 +205,6 @@ class InputCollector: console.print(f" [{UIColors.SUCCESS}]{option.key}[/{UIColors.SUCCESS}] {option.description}") console.print() -<<<<<<< HEAD -======= - # Get valid keys from CICDOption enum ->>>>>>> origin/main valid_keys = [option.key for option in CICDOption] choice = CharReader.get_cicd_choice( @@ -216,24 +213,12 @@ class InputCollector: default=CICDOption.NONE.key, ) -<<<<<<< HEAD choice_map = { CICDOption.BOTH.key: (True, True), CICDOption.GITHUB.key: (True, False), CICDOption.GITLAB.key: (False, True), } return choice_map.get(choice, (False, False)) -======= - # Parse choice - if choice == CICDOption.BOTH.key: - return True, True - elif choice == CICDOption.GITHUB.key: - return True, False - elif choice == CICDOption.GITLAB.key: - return False, True - else: # Default to none (should not happen due to validation, but kept as fallback) - return False, False ->>>>>>> origin/main def get_nested_apps_config(self) -> Tuple[bool, str | None]: UIFormatter.print_separator() @@ -242,13 +227,7 @@ class InputCollector: ) console.print() -<<<<<<< HEAD choice = CharReader.get_yes_no(f"[{UIColors.HIGHLIGHT}]Nested Django apps? (y/N):[/{UIColors.HIGHLIGHT}]") -======= - choice = CharReader.get_yes_no( - f"[{UIColors.HIGHLIGHT}]Nested Django apps? (y/N):[/{UIColors.HIGHLIGHT}]", default="n" - ) ->>>>>>> origin/main if choice != "y": return False, None @@ -268,16 +247,39 @@ class InputCollector: console.print(f" [{UIColors.SUCCESS}]N[/{UIColors.SUCCESS}] Use individual database parameters") console.print() -<<<<<<< HEAD return CharReader.get_yes_no(f"[{UIColors.HIGHLIGHT}]Use DATABASE_URL? (Y/n):[/{UIColors.HIGHLIGHT}]") == "y" -======= - choice = CharReader.get_yes_no( - f"[{UIColors.HIGHLIGHT}]Use DATABASE_URL? (Y/n):[/{UIColors.HIGHLIGHT}]", default="y" + + def _get_structure_metadata( + self, project_dir: str, predefined: bool = False, unified: bool = False + ) -> Tuple[str, str, list[str], dict]: + """Helper method to get metadata for predefined/unified structures.""" + project_name = project_dir + app_names: list[str] = [] + + # Ask for workflows so shared templates for CI/CD can be added + use_github, use_gitlab = self.get_cicd_choice() + + # Ask database configuration + use_database_url = self.get_database_config_choice() + + # Default package_name to "backend" if project_dir is "." or empty + package_name = get_package_name(project_dir) + + project_module_name = "config" if predefined else "core" if unified else None + + metadata = ProjectMetadata( + package_name=package_name, + use_github_actions=use_github, + use_gitlab_ci=use_gitlab, + nested_apps=True, + nested_dir="apps", + use_database_url=use_database_url, + predefined_structure=predefined, + unified_structure=unified, + project_module_name=project_module_name, ) - # Return True if choice is 'y', False if 'n' - return choice == "y" ->>>>>>> origin/main + return project_name, "", app_names, metadata.to_dict() class CharReader: @@ -290,7 +292,6 @@ class CharReader: return CharReader._get_char_unix() @staticmethod -<<<<<<< HEAD def _is_enter_key(response: str) -> bool: """Check if response represents Enter key or control character.""" return not response or response in ("\r", "\n") or ord(response) < 32 @@ -323,30 +324,10 @@ class CharReader: # Handle Enter key (default) if CharReader._is_enter_key(response): return CharReader._handle_default(default) -======= - def get_yes_no(prompt: str, default: str = "n") -> str: - max_attempts = 3 - attempt = 0 - - while attempt < max_attempts: - try: - console.print(f"{prompt} ", end="") - sys.stdout.flush() - - reader = CharReader() - response = reader.get_char() - - # Handle Enter key (default) - in raw mode, Enter is '\r' (carriage return) - # Also handle empty string or other control characters as Enter - if not response or response == "\r" or response == "\n" or ord(response) < 32: - console.print(default.upper()) - return default.lower() ->>>>>>> origin/main response = response.lower() # Validate response -<<<<<<< HEAD if response in valid_keys_lower: console.print(response.upper()) return response @@ -359,30 +340,11 @@ class CharReader: if attempt < CharReader.MAX_ATTEMPTS: console.print( f"[{UIColors.MUTED}]Please try again ({attempt}/{CharReader.MAX_ATTEMPTS}).[/{UIColors.MUTED}]" -======= - if response in ("y", "n"): - console.print(response.upper()) - return response - - # Invalid input - show error (only show if it's a printable character) - console.print() - if response.isprintable(): - UIFormatter.print_error(f"Invalid input '{response}'. Please enter 'y' or 'n'.") - attempt += 1 - if attempt < max_attempts: - console.print( - f"[{UIColors.MUTED}]Please try again ({attempt}/{max_attempts}).[/{UIColors.MUTED}]" ->>>>>>> origin/main ) console.print() else: # Control character entered, treat as Enter -<<<<<<< HEAD return CharReader._handle_default(default) -======= - console.print(default.upper()) - return default.lower() ->>>>>>> origin/main except KeyboardInterrupt: console.print() @@ -395,7 +357,6 @@ class CharReader: return default.lower() @staticmethod -<<<<<<< HEAD def get_yes_no(prompt: str, default: str = None) -> str: # Auto-detect default from prompt format (capital letter indicates default) if default is None: @@ -423,70 +384,16 @@ class CharReader: default=default, error_message=f"Invalid input '{{response}}'. Please enter one of: {valid_keys_str}.", ) -======= - def get_cicd_choice(prompt: str, valid_keys: list[str], default: str = "n") -> str: - """ - Get CI/CD choice input with validation and retries. - Args: - prompt: The prompt message to display - valid_keys: List of valid key characters (e.g., ['g', 'l', 'b', 'n']) - default: Default value if Enter is pressed - - Returns: - Valid key (lowercase) - """ - max_attempts = 3 - attempt = 0 - valid_keys_lower = [k.lower() for k in valid_keys] - - while attempt < max_attempts: - try: - console.print(f"{prompt} ", end="") - sys.stdout.flush() - - reader = CharReader() - response = reader.get_char() - - # Handle Enter key (default) - in raw mode, Enter is '\r' (carriage return) - # Also handle empty string or other control characters as Enter - if not response or response == "\r" or response == "\n" or ord(response) < 32: - console.print(default.upper()) - return default.lower() - - response = response.lower() - - # Validate response - if response in valid_keys_lower: - console.print(response.upper()) - return response - - # Invalid input - show error (only show if it's a printable character) - console.print() - if response.isprintable(): - valid_keys_str = ", ".join(f"'{k}'" for k in valid_keys) - UIFormatter.print_error(f"Invalid input '{response}'. Please enter one of: {valid_keys_str}.") - attempt += 1 - if attempt < max_attempts: - console.print( - f"[{UIColors.MUTED}]Please try again ({attempt}/{max_attempts}).[/{UIColors.MUTED}]" - ) - console.print() - else: - # Control character entered, treat as Enter - console.print(default.upper()) - return default.lower() - - except KeyboardInterrupt: - console.print() - UIFormatter.print_info("\nOperation cancelled by user.") - raise - - # Max attempts reached - use default - console.print() - UIFormatter.print_warning(f"Maximum attempts reached. Using default: '{default.upper()}'.") - return default.lower() ->>>>>>> origin/main + @staticmethod + def get_structure_choice() -> str: + """Get structure type choice without requiring Enter key.""" + return CharReader._get_validated_char_input( + prompt=f"[{UIColors.HIGHLIGHT}]Choose structure type (1/2/3) [default: 1]:[/{UIColors.HIGHLIGHT}]", + valid_keys=["1", "2", "3"], + default="1", + error_message="Invalid input '{response}'. Please enter '1', '2', or '3'.", + ) @staticmethod def _get_char_windows() -> str: @@ -497,13 +404,32 @@ class CharReader: for encoding in encodings: try: ch = msvcrt.getch() - return ch.decode(encoding).lower() + # Detect Ctrl+C (ETX character \x03) + if isinstance(ch, bytes): + if ch == b"\x03": + raise KeyboardInterrupt + decoded = ch.decode(encoding).lower() + else: + if ord(ch) == 3: # Ctrl+C + raise KeyboardInterrupt + decoded = ch.lower() + return decoded except (UnicodeDecodeError, AttributeError): continue # Fallback try: - return msvcrt.getch().lower() + ch = msvcrt.getch() + if isinstance(ch, bytes): + if ch == b"\x03": + raise KeyboardInterrupt + return ch.decode("utf-8", errors="ignore").lower() + else: + if ord(ch) == 3: # Ctrl+C + raise KeyboardInterrupt + return ch.lower() + except KeyboardInterrupt: + raise except Exception: return "" @@ -514,201 +440,101 @@ class CharReader: try: tty.setraw(sys.stdin.fileno()) ch = sys.stdin.read(1) + # Detect Ctrl+C (ETX character \x03) + if ord(ch) == 3: # Ctrl+C + raise KeyboardInterrupt return ch.lower() finally: termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) def get_user_input() -> Tuple[str, str, str, list, dict]: - collector = InputCollector() - console.print() + try: + collector = InputCollector() + console.print() - # Pre-step: Ask for structure type first - UIFormatter.print_separator() -<<<<<<< HEAD - console.print(f"\n[{UIColors.INFO}]Choose Structure Type[/{UIColors.INFO}]\n") - console.print(f"[{UIColors.MUTED}]1. Standard structure (default Django layout)[/{UIColors.MUTED}]") - console.print(f"[{UIColors.MUTED}]2. Predefined structure (apps/users, apps/core, api/)[/{UIColors.MUTED}]") - console.print(f"[{UIColors.MUTED}]3. Unified structure (core/, apps/core, apps/api)[/{UIColors.MUTED}]") -======= - console.print(f"\n[{UIColors.INFO}]Step 1: Project Setup[/{UIColors.INFO}]\n") + # Pre-step: Ask for structure type first + UIFormatter.print_separator() + console.print(f"\n[{UIColors.INFO}]Choose Structure Type[/{UIColors.INFO}]\n") + console.print(f"[{UIColors.MUTED}]1. Standard structure (default Django layout)[/{UIColors.MUTED}]") + console.print(f"[{UIColors.MUTED}]2. Predefined structure (apps/users, apps/core, api/)[/{UIColors.MUTED}]") + console.print(f"[{UIColors.MUTED}]3. Unified structure (core/, apps/core, apps/api)[/{UIColors.MUTED}]") + console.print() - console.print(f"[{UIColors.HIGHLIGHT}]Project Directory[/{UIColors.HIGHLIGHT}]") - console.print(f"[{UIColors.MUTED}]Where your project files will be created[/{UIColors.MUTED}]") - console.print(f"[{UIColors.MUTED}]Press Enter or enter '.' to create in current directory[/{UIColors.MUTED}]") + structure_choice = CharReader.get_structure_choice() - # Get project directory with special handling for '.' and empty input - attempt = 0 - project_dir = None + use_predefined = structure_choice == "2" + use_unified = structure_choice == "3" - while attempt < collector.MAX_ATTEMPTS and project_dir is None: - try: - user_input = console.input( - f"[{UIColors.HIGHLIGHT}]Enter project directory name:[/{UIColors.HIGHLIGHT}] " - ).strip() + if use_predefined or use_unified: + UIFormatter.print_separator() + console.print(f"\n[{UIColors.INFO}]Step 1: Project Setup[/{UIColors.INFO}]\n") + console.print( + f"[{UIColors.MUTED}]Press Enter or enter '.' to create in current directory[/{UIColors.MUTED}]" + ) + project_dir = collector._get_project_directory() + if project_dir is None: + UIFormatter.print_error("Maximum attempts reached for project directory name. Exiting.") + sys.exit(1) - # Handle empty input or '.' - use current directory - if not user_input or user_input == ".": - project_dir = "." - UIFormatter.print_info(f"Creating project in current directory: {os.getcwd()}") - break + project_name, primary_app, app_names, metadata_dict = collector._get_structure_metadata( + project_dir, predefined=use_predefined, unified=use_unified + ) - # Validate the input - is_valid, error_msg = validate_project_name(user_input) - if not is_valid: - UIFormatter.print_error(error_msg) - attempt += 1 - collector._show_retry_message(attempt) - continue + return project_dir, project_name, primary_app, app_names, metadata_dict - # Check if directory exists and ask for alternative - if os.path.exists(user_input): - UIFormatter.print_error(f"Directory '{user_input}' already exists.") - UIFormatter.print_info( - "Please choose a different project directory name (or press Enter for current directory)." - ) - attempt += 1 - collector._show_retry_message(attempt) - continue - - project_dir = user_input - break - - except KeyboardInterrupt: - UIFormatter.print_info("\nSetup cancelled by user.") - sys.exit(0) - - if project_dir is None: - UIFormatter.print_error("Maximum attempts reached for project directory name. Exiting.") - sys.exit(1) - ->>>>>>> origin/main - console.print() - - structure_choice = console.input( - f"[{UIColors.HIGHLIGHT}]Choose structure type (1/2/3) [default: 1]:[/{UIColors.HIGHLIGHT}] " - ).strip() - if not structure_choice: - structure_choice = "1" - - use_predefined = structure_choice == "2" - use_unified = structure_choice == "3" - - if use_predefined: + # Section 1: Project Setup (standard flow) UIFormatter.print_separator() console.print(f"\n[{UIColors.INFO}]Step 1: Project Setup[/{UIColors.INFO}]\n") console.print(f"[{UIColors.MUTED}]Press Enter or enter '.' to create in current directory[/{UIColors.MUTED}]") + project_dir = collector._get_project_directory() + if project_dir is None: UIFormatter.print_error("Maximum attempts reached for project directory name. Exiting.") sys.exit(1) - # Minimal inputs: project_dir only, then ask for CI/CD choice. - project_name = project_dir - app_names: list[str] = [] + console.print() - # Ask for workflows so shared templates for CI/CD can be added - use_github, use_gitlab = collector.get_cicd_choice() - - # Ask database configuration for predefined structure as well - use_database_url = collector.get_database_config_choice() - # Default package_name to "backend" if project_dir is "." or empty - package_name = "backend" if project_dir == "." or not project_dir else project_dir - metadata = ProjectMetadata( - package_name=package_name, - use_github_actions=use_github, - use_gitlab_ci=use_gitlab, - nested_apps=True, - nested_dir="apps", - use_database_url=use_database_url, - predefined_structure=True, - project_module_name="config", + console.print(f"[{UIColors.MUTED}]Common names: config, core, settings[/{UIColors.MUTED}]") + project_name = collector.get_validated_input( + "Enter Django project name", validate_project_name, "Django project name" ) + console.print() - return project_dir, project_name, "", app_names, metadata.to_dict() - - if use_unified: + # Section 2: Django Apps + console.print() UIFormatter.print_separator() - console.print(f"\n[{UIColors.INFO}]Step 1: Project Setup[/{UIColors.INFO}]\n") - console.print(f"[{UIColors.MUTED}]Press Enter or enter '.' to create in current directory[/{UIColors.MUTED}]") - project_dir = collector._get_project_directory() - if project_dir is None: - UIFormatter.print_error("Maximum attempts reached for project directory name. Exiting.") - sys.exit(1) + console.print(f"\n[{UIColors.INFO}]Step 2: Django Apps[/{UIColors.INFO}]\n") + nested, nested_dir = collector.get_nested_apps_config() + app_names = collector.get_app_names() + console.print() - # Minimal inputs: project_dir only, then ask for CI/CD choice. - project_name = project_dir - app_names: list[str] = [] - - # Ask for workflows so shared templates for CI/CD can be added + # Section 3: CI/CD Pipeline use_github, use_gitlab = collector.get_cicd_choice() - # Ask database configuration for unified structure as well + # Section 4: Database Configuration use_database_url = collector.get_database_config_choice() + + # Create metadata # Default package_name to "backend" if project_dir is "." or empty - package_name = "backend" if project_dir == "." or not project_dir else project_dir + package_name = get_package_name(project_dir) metadata = ProjectMetadata( package_name=package_name, use_github_actions=use_github, use_gitlab_ci=use_gitlab, - nested_apps=True, - nested_dir="apps", + nested_apps=nested, + nested_dir=nested_dir, use_database_url=use_database_url, - unified_structure=True, - project_module_name="core", ) - return project_dir, project_name, "", app_names, metadata.to_dict() + console.print() - # Section 1: Project Setup (standard flow) - UIFormatter.print_separator() - console.print(f"\n[{UIColors.INFO}]Step 1: Project Setup[/{UIColors.INFO}]\n") - console.print(f"[{UIColors.MUTED}]Press Enter or enter '.' to create in current directory[/{UIColors.MUTED}]") - - project_dir = collector._get_project_directory() - - if project_dir is None: - UIFormatter.print_error("Maximum attempts reached for project directory name. Exiting.") - sys.exit(1) - - console.print() - - console.print(f"[{UIColors.MUTED}]Common names: config, core, settings[/{UIColors.MUTED}]") - project_name = collector.get_validated_input( - "Enter Django project name", validate_project_name, "Django project name" - ) - console.print() - - # Section 2: Django Apps - console.print() - UIFormatter.print_separator() - console.print(f"\n[{UIColors.INFO}]Step 2: Django Apps[/{UIColors.INFO}]\n") - nested, nested_dir = collector.get_nested_apps_config() - app_names = collector.get_app_names() - console.print() - - # Section 3: CI/CD Pipeline - use_github, use_gitlab = collector.get_cicd_choice() - - # Section 4: Database Configuration - use_database_url = collector.get_database_config_choice() - - # Create metadata - # Default package_name to "backend" if project_dir is "." or empty - package_name = "backend" if project_dir == "." or not project_dir else project_dir - metadata = ProjectMetadata( - package_name=package_name, - use_github_actions=use_github, - use_gitlab_ci=use_gitlab, - nested_apps=nested, - nested_dir=nested_dir, - use_database_url=use_database_url, - ) - - console.print() - - # Return in legacy format for backward compatibility - return project_dir, project_name, app_names[0], app_names, metadata.to_dict() + # Return in legacy format for backward compatibility + return project_dir, project_name, app_names[0], app_names, metadata.to_dict() + except KeyboardInterrupt: + UIFormatter.print_info("\nSetup cancelled by user.") + sys.exit(0) def confirm_setup(project_dir: str, project_name: str, app_names: list, metadata: dict) -> bool: @@ -734,13 +560,7 @@ def confirm_setup(project_dir: str, project_name: str, app_names: list, metadata console.print() try: -<<<<<<< HEAD response = CharReader.get_yes_no(f"[{UIColors.WARNING}]Proceed with setup? (y/N):[/{UIColors.WARNING}]") -======= - response = CharReader.get_yes_no( - f"[{UIColors.WARNING}]Proceed with setup? (y/N):[/{UIColors.WARNING}]", default="n" - ) ->>>>>>> origin/main return response == "y" except KeyboardInterrupt: diff --git a/djinit/scripts/project.py b/djinit/scripts/project.py index da7a9a2..bddfb99 100644 --- a/djinit/scripts/project.py +++ b/djinit/scripts/project.py @@ -9,6 +9,7 @@ from djinit.scripts.console_ui import UIFormatter from djinit.scripts.django_helper import DjangoHelper from djinit.utils import ( calculate_app_module_paths, + create_directory_with_init, extract_existing_apps, get_base_settings_path, insert_apps_into_user_defined_apps, @@ -22,23 +23,14 @@ class ProjectManager: self.project_name = project_name self.app_names = app_names self.metadata = metadata -<<<<<<< HEAD self.project_root = os.getcwd() if project_dir == "." else os.path.join(os.getcwd(), project_dir) # Support custom module name for the Django config package (e.g., 'config') # Fall back to project_name if key present but None self.module_name = metadata.get("project_module_name") or self.project_name -======= - # Handle '.' for current directory - if project_dir == ".": - self.project_root = os.getcwd() - else: - self.project_root = os.path.join(os.getcwd(), project_dir) ->>>>>>> origin/main def create_project(self) -> bool: os.makedirs(self.project_root, exist_ok=True) -<<<<<<< HEAD unified = self.metadata.get("unified_structure", False) success = DjangoHelper.startproject(self.module_name, self.project_root, unified=unified) if success: @@ -46,29 +38,19 @@ class ProjectManager: else: UIFormatter.print_error(f"Failed to create Django project '{self.project_name}'") return success -======= - # Create Django project inside the directory - # Use 'python -m django' instead of 'django-admin' for better compatibility - subprocess.run( - [sys.executable, "-m", "django", "startproject", self.project_name, self.project_root], - check=True, - ) - UIFormatter.print_success(f"Django project '{self.project_name}' created successfully!") - return True ->>>>>>> origin/main + def _get_apps_base_dir(self) -> str: + """Get the base directory for apps, considering nested structure.""" + apps_base_dir = self.project_root + if self.metadata.get("nested_apps") and self.metadata.get("nested_dir"): + apps_base_dir = os.path.join(self.project_root, self.metadata.get("nested_dir")) + return apps_base_dir def create_apps(self) -> bool: - apps_base_dir = self.project_root - nested = bool(self.metadata.get("nested_apps")) - nested_dir_name = self.metadata.get("nested_dir") if nested else None + apps_base_dir = self._get_apps_base_dir() - if nested and nested_dir_name: - apps_base_dir = os.path.join(self.project_root, nested_dir_name) - os.makedirs(apps_base_dir, exist_ok=True) - init_file = os.path.join(apps_base_dir, "__init__.py") - if not os.path.exists(init_file): - open(init_file, "a").close() + if apps_base_dir != self.project_root: + create_directory_with_init(apps_base_dir, f"Created {os.path.basename(apps_base_dir)}/__init__.py") for app_name in self.app_names: success = DjangoHelper.startapp(app_name, apps_base_dir) @@ -78,12 +60,12 @@ class ProjectManager: UIFormatter.print_success(f"Django app '{app_name}' created successfully!") # Add all apps to USER_DEFINED_APPS in settings - if not self._add_apps_to_settings(): + if not self.add_apps_to_settings(): return False return True - def _add_apps_to_settings(self) -> bool: + def add_apps_to_settings(self) -> bool: """Add all apps to USER_DEFINED_APPS in base.py settings file.""" base_settings_path = get_base_settings_path(self.project_root, self.module_name) @@ -116,10 +98,6 @@ class ProjectManager: UIFormatter.print_success(f"Added apps to USER_DEFINED_APPS: {added_apps_str}") return True - # Public wrapper to allow external callers to add apps based on current state - def add_apps_to_settings(self) -> bool: - return self._add_apps_to_settings() - def validate_project_structure(self) -> bool: required_files = [ os.path.join(self.project_root, "manage.py"), @@ -133,9 +111,7 @@ class ProjectManager: os.path.join(self.project_root, self.module_name, "asgi.py"), ] - apps_base_dir = self.project_root - if self.metadata.get("nested_apps") and self.metadata.get("nested_dir"): - apps_base_dir = os.path.join(self.project_root, self.metadata.get("nested_dir")) + apps_base_dir = self._get_apps_base_dir() # In predefined or unified structure, apps layout differs; skip strict per-app validation if not self.metadata.get("predefined_structure") and not self.metadata.get("unified_structure"): diff --git a/djinit/templates/predefined/api/urls.j2 b/djinit/templates/predefined/api/urls.j2 index 69585ec..51e3dfb 100644 --- a/djinit/templates/predefined/api/urls.j2 +++ b/djinit/templates/predefined/api/urls.j2 @@ -5,5 +5,3 @@ app_name = "api" urlpatterns = [ path("v1/", include("api.v1.urls")), ] - - diff --git a/djinit/templates/predefined/api/v1/urls.j2 b/djinit/templates/predefined/api/v1/urls.j2 index 40ebdea..7a0d9a8 100644 --- a/djinit/templates/predefined/api/v1/urls.j2 +++ b/djinit/templates/predefined/api/v1/urls.j2 @@ -5,5 +5,3 @@ app_name = "v1" urlpatterns = [ path("users/", include("apps.users.urls")), ] - - diff --git a/djinit/templates/predefined/apps/generic/models.j2 b/djinit/templates/predefined/apps/generic/models.j2 index 8067069..9f99a46 100644 --- a/djinit/templates/predefined/apps/generic/models.j2 +++ b/djinit/templates/predefined/apps/generic/models.j2 @@ -6,5 +6,3 @@ class {{ model_class_name }}(models.Model): # Add fields here pass - - diff --git a/djinit/templates/predefined/apps/generic/serializers.j2 b/djinit/templates/predefined/apps/generic/serializers.j2 index ef60bcb..86f1b45 100644 --- a/djinit/templates/predefined/apps/generic/serializers.j2 +++ b/djinit/templates/predefined/apps/generic/serializers.j2 @@ -7,5 +7,3 @@ class {{ model_class_name }}Serializer(serializers.ModelSerializer): class Meta: model = {{ model_class_name }} fields = "__all__" - - diff --git a/djinit/templates/predefined/apps/generic/services.j2 b/djinit/templates/predefined/apps/generic/services.j2 index eb75f90..2203fb8 100644 --- a/djinit/templates/predefined/apps/generic/services.j2 +++ b/djinit/templates/predefined/apps/generic/services.j2 @@ -8,5 +8,3 @@ def get_{{ app_name }}_by_id(object_id: int) -> Optional[{{ model_class_name }}] return {{ model_class_name }}.objects.get(id=object_id) except {{ model_class_name }}.DoesNotExist: return None - - diff --git a/djinit/templates/predefined/apps/generic/tests.j2 b/djinit/templates/predefined/apps/generic/tests.j2 index 910bad8..41fc906 100644 --- a/djinit/templates/predefined/apps/generic/tests.j2 +++ b/djinit/templates/predefined/apps/generic/tests.j2 @@ -10,5 +10,3 @@ def test_{{ app_name }}_list_empty(): response = client.get(url) assert response.status_code == 200 assert response.json() == [] - - diff --git a/djinit/templates/predefined/apps/generic/urls.j2 b/djinit/templates/predefined/apps/generic/urls.j2 index 68578e9..caec1a6 100644 --- a/djinit/templates/predefined/apps/generic/urls.j2 +++ b/djinit/templates/predefined/apps/generic/urls.j2 @@ -12,5 +12,3 @@ router.register(r"{{ app_name }}", {{ model_class_name }}ViewSet, basename="{{ a urlpatterns = [ path("", include(router.urls)), ] - - diff --git a/djinit/templates/predefined/apps/generic/views.j2 b/djinit/templates/predefined/apps/generic/views.j2 index af3545c..5976564 100644 --- a/djinit/templates/predefined/apps/generic/views.j2 +++ b/djinit/templates/predefined/apps/generic/views.j2 @@ -7,5 +7,3 @@ from {{ app_module }}.serializers.{{ app_name }}_serializer import {{ model_clas class {{ model_class_name }}ViewSet(viewsets.ModelViewSet): queryset = {{ model_class_name }}.objects.all() serializer_class = {{ model_class_name }}Serializer - - diff --git a/djinit/templates/predefined/apps/users/models.j2 b/djinit/templates/predefined/apps/users/models.j2 index 556619b..f6236bd 100644 --- a/djinit/templates/predefined/apps/users/models.j2 +++ b/djinit/templates/predefined/apps/users/models.j2 @@ -7,5 +7,3 @@ class User(AbstractUser): # Add custom fields here if needed pass - - diff --git a/djinit/templates/predefined/apps/users/serializers.j2 b/djinit/templates/predefined/apps/users/serializers.j2 index ca7a68e..d7a4f40 100644 --- a/djinit/templates/predefined/apps/users/serializers.j2 +++ b/djinit/templates/predefined/apps/users/serializers.j2 @@ -7,5 +7,3 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = User fields = ["id", "username", "email", "first_name", "last_name"] - - diff --git a/djinit/templates/predefined/apps/users/services.j2 b/djinit/templates/predefined/apps/users/services.j2 index 5888378..036060b 100644 --- a/djinit/templates/predefined/apps/users/services.j2 +++ b/djinit/templates/predefined/apps/users/services.j2 @@ -8,5 +8,3 @@ def get_user_by_id(user_id: int) -> Optional[User]: return User.objects.get(id=user_id) except User.DoesNotExist: return None - - diff --git a/djinit/templates/predefined/apps/users/tests.j2 b/djinit/templates/predefined/apps/users/tests.j2 index 3bdb897..1bde766 100644 --- a/djinit/templates/predefined/apps/users/tests.j2 +++ b/djinit/templates/predefined/apps/users/tests.j2 @@ -10,5 +10,3 @@ def test_users_list_empty(): response = client.get(url) assert response.status_code == 200 assert response.json() == [] - - diff --git a/djinit/templates/predefined/apps/users/urls.j2 b/djinit/templates/predefined/apps/users/urls.j2 index 98e7d7a..08a6fb4 100644 --- a/djinit/templates/predefined/apps/users/urls.j2 +++ b/djinit/templates/predefined/apps/users/urls.j2 @@ -12,5 +12,3 @@ router.register(r"users", UserViewSet, basename="users") urlpatterns = [ path("", include(router.urls)), ] - - diff --git a/djinit/templates/predefined/apps/users/views.j2 b/djinit/templates/predefined/apps/users/views.j2 index 660fcc7..8db2347 100644 --- a/djinit/templates/predefined/apps/users/views.j2 +++ b/djinit/templates/predefined/apps/users/views.j2 @@ -11,5 +11,3 @@ class UserViewSet(viewsets.ReadOnlyModelViewSet): def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) - - diff --git a/djinit/templates/predefined/core/exceptions.j2 b/djinit/templates/predefined/core/exceptions.j2 index 3a2f7fa..bb426d3 100644 --- a/djinit/templates/predefined/core/exceptions.j2 +++ b/djinit/templates/predefined/core/exceptions.j2 @@ -3,5 +3,3 @@ class ApplicationError(Exception): def __init__(self, message: str): super().__init__(message) - - diff --git a/djinit/templates/predefined/core/middleware/request_logger.j2 b/djinit/templates/predefined/core/middleware/request_logger.j2 index 00ed0fe..d922ad1 100644 --- a/djinit/templates/predefined/core/middleware/request_logger.j2 +++ b/djinit/templates/predefined/core/middleware/request_logger.j2 @@ -12,5 +12,3 @@ class RequestLoggerMiddleware: logger.info("%s %s", request.method, request.path) response = self.get_response(request) return response - - diff --git a/djinit/templates/predefined/core/mixins/timestamped_model.j2 b/djinit/templates/predefined/core/mixins/timestamped_model.j2 index 3d27df1..6b2e70e 100644 --- a/djinit/templates/predefined/core/mixins/timestamped_model.j2 +++ b/djinit/templates/predefined/core/mixins/timestamped_model.j2 @@ -7,5 +7,3 @@ class TimestampedModel(models.Model): class Meta: abstract = True - - diff --git a/djinit/templates/predefined/core/utils/responses.j2 b/djinit/templates/predefined/core/utils/responses.j2 index fc6bf7a..bae79a1 100644 --- a/djinit/templates/predefined/core/utils/responses.j2 +++ b/djinit/templates/predefined/core/utils/responses.j2 @@ -7,5 +7,3 @@ def success(data=None, status_code=200): def error(message, status_code=400): return Response({"success": False, "message": message}, status=status_code) - - diff --git a/djinit/templates/project/urls_with_api.j2 b/djinit/templates/project/urls_with_api.j2 index e236407..ee6843f 100644 --- a/djinit/templates/project/urls_with_api.j2 +++ b/djinit/templates/project/urls_with_api.j2 @@ -26,4 +26,4 @@ if settings.DEBUG: urlpatterns += [ path("schema/", SpectacularAPIView.as_view(), name="schema"), path("docs/", SpectacularSwaggerView.as_view(url_name="schema")), - ] \ No newline at end of file + ] diff --git a/djinit/templates/unified/apps/core/apps.j2 b/djinit/templates/unified/apps/core/apps.j2 index e59efb0..1d202ca 100644 --- a/djinit/templates/unified/apps/core/apps.j2 +++ b/djinit/templates/unified/apps/core/apps.j2 @@ -6,4 +6,3 @@ from django.apps import AppConfig class CoreConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "apps.core" - diff --git a/djinit/templates/unified/apps/core/models/__init__.py.j2 b/djinit/templates/unified/apps/core/models/__init__.py.j2 index 71067ec..89b2eb6 100644 --- a/djinit/templates/unified/apps/core/models/__init__.py.j2 +++ b/djinit/templates/unified/apps/core/models/__init__.py.j2 @@ -2,4 +2,3 @@ from .base import AbstractBaseModel from .mixins import TimestampedModel __all__ = ["AbstractBaseModel", "TimestampedModel"] - diff --git a/djinit/templates/unified/apps/core/models/base.py.j2 b/djinit/templates/unified/apps/core/models/base.py.j2 index 95ed131..a3970f9 100644 --- a/djinit/templates/unified/apps/core/models/base.py.j2 +++ b/djinit/templates/unified/apps/core/models/base.py.j2 @@ -11,4 +11,3 @@ class AbstractBaseModel(models.Model): class Meta: abstract = True - diff --git a/djinit/templates/unified/apps/core/models/mixins.py.j2 b/djinit/templates/unified/apps/core/models/mixins.py.j2 index c456739..fe40544 100644 --- a/djinit/templates/unified/apps/core/models/mixins.py.j2 +++ b/djinit/templates/unified/apps/core/models/mixins.py.j2 @@ -11,4 +11,3 @@ class TimestampedModel(models.Model): class Meta: abstract = True - diff --git a/djinit/templates/unified/apps/core/utils/__init__.py.j2 b/djinit/templates/unified/apps/core/utils/__init__.py.j2 index 56fdd17..155f5b4 100644 --- a/djinit/templates/unified/apps/core/utils/__init__.py.j2 +++ b/djinit/templates/unified/apps/core/utils/__init__.py.j2 @@ -1,4 +1,3 @@ from .responses import success, error __all__ = ["success", "error"] - diff --git a/djinit/templates/unified/apps/core/utils/responses.py.j2 b/djinit/templates/unified/apps/core/utils/responses.py.j2 index 7f32fda..929c2f6 100644 --- a/djinit/templates/unified/apps/core/utils/responses.py.j2 +++ b/djinit/templates/unified/apps/core/utils/responses.py.j2 @@ -11,4 +11,3 @@ def success(data=None, status_code=200): def error(message, status_code=400): """Return an error response.""" return Response({"success": False, "message": message}, status=status_code) - diff --git a/djinit/utils.py b/djinit/utils.py index a41e9a0..a11cdc5 100644 --- a/djinit/utils.py +++ b/djinit/utils.py @@ -14,10 +14,6 @@ from djinit.scripts.template_engine import template_engine def format_file(filename: str) -> None: """Format Python file using Ruff formatter.""" -<<<<<<< HEAD -======= - # Use 'python -m ruff' instead of 'ruff' for better compatibility ->>>>>>> origin/main subprocess.run([sys.executable, "-m", "ruff", "format", filename], check=False, capture_output=True) @@ -51,6 +47,38 @@ def create_init_file(directory: str, success_message: str) -> None: create_file_from_template(init_path, "shared/init.j2", {}, success_message, should_format=False) +def create_directory_with_init(dir_path: str, message: str) -> None: + """Create directory and __init__.py file.""" + os.makedirs(dir_path, exist_ok=True) + create_init_file(dir_path, message) + + +def create_files_from_templates(base_dir: str, files: list, prefix: str = "", should_format: bool = False) -> None: + """Create multiple files from templates. + + Args: + base_dir: Base directory where files will be created + files: List of tuples (filename, template_path, context) + prefix: Optional prefix for success message + should_format: Whether to format the created files + """ + for file_spec in files: + if len(file_spec) == 3: + filename, template_path, context = file_spec + else: + # Support (filename, template_path) format with empty context + filename, template_path = file_spec + context = {} + filepath = os.path.join(base_dir, filename) + message = f"Created {prefix}{filename}" if prefix else f"Created {filename}" + create_file_from_template(filepath, template_path, context, message, should_format=should_format) + + +def get_package_name(project_dir: str) -> str: + """Get package name, defaulting to 'backend' if project_dir is '.' or empty.""" + return "backend" if project_dir == "." or not project_dir else project_dir + + # change the current working dir tempporary @contextmanager def change_cwd(path: str): @@ -62,24 +90,41 @@ def change_cwd(path: str): os.chdir(original_cwd) -# get app names from USER_DEFINED_APPS section in project_name/settings/base.py -def extract_existing_apps(content: str) -> set: +def _parse_app_entry_from_line(line: str) -> str | None: + """Extract app name from a line in USER_DEFINED_APPS section.""" + line_stripped = line.strip().strip(",").strip() + for quote in ('"', "'"): + if line_stripped.startswith(quote) and line_stripped.endswith(quote): + return line_stripped.strip(quote) + return None + + +def _is_user_apps_section_start(line: str) -> bool: + """Check if line starts USER_DEFINED_APPS section.""" + return "USER_DEFINED_APPS" in line and "=" in line + + +def _iterate_user_apps_lines(content: str): + """Generator that yields lines within USER_DEFINED_APPS section.""" lines = content.split("\n") - existing_apps = set() in_user_apps = False for line in lines: - if "USER_DEFINED_APPS" in line and "=" in line: + if _is_user_apps_section_start(line): in_user_apps = True elif in_user_apps and line.strip() == "]": break elif in_user_apps: - line_stripped = line.strip().strip(",").strip() - for quote in ('"', "'"): - if line_stripped.startswith(quote) and line_stripped.endswith(quote): - existing_apps.add(line_stripped.strip(quote)) - break + yield line + +# get app names from USER_DEFINED_APPS section in project_name/settings/base.py +def extract_existing_apps(content: str) -> set: + existing_apps = set() + for line in _iterate_user_apps_lines(content): + app_name = _parse_app_entry_from_line(line) + if app_name: + existing_apps.add(app_name) return existing_apps @@ -123,7 +168,7 @@ def insert_apps_into_user_defined_apps(content: str, apps_to_add: list) -> str: for i, line in enumerate(lines): # Entering USER_DEFINED_APPS section - if "USER_DEFINED_APPS" in line and "=" in line and not apps_added: + if _is_user_apps_section_start(line) and not apps_added: in_user_apps = True # Handle closing bracket on same line @@ -166,7 +211,7 @@ def insert_apps_into_user_defined_apps(content: str, apps_to_add: list) -> str: def calculate_app_module_paths(app_names: list, metadata: dict) -> list: nested = bool(metadata.get("nested_apps")) nested_dir_name = metadata.get("nested_dir") if nested else None - return [f"{nested_dir_name}.{app_name}" if nested and nested_dir_name else app_name for app_name in app_names] + return [calculate_app_module_path(app_name, nested, nested_dir_name) for app_name in app_names] # calculate app module path for a single app @@ -234,22 +279,13 @@ def detect_nested_structure_from_settings( with open(base_settings_path) as f: base_content = f.read() - in_user_apps = False - for line in base_content.splitlines(): - if "USER_DEFINED_APPS" in line and "=" in line: - in_user_apps = True - continue - if in_user_apps and "]" in line: - break - if in_user_apps: - line_stripped = line.strip().strip(",") - if line_stripped.startswith('"') or line_stripped.startswith("'"): - app_label = line_stripped.strip("\"'") - if "." in app_label: - nested_dir_name = app_label.split(".", 1)[0] - nested_path = os.path.join(search_dir, nested_dir_name) - if os.path.isdir(nested_path): - return True, nested_dir_name, nested_path + for line in _iterate_user_apps_lines(base_content): + app_label = _parse_app_entry_from_line(line) + if app_label and "." in app_label: + nested_dir_name = app_label.split(".", 1)[0] + nested_path = os.path.join(search_dir, nested_dir_name) + if os.path.isdir(nested_path): + return True, nested_dir_name, nested_path except Exception: pass -- 2.43.0 From 84db52c90633d07a85b87a68e1b5fd3570d9f543 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Sun, 9 Nov 2025 15:17:33 +0545 Subject: [PATCH 15/15] chore: remove redundant labels from dependabot config --- .github/dependabot.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c6e4d40..c7c20e5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,9 +6,6 @@ updates: interval: "weekly" target-branch: "main" rebase-strategy: "auto" - labels: - - "dependencies" - - "github-actions" - package-ecosystem: "pip" directory: "/" @@ -16,7 +13,4 @@ updates: interval: "weekly" target-branch: "main" rebase-strategy: "auto" - labels: - - "dependencies" - - "python" open-pull-requests-limit: 10 -- 2.43.0