forked from ungleich-public/dynamicweb
1292 lines
59 KiB
Python
1292 lines
59 KiB
Python
#!/usr/bin/env python
|
|
import argparse
|
|
import difflib
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import urllib.error
|
|
import urllib.request
|
|
import zipfile
|
|
|
|
from django.core.checks.security.base import SECRET_KEY_INSECURE_PREFIX
|
|
from django.core.management import CommandError
|
|
from django.core.management.templates import TemplateCommand
|
|
from django.core.management.utils import get_random_secret_key
|
|
|
|
from cms import __version__ as cms_version
|
|
|
|
|
|
class ShellMixin: # pragma: no cover
|
|
"""Run subprocesses / management commands and echo them to the user.
|
|
|
|
Shared by both the new-project and existing-project flows. Expects the
|
|
``COMMAND`` style callable and ``stdout``/``stderr`` provided by the
|
|
command class it is mixed into.
|
|
"""
|
|
|
|
def write_command(self, command):
|
|
self.stderr.write(self.COMMAND(command))
|
|
|
|
def run_management_command(self, commands, capture_output=False):
|
|
self.write_command("python -m manage " + " ".join(commands))
|
|
result = subprocess.run([sys.executable, "-m", "manage"] + commands, capture_output=capture_output, check=False)
|
|
if result.returncode:
|
|
if capture_output:
|
|
self.stderr.write(self.style.ERROR(result.stderr.decode()))
|
|
raise CommandError(f"{sys.executable} -m manage {' '.join(commands)} failed.")
|
|
|
|
@staticmethod
|
|
def running_in_venv():
|
|
return sys.prefix != sys.base_prefix
|
|
|
|
|
|
class PromptMixin: # pragma: no cover
|
|
"""Interactive prompting for the project name and the project options."""
|
|
|
|
def ask(self, label, default=None):
|
|
suffix = f" [{default}]" if default not in (None, "") else ""
|
|
try:
|
|
return input(f"{label}{suffix}: ").strip() or default
|
|
except EOFError as exc:
|
|
raise CommandError(
|
|
"Input is not available. Provide a project name, or use --noinput to disable prompts."
|
|
) from exc
|
|
|
|
def ask_choice(self, label, choices, default):
|
|
while True:
|
|
value = self.ask(f"{label} ({'/'.join(choices)})", default)
|
|
if value in choices:
|
|
return value
|
|
self.stderr.write(self.style.ERROR(f"Please choose one of: {', '.join(choices)}"))
|
|
|
|
def ask_bool(self, label, default):
|
|
while True:
|
|
value = self.ask(f"{label} (yes/no)", "yes" if default else "no").lower()
|
|
if value in ("y", "yes", "true", "1", "on"):
|
|
return True
|
|
if value in ("n", "no", "false", "0", "off"):
|
|
return False
|
|
self.stderr.write(self.style.ERROR("Please answer yes or no."))
|
|
|
|
def prompt_for_options(self, name, options):
|
|
"""Ask for the project name and any option still unset (``None``)."""
|
|
self.stdout.write(self.HEADING(f"Create django CMS {cms_version} project"))
|
|
if not name:
|
|
while not name:
|
|
name = self.ask("Project name")
|
|
if not name:
|
|
self.stderr.write(self.style.ERROR("A project name is required."))
|
|
for option_name, definition in self.get_template_options().items():
|
|
if options.get(option_name) is not None:
|
|
continue
|
|
if not self._requirements_met(definition.get("requires"), options):
|
|
options[option_name] = False if definition["type"] == "boolean" else definition["default"]
|
|
continue
|
|
label = definition.get("label", option_name.replace("_", " ").title())
|
|
if definition["type"] == "choice":
|
|
options[option_name] = self.ask_choice(label, tuple(definition["choices"]), definition["default"])
|
|
elif definition["type"] == "boolean":
|
|
options[option_name] = self.ask_bool(label, definition["default"])
|
|
else:
|
|
raise CommandError(f"Unsupported option type for --{option_name}: {definition['type']}")
|
|
return name
|
|
|
|
|
|
class SourceEditorMixin:
|
|
"""Best-effort, regex-based editing of a project's ``settings.py`` / ``urls.py``.
|
|
|
|
A self-contained toolkit of (mostly static) text-manipulation helpers used
|
|
when adding django CMS to an existing project. It knows nothing about the
|
|
command options; the install rules in :class:`ExistingProjectMixin` decide
|
|
*what* to insert, these helpers decide *how* to splice it into the source.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _read(path):
|
|
with open(path, encoding="utf-8") as handle:
|
|
return handle.read()
|
|
|
|
@staticmethod
|
|
def _write(path, text):
|
|
with open(path, "w", encoding="utf-8") as handle:
|
|
handle.write(text)
|
|
|
|
def _show_diff(self, path, original, new):
|
|
"""Print a coloured unified diff of an edit (for ``--dry-run``).
|
|
|
|
An empty ``original`` renders as a new file (``--- /dev/null``).
|
|
"""
|
|
if original == new:
|
|
return
|
|
rel = os.path.relpath(path)
|
|
diff = difflib.unified_diff(
|
|
original.splitlines(), new.splitlines(),
|
|
fromfile="/dev/null" if not original else rel, tofile=rel, lineterm="",
|
|
)
|
|
for line in diff:
|
|
if line.startswith(("+++", "---")):
|
|
self.stdout.write(line)
|
|
elif line.startswith("+"):
|
|
self.stdout.write(self.style.SUCCESS(line))
|
|
elif line.startswith("-"):
|
|
self.stdout.write(self.style.ERROR(line))
|
|
elif line.startswith("@@"):
|
|
self.stdout.write(self.style.WARNING(line))
|
|
else:
|
|
self.stdout.write(line)
|
|
|
|
@staticmethod
|
|
def module_to_path(module):
|
|
"""Resolve a dotted module path to a file below the current directory."""
|
|
parts = module.split(".")
|
|
base = os.path.join(os.getcwd(), *parts)
|
|
if os.path.isfile(base + ".py"):
|
|
return base + ".py"
|
|
if os.path.isfile(os.path.join(base, "__init__.py")):
|
|
return os.path.join(base, "__init__.py")
|
|
return None
|
|
|
|
def get_settings_module(self, manage_py):
|
|
"""Extract the DJANGO_SETTINGS_MODULE value from a manage.py file."""
|
|
text = self._read(manage_py)
|
|
match = re.search(r"""DJANGO_SETTINGS_MODULE['"]?\s*,\s*['"]([\w.]+)['"]""", text)
|
|
if not match:
|
|
raise CommandError("Could not determine DJANGO_SETTINGS_MODULE from manage.py.")
|
|
return match.group(1)
|
|
|
|
def get_urlconf(self, settings_text, settings_module):
|
|
"""Return the ROOT_URLCONF module, falling back to ``<project>.urls``."""
|
|
match = re.search(r"""(?m)^ROOT_URLCONF\s*=\s*['"]([\w.]+)['"]""", settings_text)
|
|
if match:
|
|
return match.group(1)
|
|
return settings_module.rsplit(".", 1)[0] + ".urls"
|
|
|
|
@staticmethod
|
|
def _get_setting_value(text, name):
|
|
"""Return the quoted string value of a top-level ``NAME = "..."`` setting."""
|
|
match = re.search(rf"""(?m)^{name}\s*=\s*['"]([^'"]+)['"]""", text)
|
|
return match.group(1) if match else None
|
|
|
|
@staticmethod
|
|
def _language_name(language_code):
|
|
"""Human-readable name for a language code, falling back to the code."""
|
|
try:
|
|
from django.utils.translation import get_language_info
|
|
|
|
return get_language_info(language_code)["name"]
|
|
except KeyError:
|
|
return language_code
|
|
|
|
@staticmethod
|
|
def _uses_i18n_patterns(text):
|
|
"""Whether the urls module already routes patterns through ``i18n_patterns``."""
|
|
return bool(re.search(r"\bi18n_patterns\s*\(", text))
|
|
|
|
@staticmethod
|
|
def _append_i18n_patterns(text, items):
|
|
"""Append ``urlpatterns += i18n_patterns(...)`` with ``items`` to the module.
|
|
|
|
A new block is added at the end of the file (after any existing
|
|
``i18n_patterns`` call) so the CMS catch-all stays last. The
|
|
``i18n_patterns`` import is assumed present -- this is only called when
|
|
the module already uses it.
|
|
"""
|
|
if not text.endswith("\n"):
|
|
text += "\n"
|
|
lines = "\n".join(f" {item}," for item in items)
|
|
block = "\n# django CMS URLs (added by `djangocms .`)\nurlpatterns += i18n_patterns(\n" + lines + "\n)\n"
|
|
return text + block, list(items)
|
|
|
|
@classmethod
|
|
def _ensure_include_import(cls, text):
|
|
"""Make sure ``include`` is importable from ``django.urls`` in urls.py."""
|
|
if re.search(r"(?m)^\s*from django\.urls import .*\binclude\b", text):
|
|
return text
|
|
return cls._insert_import(text, "from django.urls import include")
|
|
|
|
@staticmethod
|
|
def _insert_import(text, statement):
|
|
"""Insert an import ``statement`` at a safe top-level position.
|
|
|
|
Inserts after the last existing top-level ``import``/``from`` line so the
|
|
statement never lands before a shebang, encoding comment, module
|
|
docstring, or ``from __future__`` imports (which must stay at the top).
|
|
If the module has no imports yet, the statement is placed after any
|
|
shebang, encoding declaration, and module docstring.
|
|
"""
|
|
lines = text.splitlines(keepends=True)
|
|
insert_at = 0
|
|
for i, line in enumerate(lines):
|
|
if line.startswith(("import ", "from ")):
|
|
insert_at = i + 1
|
|
if insert_at == 0:
|
|
# No imports found: skip over shebang, encoding comment and docstring.
|
|
if insert_at < len(lines) and lines[insert_at].startswith("#!"):
|
|
insert_at += 1
|
|
if insert_at < len(lines) and re.match(r"#.*coding[:=]", lines[insert_at]):
|
|
insert_at += 1
|
|
match = re.match(r'\s*(?P<quote>"""|\'\'\')', lines[insert_at]) if insert_at < len(lines) else None
|
|
if match:
|
|
quote = match.group("quote")
|
|
# Single-line docstring (opening and closing quotes on one line).
|
|
if lines[insert_at].count(quote) >= 2:
|
|
insert_at += 1
|
|
else:
|
|
insert_at += 1
|
|
while insert_at < len(lines) and quote not in lines[insert_at]:
|
|
insert_at += 1
|
|
if insert_at < len(lines):
|
|
insert_at += 1
|
|
lines.insert(insert_at, statement + "\n")
|
|
return "".join(lines)
|
|
|
|
@staticmethod
|
|
def _insert_into_list(text, name, items, quote=True):
|
|
"""Insert ``items`` into a list/tuple assignment ``name = [...]``.
|
|
|
|
Works for top-level assignments (``NAME = [`` / ``NAME += [``) as well as
|
|
dict entries (``"name": [``). Existing entries are left untouched.
|
|
Returns ``(new_text, added_items)``.
|
|
"""
|
|
pattern = re.compile(rf"""(?m)^(?P<indent>[ \t]*)['"]?{re.escape(name)}['"]?\s*(?:\+?=|:)\s*[\[(]""")
|
|
match = pattern.search(text)
|
|
if not match:
|
|
return text, []
|
|
open_pos = match.end() - 1
|
|
depth = 0
|
|
close_pos = None
|
|
for i in range(open_pos, len(text)):
|
|
char = text[i]
|
|
if char in "[(":
|
|
depth += 1
|
|
elif char in ")]":
|
|
depth -= 1
|
|
if depth == 0:
|
|
close_pos = i
|
|
break
|
|
if close_pos is None:
|
|
return text, []
|
|
|
|
body = text[open_pos + 1:close_pos]
|
|
# Derive the indentation of the list items.
|
|
indent = None
|
|
for line in body.splitlines():
|
|
if line.strip():
|
|
indent = line[: len(line) - len(line.lstrip())]
|
|
break
|
|
if indent is None:
|
|
indent = match.group("indent") + " "
|
|
|
|
added, rendered = [], []
|
|
for item in items:
|
|
token = f'"{item}"' if quote else item
|
|
present = (f'"{item}"' in body or f"'{item}'" in body) if quote else (item in body)
|
|
if present:
|
|
continue
|
|
rendered.append(f"{indent}{token},")
|
|
added.append(item)
|
|
if not added:
|
|
return text, []
|
|
|
|
# Splice the new lines in just before the closing bracket, preserving
|
|
# that bracket's own indentation when it sits on its own line.
|
|
line_start = text.rfind("\n", 0, close_pos) + 1
|
|
if text[line_start:close_pos].strip() == "":
|
|
head, tail = text[:line_start], text[line_start:]
|
|
else:
|
|
# Inline list (e.g. ``"DIRS": []``): the closing bracket moves to its
|
|
# own line, indented like the assignment for readability.
|
|
head, tail = text[:close_pos], match.group("indent") + text[close_pos:]
|
|
head = head.rstrip()
|
|
if head and head[-1] not in "[(,":
|
|
head += ","
|
|
new_text = head + "\n" + "\n".join(rendered) + "\n" + tail
|
|
return new_text, added
|
|
|
|
def _insert_near_anchor(self, text, item, anchor, before=False, list_name=None):
|
|
"""Insert quoted ``item`` immediately before/after the ``anchor`` entry.
|
|
|
|
Used for entries with a required position (e.g. Django's LocaleMiddleware
|
|
after ``SessionMiddleware``, or the admin style before
|
|
``django.contrib.admin``). Falls back to appending to ``list_name`` when
|
|
the anchor is not found.
|
|
"""
|
|
if f'"{item}"' in text or f"'{item}'" in text:
|
|
return text, []
|
|
pattern = re.compile(rf"""(?m)^([ \t]*)(["']){re.escape(anchor)}\2(\s*,)?""")
|
|
match = pattern.search(text)
|
|
if not match:
|
|
return self._insert_into_list(text, list_name, [item]) if list_name else (text, [])
|
|
indent, quote, comma = match.group(1), match.group(2), match.group(3)
|
|
if before:
|
|
new_line = f"{indent}{quote}{item}{quote},\n"
|
|
text = text[: match.start()] + new_line + text[match.start():]
|
|
else:
|
|
# Make sure the anchor line keeps its trailing comma.
|
|
prefix = "" if comma else ","
|
|
new_line = f"\n{indent}{quote}{item}{quote},"
|
|
text = text[: match.end()] + prefix + new_line + text[match.end():]
|
|
return text, [item]
|
|
|
|
@staticmethod
|
|
def _list_assignment_kind(text, name):
|
|
"""Classify the first top-level assignment to ``name``.
|
|
|
|
Returns ``"literal"`` for an editable ``NAME = [`` / ``NAME = (`` list or
|
|
tuple, ``"computed"`` for any other value (e.g. ``NAME = DJANGO + EXTRA``,
|
|
which has no list literal to splice into), or ``"none"`` when there is no
|
|
such assignment. The *first* assignment wins, so a recovery reassignment
|
|
appended later (see :meth:`_append_list_extension`) does not flip a
|
|
computed list back to "literal" on a rerun.
|
|
"""
|
|
match = re.search(rf"(?m)^[ \t]*{re.escape(name)}\s*\+?=\s*(\S)", text)
|
|
if not match:
|
|
return "none"
|
|
return "literal" if match.group(1) in "[(" else "computed"
|
|
|
|
def _append_list_extension(self, text, name, before_items, after_items):
|
|
"""Recover when ``name`` is a computed value rather than a literal list.
|
|
|
|
Appends a ``name = [before] + name + [after]`` reassignment to the end of
|
|
the module so the new entries are added without rewriting the original
|
|
expression. This is best-effort: exact ``before``/``after`` anchors are
|
|
reduced to 'prepend' / 'append', so the caller flags the result for
|
|
review. Items already present anywhere in the module are skipped, which
|
|
keeps reruns idempotent. Returns ``(new_text, added_items)``.
|
|
"""
|
|
before_new = [item for item in before_items if f'"{item}"' not in text and f"'{item}'" not in text]
|
|
after_new = [item for item in after_items if f'"{item}"' not in text and f"'{item}'" not in text]
|
|
if not before_new and not after_new:
|
|
return text, []
|
|
if not text.endswith("\n"):
|
|
text += "\n"
|
|
prefix = "[\n" + "".join(f' "{item}",\n' for item in before_new) + "] + " if before_new else ""
|
|
suffix = " + [\n" + "".join(f' "{item}",\n' for item in after_new) + "]" if after_new else ""
|
|
block = (
|
|
f"\n# django CMS: {name} is a computed value, not a literal list, so the new\n"
|
|
f"# entries were appended here instead of inserted in place (`djangocms .`).\n"
|
|
f"# Review their position relative to your existing entries.\n"
|
|
f"{name} = {prefix}{name}{suffix}\n"
|
|
)
|
|
return text + block, before_new + after_new
|
|
|
|
|
|
class NewProjectMixin: # pragma: no cover
|
|
"""Create a brand-new project by cloning the cms-template, then bootstrap it.
|
|
|
|
Covers the default flow (a project name other than ``"."``): rendering the
|
|
template, installing its requirements, running migrations, creating the
|
|
superuser and reporting success.
|
|
"""
|
|
|
|
def get_default_template(self):
|
|
return f"https://github.com/django-cms/cms-template/archive/{self.major_minor}.tar.gz"
|
|
|
|
def handle_template(self, template, subdir):
|
|
if not template:
|
|
template = self.get_default_template()
|
|
return super().handle_template(template, subdir)
|
|
|
|
def postprocess(self, project, options):
|
|
# Go to project dir
|
|
self.write_command(f'cd "{project}"')
|
|
os.chdir(project)
|
|
|
|
# Install requirements
|
|
self.install_requirements(project)
|
|
|
|
# Create database by running migrations
|
|
self.stdout.write(self.HEADING("Run migrations"))
|
|
self.run_management_command(["migrate"], capture_output=True)
|
|
|
|
# Create superuser (respecting command line arguments)
|
|
self.stdout.write(self.HEADING("Create superuser"))
|
|
command = ["createsuperuser"]
|
|
if options.get("username"):
|
|
command.append("--username")
|
|
command.append(options.get("username"))
|
|
if options.get("email"):
|
|
command.append("--email")
|
|
command.append(options.get("email"))
|
|
if not options["interactive"]:
|
|
if "--username" not in command:
|
|
command.append("--username")
|
|
command.append(os.environ.get("DJANGO_SUPERUSER_USERNAME", "admin"))
|
|
if "--email" not in command:
|
|
command.append("--email")
|
|
command.append(os.environ.get("DJANGO_SUPERUSER_EMAIL", "none@nowhere.com"))
|
|
command.append("--noinput")
|
|
self.run_management_command(command)
|
|
|
|
# Check installation
|
|
self.stdout.write(self.HEADING("Check installation"))
|
|
self.run_management_command(["cms", "check"], capture_output=True)
|
|
|
|
# Display success message
|
|
message = f"django CMS {cms_version} installed successfully"
|
|
separator = "*" * len(message)
|
|
self.stdout.write(self.HEADING(f"{separator}\n{message}\n{separator}"))
|
|
self.stdout.write(
|
|
f"""
|
|
Congratulations! You have successfully installed django CMS,
|
|
the lean enterprise content management powered by Django!
|
|
|
|
Now, to start the development server first go to your newly
|
|
created project and then call the runserver management command:
|
|
$ {self.style.SUCCESS("cd " + project)}
|
|
$ {self.style.SUCCESS("python -m manage runserver")}
|
|
|
|
Learn more at https://docs.django-cms.org/
|
|
Join the django CMS Discord Server at https://discord-main-channel.django-cms.org
|
|
|
|
Enjoy!
|
|
"""
|
|
)
|
|
|
|
def install_requirements(self, project):
|
|
for req_file in ("requirements.txt", "requirements.in"):
|
|
requirements = os.path.join(project, req_file)
|
|
if os.path.isfile(requirements):
|
|
if self.running_in_venv() or os.environ.get("DJANGOCMS_ALLOW_PIP_INSTALL", "False") == "True":
|
|
self.stdout.write(self.HEADING(f"Install requirements in {requirements}"))
|
|
self.write_command(f"python -m pip install -r {shlex.quote(requirements)}")
|
|
result = subprocess.run(
|
|
[sys.executable, "-m", "pip", "install", "-r", requirements],
|
|
capture_output=True, check=False,
|
|
)
|
|
if result.returncode:
|
|
self.stderr.write(self.style.ERROR(result.stderr.decode()))
|
|
raise CommandError(f"Failed to install requirements in {requirements}")
|
|
break
|
|
else:
|
|
self.stderr.write(
|
|
self.style.ERROR(
|
|
"To automatically install requirements for your new django CMS "
|
|
"project use this command in a virtual environment."
|
|
)
|
|
)
|
|
raise CommandError("Requirements not installed")
|
|
|
|
|
|
class ExistingProjectMixin:
|
|
"""Add django CMS to the existing Django project in the current directory.
|
|
|
|
Covers the ``djangocms .`` flow: it reads the install rules (fetched from
|
|
the cms-template repository, with a bundled fallback), edits the project's
|
|
``settings.py`` and ``urls.py`` through :class:`SourceEditorMixin`, and
|
|
installs the resulting package set.
|
|
"""
|
|
|
|
# The rules for installing django CMS into an *existing* project (name ".")
|
|
# live in a JSON file that is fetched from the cms-template repository (the
|
|
# branch matching the installed django CMS major.minor), with the file
|
|
# bundled next to this command serving as the offline fallback.
|
|
INSTALL_RULES_FILENAME = "djangocms_install_rules.json"
|
|
|
|
# Package specs are intentionally narrow: a package name plus an optional
|
|
# lower-bound version pin from trusted install rules.
|
|
SAFE_PACKAGE_RE = re.compile(r"^[A-Za-z0-9_-]+(>=[0-9][A-Za-z0-9_.-]*)?$")
|
|
BUNDLED_RULES_OPTION = "--use-bundled-install-rules"
|
|
|
|
def get_install_rules_url(self):
|
|
return (
|
|
"https://raw.githubusercontent.com/django-cms/cms-template/"
|
|
f"{self.major_minor}/{self.INSTALL_RULES_FILENAME}"
|
|
)
|
|
|
|
@staticmethod
|
|
def _command_line_option(name):
|
|
"""Return an option value from ``sys.argv`` before argparse runs."""
|
|
for index, argument in enumerate(sys.argv[1:]):
|
|
if argument.startswith(name + "="):
|
|
return argument.split("=", 1)[1]
|
|
if argument == name and index + 2 < len(sys.argv):
|
|
return sys.argv[index + 2]
|
|
return None
|
|
|
|
def load_template_install_rules(self, template):
|
|
"""Load install rules stored at the root of a custom project template."""
|
|
if os.path.isdir(template):
|
|
path = os.path.join(template, self.INSTALL_RULES_FILENAME)
|
|
if not os.path.isfile(path):
|
|
return None
|
|
return json.loads(self._read(path))
|
|
|
|
if os.path.isfile(template):
|
|
with open(template, "rb") as template_file:
|
|
content = template_file.read()
|
|
else:
|
|
with urllib.request.urlopen(template, timeout=15) as response: # noqa: S310 (user-provided template URL)
|
|
content = response.read()
|
|
|
|
archive = io.BytesIO(content)
|
|
try:
|
|
with zipfile.ZipFile(archive) as template_zip:
|
|
matches = [
|
|
name
|
|
for name in template_zip.namelist()
|
|
if name.rsplit("/", 1)[-1] == self.INSTALL_RULES_FILENAME
|
|
]
|
|
if not matches:
|
|
return None
|
|
return json.loads(template_zip.read(min(matches, key=lambda name: name.count("/"))).decode("utf-8"))
|
|
except zipfile.BadZipFile:
|
|
archive.seek(0)
|
|
|
|
try:
|
|
with tarfile.open(fileobj=archive, mode="r:*") as template_tar:
|
|
matches = [
|
|
member
|
|
for member in template_tar.getmembers()
|
|
if member.isfile() and member.name.rsplit("/", 1)[-1] == self.INSTALL_RULES_FILENAME
|
|
]
|
|
if not matches:
|
|
return None
|
|
rules_file = template_tar.extractfile(min(matches, key=lambda member: member.name.count("/")))
|
|
return json.loads(rules_file.read().decode("utf-8"))
|
|
except tarfile.TarError as exc:
|
|
raise ValueError("custom template is not a supported directory, ZIP, or tar archive") from exc
|
|
|
|
def add_to_existing_project(self, options):
|
|
"""Add django CMS to the existing Django project in the current directory.
|
|
|
|
The project's settings module is read from ``manage.py``; INSTALLED_APPS,
|
|
MIDDLEWARE and the template context processors are updated according to
|
|
the rules (and the given flags), and the project's ``urls.py`` (from
|
|
``ROOT_URLCONF``) is wired up for the selected mode.
|
|
"""
|
|
cwd = os.getcwd()
|
|
manage_py = os.path.join(cwd, "manage.py")
|
|
if not os.path.isfile(manage_py):
|
|
raise CommandError("Cannot add django CMS: no manage.py found in the current directory.")
|
|
|
|
settings_module = self.get_settings_module(manage_py)
|
|
settings_file = self.module_to_path(settings_module)
|
|
if not settings_file:
|
|
raise CommandError(f"Cannot locate the settings file for '{settings_module}'.")
|
|
|
|
dry_run = options.get("dry_run")
|
|
self.stdout.write(self.HEADING(f"Add django CMS {cms_version} to an existing project"))
|
|
if dry_run:
|
|
# Dry run: nothing is written, so there is nothing to confirm.
|
|
self.stdout.write(
|
|
f"Dry run: showing the changes that would be made to {os.path.relpath(settings_file, cwd)}\n"
|
|
"and your project's urls. No files are created, modified or installed."
|
|
)
|
|
else:
|
|
# Make sure the user understands these are automated, best-effort edits
|
|
# to their own project files before anything is changed.
|
|
self.stdout.write(
|
|
f"This will make automated changes to {os.path.relpath(settings_file, cwd)} and your\n"
|
|
"project's urls, and may create a templates directory. These edits are best-effort\n"
|
|
"and must be reviewed afterwards, so make sure your project is under version control\n"
|
|
"(or backed up) to inspect the changes."
|
|
)
|
|
if options.get("interactive", True) and not self.ask_bool("Continue", True):
|
|
self.stdout.write("Aborted; no changes made.")
|
|
return
|
|
|
|
rules = self.load_install_rules()
|
|
|
|
# Emit any warnings whose condition matches (e.g. an option that no
|
|
# longer has an effect). These are defined by the rules file so they can
|
|
# be added/updated without changing django CMS.
|
|
for warning in rules.get("warnings", []):
|
|
if self._rule_applies(warning.get("when"), options):
|
|
self.stderr.write(self.style.WARNING(warning["message"]))
|
|
|
|
self.stdout.write(self.HEADING(f"Add django CMS {cms_version} to {settings_module}"))
|
|
|
|
# --- settings.py ---------------------------------------------------
|
|
original_settings = self._read(settings_file)
|
|
text = original_settings
|
|
|
|
# Apps to add from the rules, honouring each rule's condition (a flag
|
|
# and/or the selected mode). Rules with a `before`/`after` anchor are
|
|
# positioned relative to an existing entry; the rest are appended.
|
|
text, apps, added_apps, apps_recovered = self._apply_list_rules(
|
|
text, "INSTALLED_APPS", rules.get("installed_apps", []), options
|
|
)
|
|
text, _, added_mw, mw_recovered = self._apply_list_rules(
|
|
text, "MIDDLEWARE", rules.get("middleware", []), options
|
|
)
|
|
|
|
context_processors = []
|
|
for rule in rules.get("context_processors", []):
|
|
if self._rule_applies(rule.get("when"), options):
|
|
context_processors += rule["items"]
|
|
text, added_cp = self._insert_into_list(text, "context_processors", context_processors)
|
|
|
|
text, added_dirs, created_paths = self._ensure_template_dir(text, cwd, rules.get("template_dir"), options)
|
|
text, added_settings = self._append_cms_settings(text, rules.get("settings", []), options)
|
|
|
|
if dry_run:
|
|
# In a dry run the diff is the whole report: the settings change plus
|
|
# any files that would be created (e.g. the base template).
|
|
self._show_diff(settings_file, original_settings, text)
|
|
template_content = (rules.get("template_dir") or {}).get("base_template_content", "")
|
|
for path in created_paths:
|
|
if not path.endswith("/"):
|
|
self._show_diff(path, "", template_content)
|
|
else:
|
|
self._write(settings_file, text)
|
|
self.stdout.write(f"Updated {os.path.relpath(settings_file, cwd)}")
|
|
for app in added_apps:
|
|
self.stdout.write(f" + INSTALLED_APPS: {app}")
|
|
for mw in added_mw:
|
|
self.stdout.write(f" + MIDDLEWARE: {mw}")
|
|
for cp in added_cp:
|
|
self.stdout.write(f" + context_processors: {cp}")
|
|
for entry in added_dirs:
|
|
self.stdout.write(f" + TEMPLATES DIRS: {entry}")
|
|
for setting in added_settings:
|
|
self.stdout.write(f" + {setting}")
|
|
for path in created_paths:
|
|
self.stdout.write(f" + created {path}")
|
|
|
|
# When a setting is a computed value rather than a literal list, the
|
|
# entries could not be spliced in place and were appended through a
|
|
# reassignment instead -- the order is a best-effort guess to review.
|
|
if apps_recovered and added_apps:
|
|
self.stderr.write(
|
|
self.style.WARNING(
|
|
"INSTALLED_APPS is a computed value, so the new apps were appended via a "
|
|
"reassignment at the end of the settings file. Review their order."
|
|
)
|
|
)
|
|
if mw_recovered and added_mw:
|
|
self.stderr.write(
|
|
self.style.WARNING(
|
|
"MIDDLEWARE is a computed value, so the new middleware was appended via a "
|
|
"reassignment at the end of the settings file. Review the order carefully, "
|
|
"as middleware ordering is significant."
|
|
)
|
|
)
|
|
|
|
# --- urls.py -------------------------------------------------------
|
|
urls_module = self.get_urlconf(text, settings_module)
|
|
urls_file = self.module_to_path(urls_module)
|
|
if urls_file:
|
|
added_urls, urls_original, urls_text = self.update_urls(urls_file, options, rules.get("urls", []))
|
|
if dry_run:
|
|
self._show_diff(urls_file, urls_original, urls_text)
|
|
else:
|
|
self._write(urls_file, urls_text)
|
|
self.stdout.write(f"Updated {os.path.relpath(urls_file, cwd)}")
|
|
for url in added_urls:
|
|
self.stdout.write(f" + urlpatterns: {url}")
|
|
else:
|
|
self.stderr.write(
|
|
self.style.WARNING(
|
|
f"Could not locate the urls file for '{urls_module}'. "
|
|
"Please add the django CMS urls manually."
|
|
)
|
|
)
|
|
|
|
self._finish_existing_project(apps, rules.get("packages", {}), options)
|
|
|
|
def load_install_rules(self):
|
|
"""Load custom-template rules, or use the official and bundled fallbacks."""
|
|
cached = getattr(self, "_install_rules", None)
|
|
if cached is not None:
|
|
return cached
|
|
if getattr(self, "_use_bundled_install_rules", False):
|
|
rules = self.load_bundled_install_rules()
|
|
else:
|
|
rules = None
|
|
template = getattr(self, "_requested_template", None)
|
|
if template:
|
|
try:
|
|
rules = self.load_template_install_rules(template)
|
|
except (urllib.error.URLError, OSError, ValueError) as exc:
|
|
self.stderr.write(
|
|
self.style.WARNING(
|
|
f"Could not load installation rules from custom template {template} ({exc})."
|
|
)
|
|
)
|
|
if rules is None:
|
|
self.stderr.write(
|
|
self.style.WARNING(
|
|
f"Custom template {template} does not provide {self.INSTALL_RULES_FILENAME}; "
|
|
"using the official installation rules."
|
|
)
|
|
)
|
|
if rules is None:
|
|
url = self.get_install_rules_url()
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=15) as response: # noqa: S310 (https URL)
|
|
rules = json.loads(response.read().decode("utf-8"))
|
|
except (urllib.error.URLError, OSError, ValueError) as exc:
|
|
self.stderr.write(
|
|
self.style.WARNING(
|
|
f"Could not fetch installation rules from {url} ({exc}); using bundled defaults."
|
|
)
|
|
)
|
|
rules = self.load_bundled_install_rules()
|
|
if not isinstance(rules, dict):
|
|
raise CommandError("Invalid installation rules: expected a JSON object.")
|
|
# Metadata keys such as "$schema" or "comment" carry no rules.
|
|
rules = {key: value for key, value in rules.items() if not key.startswith("$")}
|
|
if "options" not in rules:
|
|
bundled_rules = {
|
|
key: value for key, value in self.load_bundled_install_rules().items() if not key.startswith("$")
|
|
}
|
|
rules["options"] = bundled_rules["options"]
|
|
self._validate_install_rules(rules)
|
|
self._install_rules = rules
|
|
return rules
|
|
|
|
def load_bundled_install_rules(self):
|
|
"""Read the bundled install rules without fetching from cms-template."""
|
|
bundled = os.path.join(os.path.dirname(__file__), self.INSTALL_RULES_FILENAME)
|
|
return json.loads(self._read(bundled))
|
|
|
|
def get_template_options(self):
|
|
"""Return the template-owned command options from the install rules."""
|
|
return self.load_install_rules()["options"]
|
|
|
|
def get_template_option_defaults(self):
|
|
"""Effective defaults for template-owned options."""
|
|
return {name: definition["default"] for name, definition in self.get_template_options().items()}
|
|
|
|
def apply_template_option_defaults(self, options):
|
|
"""Fill unset template-owned options from the JSON metadata."""
|
|
for key, value in self.get_template_option_defaults().items():
|
|
if options.get(key) is None:
|
|
options[key] = value
|
|
|
|
def validate_template_option_requirements(self, options):
|
|
"""Validate simple option dependencies from the JSON metadata."""
|
|
for option_name, definition in self.get_template_options().items():
|
|
required = definition.get("requires")
|
|
if options.get(option_name) and not self._requirements_met(required, options):
|
|
requirements = ", ".join(name if value is True else f"{name}={value}" for name, value in required.items())
|
|
raise CommandError(f"--{option_name} requires {requirements}.")
|
|
|
|
@classmethod
|
|
def _validate_install_rules(cls, rules):
|
|
"""Validate the parts of the install rules that the command executes."""
|
|
options = rules.get("options")
|
|
if not isinstance(options, dict) or not options:
|
|
raise CommandError("Invalid installation rules: expected a non-empty 'options' object.")
|
|
for option_name, definition in options.items():
|
|
if not re.match(r"^[A-Za-z][A-Za-z0-9_]*$", option_name):
|
|
raise CommandError(f"Invalid installation rules: invalid option name '{option_name}'.")
|
|
if not isinstance(definition, dict):
|
|
raise CommandError(f"Invalid installation rules: option '{option_name}' must be an object.")
|
|
option_type = definition.get("type")
|
|
if option_type not in ("boolean", "choice"):
|
|
raise CommandError(
|
|
f"Invalid installation rules: option '{option_name}' has unsupported type '{option_type}'."
|
|
)
|
|
if "default" not in definition:
|
|
raise CommandError(f"Invalid installation rules: option '{option_name}' needs a default.")
|
|
if option_type == "boolean" and not isinstance(definition["default"], bool):
|
|
raise CommandError(f"Invalid installation rules: option '{option_name}' default must be boolean.")
|
|
if option_type == "choice":
|
|
choices = definition.get("choices")
|
|
if not isinstance(choices, list) or not choices:
|
|
raise CommandError(f"Invalid installation rules: option '{option_name}' needs choices.")
|
|
if definition["default"] not in choices:
|
|
raise CommandError(
|
|
f"Invalid installation rules: option '{option_name}' default must be one of its choices."
|
|
)
|
|
requires = definition.get("requires", {})
|
|
if not isinstance(requires, dict):
|
|
raise CommandError(f"Invalid installation rules: option '{option_name}' requires must be an object.")
|
|
for required_name in requires:
|
|
if required_name not in options:
|
|
raise CommandError(
|
|
f"Invalid installation rules: option '{option_name}' requires unknown option "
|
|
f"'{required_name}'."
|
|
)
|
|
|
|
declared_options = set(options)
|
|
for when in cls._iter_when_conditions(rules):
|
|
flag = when.get("flag")
|
|
if flag and flag not in declared_options:
|
|
raise CommandError(f"Invalid installation rules: unknown option flag '{flag}'.")
|
|
modes = when.get("mode")
|
|
if modes is not None:
|
|
mode_definition = options.get("mode")
|
|
if not mode_definition or mode_definition["type"] != "choice":
|
|
raise CommandError("Invalid installation rules: 'mode' conditions require a choice option.")
|
|
unknown_modes = set(modes) - set(mode_definition["choices"])
|
|
if unknown_modes:
|
|
raise CommandError(
|
|
"Invalid installation rules: unknown mode value(s) " + ", ".join(sorted(unknown_modes))
|
|
)
|
|
|
|
@classmethod
|
|
def _iter_when_conditions(cls, rules):
|
|
for section in ("installed_apps", "middleware", "context_processors", "settings", "urls", "warnings"):
|
|
for rule in rules.get(section, []):
|
|
when = rule.get("when") if isinstance(rule, dict) else None
|
|
if when:
|
|
yield when
|
|
template_dir = rules.get("template_dir")
|
|
if isinstance(template_dir, dict) and template_dir.get("when"):
|
|
yield template_dir["when"]
|
|
|
|
@staticmethod
|
|
def _requirements_met(requirements, options):
|
|
if not requirements:
|
|
return True
|
|
return all(options.get(name) == value for name, value in requirements.items())
|
|
|
|
@staticmethod
|
|
def _rule_applies(when, options):
|
|
"""Evaluate a rule condition against the command options.
|
|
|
|
``when`` may contain a ``flag`` (the option must be truthy) and/or a
|
|
``mode`` (a list of matching ``--mode`` values). A missing/empty
|
|
condition always applies.
|
|
"""
|
|
if not when:
|
|
return True
|
|
if "flag" in when and not options.get(when["flag"]):
|
|
return False
|
|
if "mode" in when and options.get("mode") not in when["mode"]:
|
|
return False
|
|
return True
|
|
|
|
def update_urls(self, urls_file, options, url_rules):
|
|
"""Add the url patterns whose rule condition matches the options.
|
|
|
|
Patterns flagged with ``"i18n": true`` (the CMS catch-all) must keep a
|
|
language prefix, so they are added through ``i18n_patterns()`` -- but
|
|
only when the project's urls.py already uses ``i18n_patterns``;
|
|
otherwise they join the plain ``urlpatterns`` list like every other
|
|
pattern.
|
|
|
|
This computes the change but does not persist it; the caller writes the
|
|
result (or shows a diff for ``--dry-run``). Returns
|
|
``(added_patterns, original_text, new_text)``.
|
|
"""
|
|
original = self._read(urls_file)
|
|
text = original
|
|
uses_i18n = self._uses_i18n_patterns(text)
|
|
plain_items, i18n_items = [], []
|
|
for rule in url_rules:
|
|
if not self._rule_applies(rule.get("when"), options):
|
|
continue
|
|
pattern = rule["pattern"]
|
|
# Skip patterns whose include target is already routed.
|
|
target = re.search(r"""include\(['"]([^'"]+)['"]\)""", pattern)
|
|
if target and target.group(1) in text:
|
|
continue
|
|
if rule.get("i18n") and uses_i18n:
|
|
i18n_items.append(pattern)
|
|
else:
|
|
plain_items.append(pattern)
|
|
if not plain_items and not i18n_items:
|
|
return [], original, original
|
|
text = self._ensure_include_import(text)
|
|
added = []
|
|
if plain_items:
|
|
text, just_added = self._insert_into_list(text, "urlpatterns", plain_items, quote=False)
|
|
added += just_added
|
|
if i18n_items:
|
|
text, just_added = self._append_i18n_patterns(text, i18n_items)
|
|
added += just_added
|
|
return added, original, text
|
|
|
|
def _ensure_template_dir(self, text, cwd, rule, options):
|
|
"""Create a project template directory if the project has none.
|
|
|
|
When the rule's condition matches and the configured directory does not
|
|
exist, it is created, registered in the ``TEMPLATES`` ``DIRS`` list, and
|
|
seeded with the rule's base template. Returns
|
|
``(new_text, added_dirs, created_paths)``.
|
|
"""
|
|
if not rule or not self._rule_applies(rule.get("when"), options):
|
|
return text, [], []
|
|
dir_name = rule.get("path", "templates")
|
|
templates_dir = os.path.join(cwd, dir_name)
|
|
if os.path.isdir(templates_dir):
|
|
return text, [], []
|
|
|
|
base_template = rule.get("base_template", "base.html")
|
|
# In a dry run the DIRS entry is still added to the (in-memory) settings
|
|
# text so the diff is accurate, but nothing is created on disk.
|
|
if not options.get("dry_run"):
|
|
os.makedirs(templates_dir)
|
|
self._write(os.path.join(templates_dir, base_template), rule.get("base_template_content", ""))
|
|
created_paths = [dir_name + "/", os.path.join(dir_name, base_template)]
|
|
|
|
# Settings created before Django 3.1 define BASE_DIR with os.path
|
|
# instead of pathlib, where the ``/`` operator does not work.
|
|
if re.search(r"(?m)^BASE_DIR\s*=\s*Path\(", text):
|
|
entry = f'BASE_DIR / "{dir_name}"'
|
|
else:
|
|
entry = f'os.path.join(BASE_DIR, "{dir_name}")'
|
|
if not re.search(r"(?m)^import os\b", text):
|
|
text = self._insert_import(text, "import os")
|
|
text, added_dirs = self._insert_into_list(text, "DIRS", [entry], quote=False)
|
|
return text, added_dirs, created_paths
|
|
|
|
def _append_cms_settings(self, text, settings_rules, options):
|
|
"""Append the extra django CMS settings (from the rules) that are missing.
|
|
|
|
Snippets may reference ``{language_code}`` / ``{language_name}``; these
|
|
are derived from the project's LANGUAGE_CODE.
|
|
"""
|
|
language_code = self._get_setting_value(text, "LANGUAGE_CODE") or "en-us"
|
|
format_context = {"language_code": language_code, "language_name": self._language_name(language_code)}
|
|
additions, added = [], []
|
|
for rule in settings_rules:
|
|
name = rule["name"]
|
|
if not self._rule_applies(rule.get("when"), options):
|
|
continue
|
|
if re.search(rf"(?m)^{name}\s*=", text):
|
|
continue
|
|
additions.append(rule["snippet"].format(**format_context))
|
|
added.append(name)
|
|
if not additions:
|
|
return text, []
|
|
if not text.endswith("\n"):
|
|
text += "\n"
|
|
block = "\n# django CMS settings (added by `djangocms .`)\n" + "\n".join(additions) + "\n"
|
|
return text + block, added
|
|
|
|
def _apply_list_rules(self, text, list_name, list_rules, options):
|
|
"""Apply a set of list rules (e.g. INSTALLED_APPS / MIDDLEWARE).
|
|
|
|
Each rule may carry a ``when`` condition and a ``before``/``after``
|
|
anchor for positional insertion; rules without an anchor are appended in
|
|
order.
|
|
|
|
When the setting is a literal list this splices entries straight in. When
|
|
it is a *computed* value (e.g. ``INSTALLED_APPS = DJANGO_APPS + EXTRA``)
|
|
there is no literal to edit, so anchored entries are still positioned
|
|
relative to their anchor wherever it occurs (often a sub-list literal),
|
|
and everything else is added through a ``NAME = [..] + NAME + [..]``
|
|
reassignment appended to the module -- a best-effort recovery the caller
|
|
flags for review.
|
|
|
|
Returns ``(new_text, all_items, added_items, recovered)`` where
|
|
``all_items`` is every item from matching rules (used for the install
|
|
hint), ``added_items`` is what was actually inserted, and ``recovered``
|
|
is whether the computed-value recovery path was taken.
|
|
"""
|
|
recovery = self._list_assignment_kind(text, list_name) == "computed"
|
|
all_items, added = [], []
|
|
append_items, before_items, after_items = [], [], []
|
|
for rule in list_rules:
|
|
if not self._rule_applies(rule.get("when"), options):
|
|
continue
|
|
all_items += rule["items"]
|
|
before, after = rule.get("before"), rule.get("after")
|
|
if before or after:
|
|
for item in rule["items"]:
|
|
# In recovery mode the anchor can still match a sub-list
|
|
# literal; only items whose anchor is absent fall back to the
|
|
# appended reassignment (grouped by before/after).
|
|
new_text, just_added = self._insert_near_anchor(
|
|
text, item, before or after, before=bool(before),
|
|
list_name=None if recovery else list_name,
|
|
)
|
|
if just_added:
|
|
text, added = new_text, added + just_added
|
|
elif recovery:
|
|
(before_items if before else after_items).append(item)
|
|
elif recovery:
|
|
after_items += rule["items"]
|
|
else:
|
|
append_items += rule["items"]
|
|
if recovery:
|
|
text, just_added = self._append_list_extension(text, list_name, before_items, after_items)
|
|
else:
|
|
text, just_added = self._insert_into_list(text, list_name, append_items)
|
|
added += just_added
|
|
return text, all_items, added, recovery
|
|
|
|
def _finish_existing_project(self, apps, packages_map, options):
|
|
"""Offer to install the missing packages, then migrate and check."""
|
|
packages = ["django-cms"]
|
|
for app in apps:
|
|
# Sub-apps like "djangocms_frontend.contrib.grid" ship with their
|
|
# top-level package.
|
|
app = app.split(".", 1)[0]
|
|
if app in packages_map:
|
|
packages.append(packages_map[app])
|
|
elif app.startswith("djangocms"):
|
|
packages.append(app.replace("_", "-"))
|
|
packages = list(dict.fromkeys(packages)) # de-duplicate, keep order
|
|
|
|
self.stdout.write(self.HEADING("Install dependencies"))
|
|
self.stdout.write("django CMS needs the following packages:")
|
|
self.stdout.write(" " + " ".join(packages))
|
|
# A dry run never installs; otherwise ask (unless --noinput proceeds).
|
|
dry_run = options.get("dry_run")
|
|
if dry_run:
|
|
install = False
|
|
elif options.get("interactive", True):
|
|
install = self.ask_bool("Install them now", True)
|
|
else:
|
|
install = True
|
|
|
|
if not install:
|
|
if dry_run:
|
|
self.stdout.write("Dry run: would install the packages above, then run migrations and the check:")
|
|
else:
|
|
self.stdout.write("Skipped. Install them later, then run the migrations and check:")
|
|
self.write_command(" python -m pip install " + " ".join(shlex.quote(p) for p in packages))
|
|
self.write_command(" python -m manage migrate")
|
|
self.write_command(" python -m manage cms check")
|
|
return
|
|
|
|
self.install_packages(packages)
|
|
|
|
self.stdout.write(self.HEADING("Run migrations"))
|
|
self.run_management_command(["migrate"])
|
|
|
|
self.stdout.write(self.HEADING("Check installation"))
|
|
self.run_management_command(["cms", "check"])
|
|
|
|
message = f"django CMS {cms_version} added to your project"
|
|
separator = "*" * len(message)
|
|
self.stdout.write(self.HEADING(f"{separator}\n{message}\n{separator}"))
|
|
self.stdout.write(
|
|
f"""
|
|
Review the automated changes to your settings and urls, then
|
|
start the development server:
|
|
$ {self.style.SUCCESS("python -m manage runserver")}
|
|
|
|
Learn more at https://docs.django-cms.org/
|
|
Join the django CMS Discord Server at https://discord-main-channel.django-cms.org
|
|
|
|
Enjoy!
|
|
"""
|
|
)
|
|
|
|
def install_packages(self, packages):
|
|
"""pip install the given packages (guarded to a virtual environment)."""
|
|
unsafe = [pkg for pkg in packages if not self.SAFE_PACKAGE_RE.match(pkg)]
|
|
if unsafe:
|
|
raise CommandError("Refusing to install packages with unexpected characters: " + ", ".join(unsafe))
|
|
if not (self.running_in_venv() or os.environ.get("DJANGOCMS_ALLOW_PIP_INSTALL", "False") == "True"):
|
|
self.stderr.write(
|
|
self.style.ERROR(
|
|
"Refusing to install packages outside a virtual environment. "
|
|
"Activate one, or set DJANGOCMS_ALLOW_PIP_INSTALL=True, and install manually:"
|
|
)
|
|
)
|
|
self.write_command(" python -m pip install " + " ".join(shlex.quote(p) for p in packages))
|
|
raise CommandError("Packages not installed")
|
|
self.stdout.write(self.HEADING("Install packages"))
|
|
packages_to_install = [p for p in packages if not p.startswith("-")]
|
|
self.write_command("python -m pip install " + " ".join(shlex.quote(p) for p in packages_to_install))
|
|
result = subprocess.run( # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit
|
|
[sys.executable, "-m", "pip", "install", *packages_to_install], check=False
|
|
)
|
|
if result.returncode:
|
|
raise CommandError("Failed to install the required packages.")
|
|
|
|
|
|
class Command(PromptMixin, NewProjectMixin, ExistingProjectMixin, SourceEditorMixin, ShellMixin, TemplateCommand): # noqa: E501
|
|
"""Entry point: define the CLI and dispatch to the new/existing-project flow.
|
|
|
|
The behaviour is split across focused mixins:
|
|
|
|
* :class:`PromptMixin` -- interactive prompting and the option defaults,
|
|
* :class:`NewProjectMixin` -- creating a project from the cms-template,
|
|
* :class:`ExistingProjectMixin` -- adding django CMS to an existing project,
|
|
* :class:`SourceEditorMixin` -- the regex-based settings/urls editing toolkit,
|
|
* :class:`ShellMixin` -- running subprocesses and management commands.
|
|
|
|
``TemplateCommand`` is kept last in the MRO so every ``super()`` call
|
|
(``create_parser``, ``add_arguments``, ``handle``, ``handle_template``)
|
|
resolves to Django's implementation.
|
|
"""
|
|
|
|
help = (
|
|
"Creates a django CMS project directory structure for the given project "
|
|
"name in the current directory or optionally in the given directory. "
|
|
'Alternatively, use a project name of "." to add django CMS to the '
|
|
"existing Django project in the current directory instead: its settings "
|
|
"and urls are updated in place (best-effort, automated edits to review "
|
|
"afterwards) and the required packages are installed."
|
|
)
|
|
missing_args_message = "You must provide a project name."
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
|
|
# Version major.minor
|
|
self.major_minor = ".".join(cms_version.split(".")[:2])
|
|
|
|
# Configure formatting
|
|
self.HEADING = lambda text: "\n" + self.style.SQL_FIELD(text)
|
|
self.COMMAND = self.style.HTTP_SUCCESS
|
|
|
|
def create_parser(self, *args, **kwargs): # pragma: no cover -- argparse wiring; tests call handle() directly
|
|
parser = super().create_parser(*args, **kwargs)
|
|
# Allow running with no arguments at all: a missing project name drops
|
|
# into interactive mode instead of erroring out. handle() still reports
|
|
# a missing name when input is suppressed with --noinput.
|
|
parser.missing_args_message = None
|
|
return parser
|
|
|
|
def add_arguments(self, parser): # pragma: no cover -- argparse wiring; tests call handle() directly
|
|
super().add_arguments(parser)
|
|
self._use_bundled_install_rules = self.BUNDLED_RULES_OPTION in sys.argv
|
|
self._requested_template = self._command_line_option("--template")
|
|
# Make the project name optional so it can be asked for in interactive
|
|
# mode; a missing name is reported by handle() otherwise.
|
|
for action in parser._actions:
|
|
if action.dest == "name":
|
|
action.nargs = "?"
|
|
break
|
|
parser.add_argument(
|
|
"--noinput",
|
|
"--no-input",
|
|
action="store_false",
|
|
dest="interactive",
|
|
help=(
|
|
"Tells django CMS to NOT prompt the user for input of any kind. "
|
|
"Superusers created with --noinput will "
|
|
"not be able to log in until they're given a valid password."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--interactive",
|
|
action="store_true",
|
|
dest="prompt",
|
|
default=False,
|
|
help=(
|
|
"Ask for the project name and any option not given on the command line. "
|
|
"Interactive mode is also entered automatically when no project name is "
|
|
"given (unless --noinput is used)."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--username",
|
|
help="Specifies the login for the superuser to be created (only when creating a new project).",
|
|
)
|
|
parser.add_argument(
|
|
"--email",
|
|
help="Specifies the email for the superuser to be created (only when creating a new project).",
|
|
)
|
|
parser.add_argument(
|
|
self.BUNDLED_RULES_OPTION,
|
|
action="store_true",
|
|
dest="use_bundled_install_rules",
|
|
default=False,
|
|
help=(
|
|
"Use the install rules bundled with this django CMS package instead of fetching "
|
|
"them from cms-template."
|
|
),
|
|
)
|
|
for option_name, definition in self.get_template_options().items():
|
|
argument = "--" + option_name.replace("_", "-")
|
|
kwargs = {"default": None, "dest": option_name, "help": definition.get("help", argparse.SUPPRESS)}
|
|
if definition["type"] == "boolean":
|
|
kwargs["action"] = argparse.BooleanOptionalAction
|
|
elif definition["type"] == "choice":
|
|
kwargs["choices"] = tuple(definition["choices"])
|
|
else:
|
|
raise CommandError(f"Unsupported option type for {argument}: {definition['type']}")
|
|
parser.add_argument(argument, **kwargs)
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
dest="dry_run",
|
|
default=False,
|
|
help=(
|
|
"Show the changes that would be made to an existing project (name '.') as a "
|
|
"diff, without writing any files or installing packages."
|
|
),
|
|
)
|
|
|
|
def handle(self, **options):
|
|
# Capture target and name for postprocessing
|
|
name = options.pop("name", None)
|
|
directory = options.pop("directory", None)
|
|
use_bundled_install_rules = options.pop("use_bundled_install_rules", False)
|
|
if use_bundled_install_rules and not getattr(self, "_use_bundled_install_rules", False):
|
|
self._install_rules = None
|
|
self._use_bundled_install_rules = use_bundled_install_rules or getattr(
|
|
self, "_use_bundled_install_rules", False
|
|
)
|
|
requested_template = options.get("template")
|
|
if requested_template and requested_template != getattr(self, "_requested_template", None):
|
|
self._requested_template = requested_template
|
|
self._install_rules = None
|
|
|
|
# Interactively ask for the project name and any option not provided.
|
|
# Prompting is enabled explicitly via --interactive, or implicitly when
|
|
# no project name was given (unless input is suppressed with --noinput).
|
|
prompt = options.pop("prompt", False) or (not name and options.get("interactive", True))
|
|
if prompt:
|
|
name = self.prompt_for_options(name, options)
|
|
|
|
# Fill in the effective defaults for any template-owned option not given
|
|
# (and not asked for interactively).
|
|
self.apply_template_option_defaults(options)
|
|
|
|
if not name:
|
|
raise CommandError(self.missing_args_message)
|
|
|
|
self.validate_template_option_requirements(options)
|
|
|
|
# A project name of "." means: add django CMS to the existing project in
|
|
# the current directory instead of cloning the project template.
|
|
if name == ".":
|
|
self.add_to_existing_project(options)
|
|
return
|
|
|
|
# --dry-run only makes sense for the in-place edits of an existing
|
|
# project; there is nothing to diff when cloning a fresh template.
|
|
if options.get("dry_run"):
|
|
raise CommandError(
|
|
"--dry-run is only supported when adding django CMS to an existing project (name '.')."
|
|
)
|
|
|
|
# Render requirements.in as a template too (django-admin only renders
|
|
# files matching --extension or listed in --name by default); it
|
|
# contains template variables for the selected options.
|
|
options.setdefault("files", [])
|
|
if "requirements.in" not in options["files"]:
|
|
options["files"] = options["files"] + ["requirements.in"]
|
|
|
|
# Create a random SECRET_KEY to put it in the main settings.
|
|
options["secret_key"] = SECRET_KEY_INSECURE_PREFIX + get_random_secret_key()
|
|
|
|
self.stdout.write(self.HEADING("Clone template using django-admin"))
|
|
command = (
|
|
f'django-admin startproject "{name}" ' f'--template {options["template"] or self.get_default_template()}'
|
|
)
|
|
if directory:
|
|
command += f' --directory "{directory}"'
|
|
self.write_command(command)
|
|
|
|
if directory is None:
|
|
top_dir = os.path.join(os.getcwd(), name)
|
|
else:
|
|
top_dir = os.path.abspath(os.path.expanduser(directory))
|
|
# Only a directory created by this run may be cleaned up on failure;
|
|
# an existing target (django-admin renders into it) is left untouched.
|
|
created_dir = directory is None and not os.path.exists(top_dir)
|
|
original_cwd = os.getcwd()
|
|
|
|
try:
|
|
# Run startproject command
|
|
super().handle(
|
|
"project", name, directory, cms_version=cms_version, cms_docs_version=self.major_minor, **options
|
|
)
|
|
self.postprocess(top_dir, options)
|
|
except BaseException:
|
|
# Remove the partially created project if the command fails or is
|
|
# interrupted (e.g. Ctrl-C), but only when we created the directory.
|
|
if created_dir and os.path.isdir(top_dir):
|
|
os.chdir(original_cwd)
|
|
self.stderr.write(self.style.WARNING(f"Removing partially created project at {top_dir}"))
|
|
shutil.rmtree(top_dir, ignore_errors=True)
|
|
raise
|