Skip to content

Commit 167f9ce

Browse files
committed
feat: add Wagtail CMS support to dbx project add
- Add --wagtail/-w flag to `dbx project add` that enables Wagtail CMS in the generated project (settings, URLs, pyproject.toml deps) - Fix Wagtail/MongoDB compatibility: add custom AppConfigs for wagtail.embeds, wagtail.users, and taggit (all hardcode AutoField); add missing wagtail.sites and wagtail.users to WAGTAIL_INSTALLED_APPS; add wagtailsites/wagtailusers migration package dirs - Add HomeView to project template that injects live Wagtail page tree into the root landing page navbar (gracefully degrades when Wagtail is not installed) - Fix dbx project run: create frontend/build dir before migrations to suppress staticfiles.W004; show last 3 stderr lines on failure instead of only the last line - Fix dbx project run: detect and reinstall declared dependencies that have a stale editable-install dist-info pointing to a deleted source directory (scoped to current project's pyproject.toml deps only)
1 parent ae4a7bd commit 167f9ce

9 files changed

Lines changed: 453 additions & 45 deletions

File tree

src/dbx_python_cli/commands/project.py

Lines changed: 194 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,12 @@ def add_project(
225225
"-f/-F",
226226
help="Add frontend (default: True)",
227227
),
228+
add_wagtail: bool = typer.Option(
229+
False,
230+
"--wagtail/--no-wagtail",
231+
"-w/-W",
232+
help="Enable Wagtail CMS (default: False)",
233+
),
228234
auto_install: bool = typer.Option(
229235
True,
230236
"--install/--no-install",
@@ -240,6 +246,7 @@ def add_project(
240246
"""
241247
Create a new Django project using bundled templates.
242248
Frontend is added by default. Use --no-frontend to skip frontend creation.
249+
Use --wagtail to enable Wagtail CMS.
243250
244251
Projects are created in base_dir/projects/ by default.
245252
If no name is provided, a random name is generated.
@@ -249,6 +256,7 @@ def add_project(
249256
dbx project add # Create with random name (includes frontend)
250257
dbx project add myproject # Create with explicit name (includes frontend)
251258
dbx project add myproject --no-frontend # Create without frontend
259+
dbx project add myproject --wagtail # Create with Wagtail CMS enabled
252260
dbx project add -d ~/custom/path # Create with random name in custom directory
253261
dbx project add myproject -d ~/custom/path # Create in custom directory
254262
dbx project add myproject --base-dir ~/path/to/myproject # Create directly at ~/path/to/myproject
@@ -263,6 +271,8 @@ def add_project(
263271
base_dir = None
264272
if not isinstance(add_frontend, bool):
265273
add_frontend = True
274+
if not isinstance(add_wagtail, bool):
275+
add_wagtail = False
266276
if not isinstance(auto_install, bool):
267277
auto_install = True
268278
if not isinstance(python_path_override, (str, type(None))):
@@ -443,7 +453,18 @@ def add_project(
443453
raise typer.Exit(code=1)
444454

445455
# Add pyproject.toml after project creation
446-
_create_pyproject_toml(project_path, name, settings_path)
456+
_create_pyproject_toml(project_path, name, settings_path, wagtail=add_wagtail)
457+
458+
# Enable Wagtail CMS if requested
459+
if add_wagtail:
460+
typer.echo(f"🌿 Enabling Wagtail CMS for project '{name}'...")
461+
try:
462+
_enable_wagtail(project_path, name)
463+
except Exception as e:
464+
typer.echo(
465+
f"⚠️ Project created successfully, but Wagtail setup failed: {e}",
466+
err=True,
467+
)
447468

448469
# Create frontend by default (unless --no-frontend is specified)
449470
if add_frontend:
@@ -515,6 +536,111 @@ def add_project(
515536
)
516537

517538

539+
def _fix_broken_editable_installs(
540+
python_path: str, project_path: Path, verbose: bool = False
541+
) -> None:
542+
"""Reinstall any declared dependency that has a stale editable-install dist-info.
543+
544+
Scoped to the project's declared dependencies so that old removed projects
545+
(which also leave behind dist-info entries) are never touched.
546+
547+
uv considers a package satisfied if its dist-info exists, even when the source
548+
directory the editable install pointed to has been deleted. This leaves the package
549+
unimportable while uv's resolver thinks it's fine.
550+
"""
551+
import json
552+
import re
553+
import tomllib
554+
555+
pyproject = project_path / "pyproject.toml"
556+
if not pyproject.exists():
557+
return
558+
559+
try:
560+
with open(pyproject, "rb") as f:
561+
toml_data = tomllib.load(f)
562+
except Exception:
563+
return
564+
565+
raw_deps = toml_data.get("project", {}).get("dependencies", [])
566+
if not raw_deps:
567+
return
568+
569+
# Build a set of normalised names (both hyphen and underscore forms) so we
570+
# can match dist-info directory names regardless of normalisation.
571+
declared: set[str] = set()
572+
for dep in raw_deps:
573+
name = re.split(r"[>=<!;\[\s]", dep, maxsplit=1)[0].strip()
574+
declared.add(name.lower().replace("-", "_"))
575+
declared.add(name.lower().replace("_", "-"))
576+
577+
site_result = subprocess.run(
578+
[python_path, "-c", "import site; print(site.getsitepackages()[0])"],
579+
capture_output=True,
580+
text=True,
581+
check=False,
582+
)
583+
if site_result.returncode != 0:
584+
return
585+
586+
site_packages = Path(site_result.stdout.strip())
587+
if not site_packages.exists():
588+
return
589+
590+
for dist_info in site_packages.glob("*.dist-info"):
591+
pkg_raw = dist_info.stem.rsplit("-", 1)[0]
592+
if (
593+
pkg_raw.lower() not in declared
594+
and pkg_raw.lower().replace("_", "-") not in declared
595+
and pkg_raw.lower().replace("-", "_") not in declared
596+
):
597+
continue # not a declared dependency of this project
598+
599+
direct_url = dist_info / "direct_url.json"
600+
if not direct_url.exists():
601+
continue
602+
try:
603+
data = json.loads(direct_url.read_text())
604+
except Exception:
605+
continue
606+
if not data.get("dir_info", {}).get("editable"):
607+
continue
608+
url = data.get("url", "")
609+
if not url.startswith("file://"):
610+
continue
611+
source_path = Path(url[len("file://"):])
612+
if source_path.exists() and (
613+
(source_path / "pyproject.toml").exists()
614+
or (source_path / "setup.py").exists()
615+
):
616+
continue # source is still a valid installable package
617+
618+
top_level = dist_info / "top_level.txt"
619+
if not top_level.exists():
620+
continue
621+
modules = [m.strip() for m in top_level.read_text().splitlines() if m.strip()]
622+
if not modules:
623+
continue
624+
625+
check = subprocess.run(
626+
[python_path, "-c", f"import {modules[0]}"],
627+
capture_output=True,
628+
cwd="/tmp",
629+
check=False,
630+
)
631+
if check.returncode == 0:
632+
continue
633+
634+
pkg_name = pkg_raw.replace("_", "-")
635+
typer.echo(f"🔧 Reinstalling broken editable install: {pkg_name}")
636+
subprocess.run(
637+
["uv", "pip", "install", "--reinstall", "--python", python_path, pkg_name],
638+
capture_output=not verbose,
639+
text=True,
640+
check=False,
641+
)
642+
643+
518644
def _ensure_package_installed(
519645
import_name: str,
520646
python_path: str,
@@ -566,9 +692,21 @@ def _ensure_package_installed(
566692

567693

568694
def _create_pyproject_toml(
569-
project_path: Path, project_name: str, settings_path: str = "settings.base"
695+
project_path: Path,
696+
project_name: str,
697+
settings_path: str = "settings.base",
698+
wagtail: bool = False,
570699
):
571700
"""Create a pyproject.toml file for the Django project."""
701+
base_deps = [
702+
'"django-debug-toolbar"',
703+
'"django-mongodb-backend"',
704+
'"python-webpack-boilerplate"',
705+
]
706+
if wagtail:
707+
base_deps.append('"wagtail"')
708+
deps_str = ",\n ".join(base_deps)
709+
572710
pyproject_content = f"""[build-system]
573711
requires = ["setuptools", "wheel"]
574712
build-backend = "setuptools.build_meta"
@@ -581,9 +719,7 @@ def _create_pyproject_toml(
581719
{{name = "Your Name", email = "your.email@example.com"}},
582720
]
583721
dependencies = [
584-
"django-debug-toolbar",
585-
"django-mongodb-backend",
586-
"python-webpack-boilerplate",
722+
{deps_str},
587723
]
588724
589725
[project.optional-dependencies]
@@ -624,6 +760,44 @@ def _create_pyproject_toml(
624760
typer.echo(f"⚠️ Failed to create pyproject.toml: {e}", err=True)
625761

626762

763+
def _enable_wagtail(project_path: Path, project_name: str) -> None:
764+
"""Uncomment the Wagtail block in settings and append Wagtail URL patterns."""
765+
settings_file = project_path / project_name / "settings" / f"{project_name}.py"
766+
if settings_file.exists():
767+
content = settings_file.read_text()
768+
content = content.replace(
769+
"# from .wagtail import * # noqa\n"
770+
"# INSTALLED_APPS += WAGTAIL_INSTALLED_APPS # noqa: F405\n"
771+
"# MIDDLEWARE += WAGTAIL_MIDDLEWARE # noqa: F405\n"
772+
"# MIGRATION_MODULES.update(WAGTAIL_MIGRATION_MODULES) # noqa: F405",
773+
"from .wagtail import * # noqa\n"
774+
"INSTALLED_APPS += WAGTAIL_INSTALLED_APPS # noqa: F405\n"
775+
"MIDDLEWARE += WAGTAIL_MIDDLEWARE # noqa: F405\n"
776+
"MIGRATION_MODULES.update(WAGTAIL_MIGRATION_MODULES) # noqa: F405",
777+
)
778+
settings_file.write_text(content)
779+
780+
urls_file = project_path / project_name / "urls.py"
781+
if urls_file.exists():
782+
wagtail_block = (
783+
"\n\n# Wagtail CMS\n"
784+
"from django.conf import settings\n"
785+
"from django.conf.urls.static import static\n"
786+
"from django.urls import include\n"
787+
"from wagtail import urls as wagtail_urls\n"
788+
"from wagtail.admin import urls as wagtailadmin_urls\n"
789+
"from wagtail.documents import urls as wagtaildocs_urls\n"
790+
"\n"
791+
"urlpatterns += [\n"
792+
' path("cms/", include(wagtailadmin_urls)),\n'
793+
' path("documents/", include(wagtaildocs_urls)),\n'
794+
' path("", include(wagtail_urls)),\n'
795+
"] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)\n"
796+
)
797+
with open(urls_file, "a") as f:
798+
f.write(wagtail_block)
799+
800+
627801
def _add_frontend(
628802
project_name: str,
629803
directory: Path = Path("."),
@@ -805,13 +979,23 @@ def run_project(
805979
err=True,
806980
)
807981

982+
# Fix any declared dependency that has a stale editable-install dist-info.
983+
# Scoped to this project's pyproject.toml so removed projects in the same
984+
# venv are never accidentally reinstalled.
985+
_fix_broken_editable_installs(python_path, proj.project_path, verbose)
986+
808987
# Ensure django_mongodb_extensions is available: prefer a local clone over PyPI.
809988
_ensure_package_installed("django_mongodb_extensions", python_path, proj.base_dir, verbose)
810989

811990
# Check if frontend exists
812991
frontend_path = proj.project_path / "frontend"
813992
has_frontend = frontend_path.exists() and (frontend_path / "package.json").exists()
814993

994+
# Ensure the frontend build directory exists so Django's staticfiles system
995+
# check (W004) doesn't fire before webpack has had a chance to create it.
996+
if has_frontend:
997+
(frontend_path / "build").mkdir(exist_ok=True)
998+
815999
typer.echo(f"🚀 Running project '{proj.name}' on http://{host}:{port}")
8161000

8171001
# Set up environment
@@ -839,8 +1023,11 @@ def run_project(
8391023
if verbose and result.stderr:
8401024
typer.echo(result.stderr, err=True)
8411025
elif not verbose and result.stderr:
842-
last_line = result.stderr.strip().splitlines()[-1]
843-
typer.echo(f" {last_line}", err=True)
1026+
# Show the last few non-blank lines so the real error isn't hidden
1027+
# behind a warning printed earlier in the same stderr stream.
1028+
lines = [l for l in result.stderr.strip().splitlines() if l.strip()]
1029+
for line in lines[-3:]:
1030+
typer.echo(f" {line}", err=True)
8441031
raise typer.Exit(code=result.returncode)
8451032
typer.echo("✅ Migrations completed successfully")
8461033

src/dbx_python_cli/templates/project_template/project_name/migrations/wagtailsites/__init__.py

Whitespace-only changes.

src/dbx_python_cli/templates/project_template/project_name/migrations/wagtailusers/__init__.py

Whitespace-only changes.

src/dbx_python_cli/templates/project_template/project_name/settings/apps/wagtail.py

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,54 +15,79 @@
1515
from wagtail.contrib.forms.apps import WagtailFormsAppConfig
1616
from wagtail.contrib.redirects.apps import WagtailRedirectsAppConfig
1717
from wagtail.documents.apps import WagtailDocsAppConfig
18+
from wagtail.embeds.apps import WagtailEmbedsAppConfig
1819
from wagtail.images.apps import WagtailImagesAppConfig
1920
from wagtail.search.apps import WagtailSearchAppConfig
2021
from wagtail.snippets.apps import WagtailSnippetsAppConfig
22+
from wagtail.users.apps import WagtailUsersAppConfig
23+
from taggit.apps import TaggitAppConfig
24+
25+
26+
def _auto_field():
27+
return getattr(settings, "DEFAULT_AUTO_FIELD", "django.db.models.BigAutoField")
2128

2229

2330
class CustomWagtailConfig(WagtailAppConfig):
2431
@property
2532
def default_auto_field(self):
26-
return getattr(settings, "DEFAULT_AUTO_FIELD", "django.db.models.BigAutoField")
33+
return _auto_field()
2734

2835

2936
class CustomWagtailAdminConfig(WagtailAdminAppConfig):
3037
@property
3138
def default_auto_field(self):
32-
return getattr(settings, "DEFAULT_AUTO_FIELD", "django.db.models.BigAutoField")
39+
return _auto_field()
3340

3441

3542
class CustomWagtailDocsConfig(WagtailDocsAppConfig):
3643
@property
3744
def default_auto_field(self):
38-
return getattr(settings, "DEFAULT_AUTO_FIELD", "django.db.models.BigAutoField")
45+
return _auto_field()
46+
47+
48+
class CustomWagtailEmbedsConfig(WagtailEmbedsAppConfig):
49+
@property
50+
def default_auto_field(self):
51+
return _auto_field()
3952

4053

4154
class CustomWagtailImagesConfig(WagtailImagesAppConfig):
4255
@property
4356
def default_auto_field(self):
44-
return getattr(settings, "DEFAULT_AUTO_FIELD", "django.db.models.BigAutoField")
57+
return _auto_field()
4558

4659

4760
class CustomWagtailSearchConfig(WagtailSearchAppConfig):
4861
@property
4962
def default_auto_field(self):
50-
return getattr(settings, "DEFAULT_AUTO_FIELD", "django.db.models.BigAutoField")
63+
return _auto_field()
5164

5265

5366
class CustomWagtailSnippetsConfig(WagtailSnippetsAppConfig):
5467
@property
5568
def default_auto_field(self):
56-
return getattr(settings, "DEFAULT_AUTO_FIELD", "django.db.models.BigAutoField")
69+
return _auto_field()
5770

5871

5972
class CustomWagtailFormsConfig(WagtailFormsAppConfig):
6073
@property
6174
def default_auto_field(self):
62-
return getattr(settings, "DEFAULT_AUTO_FIELD", "django.db.models.BigAutoField")
75+
return _auto_field()
6376

6477

6578
class CustomWagtailRedirectsConfig(WagtailRedirectsAppConfig):
6679
@property
6780
def default_auto_field(self):
68-
return getattr(settings, "DEFAULT_AUTO_FIELD", "django.db.models.BigAutoField")
81+
return _auto_field()
82+
83+
84+
class CustomWagtailUsersConfig(WagtailUsersAppConfig):
85+
@property
86+
def default_auto_field(self):
87+
return _auto_field()
88+
89+
90+
class CustomTaggitConfig(TaggitAppConfig):
91+
@property
92+
def default_auto_field(self):
93+
return _auto_field()

0 commit comments

Comments
 (0)