dynamicweb/cms/tests/test_startcmsproject.py

803 lines
34 KiB
Python

import io
import json
import os
import sys
import tarfile
import tempfile
import urllib.error
from io import StringIO
from unittest import mock
from django.core.management import CommandError
from django.test import SimpleTestCase
from cms.management.commands.startcmsproject import Command
MANAGE_PY = """#!/usr/bin/env python
import os
import sys
def main():
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings')
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
"""
SETTINGS_PY = '''"""Django settings for mysite project."""
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = "test-secret-key"
DEBUG = True
ALLOWED_HOSTS = []
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "mysite.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
LANGUAGE_CODE = "en-us"
'''
OLD_STYLE_SETTINGS_PY = SETTINGS_PY.replace(
"from pathlib import Path\n\nBASE_DIR = Path(__file__).resolve().parent.parent",
"import os\n\nBASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))",
)
COMPUTED_APPS_SETTINGS_PY = SETTINGS_PY.replace(
"""INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
]""",
"""DJANGO_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
]
THIRD_PARTY_APPS = []
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS""",
)
URLS_PY = """from django.contrib import admin
from django.urls import path
urlpatterns = [
path("admin/", admin.site.urls),
]
"""
URLS_PY_I18N = """from django.conf.urls.i18n import i18n_patterns
from django.contrib import admin
from django.urls import path
urlpatterns = [
path("admin/", admin.site.urls),
]
urlpatterns += i18n_patterns(
path("accounts/", include("accounts.urls")),
)
"""
DEFAULT_OPTIONS = {
"interactive": False,
"mode": "traditional",
"versioning": True,
"moderation": False,
"alias": True,
"stories": False,
"history": False,
}
def bundled_rules(**extra):
import cms.management.commands.startcmsproject as command_module
path = os.path.join(os.path.dirname(command_module.__file__), Command.INSTALL_RULES_FILENAME)
with open(path, encoding="utf-8") as handle:
rules = json.load(handle)
rules.update(extra)
return rules
def make_command():
return Command(stdout=StringIO(), stderr=StringIO(), no_color=True)
class LoadInstallRulesTests(SimpleTestCase):
def test_loads_rules_from_custom_template_directory(self):
rules = bundled_rules()
rules["installed_apps"] = [{"items": ["agency_app"]}]
with tempfile.TemporaryDirectory() as template:
with open(os.path.join(template, Command.INSTALL_RULES_FILENAME), "w", encoding="utf-8") as rules_file:
json.dump(rules, rules_file)
command = make_command()
command._requested_template = template
with mock.patch("urllib.request.urlopen") as urlopen:
loaded = command.load_install_rules()
self.assertEqual(loaded["installed_apps"], [{"items": ["agency_app"]}])
urlopen.assert_not_called()
def test_loads_rules_from_custom_remote_template_archive(self):
rules = bundled_rules()
rules["installed_apps"] = [{"items": ["remote_agency_app"]}]
archive = io.BytesIO()
payload = json.dumps(rules).encode()
with tarfile.open(fileobj=archive, mode="w:gz") as template_tar:
member = tarfile.TarInfo(f"agency-template/{Command.INSTALL_RULES_FILENAME}")
member.size = len(payload)
template_tar.addfile(member, io.BytesIO(payload))
response = mock.MagicMock()
response.__enter__.return_value.read.return_value = archive.getvalue()
command = make_command()
command._requested_template = "https://example.com/agency-template.tar.gz"
with mock.patch("urllib.request.urlopen", return_value=response) as urlopen:
loaded = command.load_install_rules()
self.assertEqual(loaded["installed_apps"], [{"items": ["remote_agency_app"]}])
urlopen.assert_called_once_with("https://example.com/agency-template.tar.gz", timeout=15)
def test_metadata_keys_are_ignored(self):
"""A ``$schema`` entry (and any other ``$``-prefixed key) is dropped."""
payload = json.dumps(bundled_rules(**{"$schema": "https://example.com/schema.json"})).encode()
response = mock.MagicMock()
response.__enter__.return_value.read.return_value = payload
command = make_command()
with mock.patch("urllib.request.urlopen", return_value=response):
rules = command.load_install_rules()
self.assertNotIn("$schema", rules)
self.assertIn("installed_apps", rules)
def test_falls_back_to_bundled_rules(self):
command = make_command()
with mock.patch("urllib.request.urlopen", side_effect=urllib.error.URLError("offline")):
rules = command.load_install_rules()
self.assertIn("installed_apps", rules)
self.assertIn("using bundled defaults", command.stderr.getvalue())
def test_can_force_bundled_rules(self):
command = make_command()
command._use_bundled_install_rules = True
with mock.patch("urllib.request.urlopen") as urlopen:
rules = command.load_install_rules()
self.assertIn("installed_apps", rules)
urlopen.assert_not_called()
def test_fetched_rules_without_options_use_bundled_options(self):
rules = bundled_rules()
rules.pop("options")
rules["installed_apps"] = [{"items": ["remote_app"]}]
response = mock.MagicMock()
response.__enter__.return_value.read.return_value = json.dumps(rules).encode()
command = make_command()
with mock.patch("urllib.request.urlopen", return_value=response):
loaded = command.load_install_rules()
self.assertIn("options", loaded)
self.assertEqual(loaded["installed_apps"], [{"items": ["remote_app"]}])
def test_non_object_rules_raise(self):
response = mock.MagicMock()
response.__enter__.return_value.read.return_value = b'["not", "a", "dict"]'
command = make_command()
with mock.patch("urllib.request.urlopen", return_value=response):
with self.assertRaises(CommandError):
command.load_install_rules()
def test_unknown_option_flag_raises(self):
rules = bundled_rules(installed_apps=[{"items": ["cms"], "when": {"flag": "unknown"}}])
response = mock.MagicMock()
response.__enter__.return_value.read.return_value = json.dumps(rules).encode()
command = make_command()
with mock.patch("urllib.request.urlopen", return_value=response):
with self.assertRaises(CommandError) as raised:
command.load_install_rules()
self.assertIn("unknown option flag", str(raised.exception))
class ParserOptionTests(SimpleTestCase):
def test_custom_template_defines_command_options(self):
rules = bundled_rules()
rules["options"]["agency_feature"] = {
"type": "boolean",
"default": False,
"label": "Agency feature",
"help": "Install the agency feature.",
}
with tempfile.TemporaryDirectory() as template:
with open(os.path.join(template, Command.INSTALL_RULES_FILENAME), "w", encoding="utf-8") as rules_file:
json.dump(rules, rules_file)
command = make_command()
with mock.patch.object(sys, "argv", ["djangocms", "mysite", "--template", template]):
parser = command.create_parser("djangocms", "")
parsed = parser.parse_args(["mysite", "--template", template, "--agency-feature"])
self.assertTrue(parsed.agency_feature)
def test_template_options_are_added_from_rules(self):
command = make_command()
command.load_install_rules = mock.Mock(return_value=bundled_rules())
parser = command.create_parser("djangocms", "")
parsed = parser.parse_args(["mysite", "--mode", "headless", "--no-versioning", "--stories", "--history"])
self.assertEqual(parsed.mode, "headless")
self.assertFalse(parsed.versioning)
self.assertTrue(parsed.stories)
self.assertTrue(parsed.history)
def test_use_bundled_rules_option_skips_fetch_while_building_parser(self):
command = make_command()
with mock.patch.object(sys, "argv", ["djangocms", "--use-bundled-install-rules"]), mock.patch(
"urllib.request.urlopen"
) as urlopen:
parser = command.create_parser("djangocms", "")
parsed = parser.parse_args(["mysite", "--use-bundled-install-rules"])
self.assertTrue(parsed.use_bundled_install_rules)
urlopen.assert_not_called()
class EditingHelperTests(SimpleTestCase):
def test_insert_into_list_appends_missing_items(self):
text, added = Command._insert_into_list(SETTINGS_PY, "INSTALLED_APPS", ["cms", "django.contrib.auth"])
self.assertEqual(added, ["cms"]) # existing entries are not duplicated
self.assertIn(' "cms",\n]', text)
def test_insert_into_inline_list(self):
text, added = Command._insert_into_list(SETTINGS_PY, "DIRS", ['BASE_DIR / "templates"'], quote=False)
self.assertEqual(added, ['BASE_DIR / "templates"'])
self.assertIn('BASE_DIR / "templates",', text)
def test_insert_near_anchor_before_and_after(self):
text, added = make_command()._insert_near_anchor(
SETTINGS_PY, "djangocms_simple_admin_style", "django.contrib.admin", before=True
)
self.assertEqual(added, ["djangocms_simple_admin_style"])
self.assertLess(text.index("djangocms_simple_admin_style"), text.index("django.contrib.admin"))
text, added = make_command()._insert_near_anchor(
text,
"django.middleware.locale.LocaleMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
)
self.assertEqual(added, ["django.middleware.locale.LocaleMiddleware"])
self.assertLess(
text.index("django.contrib.sessions.middleware.SessionMiddleware"),
text.index("django.middleware.locale.LocaleMiddleware"),
)
def test_insert_near_anchor_falls_back_to_append(self):
text, added = make_command()._insert_near_anchor(
SETTINGS_PY, "cms", "no.such.entry", list_name="INSTALLED_APPS"
)
self.assertEqual(added, ["cms"])
self.assertIn(' "cms",\n]', text)
def test_ensure_include_import(self):
text = Command._ensure_include_import(URLS_PY)
self.assertIn("from django.urls import include", text)
# Already present: unchanged
self.assertEqual(Command._ensure_include_import(text), text)
def test_uses_i18n_patterns_detects_usage_not_import(self):
# The bare import must not count as usage.
self.assertFalse(Command._uses_i18n_patterns("from django.conf.urls.i18n import i18n_patterns\n"))
self.assertTrue(Command._uses_i18n_patterns(URLS_PY_I18N))
self.assertFalse(Command._uses_i18n_patterns(URLS_PY))
def test_append_i18n_patterns_adds_block(self):
text, added = Command._append_i18n_patterns(URLS_PY_I18N, ['path("", include("cms.urls"))'])
self.assertEqual(added, ['path("", include("cms.urls"))'])
self.assertIn('urlpatterns += i18n_patterns(\n path("", include("cms.urls")),\n)', text)
# The CMS catch-all is appended last, after the project's own i18n block.
self.assertLess(text.index('include("accounts.urls")'), text.index('include("cms.urls")'))
def test_insert_import_after_existing_imports(self):
text = "from pathlib import Path\nimport sys\n\nBASE_DIR = Path()\n"
result = Command._insert_import(text, "import os")
self.assertEqual(result, "from pathlib import Path\nimport sys\nimport os\n\nBASE_DIR = Path()\n")
def test_insert_import_skips_module_preamble(self):
# Without existing imports, the statement must not land before the
# shebang, encoding comment, docstring or __future__ imports.
text = (
"#!/usr/bin/env python\n"
"# -*- coding: utf-8 -*-\n"
'"""Module docstring.\n\nspanning lines.\n"""\n'
"from __future__ import annotations\n\n"
"BASE_DIR = 1\n"
)
result = Command._insert_import(text, "import os")
self.assertIn("from __future__ import annotations\nimport os\n", result)
self.assertTrue(result.startswith("#!/usr/bin/env python\n"))
def test_insert_import_after_single_line_docstring(self):
text = '"""Settings."""\nBASE_DIR = 1\n'
result = Command._insert_import(text, "import os")
self.assertEqual(result, '"""Settings."""\nimport os\nBASE_DIR = 1\n')
def test_get_settings_module_and_urlconf(self):
command = make_command()
with tempfile.TemporaryDirectory() as tmp:
manage_py = os.path.join(tmp, "manage.py")
with open(manage_py, "w", encoding="utf-8") as handle:
handle.write(MANAGE_PY)
self.assertEqual(command.get_settings_module(manage_py), "mysite.settings")
self.assertEqual(command.get_urlconf(SETTINGS_PY, "mysite.settings"), "mysite.urls")
self.assertEqual(command.get_urlconf("", "mysite.settings"), "mysite.urls")
def test_rule_applies(self):
options = {"versioning": True, "mode": "headless"}
self.assertTrue(Command._rule_applies(None, options))
self.assertTrue(Command._rule_applies({"flag": "versioning"}, options))
self.assertFalse(Command._rule_applies({"flag": "stories"}, options))
self.assertTrue(Command._rule_applies({"mode": ["headless", "hybrid"]}, options))
self.assertFalse(Command._rule_applies({"mode": ["traditional"]}, options))
self.assertFalse(Command._rule_applies({"flag": "versioning", "mode": ["traditional"]}, options))
class PackageDerivationTests(SimpleTestCase):
def test_dotted_apps_resolve_to_their_top_level_package(self):
command = make_command()
command.install_packages = mock.Mock()
command.run_management_command = mock.Mock()
apps = [
"cms",
"filer",
"djangocms_frontend",
"djangocms_frontend.contrib.grid",
"djangocms_frontend.contrib.image",
"rest_framework",
"djangocms_rest",
"djangocms_history",
]
packages_map = {
"filer": "django-filer",
"djangocms_history": "djangocms-history>=3",
"rest_framework": "djangorestframework",
}
command._finish_existing_project(apps, packages_map, {"interactive": False})
packages = command.install_packages.call_args.args[0]
self.assertEqual(
packages,
[
"django-cms",
"django-filer",
"djangocms-frontend",
"djangorestframework",
"djangocms-rest",
"djangocms-history>=3",
],
)
def test_install_packages_rejects_unexpected_characters(self):
command = make_command()
for bad in ["django-cms; rm -rf /", "pkg==1.0", "foo bar", "evil$(whoami)", "a/b"]:
with self.assertRaises(CommandError):
command.install_packages([bad])
def test_install_packages_accepts_lower_bound_specs(self):
command = make_command()
with mock.patch.object(command, "running_in_venv", return_value=True), mock.patch("subprocess.run") as run:
run.return_value.returncode = 0
command.install_packages(["djangocms-history>=3"])
run.assert_called_once()
def test_install_packages_refuses_outside_venv(self):
# Outside a virtual environment (and without the opt-out env var) the
# command refuses to pip install and prints the manual command instead.
command = make_command()
with mock.patch.object(command, "running_in_venv", return_value=False), mock.patch.dict(
os.environ, {"DJANGOCMS_ALLOW_PIP_INSTALL": "False"}
):
with self.assertRaises(CommandError):
command.install_packages(["django-cms"])
self.assertIn("virtual environment", command.stderr.getvalue())
def test_finish_existing_project_dry_run_skips_install(self):
command = make_command()
command.install_packages = mock.Mock()
command.run_management_command = mock.Mock()
command._finish_existing_project(["cms"], {}, {"interactive": False, "dry_run": True})
command.install_packages.assert_not_called()
command.run_management_command.assert_not_called()
self.assertIn("Dry run: would install", command.stdout.getvalue())
def test_finish_existing_project_can_skip_install(self):
# Declining the install prompt prints the manual follow-up steps and
# neither installs packages nor runs migrations.
command = make_command()
command.install_packages = mock.Mock()
command.run_management_command = mock.Mock()
command.ask_bool = mock.Mock(return_value=False)
command._finish_existing_project(["cms"], {}, {"interactive": True})
command.install_packages.assert_not_called()
command.run_management_command.assert_not_called()
self.assertIn("Install them later", command.stdout.getvalue())
class AddToExistingProjectTests(SimpleTestCase):
"""Run ``djangocms .`` against a synthetic ``django-admin startproject`` layout."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.project_dir = self.tmp.name
self._old_cwd = os.getcwd()
self.addCleanup(os.chdir, self._old_cwd)
package_dir = os.path.join(self.project_dir, "mysite")
os.makedirs(package_dir)
self._write(os.path.join(self.project_dir, "manage.py"), MANAGE_PY)
self._write(os.path.join(package_dir, "__init__.py"), "")
self._write(os.path.join(package_dir, "settings.py"), SETTINGS_PY)
self._write(os.path.join(package_dir, "urls.py"), URLS_PY)
os.chdir(self.project_dir)
@staticmethod
def _write(path, text):
with open(path, "w", encoding="utf-8") as handle:
handle.write(text)
@staticmethod
def _read(path):
with open(path, encoding="utf-8") as handle:
return handle.read()
def run_command(self, **option_overrides):
command = make_command()
command.load_install_rules = mock.Mock(return_value=bundled_rules())
command._finish_existing_project = mock.Mock()
options = {**DEFAULT_OPTIONS, **option_overrides}
command.add_to_existing_project(options)
return command
def test_traditional_mode(self):
command = self.run_command()
settings_text = self._read("mysite/settings.py")
for app in (
'"cms",',
'"menus",',
'"djangocms_frontend",',
'"djangocms_frontend.contrib.grid",',
'"djangocms_versioning",',
'"djangocms_alias",',
):
self.assertIn(app, settings_text)
self.assertNotIn("djangocms_moderation", settings_text)
self.assertNotIn("djangocms_stories", settings_text)
self.assertNotIn("djangocms_history", settings_text)
self.assertNotIn("djangocms_rest", settings_text)
# Positional inserts
self.assertLess(
settings_text.index("djangocms_simple_admin_style"), settings_text.index("django.contrib.admin")
)
self.assertLess(
settings_text.index("django.contrib.sessions.middleware.SessionMiddleware"),
settings_text.index("django.middleware.locale.LocaleMiddleware"),
)
self.assertIn("sekizai.context_processors.sekizai", settings_text)
self.assertIn("SITE_ID = 1", settings_text)
self.assertIn("CMS_TEMPLATES", settings_text)
self.assertNotIn("CMS_PLACEHOLDERS", settings_text)
self.assertIn('BASE_DIR / "templates"', settings_text)
self.assertTrue(os.path.isfile("templates/cms-base.html"))
urls_text = self._read("mysite/urls.py")
self.assertIn("from django.urls import include", urls_text)
self.assertIn('path("", include("cms.urls"))', urls_text)
self.assertNotIn("djangocms_rest", urls_text)
# The dotted frontend apps must reach the package resolution step.
apps = command._finish_existing_project.call_args.args[0]
self.assertIn("djangocms_frontend.contrib.grid", apps)
def test_history_option(self):
command = self.run_command(history=True)
settings_text = self._read("mysite/settings.py")
self.assertIn('"djangocms_history",', settings_text)
apps = command._finish_existing_project.call_args.args[0]
packages_map = command._finish_existing_project.call_args.args[1]
self.assertIn("djangocms_history", apps)
self.assertEqual(packages_map["djangocms_history"], "djangocms-history>=3")
def test_headless_mode(self):
self.run_command(mode="headless")
settings_text = self._read("mysite/settings.py")
self.assertIn('"djangocms_rest",', settings_text)
self.assertIn('"rest_framework",', settings_text)
self.assertIn("CMS_PLACEHOLDERS", settings_text)
self.assertNotIn("CMS_TEMPLATES", settings_text)
self.assertFalse(os.path.isdir("templates"))
urls_text = self._read("mysite/urls.py")
self.assertIn('path("api/", include("djangocms_rest.urls"))', urls_text)
self.assertNotIn('include("cms.urls")', urls_text)
def test_cms_urls_go_into_i18n_patterns_when_used(self):
# A project whose urls.py already uses i18n_patterns must get the CMS
# catch-all routed through i18n_patterns (so pages keep a language
# prefix), while non-i18n routes stay in the plain urlpatterns list.
self._write("mysite/urls.py", URLS_PY_I18N)
self.run_command(mode="hybrid")
urls_text = self._read("mysite/urls.py")
self.assertIn('urlpatterns += i18n_patterns(\n path("", include("cms.urls")),\n)', urls_text)
# The CMS catch-all stays last, after the project's existing i18n block.
self.assertLess(urls_text.index('include("accounts.urls")'), urls_text.index('include("cms.urls")'))
# The headless API route is not language-prefixed: it joins the plain list.
api_pos = urls_text.index('include("djangocms_rest.urls")')
self.assertLess(api_pos, urls_text.index("i18n_patterns("))
def test_cms_urls_join_plain_list_without_i18n_patterns(self):
# Without i18n_patterns the catch-all simply joins the plain list and
# no i18n_patterns block is introduced.
self.run_command()
urls_text = self._read("mysite/urls.py")
self.assertIn('path("", include("cms.urls"))', urls_text)
self.assertNotIn("i18n_patterns", urls_text)
def test_i18n_urls_rerun_is_idempotent(self):
self._write("mysite/urls.py", URLS_PY_I18N)
self.run_command(mode="hybrid")
urls_text = self._read("mysite/urls.py")
self.run_command(mode="hybrid")
self.assertEqual(self._read("mysite/urls.py"), urls_text)
# The catch-all was added exactly once.
self.assertEqual(urls_text.count('include("cms.urls")'), 1)
def test_rerun_is_idempotent(self):
self.run_command()
settings_text = self._read("mysite/settings.py")
urls_text = self._read("mysite/urls.py")
self.run_command()
self.assertEqual(self._read("mysite/settings.py"), settings_text)
self.assertEqual(self._read("mysite/urls.py"), urls_text)
def test_old_style_base_dir(self):
self._write("mysite/settings.py", OLD_STYLE_SETTINGS_PY)
self.run_command()
settings_text = self._read("mysite/settings.py")
self.assertIn('os.path.join(BASE_DIR, "templates")', settings_text)
self.assertNotIn('BASE_DIR / "templates"', settings_text)
def test_schema_entry_in_rules_is_harmless(self):
command = make_command()
command.load_install_rules = mock.Mock(
return_value=bundled_rules(**{"$schema": "https://example.com/schema.json"})
)
command._finish_existing_project = mock.Mock()
command.add_to_existing_project(dict(DEFAULT_OPTIONS))
self.assertIn('"cms",', self._read("mysite/settings.py"))
def test_missing_manage_py(self):
os.remove("manage.py")
with self.assertRaises(CommandError):
self.run_command()
def test_missing_settings_file_fails_cleanly(self):
# manage.py still resolves to "mysite.settings", but the settings file
# itself is gone: the command must fail with a clear message rather than
# blowing up while reading a missing file.
os.remove("mysite/settings.py")
with self.assertRaises(CommandError) as raised:
self.run_command()
self.assertIn("Cannot locate the settings file", str(raised.exception))
def test_missing_urls_file_degrades_gracefully(self):
# ROOT_URLCONF points at "mysite.urls" but the file is gone. The settings
# edits must still be applied and the install step still reached; the
# missing urls only produces a warning, not a crash.
os.remove("mysite/urls.py")
command = self.run_command()
self.assertIn('"cms",', self._read("mysite/settings.py"))
self.assertIn("Could not locate the urls file", command.stderr.getvalue())
command._finish_existing_project.assert_called_once()
def test_computed_installed_apps_recovery(self):
# INSTALLED_APPS is built from other lists (DJANGO_APPS + THIRD_PARTY_APPS),
# so there is no literal list to splice into. The command must recover by
# appending a reassignment rather than silently doing nothing.
self._write("mysite/settings.py", COMPUTED_APPS_SETTINGS_PY)
command = self.run_command()
settings_text = self._read("mysite/settings.py")
# The original computed assignment is preserved...
self.assertIn("INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS", settings_text)
# ...and the plain apps are added through an appended reassignment.
self.assertIn("INSTALLED_APPS = INSTALLED_APPS + [", settings_text)
self.assertIn('"cms",', settings_text)
self.assertIn('"menus",', settings_text)
# The admin style is anchored on django.contrib.admin, which lives in the
# DJANGO_APPS literal, so it is still placed precisely (not in the wrap block).
self.assertLess(
settings_text.index("djangocms_simple_admin_style"),
settings_text.index("django.contrib.admin"),
)
self.assertNotIn("djangocms_simple_admin_style", settings_text.split("INSTALLED_APPS = INSTALLED_APPS")[1])
# The user is warned to review the appended block.
self.assertIn("INSTALLED_APPS is a computed value", command.stderr.getvalue())
# Packages are still derived from all matched apps.
self.assertIn("cms", command._finish_existing_project.call_args.args[0])
def test_computed_installed_apps_rerun_is_idempotent(self):
self._write("mysite/settings.py", COMPUTED_APPS_SETTINGS_PY)
self.run_command()
settings_text = self._read("mysite/settings.py")
self.run_command()
self.assertEqual(self._read("mysite/settings.py"), settings_text)
# The recovery reassignment was appended exactly once.
self.assertEqual(settings_text.count("INSTALLED_APPS = INSTALLED_APPS + ["), 1)
def test_dry_run_makes_no_changes_and_shows_diff(self):
# --dry-run must leave every file untouched and report the planned edits
# purely as unified diffs (no per-item summary).
settings_before = self._read("mysite/settings.py")
urls_before = self._read("mysite/urls.py")
command = self.run_command(dry_run=True)
self.assertEqual(self._read("mysite/settings.py"), settings_before)
self.assertEqual(self._read("mysite/urls.py"), urls_before)
self.assertFalse(os.path.isdir("templates"))
out = command.stdout.getvalue()
self.assertIn("Dry run", out)
# The diff is the whole report -- the per-item summary is gone.
self.assertNotIn(" + INSTALLED_APPS:", out)
self.assertNotIn("Updated mysite/", out)
# settings.py change shown as a diff
self.assertIn("--- mysite/settings.py", out)
self.assertIn('+ "cms",', out)
# the base template that would be created shows as a new-file diff
self.assertIn("+++ templates/cms-base.html", out)
self.assertIn("/dev/null", out)
# urls.py change shown as a diff
self.assertIn("--- mysite/urls.py", out)
self.assertIn('include("cms.urls")', out)
def test_interactive_decline_makes_no_changes(self):
# Declining the "Continue?" prompt must abort before anything is read or
# written -- the rules are not even fetched and both files stay byte-identical.
command = make_command()
command.load_install_rules = mock.Mock(return_value=bundled_rules())
command._finish_existing_project = mock.Mock()
command.ask_bool = mock.Mock(return_value=False)
command.add_to_existing_project({**DEFAULT_OPTIONS, "interactive": True})
self.assertEqual(self._read("mysite/settings.py"), SETTINGS_PY)
self.assertEqual(self._read("mysite/urls.py"), URLS_PY)
self.assertIn("Aborted", command.stdout.getvalue())
command.load_install_rules.assert_not_called()
command._finish_existing_project.assert_not_called()
class HandleValidationTests(SimpleTestCase):
def test_moderation_requires_versioning(self):
command = make_command()
command.load_install_rules = mock.Mock(return_value=bundled_rules())
options = {
"name": "mysite",
"directory": None,
"interactive": False,
"prompt": False,
"template": None,
"mode": "traditional",
"versioning": False,
"moderation": True,
"alias": None,
"stories": None,
}
with self.assertRaises(CommandError) as raised:
command.handle(**options)
self.assertIn("moderation requires versioning", str(raised.exception))
def test_dry_run_rejected_for_new_project(self):
command = make_command()
command.load_install_rules = mock.Mock(return_value=bundled_rules())
options = {
"name": "mysite",
"directory": None,
"interactive": False,
"prompt": False,
"template": None,
"mode": "traditional",
"versioning": True,
"moderation": False,
"alias": True,
"stories": False,
"dry_run": True,
}
with self.assertRaises(CommandError) as raised:
command.handle(**options)
self.assertIn("--dry-run is only supported", str(raised.exception))
def test_missing_name_with_noinput(self):
command = make_command()
command.load_install_rules = mock.Mock(return_value=bundled_rules())
options = {
"name": None,
"directory": None,
"interactive": False,
"prompt": False,
"template": None,
"mode": None,
"versioning": None,
"moderation": None,
"alias": None,
"stories": None,
}
with self.assertRaises(CommandError) as raised:
command.handle(**options)
self.assertEqual(str(raised.exception), Command.missing_args_message)
def test_missing_name_without_input_fails_cleanly(self):
command = make_command()
command.load_install_rules = mock.Mock(return_value=bundled_rules())
options = {
"name": None,
"directory": None,
"interactive": True,
"prompt": False,
"template": None,
"mode": None,
"versioning": None,
"moderation": None,
"alias": None,
"stories": None,
"history": None,
}
with mock.patch("builtins.input", side_effect=EOFError), self.assertRaises(CommandError) as raised:
command.handle(**options)
self.assertIn("Input is not available", str(raised.exception))