diff --git a/.gitignore b/.gitignore index 7e54e8f..c96fb6f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ django_mongodb_cli.egg-info/ -django_mongodb_cli/__pycache__/ +__pycache__ /src/ .idea server.log diff --git a/demo/__init__.py b/demo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo/medical_records/__init__.py b/demo/medical_records/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo/medical_records/admin.py b/demo/medical_records/admin.py new file mode 100644 index 0000000..905414a --- /dev/null +++ b/demo/medical_records/admin.py @@ -0,0 +1,7 @@ +from django.contrib import admin +from .models import Patient + + +@admin.register(Patient) +class PatientAdmin(admin.ModelAdmin): + pass diff --git a/demo/medical_records/apps.py b/demo/medical_records/apps.py new file mode 100644 index 0000000..7a99d59 --- /dev/null +++ b/demo/medical_records/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MedicalRecordsConfig(AppConfig): + default_auto_field = "django_mongodb_backend.fields.ObjectIdAutoField" + name = "demo.medical_records" diff --git a/demo/medical_records/management/commands/create_patient.py b/demo/medical_records/management/commands/create_patient.py new file mode 100644 index 0000000..8cacc89 --- /dev/null +++ b/demo/medical_records/management/commands/create_patient.py @@ -0,0 +1,77 @@ +import os +import random +from django.core.management.base import BaseCommand +from faker import Faker + +from django_mongodb_demo.models import Patient, PatientRecord, Billing + + +class Command(BaseCommand): + help = "Create patients with embedded patient records and billing using Faker. Optionally set MONGODB_URI." + + def add_arguments(self, parser): + parser.add_argument( + "num_patients", type=int, help="Number of patients to create" + ) + parser.add_argument( + "--flush", + action="store_true", + help="Delete all existing patients before creating new ones", + ) + parser.add_argument( + "--mongodb-uri", + type=str, + help="MongoDB connection URI to set as MONGODB_URI env var", + ) + + def handle(self, *args, **options): + fake = Faker() + + num_patients = options["num_patients"] + + # Set MONGODB_URI if provided + if options.get("mongodb_uri"): + os.environ["MONGODB_URI"] = options["mongodb_uri"] + self.stdout.write( + self.style.SUCCESS(f"MONGODB_URI set to: {options['mongodb_uri']}") + ) + + # Optionally flush + if options["flush"]: + Patient.objects.all().delete() + self.stdout.write(self.style.WARNING("Deleted all existing patients.")) + + for _ in range(num_patients): + # Create a Billing object + billing = Billing( + cc_type=fake.credit_card_provider(), cc_number=fake.credit_card_number() + ) + + # Create a PatientRecord object + record = PatientRecord( + ssn=fake.ssn(), + billing=billing, + bill_amount=round(random.uniform(50.0, 5000.0), 2), + ) + + # Create Patient + patient = Patient( + patient_name=fake.name(), + patient_id=random.randint(100000, 999999), + patient_record=record, + ) + patient.save() + + self.stdout.write( + self.style.SUCCESS( + f"Created Patient: {patient.patient_name} ({patient.patient_id})" + ) + ) + self.stdout.write(f" SSN: {record.ssn}") + self.stdout.write(f" Billing CC Type: {billing.cc_type}") + self.stdout.write(f" Billing CC Number: {billing.cc_number}") + self.stdout.write(f" Bill Amount: ${record.bill_amount}") + + self.stdout.write( + self.style.SUCCESS(f"Successfully created {num_patients} patient(s).") + ) diff --git a/demo/medical_records/models.py b/demo/medical_records/models.py new file mode 100644 index 0000000..fa6e3a7 --- /dev/null +++ b/demo/medical_records/models.py @@ -0,0 +1,27 @@ +from django.db import models +from django_mongodb_backend.models import EmbeddedModel +from django_mongodb_backend.fields import ( + EmbeddedModelField, + EncryptedEmbeddedModelField, + EncryptedCharField, +) + + +class Patient(models.Model): + patient_name = models.CharField(max_length=255) + patient_id = models.BigIntegerField() + patient_record = EmbeddedModelField("PatientRecord") + + def __str__(self): + return f"{self.patient_name} ({self.patient_id})" + + +class PatientRecord(EmbeddedModel): + ssn = EncryptedCharField(max_length=11) + billing = EncryptedEmbeddedModelField("Billing") + bill_amount = models.DecimalField(max_digits=10, decimal_places=2) + + +class Billing(EmbeddedModel): + cc_type = models.CharField(max_length=50) + cc_number = models.CharField(max_length=20) diff --git a/demo/medical_records/tests.py b/demo/medical_records/tests.py new file mode 100644 index 0000000..6cfaf7b --- /dev/null +++ b/demo/medical_records/tests.py @@ -0,0 +1,14 @@ +from django.test import TestCase +from .models import Author, Article + + +class DemoTest(TestCase): + def test_create_author_and_article(self): + author = Author.objects.create(name="Alice", email="alice@example.com") + article = Article.objects.create( + title="Hello MongoDB", + slug="hello-mongodb", + author=author, + content="Testing MongoDB backend.", + ) + self.assertEqual(article.author.name, "Alice") diff --git a/demo/medical_records/urls.py b/demo/medical_records/urls.py new file mode 100644 index 0000000..44b872f --- /dev/null +++ b/demo/medical_records/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path("", views.article_list, name="article_list"), + path("article//", views.article_detail, name="article_detail"), +] diff --git a/demo/medical_records/views.py b/demo/medical_records/views.py new file mode 100644 index 0000000..a127ce2 --- /dev/null +++ b/demo/medical_records/views.py @@ -0,0 +1,15 @@ +from django.shortcuts import render, get_object_or_404 +from .models import Article + + +def article_list(request): + articles = Article.objects.all().order_by("-published_at") + return render(request, "demo/article_list.html", {"articles": articles}) + + +def article_detail(request, slug): + article = get_object_or_404(Article, slug=slug) + comments = article.comments.all().order_by("-created_at") + return render( + request, "demo/article_detail.html", {"article": article, "comments": comments} + ) diff --git a/django_mongodb_cli/app.py b/django_mongodb_cli/app.py index c369fb1..60c1767 100644 --- a/django_mongodb_cli/app.py +++ b/django_mongodb_cli/app.py @@ -8,7 +8,7 @@ app = typer.Typer(help="Manage Django apps.") -@app.command("create") +@app.command("add") def add_app(name: str, project_name: str, directory: Path = Path(".")): """ Create a new Django app inside an existing project using bundled templates. diff --git a/django_mongodb_cli/project.py b/django_mongodb_cli/project.py index 75ba30c..c31e9f0 100644 --- a/django_mongodb_cli/project.py +++ b/django_mongodb_cli/project.py @@ -5,6 +5,7 @@ import subprocess import importlib.resources as resources import os +import sys from .frontend import add_frontend as _add_frontend from .utils import Repo @@ -38,7 +39,45 @@ def add_project( name, ] typer.echo(f"📦 Creating project: {name}") - subprocess.run(cmd, check=True) + + # Run django-admin in a way that surfaces a clean, user-friendly error + # instead of a full Python traceback when Django is missing or + # misconfigured in the current environment. + try: + result = subprocess.run( + cmd, + check=False, + capture_output=True, + text=True, + ) + except FileNotFoundError: + typer.echo( + "❌ 'django-admin' command not found. Make sure Django is installed " + "in this environment and that 'django-admin' is on your PATH.", + err=True, + ) + raise typer.Exit(code=1) + + if result.returncode != 0: + # Try to show a concise reason (e.g. "ModuleNotFoundError: No module named 'django'") + reason = None + if result.stderr: + lines = [ + line.strip() for line in result.stderr.splitlines() if line.strip() + ] + if lines: + reason = lines[-1] + + typer.echo( + "❌ Failed to create project using django-admin. " + "This usually means Django is not installed or is misconfigured " + "in the current Python environment.", + err=True, + ) + if reason: + typer.echo(f" Reason: {reason}", err=True) + + raise typer.Exit(code=result.returncode) # Add pyproject.toml after project creation _create_pyproject_toml(project_path, name) @@ -106,15 +145,83 @@ def _create_pyproject_toml(project_path: Path, project_name: str): @project.command("remove") def remove_project(name: str, directory: Path = Path(".")): - """ - Delete a Django project by name. + """Delete a Django project by name. + + This will first attempt to uninstall the project package using pip in the + current Python environment, then remove the project directory. """ target = directory / name - if target.exists() and target.is_dir(): - shutil.rmtree(target) - typer.echo(f"🗑️ Removed project {name}") - else: + + if not target.exists() or not target.is_dir(): typer.echo(f"❌ Project {name} does not exist.", err=True) + return + + # Try to uninstall the package from the current environment before + # removing the project directory. Failures here are non-fatal so that + # filesystem cleanup still proceeds. + uninstall_cmd = [sys.executable, "-m", "pip", "uninstall", "-y", name] + typer.echo(f"📦 Uninstalling project package '{name}' with pip") + try: + result = subprocess.run(uninstall_cmd, check=False) + if result.returncode != 0: + typer.echo( + f"⚠️ pip uninstall exited with code {result.returncode}. " + "Proceeding to remove project files.", + err=True, + ) + except FileNotFoundError: + typer.echo( + "⚠️ Could not run pip to uninstall the project package. " + "Proceeding to remove project files.", + err=True, + ) + + shutil.rmtree(target) + typer.echo(f"🗑️ Removed project {name}") + + +@project.command("install") +def install_project(name: str, directory: Path = Path(".")): + """Install a generated Django project by running ``pip install .`` in its directory. + + Example: + dm project install qe + """ + project_path = directory / name + + if not project_path.exists() or not project_path.is_dir(): + typer.echo( + f"❌ Project '{name}' does not exist at {project_path}.", + err=True, + ) + raise typer.Exit(code=1) + + typer.echo(f"📦 Installing project '{name}' with pip (cwd={project_path})") + + # Use the current Python interpreter to ensure we install into the + # same environment that is running the CLI. + cmd = [sys.executable, "-m", "pip", "install", "."] + try: + result = subprocess.run( + cmd, + cwd=project_path, + check=False, + ) + except FileNotFoundError: + typer.echo( + "❌ Could not run pip. Make sure Python and pip are available in this environment.", + err=True, + ) + raise typer.Exit(code=1) + + if result.returncode != 0: + typer.echo( + f"❌ pip install failed with exit code {result.returncode}.", + err=True, + ) + raise typer.Exit(code=result.returncode) + + typer.echo(f"✅ Successfully installed project '{name}'") def _django_manage_command( diff --git a/django_mongodb_cli/templates/project_template/project_name/settings/project_name.py b/django_mongodb_cli/templates/project_template/project_name/settings/project_name.py index a516a30..bc47642 100644 --- a/django_mongodb_cli/templates/project_template/project_name/settings/project_name.py +++ b/django_mongodb_cli/templates/project_template/project_name/settings/project_name.py @@ -4,10 +4,12 @@ from pymongo.encryption_options import AutoEncryptionOpts from bson import ObjectId +import os + # Queryable Encryption INSTALLED_APPS += [ # noqa "django_mongodb_backend", - "django_mongodb_demo", + "demo.medical_records", ] DATABASES["encrypted"] = { # noqa @@ -22,6 +24,8 @@ } }, key_vault_namespace="{{ project_name }}_encrypted.__keyVault", + crypt_shared_lib_path=os.getenv("CRYPT_SHARED_LIB_PATH"), + crypt_shared_lib_required=True, ), }, } diff --git a/django_mongodb_cli/utils.py b/django_mongodb_cli/utils.py index 513a1cb..95accde 100644 --- a/django_mongodb_cli/utils.py +++ b/django_mongodb_cli/utils.py @@ -9,11 +9,6 @@ from git import GitCommandError from git import Repo as GitRepo -URL_PATTERN = re.compile(r"git\+ssh://(?:[^@]+@)?([^/]+)/([^@]+)") -BRANCH_PATTERN = re.compile( - r"git\+ssh://git@github\.com/[^/]+/[^@]+@([a-zA-Z0-9_\-\.]+)\b" -) - class Repo: """ @@ -95,10 +90,23 @@ def origin_cfg(self) -> dict: @staticmethod def parse_git_url(raw: str) -> tuple[str, str]: - m_branch = BRANCH_PATTERN.search(raw) - branch = m_branch.group(1) if m_branch else "main" - m_url = URL_PATTERN.search(raw) - url = m_url.group(0) if m_url else raw + branch = "main" + url = raw + + # Check for branch specified at the end of the URL, e.g., '...repo.git@my-branch' + match_branch = re.search(r"@([a-zA-Z0-9_\-\.]+)*$", url) + if match_branch: + branch = match_branch.group(1) + url = url[: match_branch.start()] # Remove branch part from URL + + # Remove 'git+' prefix if present + if url.startswith("git+"): + url = url[4:] + + # Remove duplicate 'https://' or 'http://' + # This handles cases like 'https://https://github.com/...' or 'http://http://github.com' + url = re.sub(r"^(http(s)?://)(http(s)?://)", r"\1", url) + return url, branch def copy_file( @@ -458,7 +466,7 @@ def get_repo_diff(self, repo_name: str) -> None: except GitCommandError as e: self.err(f"❌ Failed to diff working tree: {e}") - def _list_repos(self) -> set: + def _list_repos(self) -> tuple[set, set]: map_repos = set(self.map.keys()) try: @@ -466,7 +474,7 @@ def _list_repos(self) -> set: fs_repos = {entry for entry in fs_entries if (self.path / entry).is_dir()} except Exception as e: self.err(f"❌ Failed to list repositories in filesystem: {e}") - return + return set(), set() return map_repos, fs_repos @@ -503,11 +511,21 @@ def list_repos(self) -> None: def open_repo(self, repo_name: str) -> None: """ Open the specified repository with `gh browse` command. + + If the repository directory does not exist, emit a clear error message and + exit with a non-zero status code so callers can detect the failure. """ self.info(f"Opening repository: {repo_name}") + path, _ = self.ensure_repo(repo_name) if not path: - return + # `ensure_repo` may already have printed something depending on the + # current "quiet" setting, but for an explicit "open" request we + # always want a visible error and a failing exit code. + self.err( + f"❌ Repository '{repo_name}' does not exist at {self.get_repo_path(repo_name)}" + ) + raise typer.Exit(code=1) if self.run(["gh", "browse"], cwd=path): self.ok(f"✅ Successfully opened {repo_name} in browser.") @@ -613,19 +631,46 @@ def remote_remove(self, remote_name: str) -> None: def set_default_repo(self, repo_name: str) -> None: """ Set the default repository in the configuration file. + + This uses the GitHub CLI (`gh repo set-default`). If the repository + directory does not exist or the `gh` binary is missing/fails, emit a + clear error and exit with a non-zero status instead of raising a + traceback. """ self.info(f"Setting default repository to: {repo_name}") if repo_name not in self.map: self.err(f"Repository '{repo_name}' not found in configuration.") - return + raise typer.Exit(code=1) + + path = self.get_repo_path(repo_name) + if not path.exists(): + self.err( + f"❌ Repository directory '{path}' does not exist. Clone it first with `dm repo clone {repo_name}`." + ) + raise typer.Exit(code=1) + try: subprocess.run( ["gh", "repo", "set-default"], - cwd=self.get_repo_path(repo_name), + cwd=path, check=True, ) + self.ok(f"✅ Default repository set to: {repo_name}.") + except FileNotFoundError: + # Either the `gh` executable or the repo path is missing; by this + # point we have already validated the path, so treat it as a + # missing GitHub CLI binary. + self.err( + "❌ Failed to set default repository: GitHub CLI 'gh' was not found. " + "Install it from https://cli.github.com/ and ensure 'gh' is on your PATH." + ) + raise typer.Exit(code=1) except subprocess.CalledProcessError as e: + self.err(f"❌ Failed to set default repository via 'gh': {e}") + raise typer.Exit(code=1) + except Exception as e: self.err(f"❌ Failed to set default repository: {e}") + raise typer.Exit(code=1) class Package(Repo): diff --git a/justfile b/justfile index 21ce45f..7698f84 100644 --- a/justfile +++ b/justfile @@ -30,16 +30,11 @@ git-remote repo: dm repo set-default django-mongodb-backend; \ dm repo fetch django-mongodb-backend; \ dm repo remote django-mongodb-extensions add origin git+ssh://git@github.com/aclark4life/django-mongodb-extensions; \ - dm repo remote django-mongodb-extensions add upstream git+ssh://git@github.com/mongodb/django-mongodb-extensions; \ - dm repo set-default django-mongodb-extensions; \ - dm repo fetch django-mongodb-extensions; \ - elif [ "{{repo}}" = "django-mongodb-extensions" ]; then \ - echo "Setting remotes for django-mongodb-extensions"; \ - dm repo remote django-mongodb-extensions add origin git+ssh://git@github.com/aclark4life/django-mongodb-extensions; \ dm repo remote django-mongodb-extensions add upstream git+ssh://git@github.com/mongodb-labs/django-mongodb-extensions; \ dm repo set-default django-mongodb-extensions; \ dm repo fetch django-mongodb-extensions; \ - elif [ "{{repo}}" = "mongo-python-driver" ]; then \ + dm repo fetch django-mongodb-extensions; \ + elif [ "{{repo}}" = "pymongo" ]; then \ echo "Setting remotes for mongo-python-driver"; \ dm repo remote mongo-python-driver add origin git+ssh://git@github.com/aclark4life/mongo-python-driver; \ dm repo remote mongo-python-driver add upstream git+ssh://git@github.com/mongodb/mongo-python-driver; \ @@ -107,8 +102,8 @@ alias d := drop # install python dependencies and activate pre-commit hooks [group('python')] pip-install: check-venv - brew install libxml2 libxmlsec1 mongo-c-driver mongo-c-driver@1 pkg-config - pip install lxml==5.3.2 --no-binary :all: + # brew install libxml2 libxmlsec1 mongo-c-driver mongo-c-driver@1 pkg-config + # pip install lxml==5.3.2 --no-binary :all: pip install -U pip pip install -e . pre-commit install diff --git a/pyproject.toml b/pyproject.toml index 68e5b65..535bd26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ docs = [ dm = "django_mongodb_cli:dm" [tool.setuptools] -packages = ["django_mongodb_cli"] +packages = ["django_mongodb_cli", "demo"] [tool.setuptools.package-data] "django_mongodb_cli" = ["templates/**/*"] @@ -64,6 +64,7 @@ repos = [ "GenAI-Showcase @ git+ssh://git@github.com/aclark4life/GenAI-Showcase.git@main", "langchain-mongodb @ git+ssh://git@github.com/langchain-ai/langchain-mongodb@main", "libmongocrypt @ git+ssh://git@github.com/mongodb-labs/libmongocrypt@master", + "llm-mongogpt @ git+https://https://github.com/10gen/llm-mongogpt.git", "mongo @ git+ssh://git@github.com/mongodb/mongo@master", "mongo-arrow @ git+ssh://git@github.com/mongodb-labs/mongo-arrow@main", "mongo-orchestration @ git+ssh://git@github.com/mongodb-labs/mongo-orchestration@master",