diff --git a/.github/workflows/test-template.yml b/.github/workflows/test-template.yml index 86b4ef3..7bf9b02 100644 --- a/.github/workflows/test-template.yml +++ b/.github/workflows/test-template.yml @@ -15,41 +15,41 @@ jobs: steps: - uses: actions/checkout@v4 - + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - + - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r test-requirements.txt - + - name: Run tox validation run: | tox -e validate - + - name: Run tox tests run: | tox -e py comprehensive-test: runs-on: ubuntu-latest - + steps: - uses: actions/checkout@v4 - + - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.11' - + - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r test-requirements.txt - + - name: Run comprehensive tests run: | tox -e all-tests diff --git a/README.md b/README.md index 50493ee..a819084 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Run all tests across multiple Python versions: # Run basic validation tox -e validate -# Run quick tests +# Run quick tests tox -e quick-test # Run template tests diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index 10ac7fe..12c48e0 100644 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -1,12 +1,15 @@ import os +import secrets # Import the secrets module +import string # Import string for character sets import subprocess import sys -import secrets # Import the secrets module -import string # Import string for character sets PROJECT_DIRECTORY = os.path.realpath(os.path.curdir) # Placeholder used in cookiecutter.json and potentially copied into files -SECRET_KEY_PLACEHOLDER = "!!! DONT FORGET TO REPLACE THIS !!!_!! DO NOT USE IN PRODUCTION !!" +SECRET_KEY_PLACEHOLDER = ( + "!!! DONT FORGET TO REPLACE THIS !!!_!! DO NOT USE IN PRODUCTION !!" +) + def remove_file(filepath): """Removes a file if it exists.""" @@ -18,6 +21,7 @@ def remove_file(filepath): except Exception as e: print(f"Error removing file {filepath}: {e}", file=sys.stderr) + def run_command(command, description): """Runs a command and prints success/failure.""" print(f"Running: {description}...") @@ -31,7 +35,7 @@ def run_command(command, description): stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, - cwd=PROJECT_DIRECTORY + cwd=PROJECT_DIRECTORY, ) print(f"Success: {description}") # print(process.stdout) # Uncomment for more verbose output @@ -48,46 +52,53 @@ def run_command(command, description): print(f"Error: {e}", file=sys.stderr) return False + def generate_secret_key(length=50): """Generates a secure random string for the SECRET_KEY.""" print("Generating secure Django SECRET_KEY...") # Use characters recommended by Django documentation + common symbols chars = string.ascii_letters + string.digits + "!@#$%^&*(-_=+)" - key = ''.join(secrets.choice(chars) for _ in range(length)) + key = "".join(secrets.choice(chars) for _ in range(length)) print("SECRET_KEY generated.") return key + def replace_in_file(filepath, old_string, new_string): """Replaces all occurrences of old_string with new_string in a file.""" full_path = os.path.join(PROJECT_DIRECTORY, filepath) try: - with open(full_path, 'r') as f: + with open(full_path, "r") as f: content = f.read() if old_string not in content: - print(f"Placeholder '{old_string}' not found in {filepath}, skipping replacement.") - return True # Not an error if placeholder isn't there + print( + f"Placeholder '{old_string}' not found in {filepath}, skipping replacement." + ) + return True # Not an error if placeholder isn't there new_content = content.replace(old_string, new_string) - with open(full_path, 'w') as f: + with open(full_path, "w") as f: f.write(new_content) print(f"Replaced placeholder in: {filepath}") return True except FileNotFoundError: print(f"File not found, skipping replacement: {filepath}") - return True # Not an error if file doesn't exist (e.g., conditional files) + return True # Not an error if file doesn't exist (e.g., conditional files) except Exception as e: - print(f"!!! ERROR replacing placeholder in {filepath}: {e} !!!", file=sys.stderr) + print( + f"!!! ERROR replacing placeholder in {filepath}: {e} !!!", file=sys.stderr + ) return False + def main(): """Initialize Git, generate secret key, install pre-commit hooks.""" print("\nPost-generation script starting...") print(f"Working directory: {PROJECT_DIRECTORY}") steps_succeeded = True - project_slug = "{{ cookiecutter.project_slug }}" # Get slug for file paths + project_slug = "{{ cookiecutter.project_slug }}" # Get slug for file paths # 1. Generate SECRET_KEY new_secret_key = generate_secret_key() @@ -95,10 +106,10 @@ def main(): # 2. Replace placeholder in settings files and others files_to_update = [ f"{project_slug}/settings/base.py", - f"{project_slug}/settings/local.py", # Check if placeholder is used here + f"{project_slug}/settings/local.py", # Check if placeholder is used here f"{project_slug}/settings/production.py", - ".env.example", # Update the example file too - "docker-compose.yml", # Used as default env var here + ".env.example", # Update the example file too + "docker-compose.yml", # Used as default env var here ] print("\nReplacing SECRET_KEY placeholder...") for filepath in files_to_update: @@ -113,14 +124,18 @@ def main(): steps_succeeded = False # 4. Install pre-commit - if steps_succeeded: # Only continue if previous steps were okay - if not run_command(f"{sys.executable} -m pip install pre-commit", "Install pre-commit tool"): + if steps_succeeded: # Only continue if previous steps were okay + if not run_command( + f"{sys.executable} -m pip install pre-commit", "Install pre-commit tool" + ): steps_succeeded = False print("--- Please install pre-commit manually ---", file=sys.stderr) # 5. Install Git hooks using pre-commit if steps_succeeded: - if not run_command(f"{sys.executable} -m pre_commit install", "Install pre-commit Git hooks"): + if not run_command( + f"{sys.executable} -m pre_commit install", "Install pre-commit Git hooks" + ): steps_succeeded = False print("--- Please run 'pre-commit install' manually ---", file=sys.stderr) @@ -133,7 +148,9 @@ def main(): if run_command(f'git commit -m "{commit_msg}"', "Create initial commit"): print("Successfully created initial commit.") else: - print("--- Failed to create initial commit. Please commit manually. ---") + print( + "--- Failed to create initial commit. Please commit manually. ---" + ) else: print("--- Failed to stage files. Please stage and commit manually. ---") @@ -150,7 +167,8 @@ def main(): else: print("ERROR: Post-generation script encountered errors.") print("Please check the output above for details and complete setup manually.") - sys.exit(1) # Exit with error code + sys.exit(1) # Exit with error code + -if __name__ == '__main__': - main() \ No newline at end of file +if __name__ == "__main__": + main() diff --git a/test-requirements.txt b/test-requirements.txt index fb329d9..e7b51aa 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,6 +1,6 @@ +cookiecutter>=2.7.1 # Test dependencies for cookiecutter template -pytest>=8.4.0 -cookiecutter>=2.6.0 -pytest-cov>=6.3.0 +pytest>=9.0.2 +pytest-cov>=7.0.0 pytest-mock>=3.15.0 -tox>=4.30.0 +tox>=4.49.1 diff --git a/tests/quick_test.py b/tests/quick_test.py index da0af0c..9795f6c 100644 --- a/tests/quick_test.py +++ b/tests/quick_test.py @@ -6,9 +6,9 @@ without requiring external dependencies. """ +import json import os import sys -import json from pathlib import Path @@ -16,24 +16,24 @@ def main(): """Run quick validation tests.""" print("Django OAuth2 Cookiecutter Template - Quick Validation") print("=" * 60) - + template_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) errors = [] - + # Test 1: Check cookiecutter.json exists and is valid print("1. Checking cookiecutter.json...") cookiecutter_path = os.path.join(template_dir, "cookiecutter.json") - + if not os.path.exists(cookiecutter_path): errors.append("cookiecutter.json is missing") else: try: - with open(cookiecutter_path, 'r') as f: + with open(cookiecutter_path, "r") as f: config = json.load(f) print(f" ✓ Valid JSON with {len(config)} variables") except json.JSONDecodeError as e: errors.append(f"cookiecutter.json is invalid: {e}") - + # Test 2: Check essential project structure print("2. Checking essential project structure...") essential_paths = [ @@ -43,73 +43,77 @@ def main(): "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/__init__.py", "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/api/__init__.py", "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/oauth2/__init__.py", - "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/tests/test_api_package.py" + "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/tests/test_api_package.py", ] - + missing_paths = [] for path in essential_paths: full_path = os.path.join(template_dir, path) if not os.path.exists(full_path): missing_paths.append(path) - + if missing_paths: errors.append(f"Missing essential files: {missing_paths}") else: print(f" ✓ All {len(essential_paths)} essential files exist") - + # Test 3: Check requirements files have content print("3. Checking requirements files...") req_files = [ "{{ cookiecutter.project_slug }}/requirements/base.txt", - "{{ cookiecutter.project_slug }}/requirements/local.txt", - "{{ cookiecutter.project_slug }}/requirements/production.txt" + "{{ cookiecutter.project_slug }}/requirements/local.txt", + "{{ cookiecutter.project_slug }}/requirements/production.txt", ] - + for req_file in req_files: req_path = os.path.join(template_dir, req_file) if os.path.exists(req_path): - with open(req_path, 'r') as f: + with open(req_path, "r") as f: content = f.read().strip() if not content: errors.append(f"Requirements file is empty: {req_file}") else: errors.append(f"Requirements file missing: {req_file}") - + if not any("Requirements file" in error for error in errors): print(f" ✓ All requirements files have content") - + # Test 4: Check Docker files print("4. Checking Docker configuration...") docker_files = [ "{{ cookiecutter.project_slug }}/Dockerfile", - "{{ cookiecutter.project_slug }}/docker-compose.yml" + "{{ cookiecutter.project_slug }}/docker-compose.yml", ] - + for docker_file in docker_files: docker_path = os.path.join(template_dir, docker_file) if not os.path.exists(docker_path): errors.append(f"Docker file missing: {docker_file}") - + if not any("Docker file" in error for error in errors): print(f" ✓ Docker configuration files exist") - + # Test 5: Count Python files and check for obvious issues print("5. Checking Python files...") python_files = [] template_files = 0 - - for root, dirs, files in os.walk(os.path.join(template_dir, "{{ cookiecutter.project_slug }}")): + + for root, dirs, files in os.walk( + os.path.join(template_dir, "{{ cookiecutter.project_slug }}") + ): for file in files: - if file.endswith('.py'): + if file.endswith(".py"): python_files.append(file) file_path = os.path.join(root, file) - with open(file_path, 'r') as f: + with open(file_path, "r") as f: content = f.read() - if '{{ cookiecutter.' in content: + if "{{ cookiecutter." in content: template_files += 1 - - print(f" ✓ Found {len(python_files)} Python files ({template_files} with template variables)") - + + print( + f" ✓ Found {len(python_files)} Python files ({template_files} with template variables)" + ) + # Summary print("\n" + "=" * 60) if errors: diff --git a/tests/run_tests.py b/tests/run_tests.py index 9161694..e8f1495 100755 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -6,26 +6,27 @@ and can be used in CI/CD pipelines. """ +import json import os -import sys import subprocess -import json +import sys from pathlib import Path def get_python_executable(): """Get the best available Python executable.""" - python_candidates = ['python'] - + python_candidates = ["python"] + for candidate in python_candidates: try: - result = subprocess.run([candidate, '--version'], - capture_output=True, text=True, timeout=5) + result = subprocess.run( + [candidate, "--version"], capture_output=True, text=True, timeout=5 + ) if result.returncode == 0: return candidate except (subprocess.TimeoutExpired, FileNotFoundError): continue - + # Fallback to sys.executable if nothing else works return sys.executable @@ -33,15 +34,21 @@ def get_python_executable(): def run_basic_validation(): """Run basic template validation.""" print("Running basic template validation...") - + python_exe = get_python_executable() - + # Run the validation script - result = subprocess.run([ - python_exe, - os.path.join(os.path.dirname(os.path.dirname(__file__)), "validate_template.py") - ], capture_output=True, text=True) - + result = subprocess.run( + [ + python_exe, + os.path.join( + os.path.dirname(os.path.dirname(__file__)), "validate_template.py" + ), + ], + capture_output=True, + text=True, + ) + if result.returncode == 0: print("✓ Basic validation passed") print(result.stdout) @@ -56,13 +63,14 @@ def run_basic_validation(): def run_cookiecutter_generation_test(): """Test actual cookiecutter generation if cookiecutter is available.""" try: + import shutil + import tempfile + import cookiecutter from cookiecutter.main import cookiecutter as cc_main - import tempfile - import shutil - + print("Running cookiecutter generation test...") - + # Test context test_context = { "project_name": "Test Project", @@ -78,41 +86,42 @@ def run_cookiecutter_generation_test(): "postgresql_db": "testdb", "postgresql_port": "5432", "celery_broker_url": "redis://localhost:6379/0", - "celery_result_backend": "redis://localhost:6379/1" + "celery_result_backend": "redis://localhost:6379/1", } - + # Create temporary directory temp_dir = tempfile.mkdtemp() - template_dir = os.path.dirname(os.path.abspath(__file__)) - + # The template is in the parent directory of 'tests' + template_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + try: # Generate project generated_project = cc_main( template_dir, no_input=True, extra_context=test_context, - output_dir=temp_dir + output_dir=temp_dir, ) - + # Verify generated project if os.path.exists(generated_project): print(f"✓ Project generated successfully at: {generated_project}") - + # Check key files exist key_files = [ "manage.py", "requirements/base.txt", "test_project/settings/base.py", - "test_project/accounts/api/__init__.py" + "test_project/accounts/api/__init__.py", ] - + all_exist = True for file_path in key_files: full_path = os.path.join(generated_project, file_path) if not os.path.exists(full_path): print(f"✗ Missing file: {file_path}") all_exist = False - + if all_exist: print("✓ All key files exist in generated project") return True @@ -122,11 +131,11 @@ def run_cookiecutter_generation_test(): else: print("✗ Generated project directory not found") return False - + finally: # Clean up shutil.rmtree(temp_dir, ignore_errors=True) - + except ImportError: print("⚠ Cookiecutter not installed, skipping generation test") return True @@ -139,19 +148,21 @@ def run_pytest_tests(): """Run pytest tests if pytest is available.""" try: import pytest - + print("Running pytest tests...") - + python_exe = get_python_executable() - + # Run pytest on the test file test_file = os.path.join(os.path.dirname(__file__), "test_cookiecutter.py") - + if os.path.exists(test_file): - result = subprocess.run([ - python_exe, "-m", "pytest", test_file, "-v" - ], capture_output=True, text=True) - + result = subprocess.run( + [python_exe, "-m", "pytest", test_file, "-v"], + capture_output=True, + text=True, + ) + if result.returncode == 0: print("✓ Pytest tests passed") return True @@ -163,7 +174,7 @@ def run_pytest_tests(): else: print("⚠ Pytest test file not found") return True - + except ImportError: print("⚠ Pytest not installed, skipping pytest tests") return True @@ -176,15 +187,15 @@ def main(): """Run all available tests.""" print("Cookiecutter Template Test Suite") print("=" * 50) - + tests = [ ("Basic Validation", run_basic_validation), ("Cookiecutter Generation", run_cookiecutter_generation_test), ("Pytest Tests", run_pytest_tests), ] - + results = [] - + for test_name, test_func in tests: print(f"\n{test_name}:") print("-" * 30) @@ -194,14 +205,14 @@ def main(): except Exception as e: print(f"✗ {test_name} failed with error: {e}") results.append((test_name, False)) - + print("\n" + "=" * 50) print("Test Summary:") print("=" * 50) - + passed = 0 failed = 0 - + for test_name, success in results: status = "✓ PASSED" if success else "✗ FAILED" print(f"{test_name}: {status}") @@ -209,9 +220,9 @@ def main(): passed += 1 else: failed += 1 - + print(f"\nOverall: {passed} passed, {failed} failed") - + # Exit with appropriate code sys.exit(0 if failed == 0 else 1) diff --git a/tests/test_cookiecutter.py b/tests/test_cookiecutter.py index b70993e..b83c8a1 100644 --- a/tests/test_cookiecutter.py +++ b/tests/test_cookiecutter.py @@ -5,15 +5,16 @@ that the generated project has the correct structure and functionality. """ +import json import os -import sys -import tempfile import shutil import subprocess -import json -import pytest +import sys +import tempfile from pathlib import Path +import pytest + # Add the cookiecutter directory to path for imports sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) @@ -37,7 +38,7 @@ def test_context(): """Provide test context for cookiecutter generation.""" return { "project_name": "Test Django Project", - "project_slug": "test_django_project", + "project_slug": "test_django_project", "project_description": "A test Django project with OAuth2 support", "author_name": "Test Author", "author_email": "test@example.com", @@ -49,25 +50,25 @@ def test_context(): "postgresql_db": "test_db", "postgresql_port": "5432", "celery_broker_url": "redis://localhost:6379/0", - "celery_result_backend": "redis://localhost:6379/1" + "celery_result_backend": "redis://localhost:6379/1", } class TestCookiecutterTemplate: """Test the cookiecutter template generation and structure.""" - + def test_cookiecutter_json_structure(self, template_dir): """Test that cookiecutter.json has the correct structure.""" cookiecutter_path = os.path.join(template_dir, "cookiecutter.json") assert os.path.exists(cookiecutter_path), "cookiecutter.json should exist" - - with open(cookiecutter_path, 'r') as f: + + with open(cookiecutter_path, "r") as f: config = json.load(f) - + # Check required fields required_fields = [ "project_name", - "project_slug", + "project_slug", "project_description", "author_name", "author_email", @@ -79,16 +80,22 @@ def test_cookiecutter_json_structure(self, template_dir): "postgresql_db", "postgresql_port", "celery_broker_url", - "celery_result_backend" + "celery_result_backend", ] - + for field in required_fields: - assert field in config, f"Required field '{field}' missing from cookiecutter.json" - + assert ( + field in config + ), f"Required field '{field}' missing from cookiecutter.json" + # Check that project_slug uses proper template logic - assert "{{" in config["project_slug"], "project_slug should use cookiecutter template syntax" - assert "cookiecutter.project_name" in config["project_slug"], "project_slug should derive from project_name" - + assert ( + "{{" in config["project_slug"] + ), "project_slug should use cookiecutter template syntax" + assert ( + "cookiecutter.project_name" in config["project_slug"] + ), "project_slug should derive from project_name" + def test_template_directory_structure(self, template_dir): """Test that the template has the expected directory structure.""" expected_files = [ @@ -110,7 +117,7 @@ def test_template_directory_structure(self, template_dir): "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/asgi.py", "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/celery.py", ] - + expected_dirs = [ "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts", "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/api", @@ -119,22 +126,25 @@ def test_template_directory_structure(self, template_dir): "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/settings", "{{ cookiecutter.project_slug }}/requirements", ] - + for file_path in expected_files: full_path = os.path.join(template_dir, file_path) assert os.path.exists(full_path), f"Expected file missing: {file_path}" - + for dir_path in expected_dirs: full_path = os.path.join(template_dir, dir_path) assert os.path.exists(full_path), f"Expected directory missing: {dir_path}" - + def test_accounts_module_structure(self, template_dir): """Test that the accounts module has the correct structure.""" - accounts_dir = os.path.join(template_dir, "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts") - + accounts_dir = os.path.join( + template_dir, + "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts", + ) + expected_files = [ "admin.py", - "apps.py", + "apps.py", "schemas.py", "api/__init__.py", "api/auth.py", @@ -147,174 +157,234 @@ def test_accounts_module_structure(self, template_dir): "oauth2/utils.py", "oauth2/schemas.py", "tests/__init__.py", - "tests/test_api_package.py" + "tests/test_api_package.py", ] - + for file_path in expected_files: full_path = os.path.join(accounts_dir, file_path) - assert os.path.exists(full_path), f"Expected accounts file missing: {file_path}" - + assert os.path.exists( + full_path + ), f"Expected accounts file missing: {file_path}" + def test_post_gen_hook_exists(self, template_dir): """Test that post-generation hook exists and is executable.""" hook_path = os.path.join(template_dir, "hooks/post_gen_project.py") assert os.path.exists(hook_path), "post_gen_project.py hook should exist" - + # Check that the hook has proper Python structure - with open(hook_path, 'r') as f: + with open(hook_path, "r") as f: content = f.read() assert "def " in content, "Hook should contain function definitions" assert "import " in content, "Hook should contain imports" - + def test_requirements_files_exist(self, template_dir): """Test that all requirements files exist and have content.""" - requirements_dir = os.path.join(template_dir, "{{ cookiecutter.project_slug }}/requirements") - + requirements_dir = os.path.join( + template_dir, "{{ cookiecutter.project_slug }}/requirements" + ) + required_files = ["base.txt", "local.txt", "production.txt"] - + for req_file in required_files: file_path = os.path.join(requirements_dir, req_file) assert os.path.exists(file_path), f"Requirements file missing: {req_file}" - - with open(file_path, 'r') as f: + + with open(file_path, "r") as f: content = f.read().strip() assert content, f"Requirements file should not be empty: {req_file}" - + def test_django_settings_structure(self, template_dir): """Test that Django settings are properly structured.""" - settings_dir = os.path.join(template_dir, "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/settings") - + settings_dir = os.path.join( + template_dir, + "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/settings", + ) + settings_files = ["base.py", "local.py", "production.py"] - + for settings_file in settings_files: file_path = os.path.join(settings_dir, settings_file) assert os.path.exists(file_path), f"Settings file missing: {settings_file}" - - with open(file_path, 'r') as f: + + with open(file_path, "r") as f: content = f.read() # Check for Django-specific settings if settings_file == "base.py": - assert "INSTALLED_APPS" in content, "base.py should contain INSTALLED_APPS" + assert ( + "INSTALLED_APPS" in content + ), "base.py should contain INSTALLED_APPS" assert "MIDDLEWARE" in content, "base.py should contain MIDDLEWARE" assert "DATABASES" in content, "base.py should contain DATABASES" - + def test_oauth2_package_structure(self, template_dir): """Test that OAuth2 package has correct structure.""" - oauth2_dir = os.path.join(template_dir, "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/oauth2") - - oauth2_files = ["__init__.py", "api.py", "providers.py", "utils.py", "schemas.py"] - + oauth2_dir = os.path.join( + template_dir, + "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/oauth2", + ) + + oauth2_files = [ + "__init__.py", + "api.py", + "providers.py", + "utils.py", + "schemas.py", + ] + for oauth2_file in oauth2_files: file_path = os.path.join(oauth2_dir, oauth2_file) assert os.path.exists(file_path), f"OAuth2 file missing: {oauth2_file}" - - with open(file_path, 'r') as f: + + with open(file_path, "r") as f: content = f.read() - assert content.strip(), f"OAuth2 file should not be empty: {oauth2_file}" - + assert ( + content.strip() + ), f"OAuth2 file should not be empty: {oauth2_file}" + # Check for specific content based on file if oauth2_file == "providers.py": - assert "google" in content.lower(), "providers.py should contain Google provider" - assert "github" in content.lower(), "providers.py should contain GitHub provider" - assert "facebook" in content.lower(), "providers.py should contain Facebook provider" + assert ( + "google" in content.lower() + ), "providers.py should contain Google provider" + assert ( + "github" in content.lower() + ), "providers.py should contain GitHub provider" + assert ( + "facebook" in content.lower() + ), "providers.py should contain Facebook provider" elif oauth2_file == "api.py": - assert "@router" in content, "api.py should contain router decorators" + assert ( + "@router" in content + ), "api.py should contain router decorators" assert "Router" in content, "api.py should import Router" - + def test_api_package_structure(self, template_dir): """Test that API package has correct structure.""" - api_dir = os.path.join(template_dir, "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/api") - + api_dir = os.path.join( + template_dir, + "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/api", + ) + api_files = ["__init__.py", "auth.py", "oauth2.py", "users.py", "schemas.py"] - + for api_file in api_files: file_path = os.path.join(api_dir, api_file) assert os.path.exists(file_path), f"API file missing: {api_file}" - - with open(file_path, 'r') as f: + + with open(file_path, "r") as f: content = f.read() assert content.strip(), f"API file should not be empty: {api_file}" - + # Check for specific content based on file if api_file == "auth.py": - assert "register" in content.lower(), "auth.py should contain register functionality" - assert "login" in content.lower(), "auth.py should contain login functionality" + assert ( + "register" in content.lower() + ), "auth.py should contain register functionality" + assert ( + "login" in content.lower() + ), "auth.py should contain login functionality" elif api_file == "users.py": assert "router" in content.lower(), "users.py should define router" elif api_file == "__init__.py": assert "router" in content, "__init__.py should export routers" - + def test_tests_package_structure(self, template_dir): """Test that tests package has correct structure.""" - tests_dir = os.path.join(template_dir, "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/tests") - - assert os.path.exists(os.path.join(tests_dir, "__init__.py")), "Tests __init__.py should exist" - assert os.path.exists(os.path.join(tests_dir, "test_api_package.py")), "Main test file should exist" - + tests_dir = os.path.join( + template_dir, + "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/tests", + ) + + assert os.path.exists( + os.path.join(tests_dir, "__init__.py") + ), "Tests __init__.py should exist" + assert os.path.exists( + os.path.join(tests_dir, "test_api_package.py") + ), "Main test file should exist" + # Check that test file contains comprehensive tests - with open(os.path.join(tests_dir, "test_api_package.py"), 'r') as f: + with open(os.path.join(tests_dir, "test_api_package.py"), "r") as f: content = f.read() assert "class " in content, "Test file should contain test classes" assert "def test_" in content, "Test file should contain test methods" assert "OAuth2" in content, "Test file should contain OAuth2 tests" assert "Auth" in content, "Test file should contain Auth tests" assert "Users" in content, "Test file should contain Users tests" - + def test_docker_configuration(self, template_dir): """Test that Docker configuration is present.""" project_dir = os.path.join(template_dir, "{{ cookiecutter.project_slug }}") - + dockerfile_path = os.path.join(project_dir, "Dockerfile") compose_path = os.path.join(project_dir, "docker-compose.yml") - + assert os.path.exists(dockerfile_path), "Dockerfile should exist" assert os.path.exists(compose_path), "docker-compose.yml should exist" - + # Check Dockerfile content - with open(dockerfile_path, 'r') as f: + with open(dockerfile_path, "r") as f: dockerfile_content = f.read() - assert "FROM python" in dockerfile_content, "Dockerfile should use Python base image" - assert "COPY ./requirements" in dockerfile_content, "Dockerfile should copy requirements" - + assert ( + "FROM python" in dockerfile_content + ), "Dockerfile should use Python base image" + assert ( + "COPY ./requirements" in dockerfile_content + ), "Dockerfile should copy requirements" + # Check docker-compose content - with open(compose_path, 'r') as f: + with open(compose_path, "r") as f: compose_content = f.read() - assert "version:" in compose_content, "docker-compose.yml should have version" - assert "services:" in compose_content, "docker-compose.yml should have services" - + assert ( + "version:" in compose_content + ), "docker-compose.yml should have version" + assert ( + "services:" in compose_content + ), "docker-compose.yml should have services" + def test_manage_py_exists(self, template_dir): """Test that manage.py exists and has correct content.""" - manage_path = os.path.join(template_dir, "{{ cookiecutter.project_slug }}/manage.py") - + manage_path = os.path.join( + template_dir, "{{ cookiecutter.project_slug }}/manage.py" + ) + assert os.path.exists(manage_path), "manage.py should exist" - - with open(manage_path, 'r') as f: + + with open(manage_path, "r") as f: content = f.read() assert "#!/usr/bin/env python" in content, "manage.py should have shebang" - assert "django.core.management" in content, "manage.py should import Django management" - assert "execute_from_command_line" in content, "manage.py should use execute_from_command_line" - assert "{{ cookiecutter.project_slug }}" in content, "manage.py should reference project slug" + assert ( + "django.core.management" in content + ), "manage.py should import Django management" + assert ( + "execute_from_command_line" in content + ), "manage.py should use execute_from_command_line" + assert ( + "{{ cookiecutter.project_slug }}" in content + ), "manage.py should reference project slug" class TestCookiecutterGeneration: """Test the actual cookiecutter generation process.""" - - def test_cookiecutter_generation_process(self, temp_dir, template_dir, test_context): + + def test_cookiecutter_generation_process( + self, temp_dir, template_dir, test_context + ): """Test that cookiecutter can generate a project successfully.""" pytest.importorskip("cookiecutter") from cookiecutter.main import cookiecutter - + # Generate project using cookiecutter try: generated_project = cookiecutter( template_dir, no_input=True, extra_context=test_context, - output_dir=temp_dir + output_dir=temp_dir, ) - + # Check that project was generated assert os.path.exists(generated_project), "Generated project should exist" - + # Check that key files exist in generated project expected_files = [ "manage.py", @@ -322,32 +392,29 @@ def test_cookiecutter_generation_process(self, temp_dir, template_dir, test_cont f"{test_context['project_slug']}/settings/base.py", f"{test_context['project_slug']}/accounts/api/__init__.py", f"{test_context['project_slug']}/accounts/oauth2/api.py", - f"{test_context['project_slug']}/accounts/tests/test_api_package.py" + f"{test_context['project_slug']}/accounts/tests/test_api_package.py", ] - + for file_path in expected_files: full_path = os.path.join(generated_project, file_path) assert os.path.exists(full_path), f"Generated file missing: {file_path}" - + # Test passed - project generated successfully - + except Exception as e: pytest.fail(f"Cookiecutter generation failed: {e}") - + def test_generated_project_structure(self, temp_dir, template_dir, test_context): """Test that generated project has correct structure.""" pytest.importorskip("cookiecutter") from cookiecutter.main import cookiecutter - + generated_project = cookiecutter( - template_dir, - no_input=True, - extra_context=test_context, - output_dir=temp_dir + template_dir, no_input=True, extra_context=test_context, output_dir=temp_dir ) - - project_slug = test_context['project_slug'] - + + project_slug = test_context["project_slug"] + # Check that project structure is correct expected_structure = [ "manage.py", @@ -359,43 +426,40 @@ def test_generated_project_structure(self, temp_dir, template_dir, test_context) f"{project_slug}/accounts/", f"{project_slug}/accounts/api/", f"{project_slug}/accounts/oauth2/", - f"{project_slug}/accounts/tests/" + f"{project_slug}/accounts/tests/", ] - + for item in expected_structure: full_path = os.path.join(generated_project, item) assert os.path.exists(full_path), f"Generated structure missing: {item}" - + def test_generated_imports_work(self, temp_dir, template_dir, test_context): """Test that generated project has working imports.""" pytest.importorskip("cookiecutter") from cookiecutter.main import cookiecutter - + generated_project = cookiecutter( - template_dir, - no_input=True, - extra_context=test_context, - output_dir=temp_dir + template_dir, no_input=True, extra_context=test_context, output_dir=temp_dir ) - - project_slug = test_context['project_slug'] - + + project_slug = test_context["project_slug"] + # Check that Python files don't have syntax errors python_files = [ "manage.py", f"{project_slug}/settings/base.py", f"{project_slug}/accounts/api/__init__.py", f"{project_slug}/accounts/oauth2/api.py", - f"{project_slug}/accounts/tests/test_api_package.py" + f"{project_slug}/accounts/tests/test_api_package.py", ] - + for py_file in python_files: file_path = os.path.join(generated_project, py_file) try: - with open(file_path, 'r') as f: + with open(file_path, "r") as f: content = f.read() # Try to compile the Python code - compile(content, file_path, 'exec') + compile(content, file_path, "exec") except SyntaxError as e: pytest.fail(f"Syntax error in generated file {py_file}: {e}") except Exception as e: diff --git a/tox.ini b/tox.ini index bdea6bf..310bebb 100644 --- a/tox.ini +++ b/tox.ini @@ -1,71 +1,71 @@ [tox] -envlist = py38,py39,py310,py311,validate,all-tests +envlist = py311,py312,py313,py314,validate,all-tests skipsdist = true skip_missing_interpreters = true [testenv] -deps = +deps = -r{toxinidir}/test-requirements.txt -commands = +commands = python -m pytest tests/test_cookiecutter.py -v [testenv:validate] -deps = +deps = -r{toxinidir}/test-requirements.txt -commands = +commands = python validate_template.py [testenv:quick-test] -deps = +deps = -r{toxinidir}/test-requirements.txt -commands = +commands = python tests/quick_test.py [testenv:template-test] -deps = +deps = -r{toxinidir}/test-requirements.txt -commands = +commands = python tests/run_tests.py [testenv:lint] -deps = +deps = flake8>=6.0.0 black>=23.0.0 isort>=5.12.0 -commands = +commands = flake8 --max-line-length=88 --exclude=hooks,{{ cookiecutter.project_slug }} . black --check --diff . isort --check-only --diff . [testenv:format] -deps = +deps = black>=23.0.0 isort>=5.12.0 -commands = +commands = black . isort . [testenv:coverage] -deps = +deps = -r{toxinidir}/test-requirements.txt coverage>=7.0.0 -commands = +commands = coverage run -m pytest tests/test_cookiecutter.py coverage report -m coverage html coverage xml [testenv:all-tests] -deps = +deps = -r{toxinidir}/test-requirements.txt -commands = +commands = python validate_template.py python tests/quick_test.py python -m pytest tests/test_cookiecutter.py -v [flake8] max-line-length = 88 -exclude = +exclude = .git, __pycache__, .tox, @@ -73,14 +73,14 @@ exclude = *.egg-info, hooks, {{ cookiecutter.project_slug }} -ignore = +ignore = E203, # whitespace before ':' W503, # line break before binary operator E501 # line too long (handled by black) [coverage:run] source = . -omit = +omit = .tox/* */.tox/* */tests/* diff --git a/validate_template.py b/validate_template.py index 0aa3491..596eba0 100644 --- a/validate_template.py +++ b/validate_template.py @@ -5,46 +5,57 @@ to be installed. They check file existence, content, and basic structure. """ -import os -import sys import json +import os import re +import sys from pathlib import Path class TestTemplateStructure: """Test the template structure without cookiecutter generation.""" - + def __init__(self): self.template_dir = os.path.dirname(os.path.abspath(__file__)) - self.project_template_dir = os.path.join(self.template_dir, "{{ cookiecutter.project_slug }}") - + self.project_template_dir = os.path.join( + self.template_dir, "{{ cookiecutter.project_slug }}" + ) + def test_cookiecutter_json_validity(self): """Test that cookiecutter.json is valid JSON.""" cookiecutter_path = os.path.join(self.template_dir, "cookiecutter.json") - + assert os.path.exists(cookiecutter_path), "cookiecutter.json should exist" - + try: - with open(cookiecutter_path, 'r') as f: + with open(cookiecutter_path, "r") as f: config = json.load(f) except json.JSONDecodeError as e: raise AssertionError(f"cookiecutter.json is not valid JSON: {e}") - + # Check required fields required_fields = [ - "project_name", "project_slug", "project_description", - "author_name", "author_email", "github_username", - "django_secret_key", "use_docker", "postgresql_user", - "postgresql_password", "postgresql_db", "postgresql_port", - "celery_broker_url", "celery_result_backend" + "project_name", + "project_slug", + "project_description", + "author_name", + "author_email", + "github_username", + "django_secret_key", + "use_docker", + "postgresql_user", + "postgresql_password", + "postgresql_db", + "postgresql_port", + "celery_broker_url", + "celery_result_backend", ] - + missing_fields = [field for field in required_fields if field not in config] assert not missing_fields, f"Missing required fields: {missing_fields}" - + print("✓ cookiecutter.json is valid and contains all required fields") - + def test_essential_files_exist(self): """Test that essential template files exist.""" essential_files = [ @@ -59,16 +70,16 @@ def test_essential_files_exist(self): "{{ cookiecutter.project_slug }}/requirements/local.txt", "{{ cookiecutter.project_slug }}/requirements/production.txt", ] - + missing_files = [] for file_path in essential_files: full_path = os.path.join(self.template_dir, file_path) if not os.path.exists(full_path): missing_files.append(file_path) - + assert not missing_files, f"Missing essential files: {missing_files}" print(f"✓ All {len(essential_files)} essential files exist") - + def test_django_structure_exists(self): """Test that Django project structure exists.""" django_files = [ @@ -82,16 +93,16 @@ def test_django_structure_exists(self): "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/asgi.py", "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/celery.py", ] - + missing_files = [] for file_path in django_files: full_path = os.path.join(self.template_dir, file_path) if not os.path.exists(full_path): missing_files.append(file_path) - + assert not missing_files, f"Missing Django files: {missing_files}" print(f"✓ All {len(django_files)} Django structure files exist") - + def test_accounts_app_structure(self): """Test that accounts app has correct structure.""" accounts_files = [ @@ -112,52 +123,60 @@ def test_accounts_app_structure(self): "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/tests/__init__.py", "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/tests/test_api_package.py", ] - + missing_files = [] for file_path in accounts_files: full_path = os.path.join(self.template_dir, file_path) if not os.path.exists(full_path): missing_files.append(file_path) - + assert not missing_files, f"Missing accounts app files: {missing_files}" print(f"✓ All {len(accounts_files)} accounts app files exist") - + def test_python_files_syntax(self): """Test that Python files have valid syntax (skip template files).""" python_files = [] - + # Find all Python files in the template for root, dirs, files in os.walk(self.template_dir): for file in files: - if file.endswith('.py'): + if file.endswith(".py"): python_files.append(os.path.join(root, file)) - + syntax_errors = [] for py_file in python_files: try: - with open(py_file, 'r', encoding='utf-8') as f: + with open(py_file, "r", encoding="utf-8") as f: content = f.read() - + # Skip files with cookiecutter template variables - if '{{ cookiecutter.' in content: + if "{{ cookiecutter." in content: continue - + # Try to compile the Python code - compile(content, py_file, 'exec') + try: + compile(content, py_file, "exec") + except SyntaxError: + # Skip if there are template variables like {{ project_slug }} + if "{{" in content and "}}" in content: + continue + raise except SyntaxError as e: relative_path = os.path.relpath(py_file, self.template_dir) syntax_errors.append(f"{relative_path}: {e}") except UnicodeDecodeError as e: # Skip files with encoding issues continue - + # Only report syntax errors if we found any non-template files with errors if syntax_errors: assert False, f"Syntax errors found in non-template files: {syntax_errors}" - - print(f"✓ All {len(python_files)} Python files have valid syntax (template files skipped)") + + print( + f"✓ All {len(python_files)} Python files have valid syntax (template files skipped)" + ) return True - + def test_requirements_files_content(self): """Test that requirements files have valid content.""" requirements_files = [ @@ -165,46 +184,64 @@ def test_requirements_files_content(self): "{{ cookiecutter.project_slug }}/requirements/local.txt", "{{ cookiecutter.project_slug }}/requirements/production.txt", ] - + for req_file in requirements_files: full_path = os.path.join(self.template_dir, req_file) assert os.path.exists(full_path), f"Requirements file missing: {req_file}" - - with open(full_path, 'r') as f: + + with open(full_path, "r") as f: content = f.read().strip() assert content, f"Requirements file is empty: {req_file}" - + # Check for basic Django requirements if "base.txt" in req_file: assert "Django" in content, f"Django missing from {req_file}" - assert "django-ninja" in content, f"django-ninja missing from {req_file}" - + assert ( + "django-ninja" in content + ), f"django-ninja missing from {req_file}" + print("✓ All requirements files have valid content") - + def test_docker_files_content(self): """Test that Docker files have valid content.""" - dockerfile_path = os.path.join(self.template_dir, "{{ cookiecutter.project_slug }}/Dockerfile") - compose_path = os.path.join(self.template_dir, "{{ cookiecutter.project_slug }}/docker-compose.yml") - + dockerfile_path = os.path.join( + self.template_dir, "{{ cookiecutter.project_slug }}/Dockerfile" + ) + compose_path = os.path.join( + self.template_dir, "{{ cookiecutter.project_slug }}/docker-compose.yml" + ) + # Test Dockerfile assert os.path.exists(dockerfile_path), "Dockerfile should exist" - with open(dockerfile_path, 'r') as f: + with open(dockerfile_path, "r") as f: dockerfile_content = f.read() - assert "FROM python" in dockerfile_content, "Dockerfile should use Python base image" - assert ("COPY requirements" in dockerfile_content or - "COPY ./requirements" in dockerfile_content), "Dockerfile should copy requirements" - assert "RUN pip install" in dockerfile_content, "Dockerfile should install dependencies" - + assert ( + "FROM python" in dockerfile_content + ), "Dockerfile should use Python base image" + assert ( + "COPY requirements" in dockerfile_content + or "COPY ./requirements" in dockerfile_content + ), "Dockerfile should copy requirements" + assert ( + "RUN pip install" in dockerfile_content + ), "Dockerfile should install dependencies" + # Test docker-compose.yml assert os.path.exists(compose_path), "docker-compose.yml should exist" - with open(compose_path, 'r') as f: + with open(compose_path, "r") as f: compose_content = f.read() - assert "version:" in compose_content, "docker-compose.yml should have version" - assert "services:" in compose_content, "docker-compose.yml should have services" - assert "web:" in compose_content, "docker-compose.yml should have web service" - + assert ( + "version:" in compose_content + ), "docker-compose.yml should have version" + assert ( + "services:" in compose_content + ), "docker-compose.yml should have services" + assert ( + "web:" in compose_content + ), "docker-compose.yml should have web service" + print("✓ Docker files have valid content") - + def test_oauth2_implementation(self): """Test that OAuth2 implementation is complete.""" oauth2_files = [ @@ -213,15 +250,15 @@ def test_oauth2_implementation(self): "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/oauth2/utils.py", "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/oauth2/schemas.py", ] - + for oauth2_file in oauth2_files: full_path = os.path.join(self.template_dir, oauth2_file) assert os.path.exists(full_path), f"OAuth2 file missing: {oauth2_file}" - - with open(full_path, 'r') as f: + + with open(full_path, "r") as f: content = f.read() assert content.strip(), f"OAuth2 file is empty: {oauth2_file}" - + # Check for specific OAuth2 content if "providers.py" in oauth2_file: assert "google" in content.lower(), "Google provider missing" @@ -231,9 +268,9 @@ def test_oauth2_implementation(self): assert "router" in content, "Router missing from OAuth2 API" assert "authorize" in content.lower(), "Authorize endpoint missing" assert "callback" in content.lower(), "Callback endpoint missing" - + print("✓ OAuth2 implementation is complete") - + def test_api_structure_complete(self): """Test that API structure is complete.""" api_files = [ @@ -243,15 +280,15 @@ def test_api_structure_complete(self): "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/api/users.py", "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/api/schemas.py", ] - + for api_file in api_files: full_path = os.path.join(self.template_dir, api_file) assert os.path.exists(full_path), f"API file missing: {api_file}" - - with open(full_path, 'r') as f: + + with open(full_path, "r") as f: content = f.read() assert content.strip(), f"API file is empty: {api_file}" - + # Check for specific API content if "auth.py" in api_file: assert "register" in content.lower(), "Register endpoint missing" @@ -260,73 +297,77 @@ def test_api_structure_complete(self): assert "router" in content, "Router missing from users API" elif "__init__.py" in api_file: assert "router" in content, "Router export missing from API package" - + print("✓ API structure is complete") - + def test_comprehensive_tests_exist(self): """Test that comprehensive tests exist.""" test_file = os.path.join( - self.template_dir, - "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/tests/test_api_package.py" + self.template_dir, + "{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/tests/test_api_package.py", ) - + assert os.path.exists(test_file), "Main test file should exist" - - with open(test_file, 'r') as f: + + with open(test_file, "r") as f: content = f.read() - + # Check for test classes required_test_classes = [ "AuthAPITestCase", - "UsersAPITestCase", + "UsersAPITestCase", "OAuth2ProvidersTestCase", "OAuth2UtilsTestCase", - "OAuth2APITestCase" + "OAuth2APITestCase", + ] + + missing_classes = [ + cls for cls in required_test_classes if cls not in content ] - - missing_classes = [cls for cls in required_test_classes if cls not in content] assert not missing_classes, f"Missing test classes: {missing_classes}" - + # Check for test methods - test_methods = re.findall(r'def (test_\w+)', content) - assert len(test_methods) >= 20, f"Should have at least 20 test methods, found {len(test_methods)}" - + test_methods = re.findall(r"def (test_\w+)", content) + assert ( + len(test_methods) >= 20 + ), f"Should have at least 20 test methods, found {len(test_methods)}" + print(f"✓ Comprehensive tests exist with {len(test_methods)} test methods") - + # def test_template_variables_usage(self): # """Test that cookiecutter variables are used correctly.""" # template_files = [] - + # # Find all files that might contain template variables # for root, dirs, files in os.walk(self.template_dir): # for file in files: # if file.endswith(('.py', '.yml', '.yaml', '.txt', '.md', '.json')): # template_files.append(os.path.join(root, file)) - + # variable_issues = [] - + # for template_file in template_files: # try: # with open(template_file, 'r', encoding='utf-8') as f: # content = f.read() - + # # Check for common template variable patterns # if '{{ cookiecutter.' in content: # # Check for proper variable usage # import re - + # # Find all cookiecutter variables # variables = re.findall(r'\{\{\s*cookiecutter\.(\w+)\s*\}\}', content) - + # # Check that used variables are defined in cookiecutter.json # with open(os.path.join(self.template_dir, 'cookiecutter.json'), 'r') as config_file: # config = json.load(config_file) - + # for var in variables: # if var not in config: # relative_path = os.path.relpath(template_file, self.template_dir) # variable_issues.append(f"{relative_path}: Undefined variable 'cookiecutter.{var}'") - + # # Check for malformed template syntax # malformed = re.findall(r'\{\{[^}]*\}\}', content) # for match in malformed: @@ -334,17 +375,17 @@ def test_comprehensive_tests_exist(self): # if 'cookiecutter.' in match: # Only flag cookiecutter-related syntax # relative_path = os.path.relpath(template_file, self.template_dir) # variable_issues.append(f"{relative_path}: Malformed template syntax '{match}'") - + # except (UnicodeDecodeError, json.JSONDecodeError): # continue - + # assert not variable_issues, f"Template variable issues: {variable_issues}" # print(f"✓ Template variables are correctly used in {len(template_files)} files") def run_all_tests(self): """Run all validation tests.""" print("Running cookiecutter template validation tests...\n") - + test_methods = [ self.test_cookiecutter_json_validity, self.test_essential_files_exist, @@ -358,10 +399,10 @@ def run_all_tests(self): self.test_comprehensive_tests_exist, # self.test_template_variables_usage ] - + passed = 0 failed = 0 - + for test_method in test_methods: try: test_method() @@ -372,11 +413,11 @@ def run_all_tests(self): except Exception as e: print(f"✗ {test_method.__name__}: Unexpected error: {e}") failed += 1 - + print(f"\n{'='*50}") print(f"Test Results: {passed} passed, {failed} failed") print(f"{'='*50}") - + return failed == 0 diff --git a/{{ cookiecutter.project_slug }}/.env.example b/{{ cookiecutter.project_slug }}/.env.example index e2fe5c1..4a7001c 100644 --- a/{{ cookiecutter.project_slug }}/.env.example +++ b/{{ cookiecutter.project_slug }}/.env.example @@ -19,4 +19,4 @@ CACHE_URL=redis://redis:6379/2 # Add other variables as needed (e.g., for production settings) # DJANGO_ALLOWED_HOSTS= # EMAIL_HOST= -# ... \ No newline at end of file +# ... diff --git a/{{ cookiecutter.project_slug }}/.env.oauth2.example b/{{ cookiecutter.project_slug }}/.env.oauth2.example index 0a9bcf6..f70db5e 100644 --- a/{{ cookiecutter.project_slug }}/.env.oauth2.example +++ b/{{ cookiecutter.project_slug }}/.env.oauth2.example @@ -6,7 +6,7 @@ GOOGLE_OAUTH2_CLIENT_ID=your-google-client-id.apps.googleusercontent.com GOOGLE_OAUTH2_CLIENT_SECRET=your-google-client-secret -# GitHub OAuth2 Credentials +# GitHub OAuth2 Credentials # Get these from: https://github.com/settings/applications/new GITHUB_OAUTH2_CLIENT_ID=your-github-client-id GITHUB_OAUTH2_CLIENT_SECRET=your-github-client-secret diff --git a/{{ cookiecutter.project_slug }}/.github/workflows/django-ci.yml b/{{ cookiecutter.project_slug }}/.github/workflows/django-ci.yml index 7887d1a..6f9dc4f 100644 --- a/{{ cookiecutter.project_slug }}/.github/workflows/django-ci.yml +++ b/{{ cookiecutter.project_slug }}/.github/workflows/django-ci.yml @@ -95,4 +95,4 @@ jobs: # flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics # --- END JINJA RAW BLOCK --- - {% endraw %} \ No newline at end of file + {% endraw %} diff --git a/{{ cookiecutter.project_slug }}/.pre-commit-config.yaml b/{{ cookiecutter.project_slug }}/.pre-commit-config.yaml index 4789e03..211618d 100644 --- a/{{ cookiecutter.project_slug }}/.pre-commit-config.yaml +++ b/{{ cookiecutter.project_slug }}/.pre-commit-config.yaml @@ -48,4 +48,4 @@ repos: # hooks: # - id: bandit # args: ["-c", "pyproject.toml"] # Requires bandit config in pyproject.toml -# exclude: tests/ # Exclude test directories if needed \ No newline at end of file +# exclude: tests/ # Exclude test directories if needed diff --git a/{{ cookiecutter.project_slug }}/Dockerfile b/{{ cookiecutter.project_slug }}/Dockerfile index 6212329..c717668 100644 --- a/{{ cookiecutter.project_slug }}/Dockerfile +++ b/{{ cookiecutter.project_slug }}/Dockerfile @@ -1,5 +1,5 @@ # Start from a Python base image -FROM python:3.12-slim-bullseye as base +FROM python:3.13-slim-bookworm as base # Set environment variables ENV PYTHONDONTWRITEBYTECODE 1 @@ -59,4 +59,4 @@ EXPOSE 8000 # Run gunicorn # The specific command might be overridden in docker-compose.yml # Use --bind 0.0.0.0 to allow connections from outside the container -CMD ["gunicorn", "--bind", "0.0.0.0:8000", "{{ cookiecutter.project_slug }}.wsgi:application"] \ No newline at end of file +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "{{ cookiecutter.project_slug }}.wsgi:application"] diff --git a/{{ cookiecutter.project_slug }}/README.md b/{{ cookiecutter.project_slug }}/README.md index d05c14f..703ca9e 100644 --- a/{{ cookiecutter.project_slug }}/README.md +++ b/{{ cookiecutter.project_slug }}/README.md @@ -182,4 +182,4 @@ Deploying this project involves several steps beyond the scope of this README. K * **Media Files:** Configure `MEDIA_ROOT` and `MEDIA_URL`. Production usually requires a persistent shared storage solution (like AWS S3, Google Cloud Storage) rather than the local filesystem. * **Celery:** Run Celery workers and Celery Beat as persistent background services (e.g., using `systemd` or `supervisor`). * **Database/Redis:** Use managed database and Redis services or properly secured and backed-up instances. -* **Security:** Review Django's deployment checklist: [https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/](https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/) \ No newline at end of file +* **Security:** Review Django's deployment checklist: [https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/](https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/) diff --git a/{{ cookiecutter.project_slug }}/manage.py b/{{ cookiecutter.project_slug }}/manage.py index 0423971..5b6296e 100755 --- a/{{ cookiecutter.project_slug }}/manage.py +++ b/{{ cookiecutter.project_slug }}/manage.py @@ -6,7 +6,9 @@ def main(): """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', '{{ cookiecutter.project_slug }}.settings.local') + os.environ.setdefault( + "DJANGO_SETTINGS_MODULE", "{{ cookiecutter.project_slug }}.settings.local" + ) try: from django.core.management import execute_from_command_line except ImportError as exc: @@ -18,5 +20,5 @@ def main(): execute_from_command_line(sys.argv) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/{{ cookiecutter.project_slug }}/requirements/base.txt b/{{ cookiecutter.project_slug }}/requirements/base.txt index e75d9f3..6a6dc90 100644 --- a/{{ cookiecutter.project_slug }}/requirements/base.txt +++ b/{{ cookiecutter.project_slug }}/requirements/base.txt @@ -1,19 +1,21 @@ # Base requirements for {{ cookiecutter.project_name }} - +# Celery and related packages +celery>=5.6.2,<6.0 +# Optional: For parsing DATABASE_URL format easily +dj-database-url>=3.1.2,<4.0 # Django framework -django>=5.2.9,<5.3 # Use Django 5.2.x (latest stable LTS - Django 6.0 not yet compatible with django-celery-beat) +django>=6.0.3,<6.1 # Use Django 6.0.x (latest) +django-celery-beat>=2.9.0,<3.0 # Database-backed periodic task scheduler +django-celery-results>=2.6.0,<3.0 # Celery result backend using Django ORM -# API Framework -django-ninja>=1.5.1,<2.0 +# Object-level Permissions +django-guardian>=3.3.0,<4.0 -# Celery and related packages -celery>=5.6.0,<6.0 -redis>=7.1.0,<8.0 # Python client for Redis (used by Celery) -django-celery-beat>=2.8.1,<3.0 # Database-backed periodic task scheduler -django-celery-results>=2.6.0,<3.0 # Celery result backend using Django ORM +# API Framework +django-ninja>=1.6.0,<2.0 -# Database driver (PostgreSQL) -psycopg2-binary>=2.9.11,<3.0 # Use binary for easier installation +# OAuth2 Authentication +django-oauth-toolkit>=3.2.0,<4.0 # OAuth2 provider and consumer # Cache backend (Redis) django-redis>=6.0.0,<7.0 @@ -21,18 +23,14 @@ django-redis>=6.0.0,<7.0 # JWT Authentication djangorestframework-simplejwt>=5.5.1,<6.0 -# OAuth2 Authentication -django-oauth-toolkit>=3.1.0,<4.0 # OAuth2 provider and consumer -requests-oauthlib>=2.0.0,<3.0 # OAuth2 client library -social-auth-app-django>=5.6.0,<6.0 # Social authentication (Google, GitHub, etc.) +# WSGI server for production +gunicorn>=25.1.0,<26.0 -# Object-level Permissions -django-guardian>=3.2.0,<4.0 +# Database driver (PostgreSQL) +psycopg2-binary>=2.9.11,<3.0 # Use binary for easier installation # Environment variable management python-decouple>=3.8,<4.0 -# Optional: For parsing DATABASE_URL format easily -dj-database-url>=3.0.1,<4.0 - -# WSGI server for production -gunicorn>=23.0.0,<24.0 \ No newline at end of file +redis>=7.3.0,<8.0 # Python client for Redis (used by Celery) +requests-oauthlib>=2.0.0,<3.0 # OAuth2 client library +social-auth-app-django>=5.7.0,<6.0 # Social authentication (Google, GitHub, etc.) diff --git a/{{ cookiecutter.project_slug }}/requirements/local.txt b/{{ cookiecutter.project_slug }}/requirements/local.txt index 0702946..6eb4baa 100644 --- a/{{ cookiecutter.project_slug }}/requirements/local.txt +++ b/{{ cookiecutter.project_slug }}/requirements/local.txt @@ -3,18 +3,18 @@ -r base.txt +# Code formatting and linting +black>=26.3.1,<27.0 +# For debugging in the browser +django-debug-toolbar>=6.2.0,<7.0 + # Useful Django extensions (runserver_plus, shell_plus, etc.) django-extensions>=4.1,<5.0 -# For debugging in the browser -django-debug-toolbar>=6.1.0,<7.0 -# Werkzeug might be needed by runserver_plus or debug toolbar in some cases -Werkzeug>=3.1.4,<4.0 +flake8>=7.3.0,<8.0 +isort>=8.0.1,<9.0 # Testing libraries pytest>=9.0.2,<10.0 -pytest-django>=4.11.1,<5.0 - -# Code formatting and linting -black>=25.12.0,<26.0 -isort>=7.0.0,<8.0 -flake8>=7.3.0,<8.0 \ No newline at end of file +pytest-django>=4.12.0,<5.0 +# Werkzeug might be needed by runserver_plus or debug toolbar in some cases +Werkzeug>=3.1.6,<4.0 \ No newline at end of file diff --git a/{{ cookiecutter.project_slug }}/requirements/production.txt b/{{ cookiecutter.project_slug }}/requirements/production.txt index c1ef24e..ed61e90 100644 --- a/{{ cookiecutter.project_slug }}/requirements/production.txt +++ b/{{ cookiecutter.project_slug }}/requirements/production.txt @@ -3,11 +3,11 @@ -r base.txt -# Simplified static file serving for production -whitenoise[brotli]>=6.11.0,<7.0 # Includes brotli compression support - # Production monitoring and logging -sentry-sdk[django]>=2.48.0,<3.0 # Error tracking and performance monitoring +sentry-sdk[django]>=2.54.0,<3.0 # Error tracking and performance monitoring + +# Simplified static file serving for production +whitenoise[brotli]>=6.12.0,<7.0 # Includes brotli compression support # Add any other production-specific dependencies here -# e.g., monitoring agents, specific logging libraries \ No newline at end of file +# e.g., monitoring agents, specific logging libraries diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/__init__.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/__init__.py index 45ca9ab..5568b6d 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/__init__.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/__init__.py @@ -2,4 +2,4 @@ # Django starts so that shared_task will use this app. from .celery import app as celery_app -__all__ = ('celery_app',) \ No newline at end of file +__all__ = ("celery_app",) diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/admin.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/admin.py index 987221a..15d4fe1 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/admin.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/admin.py @@ -11,4 +11,4 @@ # @admin.register(User) # class UserAdmin(BaseUserAdmin): # # Add customizations here -# pass \ No newline at end of file +# pass diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/api/__init__.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/api/__init__.py index d872ce1..0e3d9f0 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/api/__init__.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/api/__init__.py @@ -8,14 +8,13 @@ - JWT token management """ from ninja import Router - -from {{ cookiecutter.project_slug }}.accounts.api.auth import router as auth_router -from {{ cookiecutter.project_slug }}.accounts.api.oauth2 import router as oauth2_router -from {{ cookiecutter.project_slug }}.accounts.api.users import router as users_router +from {{cookiecutter.project_slug}}.accounts.api.auth import router as auth_router +from {{cookiecutter.project_slug}}.accounts.api.oauth2 import router as oauth2_router +from {{cookiecutter.project_slug}}.accounts.api.users import router as users_router __all__ = [ 'auth_router', - 'oauth2_router', + 'oauth2_router', 'users_router', ] diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/api/auth.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/api/auth.py index 9ae3250..14e2277 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/api/auth.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/api/auth.py @@ -7,14 +7,21 @@ - JWT token management """ -from ninja import Router -from django.contrib.auth import authenticate, login as django_login +from datetime import timedelta + +from django.contrib.auth import authenticate +from django.contrib.auth import login as django_login from django.contrib.auth.models import User from django.db import IntegrityError +from ninja import Router from rest_framework_simplejwt.tokens import RefreshToken -from datetime import timedelta - -from {{ cookiecutter.project_slug }}.accounts.schemas import UserRegisterSchema, UserLoginSchema, TokenResponseSchema, UserSchema, ErrorSchema +from {{cookiecutter.project_slug}}.accounts.schemas import ( + ErrorSchema, + TokenResponseSchema, + UserLoginSchema, + UserRegisterSchema, + UserSchema, +) # Initialize the authentication router router = Router() diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/api/oauth2.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/api/oauth2.py index 05bc889..8d8f3a5 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/api/oauth2.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/api/oauth2.py @@ -10,7 +10,9 @@ from ninja import Router # Import the OAuth2 router from the oauth2 package -from {{ cookiecutter.project_slug }}.accounts.oauth2.api import router as oauth2_base_router +from {{cookiecutter.project_slug}}.accounts.oauth2.api import ( + router as oauth2_base_router, +) # Initialize the OAuth2 API router router = Router() diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/api/schemas.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/api/schemas.py index dbfe142..2df4cdd 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/api/schemas.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/api/schemas.py @@ -4,20 +4,25 @@ Additional schemas for user profile management and API operations. """ +from typing import Optional + from ninja import Schema from pydantic import Field -from typing import Optional class UserUpdateSchema(Schema): """Schema for updating user profile.""" + first_name: Optional[str] = Field(None, max_length=150) last_name: Optional[str] = Field(None, max_length=150) - email: Optional[str] = Field(None, pattern=r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)") + email: Optional[str] = Field( + None, pattern=r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)" + ) class UserPublicSchema(Schema): """Schema for public user information (excludes sensitive data).""" + id: int username: str first_name: Optional[str] = None @@ -27,6 +32,7 @@ class UserPublicSchema(Schema): class AuthTokenSchema(Schema): """Schema for authentication token information.""" + token_type: str = "Bearer" access_token: str expires_in: int = 3600 # 1 hour default @@ -34,6 +40,7 @@ class AuthTokenSchema(Schema): class PasswordChangeSchema(Schema): """Schema for password change requests.""" + old_password: str = Field(..., min_length=8) new_password: str = Field(..., min_length=8) confirm_password: str = Field(..., min_length=8) @@ -41,11 +48,15 @@ class PasswordChangeSchema(Schema): class PasswordResetRequestSchema(Schema): """Schema for password reset requests.""" - email: str = Field(..., pattern=r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)") + + email: str = Field( + ..., pattern=r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)" + ) class PasswordResetConfirmSchema(Schema): """Schema for password reset confirmation.""" + token: str = Field(..., min_length=1) new_password: str = Field(..., min_length=8) confirm_password: str = Field(..., min_length=8) @@ -53,5 +64,6 @@ class PasswordResetConfirmSchema(Schema): class APISuccessSchema(Schema): """Schema for successful API operations.""" + success: bool = True message: str diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/api/users.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/api/users.py index d457ab0..e80390f 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/api/users.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/api/users.py @@ -7,14 +7,13 @@ - User management operations """ -from ninja import Router +from django.contrib.auth import get_user_model from django.contrib.auth.models import User +from ninja import Router from ninja.security import HttpBearer from rest_framework_simplejwt.authentication import JWTAuthentication from rest_framework_simplejwt.exceptions import InvalidToken, TokenError -from django.contrib.auth import get_user_model - -from {{ cookiecutter.project_slug }}.accounts.schemas import UserSchema, ErrorSchema +from {{cookiecutter.project_slug}}.accounts.schemas import ErrorSchema, UserSchema # Initialize the users router router = Router() @@ -46,7 +45,7 @@ def get_current_user(request): """ if not request.auth: return 401, {"detail": "Authentication required."} - + return 200, request.auth @@ -63,9 +62,9 @@ def update_current_user(request, payload: dict): """ if not request.auth: return 401, {"detail": "Authentication required."} - + user = request.auth - + # Update allowed fields if 'first_name' in payload: user.first_name = payload['first_name'] @@ -76,7 +75,7 @@ def update_current_user(request, payload: dict): if User.objects.filter(email=payload['email']).exclude(id=user.id).exists(): return 400, {"detail": "Email address is already in use."} user.email = payload['email'] - + try: user.save() return 200, user @@ -97,7 +96,7 @@ def delete_current_user(request): """ if not request.auth: return 401, {"detail": "Authentication required."} - + user = request.auth user.delete() return 204, None diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/apps.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/apps.py index 8b1f9d9..fd65b00 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/apps.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/apps.py @@ -1,6 +1,7 @@ from django.apps import AppConfig + class AccountsConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = '{{ cookiecutter.project_slug }}.accounts' # Use full path for clarity - label = 'accounts' # Optional shorter label \ No newline at end of file + default_auto_field = "django.db.models.BigAutoField" + name = "{{ cookiecutter.project_slug }}.accounts" # Use full path for clarity + label = "accounts" # Optional shorter label diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/oauth2/__init__.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/oauth2/__init__.py index d4f13bf..dee9047 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/oauth2/__init__.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/oauth2/__init__.py @@ -5,9 +5,16 @@ It includes provider configuration, user data normalization, and API endpoints. """ -from {{ cookiecutter.project_slug }}.accounts.oauth2.providers import OAUTH2_PROVIDERS, get_oauth2_config -from {{ cookiecutter.project_slug }}.accounts.oauth2.utils import exchange_code_for_token, get_user_info, normalize_user_data -from {{ cookiecutter.project_slug }}.accounts.oauth2.api import router as oauth2_router +from {{cookiecutter.project_slug}}.accounts.oauth2.api import router as oauth2_router +from {{cookiecutter.project_slug}}.accounts.oauth2.providers import ( + OAUTH2_PROVIDERS, + get_oauth2_config, +) +from {{cookiecutter.project_slug}}.accounts.oauth2.utils import ( + exchange_code_for_token, + get_user_info, + normalize_user_data, +) __all__ = [ 'OAUTH2_PROVIDERS', diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/oauth2/api.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/oauth2/api.py index b563dca..f9311c3 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/oauth2/api.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/oauth2/api.py @@ -6,22 +6,29 @@ import secrets import urllib.parse -from ninja import Router + +import requests from django.contrib.auth.models import User from django.core.cache import cache +from ninja import Router from rest_framework_simplejwt.tokens import RefreshToken -import requests - -from {{ cookiecutter.project_slug }}.accounts.oauth2.schemas import ( +from {{cookiecutter.project_slug}}.accounts.oauth2.providers import ( + get_available_providers, + get_oauth2_config, +) +from {{cookiecutter.project_slug}}.accounts.oauth2.schemas import ( + OAuth2AuthorizeResponseSchema, OAuth2AuthorizeSchema, OAuth2CallbackSchema, - OAuth2TokenResponseSchema, OAuth2ErrorSchema, OAuth2ProvidersResponseSchema, - OAuth2AuthorizeResponseSchema, + OAuth2TokenResponseSchema, +) +from {{cookiecutter.project_slug}}.accounts.oauth2.utils import ( + exchange_code_for_token, + get_user_info, + normalize_user_data, ) -from {{ cookiecutter.project_slug }}.accounts.oauth2.providers import get_oauth2_config, get_available_providers -from {{ cookiecutter.project_slug }}.accounts.oauth2.utils import exchange_code_for_token, get_user_info, normalize_user_data # Initialize the OAuth2 router router = Router() @@ -35,12 +42,12 @@ def oauth2_providers(request): """ Get list of configured OAuth2 providers. - + Returns a list of OAuth2 providers that have been configured with valid client credentials. """ available_providers = get_available_providers() - + return 200, { "providers": available_providers } @@ -54,7 +61,7 @@ def oauth2_providers(request): def oauth2_authorize(request, payload: OAuth2AuthorizeSchema): """ Generate OAuth2 authorization URL for specified provider. - + This endpoint generates the URL where the user should be redirected for OAuth2 authentication. It also handles CSRF protection using the state parameter. @@ -63,17 +70,17 @@ def oauth2_authorize(request, payload: OAuth2AuthorizeSchema): config = get_oauth2_config(payload.provider) except ValueError as e: return 400, {"error": "invalid_provider", "error_description": str(e)} - + # Generate state for CSRF protection state = payload.state or secrets.token_urlsafe(32) - + # Cache the state and redirect_uri for verification cache_key = f"oauth2_state_{state}" cache.set(cache_key, { 'provider': payload.provider, 'redirect_uri': payload.redirect_uri }, timeout=600) # 10 minutes - + # Build authorization URL params = { 'client_id': config['client_id'], @@ -82,13 +89,13 @@ def oauth2_authorize(request, payload: OAuth2AuthorizeSchema): 'response_type': 'code', 'state': state, } - + if payload.provider == 'google': params['access_type'] = 'offline' params['prompt'] = 'consent' - + auth_url = f"{config['authorization_url']}?{urllib.parse.urlencode(params)}" - + return 200, { "authorization_url": auth_url, "state": state @@ -103,7 +110,7 @@ def oauth2_authorize(request, payload: OAuth2AuthorizeSchema): def oauth2_callback(request, payload: OAuth2CallbackSchema): """ Handle OAuth2 callback, exchange code for token, and authenticate/create user. - + This endpoint handles the OAuth2 callback from the provider, exchanges the authorization code for an access token, retrieves user information, and either finds an existing user or creates a new one. Returns JWT tokens @@ -115,70 +122,70 @@ def oauth2_callback(request, payload: OAuth2CallbackSchema): cached_data = cache.get(cache_key) if not cached_data: return 400, { - "error": "invalid_state", + "error": "invalid_state", "error_description": "State parameter is invalid or expired" } - - if (cached_data['provider'] != payload.provider or + + if (cached_data['provider'] != payload.provider or cached_data['redirect_uri'] != payload.redirect_uri): return 400, { - "error": "state_mismatch", + "error": "state_mismatch", "error_description": "State parameters do not match" } - + # Clean up the state cache.delete(cache_key) - + try: # Exchange code for access token token_response = exchange_code_for_token( - payload.provider, - payload.code, + payload.provider, + payload.code, payload.redirect_uri ) access_token = token_response.get('access_token') - + if not access_token: return 400, { - "error": "token_exchange_failed", + "error": "token_exchange_failed", "error_description": "Failed to obtain access token" } - + # Get user information from OAuth2 provider user_info = get_user_info(payload.provider, access_token) normalized_data = normalize_user_data(payload.provider, user_info, access_token) - + if not normalized_data.get('email'): return 400, { - "error": "no_email", + "error": "no_email", "error_description": "Email address is required but not provided by the OAuth2 provider" } - + # Find or create user user = _find_or_create_user(normalized_data) - + # Generate JWT tokens refresh = RefreshToken.for_user(user) - + return 200, { 'access': str(refresh.access_token), 'refresh': str(refresh), 'user': user } - + except requests.RequestException as e: return 400, { - "error": "api_error", + "error": "api_error", "error_description": f"OAuth2 API request failed: {str(e)}" } except ValueError as e: return 400, { - "error": "invalid_provider", + "error": "invalid_provider", "error_description": str(e) } except Exception as e: return 400, { - "error": "unexpected_error", + "error": "unexpected_error", "error_description": "An unexpected error occurred during authentication" } @@ -186,16 +193,16 @@ def oauth2_callback(request, payload: OAuth2CallbackSchema): def _find_or_create_user(normalized_data: dict) -> User: """ Find existing user by email or create a new one. - + Args: normalized_data: Normalized user data from OAuth2 provider - + Returns: User instance (existing or newly created) """ email = normalized_data['email'] username = normalized_data['username'] - + # Try to find existing user by email try: user = User.objects.get(email=email) @@ -209,7 +216,7 @@ def _find_or_create_user(normalized_data: dict) -> User: while User.objects.filter(username=final_username).exists(): final_username = f"{base_username}{counter}" counter += 1 - + user = User.objects.create_user( username=final_username, email=email, @@ -219,5 +226,5 @@ def _find_or_create_user(normalized_data: dict) -> User: ) user.set_unusable_password() user.save() - + return user diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/oauth2/providers.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/oauth2/providers.py index 5e54d63..5681298 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/oauth2/providers.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/oauth2/providers.py @@ -5,9 +5,9 @@ their endpoints, scopes, and settings keys. """ -from django.conf import settings -from typing import Dict, Any, List +from typing import Any, Dict, List +from django.conf import settings # OAuth2 Provider Configuration OAUTH2_PROVIDERS = { diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/oauth2/schemas.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/oauth2/schemas.py index 9807c44..fd97c71 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/oauth2/schemas.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/oauth2/schemas.py @@ -2,14 +2,17 @@ OAuth2 Pydantic schemas for request/response validation. """ +from typing import Optional + from ninja import Schema from pydantic import Field -from typing import Optional -from ..schemas import UserSchema, ErrorSchema + +from ..schemas import ErrorSchema, UserSchema class OAuth2AuthorizeSchema(Schema): """Schema for OAuth2 authorization request.""" + provider: str = Field(..., description="OAuth2 provider (google, github, facebook)") redirect_uri: str = Field(..., description="Redirect URI after authorization") state: Optional[str] = Field(None, description="CSRF protection state parameter") @@ -17,14 +20,18 @@ class OAuth2AuthorizeSchema(Schema): class OAuth2CallbackSchema(Schema): """Schema for OAuth2 callback request.""" + provider: str = Field(..., description="OAuth2 provider") code: str = Field(..., description="Authorization code from provider") - state: Optional[str] = Field(None, description="State parameter for CSRF protection") + state: Optional[str] = Field( + None, description="State parameter for CSRF protection" + ) redirect_uri: str = Field(..., description="Redirect URI used in authorization") class OAuth2TokenResponseSchema(Schema): """Schema for OAuth2 token response.""" + access: str refresh: str user: UserSchema @@ -32,22 +39,26 @@ class OAuth2TokenResponseSchema(Schema): class OAuth2ErrorSchema(Schema): """Schema for OAuth2 error responses.""" + error: str error_description: Optional[str] = None class OAuth2ProviderSchema(Schema): """Schema for OAuth2 provider information.""" + name: str scope: str class OAuth2ProvidersResponseSchema(Schema): """Schema for OAuth2 providers list response.""" + providers: list[OAuth2ProviderSchema] class OAuth2AuthorizeResponseSchema(Schema): """Schema for OAuth2 authorization URL response.""" + authorization_url: str state: str diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/oauth2/utils.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/oauth2/utils.py index e97e838..461d1b8 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/oauth2/utils.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/oauth2/utils.py @@ -5,8 +5,10 @@ user information retrieval, and data normalization. """ -import requests from typing import Any, Dict, List, Optional + +import requests + from .providers import get_oauth2_config diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/schemas.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/schemas.py index 6acced0..11de55a 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/schemas.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/schemas.py @@ -1,23 +1,30 @@ -from ninja import Schema -from pydantic import Field # Use Field for validation examples import re from typing import Optional +from ninja import Schema +from pydantic import Field # Use Field for validation examples + + class UserRegisterSchema(Schema): username: str = Field(..., min_length=3, max_length=150) - email: str = Field(..., pattern=r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)") # Basic email pattern + email: str = Field( + ..., pattern=r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)" + ) # Basic email pattern password: str = Field(..., min_length=8) first_name: Optional[str] = None last_name: Optional[str] = None + class UserLoginSchema(Schema): username: str password: str + class TokenResponseSchema(Schema): access: str refresh: str + # Schema for returning user details (excluding password) class UserSchema(Schema): id: int @@ -26,5 +33,6 @@ class UserSchema(Schema): first_name: Optional[str] = None last_name: Optional[str] = None + class ErrorSchema(Schema): - detail: str \ No newline at end of file + detail: str diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/tests/test_api_package.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/tests/test_api_package.py index 83c3233..6594d7a 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/tests/test_api_package.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/accounts/tests/test_api_package.py @@ -8,17 +8,26 @@ - Provider configuration and utilities """ -from django.test import TestCase +import json +from unittest.mock import MagicMock, patch + from django.contrib.auth.models import User -from unittest.mock import patch, MagicMock from django.core.cache import cache +from django.test import TestCase from rest_framework_simplejwt.tokens import RefreshToken -import json # Import OAuth2 modules for testing try: - from {{ cookiecutter.project_slug }}.accounts.oauth2.providers import get_oauth2_config, get_available_providers, OAUTH2_PROVIDERS - from {{ cookiecutter.project_slug }}.accounts.oauth2.utils import normalize_user_data, exchange_code_for_token, get_user_info + from {{cookiecutter.project_slug}}.accounts.oauth2.providers import ( + OAUTH2_PROVIDERS, + get_available_providers, + get_oauth2_config, + ) + from {{cookiecutter.project_slug}}.accounts.oauth2.utils import ( + exchange_code_for_token, + get_user_info, + normalize_user_data, + ) except ImportError: # Fallback for testing environment pass @@ -26,7 +35,7 @@ class AuthAPITestCase(TestCase): """Test authentication API endpoints (/api/accounts/auth/).""" - + def setUp(self): self.base_url = '/api/accounts/auth' self.test_user_data = { @@ -47,12 +56,12 @@ def test_user_registration_success(self): ) self.assertEqual(response.status_code, 201) self.assertEqual(User.objects.count(), initial_count + 1) - + data = response.json() self.assertEqual(data['username'], self.test_user_data['username']) self.assertEqual(data['email'], self.test_user_data['email']) self.assertNotIn('password', data) # Password should not be returned - + # Verify user was created in database user = User.objects.get(username=self.test_user_data['username']) self.assertEqual(user.email, self.test_user_data['email']) @@ -67,7 +76,7 @@ def test_user_registration_duplicate_username(self): password='password123' ) initial_count = User.objects.count() - + response = self.client.post( f'{self.base_url}/register', data=json.dumps(self.test_user_data), @@ -87,7 +96,7 @@ def test_user_registration_duplicate_email(self): password='password123' ) initial_count = User.objects.count() - + response = self.client.post( f'{self.base_url}/register', data=json.dumps(self.test_user_data), @@ -102,7 +111,7 @@ def test_user_registration_invalid_email(self): """Test user registration with invalid email format.""" invalid_data = self.test_user_data.copy() invalid_data['email'] = 'not-an-email' - + response = self.client.post( f'{self.base_url}/register', data=json.dumps(invalid_data), @@ -115,7 +124,7 @@ def test_user_registration_short_password(self): """Test user registration with short password.""" invalid_data = self.test_user_data.copy() invalid_data['password'] = 'short' # Less than 8 characters - + response = self.client.post( f'{self.base_url}/register', data=json.dumps(invalid_data), @@ -132,12 +141,12 @@ def test_user_login_valid_credentials(self): email=self.test_user_data['email'], password=self.test_user_data['password'] ) - + login_data = { 'username': self.test_user_data['username'], 'password': self.test_user_data['password'] } - + response = self.client.post( f'{self.base_url}/login', data=json.dumps(login_data), @@ -158,12 +167,12 @@ def test_user_login_invalid_credentials(self): email=self.test_user_data['email'], password=self.test_user_data['password'] ) - + login_data = { 'username': self.test_user_data['username'], 'password': 'wrongpassword' } - + response = self.client.post( f'{self.base_url}/login', data=json.dumps(login_data), @@ -179,7 +188,7 @@ def test_user_login_nonexistent_user(self): 'username': 'nonexistent', 'password': 'password123' } - + response = self.client.post( f'{self.base_url}/login', data=json.dumps(login_data), @@ -192,7 +201,7 @@ def test_user_login_nonexistent_user(self): class UsersAPITestCase(TestCase): """Test user management API endpoints (/api/accounts/users/).""" - + def setUp(self): self.base_url = '/api/accounts/users' self.user = User.objects.create_user( @@ -231,7 +240,7 @@ def test_update_current_user_success(self): 'last_name': 'Name', 'email': 'updated@example.com' } - + response = self.client.put( f'{self.base_url}/me', data=json.dumps(update_data), @@ -239,7 +248,7 @@ def test_update_current_user_success(self): **self.auth_headers ) self.assertEqual(response.status_code, 200) - + # Verify user was updated self.user.refresh_from_db() self.assertEqual(self.user.first_name, 'Updated') @@ -254,11 +263,11 @@ def test_update_current_user_duplicate_email(self): email='other@example.com', password='password123' ) - + update_data = { 'email': 'other@example.com' } - + response = self.client.put( f'{self.base_url}/me', data=json.dumps(update_data), @@ -287,10 +296,10 @@ def test_get_user_by_id_not_found(self): def test_delete_current_user_success(self): """Test deleting current user account.""" user_id = self.user.id - + response = self.client.delete(f'{self.base_url}/me', **self.auth_headers) self.assertEqual(response.status_code, 204) - + # Verify user was deleted with self.assertRaises(User.DoesNotExist): User.objects.get(id=user_id) @@ -303,14 +312,14 @@ def test_delete_current_user_unauthenticated(self): class OAuth2ProvidersTestCase(TestCase): """Test OAuth2 provider configuration and utilities.""" - + def test_oauth2_providers_config_structure(self): """Test OAuth2 providers configuration structure.""" try: self.assertIn('google', OAUTH2_PROVIDERS) self.assertIn('github', OAUTH2_PROVIDERS) self.assertIn('facebook', OAUTH2_PROVIDERS) - + for provider, config in OAUTH2_PROVIDERS.items(): self.assertIn('authorization_url', config) self.assertIn('token_url', config) @@ -320,7 +329,7 @@ def test_oauth2_providers_config_structure(self): self.assertIn('client_secret_setting', config) except NameError: self.skipTest("OAuth2 providers not available in test environment") - + def test_get_oauth2_config_valid_provider(self): """Test getting OAuth2 config for valid provider.""" try: @@ -332,7 +341,7 @@ def test_get_oauth2_config_valid_provider(self): self.assertIn('authorization_url', config) except NameError: self.skipTest("OAuth2 config function not available in test environment") - + def test_get_oauth2_config_invalid_provider(self): """Test getting OAuth2 config for invalid provider.""" try: @@ -341,7 +350,7 @@ def test_get_oauth2_config_invalid_provider(self): self.assertIn('Unsupported OAuth2 provider', str(context.exception)) except NameError: self.skipTest("OAuth2 config function not available in test environment") - + def test_get_oauth2_config_missing_credentials(self): """Test getting OAuth2 config with missing credentials.""" try: @@ -351,7 +360,7 @@ def test_get_oauth2_config_missing_credentials(self): self.assertIn('OAuth2 credentials not configured', str(context.exception)) except NameError: self.skipTest("OAuth2 config function not available in test environment") - + def test_get_available_providers(self): """Test getting list of available providers.""" try: @@ -365,7 +374,7 @@ def test_get_available_providers(self): class OAuth2UtilsTestCase(TestCase): """Test OAuth2 utility functions.""" - + def test_normalize_google_data(self): """Test user data normalization for Google.""" try: @@ -375,9 +384,9 @@ def test_normalize_google_data(self): 'given_name': 'Test', 'family_name': 'User' } - + normalized = normalize_user_data('google', user_info) - + self.assertEqual(normalized['email'], 'test@example.com') self.assertEqual(normalized['first_name'], 'Test') self.assertEqual(normalized['last_name'], 'User') @@ -385,7 +394,7 @@ def test_normalize_google_data(self): self.assertEqual(normalized['provider_id'], '12345') except NameError: self.skipTest("OAuth2 utils not available in test environment") - + def test_normalize_github_data(self): """Test user data normalization for GitHub.""" try: @@ -395,9 +404,9 @@ def test_normalize_github_data(self): 'login': 'testuser', 'name': 'Test User' } - + normalized = normalize_user_data('github', user_info) - + self.assertEqual(normalized['email'], 'test@example.com') self.assertEqual(normalized['first_name'], 'Test') self.assertEqual(normalized['last_name'], 'User') @@ -405,7 +414,7 @@ def test_normalize_github_data(self): self.assertEqual(normalized['provider_id'], '12345') except NameError: self.skipTest("OAuth2 utils not available in test environment") - + def test_normalize_facebook_data(self): """Test user data normalization for Facebook.""" try: @@ -415,9 +424,9 @@ def test_normalize_facebook_data(self): 'first_name': 'Test', 'last_name': 'User' } - + normalized = normalize_user_data('facebook', user_info) - + self.assertEqual(normalized['email'], 'test@example.com') self.assertEqual(normalized['first_name'], 'Test') self.assertEqual(normalized['last_name'], 'User') @@ -429,7 +438,7 @@ def test_normalize_facebook_data(self): class OAuth2APITestCase(TestCase): """Test OAuth2 API endpoints (/api/accounts/oauth2/).""" - + def setUp(self): self.base_url = '/api/accounts/oauth2' cache.clear() @@ -494,13 +503,13 @@ def test_oauth2_callback_invalid_state(self): 'state': 'invalid-state', 'redirect_uri': 'http://localhost:8000/callback' } - + response = self.client.post( f'{self.base_url}/callback', data=json.dumps(payload), content_type='application/json' ) - + if response.status_code in [400, 404]: if response.status_code == 400: data = response.json() @@ -511,7 +520,7 @@ def test_oauth2_callback_invalid_state(self): class APIIntegrationTestCase(TestCase): """Integration tests for the entire API package.""" - + def test_api_package_structure(self): """Test that the API package structure is correct.""" # Test that core endpoints exist and return proper error codes @@ -519,31 +528,31 @@ def test_api_package_structure(self): ('/api/accounts/auth/register', 'POST'), ('/api/accounts/auth/login', 'POST'), ] - + users_endpoints = [ ('/api/accounts/users/me', 'GET'), ('/api/accounts/users/1', 'GET'), ] - + oauth2_endpoints = [ ('/api/accounts/oauth2/providers', 'GET'), ('/api/accounts/oauth2/authorize', 'POST'), ('/api/accounts/oauth2/callback', 'POST'), ] - + all_endpoints = auth_endpoints + users_endpoints + oauth2_endpoints - + for endpoint, method in all_endpoints: self.assertTrue(endpoint.startswith('/api/accounts/')) - + # Test that endpoints exist (should return something, not 404) if method == 'GET': response = self.client.get(endpoint) else: response = self.client.post(endpoint, content_type='application/json') - + # We expect various status codes but not 404 (endpoint not found) - self.assertNotEqual(response.status_code, 404, + self.assertNotEqual(response.status_code, 404, f"Endpoint {endpoint} not found (404)") def test_authentication_flow(self): @@ -556,40 +565,40 @@ def test_authentication_flow(self): 'first_name': 'Flow', 'last_name': 'Test' } - + register_response = self.client.post( '/api/accounts/auth/register', data=json.dumps(user_data), content_type='application/json' ) - + if register_response.status_code == 201: # 2. Login user login_data = { 'username': user_data['username'], 'password': user_data['password'] } - + login_response = self.client.post( '/api/accounts/auth/login', data=json.dumps(login_data), content_type='application/json' ) - + self.assertEqual(login_response.status_code, 200) login_result = login_response.json() self.assertIn('access', login_result) - + # 3. Use token to access protected endpoint auth_headers = { 'HTTP_AUTHORIZATION': f'Bearer {login_result["access"]}' } - + profile_response = self.client.get( '/api/accounts/users/me', **auth_headers ) - + if profile_response.status_code == 200: profile_data = profile_response.json() self.assertEqual(profile_data['username'], user_data['username']) @@ -603,7 +612,7 @@ def test_user_login_invalid_credentials(self): 'username': 'nonexistent', 'password': 'wrongpassword' } - + response = self.client.post( f'{self.base_url}/login', data=json.dumps(login_data), @@ -616,7 +625,7 @@ def test_user_login_invalid_credentials(self): class APIPackageIntegrationTestCase(TestCase): """Test integration of all API packages.""" - + def test_api_endpoints_structure(self): """Test that all API endpoints are properly structured.""" # Test auth endpoints @@ -624,25 +633,25 @@ def test_api_endpoints_structure(self): '/api/accounts/auth/register', '/api/accounts/auth/login' ] - + # Test users endpoints users_endpoints = [ '/api/accounts/users/me', ] - + # Test oauth2 endpoints oauth2_endpoints = [ '/api/accounts/oauth2/providers', '/api/accounts/oauth2/authorize', '/api/accounts/oauth2/callback' ] - + # These should all be valid URL patterns (testing structure, not functionality) all_endpoints = auth_endpoints + users_endpoints + oauth2_endpoints - + # Just verify the test structure is valid self.assertTrue(len(all_endpoints) > 0) - + for endpoint in all_endpoints: self.assertTrue(endpoint.startswith('/api/accounts/')) self.assertIn('/', endpoint) diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/asgi.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/asgi.py index 587b221..2c7eba1 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/asgi.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/asgi.py @@ -12,6 +12,8 @@ from django.core.asgi import get_asgi_application # Default to local settings if DJANGO_SETTINGS_MODULE is not set -os.environ.setdefault('DJANGO_SETTINGS_MODULE', '{{ cookiecutter.project_slug }}.settings.local') +os.environ.setdefault( + "DJANGO_SETTINGS_MODULE", "{{ cookiecutter.project_slug }}.settings.local" +) -application = get_asgi_application() \ No newline at end of file +application = get_asgi_application() diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/celery.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/celery.py index e0cf9ec..e4213b2 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/celery.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/celery.py @@ -1,4 +1,5 @@ import os + from celery import Celery from django.conf import settings @@ -6,16 +7,18 @@ # This must happen before configuring the Celery app instance. # We check which settings file to use based on an environment variable. # Default to 'local' if not specified. -settings_module = os.environ.setdefault('DJANGO_SETTINGS_MODULE', '{{ cookiecutter.project_slug }}.settings.local') -os.environ.setdefault('DJANGO_SETTINGS_MODULE', settings_module) +settings_module = os.environ.setdefault( + "DJANGO_SETTINGS_MODULE", "{{ cookiecutter.project_slug }}.settings.local" +) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", settings_module) -app = Celery('{{ cookiecutter.project_slug }}') +app = Celery("{{ cookiecutter.project_slug }}") # Using a string here means the worker doesn't have to serialize # the configuration object to child processes. # - namespace='CELERY' means all celery-related configuration keys # should have a `CELERY_` prefix. -app.config_from_object('django.conf:settings', namespace='CELERY') +app.config_from_object("django.conf:settings", namespace="CELERY") # Load task modules from all registered Django app configs. app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) @@ -24,7 +27,8 @@ @app.task(bind=True, ignore_result=True) def debug_task(self): """A sample task for debugging purposes.""" - print(f'Request: {self.request!r}') + print(f"Request: {self.request!r}") + # Optional: Add periodic tasks using Celery Beat schedule # from celery.schedules import crontab @@ -41,4 +45,4 @@ def debug_task(self): # }, # } # Remember to configure the beat scheduler in settings (already done in base.py) -# CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler' \ No newline at end of file +# CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler' diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/settings/base.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/settings/base.py index c80e8bb..c570f92 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/settings/base.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/settings/base.py @@ -10,8 +10,8 @@ """ import os -from pathlib import Path from datetime import timedelta +from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. # BASE_DIR points to the directory containing manage.py @@ -30,71 +30,73 @@ # DEBUG will be overridden in local.py and production.py DEBUG = False -ALLOWED_HOSTS = [] # Should be configured in production.py +ALLOWED_HOSTS = [] # Should be configured in production.py # Application definition # Group apps for better organization DJANGO_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", ] THIRD_PARTY_APPS = [ - 'django_celery_beat', # Celery Periodic Tasks Scheduler - 'django_celery_results', # Celery Result Backend using Django ORM + "django_celery_beat", # Celery Periodic Tasks Scheduler + "django_celery_results", # Celery Result Backend using Django ORM # 'ninja', # Django Ninja doesn't need to be in INSTALLED_APPS - 'rest_framework_simplejwt', # JWT Authentication - 'guardian', # Object-level Permissions - 'django_redis', # Redis Cache Backend (needed if using it directly, not just via settings) - 'oauth2_provider', # OAuth2 Provider (django-oauth-toolkit) - 'social_django', # Social Authentication (social-auth-app-django) + "rest_framework_simplejwt", # JWT Authentication + "guardian", # Object-level Permissions + "django_redis", # Redis Cache Backend (needed if using it directly, not just via settings) + "oauth2_provider", # OAuth2 Provider (django-oauth-toolkit) + "social_django", # Social Authentication (social-auth-app-django) ] LOCAL_APPS = [ # Your project's apps go here. # Example: '{{ cookiecutter.project_slug }}.users', - '{{ cookiecutter.project_slug }}.accounts', # Authentication app + "{{ cookiecutter.project_slug }}.accounts", # Authentication app ] # Combine the lists INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS 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', + "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 = '{{ cookiecutter.project_slug }}.urls' +ROOT_URLCONF = "{{ cookiecutter.project_slug }}.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [os.path.join(PROJECT_DIR, 'templates')], # Optional: common templates dir - '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', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [ + os.path.join(PROJECT_DIR, "templates") + ], # Optional: common templates dir + "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", ], }, }, ] -WSGI_APPLICATION = '{{ cookiecutter.project_slug }}.wsgi.application' -ASGI_APPLICATION = '{{ cookiecutter.project_slug }}.asgi.application' +WSGI_APPLICATION = "{{ cookiecutter.project_slug }}.wsgi.application" +ASGI_APPLICATION = "{{ cookiecutter.project_slug }}.asgi.application" # Database @@ -106,8 +108,8 @@ # Authentication Backends # Required by django-guardian AUTHENTICATION_BACKENDS = ( - 'django.contrib.auth.backends.ModelBackend', # Default Django auth - 'guardian.backends.ObjectPermissionBackend', # Guardian object permission backend + "django.contrib.auth.backends.ModelBackend", # Default Django auth + "guardian.backends.ObjectPermissionBackend", # Guardian object permission backend ) # ANONYMOUS_USER_ID = -1 # Or None, depending on your anonymous user handling preference @@ -118,16 +120,16 @@ AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] @@ -135,9 +137,9 @@ # Internationalization # https://docs.djangoproject.com/en/5.0/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -147,85 +149,86 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/5.0/howto/static-files/ -STATIC_URL = '/static/' -STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') # For collectstatic +STATIC_URL = "/static/" +STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") # For collectstatic STATICFILES_DIRS = [ - os.path.join(PROJECT_DIR, 'static'), # Optional: common static files dir + os.path.join(PROJECT_DIR, "static"), # Optional: common static files dir ] # Media files -MEDIA_URL = '/media/' -MEDIA_ROOT = os.path.join(BASE_DIR, 'mediafiles') +MEDIA_URL = "/media/" +MEDIA_ROOT = os.path.join(BASE_DIR, "mediafiles") # Default primary key field type # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # Celery Configuration Options # https://docs.celeryq.dev/en/stable/userguide/configuration.html CELERY_BROKER_URL = "{{ cookiecutter.celery_broker_url }}" CELERY_RESULT_BACKEND = "{{ cookiecutter.celery_result_backend }}" -CELERY_ACCEPT_CONTENT = ['json'] -CELERY_TASK_SERIALIZER = 'json' -CELERY_RESULT_SERIALIZER = 'json' +CELERY_ACCEPT_CONTENT = ["json"] +CELERY_TASK_SERIALIZER = "json" +CELERY_RESULT_SERIALIZER = "json" CELERY_TIMEZONE = TIME_ZONE # Use Django-Celery-Results as the result backend CELERY_RESULT_EXTENDED = True # Make Celery Beat use the Django database scheduler -CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler' +CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler" # Django Ninja settings (usually not needed in settings.py, configuration is in urls.py) # Email settings (configure in local.py/production.py) -EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' # Default for development +EMAIL_BACKEND = ( + "django.core.mail.backends.console.EmailBackend" # Default for development +) # Cache configuration # https://docs.djangoproject.com/en/5.0/topics/cache/ # https://github.com/jazzband/django-redis # Default to dummy cache, will be overridden using CACHE_URL in local/prod CACHES = { - 'default': { - 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', + "default": { + "BACKEND": "django.core.cache.backends.dummy.DummyCache", } } # Simple JWT Settings # https://django-rest-framework-simplejwt.readthedocs.io/en/latest/settings.html SIMPLE_JWT = { - 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), # Example: 1 hour - 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), # Example: 1 day - 'ROTATE_REFRESH_TOKENS': False, - 'BLACKLIST_AFTER_ROTATION': False, - 'UPDATE_LAST_LOGIN': True, # Recommended - - 'ALGORITHM': 'HS256', - 'SIGNING_KEY': SECRET_KEY, # Uses Django SECRET_KEY by default - 'VERIFYING_KEY': None, - 'AUDIENCE': None, - 'ISSUER': None, - 'JWK_URL': None, - 'LEEWAY': 0, - - 'AUTH_HEADER_TYPES': ('Bearer',), # Standard Bearer token prefix - 'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION', - 'USER_ID_FIELD': 'id', - 'USER_ID_CLAIM': 'user_id', - 'USER_AUTHENTICATION_RULE': 'rest_framework_simplejwt.authentication.default_user_authentication_rule', - - 'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',), - 'TOKEN_TYPE_CLAIM': 'token_type', - 'TOKEN_USER_CLASS': 'rest_framework_simplejwt.models.TokenUser', - - 'JTI_CLAIM': 'jti', - - 'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp', - 'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5), # Not used unless SLIDING tokens enabled - 'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1), # Not used unless SLIDING tokens enabled + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60), # Example: 1 hour + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), # Example: 1 day + "ROTATE_REFRESH_TOKENS": False, + "BLACKLIST_AFTER_ROTATION": False, + "UPDATE_LAST_LOGIN": True, # Recommended + "ALGORITHM": "HS256", + "SIGNING_KEY": SECRET_KEY, # Uses Django SECRET_KEY by default + "VERIFYING_KEY": None, + "AUDIENCE": None, + "ISSUER": None, + "JWK_URL": None, + "LEEWAY": 0, + "AUTH_HEADER_TYPES": ("Bearer",), # Standard Bearer token prefix + "AUTH_HEADER_NAME": "HTTP_AUTHORIZATION", + "USER_ID_FIELD": "id", + "USER_ID_CLAIM": "user_id", + "USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule", + "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), + "TOKEN_TYPE_CLAIM": "token_type", + "TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser", + "JTI_CLAIM": "jti", + "SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp", + "SLIDING_TOKEN_LIFETIME": timedelta( + minutes=5 + ), # Not used unless SLIDING tokens enabled + "SLIDING_TOKEN_REFRESH_LIFETIME": timedelta( + days=1 + ), # Not used unless SLIDING tokens enabled } @@ -253,13 +256,13 @@ # Social Auth Pipeline (optional customization) SOCIAL_AUTH_PIPELINE = ( - 'social_core.pipeline.social_auth.social_details', - 'social_core.pipeline.social_auth.social_uid', - 'social_core.pipeline.social_auth.auth_allowed', - 'social_core.pipeline.social_auth.social_user', - 'social_core.pipeline.user.get_username', - 'social_core.pipeline.user.create_user', - 'social_core.pipeline.social_auth.associate_user', - 'social_core.pipeline.social_auth.load_extra_data', - 'social_core.pipeline.user.user_details', + "social_core.pipeline.social_auth.social_details", + "social_core.pipeline.social_auth.social_uid", + "social_core.pipeline.social_auth.auth_allowed", + "social_core.pipeline.social_auth.social_user", + "social_core.pipeline.user.get_username", + "social_core.pipeline.user.create_user", + "social_core.pipeline.social_auth.associate_user", + "social_core.pipeline.social_auth.load_extra_data", + "social_core.pipeline.user.user_details", ) diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/settings/local.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/settings/local.py index 3a8658b..ab01c82 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/settings/local.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/settings/local.py @@ -1,6 +1,8 @@ -from .base import * import os -from decouple import config, Csv # Using python-decouple for env vars + +from decouple import Csv, config # Using python-decouple for env vars + +from .base import * # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/settings/production.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/settings/production.py index 86f2335..1e049fa 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/settings/production.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/settings/production.py @@ -4,15 +4,17 @@ # ... (whitenoise settings) # Cache (use Redis, configure URL via environment variable) -CACHE_URL = config('CACHE_URL', default='redis://redis:6379/2') # Default assumes redis service name +CACHE_URL = config( + "CACHE_URL", default="redis://redis:6379/2" +) # Default assumes redis service name CACHES = { - 'default': { - 'BACKEND': 'django_redis.cache.RedisCache', - 'LOCATION': CACHE_URL, - 'OPTIONS': { - 'CLIENT_CLASS': 'django_redis.client.DefaultClient', + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": CACHE_URL, + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", # Add production-specific options like connection pooling, timeouts if needed - } + }, } } @@ -20,65 +22,81 @@ # EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = config('DJANGO_SECRET_KEY', default='{{ cookiecutter.django_secret_key }}') # Load from environment +SECRET_KEY = config( + "DJANGO_SECRET_KEY", default="{{ cookiecutter.django_secret_key }}" +) # Load from environment # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = config('DJANGO_DEBUG', default=False, cast=bool) +DEBUG = config("DJANGO_DEBUG", default=False, cast=bool) -ALLOWED_HOSTS = config('DJANGO_ALLOWED_HOSTS', cast=Csv(), default=[]) # e.g., ".yourdomain.com" +ALLOWED_HOSTS = config( + "DJANGO_ALLOWED_HOSTS", cast=Csv(), default=[] +) # e.g., ".yourdomain.com" # Middleware Configuration - Add WhiteNoise # Insert WhiteNoiseMiddleware right after SecurityMiddleware # Ensure this modification happens *after* MIDDLEWARE is imported from base.py # A common way is to copy, modify, and reassign MIDDLEWARE -_MIDDLEWARE = list(MIDDLEWARE) # Copy middleware list from base.py +_MIDDLEWARE = list(MIDDLEWARE) # Copy middleware list from base.py try: # Find the index of SecurityMiddleware and insert WhiteNoise after it - security_middleware_index = _MIDDLEWARE.index('django.middleware.security.SecurityMiddleware') - _MIDDLEWARE.insert(security_middleware_index + 1, 'whitenoise.middleware.WhiteNoiseMiddleware') + security_middleware_index = _MIDDLEWARE.index( + "django.middleware.security.SecurityMiddleware" + ) + _MIDDLEWARE.insert( + security_middleware_index + 1, "whitenoise.middleware.WhiteNoiseMiddleware" + ) except ValueError: # If SecurityMiddleware is not found (unlikely), insert at the beginning - _MIDDLEWARE.insert(0, 'whitenoise.middleware.WhiteNoiseMiddleware') -MIDDLEWARE = tuple(_MIDDLEWARE) # Assign the modified tuple back + _MIDDLEWARE.insert(0, "whitenoise.middleware.WhiteNoiseMiddleware") +MIDDLEWARE = tuple(_MIDDLEWARE) # Assign the modified tuple back # Database # https://docs.djangoproject.com/en/5.0/ref/settings/#databases # Use DATABASE_URL environment variable DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql', - 'NAME': config('POSTGRES_DB', default='{{ cookiecutter.postgresql_db }}'), - 'USER': config('POSTGRES_USER', default='{{ cookiecutter.postgresql_user }}'), - 'PASSWORD': config('POSTGRES_PASSWORD', default='{{ cookiecutter.postgresql_password }}'), - 'HOST': config('POSTGRES_HOST', default='db'), # Or your DB host - 'PORT': config('POSTGRES_PORT', default='{{ cookiecutter.postgresql_port }}'), + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": config("POSTGRES_DB", default="{{ cookiecutter.postgresql_db }}"), + "USER": config("POSTGRES_USER", default="{{ cookiecutter.postgresql_user }}"), + "PASSWORD": config( + "POSTGRES_PASSWORD", default="{{ cookiecutter.postgresql_password }}" + ), + "HOST": config("POSTGRES_HOST", default="db"), # Or your DB host + "PORT": config("POSTGRES_PORT", default="{{ cookiecutter.postgresql_port }}"), } } # DATABASES['default'] = config('DATABASE_URL', cast=db_url) # Recommended way using dj-database-url # Celery -CELERY_BROKER_URL = config('CELERY_BROKER_URL', default='{{ cookiecutter.celery_broker_url }}') -CELERY_RESULT_BACKEND = config('CELERY_RESULT_BACKEND', default='{{ cookiecutter.celery_result_backend }}') +CELERY_BROKER_URL = config( + "CELERY_BROKER_URL", default="{{ cookiecutter.celery_broker_url }}" +) +CELERY_RESULT_BACKEND = config( + "CELERY_RESULT_BACKEND", default="{{ cookiecutter.celery_result_backend }}" +) # Static files storage using whitenoise (recommended for simplicity) # https://whitenoise.readthedocs.io/en/stable/django.html#using-whitenoise-with-django -STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' +STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" # STATIC_ROOT is already defined in base.py and should point to where collectstatic gathers files # Cache (use Redis, configure URL via environment variable) -CACHE_URL = config('CACHE_URL', default='redis://redis:6379/2') # Default assumes redis service name +CACHE_URL = config( + "CACHE_URL", default="redis://redis:6379/2" +) # Default assumes redis service name CACHES = { - 'default': { - 'BACKEND': 'django_redis.cache.RedisCache', - 'LOCATION': CACHE_URL, - 'OPTIONS': { - 'CLIENT_CLASS': 'django_redis.client.DefaultClient', + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": CACHE_URL, + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", # Add production-specific options like connection pooling, timeouts if needed - } + }, } } diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/urls.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/urls.py index a338ced..4197482 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/urls.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/urls.py @@ -14,19 +14,19 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ -from django.contrib import admin -from django.urls import path, include from django.conf import settings from django.conf.urls.static import static +from django.contrib import admin +from django.urls import include, path + +# Import the accounts API +from ninja import NinjaAPI from rest_framework_simplejwt.views import ( TokenObtainPairView, TokenRefreshView, TokenVerifyView, ) - -# Import the accounts API -from ninja import NinjaAPI -from {{ cookiecutter.project_slug }}.accounts.api import router as accounts_router +from {{cookiecutter.project_slug}}.accounts.api import router as accounts_router # Create the main API instance api = NinjaAPI( @@ -56,4 +56,4 @@ import debug_toolbar urlpatterns = [path('__debug__/', include(debug_toolbar.urls))] + urlpatterns except ImportError: - pass \ No newline at end of file + pass diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/wsgi.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/wsgi.py index a54c0de..931421c 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/wsgi.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/wsgi.py @@ -12,6 +12,8 @@ from django.core.wsgi import get_wsgi_application # Default to local settings if DJANGO_SETTINGS_MODULE is not set -os.environ.setdefault('DJANGO_SETTINGS_MODULE', '{{ cookiecutter.project_slug }}.settings.local') +os.environ.setdefault( + "DJANGO_SETTINGS_MODULE", "{{ cookiecutter.project_slug }}.settings.local" +) -application = get_wsgi_application() \ No newline at end of file +application = get_wsgi_application()