- models: DomainConfig, ServiceConfig, DiagnosticReport (single source of truth) - interfaces: DIP & ISP contracts for detector, generator, executor, doctor, and migrator - detector: Smart auto-detection for modular (config/wsgi.py), traditional, and flat layouts - service_generator: Systemd service generation with --umask 007 and security sandboxing - executor: Linux command execution with dry-run support - nginx_manager: HestiaCP template installation and validation - migrator: Automated migration and repair of existing deployments - doctor: Comprehensive post-deployment health check engine
208 lines
7.6 KiB
Python
208 lines
7.6 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
Smart Django project structure detector.
|
|
|
|
**Single Responsibility:** Inspect a ``public_html`` directory and produce a
|
|
fully populated ``DjangoProjectInfo`` — identifying the WSGI module, virtual
|
|
environment, static-file conventions, and database engine.
|
|
|
|
Supports three layout styles:
|
|
* **Modular** — ``config/wsgi.py`` (clean architecture like ``portfolio``)
|
|
* **Traditional** — ``<project>/wsgi.py`` (``django-admin startproject``)
|
|
* **Flat** — ``wsgi.py`` at the project root (rare but valid)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from core.interfaces import IDjangoDetector
|
|
from core.models import (
|
|
DEFAULT_STATIC_DIR,
|
|
DEFAULT_VENV_CANDIDATES,
|
|
DatabaseEngine,
|
|
DjangoProjectInfo,
|
|
ProjectLayout,
|
|
)
|
|
|
|
|
|
class DjangoProjectDetector(IDjangoDetector):
|
|
"""Concrete detector that inspects the filesystem for Django conventions."""
|
|
|
|
# Regex patterns for extracting settings values
|
|
_STATIC_ROOT_RE = re.compile(
|
|
r"""STATIC_ROOT\s*=\s*(?:"""
|
|
r"""BASE_DIR\s*/\s*['"]([^'"]+)['"]""" # BASE_DIR / 'staticfiles'
|
|
r"""|os\.path\.join\s*\(\s*BASE_DIR\s*,\s*['"]([^'"]+)['"]""" # os.path.join(...)
|
|
r"""|['"]([^'"]+)['"]""" # plain string
|
|
r""")""",
|
|
re.VERBOSE,
|
|
)
|
|
_DB_ENGINE_RE = re.compile(r"""['"]ENGINE['"]\s*:\s*['"]([^'"]+)['"]""")
|
|
_WSGI_APP_RE = re.compile(r"""WSGI_APPLICATION\s*=\s*['"]([^'"]+)['"]""")
|
|
|
|
def detect(self, web_dir: Path, project_hint: Optional[str] = None) -> DjangoProjectInfo:
|
|
"""Analyse *web_dir* and return a populated ``DjangoProjectInfo``."""
|
|
info = DjangoProjectInfo()
|
|
|
|
if not web_dir.is_dir():
|
|
info.errors.append(f"Web directory does not exist: {web_dir}")
|
|
return info
|
|
|
|
# --- 1. Detect project layout & WSGI module ---
|
|
self._detect_layout(web_dir, info, project_hint)
|
|
|
|
# --- 2. Locate virtualenv ---
|
|
self._detect_venv(web_dir, info)
|
|
|
|
# --- 3. Inspect settings.py ---
|
|
self._inspect_settings(web_dir, info)
|
|
|
|
# --- 4. Check for .env and requirements.txt ---
|
|
info.has_dot_env = (web_dir / ".env").is_file()
|
|
info.has_requirements = (web_dir / "requirements.txt").is_file()
|
|
|
|
return info
|
|
|
|
# ------------------------------------------------------------------
|
|
# Private helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
def _detect_layout(
|
|
self,
|
|
web_dir: Path,
|
|
info: DjangoProjectInfo,
|
|
project_hint: Optional[str],
|
|
) -> None:
|
|
"""Determine project layout and set ``wsgi_module``."""
|
|
|
|
# Priority 1: Modular layout (config/wsgi.py)
|
|
if (web_dir / "config" / "wsgi.py").is_file():
|
|
info.layout = ProjectLayout.MODULAR
|
|
info.wsgi_module = "config.wsgi:application"
|
|
info.settings_module = "config.settings"
|
|
return
|
|
|
|
# Priority 2: Traditional layout (<project>/wsgi.py)
|
|
if project_hint:
|
|
candidate = web_dir / project_hint / "wsgi.py"
|
|
if candidate.is_file():
|
|
info.layout = ProjectLayout.TRADITIONAL
|
|
info.wsgi_module = f"{project_hint}.wsgi:application"
|
|
info.settings_module = f"{project_hint}.settings"
|
|
return
|
|
|
|
# Auto-scan for traditional layout directories
|
|
for child in sorted(web_dir.iterdir()):
|
|
if child.is_dir() and (child / "wsgi.py").is_file():
|
|
name = child.name
|
|
info.layout = ProjectLayout.TRADITIONAL
|
|
info.wsgi_module = f"{name}.wsgi:application"
|
|
info.settings_module = f"{name}.settings"
|
|
return
|
|
|
|
# Priority 3: Flat layout (wsgi.py at root)
|
|
if (web_dir / "wsgi.py").is_file():
|
|
info.layout = ProjectLayout.FLAT
|
|
info.wsgi_module = "wsgi:application"
|
|
# Try to read DJANGO_SETTINGS_MODULE from wsgi.py
|
|
self._extract_settings_from_wsgi(web_dir / "wsgi.py", info)
|
|
return
|
|
|
|
info.errors.append(
|
|
"Could not locate wsgi.py — checked config/, <project>/, and root."
|
|
)
|
|
|
|
def _detect_venv(self, web_dir: Path, info: DjangoProjectInfo) -> None:
|
|
"""Find the first existing virtualenv candidate directory."""
|
|
search_dirs = [web_dir, web_dir.parent] # public_html, then domain root
|
|
|
|
for base in search_dirs:
|
|
for candidate_name in DEFAULT_VENV_CANDIDATES:
|
|
candidate = base / candidate_name
|
|
if (candidate / "bin" / "python").is_file() or \
|
|
(candidate / "bin" / "python3").is_file():
|
|
info.venv_path = candidate
|
|
return
|
|
|
|
info.errors.append(
|
|
f"No virtualenv found. Searched for {DEFAULT_VENV_CANDIDATES} "
|
|
f"in {web_dir} and {web_dir.parent}."
|
|
)
|
|
|
|
def _inspect_settings(self, web_dir: Path, info: DjangoProjectInfo) -> None:
|
|
"""Parse ``settings.py`` for STATIC_ROOT and DATABASE ENGINE."""
|
|
settings_path = self._find_settings_file(web_dir, info)
|
|
if settings_path is None:
|
|
return
|
|
|
|
try:
|
|
content = settings_path.read_text(encoding="utf-8", errors="replace")
|
|
except OSError as exc:
|
|
info.errors.append(f"Cannot read settings: {exc}")
|
|
return
|
|
|
|
# STATIC_ROOT
|
|
match = self._STATIC_ROOT_RE.search(content)
|
|
if match:
|
|
# Pick the first non-None group
|
|
info.static_root_name = next(
|
|
(g for g in match.groups() if g), DEFAULT_STATIC_DIR
|
|
)
|
|
|
|
# Database engine
|
|
db_match = self._DB_ENGINE_RE.search(content)
|
|
if db_match:
|
|
engine_str = db_match.group(1).lower()
|
|
if "sqlite" in engine_str:
|
|
info.database_engine = DatabaseEngine.SQLITE
|
|
elif "postgresql" in engine_str or "postgis" in engine_str:
|
|
info.database_engine = DatabaseEngine.POSTGRESQL
|
|
elif "mysql" in engine_str:
|
|
info.database_engine = DatabaseEngine.MYSQL
|
|
else:
|
|
info.database_engine = DatabaseEngine.OTHER
|
|
|
|
def _find_settings_file(
|
|
self, web_dir: Path, info: DjangoProjectInfo
|
|
) -> Optional[Path]:
|
|
"""Locate settings.py based on the detected layout."""
|
|
if info.settings_module:
|
|
relative = info.settings_module.replace(".", "/") + ".py"
|
|
candidate = web_dir / relative
|
|
if candidate.is_file():
|
|
return candidate
|
|
|
|
# Fallback: search common locations
|
|
for candidate in [
|
|
web_dir / "config" / "settings.py",
|
|
web_dir / "settings.py",
|
|
]:
|
|
if candidate.is_file():
|
|
return candidate
|
|
|
|
# Search any subdirectory
|
|
for child in sorted(web_dir.iterdir()):
|
|
if child.is_dir():
|
|
settings = child / "settings.py"
|
|
if settings.is_file():
|
|
return settings
|
|
|
|
return None
|
|
|
|
@staticmethod
|
|
def _extract_settings_from_wsgi(wsgi_path: Path, info: DjangoProjectInfo) -> None:
|
|
"""Try to read DJANGO_SETTINGS_MODULE from a flat wsgi.py."""
|
|
try:
|
|
content = wsgi_path.read_text(encoding="utf-8", errors="replace")
|
|
match = re.search(
|
|
r"""os\.environ.*?['"]DJANGO_SETTINGS_MODULE['"]\s*,\s*['"]([^'"]+)['"]""",
|
|
content,
|
|
)
|
|
if match:
|
|
info.settings_module = match.group(1)
|
|
except OSError:
|
|
pass
|