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/.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 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/cli.py b/djinit/cli.py index bf648fe..53dc429 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,41 +19,66 @@ 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), - ("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 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 + + # 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("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)) + 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 @@ -97,12 +119,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..f1e9754 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 + 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 02b69a2..a9111aa 100644 --- a/djinit/scripts/app_generator.py +++ b/djinit/scripts/app_generator.py @@ -4,13 +4,22 @@ 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, + create_directory_with_init, + create_file_from_template, + create_init_file, + 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: @@ -18,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(): @@ -36,43 +46,42 @@ 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) + # 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: + _, _, 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: - # Detect project 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 - manage_py_path = os.path.join(self.current_dir, "manage.py") - if not os.path.exists(manage_py_path): + # Verify manage.py exists in project root + if not os.path.exists(self.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 - ) + # 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")) - UIFormatter.print_success(f"Created Django app '{self.app_name}' in {apps_base_dir}") - return True + # 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}") + 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 +94,17 @@ class AppManager: with open(base_settings_path) as f: content = f.read() - # Detect project structure to determine the correct app module path - is_nested, nested_dir, apps_base_dir = self._detect_project_structure() - - # Determine the correct module path for the app - if is_nested and nested_dir: - app_module_path = f"{nested_dir}.{self.app_name}" + # Get the app's module path (e.g., "apps.users" or just "users") + # For predefined structure, use "apps" as nested_dir + if self._is_predefined_structure(): + app_module_path = f"apps.{self.app_name}" else: - app_module_path = self.app_name + 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 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 +112,122 @@ 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") + 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) - # 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 + 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 - # 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 + 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) - return None + 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") - 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 + # 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} - # 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 - - 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 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", ) - # 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, + # Subfolders + subfolders = { + "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) + 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 + 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 + 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, 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/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..ec03879 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,16 +116,12 @@ 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") 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 7346822..06aac3b 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(" [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(" [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 @@ -261,11 +273,9 @@ 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]4.[/dim] Create superuser: [cyan]python manage.py createsuperuser[/cyan]" - ) - console.print(" [dim]└─[/dim] [dim]5.[/dim] Start server: [cyan]python manage.py runserver[/cyan]") + 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]5.[/dim] Start server: [cyan]just dev[/cyan]") console.print() # Useful URLs 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..207c3de --- /dev/null +++ b/djinit/scripts/django_helper.py @@ -0,0 +1,101 @@ +""" +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_directory_with_init, + create_file_from_template, + create_files_from_templates, + 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, 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, "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) + 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") + create_directory_with_init(settings_dir, f"Created {project_name}/settings/__init__.py") + + base_context = {"project_name": project_name, "app_names": []} + 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} + 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 + + 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", "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), + ] + + 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") + create_directory_with_init(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..b3d7fdc 100644 --- a/djinit/scripts/files.py +++ b/djinit/scripts/files.py @@ -6,7 +6,15 @@ 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_directory_with_init, + create_file_from_template, + create_file_with_content, + create_files_from_templates, + get_package_name, +) class FileManager: @@ -15,149 +23,322 @@ 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") + 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 _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) + 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) + 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: - with change_cwd(self.project_root): - gitignore_content = template_engine.render_template("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("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} - readme_content = template_engine.render_template("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("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): - context = {"package_name": metadata["package_name"], "project_name": self.project_name} - pyproject_content = template_engine.render_template("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") + 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", + "shared/pyproject_toml.j2", + context, + "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_project_urls(self) -> bool: + """Create project urls.py using project_urls.j2 template (replaces update_project_urls).""" + 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_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_justfile(self) -> bool: + 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: + # Ensure apps package + apps_dir = os.path.join(self.project_root, "apps") + create_directory_with_init(apps_dir, "Created apps/__init__.py") + + # users app + users_dir = os.path.join(apps_dir, "users") + 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 = { + "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")], + } + self._create_files_from_specs(users_dir, users_subfolders, {"app_module": "apps.users"}) + + # users urls.py at app root + users_urls_path = os.path.join(users_dir, "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") + create_directory_with_init(core_dir, "Created apps/core/__init__.py") + + # core subfolders and files + core_subfolders = { + "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")], + } + self._create_files_from_specs(core_dir, core_subfolders, {}) + + # core/exceptions.py + exceptions_path = os.path.join(core_dir, "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") + create_directory_with_init(api_dir, "Created api/__init__.py") + api_urls_path = os.path.join(api_dir, "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") + create_directory_with_init(api_v1_dir, "Created api/v1/__init__.py") + api_v1_urls_path = os.path.join(api_v1_dir, "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") + 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, + ) - 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") + def create_unified_structure(self) -> bool: + # Create core directory structure + core_dir = os.path.join(self.project_root, "core") + create_directory_with_init(core_dir, "Created core/__init__.py") + + # Create core/settings directory + settings_dir = os.path.join(core_dir, "settings") + 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 + + 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) + 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 = [ + ("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) + 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") + create_directory_with_init(apps_dir, "Created apps/__init__.py") + + # Create apps/core + apps_core_dir = os.path.join(apps_dir, "core") + create_directory_with_init(apps_core_dir, "Created apps/core/__init__.py") + + # Create apps/core/apps.py + 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") + 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"), + ] + 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") + utils_files = [ + ("__init__.py", "unified/apps/core/utils/__init__.py.j2"), + ("responses.py", "unified/apps/core/utils/responses.py.j2"), + ] + 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 + self._create_subdirectories_with_init(apps_core_dir, ["permissions", "middleware"], "apps/core/") + + # Create apps/api + apps_api_dir = os.path.join(apps_dir, "api") + 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 + self._create_subdirectories_with_init( + apps_api_dir, ["models", "serializers", "views", "services", "tests", "urls", "admin"], "apps/api/" + ) + 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 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 update_project_urls(self) -> bool: - 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 - ] - context = { - "project_name": self.project_name, - "app_names": effective_app_modules, - } - urls_content = template_engine.render_template("project_urls.j2", context) - result = create_file_with_content( - "urls.py", - urls_content, - "Updated project urls.py with comprehensive URL configuration", - should_format=True, - ) - return result + def create_runtime_txt(self) -> bool: + 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 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 create_github_actions(self) -> bool: + 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 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.""" - 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, - ) + def create_gitlab_ci(self) -> bool: + 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 af035a2..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): @@ -34,9 +35,12 @@ class ProjectMetadata: nested_apps: bool = False nested_dir: str | None = None 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: - """Convert to dictionary format.""" return { "package_name": self.package_name, "use_github_actions": self.use_github_actions, @@ -44,13 +48,14 @@ class ProjectMetadata: "nested_apps": self.nested_apps, "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, } @dataclass class ProjectSetup: - """Complete project setup configuration.""" - project_dir: str project_name: str primary_app: str @@ -58,17 +63,14 @@ 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): - 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 @@ -107,41 +109,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}]" + f"[{UIColors.MUTED}]Enter app names separated by commas(e.g. users, products, orders)[/{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,35 +158,53 @@ 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() 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() - # 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,66 +213,77 @@ 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}]" - ) - 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() - 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 - # 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", ) 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}]") 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() - choice = CharReader.get_yes_no( - f"[{UIColors.HIGHLIGHT}]Use DATABASE_URL? (Y/n):[/{UIColors.HIGHLIGHT}]", default="y" + return CharReader.get_yes_no(f"[{UIColors.HIGHLIGHT}]Use DATABASE_URL? (Y/n):[/{UIColors.HIGHLIGHT}]") == "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" + return project_name, "", app_names, metadata.to_dict() class CharReader: - """Handles single character input across platforms.""" + MAX_ATTEMPTS = 3 @staticmethod def get_char() -> str: @@ -260,45 +292,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 +356,44 @@ 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. + 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}.", + ) - 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_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: @@ -383,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 "" @@ -400,106 +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() - # Section 1: Project Setup - UIFormatter.print_separator() - 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 + # 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 = user_input - break + project_dir = collector._get_project_directory() - 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) - if project_dir is None: - UIFormatter.print_error("Maximum attempts reached for project directory name. Exiting.") - sys.exit(1) + console.print() - 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() - 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}]") - 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 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 3: CI/CD Pipeline - use_github, use_gitlab = collector.get_cicd_choice() + # Section 4: Database Configuration + use_database_url = collector.get_database_config_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 = get_package_name(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, + ) - # Create metadata - metadata = ProjectMetadata( - package_name=project_dir, - 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() - 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: @@ -509,17 +544,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 +559,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 +569,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..bddfb99 100644 --- a/djinit/scripts/project.py +++ b/djinit/scripts/project.py @@ -3,13 +3,18 @@ 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, + create_directory_with_init, + extract_existing_apps, + get_base_settings_path, + insert_apps_into_user_defined_apps, + read_base_settings, +) class ProjectManager: @@ -18,93 +23,119 @@ 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) + # 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: - 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, - ) + 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: + UIFormatter.print_error(f"Failed to create Django project '{self.project_name}'") + return success - UIFormatter.print_success(f"Django project '{self.project_name}' created successfully!") - return True + 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: - # 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 + 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) - # 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() + if apps_base_dir != self.project_root: + create_directory_with_init(apps_base_dir, f"Created {os.path.basename(apps_base_dir)}/__init__.py") - 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.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.module_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, "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"), ] - # 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")) + apps_base_dir = self._get_apps_base_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, "admin.py"), - ] - required_files.extend(app_files) + # 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"), + 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/base/admin.j2 b/djinit/templates/base/admin.j2 new file mode 100644 index 0000000..dc9bfc6 --- /dev/null +++ b/djinit/templates/base/admin.j2 @@ -0,0 +1,5 @@ +"""generated with djinit""" + +from django.contrib import admin + +# Register your models here. diff --git a/djinit/templates/base/apps.j2 b/djinit/templates/base/apps.j2 new file mode 100644 index 0000000..bc3adcc --- /dev/null +++ b/djinit/templates/base/apps.j2 @@ -0,0 +1,8 @@ +"""generated with djinit""" + +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/base/migrations_init.j2 b/djinit/templates/base/migrations_init.j2 new file mode 100644 index 0000000..e69de29 diff --git a/djinit/templates/base/models.j2 b/djinit/templates/base/models.j2 new file mode 100644 index 0000000..980c8e5 --- /dev/null +++ b/djinit/templates/base/models.j2 @@ -0,0 +1,5 @@ +"""generated with djinit""" + +from django.db import models + +# Create your models here. diff --git a/djinit/templates/routes.j2 b/djinit/templates/base/routes.j2 similarity index 93% rename from djinit/templates/routes.j2 rename to djinit/templates/base/routes.j2 index c6ea465..3831c00 100644 --- a/djinit/templates/routes.j2 +++ b/djinit/templates/base/routes.j2 @@ -1,4 +1,6 @@ """ +generated with djinit + API routes for {{ app_name }} app. """ diff --git a/djinit/templates/serializers.j2 b/djinit/templates/base/serializers.j2 similarity index 80% rename from djinit/templates/serializers.j2 rename to djinit/templates/base/serializers.j2 index 7cf7f1a..e4b115d 100644 --- a/djinit/templates/serializers.j2 +++ b/djinit/templates/base/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/base/tests.j2 b/djinit/templates/base/tests.j2 new file mode 100644 index 0000000..7fdd344 --- /dev/null +++ b/djinit/templates/base/tests.j2 @@ -0,0 +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/base/urls.j2 similarity index 85% rename from djinit/templates/app_urls.j2 rename to djinit/templates/base/urls.j2 index a31f082..501e65a 100644 --- a/djinit/templates/app_urls.j2 +++ b/djinit/templates/base/urls.j2 @@ -1,3 +1,5 @@ +"""generated with djinit""" + from django.urls import path from . import views diff --git a/djinit/templates/base/views.j2 b/djinit/templates/base/views.j2 new file mode 100644 index 0000000..7df6a0d --- /dev/null +++ b/djinit/templates/base/views.j2 @@ -0,0 +1,5 @@ +"""generated with djinit""" + +from django.shortcuts import render + +# Create your views here. diff --git a/djinit/templates/predefined/api/urls.j2 b/djinit/templates/predefined/api/urls.j2 new file mode 100644 index 0000000..51e3dfb --- /dev/null +++ b/djinit/templates/predefined/api/urls.j2 @@ -0,0 +1,7 @@ +from django.urls import include, path + +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 new file mode 100644 index 0000000..7a0d9a8 --- /dev/null +++ b/djinit/templates/predefined/api/v1/urls.j2 @@ -0,0 +1,7 @@ +from django.urls import include, path + +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 new file mode 100644 index 0000000..9f99a46 --- /dev/null +++ b/djinit/templates/predefined/apps/generic/models.j2 @@ -0,0 +1,8 @@ +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/predefined/apps/generic/serializers.j2 b/djinit/templates/predefined/apps/generic/serializers.j2 new file mode 100644 index 0000000..86f1b45 --- /dev/null +++ b/djinit/templates/predefined/apps/generic/serializers.j2 @@ -0,0 +1,9 @@ +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/predefined/apps/generic/services.j2 b/djinit/templates/predefined/apps/generic/services.j2 new file mode 100644 index 0000000..2203fb8 --- /dev/null +++ b/djinit/templates/predefined/apps/generic/services.j2 @@ -0,0 +1,10 @@ +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/predefined/apps/generic/tests.j2 b/djinit/templates/predefined/apps/generic/tests.j2 new file mode 100644 index 0000000..41fc906 --- /dev/null +++ b/djinit/templates/predefined/apps/generic/tests.j2 @@ -0,0 +1,12 @@ +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/predefined/apps/generic/urls.j2 b/djinit/templates/predefined/apps/generic/urls.j2 new file mode 100644 index 0000000..caec1a6 --- /dev/null +++ b/djinit/templates/predefined/apps/generic/urls.j2 @@ -0,0 +1,14 @@ +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/predefined/apps/generic/views.j2 b/djinit/templates/predefined/apps/generic/views.j2 new file mode 100644 index 0000000..5976564 --- /dev/null +++ b/djinit/templates/predefined/apps/generic/views.j2 @@ -0,0 +1,9 @@ +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/predefined/apps/users/models.j2 b/djinit/templates/predefined/apps/users/models.j2 new file mode 100644 index 0000000..f6236bd --- /dev/null +++ b/djinit/templates/predefined/apps/users/models.j2 @@ -0,0 +1,9 @@ +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/predefined/apps/users/serializers.j2 b/djinit/templates/predefined/apps/users/serializers.j2 new file mode 100644 index 0000000..d7a4f40 --- /dev/null +++ b/djinit/templates/predefined/apps/users/serializers.j2 @@ -0,0 +1,9 @@ +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/predefined/apps/users/services.j2 b/djinit/templates/predefined/apps/users/services.j2 new file mode 100644 index 0000000..036060b --- /dev/null +++ b/djinit/templates/predefined/apps/users/services.j2 @@ -0,0 +1,10 @@ +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/predefined/apps/users/tests.j2 b/djinit/templates/predefined/apps/users/tests.j2 new file mode 100644 index 0000000..1bde766 --- /dev/null +++ b/djinit/templates/predefined/apps/users/tests.j2 @@ -0,0 +1,12 @@ +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/predefined/apps/users/urls.j2 b/djinit/templates/predefined/apps/users/urls.j2 new file mode 100644 index 0000000..08a6fb4 --- /dev/null +++ b/djinit/templates/predefined/apps/users/urls.j2 @@ -0,0 +1,14 @@ +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/predefined/apps/users/views.j2 b/djinit/templates/predefined/apps/users/views.j2 new file mode 100644 index 0000000..8db2347 --- /dev/null +++ b/djinit/templates/predefined/apps/users/views.j2 @@ -0,0 +1,13 @@ +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/predefined/core/exceptions.j2 b/djinit/templates/predefined/core/exceptions.j2 new file mode 100644 index 0000000..bb426d3 --- /dev/null +++ b/djinit/templates/predefined/core/exceptions.j2 @@ -0,0 +1,5 @@ +class ApplicationError(Exception): + """Base application 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 new file mode 100644 index 0000000..d922ad1 --- /dev/null +++ b/djinit/templates/predefined/core/middleware/request_logger.j2 @@ -0,0 +1,14 @@ +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/predefined/core/mixins/timestamped_model.j2 b/djinit/templates/predefined/core/mixins/timestamped_model.j2 new file mode 100644 index 0000000..6b2e70e --- /dev/null +++ b/djinit/templates/predefined/core/mixins/timestamped_model.j2 @@ -0,0 +1,9 @@ +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/predefined/core/utils/responses.j2 b/djinit/templates/predefined/core/utils/responses.j2 new file mode 100644 index 0000000..bae79a1 --- /dev/null +++ b/djinit/templates/predefined/core/utils/responses.j2 @@ -0,0 +1,9 @@ +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/asgi.j2 b/djinit/templates/project/asgi.j2 similarity index 88% rename from djinit/templates/asgi.j2 rename to djinit/templates/project/asgi.j2 index 6380cd7..03b888c 100644 --- a/djinit/templates/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/settings_base.j2 b/djinit/templates/project/settings/base.j2 similarity index 99% rename from djinit/templates/settings_base.j2 rename to djinit/templates/project/settings/base.j2 index 63cc238..d711d08 100644 --- a/djinit/templates/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/settings_development.j2 b/djinit/templates/project/settings/development.j2 similarity index 95% rename from djinit/templates/settings_development.j2 rename to djinit/templates/project/settings/development.j2 index cb5aa6e..4761347 100644 --- a/djinit/templates/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/settings_production.j2 b/djinit/templates/project/settings/production.j2 similarity index 91% rename from djinit/templates/settings_production.j2 rename to djinit/templates/project/settings/production.j2 index 6d45aa1..4f7d346 100644 --- a/djinit/templates/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 @@ -35,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/project/urls.j2 b/djinit/templates/project/urls.j2 new file mode 100644 index 0000000..ab41ddb --- /dev/null +++ b/djinit/templates/project/urls.j2 @@ -0,0 +1,8 @@ +"""generated with djinit""" + +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path('admin/', admin.site.urls), +] diff --git a/djinit/templates/project/urls_with_api.j2 b/djinit/templates/project/urls_with_api.j2 new file mode 100644 index 0000000..ee6843f --- /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")), + ] diff --git a/djinit/templates/project_urls.j2 b/djinit/templates/project/urls_with_apps.j2 similarity index 97% rename from djinit/templates/project_urls.j2 rename to djinit/templates/project/urls_with_apps.j2 index 7395a4a..87da515 100644 --- a/djinit/templates/project_urls.j2 +++ b/djinit/templates/project/urls_with_apps.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/wsgi.j2 b/djinit/templates/project/wsgi.j2 similarity index 88% rename from djinit/templates/wsgi.j2 rename to djinit/templates/project/wsgi.j2 index d414bb0..fa32c6a 100644 --- a/djinit/templates/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/readme.j2 b/djinit/templates/readme.j2 deleted file mode 100644 index 6bc279f..0000000 --- a/djinit/templates/readme.j2 +++ /dev/null @@ -1,60 +0,0 @@ -# {{ project_name }} - -A Django project created with djinit. - -## 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 - -``` -{{ project_name }}/ -├── {{ project_name }}/ -│ ├── settings/ -│ │ ├── base.py -│ │ ├── development.py -│ │ └── production.py -│ ├── urls.py -│ ├── wsgi.py -│ └── asgi.py -{% for app in app_names %}├── {{ app }}/ -{% endfor %}├── manage.py -├── requirements.txt -├── pyproject.toml -├── .env.sample -└── 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/djinit/templates/github_actions_ci.j2 b/djinit/templates/shared/ci/github_actions.j2 similarity index 88% rename from djinit/templates/github_actions_ci.j2 rename to djinit/templates/shared/ci/github_actions.j2 index 52082de..6200b9c 100644 --- a/djinit/templates/github_actions_ci.j2 +++ b/djinit/templates/shared/ci/github_actions.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/gitlab_ci.j2 b/djinit/templates/shared/ci/gitlab_ci.j2 similarity index 88% rename from djinit/templates/gitlab_ci.j2 rename to djinit/templates/shared/ci/gitlab_ci.j2 index 4fcedd6..ff6ef5b 100644 --- a/djinit/templates/gitlab_ci.j2 +++ b/djinit/templates/shared/ci/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/env_sample.j2 b/djinit/templates/shared/env_sample.j2 similarity index 93% rename from djinit/templates/env_sample.j2 rename to djinit/templates/shared/env_sample.j2 index 135d06b..0a4e621 100644 --- a/djinit/templates/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/gitignore.j2 b/djinit/templates/shared/gitignore.j2 similarity index 84% rename from djinit/templates/gitignore.j2 rename to djinit/templates/shared/gitignore.j2 index 51c5a35..2b5243c 100644 --- a/djinit/templates/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/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 91% rename from djinit/templates/justfile.j2 rename to djinit/templates/shared/justfile.j2 index 93cde70..a4e0052 100644 --- a/djinit/templates/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{{ '}}' }} diff --git a/djinit/templates/manage_py.j2 b/djinit/templates/shared/manage_py.j2 similarity index 95% rename from djinit/templates/manage_py.j2 rename to djinit/templates/shared/manage_py.j2 index a616ada..422a555 100644 --- a/djinit/templates/manage_py.j2 +++ b/djinit/templates/shared/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/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/shared/readme.j2 b/djinit/templates/shared/readme.j2 new file mode 100644 index 0000000..0eb6b32 --- /dev/null +++ b/djinit/templates/shared/readme.j2 @@ -0,0 +1,148 @@ +# {{ project_name }} + +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 + +``` +{% 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 +{% 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 }}/ +│ ├── settings/ +│ │ ├── base.py +│ │ ├── development.py +│ │ └── production.py +│ ├── urls.py +│ ├── wsgi.py +│ └── asgi.py +{% for app in app_names %}├── {{ app }}/ +{% endfor %}├── manage.py +├── requirements.txt +├── pyproject.toml +├── .env.sample +└── README.md +{% endif %} +``` + +## 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/djinit/templates/requirements.j2 b/djinit/templates/shared/requirements.j2 similarity index 54% rename from djinit/templates/requirements.j2 rename to djinit/templates/shared/requirements.j2 index 365c06d..5eac586 100644 --- a/djinit/templates/requirements.j2 +++ b/djinit/templates/shared/requirements.j2 @@ -1,6 +1,10 @@ +#--------------------------------------------------# +# The following was generated by djinit: # +#--------------------------------------------------# + 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/templates/unified/apps/core/apps.j2 b/djinit/templates/unified/apps/core/apps.j2 new file mode 100644 index 0000000..1d202ca --- /dev/null +++ b/djinit/templates/unified/apps/core/apps.j2 @@ -0,0 +1,8 @@ +"""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..89b2eb6 --- /dev/null +++ b/djinit/templates/unified/apps/core/models/__init__.py.j2 @@ -0,0 +1,4 @@ +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..a3970f9 --- /dev/null +++ b/djinit/templates/unified/apps/core/models/base.py.j2 @@ -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/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..fe40544 --- /dev/null +++ b/djinit/templates/unified/apps/core/models/mixins.py.j2 @@ -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/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..155f5b4 --- /dev/null +++ b/djinit/templates/unified/apps/core/utils/__init__.py.j2 @@ -0,0 +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 new file mode 100644 index 0000000..929c2f6 --- /dev/null +++ b/djinit/templates/unified/apps/core/utils/responses.py.j2 @@ -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/djinit/utils.py b/djinit/utils.py index dfbd7c9..a11cdc5 100644 --- a/djinit/utils.py +++ b/djinit/utils.py @@ -9,46 +9,284 @@ 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) if should_format: format_file(filename) - UIFormatter.print_success(success_message) + # UIFormatter.print_success(success_message) 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) + + +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): - """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) + + +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") + in_user_apps = False + + for line in lines: + if _is_user_apps_section_start(line): + in_user_apps = True + elif in_user_apps and line.strip() == "]": + break + elif in_user_apps: + 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 + + +# 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 _is_user_apps_section_start(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 [calculate_app_module_path(app_name, nested, nested_dir_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() + + 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 + + return False, None, search_dir