From a94318f7721cab3e78474b958569e2a7c1070b0d Mon Sep 17 00:00:00 2001 From: chris Date: Thu, 12 Mar 2026 23:37:42 +0000 Subject: [PATCH] Initial template: Django REST API starter template --- .dockerignore | 46 ++ .env.example | 46 ++ .gitignore | 217 ++++++ Dockerfile | 46 ++ accounts/__init__.py | 0 accounts/admin.py | 3 + accounts/api/__init__.py | 1 + accounts/api/serializers.py | 106 +++ accounts/api/urls.py | 11 + accounts/api/views.py | 92 +++ accounts/apps.py | 6 + accounts/migrations/0001_initial.py | 35 + ...er_user_options_user_full_name_and_more.py | 27 + .../0003_user_first_name_user_last_name.py | 23 + accounts/migrations/__init__.py | 0 accounts/models.py | 42 ++ accounts/serializers.py | 16 + accounts/tests.py | 518 +++++++++++++++ accounts/urls.py | 8 + accounts/views.py | 6 + cloudbuild.yaml | 55 ++ cloudmigrate.yaml | 49 ++ devops/gcp/setup-project.sh | 224 +++++++ fabfile.py | 615 ++++++++++++++++++ manage.py | 22 + readme.md | 26 + requirements.txt | 32 + {{ cookiecutter.project_slug }}/__init__.py | 0 {{ cookiecutter.project_slug }}/asgi.py | 16 + .../settings/__init__.py | 0 .../settings/base.py | 279 ++++++++ .../settings/cloud_production.py | 162 +++++ .../settings/cloud_staging.py | 149 +++++ .../settings/dev.py | 1 + .../settings/production.py | 1 + {{ cookiecutter.project_slug }}/tests.py | 112 ++++ {{ cookiecutter.project_slug }}/urls.py | 37 ++ {{ cookiecutter.project_slug }}/wsgi.py | 16 + 38 files changed, 3045 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 accounts/__init__.py create mode 100644 accounts/admin.py create mode 100644 accounts/api/__init__.py create mode 100644 accounts/api/serializers.py create mode 100644 accounts/api/urls.py create mode 100644 accounts/api/views.py create mode 100644 accounts/apps.py create mode 100644 accounts/migrations/0001_initial.py create mode 100644 accounts/migrations/0002_alter_user_options_user_full_name_and_more.py create mode 100644 accounts/migrations/0003_user_first_name_user_last_name.py create mode 100644 accounts/migrations/__init__.py create mode 100644 accounts/models.py create mode 100644 accounts/serializers.py create mode 100644 accounts/tests.py create mode 100644 accounts/urls.py create mode 100644 accounts/views.py create mode 100644 cloudbuild.yaml create mode 100644 cloudmigrate.yaml create mode 100644 devops/gcp/setup-project.sh create mode 100644 fabfile.py create mode 100644 manage.py create mode 100644 readme.md create mode 100644 requirements.txt create mode 100644 {{ cookiecutter.project_slug }}/__init__.py create mode 100644 {{ cookiecutter.project_slug }}/asgi.py create mode 100644 {{ cookiecutter.project_slug }}/settings/__init__.py create mode 100644 {{ cookiecutter.project_slug }}/settings/base.py create mode 100644 {{ cookiecutter.project_slug }}/settings/cloud_production.py create mode 100644 {{ cookiecutter.project_slug }}/settings/cloud_staging.py create mode 100644 {{ cookiecutter.project_slug }}/settings/dev.py create mode 100644 {{ cookiecutter.project_slug }}/settings/production.py create mode 100644 {{ cookiecutter.project_slug }}/tests.py create mode 100644 {{ cookiecutter.project_slug }}/urls.py create mode 100644 {{ cookiecutter.project_slug }}/wsgi.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..44fa155 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,46 @@ +# Git +.git +.gitignore + +# Python +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +.venv +venv/ +ENV/ +env/ +.eggs/ +*.egg-info/ +*.egg + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Testing +.coverage +htmlcov/ +.pytest_cache/ +.tox/ + +# Local development +.env +.env.* +*.sqlite3 +db.sqlite3 +media/ +staticfiles/ + +# Documentation +*.md +docs/ + +# Misc +*.log +.DS_Store +Thumbs.db diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..90d3003 --- /dev/null +++ b/.env.example @@ -0,0 +1,46 @@ +# ============================================================================= +# Django Settings +# ============================================================================= +SECRET_KEY=your-secret-key-here +DEBUG=True +ALLOWED_HOSTS=localhost,127.0.0.1 + +# ============================================================================= +# Database - PostgreSQL (default) +# ============================================================================= +# Option 1: Use DATABASE_URL (recommended) +# DATABASE_URL=postgresql://user:password@localhost:5432/{{ cookiecutter.project_slug }} + +# Option 2: Use individual settings +DB_NAME={{ cookiecutter.project_slug }} +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_USER=postgres +DB_PASSWORD=postgres + +# Option 3: Use SQLite for quick testing +# USE_SQLITE=true + +# ============================================================================= +# S3 Storage (Backblaze B2 or AWS S3) - Optional +# ============================================================================= +# USE_S3_STORAGE=true +# AWS_ACCESS_KEY_ID=your-access-key-id +# AWS_SECRET_ACCESS_KEY=your-secret-access-key +# AWS_STORAGE_BUCKET_NAME=your-bucket-name +# AWS_S3_ENDPOINT_URL=https://s3.region.backblazeb2.com +# AWS_S3_REGION_NAME=your-region + +# ============================================================================= +# GCP Deployment Configuration (for fabfile.py) +# ============================================================================= +GCP_PROJECT_ID={{ cookiecutter.project_slug }} +GCP_REGION={{ cookiecutter.gcp_region }} +CLOUD_SQL_INSTANCE={{ cookiecutter.cloud_sql_instance }} +CLOUD_SQL_PROJECT={{ cookiecutter.cloud_sql_project }} +SERVICE_NAME={{ cookiecutter.project_slug }} + +# ============================================================================= +# Email (for development) +# ============================================================================= +EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa31da5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,217 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e892e55 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +# {{ cookiecutter.project_name }} - Dockerfile for Google Cloud Run +# +# Build: +# docker build -t {{ cookiecutter.project_slug }} . +# +# Run locally: +# docker run -p 8080:8080 -e DJANGO_SETTINGS_MODULE={{ cookiecutter.project_slug }}.settings.dev {{ cookiecutter.project_slug }} + +FROM python:3.11-slim + +# Install uv for faster dependency installation +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV PORT=8080 + +# Set work directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies (uv is much faster than pip) +COPY requirements.txt . +RUN uv pip install --system --no-cache -r requirements.txt + +# Copy project files +COPY . . + +# Collect static files (for WhiteNoise) +RUN python manage.py collectstatic --noinput --settings={{ cookiecutter.project_slug }}.settings.base || true + +# Create non-root user for security +RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app +USER appuser + +# Expose port +EXPOSE 8080 + +# Run gunicorn +CMD exec gunicorn --bind :$PORT --workers 2 --threads 4 --timeout 60 {{ cookiecutter.project_slug }}.wsgi:application diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts/api/__init__.py b/accounts/api/__init__.py new file mode 100644 index 0000000..384fa54 --- /dev/null +++ b/accounts/api/__init__.py @@ -0,0 +1 @@ +# API package for accounts app diff --git a/accounts/api/serializers.py b/accounts/api/serializers.py new file mode 100644 index 0000000..44d232b --- /dev/null +++ b/accounts/api/serializers.py @@ -0,0 +1,106 @@ +from rest_framework import serializers +from django.contrib.auth import get_user_model +from dj_rest_auth.registration.serializers import RegisterSerializer + +User = get_user_model() + + +class EmailRegisterSerializer(RegisterSerializer): + """ + Registration serializer that works with email-only auth. + Accepts optional full_name and exposes first_name/last_name in cleaned_data + to satisfy tests, while not requiring username. + """ + username = None + full_name = serializers.CharField(required=False, allow_blank=True) + + def get_cleaned_data(self): + data = super().get_cleaned_data() + full_name = self.validated_data.get('full_name', '').strip() + first_name = '' + last_name = '' + if full_name: + parts = full_name.split() + if len(parts) == 1: + first_name = parts[0] + else: + first_name = parts[0] + last_name = ' '.join(parts[1:]) + data.update({ + 'first_name': first_name, + 'last_name': last_name, + }) + return data + + +# Backward-compatible export expected by tests +class CustomRegisterSerializer(EmailRegisterSerializer): + pass + + +class UserDetailsSerializer(serializers.ModelSerializer): + """ + User details for /auth/user/ endpoint (include first/last names for tests) + """ + class Meta: + model = User + fields = ( + 'pk', + 'email', + 'first_name', + 'last_name', + 'date_joined', + 'last_login', + 'is_active', + ) + read_only_fields = ('pk', 'date_joined', 'last_login', 'is_active') + + def validate_email(self, value): + user = self.context['request'].user + if User.objects.exclude(pk=user.pk).filter(email=value).exists(): + raise serializers.ValidationError("A user with this email already exists.") + return value + + +class UserProfileSerializer(serializers.ModelSerializer): + """ + Read-only profile returned by our custom endpoints + """ + class Meta: + model = User + fields = ( + 'pk', + 'email', + 'full_name', + 'preferred_name', + 'date_joined', + 'last_login', + 'is_active', + ) + read_only_fields = fields + + +class ChangePasswordSerializer(serializers.Serializer): + """ + Serializer for changing user password. + """ + old_password = serializers.CharField(required=True, style={'input_type': 'password'}) + new_password1 = serializers.CharField(required=True, style={'input_type': 'password'}) + new_password2 = serializers.CharField(required=True, style={'input_type': 'password'}) + + def validate_old_password(self, value): + user = self.context['request'].user + if not user.check_password(value): + raise serializers.ValidationError("Old password is incorrect.") + return value + + def validate(self, attrs): + if attrs['new_password1'] != attrs['new_password2']: + raise serializers.ValidationError("The two password fields didn't match.") + return attrs + + def save(self): + user = self.context['request'].user + user.set_password(self.validated_data['new_password1']) + user.save() + return user diff --git a/accounts/api/urls.py b/accounts/api/urls.py new file mode 100644 index 0000000..5521ea4 --- /dev/null +++ b/accounts/api/urls.py @@ -0,0 +1,11 @@ +from django.urls import path +from . import views + +app_name = 'accounts' + +urlpatterns = [ + path('auth/registration/', views.simple_register, name='rest_register'), + path('profile/', views.UserProfileView.as_view(), name='user-profile'), + path('change-password/', views.ChangePasswordView.as_view(), name='change-password'), + path('stats/', views.user_stats, name='user-stats'), +] diff --git a/accounts/api/views.py b/accounts/api/views.py new file mode 100644 index 0000000..048561c --- /dev/null +++ b/accounts/api/views.py @@ -0,0 +1,92 @@ +from rest_framework import status, permissions +from rest_framework.decorators import api_view, permission_classes +from rest_framework.response import Response +from rest_framework.views import APIView +from django.contrib.auth import get_user_model +from rest_framework.authtoken.models import Token + +from .serializers import UserProfileSerializer, ChangePasswordSerializer + +User = get_user_model() + + +class UserProfileView(APIView): + """ + Retrieve user profile information. + """ + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + """ + Return the current user's profile. + """ + serializer = UserProfileSerializer(request.user) + return Response(serializer.data) + + +class ChangePasswordView(APIView): + """ + Change user password. + """ + permission_classes = [permissions.IsAuthenticated] + + def post(self, request): + """ + Change the user's password. + """ + serializer = ChangePasswordSerializer( + data=request.data, + context={'request': request} + ) + if serializer.is_valid(): + serializer.save() + return Response( + {'message': 'Password changed successfully.'}, + status=status.HTTP_200_OK + ) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) +@api_view(['POST']) +@permission_classes([permissions.AllowAny]) +def simple_register(request): + email = request.data.get('email') + password1 = request.data.get('password1') + password2 = request.data.get('password2') + + errors = {} + if not email: + errors['email'] = ['This field is required.'] + if not password1: + errors['password1'] = ['This field is required.'] + if not password2: + errors['password2'] = ['This field is required.'] + if errors: + return Response(errors, status=status.HTTP_400_BAD_REQUEST) + + if password1 != password2: + return Response({'password2': ['The two password fields didn\'t match.']}, status=status.HTTP_400_BAD_REQUEST) + + if User.objects.filter(email=email).exists(): + return Response({'email': ['A user with this email already exists.']}, status=status.HTTP_400_BAD_REQUEST) + + user = User.objects.create_user(email=email, password=password1) + token, _ = Token.objects.get_or_create(user=user) + return Response({'key': token.key}, status=status.HTTP_201_CREATED) + + +@api_view(['GET']) +@permission_classes([permissions.IsAuthenticated]) +def user_stats(request): + """ + Return basic user statistics. + """ + user = request.user + stats = { + 'user_id': user.pk, + + 'email': user.email, + 'date_joined': user.date_joined, + 'last_login': user.last_login, + 'is_staff': user.is_staff, + 'is_active': user.is_active, + } + return Response(stats) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..3e3c765 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts' diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..085a95e --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.5 on 2025-09-02 14:37 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('email', models.EmailField(max_length=255, unique=True, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'ordering': ['email'], + 'abstract': False, + }, + ), + ] diff --git a/accounts/migrations/0002_alter_user_options_user_full_name_and_more.py b/accounts/migrations/0002_alter_user_options_user_full_name_and_more.py new file mode 100644 index 0000000..d375047 --- /dev/null +++ b/accounts/migrations/0002_alter_user_options_user_full_name_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.5 on 2025-09-02 14:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='user', + options={'verbose_name': 'User', 'verbose_name_plural': 'Users'}, + ), + migrations.AddField( + model_name='user', + name='full_name', + field=models.CharField(blank=True, max_length=255, verbose_name='full name'), + ), + migrations.AddField( + model_name='user', + name='preferred_name', + field=models.CharField(blank=True, max_length=255, verbose_name='preferred name'), + ), + ] diff --git a/accounts/migrations/0003_user_first_name_user_last_name.py b/accounts/migrations/0003_user_first_name_user_last_name.py new file mode 100644 index 0000000..db8f4e1 --- /dev/null +++ b/accounts/migrations/0003_user_first_name_user_last_name.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.5 on 2025-09-02 15:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_user_options_user_full_name_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='first_name', + field=models.CharField(blank=True, max_length=150, verbose_name='first name'), + ), + migrations.AddField( + model_name='user', + name='last_name', + field=models.CharField(blank=True, max_length=150, verbose_name='last name'), + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/models.py b/accounts/models.py new file mode 100644 index 0000000..04b1f1e --- /dev/null +++ b/accounts/models.py @@ -0,0 +1,42 @@ +from authtools.models import AbstractEmailUser +from django.db import models + + +# Create your models here. + + +class User(AbstractEmailUser): + """ + Custom User model using email as the identifier. + Includes optional name fields used by tests and serializers. + """ + first_name = models.CharField('first name', max_length=150, blank=True) + last_name = models.CharField('last name', max_length=150, blank=True) + full_name = models.CharField('full name', max_length=255, blank=True) + preferred_name = models.CharField('preferred name', max_length=255, blank=True) + + def get_full_name(self): + """ + Return the user's full name. + """ + return self.full_name.strip() if self.full_name else '' + + def get_short_name(self): + """ + Return the user's preferred name or first part of full name. + """ + if self.preferred_name: + return self.preferred_name.strip() + elif self.full_name: + return self.full_name.split()[0] if self.full_name.split() else '' + return '' + + def __str__(self): + """ + String representation of the user. + """ + return self.email + + class Meta: + verbose_name = 'User' + verbose_name_plural = 'Users' diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..8369ed8 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,16 @@ +# Serializers have been moved to accounts/api/serializers.py +# This file is kept for backward compatibility + +from .api.serializers import ( + CustomRegisterSerializer, + UserDetailsSerializer, + UserProfileSerializer, + ChangePasswordSerializer, +) + +__all__ = [ + 'CustomRegisterSerializer', + 'UserDetailsSerializer', + 'UserProfileSerializer', + 'ChangePasswordSerializer', +] diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..8e170c0 --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,518 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from django.urls import reverse +from rest_framework.test import APITestCase, APIClient +from rest_framework import status +from rest_framework.authtoken.models import Token +import json + +User = get_user_model() + + +class UserRegistrationTestCase(APITestCase): + """ + Test cases for user registration functionality. + """ + + def setUp(self): + self.client = APIClient() + self.registration_url = '/api/accounts/auth/registration/' # Back to dj-rest-auth + self.valid_user_data = { + 'email': 'testuser@example.com', + 'password1': 'testpassword123', + 'password2': 'testpassword123', + } + + def test_user_registration_success(self): + """ + Test successful user registration. + """ + response = self.client.post( + self.registration_url, + self.valid_user_data, + format='json' + ) + + # Debug: print response if test fails + if response.status_code != status.HTTP_201_CREATED: + print(f"Registration failed with status {response.status_code}") + print(f"Response data: {response.data}") + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertIn('key', response.data) # Token should be returned + + # Verify user was created + user = User.objects.get(email=self.valid_user_data['email']) + self.assertTrue(user.is_active) # User is active since email verification is optional + + def test_user_registration_password_mismatch(self): + """ + Test registration with mismatched passwords. + """ + invalid_data = self.valid_user_data.copy() + invalid_data['password2'] = 'differentpassword' + + response = self.client.post( + self.registration_url, + invalid_data, + format='json' + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + # dj-rest-auth may return field errors; accept either + self.assertTrue('non_field_errors' in response.data or 'password2' in response.data) + + def test_user_registration_duplicate_email(self): + """ + Test registration with duplicate email. + """ + # Create a user first with the same email + User.objects.create_user( + email='testuser@example.com', + password='password123' + ) + + response = self.client.post( + self.registration_url, + self.valid_user_data, + format='json' + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + # Check for either email or username error since they're the same + self.assertTrue('email' in response.data or 'username' in response.data) + + def test_user_registration_missing_fields(self): + """ + Test registration with missing required fields. + """ + incomplete_data = { + 'email': 'testuser@example.com', + 'password1': 'testpassword123' + # Missing password2, first_name, last_name + } + + response = self.client.post( + self.registration_url, + incomplete_data, + format='json' + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('password2', response.data) + + +class UserLoginTestCase(APITestCase): + """ + Test cases for user login functionality. + """ + + def setUp(self): + self.client = APIClient() + self.login_url = '/api/accounts/auth/login/' + self.logout_url = '/api/accounts/auth/logout/' + + # Create an active user for testing + self.user = User.objects.create_user( + email='testuser@example.com', + password='testpassword123', + ) + self.user.full_name = 'Test User' + self.user.preferred_name = 'Test' + self.user.save() + self.user_credentials = { + 'email': 'testuser@example.com', + 'password': 'testpassword123' + } + + def test_user_login_success(self): + """ + Test successful user login. + """ + response = self.client.post( + self.login_url, + self.user_credentials, + format='json' + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('key', response.data) # Token should be returned + # Note: 'user' field might not be returned by default in dj-rest-auth + + # Verify token was created + token = Token.objects.get(user=self.user) + self.assertEqual(response.data['key'], token.key) + + def test_user_login_invalid_credentials(self): + """ + Test login with invalid credentials. + """ + invalid_credentials = { + 'email': 'testuser@example.com', + 'password': 'wrongpassword' + } + + response = self.client.post( + self.login_url, + invalid_credentials, + format='json' + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('non_field_errors', response.data) + + def test_user_login_inactive_user(self): + """ + Test login with inactive user account. + """ + # Create inactive user + inactive_user = User.objects.create_user( + email='inactive@example.com', + password='testpassword123', + ) + inactive_user.full_name = 'Inactive User' + inactive_user.is_active = False + inactive_user.save() + + credentials = { + 'email': 'inactive@example.com', + 'password': 'testpassword123' + } + + response = self.client.post( + self.login_url, + credentials, + format='json' + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_user_logout_success(self): + """ + Test successful user logout. + """ + # First login to get a token + login_response = self.client.post( + self.login_url, + self.user_credentials, + format='json' + ) + self.assertEqual(login_response.status_code, status.HTTP_200_OK) + + # Get token from response (might be 'key' or 'access_token') + token = login_response.data.get('key') or login_response.data.get('access_token') + self.assertIsNotNone(token, f"No token found in response: {login_response.data}") + + # Set authentication header + self.client.credentials(HTTP_AUTHORIZATION=f'Token {token}') + + # Logout + response = self.client.post(self.logout_url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Verify token was deleted + self.assertFalse(Token.objects.filter(key=token).exists()) + + def test_user_logout_without_authentication(self): + """ + Test logout without authentication. + """ + response = self.client.post(self.logout_url) + # dj-rest-auth might return 400 instead of 401 for unauthenticated logout + self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED]) + + +class TokenAuthenticationTestCase(APITestCase): + """ + Test cases for token-based authentication. + """ + + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user( + email='testuser@example.com', + password='testpassword123', + ) + self.user.full_name = 'Test User' + self.user.save() + self.token = Token.objects.create(user=self.user) + self.user_detail_url = '/api/accounts/auth/user/' + + def test_authenticated_request_with_valid_token(self): + """ + Test authenticated request with valid token. + """ + self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') + + response = self.client.get(self.user_detail_url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['email'], self.user.email) + self.assertEqual(response.data['first_name'], self.user.first_name) + + def test_authenticated_request_with_invalid_token(self): + """ + Test authenticated request with invalid token. + """ + self.client.credentials(HTTP_AUTHORIZATION='Token invalidtoken123') + + response = self.client.get(self.user_detail_url) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_authenticated_request_without_token(self): + """ + Test authenticated request without token. + """ + response = self.client.get(self.user_detail_url) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_update_user_details_with_authentication(self): + """ + Test updating user details with valid authentication. + """ + self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') + + update_data = { + 'first_name': 'Updated', + 'last_name': 'Name' + } + + response = self.client.patch( + self.user_detail_url, + update_data, + format='json' + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['first_name'], 'Updated') + self.assertEqual(response.data['last_name'], 'Name') + + # Verify database was updated + self.user.refresh_from_db() + self.assertEqual(self.user.first_name, 'Updated') + self.assertEqual(self.user.last_name, 'Name') + + +class UserProfileViewTestCase(APITestCase): + """ + Test cases for custom user profile views. + """ + + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user( + email='testuser@example.com', + password='testpassword123', + ) + self.user.full_name = 'Test User' + self.user.save() + self.token = Token.objects.create(user=self.user) + self.profile_url = reverse('accounts:user-profile') + self.stats_url = reverse('accounts:user-stats') + self.change_password_url = reverse('accounts:change-password') + + def test_get_user_profile_authenticated(self): + """ + Test retrieving user profile with authentication. + """ + self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') + + response = self.client.get(self.profile_url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['email'], self.user.email) + self.assertEqual(response.data['full_name'], 'Test User') + self.assertIn('date_joined', response.data) + + def test_get_user_profile_unauthenticated(self): + """ + Test retrieving user profile without authentication. + """ + response = self.client.get(self.profile_url) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_get_user_stats_authenticated(self): + """ + Test retrieving user stats with authentication. + """ + self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') + + response = self.client.get(self.stats_url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['user_id'], self.user.pk) + self.assertEqual(response.data['email'], self.user.email) + self.assertEqual(response.data['is_active'], True) + + def test_change_password_success(self): + """ + Test successful password change. + """ + self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') + + password_data = { + 'old_password': 'testpassword123', + 'new_password1': 'newpassword456', + 'new_password2': 'newpassword456' + } + + response = self.client.post( + self.change_password_url, + password_data, + format='json' + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('message', response.data) + + # Verify password was changed + self.user.refresh_from_db() + self.assertTrue(self.user.check_password('newpassword456')) + + def test_change_password_wrong_old_password(self): + """ + Test password change with wrong old password. + """ + self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') + + password_data = { + 'old_password': 'wrongpassword', + 'new_password1': 'newpassword456', + 'new_password2': 'newpassword456' + } + + response = self.client.post( + self.change_password_url, + password_data, + format='json' + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('old_password', response.data) + + def test_change_password_mismatch(self): + """ + Test password change with mismatched new passwords. + """ + self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') + + password_data = { + 'old_password': 'testpassword123', + 'new_password1': 'newpassword456', + 'new_password2': 'differentpassword' + } + + response = self.client.post( + self.change_password_url, + password_data, + format='json' + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('non_field_errors', response.data) + + +class SerializerTestCase(APITestCase): + """ + Test cases for custom serializers. + """ + + def setUp(self): + self.user = User.objects.create_user( + email='testuser@example.com', + password='testpassword123', + ) + self.user.full_name = 'Test User' + self.user.save() + + def test_custom_register_serializer_validation(self): + """ + Test custom registration serializer validation. + """ + from .api.serializers import CustomRegisterSerializer + + valid_data = { + 'email': 'newuser@example.com', + 'password1': 'newpassword123', + 'password2': 'newpassword123', + 'full_name': 'New User', + 'preferred_name': 'New' + } + + serializer = CustomRegisterSerializer(data=valid_data) + self.assertTrue(serializer.is_valid()) + + cleaned_data = serializer.get_cleaned_data() + self.assertEqual(cleaned_data['first_name'], 'New') + self.assertEqual(cleaned_data['last_name'], 'User') + + def test_user_details_serializer_email_validation(self): + """ + Test user details serializer email uniqueness validation. + """ + from .api.serializers import UserDetailsSerializer + from django.test import RequestFactory + from rest_framework.request import Request + + # Create another user with different email + other_user = User.objects.create_user( + email='other@example.com', + password='password123' + ) + + # Create a mock request + factory = RequestFactory() + request = factory.get('/') + request.user = other_user + drf_request = Request(request) + + # Try to update with existing email + serializer = UserDetailsSerializer( + instance=other_user, + data={'email': self.user.email}, + context={'request': drf_request}, + partial=True + ) + + self.assertFalse(serializer.is_valid()) + self.assertIn('email', serializer.errors) + + def test_change_password_serializer_validation(self): + """ + Test change password serializer validation using the actual API endpoint. + """ + from rest_framework.authtoken.models import Token + + # Create a token for the user + token = Token.objects.create(user=self.user) + + # Test with correct old password via API + self.client.credentials(HTTP_AUTHORIZATION=f'Token {token.key}') + + valid_data = { + 'old_password': 'testpassword123', + 'new_password1': 'newpassword456', + 'new_password2': 'newpassword456' + } + + response = self.client.post('/api/accounts/change-password/', valid_data, format='json') + self.assertEqual(response.status_code, 200) + + # Verify password was changed + self.user.refresh_from_db() + self.assertTrue(self.user.check_password('newpassword456')) + + # Test with incorrect old password + invalid_data = { + 'old_password': 'wrongpassword', + 'new_password1': 'anotherpassword789', + 'new_password2': 'anotherpassword789' + } + + response = self.client.post('/api/accounts/change-password/', invalid_data, format='json') + self.assertEqual(response.status_code, 400) + self.assertIn('old_password', response.data) diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..6a09025 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,8 @@ +# URLs have been moved to accounts/api/urls.py +# This file is kept for backward compatibility + +from django.urls import path, include + +urlpatterns = [ + path('', include('accounts.api.urls')), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..cd5f94e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,6 @@ +# Views have been moved to accounts/api/views.py +# This file is kept for backward compatibility + +from .api.views import UserProfileView, ChangePasswordView, user_stats + +__all__ = ['UserProfileView', 'ChangePasswordView', 'user_stats'] diff --git a/cloudbuild.yaml b/cloudbuild.yaml new file mode 100644 index 0000000..efd2140 --- /dev/null +++ b/cloudbuild.yaml @@ -0,0 +1,55 @@ +# Cloud Build configuration for building and deploying to Cloud Run +# +# Usage: +# gcloud builds submit --config cloudbuild.yaml --project=PROJECT_ID +# +# Or use the fabfile: +# fab deploy + +substitutions: + _REGION: {{ cookiecutter.gcp_region }} + _SERVICE_NAME: {{ cookiecutter.project_slug }} + _DJANGO_SETTINGS_MODULE: {{ cookiecutter.project_slug }}.settings.cloud_production + _CLOUD_SQL_INSTANCE: {{ cookiecutter.cloud_sql_project }}:{{ cookiecutter.gcp_region }}:{{ cookiecutter.cloud_sql_instance }} + _MIN_INSTANCES: "0" + _MAX_INSTANCES: "1" + +steps: + # Build the container image + - name: 'gcr.io/cloud-builders/docker' + args: + - 'build' + - '-t' + - 'gcr.io/$PROJECT_ID/${_SERVICE_NAME}' + - '--cache-from' + - 'gcr.io/$PROJECT_ID/${_SERVICE_NAME}' + - '.' + + # Push the image to Container Registry + - name: 'gcr.io/cloud-builders/docker' + args: + - 'push' + - 'gcr.io/$PROJECT_ID/${_SERVICE_NAME}' + + # Deploy to Cloud Run + - name: 'gcr.io/cloud-builders/gcloud' + args: + - 'run' + - 'deploy' + - '${_SERVICE_NAME}' + - '--image' + - 'gcr.io/$PROJECT_ID/${_SERVICE_NAME}' + - '--region' + - '${_REGION}' + - '--platform' + - 'managed' + - '--add-cloudsql-instances' + - '${_CLOUD_SQL_INSTANCE}' + - '--set-env-vars' + - 'DJANGO_SETTINGS_MODULE=${_DJANGO_SETTINGS_MODULE}' + - '--allow-unauthenticated' + +images: + - 'gcr.io/$PROJECT_ID/${_SERVICE_NAME}' + +timeout: '1200s' diff --git a/cloudmigrate.yaml b/cloudmigrate.yaml new file mode 100644 index 0000000..28de460 --- /dev/null +++ b/cloudmigrate.yaml @@ -0,0 +1,49 @@ +# Cloud Build configuration for running Django migrations +# +# Usage: +# gcloud builds submit --config cloudmigrate.yaml --project=PROJECT_ID +# +# Or use the fabfile: +# fab migrate + +substitutions: + _REGION: {{ cookiecutter.gcp_region }} + _DJANGO_SETTINGS_MODULE: {{ cookiecutter.project_slug }}.settings.cloud_production + _CLOUD_SQL_INSTANCE: {{ cookiecutter.cloud_sql_project }}:{{ cookiecutter.gcp_region }}:{{ cookiecutter.cloud_sql_instance }} + +steps: + # Build the container image (or use existing) + - name: 'gcr.io/cloud-builders/docker' + args: + - 'build' + - '-t' + - 'gcr.io/$PROJECT_ID/migrate' + - '.' + + # Run migrations + - name: 'gcr.io/cloud-builders/docker' + args: + - 'run' + - '--rm' + - '-e' + - 'DJANGO_SETTINGS_MODULE=${_DJANGO_SETTINGS_MODULE}' + - 'gcr.io/$PROJECT_ID/migrate' + - 'python' + - 'manage.py' + - 'migrate' + - '--noinput' + + # Collect static files + - name: 'gcr.io/cloud-builders/docker' + args: + - 'run' + - '--rm' + - '-e' + - 'DJANGO_SETTINGS_MODULE=${_DJANGO_SETTINGS_MODULE}' + - 'gcr.io/$PROJECT_ID/migrate' + - 'python' + - 'manage.py' + - 'collectstatic' + - '--noinput' + +timeout: '1800s' diff --git a/devops/gcp/setup-project.sh b/devops/gcp/setup-project.sh new file mode 100644 index 0000000..f29257b --- /dev/null +++ b/devops/gcp/setup-project.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +# +# GCP Project Setup Script for Django on Cloud Run +# +# This script creates all the GCP resources needed to deploy a Django app: +# - GCP Project (or uses existing) +# - Cloud Run, Cloud SQL, Secret Manager, Cloud Build APIs +# - Cloud Storage bucket for media files +# - Cloud SQL database (on shared instance) +# - Secret Manager secrets for Django settings +# - IAM permissions for Cloud Run and Cloud Build +# +# Usage: +# ./setup-project.sh [--staging] +# +# Example: +# ./setup-project.sh myproject +# ./setup-project.sh myproject-staging --staging +# + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +# Check arguments +if [ "$#" -lt 1 ]; then + echo "Usage: $0 [--staging]" + echo "Example: $0 myproject" + exit 1 +fi + +PROJECT_ID=$1 +IS_STAGING=false +if [ "$2" == "--staging" ]; then + IS_STAGING=true +fi + +# Configuration - EDIT THESE FOR YOUR ORGANIZATION +ORGANIZATION_ID="${GCP_ORGANIZATION_ID:-}" # Optional: your GCP org ID +BILLING_ACCOUNT="${GCP_BILLING_ACCOUNT:-}" # Required: your billing account +REGION="${GCP_REGION:-{{ cookiecutter.gcp_region }}}" +CLOUD_SQL_INSTANCE="${CLOUD_SQL_INSTANCE:-{{ cookiecutter.cloud_sql_instance }}}" +CLOUD_SQL_PROJECT="${CLOUD_SQL_PROJECT:-{{ cookiecutter.cloud_sql_project }}}" + +# Derived values +GS_BUCKET_NAME="${PROJECT_ID}" +if [ "$IS_STAGING" = true ]; then + SECRETS_NAME="application_settings_staging" +else + SECRETS_NAME="application_settings" +fi + +confirm_continue() { + read -p "$1 (y/N)? " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi +} + +# Validate billing account +if [ -z "$BILLING_ACCOUNT" ]; then + log_error "GCP_BILLING_ACCOUNT environment variable is required" + echo "Set it with: export GCP_BILLING_ACCOUNT=XXXXXX-XXXXXX-XXXXXX" + exit 1 +fi + +log_info "Setting up GCP project: $PROJECT_ID" +log_info "Region: $REGION" +log_info "Staging: $IS_STAGING" + +# Create or select project +log_info "Creating/selecting project..." +if [ -n "$ORGANIZATION_ID" ]; then + gcloud projects create "$PROJECT_ID" --organization "$ORGANIZATION_ID" 2>/dev/null || \ + log_warn "Project already exists or creation failed, continuing..." +else + gcloud projects create "$PROJECT_ID" 2>/dev/null || \ + log_warn "Project already exists or creation failed, continuing..." +fi + +# Link billing +log_info "Linking billing account..." +gcloud beta billing projects link "$PROJECT_ID" --billing-account "$BILLING_ACCOUNT" || \ + log_error "Failed to link billing account" + +# Enable APIs +log_info "Enabling Cloud APIs (this may take a few minutes)..." +gcloud services --project "$PROJECT_ID" enable \ + run.googleapis.com \ + sql-component.googleapis.com \ + sqladmin.googleapis.com \ + compute.googleapis.com \ + cloudbuild.googleapis.com \ + secretmanager.googleapis.com \ + storage.googleapis.com + +# Get service account emails +PROJECTNUM=$(gcloud projects describe "$PROJECT_ID" --format 'value(projectNumber)') +CLOUDRUN_SA="${PROJECTNUM}-compute@developer.gserviceaccount.com" +CLOUDBUILD_SA="${PROJECTNUM}@cloudbuild.gserviceaccount.com" + +log_info "Cloud Run SA: $CLOUDRUN_SA" +log_info "Cloud Build SA: $CLOUDBUILD_SA" + +# IAM permissions for Cloud Build +log_info "Setting up IAM permissions..." +gcloud projects add-iam-policy-binding "$PROJECT_ID" \ + --member "serviceAccount:${CLOUDBUILD_SA}" \ + --role roles/iam.serviceAccountUser --quiet + +gcloud projects add-iam-policy-binding "$PROJECT_ID" \ + --member "serviceAccount:${CLOUDBUILD_SA}" \ + --role roles/run.admin --quiet + +# Cloud SQL permissions (if using shared instance) +if [ "$CLOUD_SQL_PROJECT" != "$PROJECT_ID" ]; then + log_info "Setting up Cloud SQL permissions on $CLOUD_SQL_PROJECT..." + gcloud projects add-iam-policy-binding "$CLOUD_SQL_PROJECT" \ + --member "serviceAccount:${CLOUDRUN_SA}" \ + --role roles/cloudsql.client --quiet + + gcloud projects add-iam-policy-binding "$CLOUD_SQL_PROJECT" \ + --member "serviceAccount:${CLOUDBUILD_SA}" \ + --role roles/cloudsql.client --quiet +fi + +# Create database +log_info "Creating database on $CLOUD_SQL_INSTANCE..." +gcloud sql databases create "$PROJECT_ID" \ + --instance "$CLOUD_SQL_INSTANCE" \ + --project "$CLOUD_SQL_PROJECT" 2>/dev/null || \ + log_warn "Database already exists, continuing..." + +# Create database user with random password +log_info "Creating database user..." +PGPASS="$(LC_ALL=C tr -dc 'a-zA-Z0-9' < /dev/urandom | fold -w 30 | head -n 1)" +gcloud sql users create "$PROJECT_ID" \ + --instance "$CLOUD_SQL_INSTANCE" \ + --project "$CLOUD_SQL_PROJECT" \ + --password "$PGPASS" 2>/dev/null || \ + log_warn "User already exists, you may need to reset the password" + +# Create storage bucket +log_info "Creating storage bucket: $GS_BUCKET_NAME..." +gsutil mb -l "$REGION" -p "$PROJECT_ID" "gs://${GS_BUCKET_NAME}" 2>/dev/null || \ + log_warn "Bucket already exists, continuing..." + +# Set CORS on bucket +log_info "Setting CORS configuration..." +cat > /tmp/cors.json << 'EOF' +[ + { + "origin": ["*"], + "responseHeader": ["Content-Type"], + "method": ["GET", "HEAD"], + "maxAgeSeconds": 3600 + } +] +EOF +gsutil cors set /tmp/cors.json "gs://$GS_BUCKET_NAME" +rm /tmp/cors.json + +# Create secrets +log_info "Creating secrets in Secret Manager..." +SECRET_KEY="$(LC_ALL=C tr -dc 'a-zA-Z0-9' < /dev/urandom | fold -w 50 | head -n 1)" +DATABASE_URL="postgres://${PROJECT_ID}:${PGPASS}@//cloudsql/${CLOUD_SQL_PROJECT}:${REGION}:${CLOUD_SQL_INSTANCE}/${PROJECT_ID}" + +cat > /tmp/secrets.env << EOF +DATABASE_URL="${DATABASE_URL}" +GS_BUCKET_NAME="${GS_BUCKET_NAME}" +SECRET_KEY="${SECRET_KEY}" +DEBUG="False" +ALLOWED_HOSTS=".run.app" +CORS_ALLOWED_ORIGINS="" +EOF + +gcloud secrets create "$SECRETS_NAME" \ + --data-file /tmp/secrets.env \ + --project "$PROJECT_ID" 2>/dev/null || \ + gcloud secrets versions add "$SECRETS_NAME" \ + --data-file /tmp/secrets.env \ + --project "$PROJECT_ID" + +rm /tmp/secrets.env + +# Grant secret access +log_info "Granting secret access..." +gcloud secrets add-iam-policy-binding "$SECRETS_NAME" \ + --member "serviceAccount:${CLOUDRUN_SA}" \ + --role roles/secretmanager.secretAccessor \ + --project "$PROJECT_ID" --quiet + +gcloud secrets add-iam-policy-binding "$SECRETS_NAME" \ + --member "serviceAccount:${CLOUDBUILD_SA}" \ + --role roles/secretmanager.secretAccessor \ + --project "$PROJECT_ID" --quiet + +# Summary +echo "" +log_info "==========================================" +log_info "GCP Project Setup Complete!" +log_info "==========================================" +echo "" +echo "Project ID: $PROJECT_ID" +echo "Region: $REGION" +echo "Database: $PROJECT_ID on $CLOUD_SQL_INSTANCE" +echo "Storage Bucket: gs://$GS_BUCKET_NAME" +echo "Secrets: $SECRETS_NAME" +echo "" +echo "Next steps:" +echo " 1. Update your .env file with the project settings" +echo " 2. Build and deploy: fab deploy --env=production" +echo " 3. Run migrations: fab migrate --env=production" +echo "" +log_info "Done!" diff --git a/fabfile.py b/fabfile.py new file mode 100644 index 0000000..17869c9 --- /dev/null +++ b/fabfile.py @@ -0,0 +1,615 @@ +""" +{{ cookiecutter.project_name }} - Fabric Deployment & GCP Setup Tasks + +Usage: + # One-time GCP project setup + fab setup --project=myproject --billing=XXXXXX-XXXXXX-XXXXXX + fab setup --project=myproject-staging --billing=XXXXXX-XXXXXX-XXXXXX --staging + + # Day-to-day operations + fab deploy # Deploy to production + fab deploy --env=staging # Deploy to staging + fab build # Build Docker image only + fab migrate # Run migrations on Cloud Run + fab logs # View Cloud Run logs + fab secrets-download # Download secrets from Secret Manager + fab secrets-upload # Upload secrets to Secret Manager + fab db-export # Export database to GCS + fab db-import # Import database from GCS + +Configuration: + Set these environment variables or create a .env file: + - GCP_PROJECT_ID: Your GCP project ID + - GCP_REGION: GCP region (default: europe-west2) + - CLOUD_SQL_INSTANCE: Cloud SQL instance name + - CLOUD_SQL_PROJECT: Project containing Cloud SQL (if different) + - GCP_BILLING_ACCOUNT: Billing account for setup (optional, can pass as arg) +""" +import os +import secrets +import string +from fabric import task +from invoke import Context + +# Load environment variables +try: + from dotenv import load_dotenv + load_dotenv() +except ImportError: + pass + +# Configuration - UPDATE THESE FOR YOUR PROJECT +GCP_PROJECT_ID = os.getenv("GCP_PROJECT_ID", "{{ cookiecutter.project_slug }}") +GCP_REGION = os.getenv("GCP_REGION", "{{ cookiecutter.gcp_region }}") +CLOUD_SQL_INSTANCE = os.getenv("CLOUD_SQL_INSTANCE", "{{ cookiecutter.cloud_sql_instance }}") +CLOUD_SQL_PROJECT = os.getenv("CLOUD_SQL_PROJECT", "{{ cookiecutter.cloud_sql_project }}") +SERVICE_NAME = os.getenv("SERVICE_NAME", "{{ cookiecutter.project_slug }}") +GCP_BILLING_ACCOUNT = os.getenv("GCP_BILLING_ACCOUNT", "00139C-8D2D10-3919FA") +GCP_ORGANIZATION_ID = os.getenv("GCP_ORGANIZATION_ID", "") + +# Colors for output +GREEN = "\033[0;32m" +YELLOW = "\033[1;33m" +RED = "\033[0;31m" +NC = "\033[0m" + + +def log_info(msg): + print(f"{GREEN}[INFO]{NC} {msg}") + + +def log_warn(msg): + print(f"{YELLOW}[WARN]{NC} {msg}") + + +def log_error(msg): + print(f"{RED}[ERROR]{NC} {msg}") + + +def generate_password(length=30): + """Generate a secure random password.""" + alphabet = string.ascii_letters + string.digits + return ''.join(secrets.choice(alphabet) for _ in range(length)) + + +def get_env_config(env: str) -> dict: + """Get configuration for the specified environment.""" + configs = { + "production": { + "service": SERVICE_NAME, + "settings": f"{SERVICE_NAME}.settings.cloud_production", + "secrets_name": "application_settings", + "min_instances": 0, + "max_instances": 10, + }, + "staging": { + "service": f"{SERVICE_NAME}-staging", + "settings": f"{SERVICE_NAME}.settings.cloud_staging", + "secrets_name": "application_settings_staging", + "min_instances": 0, + "max_instances": 2, + }, + } + return configs.get(env, configs["production"]) + + +@task +def build(c, env="production"): + """Build Docker image using Cloud Build.""" + config = get_env_config(env) + image = f"gcr.io/{GCP_PROJECT_ID}/{config['service']}" + + print(f"Building image with Cloud Build: {image}") + c.run(f"""gcloud builds submit \\ + --tag {image} \\ + --project {GCP_PROJECT_ID} \\ + --timeout=30m""", pty=True) + print(f"Image built: {image}") + + +@task +def deploy(c, env="production"): + """Build and deploy to Cloud Run.""" + config = get_env_config(env) + image = f"gcr.io/{GCP_PROJECT_ID}/{config['service']}" + + # Build and push + build(c, env=env) + + c.run(f"""gcloud builds submit \\ + --config cloudmigrate.yaml \\ + --project {GCP_PROJECT_ID} \\ + --substitutions _DJANGO_SETTINGS_MODULE={config['settings']} \\ + --timeout=30m""", pty=True) + + # Deploy to Cloud Run + print(f"Deploying to Cloud Run: {config['service']}") + cmd = f"""gcloud run deploy {config['service']} \\ + --image {image} \\ + --platform managed \\ + --region {GCP_REGION} \\ + --project {GCP_PROJECT_ID} \\ + --add-cloudsql-instances {CLOUD_SQL_PROJECT}:{GCP_REGION}:{CLOUD_SQL_INSTANCE} \\ + --set-env-vars DJANGO_SETTINGS_MODULE={config['settings']},GCP_PROJECT_ID={GCP_PROJECT_ID} \\ + --min-instances {config['min_instances']} \\ + --max-instances {config['max_instances']} \\ + --allow-unauthenticated""" + c.run(cmd, pty=True) + + + + print(f"Deployed: {config['service']}") + + +@task +def migrate(c, env="production"): + """Run Django migrations via Cloud Build.""" + config = get_env_config(env) + + print(f"Running migrations for {env}...") + c.run(f"""gcloud builds submit \\ + --config cloudmigrate.yaml \\ + --project {GCP_PROJECT_ID} \\ + --substitutions _DJANGO_SETTINGS_MODULE={config['settings']} \\ + --timeout=30m""", pty=True) + + +@task +def logs(c, env="production"): + """View Cloud Run logs.""" + config = get_env_config(env) + c.run(f"gcloud run services logs read {config['service']} --region {GCP_REGION} --project {GCP_PROJECT_ID}", pty=True) + + +@task +def createsuperuser(c, email, password, env="production"): + """Create a Django superuser via Cloud Build. + + Usage: fab createsuperuser --email=admin@example.com --password=secret123 + """ + config = get_env_config(env) + + print(f"Creating superuser {email} for {env}...") + + # Create a temporary cloudbuild config for createsuperuser + cloudbuild_config = f""" +steps: + - name: 'gcr.io/google-appengine/exec-wrapper' + args: + - '-i' + - 'gcr.io/{GCP_PROJECT_ID}/{config["service"]}' + - '-s' + - '{CLOUD_SQL_PROJECT}:{GCP_REGION}:{CLOUD_SQL_INSTANCE}' + - '-e' + - 'DJANGO_SETTINGS_MODULE={config["settings"]}' + - '-e' + - 'DJANGO_SUPERUSER_EMAIL={email}' + - '-e' + - 'DJANGO_SUPERUSER_PASSWORD={password}' + - '--' + - 'python' + - 'manage.py' + - 'createsuperuser' + - '--noinput' +timeout: '600s' +""" + + import tempfile + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write(cloudbuild_config) + config_file = f.name + + try: + c.run(f"""gcloud builds submit \\ + --config {config_file} \\ + --project {GCP_PROJECT_ID} \\ + --no-source \\ + --timeout=10m""", pty=True) + print(f"Superuser {email} created successfully!") + finally: + os.unlink(config_file) + + +@task(name="secrets-download") +def secrets_download(c, env="production"): + """Download secrets from Secret Manager to .env file.""" + config = get_env_config(env) + output_file = f".env.{env}" + + print(f"Downloading secrets to {output_file}...") + c.run(f"""gcloud secrets versions access latest \\ + --secret="{config['secrets_name']}" \\ + --project={GCP_PROJECT_ID} \\ + --format="value(payload.data)" > {output_file}""") + print(f"Secrets saved to {output_file}") + + +@task(name="secrets-upload") +def secrets_upload(c, env="production", file=None): + """Upload secrets from .env file to Secret Manager.""" + config = get_env_config(env) + input_file = file or f".env.{env}" + + print(f"Uploading secrets from {input_file}...") + c.run(f"""gcloud secrets versions add {config['secrets_name']} \\ + --data-file={input_file} \\ + --project={GCP_PROJECT_ID}""", pty=True) + print(f"Secrets uploaded to {config['secrets_name']}") + + +@task(name="db-export") +def db_export(c, database=None): + """Export database to GCS bucket.""" + db = database or GCP_PROJECT_ID + bucket = GCP_PROJECT_ID + + print(f"Exporting database {db} to gs://{bucket}/{db}.gz...") + c.run(f"""gcloud sql export sql {CLOUD_SQL_INSTANCE} \\ + gs://{bucket}/{db}.gz \\ + --database={db} \\ + --project={CLOUD_SQL_PROJECT}""", pty=True) + + +@task(name="db-import") +def db_import(c, file, database=None): + """Import database from GCS bucket.""" + db = database or GCP_PROJECT_ID + + print(f"Importing {file} to database {db}...") + c.run(f"""gcloud sql import sql {CLOUD_SQL_INSTANCE} \\ + {file} \\ + --database={db} \\ + --project={CLOUD_SQL_PROJECT}""", pty=True) + + +@task(name="db-download") +def db_download(c, database=None): + """Export and download database locally.""" + db = database or GCP_PROJECT_ID + bucket = GCP_PROJECT_ID + + # Export to GCS + db_export(c, database=db) + + # Download locally + print(f"Downloading gs://{bucket}/{db}.gz...") + c.run(f"gsutil cp gs://{bucket}/{db}.gz .") + c.run(f"gunzip -f {db}.gz") + print(f"Database saved to {db}") + + +@task(name="media-download") +def media_download(c, bucket=None): + """Download media files from GCS.""" + bucket = bucket or GCP_PROJECT_ID + print(f"Downloading media from gs://{bucket}/media...") + c.run(f"gsutil -m cp -r gs://{bucket}/media .", pty=True) + + +@task(name="media-upload") +def media_upload(c, bucket=None): + """Upload media files to GCS.""" + bucket = bucket or GCP_PROJECT_ID + print(f"Uploading media to gs://{bucket}/media...") + c.run(f"gsutil -m cp -r media gs://{bucket}/", pty=True) + c.run(f"gsutil -m acl set -R -a public-read gs://{bucket}/media", pty=True) + + +@task +def shell(c, env="production"): + """Open a Django shell on Cloud Run (via Cloud Build).""" + config = get_env_config(env) + print("Note: This runs a one-off container. For interactive shell, use local development.") + c.run(f"""gcloud builds submit \\ + --config cloudshell.yaml \\ + --project {GCP_PROJECT_ID} \\ + --substitutions _DJANGO_SETTINGS_MODULE={config['settings']} \\ + --timeout=30m""", pty=True) + + +@task +def status(c, env="production"): + """Show Cloud Run service status.""" + config = get_env_config(env) + c.run(f"gcloud run services describe {config['service']} --region {GCP_REGION} --project {GCP_PROJECT_ID}", pty=True) + + +@task +def collectstatic(c): + """Run collectstatic locally (for debugging).""" + c.run("python manage.py collectstatic --noinput", pty=True) + + +# ============================================================================= +# GCP PROJECT SETUP TASKS +# ============================================================================= + + +@task +def setup(c, project, billing=None, staging=False, region=None, sql_instance=None, sql_project=None): + """ + Set up a new GCP project for Django on Cloud Run. + + Creates all GCP resources needed: + - GCP Project (or uses existing) + - Cloud Run, Cloud SQL, Secret Manager, Cloud Build APIs + - Cloud Storage bucket for media files + - Cloud SQL database (on shared instance) + - Secret Manager secrets for Django settings + - IAM permissions for Cloud Run and Cloud Build + + Usage: + fab setup --project=myproject --billing=XXXXXX-XXXXXX-XXXXXX + fab setup --project=myproject-staging --billing=XXXXXX-XXXXXX-XXXXXX --staging + """ + # Configuration + billing_account = billing or GCP_BILLING_ACCOUNT + region = region or GCP_REGION + sql_instance = sql_instance or CLOUD_SQL_INSTANCE + sql_project = sql_project or CLOUD_SQL_PROJECT + org_id = GCP_ORGANIZATION_ID + + if not billing_account: + log_error("Billing account is required.") + print("Pass --billing=XXXXXX-XXXXXX-XXXXXX or set GCP_BILLING_ACCOUNT env var") + return + + secrets_name = "application_settings_staging" if staging else "application_settings" + bucket_name = project + + log_info(f"Setting up GCP project: {project}") + log_info(f"Region: {region}") + log_info(f"Staging: {staging}") + print() + + # Create or select project + setup_create_project(c, project, org_id) + + # Link billing + setup_link_billing(c, project, billing_account) + + # Enable APIs + setup_enable_apis(c, project) + + # Get service account emails + cloudrun_sa, cloudbuild_sa = setup_get_service_accounts(c, project) + + # IAM permissions + setup_iam_permissions(c, project, cloudrun_sa, cloudbuild_sa, sql_project) + + # Create database + db_password = setup_create_database(c, project, sql_instance, sql_project) + + # Create storage bucket + setup_create_bucket(c, project, bucket_name, region) + + # Create secrets + setup_create_secrets(c, project, secrets_name, bucket_name, db_password, + sql_project, region, sql_instance, cloudrun_sa, cloudbuild_sa) + + # Summary + print() + log_info("==========================================") + log_info("GCP Project Setup Complete!") + log_info("==========================================") + print() + print(f"Project ID: {project}") + print(f"Region: {region}") + print(f"Database: {project} on {sql_instance}") + print(f"Storage Bucket: gs://{bucket_name}") + print(f"Secrets: {secrets_name}") + print() + print("Next steps:") + print(" 1. Update your .env file:") + print(f" GCP_PROJECT_ID={project}") + print(f" GCP_REGION={region}") + env_flag = "--env=staging" if staging else "" + print(f" 2. Deploy: fab deploy {env_flag}") + print(f" 3. Run migrations: fab migrate {env_flag}") + print() + log_info("Done!") + + +def setup_create_project(c, project, org_id): + """Create or select GCP project.""" + log_info("Creating/selecting project...") + try: + if org_id: + c.run(f'gcloud projects create "{project}" --organization "{org_id}"', + warn=True, hide=True) + else: + c.run(f'gcloud projects create "{project}"', warn=True, hide=True) + except Exception: + log_warn("Project already exists or creation failed, continuing...") + + +def setup_link_billing(c, project, billing_account): + """Link billing account to project.""" + log_info("Linking billing account...") + result = c.run(f'gcloud beta billing projects link "{project}" --billing-account "{billing_account}"', + warn=True) + if result.failed: + log_error("Failed to link billing account") + + +def setup_enable_apis(c, project): + """Enable required Cloud APIs.""" + log_info("Enabling Cloud APIs (this may take a few minutes)...") + apis = [ + "run.googleapis.com", + "sql-component.googleapis.com", + "sqladmin.googleapis.com", + "compute.googleapis.com", + "cloudbuild.googleapis.com", + "secretmanager.googleapis.com", + "storage.googleapis.com", + ] + c.run(f'gcloud services --project "{project}" enable {" ".join(apis)}', pty=True) + + +def setup_get_service_accounts(c, project): + """Get service account emails for Cloud Run and Cloud Build.""" + result = c.run(f'gcloud projects describe "{project}" --format "value(projectNumber)"', + hide=True) + project_num = result.stdout.strip() + cloudrun_sa = f"{project_num}-compute@developer.gserviceaccount.com" + cloudbuild_sa = f"{project_num}@cloudbuild.gserviceaccount.com" + log_info(f"Cloud Run SA: {cloudrun_sa}") + log_info(f"Cloud Build SA: {cloudbuild_sa}") + return cloudrun_sa, cloudbuild_sa + + +def setup_iam_permissions(c, project, cloudrun_sa, cloudbuild_sa, sql_project): + """Set up IAM permissions.""" + log_info("Setting up IAM permissions...") + + # Cloud Build permissions + c.run(f'gcloud projects add-iam-policy-binding "{project}" ' + f'--member "serviceAccount:{cloudbuild_sa}" ' + f'--role roles/iam.serviceAccountUser --quiet', hide=True) + + c.run(f'gcloud projects add-iam-policy-binding "{project}" ' + f'--member "serviceAccount:{cloudbuild_sa}" ' + f'--role roles/run.admin --quiet', hide=True) + + # Cloud SQL permissions (if using shared instance) + if sql_project != project: + log_info(f"Setting up Cloud SQL permissions on {sql_project}...") + c.run(f'gcloud projects add-iam-policy-binding "{sql_project}" ' + f'--member "serviceAccount:{cloudrun_sa}" ' + f'--role roles/cloudsql.client --quiet', hide=True) + + c.run(f'gcloud projects add-iam-policy-binding "{sql_project}" ' + f'--member "serviceAccount:{cloudbuild_sa}" ' + f'--role roles/cloudsql.client --quiet', hide=True) + + +def setup_create_database(c, project, sql_instance, sql_project): + """Create database and user on Cloud SQL.""" + log_info(f"Creating database on {sql_instance}...") + + # Create database + c.run(f'gcloud sql databases create "{project}" ' + f'--instance "{sql_instance}" ' + f'--project "{sql_project}"', warn=True, hide=True) + + # Create user with random password + log_info("Creating database user...") + password = generate_password() + result = c.run(f'gcloud sql users create "{project}" ' + f'--instance "{sql_instance}" ' + f'--project "{sql_project}" ' + f'--password "{password}"', warn=True, hide=True) + + if result.failed: + log_warn("User already exists, you may need to reset the password") + # Generate new password anyway for secrets + password = generate_password() + c.run(f'gcloud sql users set-password "{project}" ' + f'--instance "{sql_instance}" ' + f'--project "{sql_project}" ' + f'--password "{password}"', warn=True, hide=True) + + return password + + +def setup_create_bucket(c, project, bucket_name, region): + """Create Cloud Storage bucket.""" + log_info(f"Creating storage bucket: {bucket_name}...") + c.run(f'gsutil mb -l "{region}" -p "{project}" "gs://{bucket_name}"', + warn=True, hide=True) + + # Set CORS using temp file + log_info("Setting CORS configuration...") + import tempfile + import json + cors_config = [{"origin": ["*"], "responseHeader": ["Content-Type"], "method": ["GET", "HEAD"], "maxAgeSeconds": 3600}] + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + json.dump(cors_config, f) + cors_file = f.name + try: + c.run(f'gsutil cors set "{cors_file}" gs://{bucket_name}', warn=True) + finally: + os.unlink(cors_file) + + +def setup_create_secrets(c, project, secrets_name, bucket_name, db_password, + sql_project, region, sql_instance, cloudrun_sa, cloudbuild_sa): + """Create secrets in Secret Manager.""" + log_info("Creating secrets in Secret Manager...") + + secret_key = generate_password(50) + database_url = f"postgres://{project}:{db_password}@//cloudsql/{sql_project}:{region}:{sql_instance}/{project}" + + secrets_content = f'''DATABASE_URL="{database_url}" +GS_BUCKET_NAME="{bucket_name}" +SECRET_KEY="{secret_key}" +DEBUG="False" +ALLOWED_HOSTS=".run.app" +CORS_ALLOWED_ORIGINS="" +''' + + # Write to temp file + import tempfile + with tempfile.NamedTemporaryFile(mode='w', suffix='.env', delete=False) as f: + f.write(secrets_content) + temp_file = f.name + + try: + # Try to create secret + result = c.run(f'gcloud secrets create "{secrets_name}" ' + f'--data-file="{temp_file}" ' + f'--project "{project}"', warn=True, hide=True) + + if result.failed: + # Secret exists, add new version + c.run(f'gcloud secrets versions add "{secrets_name}" ' + f'--data-file="{temp_file}" ' + f'--project "{project}"', hide=True) + finally: + os.unlink(temp_file) + + # Grant secret access + log_info("Granting secret access...") + c.run(f'gcloud secrets add-iam-policy-binding "{secrets_name}" ' + f'--member "serviceAccount:{cloudrun_sa}" ' + f'--role roles/secretmanager.secretAccessor ' + f'--project "{project}" --quiet', hide=True) + + c.run(f'gcloud secrets add-iam-policy-binding "{secrets_name}" ' + f'--member "serviceAccount:{cloudbuild_sa}" ' + f'--role roles/secretmanager.secretAccessor ' + f'--project "{project}" --quiet', hide=True) + + +@task(name="setup-apis") +def setup_apis(c, project=None): + """Enable required GCP APIs for an existing project.""" + project = project or GCP_PROJECT_ID + setup_enable_apis(c, project) + + +@task(name="setup-iam") +def setup_iam(c, project=None): + """Set up IAM permissions for an existing project.""" + project = project or GCP_PROJECT_ID + cloudrun_sa, cloudbuild_sa = setup_get_service_accounts(c, project) + setup_iam_permissions(c, project, cloudrun_sa, cloudbuild_sa, CLOUD_SQL_PROJECT) + + +@task(name="setup-bucket") +def setup_bucket(c, project=None, bucket=None): + """Create Cloud Storage bucket for an existing project.""" + project = project or GCP_PROJECT_ID + bucket = bucket or project + setup_create_bucket(c, project, bucket, GCP_REGION) + + +@task(name="setup-database") +def setup_database(c, project=None): + """Create database on Cloud SQL for an existing project.""" + project = project or GCP_PROJECT_ID + password = setup_create_database(c, project, CLOUD_SQL_INSTANCE, CLOUD_SQL_PROJECT) + print(f"\nDatabase password: {password}") + print("Save this password - you'll need it for your secrets!") + diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..95ea279 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', '{{ cookiecutter.project_slug }}.settings.base') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..2198c1b --- /dev/null +++ b/readme.md @@ -0,0 +1,26 @@ +# {{ cookiecutter.project_name }} + +Django project with: + +- Django REST Framework +- Django Allauth (email-only auth) +- dj-rest-auth (API endpoints) +- django-authtools (custom User model) +- S3 storage (Backblaze B2) +- django-cloud-tasks (GCP Cloud Tasks queue) + +## Quick Start + +This project uses [uv](https://github.com/astral-sh/uv) for fast dependency management. + +```bash +# Install uv (if not already installed) +curl -LsSf https://astral.sh/uv/install.sh | sh + +# Install dependencies (uv is much faster than pip) +uv pip install -r requirements.txt + +python manage.py migrate +python manage.py runserver +``` + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..727a1d9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,32 @@ +# Django Core +Django>=5.2,<6.0 +psycopg2-binary>=2.9 +dj-database-url>=2.1 + +# Django REST Framework & Auth +djangorestframework>=3.15 +dj-rest-auth>=7.0 +django-allauth>=65.0 +django-authtools>=2.0 + +# Storage +django-storages>=1.14 +boto3>=1.34 + +# GCP Deployment +django-environ>=0.11 +google-auth>=2.27 +google-cloud-secret-manager>=2.18 +google-cloud-storage>=2.14 +gunicorn>=21.2 +whitenoise>=6.6 +django-cors-headers>=4.3 + +# Task Queue +django-google-cloud-tasks>=0.22 + +# Development +python-dotenv>=1.0 + +# Deployment Automation +fabric>=3.2 diff --git a/{{ cookiecutter.project_slug }}/__init__.py b/{{ cookiecutter.project_slug }}/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{ cookiecutter.project_slug }}/asgi.py b/{{ cookiecutter.project_slug }}/asgi.py new file mode 100644 index 0000000..fdd900b --- /dev/null +++ b/{{ cookiecutter.project_slug }}/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for {{ cookiecutter.project_slug }} project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', '{{ cookiecutter.project_slug }}.settings.base') + +application = get_asgi_application() diff --git a/{{ cookiecutter.project_slug }}/settings/__init__.py b/{{ cookiecutter.project_slug }}/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{ cookiecutter.project_slug }}/settings/base.py b/{{ cookiecutter.project_slug }}/settings/base.py new file mode 100644 index 0000000..87f0c2c --- /dev/null +++ b/{{ cookiecutter.project_slug }}/settings/base.py @@ -0,0 +1,279 @@ +""" +Django settings for {{ cookiecutter.project_slug }} project. + +Generated by 'django-admin startproject' using Django 5.1.6. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.1/ref/settings/ +""" + +from pathlib import Path +import warnings +import os + +try: + import dotenv + dotenv.load_dotenv() +except ImportError: + pass + +# Suppress deprecation warnings from third-party packages +warnings.filterwarnings('ignore', category=UserWarning, module='dj_rest_auth') + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-change-me-in-production') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.getenv('DEBUG', 'True').lower() in ('true', '1', 'yes') + +ALLOWED_HOSTS = [h.strip() for h in os.getenv('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') if h.strip()] + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django.contrib.sites', + + # Third party apps + 'authtools', + 'rest_framework', + 'rest_framework.authtoken', + 'allauth', + 'allauth.account', + 'allauth.socialaccount', + 'dj_rest_auth', + 'dj_rest_auth.registration', + 'django_cloud_tasks', + + # Local apps + '{{ cookiecutter.project_slug }}', + 'accounts', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', + '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', + 'allauth.account.middleware.AccountMiddleware', +] + +ROOT_URLCONF = '{{ cookiecutter.project_slug }}.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + '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' + +# Database +# https://docs.djangoproject.com/en/5.1/ref/settings/#databases +# Default: PostgreSQL (recommended for production) +# Set DATABASE_URL environment variable or configure below + +DATABASE_URL = os.getenv('DATABASE_URL') +if DATABASE_URL: + # Parse DATABASE_URL for production + import dj_database_url + DATABASES = { + 'default': dj_database_url.parse(DATABASE_URL) + } +else: + # Default to PostgreSQL for local development + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.getenv('DB_NAME', '{{ cookiecutter.project_slug }}'), + 'HOST': os.getenv('DB_HOST', '127.0.0.1'), + 'PORT': os.getenv('DB_PORT', '5432'), + 'USER': os.getenv('DB_USER', 'postgres'), + 'PASSWORD': os.getenv('DB_PASSWORD', 'postgres'), + } + } + +# Fallback to SQLite if USE_SQLITE is set (for quick local testing) +if os.getenv('USE_SQLITE', '').lower() in ('true', '1', 'yes'): + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } + } + +# Password validation +# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, + {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, + {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, + {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, +] + +# Internationalization +# https://docs.djangoproject.com/en/5.1/topics/i18n/ + +LANGUAGE_CODE = 'en-us' +TIME_ZONE = 'UTC' +USE_I18N = True +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.1/howto/static-files/ + +STATIC_URL = 'static/' +STATIC_ROOT = BASE_DIR / 'staticfiles' + +# Default primary key field type +# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +SITE_ID = 1 + +# Custom User Model +AUTH_USER_MODEL = 'accounts.User' + +# Django REST Framework Configuration +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.TokenAuthentication', + 'rest_framework.authentication.SessionAuthentication', + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ], + 'DEFAULT_RENDERER_CLASSES': [ + 'rest_framework.renderers.JSONRenderer', + ], + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 20, +} + +# Django Allauth Configuration +ACCOUNT_LOGIN_METHODS = {'email'} +ACCOUNT_SIGNUP_FIELDS = ['email*', 'password1*', 'password2*'] +ACCOUNT_EMAIL_VERIFICATION = 'optional' +ACCOUNT_CONFIRM_EMAIL_ON_GET = True +ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True +ACCOUNT_LOGOUT_ON_GET = True +ACCOUNT_RATE_LIMITS = {'login_failed': '5/5m'} +ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = 3 +ACCOUNT_PASSWORD_MIN_LENGTH = 8 + +# Configure allauth to work with email-only user model (no username) +ACCOUNT_USER_MODEL_USERNAME_FIELD = None +ACCOUNT_USER_MODEL_EMAIL_FIELD = 'email' + +# dj-rest-auth Configuration +REST_AUTH_SERIALIZERS = { + 'USER_DETAILS_SERIALIZER': 'accounts.api.serializers.UserDetailsSerializer', +} +REST_AUTH_REGISTER_SERIALIZERS = { + 'REGISTER_SERIALIZER': 'accounts.api.serializers.CustomRegisterSerializer', +} + +REST_USE_JWT = False +REST_SESSION_LOGIN = False + +# Email Configuration (for development) +EMAIL_BACKEND = os.getenv('EMAIL_BACKEND', 'django.core.mail.backends.console.EmailBackend') + +# Authentication backends +AUTHENTICATION_BACKENDS = [ + 'django.contrib.auth.backends.ModelBackend', + 'allauth.account.auth_backends.AuthenticationBackend', +] + +# S3/Backblaze B2 Storage Configuration (optional) +{% if cookiecutter.use_s3_storage == 'y' %} +USE_S3_STORAGE = os.getenv('USE_S3_STORAGE', '').lower() in ('true', '1', 'yes') + +{% endif %} +if USE_S3_STORAGE: + AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID") + AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY") + AWS_STORAGE_BUCKET_NAME = os.getenv("AWS_STORAGE_BUCKET_NAME") + AWS_S3_ENDPOINT_URL = os.getenv("AWS_S3_ENDPOINT_URL") + AWS_S3_REGION_NAME = os.getenv("AWS_S3_REGION_NAME") + AWS_DEFAULT_ACL = None + AWS_S3_FILE_OVERWRITE = False + AWS_S3_VERIFY = True + AWS_QUERYSTRING_AUTH = True + AWS_QUERYSTRING_EXPIRE = 3600 + AWS_S3_SIGNATURE_VERSION = 's3v4' + AWS_S3_ADDRESSING_STYLE = 'virtual' + + STORAGES = { + "default": {"BACKEND": "storages.backends.s3.S3Storage"}, + "staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"}, + } +else: + STORAGES = { + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"}, + } + +# ============================================================================= +# Django Cloud Tasks Configuration +# ============================================================================= +# In development, tasks run synchronously in-process (eager mode) +# In production, configure GCP_PROJECT_ID and GCP_LOCATION for Cloud Tasks + +DJANGO_CLOUD_TASKS = { + # Run tasks synchronously in development (no Cloud Tasks infrastructure needed) + 'eager': os.getenv('CLOUD_TASKS_EAGER', 'True').lower() in ('true', '1', 'yes'), + + # GCP settings (used in production when eager=False) + 'project_id': os.getenv('GCP_PROJECT_ID', ''), + 'location': os.getenv('GCP_LOCATION', '{{ cookiecutter.gcp_region }}'), + + # Task queues with different priorities + 'queues': { + 'instant': { + 'name': 'instant', + }, + 'high': { + 'name': 'high', + }, + 'medium': { + 'name': 'medium', + }, + 'low': { + 'name': 'low', + }, + }, + + # Default queue for tasks without explicit queue + 'default_queue': 'medium', +} diff --git a/{{ cookiecutter.project_slug }}/settings/cloud_production.py b/{{ cookiecutter.project_slug }}/settings/cloud_production.py new file mode 100644 index 0000000..da01d69 --- /dev/null +++ b/{{ cookiecutter.project_slug }}/settings/cloud_production.py @@ -0,0 +1,162 @@ +""" +Django settings for Google Cloud Run production deployment. + +This settings file: +- Reads secrets from Google Secret Manager +- Uses WhiteNoise for static files +- Uses Google Cloud Storage for media files +- Configures CORS for API access + +Required environment variables: +- DJANGO_SETTINGS_MODULE={{ cookiecutter.project_slug }}.settings.cloud_production +- GCP_PROJECT_ID (optional, auto-detected on Cloud Run) + +Required secrets in Secret Manager (application_settings): +- DATABASE_URL +- SECRET_KEY +- GS_BUCKET_NAME +- ALLOWED_HOSTS (comma-separated) +- CORS_ALLOWED_ORIGINS (comma-separated, optional) +""" + +import io +import os +from google.cloud import secretmanager + +from .base import * + +# ============================================================================= +# Load secrets from Google Secret Manager +# ============================================================================= + +def get_secret(secret_id: str, project_id: str = None) -> str: + """Fetch a secret from Google Secret Manager.""" + if project_id is None: + project_id = os.environ.get("GCP_PROJECT_ID") or os.environ.get("GOOGLE_CLOUD_PROJECT") + + client = secretmanager.SecretManagerServiceClient() + name = f"projects/{project_id}/secrets/{secret_id}/versions/latest" + response = client.access_secret_version(request={"name": name}) + return response.payload.data.decode("UTF-8") + + +# Load application settings from Secret Manager +try: + import environ + env = environ.Env() + + secret_payload = get_secret("application_settings") + env.read_env(io.StringIO(secret_payload)) +except Exception as e: + import logging + logging.warning(f"Could not load secrets from Secret Manager: {e}") + env = environ.Env() + +# ============================================================================= +# Core Settings +# ============================================================================= + +DEBUG = env.bool("DEBUG", default=False) +SECRET_KEY = env("SECRET_KEY") + +# Parse ALLOWED_HOSTS from comma-separated string +ALLOWED_HOSTS = [h.strip() for h in env("ALLOWED_HOSTS", default="").split(",") if h.strip()] + +# ============================================================================= +# Database - Cloud SQL via Unix socket +# ============================================================================= + +DATABASES = { + "default": env.db("DATABASE_URL") +} + +# ============================================================================= +# Static Files - WhiteNoise +# ============================================================================= + +MIDDLEWARE.insert(1, "whitenoise.middleware.WhiteNoiseMiddleware") + +STATIC_URL = "/static/" +STATIC_ROOT = BASE_DIR / "staticfiles" + +STORAGES = { + "default": { + "BACKEND": "storages.backends.gcloud.GoogleCloudStorage", + "OPTIONS": { + "bucket_name": env("GS_BUCKET_NAME"), + }, + }, + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, +} + +# ============================================================================= +# Media Files - Google Cloud Storage +# ============================================================================= + +GS_BUCKET_NAME = env("GS_BUCKET_NAME") +GS_DEFAULT_ACL = "publicRead" +GS_QUERYSTRING_AUTH = False + +# ============================================================================= +# CORS Configuration +# ============================================================================= + +CORS_ALLOWED_ORIGINS = [ + o.strip() for o in env("CORS_ALLOWED_ORIGINS", default="").split(",") if o.strip() +] +CORS_ALLOW_CREDENTIALS = True + +if CORS_ALLOWED_ORIGINS: + INSTALLED_APPS = ["corsheaders"] + list(INSTALLED_APPS) + MIDDLEWARE.insert(0, "corsheaders.middleware.CorsMiddleware") + +# ============================================================================= +# Security Settings +# ============================================================================= + +SECURE_SSL_REDIRECT = True +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True + +# ============================================================================= +# Logging +# ============================================================================= + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "json": { + "format": '{"time": "%(asctime)s", "level": "%(levelname)s", "name": "%(name)s", "message": "%(message)s"}', + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "json", + }, + }, + "root": { + "handlers": ["console"], + "level": env("LOG_LEVEL", default="INFO"), + }, + "loggers": { + "django": { + "handlers": ["console"], + "level": env("LOG_LEVEL", default="INFO"), + "propagate": False, + }, + }, +} + +# ============================================================================= +# Cloud Tasks Configuration (Production) +# ============================================================================= +# Disable eager mode - use real Cloud Tasks infrastructure + +DJANGO_CLOUD_TASKS['eager'] = False +DJANGO_CLOUD_TASKS['project_id'] = os.environ.get("GCP_PROJECT_ID") or os.environ.get("GOOGLE_CLOUD_PROJECT") +DJANGO_CLOUD_TASKS['location'] = env("GCP_LOCATION", default="{{ cookiecutter.gcp_region }}") diff --git a/{{ cookiecutter.project_slug }}/settings/cloud_staging.py b/{{ cookiecutter.project_slug }}/settings/cloud_staging.py new file mode 100644 index 0000000..838ad05 --- /dev/null +++ b/{{ cookiecutter.project_slug }}/settings/cloud_staging.py @@ -0,0 +1,149 @@ +""" +Django settings for Google Cloud Run staging deployment. + +Same as cloud_production.py but with staging-specific defaults: +- Uses application_settings_staging secret +- DEBUG defaults to True +- LOG_LEVEL defaults to DEBUG +""" + +import io +import os +from google.cloud import secretmanager + +from .base import * + +# ============================================================================= +# Load secrets from Google Secret Manager +# ============================================================================= + +def get_secret(secret_id: str, project_id: str = None) -> str: + """Fetch a secret from Google Secret Manager.""" + if project_id is None: + project_id = os.environ.get("GCP_PROJECT_ID") or os.environ.get("GOOGLE_CLOUD_PROJECT") + + client = secretmanager.SecretManagerServiceClient() + name = f"projects/{project_id}/secrets/{secret_id}/versions/latest" + response = client.access_secret_version(request={"name": name}) + return response.payload.data.decode("UTF-8") + + +# Load application settings from Secret Manager (staging secret) +try: + import environ + env = environ.Env() + + secret_payload = get_secret("application_settings_staging") + env.read_env(io.StringIO(secret_payload)) +except Exception as e: + import logging + logging.warning(f"Could not load secrets from Secret Manager: {e}") + env = environ.Env() + +# ============================================================================= +# Core Settings (staging defaults) +# ============================================================================= + +DEBUG = env.bool("DEBUG", default=True) +SECRET_KEY = env("SECRET_KEY") + +ALLOWED_HOSTS = [h.strip() for h in env("ALLOWED_HOSTS", default="").split(",") if h.strip()] + +# ============================================================================= +# Database - Cloud SQL via Unix socket +# ============================================================================= + +DATABASES = { + "default": env.db("DATABASE_URL") +} + +# ============================================================================= +# Static Files - WhiteNoise +# ============================================================================= + +MIDDLEWARE.insert(1, "whitenoise.middleware.WhiteNoiseMiddleware") + +STATIC_URL = "/static/" +STATIC_ROOT = BASE_DIR / "staticfiles" + +STORAGES = { + "default": { + "BACKEND": "storages.backends.gcloud.GoogleCloudStorage", + "OPTIONS": { + "bucket_name": env("GS_BUCKET_NAME"), + }, + }, + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, +} + +# ============================================================================= +# Media Files - Google Cloud Storage +# ============================================================================= + +GS_BUCKET_NAME = env("GS_BUCKET_NAME") +GS_DEFAULT_ACL = "publicRead" +GS_QUERYSTRING_AUTH = False + +# ============================================================================= +# CORS Configuration +# ============================================================================= + +CORS_ALLOWED_ORIGINS = [ + o.strip() for o in env("CORS_ALLOWED_ORIGINS", default="").split(",") if o.strip() +] +CORS_ALLOW_CREDENTIALS = True + +if CORS_ALLOWED_ORIGINS: + INSTALLED_APPS = ["corsheaders"] + list(INSTALLED_APPS) + MIDDLEWARE.insert(0, "corsheaders.middleware.CorsMiddleware") + +# ============================================================================= +# Security Settings (relaxed for staging) +# ============================================================================= + +SECURE_SSL_REDIRECT = True +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True + +# ============================================================================= +# Logging (verbose for staging) +# ============================================================================= + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "json": { + "format": '{"time": "%(asctime)s", "level": "%(levelname)s", "name": "%(name)s", "message": "%(message)s"}', + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "json", + }, + }, + "root": { + "handlers": ["console"], + "level": env("LOG_LEVEL", default="DEBUG"), + }, + "loggers": { + "django": { + "handlers": ["console"], + "level": env("LOG_LEVEL", default="DEBUG"), + "propagate": False, + }, + }, +} + +# ============================================================================= +# Cloud Tasks Configuration (Staging) +# ============================================================================= +# Disable eager mode - use real Cloud Tasks infrastructure + +DJANGO_CLOUD_TASKS['eager'] = False +DJANGO_CLOUD_TASKS['project_id'] = os.environ.get("GCP_PROJECT_ID") or os.environ.get("GOOGLE_CLOUD_PROJECT") +DJANGO_CLOUD_TASKS['location'] = env("GCP_LOCATION", default="{{ cookiecutter.gcp_region }}") diff --git a/{{ cookiecutter.project_slug }}/settings/dev.py b/{{ cookiecutter.project_slug }}/settings/dev.py new file mode 100644 index 0000000..773cfc4 --- /dev/null +++ b/{{ cookiecutter.project_slug }}/settings/dev.py @@ -0,0 +1 @@ +from .base import * \ No newline at end of file diff --git a/{{ cookiecutter.project_slug }}/settings/production.py b/{{ cookiecutter.project_slug }}/settings/production.py new file mode 100644 index 0000000..773cfc4 --- /dev/null +++ b/{{ cookiecutter.project_slug }}/settings/production.py @@ -0,0 +1 @@ +from .base import * \ No newline at end of file diff --git a/{{ cookiecutter.project_slug }}/tests.py b/{{ cookiecutter.project_slug }}/tests.py new file mode 100644 index 0000000..2713a03 --- /dev/null +++ b/{{ cookiecutter.project_slug }}/tests.py @@ -0,0 +1,112 @@ +from django.test import TestCase +from django.conf import settings + + +class S3IntegrationTests(TestCase): + def test_storages_backend_is_s3(self): + """Ensure the default storage backend is configured to S3.""" + from django.core.files.storage import default_storage + try: + # Newer django-storages exposes S3Storage here + from storages.backends.s3 import S3Storage # type: ignore + except Exception: # pragma: no cover - fallback for older versions + # Older versions expose S3Boto3Storage + from storages.backends.s3boto3 import S3Boto3Storage as S3Storage # type: ignore + + self.assertTrue( + isinstance(default_storage, S3Storage), + msg="Default storage should be an instance of S3Storage/S3Boto3Storage", + ) + + def test_s3_settings_present(self): + """Verify critical S3 settings are present and sane.""" + # STORAGES mapping should point default to S3 backend + self.assertIn("default", settings.STORAGES) + self.assertEqual( + settings.STORAGES["default"]["BACKEND"], + "storages.backends.s3.S3Storage", + ) + + # Required AWS settings + self.assertTrue(settings.AWS_ACCESS_KEY_ID) + self.assertTrue(settings.AWS_SECRET_ACCESS_KEY) + self.assertTrue(settings.AWS_STORAGE_BUCKET_NAME) + self.assertTrue(settings.AWS_S3_ENDPOINT_URL) + + # Security and URL behavior + self.assertTrue(settings.AWS_QUERYSTRING_AUTH) + self.assertEqual(settings.AWS_S3_SIGNATURE_VERSION, "s3v4") + self.assertIn(settings.AWS_S3_ADDRESSING_STYLE, ["auto", "virtual", "path"]) + + +class CloudTasksIntegrationTests(TestCase): + def test_cloud_tasks_settings_present(self): + """Basic sanity check for configured django-cloud-tasks settings.""" + self.assertTrue(hasattr(settings, "DJANGO_CLOUD_TASKS")) + config = settings.DJANGO_CLOUD_TASKS + + # Check required keys exist + self.assertIn('eager', config) + self.assertIn('queues', config) + self.assertIn('default_queue', config) + + # Check all 4 queues are configured + queues = config['queues'] + self.assertIn('instant', queues) + self.assertIn('high', queues) + self.assertIn('medium', queues) + self.assertIn('low', queues) + + # Default queue should be one of the configured queues + self.assertIn(config['default_queue'], queues) + + def test_eager_mode_in_development(self): + """In development, eager mode should be enabled by default.""" + # This test assumes we're running in dev mode where eager=True + config = settings.DJANGO_CLOUD_TASKS + # In test environment, eager should be True (tasks run in-process) + self.assertTrue(config.get('eager', False)) + +# Live S3 read/write integration tests (skipped unless S3_LIVE_TESTS=1) +import os +import uuid +import unittest +from django.core.files.base import ContentFile +from django.core.files.storage import default_storage + + +@unittest.skipUnless(os.getenv("S3_LIVE_TESTS") == "1", "Set S3_LIVE_TESTS=1 to run live S3 tests") +class S3ReadWriteTests(TestCase): + def test_upload_and_download_small_text_file(self): + """Upload a small text file to S3 and download it back via default_storage.""" + key = f"test/integration/{uuid.uuid4().hex}.txt" + content = b"hello from integration test" + saved_key = None + try: + saved_key = default_storage.save(key, ContentFile(content)) + self.assertTrue(default_storage.exists(saved_key)) + + with default_storage.open(saved_key, mode="rb") as fh: + data = fh.read() + self.assertEqual(data, content) + finally: + if saved_key and default_storage.exists(saved_key): + default_storage.delete(saved_key) + + def test_upload_and_download_binary_file(self): + """Upload/download a small binary blob to ensure binary IO works as expected.""" + key = f"test/integration/{uuid.uuid4().hex}.bin" + # Arbitrary binary payload + content = bytes([0x00, 0xFF, 0x10, 0x20, 0x7F, 0x80, 0xAB, 0xCD]) + saved_key = None + try: + saved_key = default_storage.save(key, ContentFile(content)) + self.assertTrue(default_storage.exists(saved_key)) + + with default_storage.open(saved_key, mode="rb") as fh: + data = fh.read() + self.assertEqual(data, content) + finally: + if saved_key and default_storage.exists(saved_key): + default_storage.delete(saved_key) + diff --git a/{{ cookiecutter.project_slug }}/urls.py b/{{ cookiecutter.project_slug }}/urls.py new file mode 100644 index 0000000..9dccf2d --- /dev/null +++ b/{{ cookiecutter.project_slug }}/urls.py @@ -0,0 +1,37 @@ +""" +URL configuration for {{ cookiecutter.project_slug }} project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.1/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 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 + +urlpatterns = [ + path('admin/', admin.site.urls), + + # All accounts/authentication endpoints under /api/accounts/ + path('api/accounts/', include([ + # dj-rest-auth endpoints + path('auth/', include('dj_rest_auth.urls')), + # Override registration to a simple email/password endpoint + path('auth/registration/', include(([ + path('', __import__('accounts.api.views', fromlist=['']).simple_register, name='rest_register'), + ], 'accounts'), namespace='auth_registration')), + + path('auth/registration/', include('dj_rest_auth.registration.urls')), + + # Custom accounts endpoints + path('', include('accounts.api.urls', namespace='accounts')), + ])), +] diff --git a/{{ cookiecutter.project_slug }}/wsgi.py b/{{ cookiecutter.project_slug }}/wsgi.py new file mode 100644 index 0000000..60c526e --- /dev/null +++ b/{{ cookiecutter.project_slug }}/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for {{ cookiecutter.project_slug }} project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', '{{ cookiecutter.project_slug }}.settings.base') + +application = get_wsgi_application()