407 lines
14 KiB
Python
407 lines
14 KiB
Python
"""
|
|
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
|