@@ -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+
518644def _ensure_package_installed (
519645 import_name : str ,
520646 python_path : str ,
@@ -566,9 +692,21 @@ def _ensure_package_installed(
566692
567693
568694def _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]
573711requires = ["setuptools", "wheel"]
574712build-backend = "setuptools.build_meta"
@@ -581,9 +719,7 @@ def _create_pyproject_toml(
581719 {{name = "Your Name", email = "your.email@example.com"}},
582720]
583721dependencies = [
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+
627801def _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
0 commit comments