354 lines
12 KiB
Python
354 lines
12 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
|
|
|
|
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,
|
|
}
|
|
|
|
|
|
@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]:
|
|
try:
|
|
console.print(f"[{UIColors.HIGHLIGHT}]App Names[/{UIColors.HIGHLIGHT}]")
|
|
console.print(
|
|
f"[{UIColors.MUTED}]Enter app names separated by commas or press Enter to add one at a time[/{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 one at a time):[/{UIColors.HIGHLIGHT}] "
|
|
)
|
|
|
|
# Empty input - prompt for single app
|
|
if not user_input.strip():
|
|
return self._get_apps_interactive()
|
|
|
|
# Comma-separated input
|
|
if "," in user_input:
|
|
return self._parse_comma_separated_apps(user_input)
|
|
|
|
# Single app with option to add more
|
|
return self._get_apps_starting_with(user_input.strip())
|
|
|
|
except KeyboardInterrupt:
|
|
UIFormatter.print_info("\nSetup cancelled by user.")
|
|
sys.exit(0)
|
|
except Exception as e:
|
|
UIFormatter.print_error(f"Unexpected error: {str(e)}")
|
|
return self.get_app_names() # Retry
|
|
|
|
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()
|
|
|
|
app_names = [first_app]
|
|
app_names.extend(self._prompt_for_additional_apps())
|
|
return app_names
|
|
|
|
def _get_apps_interactive(self) -> list[str]:
|
|
app_name = self.get_validated_input("Enter at least one app name", validate_app_name, "app name")
|
|
app_names = [app_name]
|
|
app_names.extend(self._prompt_for_additional_apps())
|
|
return app_names
|
|
|
|
def _prompt_for_additional_apps(self) -> list[str]:
|
|
additional_apps = []
|
|
|
|
while True:
|
|
console.print(f"[{UIColors.HIGHLIGHT}]Add another app? (y/N):[/{UIColors.HIGHLIGHT}] ", end="")
|
|
sys.stdout.flush()
|
|
|
|
response = self.char_reader.get_char()
|
|
console.print(response.upper())
|
|
|
|
if response != "y":
|
|
break
|
|
|
|
next_app = self.get_validated_input("Enter next app name", validate_app_name, "app name")
|
|
additional_apps.append(next_app)
|
|
|
|
return additional_apps
|
|
|
|
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
|
|
|
|
|
|
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:
|
|
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"
|
|
)
|
|
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")
|
|
|
|
app_names = collector.get_app_names()
|
|
console.print()
|
|
|
|
# Section 3: CI/CD Pipeline
|
|
use_github, use_gitlab = collector.get_cicd_choice()
|
|
|
|
# Create metadata
|
|
metadata = ProjectMetadata(package_name=project_dir, use_github_actions=use_github, use_gitlab_ci=use_gitlab)
|
|
|
|
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}")
|
|
|
|
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
|