diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c05e9e8..a982ba4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,6 +2,9 @@ name: CI on: pull_request: + push: + branches: + - main workflow_dispatch: jobs: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 6e28b4d..27b697c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -7,6 +7,7 @@ on: permissions: contents: write + packages: write jobs: generate-release: @@ -23,7 +24,7 @@ jobs: id: release shell: bash run: | - RELEASE_VERSION=${GITHUB_REF:11} + RELEASE_VERSION=${GITHUB_REF:10} IS_PRERELEASE=false if [[ "$RELEASE_VERSION" == *dev* ]]; then @@ -46,7 +47,7 @@ jobs: args: -vv --latest --no-exec --github-repo ${{ github.repository }} --strip all env: GIT_CLIFF__REMOTE__GITHUB__OWNER: toggle-corp - GIT_CLIFF__REMOTE__GITHUB__REPO: toggle-django-utils + GIT_CLIFF__REMOTE__GITHUB__REPO: banjo-utils - name: Create Github Release uses: softprops/action-gh-release@v2 diff --git a/.gitmodules b/.gitmodules index d5d7f64..255de8a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "fugit"] path = fugit url = https://github.com/toggle-corp/fugit.git - branch = v0.1.1 + branch = v0.1.6 diff --git a/README.md b/README.md index a021556..5a10071 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# toggle-django-utils +# Banjo utils Reusable Django utilities and management commands for Toggle projects. @@ -8,6 +8,8 @@ Reusable Django utilities and management commands for Toggle projects. - Shared management command: `wait_for_resources` — Wait for database, Redis, Minio (S3) resources to be available before startup +- Create Initial Users: `create_initial_users` + - Create Users with specified roles and permissions, useful to populate the database with default users during development or testing --- @@ -15,36 +17,33 @@ Reusable Django utilities and management commands for Toggle projects. **Using [uv](https://github.com/astral-sh/uv):** ```bash -uv pip install "git+ssh://git@github.com/toggle-corp/toggle-django-utils.git@main" +uv pip install "git+https://github.com/toggle-corp/banjo-utils.git@v0.1.0" ``` Or add to your `pyproject.toml`: ```toml [project] dependencies = [ - "toggle-django-utils", + "banjo-utils", ] [tool.uv.sources] -toggle-django-utils = { git = "https://github.com/toggle-corp/toggle-django-utils", branch = "main" } +banjo-utils = { git = "https://github.com/toggle-corp/banjo-utils", tag = "v0.1.0" } ``` --- ## Setup in Django -1. **Add to `INSTALLED_APPS` in your Django project's `settings.py`:** +- **Add to `INSTALLED_APPS` in your Django project's `settings.py`:** ```python INSTALLED_APPS = [ # ... your other apps ... - "toggle_django_utils", + "banjo_utils", ] ``` -2. (Optional) If your `settings.py` uses custom configs, ensure `"toggle_django_utils"` remains in the app list. - - --- ## Usage @@ -55,15 +54,32 @@ python manage.py wait_for_resources --db --redis ``` **Command options:** -- `--db`      Wait for database -- `--redis`   Wait for Redis server -- `--minio`   Wait for Minio (S3 storage) -- `--timeout`   Set max wait time (seconds) +- `--db`: Wait for database +- `--redis`: Wait for Redis server +- `--minio`: Wait for Minio (S3 storage) +- `--timeout`: Set max wait time (seconds) **Examples:** ```bash python manage.py wait_for_resources --db --redis python manage.py wait_for_resources --timeout 300 --minio +python manage.py create_initial_users --users-json=" +[ + { + "username": "admin", + "email": "test@example.com", + "password": "admin123", + "is_superuser": true, + "is_staff": true + }, + { + "username": "user1", + "email": "user1@gmail.com", + "password": "user123", + "is_superuser": false, + "is_staff": false + } +]' ``` --- @@ -83,6 +99,11 @@ python manage.py wait_for_resources --timeout 300 --minio ```bash uv run --all-groups --all-extras pytest ``` +4. Run commands for example project + ```bash + uv run --all-groups --all-extras python example/manage.py runserver + uv run --all-groups --all-extras python example/manage.py wait_for_resources --db --redis + ``` --- diff --git a/TODOS.md b/TODOS.md index a6b2160..6c97132 100644 --- a/TODOS.md +++ b/TODOS.md @@ -1,7 +1,7 @@ - We can use something like this inside ``` -TOGGLE_DJANGO_UTILS_CONFIG = { +BANJO_UTILS_CONFIG = { "WAIT_FOR_RESOURCES": { "ALIAS": { "dev": ["db", "redis", "minio"], diff --git a/example/main/settings.py b/example/main/settings.py index 8645988..9f27048 100644 --- a/example/main/settings.py +++ b/example/main/settings.py @@ -13,7 +13,7 @@ "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", - "toggle_django_utils", + "banjo_utils", ] MIDDLEWARE = [ @@ -26,7 +26,7 @@ DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", - "NAME": ":memory:", + "NAME": BASE_DIR / "db.sqlite3", }, } diff --git a/fugit b/fugit index 1460a27..a8cd052 160000 --- a/fugit +++ b/fugit @@ -1 +1 @@ -Subproject commit 1460a27ff4048425fb3efb34dfc35ea1293fe8b7 +Subproject commit a8cd052a7f8b1fe4d86fac6c805b644f56014575 diff --git a/pyproject.toml b/pyproject.toml index 91b3937..908580d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ requires = [ ] [project] -name = "toggle-django-utils" +name = "banjo-utils" version = "0.1.0" description = "Shared Django utilities for Toggle projects" readme = "README.md" @@ -36,12 +36,12 @@ test = [ ] [project.urls] -Homepage = "https://github.com/toggle-corp/toggle-django-utils" -Repository = "https://github.com/toggle-corp/toggle-django-utils" -Issues = "https://github.com/toggle-corp/toggle-django-utils/issues" +Homepage = "https://github.com/toggle-corp/banjo-utils" +Repository = "https://github.com/toggle-corp/banjo-utils" +Issues = "https://github.com/toggle-corp/banjo-utils/issues" [tool.hatch.build.targets.wheel] -packages = ["src/toggle_django_utils"] +packages = ["src/banjo_utils"] [tool.ruff.lint.per-file-ignores] "__init__.py" = ["E402"] diff --git a/src/toggle_django_utils/__init__.py b/src/banjo_utils/__init__.py similarity index 100% rename from src/toggle_django_utils/__init__.py rename to src/banjo_utils/__init__.py diff --git a/src/banjo_utils/apps.py b/src/banjo_utils/apps.py new file mode 100644 index 0000000..e69a11b --- /dev/null +++ b/src/banjo_utils/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class BanjoUtilsConfig(AppConfig): + name = "banjo_utils" + verbose_name = "Banjo Utils" + default_auto_field = "django.db.models.BigAutoField" diff --git a/src/toggle_django_utils/management/__init__.py b/src/banjo_utils/management/__init__.py similarity index 100% rename from src/toggle_django_utils/management/__init__.py rename to src/banjo_utils/management/__init__.py diff --git a/src/toggle_django_utils/management/commands/__init__.py b/src/banjo_utils/management/commands/__init__.py similarity index 100% rename from src/toggle_django_utils/management/commands/__init__.py rename to src/banjo_utils/management/commands/__init__.py diff --git a/src/banjo_utils/management/commands/create_initial_users.py b/src/banjo_utils/management/commands/create_initial_users.py new file mode 100644 index 0000000..448753b --- /dev/null +++ b/src/banjo_utils/management/commands/create_initial_users.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import json +import pathlib +import typing +from typing import Any, override + +from django.contrib.auth import get_user_model +from django.contrib.auth.hashers import identify_hasher +from django.core.management.base import BaseCommand, CommandError + +if typing.TYPE_CHECKING: + from django.core.management.base import CommandParser + + +class Command(BaseCommand): + help = "Create/update users using JSON input" + + @override + def add_arguments(self, parser: CommandParser): + parser.add_argument( + "--users-json", + type=str, + required=True, + help="JSON string containing list of users", + ) + + def is_hashed(self, password: str) -> bool: + try: + identify_hasher(password) + return True + except Exception: + return False + + def load_json_string(self, json_str: str) -> list[dict[str, Any]]: + try: + data = json.loads(json_str) + if not isinstance(data, list): + raise ValueError("JSON must be a list of users") + return data + except Exception as e: + raise CommandError(f"Invalid JSON: {e}") from e + + def load_json_file(self, path: str) -> list[dict[str, Any]]: + try: + with pathlib.Path(path).open(encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, list): + raise ValueError("JSON must be a list of users") + return data + except Exception as e: + raise CommandError(f"Error reading file {path}: {e}") from e + + @override + def handle(self, *_, **options: Any): + User = get_user_model() + user_data = self.load_json_string(options["users_json"]) + + if not user_data: + raise CommandError("No users provided") + + for user_info in user_data: + email = user_info.pop("email", None) + username = user_info.pop("username", email) + password = user_info.pop("password", None) + + if not email or not password: + self.stdout.write( + self.style.WARNING( + f"Skipping user (missing email/password): {user_info}", + ), + ) + continue + + defaults = {k: v for k, v in user_info.items()} + user, created = User.objects.update_or_create( + username=username, + email=email, + defaults=defaults, + ) + + if self.is_hashed(password): + user.password = password + else: + user.set_password(password) + + user.save() + + if created: + self.stdout.write( + self.style.SUCCESS(f"Created user: {email}"), + ) + else: + self.stdout.write( + self.style.SUCCESS(f"Updated user: {email}"), + ) diff --git a/src/toggle_django_utils/management/commands/wait_for_resources.py b/src/banjo_utils/management/commands/wait_for_resources.py similarity index 98% rename from src/toggle_django_utils/management/commands/wait_for_resources.py rename to src/banjo_utils/management/commands/wait_for_resources.py index 7830801..296f9ea 100644 --- a/src/toggle_django_utils/management/commands/wait_for_resources.py +++ b/src/banjo_utils/management/commands/wait_for_resources.py @@ -11,7 +11,7 @@ from redis.exceptions import ConnectionError as RedisConnectionError from typing_extensions import override -from toggle_django_utils.utils.retry import RetryHelper +from banjo_utils.utils.retry import RetryHelper class TimeoutException(Exception): ... diff --git a/src/toggle_django_utils/utils/__init__.py b/src/banjo_utils/utils/__init__.py similarity index 100% rename from src/toggle_django_utils/utils/__init__.py rename to src/banjo_utils/utils/__init__.py diff --git a/src/toggle_django_utils/utils/retry.py b/src/banjo_utils/utils/retry.py similarity index 100% rename from src/toggle_django_utils/utils/retry.py rename to src/banjo_utils/utils/retry.py diff --git a/src/toggle_django_utils/apps.py b/src/toggle_django_utils/apps.py deleted file mode 100644 index 2d8a887..0000000 --- a/src/toggle_django_utils/apps.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.apps import AppConfig - - -class ToggleDjangoUtilsConfig(AppConfig): - name = "toggle_django_utils" - verbose_name = "Toggle Django Utils" - default_auto_field = "django.db.models.BigAutoField" diff --git a/tests/conftest.py b/tests/conftest.py index 43693cb..70a34e0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,7 +16,7 @@ def pytest_configure(): "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", - "toggle_django_utils", + "banjo_utils", ], MIDDLEWARE=[ "django.contrib.sessions.middleware.SessionMiddleware", diff --git a/tests/test_create_initial_users.py b/tests/test_create_initial_users.py new file mode 100644 index 0000000..8c7ed99 --- /dev/null +++ b/tests/test_create_initial_users.py @@ -0,0 +1,59 @@ +import json + +import pytest +from django.contrib.auth import get_user_model +from django.core.management import call_command + +User = get_user_model() + + +@pytest.mark.django_db +def test_create_users_command(): + users_json = json.dumps( + [ + { + "username": "admin", + "email": "admin@example.com", + "password": "plainpassword", + "is_superuser": True, + "is_staff": True, + }, + { + "username": "guest", + "email": "guest@example.com", + "password": "pbkdf2_sha256$600000$example$hashhere", + "is_superuser": False, + "is_staff": False, + }, + { + "first_name": "John", + "last_name": "Doe", + "email": "john@example.com", + "password": "pbkdf2_sha256$600000$example$hashhere", + "is_superuser": False, + "is_staff": False, + }, + ], + ) + + call_command("create_initial_users", users_json=users_json) + + admin_user: User = User.objects.get(username="admin") + assert admin_user.email == "admin@example.com" # type: ignore[reportUnknownMemberType] + assert admin_user.is_superuser is True # type: ignore[reportUnknownMemberType] + assert admin_user.is_staff is True # type: ignore[reportUnknownMemberType] + + # password should be hashed, not equal to the plain text + assert admin_user.check_password("plainpassword") is True + + guest_user: User = User.objects.get(username="guest") + assert guest_user.email == "guest@example.com" # type: ignore[reportUnknownMemberType] + assert guest_user.is_superuser is False # type: ignore[reportUnknownMemberType] + assert guest_user.is_staff is False # type: ignore[reportUnknownMemberType] + assert guest_user.check_password("pbkdf2_sha256$600000$example$hashhere") is False # type: ignore[reportUnknownMemberType] + + # The user with email as username should be created with email as username + john_user: User = User.objects.get(email="john@example.com") + assert john_user.username == john_user.email # type: ignore[reportUnknownMemberType] + assert john_user.first_name == "John" # type: ignore[reportUnknownMemberType] + assert john_user.last_name == "Doe" # type: ignore[reportUnknownMemberType] diff --git a/tests/test_retry.py b/tests/test_retry.py index 6b0c3a9..0c9aedc 100644 --- a/tests/test_retry.py +++ b/tests/test_retry.py @@ -6,7 +6,7 @@ if typing.TYPE_CHECKING: from pytest_mock import MockerFixture -from toggle_django_utils.utils.retry import RetryHelper +from banjo_utils.utils.retry import RetryHelper def test_defaults(): diff --git a/tests/test_wait_for_resources.py b/tests/test_wait_for_resources.py index a3425e2..ad46eaa 100644 --- a/tests/test_wait_for_resources.py +++ b/tests/test_wait_for_resources.py @@ -12,7 +12,7 @@ from django.core.management import call_command from django.db.utils import OperationalError -from toggle_django_utils.management.commands.wait_for_resources import ( +from banjo_utils.management.commands.wait_for_resources import ( TimeoutException, ) @@ -23,7 +23,7 @@ def run_command(*args: Any, **kwargs: Any): return out.getvalue(), err.getvalue() -@patch("toggle_django_utils.management.commands.wait_for_resources.connections") +@patch("banjo_utils.management.commands.wait_for_resources.connections") @patch("time.sleep", return_value=None) def test_wait_for_db_retries_then_succeeds(mock_sleep: MagicMock, mock_connections: dict[str, Any]): conn = mock_connections["default"] @@ -35,7 +35,7 @@ def test_wait_for_db_retries_then_succeeds(mock_sleep: MagicMock, mock_connectio assert mock_sleep.called -@patch("toggle_django_utils.management.commands.wait_for_resources.cache") +@patch("banjo_utils.management.commands.wait_for_resources.cache") @patch("time.sleep", return_value=None) def test_wait_for_redis_success(mock_sleep: MagicMock, mock_cache: MagicMock): mock_cache.set.return_value = None @@ -47,7 +47,7 @@ def test_wait_for_redis_success(mock_sleep: MagicMock, mock_cache: MagicMock): mock_sleep.assert_not_called() -@patch("toggle_django_utils.management.commands.wait_for_resources.httpx.get") +@patch("banjo_utils.management.commands.wait_for_resources.httpx.get") @patch("time.sleep", return_value=None) def test_wait_for_minio_retries_then_succeeds(mock_sleep: MagicMock, mock_get: MagicMock, settings: LazySettings): settings.AWS_S3_ENDPOINT_URL = "http://minio:9000" @@ -73,7 +73,7 @@ def test_minio_skipped_when_no_endpoint(mock_sleep: MagicMock, settings: LazySet mock_sleep.assert_not_called() -@patch("toggle_django_utils.management.commands.wait_for_resources.connections") +@patch("banjo_utils.management.commands.wait_for_resources.connections") @patch("time.sleep") def test_timeout_handled(mock_sleep: MagicMock, mock_connections: dict[str, Any]): mock_connections["default"].ensure_connection.side_effect = OperationalError @@ -86,8 +86,8 @@ def raise_timeout(_): assert "Timed out" in err -@patch("toggle_django_utils.management.commands.wait_for_resources.connections") -@patch("toggle_django_utils.management.commands.wait_for_resources.cache") +@patch("banjo_utils.management.commands.wait_for_resources.connections") +@patch("banjo_utils.management.commands.wait_for_resources.cache") @patch("time.sleep", return_value=None) def test_multiple_flags(mock_sleep: MagicMock, mock_cache: MagicMock, mock_connections: dict[str, Any]): mock_connections["default"].ensure_connection.return_value = None diff --git a/uv.lock b/uv.lock index ee29147..d85e0fc 100644 --- a/uv.lock +++ b/uv.lock @@ -99,6 +99,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/cc/e27fd6493bbce8dbea7e6c1bc861fe3d3bc22c4f7c81f4c3befb8ff5bfaf/backports.zoneinfo-0.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:4a0f800587060bf8880f954dbef70de6c11bbe59c673c3d818921f042f9954a6", size = 38967 }, ] +[[package]] +name = "banjo-utils" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "django", version = "4.2.29", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "django", version = "5.2.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and python_full_version < '3.12'" }, + { name = "django", version = "6.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "django-redis" }, + { name = "httpx" }, +] + +[package.dev-dependencies] +dev = [ + { name = "colorlog" }, + { name = "django-stubs", version = "5.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "django-stubs", version = "6.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "types-factory-boy" }, +] +test = [ + { name = "pytest-django", version = "4.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest-django", version = "4.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest-icdiff" }, + { name = "pytest-mock", version = "3.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pytest-mock", version = "3.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "pytest-ordering" }, + { name = "pytest-profiling" }, +] + +[package.metadata] +requires-dist = [ + { name = "django", specifier = ">=3.2" }, + { name = "django-redis", specifier = ">=5.3.0,<6" }, + { name = "httpx", specifier = ">=0.28.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "colorlog" }, + { name = "django-stubs" }, + { name = "types-factory-boy", specifier = ">=0.4.1" }, +] +test = [ + { name = "pytest-django" }, + { name = "pytest-icdiff" }, + { name = "pytest-mock" }, + { name = "pytest-ordering" }, + { name = "pytest-profiling" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -759,56 +809,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138 }, ] -[[package]] -name = "toggle-django-utils" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "django", version = "4.2.29", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "django", version = "5.2.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and python_full_version < '3.12'" }, - { name = "django", version = "6.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "django-redis" }, - { name = "httpx" }, -] - -[package.dev-dependencies] -dev = [ - { name = "colorlog" }, - { name = "django-stubs", version = "5.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "django-stubs", version = "6.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "types-factory-boy" }, -] -test = [ - { name = "pytest-django", version = "4.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest-django", version = "4.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pytest-icdiff" }, - { name = "pytest-mock", version = "3.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "pytest-mock", version = "3.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "pytest-ordering" }, - { name = "pytest-profiling" }, -] - -[package.metadata] -requires-dist = [ - { name = "django", specifier = ">=3.2" }, - { name = "django-redis", specifier = ">=5.3.0,<6" }, - { name = "httpx", specifier = ">=0.28.1" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "colorlog" }, - { name = "django-stubs" }, - { name = "types-factory-boy", specifier = ">=0.4.1" }, -] -test = [ - { name = "pytest-django" }, - { name = "pytest-icdiff" }, - { name = "pytest-mock" }, - { name = "pytest-ordering" }, - { name = "pytest-profiling" }, -] - [[package]] name = "tomli" version = "2.4.0"