#!/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 ``.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"""|\'\'\')', 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[ \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