Initial template: Django REST API starter template
This commit is contained in:
parent
7ecf6a8719
commit
a94318f772
46
.dockerignore
Normal file
46
.dockerignore
Normal file
@ -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
|
||||
46
.env.example
Normal file
46
.env.example
Normal file
@ -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
|
||||
217
.gitignore
vendored
Normal file
217
.gitignore
vendored
Normal file
@ -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
|
||||
46
Dockerfile
Normal file
46
Dockerfile
Normal file
@ -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
|
||||
0
accounts/__init__.py
Normal file
0
accounts/__init__.py
Normal file
3
accounts/admin.py
Normal file
3
accounts/admin.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
1
accounts/api/__init__.py
Normal file
1
accounts/api/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# API package for accounts app
|
||||
106
accounts/api/serializers.py
Normal file
106
accounts/api/serializers.py
Normal file
@ -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
|
||||
11
accounts/api/urls.py
Normal file
11
accounts/api/urls.py
Normal file
@ -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'),
|
||||
]
|
||||
92
accounts/api/views.py
Normal file
92
accounts/api/views.py
Normal file
@ -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)
|
||||
6
accounts/apps.py
Normal file
6
accounts/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'accounts'
|
||||
35
accounts/migrations/0001_initial.py
Normal file
35
accounts/migrations/0001_initial.py
Normal file
@ -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,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
23
accounts/migrations/0003_user_first_name_user_last_name.py
Normal file
23
accounts/migrations/0003_user_first_name_user_last_name.py
Normal file
@ -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'),
|
||||
),
|
||||
]
|
||||
0
accounts/migrations/__init__.py
Normal file
0
accounts/migrations/__init__.py
Normal file
42
accounts/models.py
Normal file
42
accounts/models.py
Normal file
@ -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'
|
||||
16
accounts/serializers.py
Normal file
16
accounts/serializers.py
Normal file
@ -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',
|
||||
]
|
||||
518
accounts/tests.py
Normal file
518
accounts/tests.py
Normal file
@ -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)
|
||||
8
accounts/urls.py
Normal file
8
accounts/urls.py
Normal file
@ -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')),
|
||||
]
|
||||
6
accounts/views.py
Normal file
6
accounts/views.py
Normal file
@ -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']
|
||||
55
cloudbuild.yaml
Normal file
55
cloudbuild.yaml
Normal file
@ -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'
|
||||
49
cloudmigrate.yaml
Normal file
49
cloudmigrate.yaml
Normal file
@ -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'
|
||||
224
devops/gcp/setup-project.sh
Normal file
224
devops/gcp/setup-project.sh
Normal file
@ -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 <project-id> [--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 <project-id> [--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!"
|
||||
615
fabfile.py
vendored
Normal file
615
fabfile.py
vendored
Normal file
@ -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!")
|
||||
|
||||
22
manage.py
Normal file
22
manage.py
Normal file
@ -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()
|
||||
26
readme.md
Normal file
26
readme.md
Normal file
@ -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
|
||||
```
|
||||
|
||||
32
requirements.txt
Normal file
32
requirements.txt
Normal file
@ -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
|
||||
0
{{ cookiecutter.project_slug }}/__init__.py
Normal file
0
{{ cookiecutter.project_slug }}/__init__.py
Normal file
16
{{ cookiecutter.project_slug }}/asgi.py
Normal file
16
{{ cookiecutter.project_slug }}/asgi.py
Normal file
@ -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()
|
||||
279
{{ cookiecutter.project_slug }}/settings/base.py
Normal file
279
{{ cookiecutter.project_slug }}/settings/base.py
Normal file
@ -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',
|
||||
}
|
||||
162
{{ cookiecutter.project_slug }}/settings/cloud_production.py
Normal file
162
{{ cookiecutter.project_slug }}/settings/cloud_production.py
Normal file
@ -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 }}")
|
||||
149
{{ cookiecutter.project_slug }}/settings/cloud_staging.py
Normal file
149
{{ cookiecutter.project_slug }}/settings/cloud_staging.py
Normal file
@ -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 }}")
|
||||
1
{{ cookiecutter.project_slug }}/settings/dev.py
Normal file
1
{{ cookiecutter.project_slug }}/settings/dev.py
Normal file
@ -0,0 +1 @@
|
||||
from .base import *
|
||||
1
{{ cookiecutter.project_slug }}/settings/production.py
Normal file
1
{{ cookiecutter.project_slug }}/settings/production.py
Normal file
@ -0,0 +1 @@
|
||||
from .base import *
|
||||
112
{{ cookiecutter.project_slug }}/tests.py
Normal file
112
{{ cookiecutter.project_slug }}/tests.py
Normal file
@ -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)
|
||||
|
||||
37
{{ cookiecutter.project_slug }}/urls.py
Normal file
37
{{ cookiecutter.project_slug }}/urls.py
Normal file
@ -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')),
|
||||
])),
|
||||
]
|
||||
16
{{ cookiecutter.project_slug }}/wsgi.py
Normal file
16
{{ cookiecutter.project_slug }}/wsgi.py
Normal file
@ -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()
|
||||
Loading…
Reference in New Issue
Block a user