""" User input handling for Django project setup. Handles collection and processing of user inputs during setup. """ import os import sys import termios import tty from dataclasses import dataclass from enum import Enum from typing import Callable, Tuple from src.scripts.console_ui import UIColors, UIFormatter, console from src.scripts.name_validator import validate_app_name, validate_project_name class CICDOption(Enum): GITHUB = ("g", "GitHub Actions only") GITLAB = ("l", "GitLab CI only") BOTH = ("b", "Both (GitHub Actions + GitLab CI)") NONE = ("n", "None (skip CI/CD)") def __init__(self, key: str, description: str): self.key = key self.description = description @dataclass class ProjectMetadata: package_name: str use_github_actions: bool = False use_gitlab_ci: bool = False nested_apps: bool = False nested_dir: str | None = None use_database_url: bool = False def to_dict(self) -> dict: """Convert to dictionary format.""" return { "package_name": self.package_name, "use_github_actions": self.use_github_actions, "use_gitlab_ci": self.use_gitlab_ci, "nested_apps": self.nested_apps, "nested_dir": self.nested_dir, "use_database_url": self.use_database_url, } @dataclass class ProjectSetup: """Complete project setup configuration.""" project_dir: str project_name: str primary_app: str app_names: list[str] 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() def get_validated_input( self, prompt: str, validator: Callable[[str], Tuple[bool, str]], input_type: str, allow_empty: bool = False ) -> str: attempt = 0 while attempt < self.MAX_ATTEMPTS: try: user_input = console.input(f"[{UIColors.HIGHLIGHT}]{prompt}:[/{UIColors.HIGHLIGHT}] ") if not user_input.strip(): if allow_empty: return "" UIFormatter.print_error(f"{input_type.capitalize()} cannot be empty") attempt += 1 self._show_retry_message(attempt) continue is_valid, error_msg = validator(user_input) if is_valid: return user_input.strip() UIFormatter.print_error(error_msg) attempt += 1 self._show_retry_message(attempt) except KeyboardInterrupt: UIFormatter.print_info("\nSetup cancelled by user.") sys.exit(0) except Exception as e: UIFormatter.print_error(f"Unexpected error: {str(e)}") attempt += 1 self._show_retry_message(attempt) 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 get_app_names(self) -> list[str]: console.print(f"[{UIColors.HIGHLIGHT}]App Names[/{UIColors.HIGHLIGHT}]") console.print( f"[{UIColors.MUTED}]Enter app names separated by commas (no interactive prompts)[/{UIColors.MUTED}]" ) console.print(f"[{UIColors.MUTED}]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 if not app_list: UIFormatter.print_error("At least one app name is required") return self.get_app_names() invalid_apps = [] for app in app_list: is_valid, error_msg = validate_app_name(app) if not is_valid: invalid_apps.append((app, error_msg)) if invalid_apps: for app, error_msg in invalid_apps: UIFormatter.print_error(f"Invalid app name '{app}': {error_msg}") return self.get_app_names() 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() return [first_app] 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() def _prompt_for_additional_apps(self) -> list[str]: """Deprecated: interactive flow disabled. Always returns empty list.""" return [] 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() console.print(f"[{UIColors.HIGHLIGHT}]Your choice: [/{UIColors.HIGHLIGHT}]", end="") sys.stdout.flush() choice = self.char_reader.get_char() console.print(choice.upper()) # 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 for any unrecognized input return 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}]" ) console.print() console.print(f"[{UIColors.HIGHLIGHT}]Nested Django apps? (y/N):[/{UIColors.HIGHLIGHT}] ", end="") sys.stdout.flush() choice = self.char_reader.get_char() console.print(choice.upper()) 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)", 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() console.print(f"[{UIColors.HIGHLIGHT}]Use DATABASE_URL? (Y/n):[/{UIColors.HIGHLIGHT}] ", end="") sys.stdout.flush() choice = self.char_reader.get_char() console.print(choice.upper()) # Default to True (use DATABASE_URL) if user presses Enter or Y return choice.lower() != "n" class CharReader: """Handles single character input across platforms.""" @staticmethod def get_char() -> str: if os.name == "nt": return CharReader._get_char_windows() return CharReader._get_char_unix() @staticmethod def _get_char_windows() -> str: import msvcrt encodings = ["utf-8", "latin1"] for encoding in encodings: try: ch = msvcrt.getch() return ch.decode(encoding).lower() except (UnicodeDecodeError, AttributeError): continue # Fallback try: return msvcrt.getch().lower() except Exception: return "" @staticmethod def _get_char_unix() -> str: fd = sys.stdin.fileno() old_settings = termios.tcgetattr(fd) try: tty.setraw(sys.stdin.fileno()) ch = sys.stdin.read(1) 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() # Section 1: Project Setup UIFormatter.print_separator() console.print(f"\n[{UIColors.INFO}]Step 1: Project Setup[/{UIColors.INFO}]\n") console.print(f"[{UIColors.HIGHLIGHT}]Project Directory[/{UIColors.HIGHLIGHT}]") console.print(f"[{UIColors.MUTED}]Where your project files will be created[/{UIColors.MUTED}]") project_dir = collector.get_validated_input( "Enter project directory name", validate_project_name, "project directory name" ) # Check if directory exists and ask for alternative while os.path.exists(project_dir): UIFormatter.print_error(f"Directory '{project_dir}' already exists.") UIFormatter.print_info("Please choose a different project directory name.") project_dir = collector.get_validated_input( "Enter project directory name", validate_project_name, "project directory name" ) console.print() 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 3: CI/CD Pipeline use_github, use_gitlab = collector.get_cicd_choice() # Section 4: Database Configuration use_database_url = collector.get_database_config_choice() # Create metadata 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() # Return in legacy format for backward compatibility return project_dir, project_name, app_names[0], app_names, metadata.to_dict() def confirm_setup(project_dir: str, project_name: str, app_names: list, metadata: dict) -> bool: console.print() UIFormatter.print_separator() console.print() 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}") console.print() UIFormatter.print_separator() console.print() # Get confirmation try: console.print(f"[{UIColors.WARNING}]Proceed with setup? (y/N):[/{UIColors.WARNING}] ", end="") sys.stdout.flush() reader = CharReader() response = reader.get_char() console.print(response.upper()) return response == "y" except KeyboardInterrupt: UIFormatter.print_info("\nSetup cancelled by user.") return False def _get_cicd_display(metadata: dict) -> str: cicd_choices = [] if metadata.get("use_github_actions", False): cicd_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" get_char = CharReader.get_char _get_validated_input = InputCollector().get_validated_input