├── {{ cookiecutter.project_slug }} ├── .nvmrc ├── frontend │ ├── vendors │ │ ├── .gitkeep │ │ └── images │ │ │ ├── .gitkeep │ │ │ ├── logo.png │ │ │ ├── sample.jpg │ │ │ ├── webpack.png │ │ │ └── unknown-man.png │ ├── templates │ │ ├── account │ │ │ ├── email │ │ │ │ ├── account_already_exists_subject.txt │ │ │ │ ├── email_confirmation_subject.txt │ │ │ │ ├── email_confirmation_signup_subject.txt │ │ │ │ ├── password_reset_key_subject.txt │ │ │ │ └── password_reset_key_message.txt │ │ │ ├── logout.html │ │ │ ├── password_reset_from_key_done.html │ │ │ ├── password_reset_done.html │ │ │ ├── email_confirm.html │ │ │ ├── password_reset.html │ │ │ └── password_reset_from_key.html │ │ ├── pages │ │ │ ├── home.html │ │ │ ├── landing-page.html │ │ │ ├── uses.html │ │ │ └── user-settings.html │ │ ├── components │ │ │ ├── confirm-email.html │ │ │ ├── referrer_banner.html │ │ │ ├── feedback.html │ │ │ └── messages.html │ │ ├── 404.html │ │ ├── docs │ │ │ ├── docs_page.html │ │ │ └── base_docs.html │ │ └── blog │ │ │ ├── blog_post.html │ │ │ └── blog_posts.html │ ├── src │ │ ├── styles │ │ │ ├── index.css │ │ │ └── pygments.css │ │ ├── controllers │ │ │ ├── testing_controller.js │ │ │ ├── user_settings_controller.js │ │ │ ├── referrer_banner_controller.js │ │ │ ├── toc_controller.js │ │ │ └── feedback_controller.js │ │ ├── application │ │ │ └── index.js │ │ └── utils │ │ │ └── messages.js │ ├── README.md │ └── webpack │ │ ├── webpack.config.prod.js │ │ ├── webpack.common.js │ │ ├── webpack.config.watch.js │ │ └── webpack.config.dev.js ├── apps │ ├── api │ │ ├── migrations │ │ │ └── __init__.py │ │ ├── models.py │ │ ├── tests.py │ │ ├── admin.py │ │ ├── urls.py │ │ ├── __init__.py │ │ ├── schemas.py │ │ └── auth.py │ ├── core │ │ ├── tests │ │ │ ├── __init__.py │ │ │ ├── test_views.py │ │ │ └── conftest.py │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ └── 0001_enable_extensions.py │ │ ├── templatetags │ │ │ ├── __init__.py │ │ │ └── markdown_extras.py │ │ ├── admin.py │ │ ├── model_utils.py │ │ ├── base_models.py │ │ ├── choices.py │ │ ├── urls.py │ │ ├── __init__.py │ │ ├── forms.py │ │ ├── signals.py │ │ ├── context_processors.py │ │ ├── utils.py │ │ └── tasks.py │ ├── blog │ │ ├── migrations │ │ │ └── __init__.py │ │ ├── tests.py │ │ ├── choices.py │ │ ├── admin.py │ │ ├── __init__.py │ │ ├── urls.py │ │ ├── views.py │ │ └── models.py │ ├── docs │ │ ├── migrations │ │ │ └── __init__.py │ │ ├── admin.py │ │ ├── models.py │ │ ├── tests.py │ │ ├── __init__.py │ │ ├── content │ │ │ ├── features │ │ │ │ └── example_feature.md │ │ │ ├── getting-started │ │ │ │ └── introduction.md │ │ │ └── deployment │ │ │ │ └── render-deployment.md │ │ ├── urls.py │ │ ├── AGENTS.md │ │ └── navigation.yaml │ └── pages │ │ ├── migrations │ │ └── __init__.py │ │ ├── tests.py │ │ ├── __init__.py │ │ ├── urls.py │ │ ├── context_processors.py │ │ ├── admin.py │ │ ├── models.py │ │ └── views.py ├── {{ cookiecutter.project_slug }} │ ├── __init__.py │ ├── logging_utils.py │ ├── utils.py │ ├── storages.py │ ├── asgi.py │ ├── wsgi.py │ ├── sentry_utils.py │ ├── urls.py │ ├── sitemaps.py │ └── adapters.py ├── poetry.toml ├── Dockerfile-python ├── pytest.ini ├── .browserslistrc ├── .stylelintrc.json ├── .cursor │ ├── rules │ │ ├── stimulus-general.mdc │ │ ├── ai.mdc │ │ ├── snippets.mdc │ │ ├── frontend.mdc │ │ ├── architecture.mdc │ │ ├── coding-preferences.mdc │ │ ├── backend-logging.mdc │ │ ├── django-async-tasks.mdc │ │ └── stimulus-events.mdc │ └── mcp.json ├── tailwind.config.js ├── .babelrc ├── .eslintrc ├── postcss.config.js ├── deployment │ ├── Dockerfile.server │ ├── Dockerfile.workers │ └── entrypoint.sh ├── manage.py ├── .pre-commit-config.yaml ├── Makefile ├── .github │ └── workflows │ │ ├── deploy.yml │ │ └── deploy-workers.yml ├── docker-compose-prod.yml ├── package.json ├── .gitignore ├── pyproject.toml ├── .env.example ├── docker-compose-local.yml ├── render.yaml └── README.md ├── pyproject.toml ├── cookiecutter.json ├── .cursor └── rules │ ├── cookiecutter.mdc │ └── cookiecutter-html-templates.mdc ├── hooks └── post_gen_project.py ├── README.md ├── .gitignore └── CHANGELOG.md /{{ cookiecutter.project_slug }}/.nvmrc: -------------------------------------------------------------------------------- 1 | lts/gallium 2 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/vendors/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/api/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/core/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/blog/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/core/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/core/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/docs/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/pages/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/vendors/images/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/docs/admin.py: -------------------------------------------------------------------------------- 1 | # Register your models here. 2 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/docs/models.py: -------------------------------------------------------------------------------- 1 | # Create your models here. 2 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/docs/tests.py: -------------------------------------------------------------------------------- 1 | # Create your tests here. 2 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | create = false 3 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/api/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/api/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/blog/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/pages/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/api/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/account/email/account_already_exists_subject.txt: -------------------------------------------------------------------------------- 1 | Account Already Exists 2 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/account/email/email_confirmation_subject.txt: -------------------------------------------------------------------------------- 1 | Confirm Your Email Address 2 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/account/email/email_confirmation_signup_subject.txt: -------------------------------------------------------------------------------- 1 | Welcome to {{ cookiecutter.project_name }}! 2 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/account/email/password_reset_key_subject.txt: -------------------------------------------------------------------------------- 1 | Password Reset for {{ cookiecutter.project_name }} 2 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/core/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from apps.core.models import EmailSent 4 | 5 | admin.site.register(EmailSent) 6 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/api/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from apps.api.views import api 4 | 5 | urlpatterns = [ 6 | path("", api.urls), 7 | ] 8 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/blog/choices.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class BlogPostStatus(models.TextChoices): 5 | DRAFT = "draft" 6 | PUBLISHED = "published" 7 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/src/styles/index.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | @import "./pygments.css"; 5 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/vendors/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasulkireev/django-saas-starter/HEAD/{{ cookiecutter.project_slug }}/frontend/vendors/images/logo.png -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/vendors/images/sample.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasulkireev/django-saas-starter/HEAD/{{ cookiecutter.project_slug }}/frontend/vendors/images/sample.jpg -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/vendors/images/webpack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasulkireev/django-saas-starter/HEAD/{{ cookiecutter.project_slug }}/frontend/vendors/images/webpack.png -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/vendors/images/unknown-man.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasulkireev/django-saas-starter/HEAD/{{ cookiecutter.project_slug }}/frontend/vendors/images/unknown-man.png -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/Dockerfile-python: -------------------------------------------------------------------------------- 1 | FROM python:3.13 2 | 3 | WORKDIR /app 4 | 5 | COPY requirements.txt . 6 | RUN pip install --upgrade pip && pip install --no-cache-dir -r requirements.txt 7 | 8 | COPY . . 9 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/blog/admin.py: -------------------------------------------------------------------------------- 1 | {% if cookiecutter.generate_blog == 'y' %} 2 | from django.contrib import admin 3 | 4 | from apps.blog.models import BlogPost 5 | 6 | admin.site.register(BlogPost) 7 | {% endif %} 8 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = {{ cookiecutter.project_slug }}.settings 3 | python_files = tests.py test_*.py *_tests.py 4 | filterwarnings = ignore::DeprecationWarning:pkg_resources 5 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/api/__init__.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ApiConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'apps.api' 7 | label = 'api' 8 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/blog/__init__.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class BlogConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'apps.blog' 7 | label = 'blog' 8 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/docs/__init__.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DocsConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "apps.docs" 7 | label = "docs" 8 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/pages/__init__.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class PagesConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'apps.pages' 7 | label = 'pages' 8 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/core/model_utils.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | 4 | def generate_random_key(): 5 | characters = string.ascii_letters + string.digits 6 | return ''.join(random.choice(characters) for _ in range(30)) 7 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/.browserslistrc: -------------------------------------------------------------------------------- 1 | [production staging] 2 | >5% 3 | last 2 versions 4 | not ie > 0 5 | not ie_mob > 0 6 | Firefox ESR 7 | 8 | [development] 9 | last 1 chrome version 10 | last 1 firefox version 11 | last 1 edge version 12 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard-scss", 3 | "rules": { 4 | "at-rule-no-unknown": null, 5 | "scss/at-rule-no-unknown": true, 6 | "scss/at-import-partial-extension": null 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/src/controllers/testing_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus"; 2 | 3 | export default class extends Controller { 4 | connect() { 5 | console.log('tesing controller loaded'); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/blog/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from apps.blog import views 4 | 5 | urlpatterns = [ 6 | path("", views.BlogView.as_view(), name="blog_posts"), 7 | path("", views.BlogPostView.as_view(), name="blog_post"), 8 | ] 9 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/logging_utils.py: -------------------------------------------------------------------------------- 1 | {% if cookiecutter.use_logfire == 'y' -%} 2 | import logfire 3 | 4 | 5 | def scrubbing_callback(m: logfire.ScrubMatch): 6 | if m.path == ("attributes", "cookies"): 7 | return m.value 8 | {% endif %} 9 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/.cursor/rules/stimulus-general.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: *.js 4 | alwaysApply: false 5 | --- 6 | - Add semicolons at the end of statements 7 | - Use double quotes instead of single quotes 8 | - no need to register new controllers in index.js. New controller are auto attached. 9 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/.cursor/mcp.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "filesystem": { 4 | "command": "npx", 5 | "args": [ 6 | "-y", 7 | "@modelcontextprotocol/server-filesystem", 8 | "/Users/rasul/code/{{ cookiecutter.project_slug }}" 9 | ] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/.cursor/rules/ai.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | - Don't add useless comments explaining what the code does. Only do so for the copmlex cases where it is not clear what the purpose of the next few lines. Never explain a single line of code. 7 | - Don't add docstrings 8 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | './frontend/templates/**/*.html', 4 | './core/**/*.py', 5 | ], 6 | theme: { 7 | extend: {}, 8 | }, 9 | plugins: [ 10 | require('@tailwindcss/typography'), 11 | require('@tailwindcss/forms'), 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "useBuiltIns": "usage", 7 | "corejs": "3.0.0" 8 | } 9 | ] 10 | ], 11 | "plugins": [ 12 | "@babel/plugin-syntax-dynamic-import", 13 | "@babel/plugin-proposal-class-properties" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/core/base_models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.db import models 4 | 5 | 6 | class BaseModel(models.Model): 7 | uuid = models.UUIDField(default=uuid.uuid4, editable=False) 8 | created_at = models.DateTimeField(auto_now_add=True) 9 | updated_at = models.DateTimeField(auto_now=True) 10 | 11 | class Meta: 12 | abstract = True 13 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@babel/eslint-parser", 3 | "extends": [ 4 | "eslint:recommended" 5 | ], 6 | "env": { 7 | "browser": true, 8 | "node": true 9 | }, 10 | "parserOptions": { 11 | "ecmaVersion": 8, 12 | "sourceType": "module", 13 | "requireConfigFile": false 14 | }, 15 | "rules": { 16 | "semi": 2 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'postcss-import': {}, 4 | 'tailwindcss/nesting': 'postcss-nesting', 5 | tailwindcss: {}, 6 | autoprefixer: {}, 7 | 'postcss-preset-env': { 8 | features: { 'nesting-rules': false }, 9 | }, 10 | ...(process.env.NODE_ENV === 'production' ? { cssnano: {} } : {}) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "cookiecutter-django-bwd" 3 | version = "0.1.0" 4 | description = "Minimalistic SaaS Template for Django Projects" 5 | authors = ["Rasul Kireev "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.13" 9 | 10 | [tool.poetry.dev-dependencies] 11 | 12 | [build-system] 13 | requires = ["poetry-core>=1.0.0"] 14 | build-backend = "poetry.core.masonry.api" 15 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/utils.py: -------------------------------------------------------------------------------- 1 | import structlog 2 | 3 | 4 | def get_{{ cookiecutter.project_slug }}_logger(name): 5 | """This will add a `{{ cookiecutter.project_slug }}` prefix to logger for easy configuration.""" 6 | 7 | return structlog.get_logger( 8 | f"{{ cookiecutter.project_slug }}.{name}", 9 | project="{{ cookiecutter.project_slug }}" 10 | ) 11 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/deployment/Dockerfile.server: -------------------------------------------------------------------------------- 1 | FROM node:16 AS build 2 | 3 | WORKDIR /app 4 | COPY . . 5 | 6 | RUN npm install 7 | RUN npm run build 8 | 9 | FROM python:3.13 10 | 11 | WORKDIR /app 12 | 13 | COPY . . 14 | COPY --from=build /app/frontend/build/ ./frontend/build/ 15 | 16 | RUN pip install --no-cache-dir -r requirements.txt 17 | 18 | EXPOSE 80 19 | 20 | CMD ["deployment/entrypoint.sh", "-s"] 21 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/deployment/Dockerfile.workers: -------------------------------------------------------------------------------- 1 | FROM node:16 AS build 2 | 3 | WORKDIR /app 4 | COPY . . 5 | 6 | RUN npm install 7 | RUN npm run build 8 | 9 | FROM python:3.13 10 | 11 | WORKDIR /app 12 | 13 | COPY . . 14 | COPY --from=build /app/frontend/build/ ./frontend/build/ 15 | 16 | RUN pip install --no-cache-dir -r requirements.txt 17 | 18 | EXPOSE 80 19 | 20 | CMD ["deployment/entrypoint.sh", "-w"] 21 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/storages.py: -------------------------------------------------------------------------------- 1 | from storages.backends.s3boto3 import S3Boto3Storage 2 | 3 | class CustomS3Boto3Storage(S3Boto3Storage): 4 | def url(self, name, parameters=None, expire=None): 5 | url = super().url(name, parameters, expire) 6 | if url.startswith("http://minio:9000"): 7 | return url.replace("http://minio:9000", "http://localhost:9000") 8 | return url 9 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/blog/views.py: -------------------------------------------------------------------------------- 1 | from django.views.generic import ListView, DetailView 2 | 3 | from apps.blog.models import BlogPost 4 | 5 | 6 | class BlogView(ListView): 7 | model = BlogPost 8 | template_name = "blog/blog_posts.html" 9 | context_object_name = "blog_posts" 10 | 11 | 12 | class BlogPostView(DetailView): 13 | model = BlogPost 14 | template_name = "blog/blog_post.html" 15 | context_object_name = "blog_post" 16 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/docs/content/features/example_feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Example Feature 3 | description: Learn how to use feature X. 4 | keywords: {{ cookiecutter.project_name }} 5 | author: {{ cookiecutter.author_name }} 6 | --- 7 | 8 | Example feature 9 | 10 | ## Example 1 11 | 12 | This is a code block: 13 | 14 | ```python 15 | def hello_world(): 16 | print("Hello World") 17 | ``` 18 | 19 | ## Conclusion 20 | 21 | > Quote 22 | 23 | Conclusions 24 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/.cursor/rules/snippets.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: snippets/**/*.py 4 | alwaysApply: false 5 | --- 6 | This script will be ran in an ipython shell that is executed like so: `python ./manage.py shell_plus --ipython`, this means: 7 | 8 | - Run code linearly, no need to abstract away things needlessly 9 | - Don't run via `if __name__ == ...`. just write normally 10 | - No need for django setup stuff 11 | - I'm going to copy/paste the code into the shell 12 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/docs/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from django.views.generic import RedirectView 3 | 4 | 5 | from apps.docs.views import docs_page_view 6 | 7 | urlpatterns = [ 8 | path("", RedirectView.as_view( 9 | pattern_name='docs_page', 10 | permanent=False, 11 | kwargs={'category': 'getting-started', 'page': 'introduction'} 12 | ), name="docs_home"), 13 | path("//", docs_page_view, name="docs_page"), 14 | ] 15 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/account/email/password_reset_key_message.txt: -------------------------------------------------------------------------------- 1 | Hello, 2 | 3 | You're receiving this email because you requested a password reset for your account at {% raw %}{{ site_name }}{% endraw %}. 4 | 5 | Please click the link below to reset your password: 6 | 7 | {% raw %}{{ password_reset_url }}{% endraw %} 8 | 9 | If you didn't request this password reset, you can safely ignore this email. 10 | 11 | Thanks, 12 | The {% raw %}{{ site_name }}{% endraw %} Team 13 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/pages/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from apps.pages import views 4 | 5 | urlpatterns = [ 6 | path("", views.LandingPageView.as_view(), name="landing"), 7 | path("privacy-policy", views.PrivacyPolicyView.as_view(), name="privacy_policy"), 8 | path("terms-of-service", views.TermsOfServiceView.as_view(), name="terms_of_service"), 9 | {% if cookiecutter.use_stripe == 'y' -%} 10 | path("pricing", views.PricingView.as_view(), name="pricing"), 11 | {% endif %} 12 | ] 13 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/core/tests/test_views.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.urls import reverse 3 | 4 | @pytest.mark.django_db 5 | class TestHomeView: 6 | def test_home_view_status_code(self, client): 7 | url = reverse('home') 8 | response = client.get(url) 9 | assert response.status_code == 200 10 | 11 | def test_home_view_uses_correct_template(self, client): 12 | url = reverse('home') 13 | response = client.get(url) 14 | assert 'pages/home.html' in [t.name for t in response.templates] 15 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for {{ cookiecutter.project_slug }} project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "{{ cookiecutter.project_slug }}.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for {{ cookiecutter.project_slug }} project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "{{ cookiecutter.project_slug }}.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/core/templatetags/markdown_extras.py: -------------------------------------------------------------------------------- 1 | import markdown as md 2 | from django import template 3 | from django.template.defaultfilters import stringfilter 4 | from django.utils.safestring import mark_safe 5 | 6 | register = template.Library() 7 | 8 | 9 | @register.filter 10 | @stringfilter 11 | def markdown(value): 12 | md_instance = md.Markdown(extensions=["tables"]) 13 | 14 | html = md_instance.convert(value) 15 | 16 | return mark_safe(html) 17 | 18 | 19 | @register.filter 20 | @stringfilter 21 | def replace_quotes(value): 22 | return value.replace('"', "'") 23 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/core/choices.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | class ProfileStates(models.TextChoices): 4 | STRANGER = "stranger" 5 | SIGNED_UP = "signed_up" 6 | TRIAL_STARTED = "trial_started" 7 | TRIAL_ENDED = "trial_ended" 8 | SUBSCRIBED = "subscribed" 9 | CANCELLED = "cancelled" 10 | CHURNED = "churned" 11 | ACCOUNT_DELETED = "account_deleted" 12 | 13 | 14 | class EmailType(models.TextChoices): 15 | EMAIL_CONFIRMATION = "EMAIL_CONFIRMATION", "Email Confirmation" 16 | WELCOME = "WELCOME", "Welcome" 17 | FEEDBACK_NOTIFICATION = "FEEDBACK_NOTIFICATION", "Feedback Notification" 18 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/src/application/index.js: -------------------------------------------------------------------------------- 1 | import "../styles/index.css"; 2 | 3 | import { Application } from "@hotwired/stimulus"; 4 | import { definitionsFromContext } from "@hotwired/stimulus-webpack-helpers"; 5 | 6 | import Dropdown from '@stimulus-components/dropdown'; 7 | import RevealController from '@stimulus-components/reveal'; 8 | 9 | const application = Application.start(); 10 | 11 | const context = require.context("../controllers", true, /\.js$/); 12 | application.load(definitionsFromContext(context)); 13 | 14 | application.register('dropdown', Dropdown); 15 | application.register('reveal', RevealController); 16 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/.cursor/rules/frontend.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: *.js,*.html 4 | alwaysApply: false 5 | --- 6 | - Prefer Stimulus JS for adding interactivity to Django templates instead of raw script elements 7 | - Use Stimulus controllers to encapsulate JavaScript behavior and keep it separate from HTML structure 8 | - Leverage Stimulus data attributes to connect HTML elements with JavaScript functionality 9 | - Utilize Stimulus targets to reference specific elements within a controller 10 | - Employ Stimulus actions to handle user interactions and events 11 | - New controllers shold be created in `frontend/src/controllers` directory 12 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/core/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.conf import settings 3 | from django.core.management import call_command 4 | from django.contrib.auth import get_user_model 5 | 6 | User = get_user_model() 7 | 8 | 9 | def pytest_configure(config): 10 | settings.STORAGES['staticfiles']['BACKEND'] = ( 11 | 'django.contrib.staticfiles.storage.StaticFilesStorage' 12 | ) 13 | 14 | 15 | @pytest.fixture 16 | def user(db): 17 | """Create a test user""" 18 | return User.objects.create_user( 19 | username='testuser', 20 | email='testuser@example.com', 21 | password='testpass123' 22 | ) 23 | -------------------------------------------------------------------------------- /cookiecutter.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_name": "My Awesome Project", 3 | "project_slug": "{{ cookiecutter.project_name.lower()|replace(' ', '_')|replace('-', '_')|replace('.', '_')|trim() }}", 4 | "repo_url": "https://github.com/cookiecutter/cookiecutter", 5 | "project_description": "This project will help you be the best in the world", 6 | "author_name": "Jane Doe", 7 | "author_email": "janedoe@example.com", 8 | "project_main_color": "green", 9 | "use_posthog": "y", 10 | "use_buttondown": "y", 11 | "use_s3": "y", 12 | "use_stripe": "y", 13 | "use_sentry": "y", 14 | "generate_blog": "y", 15 | "generate_docs": "y", 16 | "use_mjml": "y", 17 | "use_ai": "y", 18 | "use_logfire": "y", 19 | "use_healthchecks": "y" 20 | } 21 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "{{ cookiecutter.project_slug }}.settings") # noqa: E501 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/src/controllers/user_settings_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus"; 2 | 3 | export default class extends Controller { 4 | connect() { 5 | this.fetchAndStoreSettings(); 6 | } 7 | 8 | async fetchAndStoreSettings() { 9 | try { 10 | const response = await fetch(`/api/user/settings`); 11 | if (!response.ok) { 12 | // This is a background task, so just log errors, don't alert the user. 13 | console.error("Failed to fetch user settings in the background."); 14 | return; 15 | } 16 | const data = await response.json(); 17 | 18 | localStorage.setItem(`userSettings`, JSON.stringify(data)); 19 | 20 | } catch (error) { 21 | console.error("Error fetching user settings:", error); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/.cursor/rules/architecture.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: Overall project architecture 3 | alwaysApply: true 4 | --- 5 | 6 | - This is a Django project built on Python 3.13. 7 | - User authentication uses `django-allauth`. 8 | - The front end is mostly standard Django views and templates. 9 | - The front end also uses Stimulusjs for dynamic user interfaces and interactions. 10 | - StimulusJS controllers are built using JS and communicate with Django via a REST API. 11 | - JavaScript files are kept in the `frontend/src/controllers` folder and built by webpack. 12 | - APIs use django-ninja 13 | - The front end uses Tailwind (Version 3). 14 | - The main database is Postgres. 15 | - Django-q2 is used for background jobs and scheduled tasks. 16 | - Redis is used as the default cache, and the message broker for django-q2. 17 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: .*migrations\/.* 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v5.0.0 5 | hooks: 6 | - id: check-yaml 7 | - id: end-of-file-fixer 8 | - id: trailing-whitespace 9 | 10 | - repo: https://github.com/astral-sh/ruff-pre-commit 11 | rev: v0.12.0 12 | hooks: 13 | - id: ruff-check 14 | args: [ --fix ] 15 | - id: ruff-format 16 | 17 | - repo: https://github.com/djlint/djLint 18 | rev: v1.36.4 19 | hooks: 20 | - id: djlint-django 21 | 22 | - repo: https://github.com/python-poetry/poetry-plugin-export 23 | rev: '1.9.0' 24 | hooks: 25 | - id: poetry-export 26 | args: [ 27 | "-f", "requirements.txt", 28 | "-o", "requirements.txt", 29 | "--without-hashes" 30 | ] 31 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/README.md: -------------------------------------------------------------------------------- 1 | # README 2 | 3 | This project was created with [python-webpack-boilerplate](https://github.com/AccordBox/python-webpack-boilerplate) 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm run start` 10 | 11 | `npm run start` will launch a server process, which makes `live reloading` possible. 12 | 13 | If you change JS or SCSS files, the web page would auto refresh after the change. Now the server is working on port 9091 by default, but you can change it in `webpack/webpack.config.dev.js` 14 | 15 | ### `npm run watch` 16 | 17 | run webpack in `watch` mode. 18 | 19 | ### `npm run build` 20 | 21 | [production mode](https://webpack.js.org/guides/production/), Webpack would focus on minified bundles, lighter weight source maps, and optimized assets to improve load time. 22 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/core/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from apps.core import views 4 | 5 | urlpatterns = [ 6 | # App pages 7 | path("home", views.HomeView.as_view(), name="home"), 8 | path("settings", views.UserSettingsView.as_view(), name="settings"), 9 | path("admin-panel", views.AdminPanelView.as_view(), name="admin_panel"), 10 | # Utils 11 | path("resend-confirmation/", views.resend_confirmation_email, name="resend_confirmation"), 12 | {% if cookiecutter.use_stripe == 'y' -%} 13 | # Payments 14 | path( 15 | "create-checkout-session///", 16 | views.create_checkout_session, 17 | name="user_upgrade_checkout_session", 18 | ), 19 | path( 20 | "create-customer-portal/", 21 | views.create_customer_portal_session, 22 | name="create_customer_portal_session" 23 | ), 24 | {% endif %} 25 | ] 26 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/sentry_utils.py: -------------------------------------------------------------------------------- 1 | from logging import LogRecord 2 | 3 | from sentry_sdk.integrations.logging import LoggingIntegration 4 | 5 | _IGNORED_LOGGERS = {"ask_hn_digest"} 6 | 7 | class CustomLoggingIntegration(LoggingIntegration): 8 | def _handle_record(self, record: LogRecord) -> None: 9 | # This match upper logger names, e.g. "celery" will match "celery.worker" 10 | # or "celery.worker.job" 11 | if record.name in _IGNORED_LOGGERS or record.name.split(".")[0] in _IGNORED_LOGGERS: 12 | return 13 | super()._handle_record(record) 14 | 15 | 16 | def before_send(event, hint): 17 | if "exc_info" in hint: 18 | exc_type, exc_value, tb = hint["exc_info"] 19 | 20 | if isinstance(exc_value, SystemExit): # group all SystemExits together 21 | event["fingerprint"] = ["system-exit"] 22 | return event 23 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/blog/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.urls import reverse 3 | 4 | from apps.core.base_models import BaseModel 5 | from apps.blog.choices import BlogPostStatus 6 | 7 | 8 | class BlogPost(BaseModel): 9 | title = models.CharField(max_length=250) 10 | description = models.TextField(blank=True) 11 | slug = models.SlugField(max_length=250) 12 | tags = models.TextField() 13 | content = models.TextField() 14 | icon = models.ImageField(upload_to="blog_post_icons/", blank=True) 15 | image = models.ImageField(upload_to="blog_post_images/", blank=True) 16 | status = models.CharField( 17 | max_length=10, 18 | choices=BlogPostStatus.choices, 19 | default=BlogPostStatus.DRAFT, 20 | ) 21 | 22 | def __str__(self): 23 | return self.title 24 | 25 | def get_absolute_url(self): 26 | return reverse("blog_post", kwargs={"slug": self.slug}) 27 | -------------------------------------------------------------------------------- /.cursor/rules/cookiecutter.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | alwaysApply: true 3 | description: Instructionon how an agent should behave in this repo. Always use! 4 | --- 5 | 6 | # Cookiecutter Django Boilerplate - Cursor Rules 7 | 8 | ## Core Principle 9 | This is a TEMPLATE repository. Never hardcode project-specific values. Always use Cookiecutter variables. 10 | 11 | ## Available Variables 12 | - See @cookiecutter.json file to see what variable are available. 13 | 14 | ## Rules 15 | - Always Use Variables, even for file names and directories (if they requre project name for example. though this is rarely the case) 16 | - Wrap optional features with `{% if cookiecutter.feature_name == 'y' %}...{% endif %}` 17 | - Don't run any commands unless specifically asked to (especially python, poetry and git commands) 18 | - Don't add new cookiecutter template variables, ask before you think it is a good idea. 19 | 20 | ## Testing 21 | - Don't write or run test commands, unless specifically asked to. 22 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/core/migrations/0001_enable_extensions.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | class Migration(migrations.Migration): 5 | """ 6 | Initial migration to enable the pgvector and pg_stat_statements extensions using raw SQL. 7 | 8 | WARNING: This uses 'CREATE EXTENSION ...;' directly without 'IF NOT EXISTS'. 9 | It will FAIL if either extension already exists in the target database. 10 | Consider using CreateExtension operation or 'IF NOT EXISTS' for safety. 11 | """ 12 | 13 | dependencies = [ 14 | ] 15 | 16 | operations = [ 17 | migrations.RunSQL( 18 | sql='CREATE EXTENSION IF NOT EXISTS vector;', 19 | reverse_sql='DROP EXTENSION IF EXISTS vector;', 20 | ), 21 | migrations.RunSQL( 22 | sql='CREATE EXTENSION IF NOT EXISTS pg_stat_statements;', 23 | reverse_sql='DROP EXTENSION IF EXISTS pg_stat_statements;', 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/src/controllers/referrer_banner_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus"; 2 | 3 | export default class extends Controller { 4 | static values = { 5 | referrer: String, 6 | }; 7 | 8 | connect() { 9 | const dismissed_banners = this.getDismissedBanners(); 10 | if (dismissed_banners.includes(this.referrerValue)) { 11 | this.element.remove(); 12 | } 13 | } 14 | 15 | dismiss() { 16 | this.saveDismissedBanner(this.referrerValue); 17 | this.element.remove(); 18 | } 19 | 20 | getDismissedBanners() { 21 | const dismissed = localStorage.getItem("dismissedReferrerBanners"); 22 | return dismissed ? JSON.parse(dismissed) : []; 23 | } 24 | 25 | saveDismissedBanner(referrer) { 26 | const dismissed_banners = this.getDismissedBanners(); 27 | if (!dismissed_banners.includes(referrer)) { 28 | dismissed_banners.push(referrer); 29 | localStorage.setItem("dismissedReferrerBanners", JSON.stringify(dismissed_banners)); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/core/__init__.py: -------------------------------------------------------------------------------- 1 | {% if cookiecutter.use_posthog == 'y' -%} 2 | import posthog 3 | {% endif %} 4 | 5 | from django.conf import settings 6 | from django.apps import AppConfig 7 | 8 | from {{ cookiecutter.project_slug }}.utils import get_{{ cookiecutter.project_slug }}_logger 9 | 10 | logger = get_{{ cookiecutter.project_slug }}_logger(__name__) 11 | 12 | 13 | class CoreConfig(AppConfig): 14 | default_auto_field = "django.db.models.BigAutoField" 15 | name = "apps.core" 16 | label = "core" 17 | 18 | def ready(self): 19 | import apps.core.signals # noqa 20 | {% if cookiecutter.use_stripe == 'y' -%} 21 | import apps.core.webhooks # noqa 22 | {% endif %} 23 | 24 | {% if cookiecutter.use_posthog == 'y' -%} 25 | if settings.POSTHOG_API_KEY: 26 | posthog.api_key = settings.POSTHOG_API_KEY 27 | posthog.host = "https://us.i.posthog.com" 28 | 29 | if settings.ENVIRONMENT == "dev": 30 | posthog.debug = True 31 | {% endif %} 32 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/pages/home.html: -------------------------------------------------------------------------------- 1 | {{ '{% extends "base_app.html" %}' }} 2 | 3 | {{ "{% block content %}" }} 4 |
5 |
6 |
7 |

8 | Welcome to {{ cookiecutter.project_name }} 9 |

10 |

11 | This is your main app dashboard. Start building your application here. 12 |

13 |
14 |
15 |
16 | 17 | {% if cookiecutter.use_stripe == 'y' -%} 18 | {{ "{% if show_confetti %}" }} 19 | 20 | 27 | {{ "{% endif %}" }} 28 | {% endif %} 29 | 30 | {{ "{% endblock content %}" }} 31 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/api/schemas.py: -------------------------------------------------------------------------------- 1 | from ninja import Schema 2 | from typing import Optional 3 | 4 | {% if cookiecutter.generate_blog == 'y' %} 5 | from apps.blog.choices import BlogPostStatus 6 | {% endif %} 7 | 8 | 9 | class SubmitFeedbackIn(Schema): 10 | feedback: str 11 | page: str 12 | 13 | class SubmitFeedbackOut(Schema): 14 | success: bool 15 | message: str 16 | 17 | {% if cookiecutter.generate_blog == 'y' %} 18 | class BlogPostIn(Schema): 19 | title: str 20 | description: str = "" 21 | slug: str 22 | tags: str = "" 23 | content: str 24 | icon: Optional[str] = None # URL or base64 string 25 | image: Optional[str] = None # URL or base64 string 26 | status: BlogPostStatus = BlogPostStatus.DRAFT 27 | 28 | 29 | class BlogPostOut(Schema): 30 | status: str # API response status: 'success' or 'failure' 31 | message: str 32 | {% endif %} 33 | 34 | 35 | class ProfileSettingsOut(Schema): 36 | has_pro_subscription: bool 37 | 38 | 39 | class UserSettingsOut(Schema): 40 | profile: ProfileSettingsOut 41 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/Makefile: -------------------------------------------------------------------------------- 1 | serve: 2 | docker compose -f docker-compose-local.yml up -d --build 3 | docker compose -f docker-compose-local.yml logs -f backend 4 | 5 | shell: 6 | docker compose -f docker-compose-local.yml run --rm backend python ./manage.py shell_plus --ipython 7 | 8 | manage: 9 | docker compose -f docker-compose-local.yml run --rm backend python ./manage.py $(filter-out $@,$(MAKECMDGOALS)) 10 | 11 | makemigrations: 12 | docker compose -f docker-compose-local.yml run --rm backend python ./manage.py makemigrations 13 | 14 | migrate: 15 | docker compose -f docker-compose-local.yml run --rm backend python ./manage.py migrate 16 | 17 | test: 18 | docker compose -f docker-compose-local.yml run --rm backend pytest 19 | 20 | restart-worker: 21 | docker compose up -d workers --force-recreate 22 | {% if cookiecutter.use_stripe == 'y' -%} 23 | test-webhook: 24 | docker compose -f docker-compose-local.yml run --rm stripe trigger customer.subscription.created 25 | 26 | stripe-sync: 27 | docker compose -f docker-compose-local.yml run --rm backend python ./manage.py djstripe_sync_models Product Price 28 | {% endif %} 29 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/account/logout.html: -------------------------------------------------------------------------------- 1 | {{ "{% extends 'base_app.html' %}" }} 2 | {{ "{% load widget_tweaks %}" }} 3 | 4 | {{ "{% block content %}" }} 5 |
6 |

Please confirm Sign Out

7 |
8 | {{ "{% csrf_token %}" }} 9 | 10 | {{ "{% if redirect_field_value %}" }} 11 | 12 | {{ "{% endif %}" }} 13 | 14 | 18 |
19 |
20 | {{ "{% endblock content %}" }} 21 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/deployment/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Default to server command if no arguments provided 4 | if [ $# -eq 0 ]; then 5 | echo "No arguments provided. Defaulting to running the server." 6 | server=true 7 | else 8 | server=false 9 | fi 10 | 11 | # All commands before the conditional ones 12 | export PROJECT_NAME={{ cookiecutter.project_slug }} 13 | 14 | export DJANGO_SETTINGS_MODULE="{{ cookiecutter.project_slug }}.settings" 15 | 16 | while getopts ":sw" option; do 17 | case "${option}" in 18 | s) # Run server 19 | server=true 20 | ;; 21 | w) # Run worker 22 | server=false 23 | ;; 24 | *) # Invalid option 25 | echo "Invalid option: -$OPTARG" >&2 26 | ;; 27 | esac 28 | done 29 | shift $((OPTIND - 1)) 30 | 31 | # If no valid option provided, default to server 32 | if [ "$server" = true ]; then 33 | python manage.py collectstatic --noinput 34 | python manage.py migrate 35 | gunicorn ${PROJECT_NAME}.wsgi:application --bind 0.0.0.0:80 --workers 3 --threads 2 --reload 36 | else 37 | python manage.py qcluster 38 | fi 39 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/webpack/webpack.config.prod.js: -------------------------------------------------------------------------------- 1 | const Webpack = require("webpack"); 2 | const { merge } = require("webpack-merge"); 3 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 4 | const common = require("./webpack.common.js"); 5 | 6 | module.exports = merge(common, { 7 | mode: "production", 8 | devtool: "source-map", 9 | bail: true, 10 | output: { 11 | filename: "js/[name].[chunkhash:8].js", 12 | chunkFilename: "js/[name].[chunkhash:8].chunk.js", 13 | }, 14 | plugins: [ 15 | new Webpack.DefinePlugin({ 16 | "process.env.NODE_ENV": JSON.stringify("production"), 17 | }), 18 | new MiniCssExtractPlugin({ 19 | filename: "css/[name].[contenthash].css", 20 | chunkFilename: "css/[id].[contenthash].css", 21 | }), 22 | ], 23 | module: { 24 | rules: [ 25 | { 26 | test: /\.js$/, 27 | exclude: /node_modules/, 28 | use: "babel-loader", 29 | }, 30 | { 31 | test: /\.s?css/i, 32 | use: [ 33 | MiniCssExtractPlugin.loader, 34 | "css-loader", 35 | "postcss-loader", 36 | "sass-loader", 37 | ], 38 | }, 39 | ], 40 | }, 41 | }); 42 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/.cursor/rules/coding-preferences.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: General preferences on coding style and conventions 3 | alwaysApply: true 4 | --- 5 | ## General Coding Preferences 6 | 7 | - Always prefer simple solutions. 8 | - Avoid duplication of code whenever possible, which means checking for other areas of the codebase that might already have similar code and functionality. 9 | - You are careful to only make changes that are requested or you are confident are well understood and related to the change being requested. 10 | - When fixing an issue or bug, do not introduce a new pattern or technology without first exhausting all options for the existing implementation. And if you finally do this, make sure to remove the old implementation afterwards so we don’t have duplicate logic. 11 | - Keep the codebase clean and organized. 12 | - Avoid writing scripts in files if possible, especially if the script is likely only to be run once. 13 | - Try to avoid having files over 200-300 lines of code. Refactor at that point. 14 | - Don't ever add mock data to functions. Only add mocks to tests or utilities that are only used by tests. 15 | - Always think about what other areas of code might be affected by any changes made. 16 | - Never overwrite my .env file without first asking and confirming. 17 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Prod Server 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | env: 9 | PROJECT_NAME: {{ cookiecutter.project_slug }} 10 | 11 | jobs: 12 | build-and-deploy: 13 | name: Deploy 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Docker Buildx 20 | uses: docker/setup-buildx-action@v3 21 | 22 | - name: Login to Docker Hub 23 | uses: docker/login-action@v3 24 | with: 25 | registry: ghcr.io 26 | username: ${{ "{{ github.repository_owner }}" }} 27 | password: ${{ "{{ secrets.REGISTRY_TOKEN }}" }} 28 | 29 | - name: Build and push 30 | uses: docker/build-push-action@v6 31 | with: 32 | context: . 33 | push: true 34 | file: deployment/Dockerfile.server 35 | tags: ghcr.io/${{ "{{ github.repository }}" }} 36 | 37 | - name: Deploy to CapRover 38 | uses: caprover/deploy-from-github@main 39 | with: 40 | server: ${{ "{{ secrets.CAPROVER_SERVER }}" }} 41 | app: ${{ "{{ env.PROJECT_NAME }}" }} 42 | token: ${{ "{{ secrets.APP_TOKEN }}" }} 43 | image: ghcr.io/${{ "{{ github.repository }}" }} 44 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/docker-compose-prod.yml: -------------------------------------------------------------------------------- 1 | # docker-compose -f docker-compose-prod.yml -p "{{ cookiecutter.project_slug }}" up --detach --remove-orphans || true 2 | 3 | services: 4 | db: 5 | image: rasulkireev/custom-postgres:17 6 | volumes: 7 | - postgres_data:/var/lib/postgresql/data 8 | healthcheck: 9 | test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] 10 | interval: 5s 11 | timeout: 5s 12 | retries: 5 13 | env_file: 14 | - .env 15 | 16 | redis: 17 | image: redis:7-alpine 18 | command: redis-server --requirepass ${REDIS_PASSWORD} 19 | volumes: 20 | - redis_data:/data 21 | env_file: 22 | - .env 23 | 24 | backend: 25 | image: ghcr.io/rasulkireev/{{ cookiecutter.project_slug }}:latest 26 | working_dir: /app 27 | ports: 28 | - "8000:80" 29 | depends_on: 30 | db: 31 | condition: service_healthy 32 | redis: 33 | condition: service_started 34 | env_file: 35 | - .env 36 | 37 | workers: 38 | image: ghcr.io/rasulkireev/{{ cookiecutter.project_slug }}-workers:latest 39 | working_dir: /app 40 | depends_on: 41 | db: 42 | condition: service_healthy 43 | redis: 44 | condition: service_started 45 | env_file: 46 | - .env 47 | 48 | volumes: 49 | postgres_data: 50 | redis_data: 51 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/.github/workflows/deploy-workers.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Prod Workers 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | env: 9 | PROJECT_NAME: {{ cookiecutter.project_slug }} 10 | 11 | jobs: 12 | build-and-deploy: 13 | name: Deploy 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Docker Buildx 20 | uses: docker/setup-buildx-action@v3 21 | 22 | - name: Login to Docker Hub 23 | uses: docker/login-action@v3 24 | with: 25 | registry: ghcr.io 26 | username: ${{ "{{ github.repository_owner }}" }} 27 | password: ${{ "{{ secrets.REGISTRY_TOKEN }}" }} 28 | 29 | - name: Build and push 30 | uses: docker/build-push-action@v6 31 | with: 32 | context: . 33 | push: true 34 | file: deployment/Dockerfile.workers 35 | tags: ghcr.io/${{ "{{ github.repository }}-workers" }} 36 | 37 | - name: Deploy to CapRover 38 | uses: caprover/deploy-from-github@main 39 | with: 40 | server: ${{ "{{ secrets.CAPROVER_SERVER }}" }} 41 | app: ${{ "{{ env.PROJECT_NAME }}-workers" }} 42 | token: ${{ "{{ secrets.WORKERS_APP_TOKEN }}" }} 43 | image: ghcr.io/${{ "{{ github.repository }}-workers" }} 44 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/docs/content/getting-started/introduction.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Getting Started with {{ cookiecutter.project_name }} 3 | description: Learn how to get started with {{ cookiecutter.project_name }}, {{ cookiecutter.project_description }} 4 | keywords: {{ cookiecutter.project_name }}, getting started, documentation 5 | author: {{ cookiecutter.author_name }} 6 | --- 7 | 8 | Welcome to {{ cookiecutter.project_name }}! This guide will help you get started with your new Django SaaS application. 9 | 10 | ## What is {{ cookiecutter.project_name }}? 11 | 12 | {{ cookiecutter.project_name }} is built on a modern Django SaaS starter template that includes: 13 | 14 | - User authentication and profile management 15 | - {% if cookiecutter.use_stripe == 'y' -%}Subscription billing with Stripe{% endif %} 16 | - {% if cookiecutter.generate_blog == 'y' -%}Built-in blog system with markdown support{% endif %} 17 | - {% if cookiecutter.use_posthog == 'y' -%}Product analytics with PostHog{% endif %} 18 | - {% if cookiecutter.use_s3 == 'y' -%}Cloud storage with S3{% endif %} 19 | - Responsive design with Tailwind CSS 20 | - API endpoints with Django Ninja 21 | - {% if cookiecutter.use_sentry == 'y' -%}Error tracking with Sentry{% endif %} 22 | 23 | ## Next Steps 24 | 25 | Ready to [get started](/docs/getting-started/quickstart)? Sign up for an account and explore the features! 26 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/docs/AGENTS.md: -------------------------------------------------------------------------------- 1 | # Documentation Writing Guidelines for AI Agents 2 | 3 | ## Purpose 4 | 5 | These guidelines ensure documentation is user-friendly, clear, and actionable for {{ cookiecutter.project_name }} users. 6 | 7 | ## Core Principles 8 | 9 | ### Write for Users, Not Developers 10 | - Focus on **what users can do** with the feature, not how it's implemented 11 | - Use plain language and avoid technical jargon 12 | - Explain business value before technical details 13 | - Assume users have no prior knowledge of the system 14 | 15 | ### Structure Content for Scanning 16 | - Start with the most important information 17 | - Use clear headings that describe what users will learn 18 | - Break content into short paragraphs (2-3 sentences max) 19 | - Use bullet points for lists of features or steps 20 | - Include visual breaks between sections 21 | 22 | ### Be Action-Oriented 23 | - Start with verbs: "Create", "Configure", "Analyze" 24 | - Provide step-by-step instructions with numbered lists 25 | - Include expected outcomes for each action 26 | - Show what success looks like 27 | 28 | ## Summary 29 | 30 | Write documentation that: 31 | - Helps users accomplish their goals 32 | - Is easy to scan and navigate 33 | - Uses clear, friendly language 34 | - Includes practical examples 35 | - Keeps users informed about next steps 36 | 37 | Good documentation turns confused users into confident power users. 38 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/components/confirm-email.html: -------------------------------------------------------------------------------- 1 | {{ "{% if not email_verified %}" }} 2 |
3 |
4 |
5 | 6 | 9 |
10 |
11 |

Attention

12 |
13 |
14 | {{ "{% csrf_token %}" }} 15 |

Your email is not yet confirmed. This will limit the available functionality.

16 |

You should have gotten a link to confirm in your email. If you haven't received it, 17 | 18 |

19 |

20 |
21 |
22 |
23 |
24 | {{ "{% endif %}" }} 25 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/pages/context_processors.py: -------------------------------------------------------------------------------- 1 | def referrer_banner(request): 2 | """ 3 | Adds referrer banner to context. Priority order: 4 | 1. Exact match on ref or utm_source parameter (e.g., ProductHunt) 5 | 2. Black Friday banner as fallback (if it exists and is active) 6 | Only displays one banner at most. 7 | """ 8 | from apps.pages.models import ReferrerBanner 9 | 10 | referrer_code = request.GET.get("ref") or request.GET.get("utm_source") 11 | 12 | if referrer_code: 13 | try: 14 | banner = ReferrerBanner.objects.get(referrer=referrer_code) 15 | if banner.should_display: 16 | return {"referrer_banner": banner} 17 | except ReferrerBanner.DoesNotExist: 18 | pass 19 | 20 | try: 21 | black_friday_banner = ReferrerBanner.objects.get( 22 | referrer_printable_name__icontains="Black Friday" 23 | ) 24 | if black_friday_banner.should_display: 25 | return {"referrer_banner": black_friday_banner} 26 | except ReferrerBanner.DoesNotExist: 27 | pass 28 | except ReferrerBanner.MultipleObjectsReturned: 29 | black_friday_banner = ( 30 | ReferrerBanner.objects.filter(referrer_printable_name__icontains="Black Friday") 31 | .filter(is_active=True) 32 | .first() 33 | ) 34 | if black_friday_banner and black_friday_banner.should_display: 35 | return {"referrer_banner": black_friday_banner} 36 | 37 | return {} 38 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/docs/navigation.yaml: -------------------------------------------------------------------------------- 1 | # Documentation navigation configuration 2 | # 3 | # Define the order of categories and pages in the sidebar navigation. 4 | # If a category or page is not listed here, it will be added alphabetically 5 | # at the end of the respective section. 6 | # 7 | # Configuration format: 8 | # - Use category directory names as keys (e.g., 'getting-started', not 'Getting Started') 9 | # - List page file names without .md extension (e.g., 'introduction', not 'introduction.md') 10 | # - Order matters: categories and pages appear in the order listed 11 | # 12 | # How it works: 13 | # 1. Categories listed here appear first, in the order specified 14 | # 2. Categories not listed appear after, in alphabetical order 15 | # 3. Within each category, pages listed appear first, in the order specified 16 | # 4. Pages not listed appear after, in alphabetical order 17 | # 18 | # Example: 19 | # navigation: 20 | # getting-started: 21 | # - introduction 22 | # - quickstart 23 | # api: 24 | # - overview 25 | # - authentication 26 | # 27 | # Result: Getting Started section first, API section second, any other sections alphabetically after 28 | 29 | navigation: 30 | getting-started: 31 | - introduction 32 | # Any pages not listed here will appear after these, in alphabetical order 33 | 34 | features: 35 | - example-feature 36 | 37 | deployment: 38 | - render-deployment 39 | - docker-compose-deployment 40 | 41 | # Any categories not listed here will appear after these, in alphabetical order 42 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/core/forms.py: -------------------------------------------------------------------------------- 1 | from allauth.account.forms import LoginForm, SignupForm 2 | from django import forms 3 | 4 | from apps.core.models import Profile 5 | from apps.core.utils import DivErrorList 6 | 7 | 8 | class CustomSignUpForm(SignupForm): 9 | def __init__(self, *args, **kwargs): 10 | super().__init__(*args, **kwargs) 11 | self.error_class = DivErrorList 12 | 13 | 14 | class CustomLoginForm(LoginForm): 15 | def __init__(self, *args, **kwargs): 16 | super().__init__(*args, **kwargs) 17 | self.error_class = DivErrorList 18 | 19 | 20 | class ProfileUpdateForm(forms.ModelForm): 21 | first_name = forms.CharField(max_length=30) 22 | last_name = forms.CharField(max_length=30) 23 | email = forms.EmailField() 24 | 25 | class Meta: 26 | model = Profile 27 | fields = [] 28 | 29 | def __init__(self, *args, **kwargs): 30 | super().__init__(*args, **kwargs) 31 | if self.instance and self.instance.user: 32 | self.fields["first_name"].initial = self.instance.user.first_name 33 | self.fields["last_name"].initial = self.instance.user.last_name 34 | self.fields["email"].initial = self.instance.user.email 35 | 36 | def save(self, commit=True): 37 | profile = super().save(commit=False) 38 | user = profile.user 39 | user.first_name = self.cleaned_data["first_name"] 40 | user.last_name = self.cleaned_data["last_name"] 41 | user.email = self.cleaned_data["email"] 42 | if commit: 43 | user.save() 44 | profile.save() 45 | return profile 46 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/account/password_reset_from_key_done.html: -------------------------------------------------------------------------------- 1 | {{ "{% extends 'base_landing.html' %}" }} 2 | 3 | {{ "{% block content %}" }} 4 |
5 |
6 |
7 |
8 | 9 | 10 | 11 |
12 |

13 | Password changed successfully 14 |

15 |

16 | Your password has been changed successfully. You can now sign in with your new password. 17 |

18 | 19 | 25 |
26 |
27 |
28 | {{ "{% endblock content %}" }} 29 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/webpack/webpack.common.js: -------------------------------------------------------------------------------- 1 | const glob = require("glob"); 2 | const Path = require("path"); 3 | const { CleanWebpackPlugin } = require("clean-webpack-plugin"); 4 | const CopyWebpackPlugin = require("copy-webpack-plugin"); 5 | const WebpackAssetsManifest = require("webpack-assets-manifest"); 6 | 7 | const getEntryObject = () => { 8 | const entries = {}; 9 | glob.sync(Path.join(__dirname, "../src/application/*.js")).forEach((path) => { 10 | const name = Path.basename(path, ".js"); 11 | entries[name] = path; 12 | }); 13 | return entries; 14 | }; 15 | 16 | module.exports = { 17 | entry: getEntryObject(), 18 | output: { 19 | path: Path.join(__dirname, "../build"), 20 | filename: "js/[name].js", 21 | publicPath: "/static/", 22 | assetModuleFilename: "[path][name][ext]", 23 | }, 24 | optimization: { 25 | splitChunks: { 26 | chunks: "all", 27 | }, 28 | 29 | runtimeChunk: "single", 30 | }, 31 | plugins: [ 32 | new CleanWebpackPlugin(), 33 | new CopyWebpackPlugin({ 34 | patterns: [ 35 | { from: Path.resolve(__dirname, "../vendors"), to: "vendors" }, 36 | ], 37 | }), 38 | new WebpackAssetsManifest({ 39 | entrypoints: true, 40 | output: "manifest.json", 41 | writeToDisk: true, 42 | publicPath: true, 43 | }), 44 | ], 45 | resolve: { 46 | alias: { 47 | "~": Path.resolve(__dirname, "../src"), 48 | }, 49 | }, 50 | module: { 51 | rules: [ 52 | { 53 | test: /\.mjs$/, 54 | include: /node_modules/, 55 | type: "javascript/auto", 56 | }, 57 | { 58 | test: /\.(ico|jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2)(\?.*)?$/, 59 | type: "asset", 60 | }, 61 | ], 62 | }, 63 | }; 64 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/webpack/webpack.config.watch.js: -------------------------------------------------------------------------------- 1 | const Path = require("path"); 2 | const Webpack = require("webpack"); 3 | const { merge } = require("webpack-merge"); 4 | const StylelintPlugin = require("stylelint-webpack-plugin"); 5 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 6 | const ESLintPlugin = require("eslint-webpack-plugin"); 7 | 8 | const common = require("./webpack.common.js"); 9 | 10 | module.exports = merge(common, { 11 | target: "web", 12 | mode: "development", 13 | devtool: "inline-source-map", 14 | output: { 15 | chunkFilename: "js/[name].chunk.js", 16 | }, 17 | plugins: [ 18 | new Webpack.DefinePlugin({ 19 | "process.env.NODE_ENV": JSON.stringify("development"), 20 | }), 21 | new StylelintPlugin({ 22 | files: Path.resolve(__dirname, "../src/**/*.s?(a|c)ss"), 23 | }), 24 | new ESLintPlugin({ 25 | extensions: "js", 26 | emitWarning: true, 27 | files: Path.resolve(__dirname, "../src"), 28 | }), 29 | new MiniCssExtractPlugin({ 30 | filename: "css/[name].css", 31 | chunkFilename: "css/[id].css", 32 | }), 33 | ], 34 | module: { 35 | rules: [ 36 | { 37 | test: /\.html$/i, 38 | loader: "html-loader", 39 | }, 40 | { 41 | test: /\.js$/, 42 | include: Path.resolve(__dirname, "../src"), 43 | loader: "babel-loader", 44 | }, 45 | { 46 | test: /\.s?css$/i, 47 | use: [ 48 | MiniCssExtractPlugin.loader, 49 | { 50 | loader: "css-loader", 51 | options: { 52 | sourceMap: true, 53 | }, 54 | }, 55 | "postcss-loader", 56 | "sass-loader", 57 | ], 58 | }, 59 | ], 60 | }, 61 | }); 62 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/404.html: -------------------------------------------------------------------------------- 1 | {{ "{% extends 'base_landing.html' %}" }} 2 | 3 | {{ "{% block meta %}" }} 4 | 404 - Page not found | {{ cookiecutter.project_name }} 5 | 6 | 7 | {{ "{% endblock meta %}" }} 8 | 9 | {{ "{% block content %}" }} 10 |
11 |
12 |

404

13 |

Page not found

14 |

Sorry, we couldn't find the page you're looking for.

15 | 19 |
20 |
21 | {{ "{% endblock content %}" }} 22 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/pages/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import ReferrerBanner 4 | 5 | 6 | @admin.register(ReferrerBanner) 7 | class ReferrerBannerAdmin(admin.ModelAdmin): 8 | list_display = ( 9 | "referrer", 10 | "referrer_printable_name", 11 | "discount_percentage", 12 | "coupon_code", 13 | "expiry_date", 14 | "is_active", 15 | "should_display", 16 | ) 17 | list_filter = ("is_active", "expiry_date") 18 | search_fields = ("referrer", "referrer_printable_name", "coupon_code") 19 | readonly_fields = ("created_at", "updated_at", "is_expired", "should_display") 20 | fieldsets = ( 21 | ( 22 | "Banner Information", 23 | { 24 | "fields": ( 25 | "referrer", 26 | "referrer_printable_name", 27 | "is_active", 28 | ) 29 | }, 30 | ), 31 | ( 32 | "Design", 33 | { 34 | "fields": ( 35 | "background_color", 36 | "text_color", 37 | ), 38 | "description": "Customize banner appearance using Tailwind CSS classes", 39 | }, 40 | ), 41 | ( 42 | "Discount Details", 43 | { 44 | "fields": ( 45 | "discount_amount", 46 | "coupon_code", 47 | "expiry_date", 48 | ) 49 | }, 50 | ), 51 | ( 52 | "Status", 53 | { 54 | "fields": ( 55 | "is_expired", 56 | "should_display", 57 | "created_at", 58 | "updated_at", 59 | ) 60 | }, 61 | ), 62 | ) 63 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/account/password_reset_done.html: -------------------------------------------------------------------------------- 1 | {{ "{% extends 'base_landing.html' %}" }} 2 | 3 | {{ "{% block content %}" }} 4 |
5 |
6 |
7 |
8 | 9 | 10 | 11 |
12 |

13 | Check your email 14 |

15 |

16 | We've sent you an email with a link to reset your password. Please check your inbox and follow the instructions. 17 |

18 |

19 | If you don't see the email, please check your spam folder. 20 |

21 | 22 | 28 |
29 |
30 |
31 | {{ "{% endblock content %}" }} 32 | -------------------------------------------------------------------------------- /hooks/post_gen_project.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Post-generation hook for cookiecutter-django-saas-starter.""" 3 | 4 | import os 5 | import shutil 6 | from pathlib import Path 7 | 8 | 9 | def remove_blog_app(): 10 | """Remove blog app if generate_blog is 'n'.""" 11 | blog_app_path = Path("apps/blog") 12 | if blog_app_path.exists(): 13 | shutil.rmtree(blog_app_path) 14 | print("Removed blog app directory") 15 | 16 | 17 | def remove_blog_templates(): 18 | """Remove blog templates if generate_blog is 'n'.""" 19 | blog_templates_path = Path("frontend/templates/blog") 20 | if blog_templates_path.exists(): 21 | shutil.rmtree(blog_templates_path) 22 | print("Removed blog templates directory") 23 | 24 | 25 | def remove_docs_app(): 26 | """Remove docs app if generate_docs is 'n'.""" 27 | docs_app_path = Path("apps/docs") 28 | if docs_app_path.exists(): 29 | shutil.rmtree(docs_app_path) 30 | print("Removed docs app directory") 31 | 32 | 33 | def remove_docs_templates(): 34 | """Remove docs templates if generate_docs is 'n'.""" 35 | docs_templates_path = Path("frontend/templates/docs") 36 | if docs_templates_path.exists(): 37 | shutil.rmtree(docs_templates_path) 38 | print("Removed docs templates directory") 39 | 40 | 41 | def main(): 42 | """Run post-generation tasks.""" 43 | generate_blog = "{{ cookiecutter.generate_blog }}" 44 | generate_docs = "{{ cookiecutter.generate_docs }}" 45 | 46 | if generate_blog != "y": 47 | print("Blog generation disabled, removing blog-related files...") 48 | remove_blog_app() 49 | remove_blog_templates() 50 | print("Blog cleanup complete!") 51 | 52 | if generate_docs != "y": 53 | print("Docs generation disabled, removing docs-related files...") 54 | remove_docs_app() 55 | remove_docs_templates() 56 | print("Docs cleanup complete!") 57 | 58 | 59 | if __name__ == "__main__": 60 | main() 61 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/urls.py: -------------------------------------------------------------------------------- 1 | """{{ cookiecutter.project_slug }} URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/4.0/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import include, path 18 | from django.contrib.sitemaps.views import sitemap 19 | from django.views.generic import TemplateView 20 | 21 | from {{ cookiecutter.project_slug }}.sitemaps import sitemaps 22 | from apps.pages.views import AccountSignupView 23 | 24 | 25 | urlpatterns = [ 26 | path("admin/", admin.site.urls), 27 | # Override allauth signup with custom view 28 | path("accounts/signup/", AccountSignupView.as_view(), name="account_signup"), 29 | path("accounts/", include("allauth.urls")), 30 | path("anymail/", include("anymail.urls")), 31 | path("uses", TemplateView.as_view(template_name="pages/uses.html"), name="uses"), 32 | {% if cookiecutter.use_stripe == 'y' -%} 33 | path("stripe/", include("djstripe.urls", namespace="djstripe")), 34 | {% endif %} 35 | {% if cookiecutter.generate_blog == 'y' -%} 36 | path("blog/", include("apps.blog.urls")), 37 | {% endif %} 38 | path("api/", include("apps.api.urls")), 39 | path("", include("apps.pages.urls")), 40 | path("", include("apps.core.urls")), 41 | {% if cookiecutter.generate_docs == 'y' -%} 42 | path("docs/", include("apps.docs.urls")), 43 | {% endif %} 44 | path( 45 | "sitemap.xml", 46 | sitemap, 47 | {"sitemaps": sitemaps}, 48 | name="django.contrib.sitemaps.views.sitemap", 49 | ), 50 | ] 51 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/webpack/webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | const Path = require("path"); 2 | const Webpack = require("webpack"); 3 | const { merge } = require("webpack-merge"); 4 | const StylelintPlugin = require("stylelint-webpack-plugin"); 5 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 6 | const ESLintPlugin = require("eslint-webpack-plugin"); 7 | 8 | const common = require("./webpack.common.js"); 9 | 10 | module.exports = merge(common, { 11 | target: "web", 12 | mode: "development", 13 | devtool: "inline-source-map", 14 | output: { 15 | chunkFilename: "js/[name].chunk.js", 16 | publicPath: "http://localhost:9091/", 17 | }, 18 | devServer: { 19 | host: "0.0.0.0", 20 | port: 9091, 21 | headers: { 22 | "Access-Control-Allow-Origin": "*", 23 | }, 24 | devMiddleware: { 25 | writeToDisk: true, 26 | }, 27 | watchFiles: [ 28 | Path.join(__dirname, '../../core/**/*.py'), 29 | Path.join(__dirname, '../templates/**/*.html'), 30 | ], 31 | }, 32 | plugins: [ 33 | new Webpack.DefinePlugin({ 34 | "process.env.NODE_ENV": JSON.stringify("development"), 35 | }), 36 | new StylelintPlugin({ 37 | files: Path.resolve(__dirname, "../src/**/*.s?(a|c)ss"), 38 | }), 39 | new ESLintPlugin({ 40 | extensions: "js", 41 | emitWarning: true, 42 | files: Path.resolve(__dirname, "../src"), 43 | }), 44 | new MiniCssExtractPlugin({ 45 | filename: "css/[name].css", 46 | chunkFilename: "css/[id].css", 47 | }), 48 | ], 49 | module: { 50 | rules: [ 51 | { 52 | test: /\.html$/i, 53 | loader: "html-loader", 54 | }, 55 | { 56 | test: /\.js$/, 57 | include: Path.resolve(__dirname, "../src"), 58 | loader: "babel-loader", 59 | }, 60 | { 61 | test: /\.s?css$/i, 62 | use: [ 63 | MiniCssExtractPlugin.loader, 64 | { 65 | loader: "css-loader", 66 | options: { 67 | sourceMap: true, 68 | }, 69 | }, 70 | "postcss-loader", 71 | ], 72 | }, 73 | ], 74 | }, 75 | }); 76 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/pages/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils import timezone 3 | 4 | from apps.core.base_models import BaseModel 5 | 6 | class ReferrerBanner(BaseModel): 7 | referrer = models.CharField( 8 | max_length=100, 9 | unique=True, 10 | help_text="The referrer code from URL parameter (e.g., 'producthunt' from ?ref=producthunt)", # noqa: E501 11 | ) 12 | referrer_printable_name = models.CharField( 13 | max_length=200, 14 | help_text="Human-readable name to display in banner (e.g., 'Product Hunt')", 15 | ) 16 | expiry_date = models.DateTimeField( 17 | null=True, blank=True, help_text="When to stop showing this banner" 18 | ) 19 | coupon_code = models.CharField( 20 | max_length=100, blank=True, help_text="Optional discount coupon code" 21 | ) 22 | discount_amount = models.DecimalField( 23 | max_digits=3, 24 | decimal_places=2, 25 | default=0, 26 | help_text="Discount from 0.00 (0%) to 1.00 (100%)", 27 | ) 28 | is_active = models.BooleanField( 29 | default=True, help_text="Manually enable/disable banner without deleting it" 30 | ) 31 | background_color = models.CharField( 32 | max_length=100, 33 | default="bg-gradient-to-r from-red-500 to-red-600", 34 | help_text="Tailwind CSS background color classes (e.g., 'bg-gradient-to-r from-red-500 to-red-600' or 'bg-blue-600')", # noqa: E501 35 | ) 36 | text_color = models.CharField( 37 | max_length=50, 38 | default="text-white", 39 | help_text="Tailwind CSS text color class (e.g., 'text-white' or 'text-gray-900')", # noqa: E501 40 | ) 41 | 42 | def __str__(self): 43 | return f"{self.referrer_printable_name} ({self.referrer})" 44 | 45 | @property 46 | def is_expired(self): 47 | if self.expiry_date is None: 48 | return False 49 | return timezone.now() > self.expiry_date 50 | 51 | @property 52 | def should_display(self): 53 | return self.is_active and not self.is_expired 54 | 55 | @property 56 | def discount_percentage(self): 57 | return int(self.discount_amount * 100) 58 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/core/signals.py: -------------------------------------------------------------------------------- 1 | from allauth.account.signals import email_confirmed, user_signed_up 2 | from django.contrib.auth.models import User 3 | from django.db.models.signals import post_save 4 | from django.dispatch import receiver 5 | from django_q.tasks import async_task 6 | 7 | {% if cookiecutter.use_buttondown == 'y' -%} 8 | from apps.core.tasks import add_email_to_buttondown 9 | {% endif %} 10 | from apps.core.models import Profile, ProfileStates 11 | from {{ cookiecutter.project_slug }}.utils import get_{{ cookiecutter.project_slug }}_logger 12 | 13 | logger = get_{{ cookiecutter.project_slug }}_logger(__name__) 14 | 15 | 16 | @receiver(post_save, sender=User) 17 | def create_user_profile(sender, instance, created, **kwargs): 18 | if created: 19 | profile = Profile.objects.create(user=instance, experimental_flag=True) 20 | profile.track_state_change( 21 | to_state=ProfileStates.SIGNED_UP, 22 | ) 23 | 24 | if instance.id == 1: 25 | # Use update() to avoid triggering the signal again 26 | User.objects.filter(id=1).update(is_staff=True, is_superuser=True) 27 | 28 | @receiver(post_save, sender=User) 29 | def save_user_profile(sender, instance, **kwargs): 30 | if hasattr(instance, 'profile'): 31 | instance.profile.save() 32 | 33 | {% if cookiecutter.use_buttondown == 'y' -%} 34 | @receiver(email_confirmed) 35 | def add_email_to_buttondown_on_confirm(sender, **kwargs): 36 | logger.info( 37 | "Adding new user to buttondown newsletter, on email confirmation", 38 | kwargs=kwargs, 39 | sender=sender, 40 | ) 41 | async_task(add_email_to_buttondown, kwargs["email_address"], tag="user") 42 | {% endif %} 43 | 44 | {% if cookiecutter.use_buttondown == 'y' -%} 45 | @receiver(user_signed_up) 46 | def email_confirmation_callback(sender, request, user, **kwargs): 47 | if 'sociallogin' in kwargs: 48 | logger.info( 49 | "Adding new user to buttondown newsletter on social signup", 50 | kwargs=kwargs, 51 | sender=sender, 52 | ) 53 | email = kwargs['sociallogin'].user.email 54 | if email: 55 | async_task(add_email_to_buttondown, email, tag="user") 56 | {% endif %} 57 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/components/referrer_banner.html: -------------------------------------------------------------------------------- 1 | {% raw %} 2 | {% if referrer_banner %} 3 |
6 |
7 |
8 | 9 | 10 | 11 |

12 | Welcome {{ referrer_banner.referrer_printable_name }} users! 13 | {% if referrer_banner.discount_amount > 0 %} 14 | 22 | {% else %} 23 | 27 | {% endif %} 28 |

29 |
30 | 31 | Get Started 32 | 33 |
34 |
35 | {% endif %} 36 | {% endraw %} 37 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/core/context_processors.py: -------------------------------------------------------------------------------- 1 | from allauth.socialaccount.models import SocialApp 2 | from django.conf import settings 3 | 4 | from apps.core.choices import ProfileStates 5 | 6 | from {{ cookiecutter.project_slug }}.utils import get_{{ cookiecutter.project_slug }}_logger 7 | 8 | logger = get_{{ cookiecutter.project_slug }}_logger(__name__) 9 | 10 | 11 | def current_state(request): 12 | if request.user.is_authenticated: 13 | return {"current_state": request.user.profile.current_state} 14 | return {"current_state": ProfileStates.STRANGER} 15 | 16 | {% if cookiecutter.use_stripe == 'y' %} 17 | def pro_subscription_status(request): 18 | """ 19 | Adds a 'has_pro_subscription' variable to the context. 20 | This variable is True if the user has an active pro subscription, False otherwise. 21 | """ 22 | if request.user.is_authenticated and hasattr(request.user, "profile"): 23 | return {"has_pro_subscription": request.user.profile.has_active_subscription} 24 | return {"has_pro_subscription": False} 25 | {% endif %} 26 | 27 | {% if cookiecutter.use_posthog == 'y' -%} 28 | def posthog_api_key(request): 29 | return {"posthog_api_key": settings.POSTHOG_API_KEY} 30 | {% endif %} 31 | 32 | {% if cookiecutter.use_mjml == 'y' -%} 33 | def mjml_url(request): 34 | return {"mjml_url": settings.MJML_URL} 35 | {% endif %} 36 | 37 | def available_social_providers(request): 38 | """ 39 | Checks which social authentication providers are available. 40 | Returns a list of provider names from either SOCIALACCOUNT_PROVIDERS settings 41 | or SocialApp database entries, as django-allauth supports both configuration methods. 42 | """ 43 | available_providers = set() 44 | 45 | configured_providers = getattr(settings, "SOCIALACCOUNT_PROVIDERS", {}) 46 | 47 | available_providers.update(configured_providers.keys()) 48 | 49 | try: 50 | social_apps = SocialApp.objects.all() 51 | for social_app in social_apps: 52 | available_providers.add(social_app.provider) 53 | except Exception as e: 54 | logger.warning("Error retrieving SocialApp entries", error=str(e)) 55 | 56 | available_providers_list = sorted(list(available_providers)) 57 | 58 | return { 59 | "available_social_providers": available_providers_list, 60 | "has_social_providers": len(available_providers_list) > 0, 61 | } 62 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/.cursor/rules/backend-logging.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: *.py 4 | alwaysApply: false 5 | --- 6 | ## Setup 7 | - Use `structlog` for structured logging 8 | - Access logger like so: 9 | ```python 10 | from {{ cookiecutter.project_slug }}.utils import get_{{ cookiecutter.project_slug }}_logger 11 | 12 | logger = get_{{ cookiecutter.project_slug }}_logger(__name__) 13 | ``` 14 | 15 | ## Logging Levels 16 | - **INFO**: Successful operations, key business events 17 | - **DEBUG**: Detailed diagnostic information (not in production) 18 | - **WARNING**: Recoverable errors, deprecations, fallback scenarios 19 | - **ERROR**: Errors that don't crash the application 20 | - **CRITICAL**: Errors that may crash the application 21 | 22 | ## Best Practices 23 | 24 | ### Success Logging 25 | ```python 26 | # After successful API calls 27 | logger.info( 28 | "API call successful", 29 | service="typefully", 30 | endpoint="/api/posts", 31 | status_code=200, 32 | response_id=result.get("id"), 33 | duration_ms=elapsed_time 34 | ) 35 | ``` 36 | 37 | ### Error Logging 38 | 39 | ```python 40 | logger.error( 41 | "API call failed", 42 | service="typefully", 43 | endpoint="/api/posts", 44 | error=str(e), 45 | status_code=getattr(response, 'status_code', None), 46 | exc_info=True # Include stack trace 47 | ) 48 | ``` 49 | 50 | ### Security & Privacy 51 | - Never log: passwords, tokens, API keys, PII 52 | - Safe to log: sanitized IDs, status codes, non-sensitive metadata 53 | 54 | ### Structured Fields 55 | - Use consistent field names: user_id, request_id, service, endpoint 56 | - Include context: timing, request identifiers, business relevant data 57 | - Keep messages human-readable but supplement with structured data 58 | - Try to use these alwaya, when possible: 59 | - email 60 | - profile_id 61 | 62 | ## Examples 63 | 64 | ```python 65 | # Function entry/exit (DEBUG level) 66 | logger.debug("Starting user authentication", user_id=user_id) 67 | 68 | # Business events (INFO level) 69 | logger.info("User logged in", user_id=user_id, login_method="oauth") 70 | 71 | # Warning scenarios 72 | logger.warning("Rate limit approaching", service="api", remaining_calls=5) 73 | 74 | # Error with context 75 | logger.error( 76 | "Database connection failed", 77 | database="primary", 78 | retry_attempt=3, 79 | error=str(e), 80 | exc_info=True 81 | ) 82 | ``` 83 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/api/auth.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest 2 | from ninja.security import APIKeyQuery 3 | 4 | from apps.core.models import Profile 5 | 6 | from {{ cookiecutter.project_slug }}.utils import get_{{ cookiecutter.project_slug }}_logger 7 | 8 | logger = get_{{ cookiecutter.project_slug }}_logger(__name__) 9 | 10 | 11 | class APIKeyAuth(APIKeyQuery): 12 | param_name = "api_key" 13 | 14 | def authenticate(self, request: HttpRequest, key: str) -> Profile | None: 15 | logger.info( 16 | "[Django Ninja Auth] API Request with key", 17 | key=key, 18 | ) 19 | try: 20 | return Profile.objects.get(key=key) 21 | except Profile.DoesNotExist: 22 | logger.warning("[Django Ninja Auth] Invalid API key", key=key) 23 | return None 24 | 25 | 26 | class SessionAuth: 27 | """Authentication via Django session""" 28 | 29 | def authenticate(self, request: HttpRequest) -> Profile | None: 30 | if hasattr(request, "user") and request.user.is_authenticated: 31 | logger.info( 32 | "[Django Ninja Auth] API Request with authenticated user", 33 | user_id=request.user.id, 34 | ) 35 | try: 36 | return request.user.profile 37 | except Profile.DoesNotExist: 38 | logger.warning("[Django Ninja Auth] No profile for user", user_id=request.user.id) 39 | return None 40 | return None 41 | 42 | def __call__(self, request: HttpRequest): 43 | return self.authenticate(request) 44 | 45 | 46 | class SuperuserAPIKeyAuth(APIKeyQuery): 47 | param_name = "api_key" 48 | 49 | def authenticate(self, request: HttpRequest, key: str) -> Profile | None: 50 | try: 51 | profile = Profile.objects.get(key=key) 52 | if profile.user.is_superuser: 53 | return profile 54 | logger.warning( 55 | "[Django Ninja Auth] Non-superuser attempted admin access", 56 | profile_id=profile.user.id, 57 | ) 58 | return None 59 | except Profile.DoesNotExist: 60 | logger.warning("[Django Ninja Auth] Profile does not exist", key=key) 61 | return None 62 | 63 | 64 | api_key_auth = APIKeyAuth() 65 | session_auth = SessionAuth() 66 | superuser_api_auth = SuperuserAPIKeyAuth() 67 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/account/email_confirm.html: -------------------------------------------------------------------------------- 1 | {{ "{% extends 'base_app.html' %}" }} 2 | {{ "{% load account %}" }} 3 | 4 | 5 | {{ "{% block head_title %}" }} 6 | Confirm Email Address 7 | {{ "{% endblock head_title %}" }} 8 | 9 | {{ "{% block content %}" }} 10 |
11 |
12 |

13 | Confirm Email Address 14 |

15 | 16 | {{ "{% if confirmation %}" }} 17 | {{ "{% user_display confirmation.email_address.user as user_display %}" }} 18 | {{ "{% if can_confirm %}" }} 19 |

20 | Please confirm that {{ "{{ confirmation.email_address.email }}" }} is an email address for user {{ "{{ user_display }}" }}. 21 |

22 | {{ "{% url 'account_confirm_email' confirmation.key as action_url %}" }} 23 |
24 | {{ "{% csrf_token %}" }} 25 | {{ "{{ redirect_field }}" }} 26 | 29 |
30 | {{ "{% else %}" }} 31 |

32 | Unable to confirm {{ "{{ confirmation.email_address.email }}" }} because it is already confirmed by a different account. 33 |

34 | {{ "{% endif %}" }} 35 | {{ "{% else %}" }} 36 | {{ "{% url 'account_email' as email_url %}" }} 37 |

38 | This email confirmation link expired or is invalid. Please issue a new email confirmation request. 39 |

40 | {{ "{% endif %}" }} 41 |
42 |
43 | {{ "{% endblock content %}" }} 44 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "{{ cookiecutter.project_slug }}", 3 | "version": "1.0.0", 4 | "description": "{{ cookiecutter.project_description }}", 5 | "scripts": { 6 | "build": "cross-env NODE_ENV=production webpack --config frontend/webpack/webpack.config.prod.js", 7 | "start": "webpack serve --config frontend/webpack/webpack.config.dev.js", 8 | "watch": "webpack --watch --config frontend/webpack/webpack.config.watch.js" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "{{ cookiecutter.repo_url }}" 13 | }, 14 | "keywords": [ 15 | "webpack", 16 | "startkit", 17 | "frontend" 18 | ], 19 | "author": "{{ cookiecutter.author_name }}", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "{{ cookiecutter.repo_url }}/issues" 23 | }, 24 | "devDependencies": { 25 | "@babel/core": "^7.16.7", 26 | "@babel/eslint-parser": "^7.16.5", 27 | "@babel/plugin-proposal-class-properties": "^7.16.7", 28 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 29 | "@babel/preset-env": "^7.16.8", 30 | "@tailwindcss/forms": "^0.5.2", 31 | "@tailwindcss/typography": "^0.5.2", 32 | "autoprefixer": "10.4.5", 33 | "babel-loader": "^8.2.3", 34 | "clean-webpack-plugin": "^4.0.0", 35 | "copy-webpack-plugin": "^10.2.0", 36 | "cross-env": "^7.0.3", 37 | "css-loader": "^6.5.1", 38 | "eslint": "^8.7.0", 39 | "eslint-webpack-plugin": "^3.1.1", 40 | "mini-css-extract-plugin": "^2.5.1", 41 | "postcss": "^8.4.14", 42 | "postcss-import": "^14.1.0", 43 | "postcss-loader": "^6.2.1", 44 | "postcss-preset-env": "^7.2.3", 45 | "sass": "~1.49.9", 46 | "sass-loader": "^12.4.0", 47 | "style-loader": "^3.3.1", 48 | "stylelint": "^14.2.0", 49 | "stylelint-config-standard-scss": "^3.0.0", 50 | "stylelint-webpack-plugin": "^3.1.1", 51 | "tailwindcss": "^3.1.3", 52 | "webpack": "^5.66.0", 53 | "webpack-assets-manifest": "^5.1.0", 54 | "webpack-cli": "^4.9.1", 55 | "webpack-dev-server": "^4.7.3", 56 | "webpack-merge": "^5.8.0" 57 | }, 58 | "dependencies": { 59 | "@hotwired/stimulus": "^3.2.2", 60 | "@hotwired/stimulus-webpack-helpers": "^1.0.1", 61 | "@stimulus-components/dropdown": "^3.0.0", 62 | "@stimulus-components/reveal": "^5.0.0", 63 | "bootstrap": "^5.1.3", 64 | "core-js": "^3.20.3", 65 | "cssnano": "^7.0.1", 66 | {% if cookiecutter.use_mjml == 'y' -%} 67 | "mjml": "^4.15.3" 68 | {% endif %} 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | To start you'll need to start the Mkdocs server, where a step-by-step process will be provided to you. To do this: 2 | 1. `poetry install` 3 | 2. `poetry run mkdocs serve` 4 | 5 | ## Features 6 | 7 | Legend: 8 | - ✅ Non-optional 9 | - ❓ Optional 10 | 11 | ### Core 12 | - ✅ Django 5 13 | - ✅ Python 3.13 14 | - ✅ Environment variables via **django-environ** 15 | 16 | ### Auth 17 | - ✅ Regular User Auth via **django-allauth** 18 | - ❓ Socail Auth via **django-allauth**: 19 | - ❓ **Google** pre configured 20 | - ❓ **Github** pre configured 21 | 22 | ### Communication 23 | - ✅ **Anymail** for email sending with **Mailgun** (Mailhog for local) 24 | - ✅ **Messages** handling with nice tempalte component pre-installed 25 | - ❓ **MJML** for email templating 26 | - ❓ **Buttondown** for newsletters 27 | 28 | ### Frontend 29 | - ✅ **Webpack** pre-configured 30 | - ✅ **TailwindCSS** for styling 31 | - ✅ **StimulusJS** for interactivity via Webpack 32 | - ✅ SEO optimized templates, pre-configured: 33 | - metatags 34 | - json-ld schema 35 | - OG images 36 | 37 | ### Database & Storage 38 | - ✅ Any Django Supported db will work fine. 39 | - ✅ Custom **Postgres** 18 db pre-configured in env files and docker compose. 40 | - ✅ **pgvector** is installed both in Postgres and in the App 41 | - ✅ **pg_stat_statements** is pre-installed on postgres too. 42 | - ❓ Media storage with any **S3 compatible** service. Comes with **Minio** both locally and in prod. 43 | 44 | ### Dev Tools 45 | - ✅ **Docker Compose** for **local dev** and **prod** pre-configured. 46 | - ✅ **Makefile** is preconfigured for necessary commands. 47 | - ✅ Automated Deployment to **Caprover** via **Github Actions** is pre-confuigured. 48 | - ✅ Testing with **pytest** 49 | - ✅ **Pre-commit** for code quality checks: **ruff**, **djlint** 50 | 51 | ### Logging, Monitoring & Analytics 52 | - ✅ **Structlog** for logging setup both for local (console) and prod (json) 53 | - ❓ **Sentry** integration 54 | - ❓ **Logfire** for prod and dev logging dashboards 55 | - ❓ **Healthcheck** integration 56 | - ❓ **Posthog** integration 57 | 58 | ### Pages 59 | - ✅ landing, pricing, signin/signup, sitemap, blog 60 | - ✅ Way to collect feedback pre-installed via a nice widget 61 | - ❓ Blog, models and pages taken care of. 62 | 63 | ### API 64 | - ✅ API support with **django-ninja** 65 | - ✅ 3 auth modes pre-installed (session, token, superuser) 66 | 67 | ## AI 68 | - ❓ **pydanticai** for agents in the app 69 | - ✅ Cursor Rules 70 | 71 | ### Payments 72 | - ❓ Stripe for payments (subscriptions) via **djstripe** 73 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/components/feedback.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 13 | 14 | 15 |
20 |
24 |

Share your feedback

25 | 26 |
27 |
28 | 35 |
36 | 37 |
38 | 45 | 51 |
52 |
53 |
54 |
55 |
56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # DB 2 | backup-dbs/ 3 | media/ 4 | *.sqlite3 5 | 6 | # Javascript 7 | node_modules/ 8 | bundles/ 9 | 10 | # Byte-compiled / optimized / DLL files 11 | __pycache__/ 12 | *.py[cod] 13 | *$py.class 14 | 15 | # C extensions 16 | *.so 17 | 18 | # Distribution / packaging 19 | .Python 20 | build/ 21 | develop-eggs/ 22 | static/ 23 | assets/css/main.css 24 | dist/ 25 | downloads/ 26 | eggs/ 27 | .eggs/ 28 | lib/ 29 | lib64/ 30 | parts/ 31 | sdist/ 32 | var/ 33 | wheels/ 34 | pip-wheel-metadata/ 35 | share/python-wheels/ 36 | *.egg-info/ 37 | .installed.cfg 38 | *.egg 39 | MANIFEST 40 | 41 | # PyInstaller 42 | # Usually these files are written by a python script from a template 43 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 44 | *.manifest 45 | *.spec 46 | 47 | # Installer logs 48 | pip-log.txt 49 | pip-delete-this-directory.txt 50 | 51 | # Unit test / coverage reports 52 | htmlcov/ 53 | .tox/ 54 | .nox/ 55 | .coverage 56 | .coverage.* 57 | .cache 58 | nosetests.xml 59 | coverage.xml 60 | *.cover 61 | *.py,cover 62 | .hypothesis/ 63 | .pytest_cache/ 64 | 65 | # Translations 66 | *.mo 67 | *.pot 68 | 69 | # Django stuff: 70 | *.log 71 | db.sqlite3 72 | db.sqlite3-journal 73 | db.sqlite3.backup 74 | db.sqlite3.backup.old 75 | 76 | # Flask stuff: 77 | instance/ 78 | .webassets-cache 79 | 80 | # Scrapy stuff: 81 | .scrapy 82 | 83 | # Sphinx documentation 84 | docs/_build/ 85 | 86 | # PyBuilder 87 | target/ 88 | 89 | # Jupyter Notebook 90 | .ipynb_checkpoints 91 | 92 | # IPython 93 | profile_default/ 94 | ipython_config.py 95 | 96 | # pyenv 97 | .python-version 98 | 99 | # pipenv 100 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 101 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 102 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 103 | # install all needed dependencies. 104 | #Pipfile.lock 105 | 106 | # celery beat schedul 107 | celerybeat-schedule 108 | 109 | # SageMath parsed files 110 | *.sage.py 111 | 112 | # Environments 113 | .env 114 | .venv 115 | env/ 116 | venv/ 117 | ENV/ 118 | env.bak/ 119 | venv.bak/ 120 | 121 | # Spyder project settings 122 | .spyderproject 123 | .spyproject 124 | 125 | # Rope project settings 126 | .ropeproject 127 | 128 | # mkdocs documentation 129 | /site 130 | 131 | # mypy 132 | .mypy_cache/ 133 | .dmypy.json 134 | dmypy.json 135 | 136 | # Pyre type checker 137 | .pyre/ 138 | 139 | # Other 140 | .DS_Store 141 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/.gitignore: -------------------------------------------------------------------------------- 1 | # DB 2 | backup-dbs/ 3 | media/ 4 | *.sqlite3 5 | 6 | # Javascript 7 | node_modules/ 8 | bundles/ 9 | 10 | # Byte-compiled / optimized / DLL files 11 | __pycache__/ 12 | *.py[cod] 13 | *$py.class 14 | 15 | # C extensions 16 | *.so 17 | 18 | # Distribution / packaging 19 | .Python 20 | build/ 21 | develop-eggs/ 22 | static/ 23 | assets/css/main.css 24 | dist/ 25 | downloads/ 26 | eggs/ 27 | .eggs/ 28 | lib/ 29 | lib64/ 30 | parts/ 31 | sdist/ 32 | var/ 33 | wheels/ 34 | pip-wheel-metadata/ 35 | share/python-wheels/ 36 | *.egg-info/ 37 | .installed.cfg 38 | *.egg 39 | MANIFEST 40 | 41 | # PyInstaller 42 | # Usually these files are written by a python script from a template 43 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 44 | *.manifest 45 | *.spec 46 | 47 | # Installer logs 48 | pip-log.txt 49 | pip-delete-this-directory.txt 50 | 51 | # Unit test / coverage reports 52 | htmlcov/ 53 | .tox/ 54 | .nox/ 55 | .coverage 56 | .coverage.* 57 | .cache 58 | nosetests.xml 59 | coverage.xml 60 | *.cover 61 | *.py,cover 62 | .hypothesis/ 63 | .pytest_cache/ 64 | 65 | # Translations 66 | *.mo 67 | *.pot 68 | 69 | # Django stuff: 70 | *.log 71 | db.sqlite3 72 | db.sqlite3-journal 73 | db.sqlite3.backup 74 | db.sqlite3.backup.old 75 | 76 | # Flask stuff: 77 | instance/ 78 | .webassets-cache 79 | 80 | # Scrapy stuff: 81 | .scrapy 82 | 83 | # Sphinx documentation 84 | docs/_build/ 85 | 86 | # PyBuilder 87 | target/ 88 | 89 | # Jupyter Notebook 90 | .ipynb_checkpoints 91 | 92 | # IPython 93 | profile_default/ 94 | ipython_config.py 95 | 96 | # pyenv 97 | .python-version 98 | 99 | # pipenv 100 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 101 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 102 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 103 | # install all needed dependencies. 104 | #Pipfile.lock 105 | 106 | # celery beat schedul 107 | celerybeat-schedule 108 | 109 | # SageMath parsed files 110 | *.sage.py 111 | 112 | # Environments 113 | .env 114 | .venv 115 | env/ 116 | venv/ 117 | ENV/ 118 | env.bak/ 119 | venv.bak/ 120 | 121 | # Spyder project settings 122 | .spyderproject 123 | .spyproject 124 | 125 | # Rope project settings 126 | .ropeproject 127 | 128 | # mkdocs documentation 129 | /site 130 | 131 | # mypy 132 | .mypy_cache/ 133 | .dmypy.json 134 | dmypy.json 135 | 136 | # Pyre type checker 137 | .pyre/ 138 | 139 | # Other 140 | .DS_Store 141 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "{{ cookiecutter.project_slug }}" 3 | version = "0.1.0" 4 | description = "{{ cookiecutter.project_description }}" 5 | authors = ["{{ cookiecutter.author_name }} <{{ cookiecutter.author_email }}>"] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.13" 9 | django-allauth = {extras = ["socialaccount"], version = "^65.13.1"} 10 | python-webpack-boilerplate = "^1.0.0" 11 | django-widget-tweaks = "^1.4.12" 12 | django = "^5.0.4" 13 | django-environ = "^0.11.2" 14 | psycopg = {extras = ["binary"], version = "^3.2.10"} 15 | ipython = "^8.27.0" 16 | django-extensions = "^3.2.3" 17 | pillow = "^10.4.0" 18 | django-q2 = "^1.7.2" 19 | whitenoise = "^6.7.0" 20 | django-storages = {extras = ["s3"], version = "^1.14.4"} 21 | structlog = "^24.4.0" 22 | django-structlog = "^8.1.0" 23 | markdown = "^3.7" 24 | {% if cookiecutter.use_sentry == 'y' -%} 25 | sentry-sdk = {extras = ["django"], version = "^2.14.0"} 26 | structlog-sentry = "^2.2.1" 27 | {% endif -%} 28 | gunicorn = "^23.0.0" 29 | pytest = "^8.3.3" 30 | pytest-django = "^4.9.0" 31 | redis = "^5.0.8" 32 | django-anymail = {extras = ["mailgun"], version = "^12.0"} 33 | {% if cookiecutter.use_posthog == 'y' -%} 34 | posthog = "^5.3.0" 35 | {% endif -%} 36 | {% if cookiecutter.use_stripe == 'y' -%} 37 | dj-stripe = "^2.10.0" 38 | stripe = "^13.0.1" 39 | {% endif -%} 40 | django-ninja = "^1.3.0" 41 | {% if cookiecutter.use_mjml == 'y' -%} 42 | django-mjml = "^1.3" 43 | {% endif -%} 44 | {% if cookiecutter.use_ai == 'y' -%} 45 | pydantic-ai = "^1.9.1" 46 | {% endif -%} 47 | {% if cookiecutter.use_logfire == 'y' -%} 48 | logfire = "^3.6.4" 49 | {% endif %} 50 | {% if cookiecutter.generate_docs == 'y' -%} 51 | python-frontmatter = "^1.1.0" 52 | pyyaml = "^6.0.3" 53 | pygments = "^2.19.2" 54 | {% endif -%} 55 | pgvector = "^0.4.1" 56 | 57 | [tool.poetry.group.dev.dependencies] 58 | pre-commit = "^3.2.1" 59 | djlint = "^1.36.4" 60 | ruff = "^0.12.0" 61 | ty = "^0.0.1a11" 62 | 63 | [tool.ruff] 64 | line-length = 100 65 | target-version = "py311" 66 | 67 | [tool.ruff.lint] 68 | select = [ 69 | "E", # pycodestyle errors 70 | "F", # pyflakes 71 | "B", # flake8-bugbear 72 | "I", # isort 73 | "DJ", # django-specific rules 74 | "UP", # pyupgrade 75 | "C90", # mccabe complexity 76 | ] 77 | 78 | [tool.ruff.lint.per-file-ignores] 79 | "**/migrations/*.py" = ["E501", "F401", "F403", "F405"] 80 | 81 | [tool.ruff.format] 82 | quote-style = "double" 83 | 84 | [tool.djlint] 85 | profile="django" 86 | ignore = "H031,H006,H023,H021,H011,T002" 87 | 88 | [build-system] 89 | requires = ["poetry-core>=1.0.0"] 90 | build-backend = "poetry.core.masonry.api" 91 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/account/password_reset.html: -------------------------------------------------------------------------------- 1 | {{ "{% extends 'base_landing.html' %}" }} 2 | {{ "{% load widget_tweaks %}" }} 3 | 4 | {{ "{% block content %}" }} 5 |
6 |
7 |
8 |

9 | Reset your password 10 |

11 |

12 | Enter your email address and we'll send you a link to reset your password. 13 |

14 |
15 |
16 | {{ "{% csrf_token %}" }} 17 | {{ "{{ form.non_field_errors | safe }}" }} 18 | 19 |
20 |
21 | {{ "{{ form.email.errors | safe }}" }} 22 | 23 | {{ '{% render_field form.email placeholder="Email address" id="email" name="email" type="email" autocomplete="email" required=True class="block relative px-3 py-2 w-full placeholder-gray-500 text-gray-900 rounded-md border border-gray-300 appearance-none focus:outline-none focus:ring-{{cookiecutter.project_main_color}}-500 focus:border-{{cookiecutter.project_main_color}}-500 focus:z-10 sm:text-sm" %}' }} 24 |
25 |
26 | 27 |
28 | 32 |
33 | 34 |
35 | 40 |
41 |
42 |
43 |
44 | {{ "{% endblock content %}" }} 45 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/.env.example: -------------------------------------------------------------------------------- 1 | # If you deploying via docker compose you don't need to update these values: 2 | # - POSTGRES_HOST 3 | # - POSTGRES_PORT 4 | # - REDIS_HOST 5 | # - REDIS_PORT 6 | 7 | # should be 'off' in prod 8 | DEBUG=on 9 | 10 | # options: 11 | # - dev (for local development) 12 | # - prod (for deployed versions) 13 | ENVIRONMENT=dev 14 | 15 | SECRET_KEY="super-secret-key" 16 | 17 | SITE_URL=http://localhost:8000 18 | 19 | POSTGRES_HOST=db 20 | POSTGRES_DB={{ cookiecutter.project_slug }} 21 | POSTGRES_USER={{ cookiecutter.project_slug }} 22 | POSTGRES_PORT=5432 23 | POSTGRES_PASSWORD={{ cookiecutter.project_slug }} 24 | 25 | REDIS_HOST=redis 26 | REDIS_PASSWORD={{ cookiecutter.project_slug }} 27 | REDIS_PORT=6379 28 | 29 | # If you want to enable Social Auth via Github 30 | # Get his values after creating a new app on GitHub: https://github.com/settings/applications/new 31 | # For Homepage URL use: http://localhost:8000 32 | # For Authorization callback URL use: http://localhost:8000/accounts/github/login/callback/ 33 | GITHUB_CLIENT_ID="" 34 | GITHUB_CLIENT_SECRET="" 35 | 36 | {% if cookiecutter.use_s3 == 'y' -%} 37 | # If you want to load blog post images to S3, instead of local storage 38 | AWS_S3_ENDPOINT_URL=http://localhost:9000 39 | AWS_ACCESS_KEY_ID={{ cookiecutter.project_slug }} 40 | AWS_SECRET_ACCESS_KEY={{ cookiecutter.project_slug }} 41 | AWS_S3_BUCKET_NAME={{ cookiecutter.project_slug }} 42 | {%- endif %} 43 | 44 | # If you need email sending capabilities 45 | # This is not really necessary in the state of the app 46 | # Apart from confirming the email. 47 | # If you don't specify the key you will get the link to confirm email 48 | # in your logs 49 | MAILGUN_API_KEY= 50 | 51 | {% if cookiecutter.use_buttondown == 'y' -%} 52 | # If you plan on doing a newsletter for subscribers 53 | # Not really needed fo custom deployments, I think 54 | BUTTONDOWN_API_KEY= 55 | {%- endif %} 56 | 57 | {% if cookiecutter.use_stripe == 'y' -%} 58 | # Stripe Integration, not needed unless you want to sell access 59 | STRIPE_LIVE_SECRET_KEY= 60 | STRIPE_TEST_SECRET_KEY= 61 | DJSTRIPE_WEBHOOK_SECRET= 62 | WEBHOOK_UUID= 63 | {%- endif %} 64 | 65 | {% if cookiecutter.use_mjml == 'y' -%} 66 | # MJML URL for self-hosted server 67 | MJML_URL=http://mjml:15500 68 | {%- endif %} 69 | 70 | {% if cookiecutter.use_ai == 'y' -%} 71 | # depending on what AI provider you use, you can set the API key here. 72 | # We are using PydanticAI so use names suggested in their docs: 73 | # https://ai.pydantic.dev/models/ 74 | OPENAI_API_KEY= 75 | {%- endif %} 76 | 77 | {% if cookiecutter.use_logfire == 'y' -%} 78 | LOGFIRE_TOKEN= 79 | LOGFIRE_CONSOLE_SHOW_PROJECT_LINK=False 80 | {%- endif %} 81 | 82 | {% if cookiecutter.use_sentry == 'y' -%} 83 | SENTRY_DSN= 84 | {%- endif %} 85 | {% if cookiecutter.use_posthog == 'y' -%} 86 | POSTHOG_API_KEY= 87 | {%- endif %} 88 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/core/utils.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from django.forms.utils import ErrorList 4 | 5 | from apps.core.models import EmailSent, Profile 6 | from apps.core.choices import EmailType 7 | 8 | from {{ cookiecutter.project_slug }}.utils import get_{{ cookiecutter.project_slug }}_logger 9 | 10 | logger = get_{{ cookiecutter.project_slug }}_logger(__name__) 11 | 12 | 13 | class DivErrorList(ErrorList): 14 | def __str__(self): 15 | return self.as_divs() 16 | 17 | def as_divs(self): 18 | if not self: 19 | return "" 20 | return f""" 21 |
22 |
23 |
24 | 25 | 28 |
29 |
30 | {''.join([f'

{e}

' for e in self])} 31 |
32 |
33 |
34 | """ # noqa: E501 35 | 36 | {% if cookiecutter.use_healthchecks == 'y' %} 37 | def ping_healthchecks(ping_id): 38 | try: 39 | requests.get(f"https://healthchecks.cr.lvtd.dev/ping/{ping_id}", timeout=10) 40 | except requests.RequestException as e: 41 | logger.error("Ping failed", error=e, exc_info=True) 42 | {% endif %} 43 | 44 | 45 | def track_email_sent(email_address: str, email_type: EmailType, profile: Profile = None): 46 | """ 47 | Track sent emails by creating EmailSent records. 48 | """ 49 | try: 50 | email_sent = EmailSent.objects.create( 51 | email_address=email_address, email_type=email_type, profile=profile 52 | ) 53 | logger.info( 54 | "[Track Email Sent] Email tracked successfully", 55 | email_address=email_address, 56 | email_type=email_type, 57 | profile_id=profile.id if profile else None, 58 | email_sent_id=email_sent.id, 59 | ) 60 | return email_sent 61 | except Exception as e: 62 | logger.error( 63 | "[Track Email Sent] Failed to track email", 64 | email_address=email_address, 65 | email_type=email_type, 66 | error=str(e), 67 | exc_info=True, 68 | ) 69 | return None 70 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/docs/content/deployment/render-deployment.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Deploying {{ cookiecutter.project_name }} to Render 3 | description: Learn how to deploy {{ cookiecutter.project_name }} on Render. 4 | keywords: {{ cookiecutter.project_name }}, deployment, render, self-hosting 5 | author: {{ cookiecutter.author_name }} 6 | --- 7 | 8 | [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo={{ cookiecutter.repo_url }}) 9 | 10 | ## Required configuration 11 | 12 | Before deploying, you need to configure environment variables. See the [Environment Variables](/docs/deployment/environment-variables/) guide for detailed information about all configuration options. 13 | 14 | Refer to the [Environment Variables](/docs/deployment/environment-variables/) guide for the complete list of required and optional variables. 15 | 16 | All other variables beyond the required ones are optional but may enhance functionality. 17 | 18 | **Note:** This should work out of the box with Render's free tier if you provide the required configuration. Here's what you need to know about the limitations: 19 | 20 | - **Worker Service Limitation**: The worker service is not a dedicated worker type (those are only available on paid plans). For the free tier, I had to use a web service through a small hack, but it works fine for most use cases. 21 | 22 | - **Memory Constraints**: The free web service has a 512 MB RAM limit, which can cause issues with **automated background tasks only**. When you add a project, it runs a suite of background tasks to analyze your website, generate articles, keywords, and other content. These automated processes can hit memory limits and potentially cause failures. 23 | 24 | - **Manual Tasks Work Fine**: However, if you perform tasks manually (like generating a single article), these typically use the web service instead of the worker and should work reliably since it's one request at a time. 25 | 26 | - **Upgrade Recommendation**: If you do upgrade to a paid plan, use the actual worker service instead of the web service workaround for better automated task reliability. 27 | 28 | **Reality Check**: The website functionality should be usable on the free tier - you'll only pay for API costs. Manual operations work fine, but automated background tasks (especially when adding multiple projects) may occasionally fail due to memory constraints. It's not super comfortable for heavy automated use, but perfectly functional for manual content generation. 29 | 30 | If you know of any other services like Render that allow deployment via a button and provide free Redis, Postgres, and web services, please let me know in the [Issues]({{ cookiecutter.repo_url }}/issues) section. I can try to create deployments for those. Bear in mind that free services are usually not large enough to run this application reliably. 31 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/.cursor/rules/django-async-tasks.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: **/tasks.py 4 | alwaysApply: false 5 | --- 6 | - **Avoid Passing `HttpRequest` to Async Tasks** 7 | - Django's `HttpRequest` object is not "pickleable" (serializable) and cannot be passed directly to a background task in `django-q`. Doing so will result in a `TypeError: cannot pickle '_io.BufferedReader' object`. This is because the request object contains live resources like network streams that cannot be stored and recreated in another process. 8 | 9 | - **Pass Only Serializable Data** 10 | - Instead of passing the entire `request` object, you must extract only the specific, serializable data that the task needs. This makes your task's dependencies explicit and guarantees it will work with the task queue. 11 | - Common serializable data includes: 12 | - User ID (`request.user.id`) 13 | - Cookies (`request.COOKIES`) 14 | - Session data (`request.session._session`) 15 | - Specific GET/POST parameters 16 | - Specific headers from `request.META` 17 | 18 | - **Benefits of this Approach** 19 | - **Decoupling:** Your tasks become independent of the web request-response cycle. They can be called from views, management commands, or other tasks. 20 | - **Testability:** Tasks are much easier to unit test, as you can pass simple data types instead of having to mock a complex `HttpRequest` object. 21 | - **Robustness:** Avoids unexpected serialization errors if the `HttpRequest` object's internal structure changes in future Django versions. 22 | 23 | - **Example** 24 | 25 | - ❌ **DON'T**: Pass the entire `request` object. This will fail. 26 | 27 | ```python 28 | # In a view method 29 | from django_q.tasks import async_task 30 | 31 | # This will raise a pickling error. 32 | async_task( 33 | 'apps.core.tasks.my_task', 34 | request=self.request 35 | ) 36 | ``` 37 | 38 | - ✅ **DO**: Extract the necessary data and pass it to the task. 39 | 40 | ```python 41 | # In a view method 42 | from django_q.tasks import async_task 43 | 44 | user_id = self.request.user.id if self.request.user.is_authenticated else None 45 | cookies = self.request.COOKIES 46 | 47 | async_task( 48 | 'apps.core.tasks.my_task', 49 | user_id=user_id, 50 | cookies=cookies 51 | ) 52 | ``` 53 | 54 | And the corresponding task: 55 | 56 | ```python 57 | # In your tasks.py 58 | from django.contrib.auth import get_user_model 59 | 60 | User = get_user_model() 61 | 62 | def my_task(user_id, cookies): 63 | if user_id: 64 | try: 65 | user = User.objects.get(id=user_id) 66 | # ... do something with the user and cookies 67 | except User.DoesNotExist: 68 | # handle case where user might not exist 69 | pass 70 | # ... 71 | ``` 72 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/sitemaps.py: -------------------------------------------------------------------------------- 1 | from django.contrib import sitemaps 2 | from django.urls import reverse 3 | from django.contrib.sitemaps import GenericSitemap 4 | 5 | {% if cookiecutter.generate_blog == 'y' %} 6 | from apps.blog.models import BlogPost 7 | {% endif %} 8 | {% if cookiecutter.generate_docs == 'y' -%} 9 | from apps.docs.views import get_docs_navigation 10 | {% endif %} 11 | 12 | class StaticViewSitemap(sitemaps.Sitemap): 13 | """Generate Sitemap for the site""" 14 | 15 | priority = 0.9 16 | protocol = "https" 17 | 18 | def items(self): 19 | """Identify items that will be in the Sitemap 20 | 21 | Returns: 22 | List: urlNames that will be in the Sitemap 23 | """ 24 | return [ 25 | "landing", 26 | "uses", 27 | {% if cookiecutter.use_stripe == 'y' -%} 28 | "pricing", 29 | {%- endif %} 30 | {% if cookiecutter.generate_blog == 'y' %} 31 | "blog_posts", 32 | {%- endif %} 33 | ] 34 | 35 | def location(self, item): 36 | """Get location for each item in the Sitemap 37 | 38 | Args: 39 | item (str): Item from the items function 40 | 41 | Returns: 42 | str: Url for the sitemap item 43 | """ 44 | return reverse(item) 45 | 46 | {% if cookiecutter.generate_docs == 'y' -%} 47 | class DocsSitemap(sitemaps.Sitemap): 48 | """Generate Sitemap for documentation pages""" 49 | 50 | priority = 0.8 51 | protocol = "https" 52 | changefreq = "weekly" 53 | 54 | def items(self): 55 | """Get all documentation pages from the navigation structure 56 | 57 | Returns: 58 | List: List of dicts with category and page slugs for each doc page 59 | """ 60 | doc_pages = [] 61 | navigation = get_docs_navigation() 62 | 63 | for category_info in navigation: 64 | category_slug = category_info["category_slug"] 65 | for page_info in category_info["pages"]: 66 | page_slug = page_info["slug"] 67 | doc_pages.append( 68 | { 69 | "category": category_slug, 70 | "page": page_slug, 71 | } 72 | ) 73 | 74 | return doc_pages 75 | 76 | def location(self, item): 77 | """Get location for each doc page in the Sitemap 78 | 79 | Args: 80 | item (dict): Dictionary with category and page slugs 81 | 82 | Returns: 83 | str: URL for the sitemap item 84 | """ 85 | return f"/docs/{item['category']}/{item['page']}/" 86 | {% endif %} 87 | 88 | sitemaps = { 89 | "static": StaticViewSitemap, 90 | {% if cookiecutter.generate_blog == 'y' %} 91 | "blog": GenericSitemap( 92 | {"queryset": BlogPost.objects.all(), "date_field": "created_at"}, 93 | priority=0.85, 94 | protocol="https", 95 | ), 96 | {%- endif %} 97 | {% if cookiecutter.generate_docs == 'y' -%} 98 | "docs": DocsSitemap, 99 | {%- endif %} 100 | } 101 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/pages/views.py: -------------------------------------------------------------------------------- 1 | from allauth.account.views import SignupView 2 | from django_q.tasks import async_task 3 | from django.conf import settings 4 | from django.views.generic import TemplateView 5 | 6 | from apps.core.models import Profile 7 | 8 | from {{ cookiecutter.project_slug }}.utils import get_{{ cookiecutter.project_slug }}_logger 9 | 10 | logger = get_{{ cookiecutter.project_slug }}_logger(__name__) 11 | 12 | 13 | class LandingPageView(TemplateView): 14 | template_name = "pages/landing-page.html" 15 | 16 | def get_context_data(self, **kwargs): 17 | context = super().get_context_data(**kwargs) 18 | 19 | {% if cookiecutter.use_posthog == 'y' -%} 20 | if self.request.user.is_authenticated and settings.POSTHOG_API_KEY: 21 | user = self.request.user 22 | profile = user.profile 23 | 24 | async_task( 25 | "core.tasks.try_create_posthog_alias", 26 | profile_id=profile.id, 27 | cookies=self.request.COOKIES, 28 | source_function="LandingPageView - get_context_data", 29 | group="Create Posthog Alias", 30 | ) 31 | {% endif %} 32 | 33 | return context 34 | 35 | 36 | class AccountSignupView(SignupView): 37 | template_name = "account/signup.html" 38 | 39 | def form_valid(self, form): 40 | response = super().form_valid(form) 41 | 42 | user = self.user 43 | profile = user.profile 44 | 45 | {% if cookiecutter.use_posthog == 'y' -%} 46 | async_task( 47 | "core.tasks.try_create_posthog_alias", 48 | profile_id=profile.id, 49 | cookies=self.request.COOKIES, 50 | source_function="AccountSignupView - form_valid", 51 | group="Create Posthog Alias", 52 | ) 53 | 54 | async_task( 55 | "core.tasks.track_event", 56 | profile_id=profile.id, 57 | event_name="user_signed_up", 58 | properties={ 59 | "$set": { 60 | "email": profile.user.email, 61 | "username": profile.user.username, 62 | }, 63 | }, 64 | source_function="AccountSignupView - form_valid", 65 | group="Track Event", 66 | ) 67 | {% endif %} 68 | 69 | return response 70 | 71 | 72 | {% if cookiecutter.use_stripe == 'y' -%} 73 | class PricingView(TemplateView): 74 | template_name = "pages/pricing.html" 75 | 76 | def get_context_data(self, **kwargs): 77 | context = super().get_context_data(**kwargs) 78 | 79 | if self.request.user.is_authenticated: 80 | try: 81 | profile = self.request.user.profile 82 | context["has_pro_subscription"] = profile.has_active_subscription 83 | except Profile.DoesNotExist: 84 | context["has_pro_subscription"] = False 85 | else: 86 | context["has_pro_subscription"] = False 87 | 88 | return context 89 | {% endif %} 90 | 91 | 92 | class PrivacyPolicyView(TemplateView): 93 | template_name = "pages/privacy-policy.html" 94 | 95 | 96 | class TermsOfServiceView(TemplateView): 97 | template_name = "pages/terms-of-service.html" 98 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/src/utils/messages.js: -------------------------------------------------------------------------------- 1 | // static/js/utils/messages.js 2 | export function showMessage(message, type = 'error') { 3 | const messagesContainer = document.querySelector('.messages-container') || createMessagesContainer(); 4 | 5 | const messageId = Date.now(); 6 | const messageHTML = ` 7 |
8 |
9 |
10 | 11 | 12 | 13 | 14 |
15 |
16 |

17 | ${message} 18 |

19 |
20 |
21 | 27 |
28 |
29 |
30 | `; 31 | 32 | messagesContainer.insertAdjacentHTML('beforeend', messageHTML); 33 | 34 | const messageElement = document.querySelector(`[data-message-id="${messageId}"]`); 35 | setTimeout(() => { 36 | messageElement.classList.remove('opacity-0', 'translate-x-full'); 37 | startTimer(messageElement); 38 | }, 100); 39 | } 40 | 41 | function createMessagesContainer() { 42 | const container = document.createElement('div'); 43 | container.className = 'fixed top-4 right-4 z-50 space-y-4 messages-container'; 44 | document.body.appendChild(container); 45 | return container; 46 | } 47 | 48 | function startTimer(item) { 49 | const timerCircle = item.querySelector('[data-timer-circle]'); 50 | const radius = 10; 51 | const circumference = 2 * Math.PI * radius; 52 | 53 | timerCircle.style.strokeDasharray = `${circumference} ${circumference}`; 54 | timerCircle.style.strokeDashoffset = circumference; 55 | 56 | let progress = 0; 57 | const interval = setInterval(() => { 58 | if (progress >= 100) { 59 | clearInterval(interval); 60 | hideMessage(item); 61 | } else { 62 | progress++; 63 | const offset = circumference - (progress / 100) * circumference; 64 | timerCircle.style.strokeDashoffset = offset; 65 | } 66 | }, 50); 67 | } 68 | 69 | function hideMessage(item) { 70 | item.classList.add('opacity-0', 'translate-x-full'); 71 | setTimeout(() => { 72 | item.remove(); 73 | }, 300); 74 | } 75 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/docs/docs_page.html: -------------------------------------------------------------------------------- 1 | {{ "{% extends 'docs/base_docs.html' %}" }} 2 | 3 | {{ "{% block meta %}" }} 4 | {{ "{{ page_title }}" }} - {{ "{{ category_title }}" }} | {{ cookiecutter.project_name }} Documentation 5 | {% raw %} 6 | {% if meta_keywords %}{% endif %} 7 | {% if author %}{% endif %}{% endraw %} 8 | 9 | {% raw %}{% if canonical_url %} 10 | 11 | {% else %} 12 | 13 | {% endif %}{% endraw %} 14 | 15 | 16 | 17 | {% raw %} 18 | {% endraw %} 19 | 20 | 21 | 22 | 23 | {% raw %}{% endraw %} 24 | {{ "{% endblock meta %}" }} 25 | 26 | {{ "{% block docs_content %}" }} 27 |

{{ "{{ page_title }}" }}

28 | {% raw %}{{ content|safe }}{% endraw %} 29 | 30 | 59 | {{ "{% endblock docs_content %}" }} 60 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/adapters.py: -------------------------------------------------------------------------------- 1 | import re 2 | import uuid 3 | 4 | from allauth.account.adapter import DefaultAccountAdapter 5 | from allauth.socialaccount.adapter import DefaultSocialAccountAdapter 6 | from django.contrib.auth import get_user_model 7 | 8 | from apps.core.choices import EmailType 9 | from apps.core.utils import track_email_sent 10 | from {{ cookiecutter.project_slug }}.utils import get_{{ cookiecutter.project_slug }}_logger 11 | 12 | logger = get_{{ cookiecutter.project_slug }}_logger(__name__) 13 | 14 | User = get_user_model() 15 | 16 | 17 | class CustomAccountAdapter(DefaultAccountAdapter): 18 | """ 19 | Custom adapter to track email confirmations and welcome emails. 20 | """ 21 | 22 | def send_confirmation_mail(self, request, emailconfirmation, signup): 23 | """ 24 | Override to track email confirmation sends. 25 | 26 | Args: 27 | request: The HTTP request 28 | emailconfirmation: The email confirmation object 29 | signup: Boolean indicating if this is during signup (True) or resend (False) 30 | """ 31 | profile = ( 32 | emailconfirmation.email_address.user.profile 33 | if hasattr(emailconfirmation.email_address.user, "profile") 34 | else None 35 | ) 36 | 37 | # Track as welcome email during signup, confirmation email on resend 38 | email_type = EmailType.WELCOME if signup else EmailType.EMAIL_CONFIRMATION 39 | 40 | logger.info( 41 | "[Send Confirmation Mail] Sending email", 42 | signup=signup, 43 | email_type=email_type, 44 | user_id=emailconfirmation.email_address.user.id, 45 | email=emailconfirmation.email_address.email, 46 | ) 47 | 48 | try: 49 | result = super().send_confirmation_mail(request, emailconfirmation, signup) 50 | track_email_sent( 51 | email_address=emailconfirmation.email_address.email, 52 | email_type=email_type, 53 | profile=profile, 54 | ) 55 | return result 56 | except Exception as error: 57 | logger.error( 58 | "[Send Confirmation Mail] Failed to send email", 59 | error=str(error), 60 | exc_info=True, 61 | user_id=emailconfirmation.email_address.user.id, 62 | email=emailconfirmation.email_address.email, 63 | ) 64 | raise 65 | 66 | class CustomSocialAccountAdapter(DefaultSocialAccountAdapter): 67 | """ 68 | Custom adapter to automatically generate usernames from email addresses 69 | during social authentication signup, bypassing the username selection page. 70 | """ 71 | 72 | def populate_user(self, request, sociallogin, data): 73 | """ 74 | Automatically set username from email address before user creation. 75 | Uses the part before @ symbol as username, ensuring uniqueness. 76 | """ 77 | user = super().populate_user(request, sociallogin, data) 78 | 79 | if not user.username and user.email: 80 | base_username = re.sub(r"[^\w]", "", user.email.split("@")[0]) 81 | if not base_username: 82 | base_username = f"user{uuid.uuid4().hex[:8]}" 83 | username = base_username 84 | 85 | counter = 1 86 | while User.objects.filter(username=username).exists(): 87 | username = f"{base_username}{counter}" 88 | counter += 1 89 | 90 | user.username = username 91 | 92 | return user 93 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project tries to adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## Types of changes 8 | 9 | **Added** for new features. 10 | **Changed** for changes in existing functionality. 11 | **Deprecated** for soon-to-be removed features. 12 | **Removed** for now removed features. 13 | **Fixed** for any bug fixes. 14 | **Security** in case of vulnerabilities. 15 | 16 | 17 | ## [Unreleased] 18 | ### Added 19 | - Added Docs Section 20 | - Added Password Reset functinoality 21 | - Added banner model to make it easy to create banners for specific Referrers. 22 | - Added EmailSent model to keep track of all the emails sent to users. 23 | - Privacy Policy and Terms of Service Links 24 | - Healthcheck API endpoint 25 | - Custom 404 error page with modern design 26 | - Add FAQs to landing page 27 | 28 | ### Changes 29 | - All landing pages are now in `pages` app 30 | - use simple text if mjml is not setup 31 | - updated dj-stripe and stripe versions 32 | - default timeout for tassk to be around an hour 33 | - moved all apps to apps directory 34 | - move `core` and `pages` apps into `apps` directory. 35 | 36 | ### Fixed 37 | - Various imports 38 | - App Config labels 39 | - No need for custom 404 view 40 | 41 | ## [0.0.5] - 2025-10-23 42 | ### Added 43 | - support for self hosted mjml server 44 | - MJML email templates for allauth (signup and email confirmation): 45 | 46 | ### Changed 47 | - landing page and home page are now different pages 48 | - added admin panel page for info and test triggers 49 | - user-settings now has a single button for all the forms on the page 50 | - stling of the upgrade flow 51 | - moved the blog and api logic to a separate app 52 | - use pg 18 for local db 53 | 54 | ### Removed 55 | - test_mjml function and template (replaced with proper allauth email templates) 56 | - unused imports from core/views.py (HttpResponse, render_to_string, strip_tags, EmailMultiAlternatives) 57 | 58 | ## [0.0.4] - 2025-10-12 59 | ### Changed 60 | - how sentry will capture logs 61 | 62 | ## [0.0.4] - 2025-09-26 63 | ### Added 64 | - New context_processor which figures out which social apps you have installed 65 | - New context_processor which figures out if user is a paying customer 66 | - render deployment configuration 67 | 68 | ### Updated 69 | - .env.example file with better instructions and more options 70 | - S3 is now optional 71 | - README with better deployment instructions 72 | - apps.py to run POSTHOG if API key is available 73 | - tasks to only run if relevant env vars are present 74 | - settings file to work with new deployment options 75 | - docker-compose files to support both local and prod deployments 76 | - makefile commands to include local compose file 77 | 78 | ### Removed 79 | - cookiecutter variable that makes social auth optional. instead the code takes care of that 80 | 81 | ## [0.0.3] - 2024-11-11 82 | ### Added 83 | - Experimental Flag 84 | 85 | ## [0.0.3] - 2024-11-11 86 | ### Added 87 | - Fix missing orphan on User settings page. 88 | - Ignore the djlint " vs. ' error. 89 | - Add django-ninja (with Auth and test endpoint) 90 | - Update dependencies 91 | 92 | 93 | ## [0.0.2] - 2024-10-10 94 | ### Added 95 | - SEO tags + JSON-LD on all the pages 96 | - Optional Blog 97 | - All pages to the sitemap 98 | 99 | ## [0.0.1] - 2024-09-28 100 | ### Added 101 | - Sign-in with Github and Logout button don't go to separate screen anymore. 102 | 103 | ### Fixed 104 | - close button on messages now works fine 105 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/docs/base_docs.html: -------------------------------------------------------------------------------- 1 | {{ "{% extends 'base_landing.html' %}" }} 2 | 3 | {{ "{% block content %}" }} 4 |
5 |
6 | 7 | 28 | 29 | 30 |
31 | 32 |
33 |

34 | 35 | 36 | 37 | Work in Progress: This documentation is being actively developed. More content will be added soon! 38 |

39 |

40 | If you run into any issues, please email {{ cookiecutter.author_email }} 41 |

42 |
43 | 44 |
45 | {{ "{% block docs_content %}" }} 46 | {{ "{% endblock docs_content %}" }} 47 |
48 |
49 | 50 | 51 | 61 |
62 |
63 | {{ "{% endblock content %}" }} 64 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/blog/blog_post.html: -------------------------------------------------------------------------------- 1 | {{ '{% extends "base_landing.html" %}' }} 2 | {{ '{% load webpack_loader static %}' }} 3 | {{ '{% load markdown_extras %}' }} 4 | 5 | {{ '{% block meta %}' }} 6 | {% raw %}{{ blog_post.title }}{% endraw %} | {{ cookiecutter.project_name }} Blog 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | {{ '{% endblock meta %}' }} 26 | 27 | {{ '{% block content %}' }} 28 |
29 |
30 |

{% raw %}{{ blog_post.title }}{% endraw %}

31 | 32 |
33 | {% raw %}{{ blog_post.content|markdown|safe }}{% endraw %} 34 |
35 |
36 |
37 | {{ '{% endblock content %}' }} 38 | 39 | {{ '{% block schema %}' }} 40 | 70 | {{ '{% endblock schema %}' }} 71 | -------------------------------------------------------------------------------- /.cursor/rules/cookiecutter-html-templates.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | alwaysApply: true 3 | description: Rules for generating Django HTML templates in a Cookiecutter boilerplate 4 | --- 5 | 6 | # Django HTML Template Generation Rules 7 | 8 | ## Core Principle 9 | Django templates in a Cookiecutter boilerplate require special escaping to distinguish between: 10 | 1. Cookiecutter template variables (processed during project generation) 11 | 2. Django template variables (remain as-is in the generated project) 12 | 13 | ## Escaping Syntax 14 | 15 | ### Simple Django Tags: Use `{{ "{% ... %}" }}` 16 | Wrap simple Django template tags (no arguments or quotes) in Cookiecutter string literals: 17 | 18 | - `{{ "{% extends 'base.html' %}" }}` 19 | - `{{ "{% load static %}" }}` 20 | - `{{ "{% block content %}" }}` 21 | - `{{ "{% endblock content %}" }}` 22 | - `{{ "{% if user.is_authenticated %}" }}` 23 | - `{{ "{% csrf_token %}" }}` 24 | - `{{ "{{ form.field_name }}" }}` 25 | 26 | ### Django Tags with Arguments: Use `{% raw %}...{% endraw %}` 27 | When Django tags contain arguments, quotes, dots, or filters, use raw blocks: 28 | 29 | - `{% raw %}{% url 'view_name' %}{% endraw %}` 30 | - `{% raw %}{% url 'user_profile' pk=user.id %}{% endraw %}` 31 | - `{% raw %}{% static 'path/to/file.css' %}{% endraw %}` 32 | - `{% raw %}{{ form.field.id_for_label }}{% endraw %}` 33 | - `{% raw %}{{ request.get_host }}{% endraw %}` 34 | - `{% raw %}{% if request.user.is_authenticated %}{% endif %}{% endraw %}` 35 | 36 | **Rule of thumb:** If the tag has arguments, use `{% raw %}...{% endraw %}` 37 | 38 | ### Cookiecutter Variables: Use `{{ cookiecutter.variable }}` 39 | For values that should be injected during project generation: 40 | 41 | - `{{ cookiecutter.project_name }}` 42 | - `{{ cookiecutter.project_description }}` 43 | - `{{ cookiecutter.author_name }}` 44 | 45 | ### Tailwind CSS Classes with Cookiecutter Variables 46 | Write the full class name with the variable inline (no spaces): 47 | 48 | ```html 49 | 52 | 53 |
54 | Styled content 55 |
56 | ``` 57 | 58 | **Important:** Keep the entire class name together as one string for Tailwind to detect it properly. 59 | 60 | ### Conditional Sections: Use `{% if cookiecutter.feature == 'y' -%}` 61 | For feature-specific HTML blocks: 62 | 63 | ```html 64 | {% if cookiecutter.use_stripe == 'y' -%} 65 |
Stripe payment form
66 | {% endif %} 67 | ``` 68 | 69 | ## Common Patterns 70 | 71 | ```html 72 | 73 | {{ "{% load static %}" }} 74 | {{ cookiecutter.project_name }} 75 | 76 | 77 | Home 78 | Profile 79 | 80 | 81 | {{ "{% csrf_token %}" }} 82 | {{ "{{ form.email }}" }} 83 | {{ "{{ form.email.errors }}" }} 84 | 85 | 86 | {{ cookiecutter.project_name }} - {{ "{{ page_title }}" }} 87 | 88 | 89 | 90 | 91 | 92 | {% if cookiecutter.use_posthog == 'y' -%} 93 | {{ "{% if posthog_api_key %}" }} 94 | 95 | {{ "{% endif %}" }} 96 | {% endif %} 97 | ``` 98 | 99 | ## Quick Decision Guide 100 | 101 | 1. **Simple Django tag?** → `{{ "{% tag %}" }}` 102 | 2. **Django tag with arguments?** → `{% raw %}{% tag 'arg' %}{% endraw %}` 103 | 3. **Cookiecutter value?** → `{{ cookiecutter.variable }}` 104 | 4. **Tailwind class with variable?** → `class="bg-{{ cookiecutter.project_main_color }}-600"` 105 | 5. **Optional feature?** → `{% if cookiecutter.feature == 'y' -%}...{% endif %}` 106 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/.cursor/rules/stimulus-events.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: *.js 4 | alwaysApply: false 5 | --- 6 | # Stimulus Controller Communication with Custom Events 7 | 8 | When building complex applications with Stimulus, it's common to encounter scenarios where controllers need to communicate with each other. While Stimulus provides `outlets` for direct parent-child communication, this pattern doesn't work for sibling controllers or more decoupled components. 9 | 10 | For these cases, a robust solution is to use custom DOM events. This approach promotes a loosely coupled architecture, making components more reusable and easier to maintain. 11 | 12 | ## The Pattern 13 | 14 | The core idea is for one controller (the "actor") to dispatch a custom event when something happens, and for another controller (the "listener") to listen for that event and react accordingly. 15 | 16 | ### 1. Dispatching the Event 17 | 18 | The actor controller creates and dispatches a `CustomEvent`. The event's `detail` object can carry a payload of data, such as the element to be moved or other relevant information. 19 | 20 | See how this is implemented in `@archive_suggestion_controller.js`. 21 | 22 | ```javascript 23 | // frontend/src/controllers/archive_suggestion_controller.js 24 | 25 | // ... 26 | if (data.status === "success") { 27 | const message = archived ? "Suggestion archived successfully." : "Suggestion unarchived successfully."; 28 | showMessage(message, "success"); 29 | 30 | const destination = archived ? "archived" : "active"; 31 | const moveEvent = new CustomEvent("suggestion:move", { 32 | bubbles: true, 33 | detail: { element: this.element, destination: destination }, 34 | }); 35 | this.element.dispatchEvent(moveEvent); 36 | } 37 | // ... 38 | ``` 39 | 40 | - **`CustomEvent("suggestion:move", ...)`**: We create a new event named `suggestion:move`. The name should be descriptive of the action. 41 | - **`bubbles: true`**: This is important as it allows the event to bubble up the DOM tree, enabling ancestor elements (like `window` or `document`) to catch it. 42 | - **`detail: { ... }`**: This object contains the data we want to send. Here, we're passing the element to move and the name of the destination list. 43 | 44 | ### 2. Listening for the Event 45 | 46 | The listener controller sets up an event listener in its `connect()` method and cleans it up in `disconnect()`. The listener is typically attached to `window` or `document` to catch bubbled events from anywhere on the page. 47 | 48 | This is demonstrated in `@archived_list_controller.js`. 49 | 50 | ```javascript 51 | // frontend/src/controllers/archived_list_controller.js 52 | 53 | // ... 54 | connect() { 55 | this.boundMove = this.move.bind(this); 56 | window.addEventListener("suggestion:move", this.boundMove); 57 | } 58 | 59 | disconnect() { 60 | window.removeEventListener("suggestion:move", this.boundMove); 61 | } 62 | 63 | move(event) { 64 | const { element, destination } = event.detail; 65 | if (this.nameValue === destination) { 66 | this.add(element); 67 | } 68 | } 69 | // ... 70 | ``` 71 | 72 | - **`connect()` and `disconnect()`**: These Stimulus lifecycle callbacks are the perfect place to add and remove global event listeners, preventing memory leaks. 73 | - **`this.boundMove = this.move.bind(this)`**: We bind the `move` method to ensure `this` refers to the controller instance when the event is handled. 74 | - **`if (this.nameValue === destination)`**: The listener inspects the event's `detail` payload to decide if it should act. In this case, it checks if its own `name` value matches the `destination` from the event. 75 | 76 | ### 3. HTML Markup 77 | 78 | With this event-based approach, the HTML becomes cleaner. There's no need for `data-*-outlet` attributes to link the controllers. Each controller is self-contained. 79 | 80 | The `archive-suggestion` controller is on an individual suggestion in `@blog_suggestion.html`, while the `archived-list` controllers are on the lists in `@blogging-agent.html`. 81 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/docker-compose-local.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: rasulkireev/custom-postgres:18 4 | volumes: 5 | - postgres_data:/var/lib/postgresql/data 6 | ports: 7 | - "5432:5432" 8 | environment: 9 | - POSTGRES_DB={{ cookiecutter.project_slug }} 10 | - POSTGRES_USER={{ cookiecutter.project_slug }} 11 | - POSTGRES_PASSWORD={{ cookiecutter.project_slug }} 12 | healthcheck: 13 | test: ["CMD-SHELL", "pg_isready -U {{ cookiecutter.project_slug }}"] 14 | interval: 5s 15 | timeout: 5s 16 | retries: 5 17 | 18 | redis: 19 | image: redis:7-alpine 20 | command: redis-server --requirepass {{ cookiecutter.project_slug }} 21 | ports: 22 | - "6379:6379" 23 | volumes: 24 | - redis_data:/data 25 | environment: 26 | - REDIS_PASSWORD={{ cookiecutter.project_slug }} 27 | 28 | backend: 29 | build: 30 | context: . 31 | dockerfile: Dockerfile-python 32 | working_dir: /app 33 | command: sh -c "python manage.py migrate && python manage.py runserver 0.0.0.0:8000" 34 | volumes: 35 | - .:/app 36 | ports: 37 | - "8000:8000" 38 | depends_on: 39 | db: 40 | condition: service_healthy 41 | redis: 42 | condition: service_started 43 | frontend: 44 | condition: service_started 45 | env_file: 46 | - .env 47 | 48 | workers: 49 | build: 50 | context: . 51 | dockerfile: Dockerfile-python 52 | working_dir: /app 53 | command: python manage.py qcluster 54 | volumes: 55 | - .:/app 56 | depends_on: 57 | db: 58 | condition: service_healthy 59 | redis: 60 | condition: service_started 61 | env_file: 62 | - .env 63 | 64 | frontend: 65 | image: node:18 66 | working_dir: /app 67 | command: sh -c "npm install && npm run start" 68 | volumes: 69 | - .:/app 70 | ports: 71 | - "9091:9091" 72 | 73 | mailhog: 74 | image: mailhog/mailhog 75 | expose: 76 | - 1025 77 | - 8025 78 | ports: 79 | - "1025:1025" 80 | - "8025:8025" 81 | 82 | {% if cookiecutter.use_stripe == 'y' -%} 83 | stripe: 84 | build: 85 | context: . 86 | dockerfile: Dockerfile-python 87 | working_dir: /app 88 | command: python manage.py stripe_listen 89 | volumes: 90 | - .:/app 91 | depends_on: 92 | db: 93 | condition: service_healthy 94 | redis: 95 | condition: service_started 96 | env_file: 97 | - .env 98 | {% endif %} 99 | 100 | {% if cookiecutter.use_mjml == 'y' -%} 101 | mjml: 102 | image: danihodovic/mjml-server 103 | ports: 104 | - "15500:15500" 105 | {% endif %} 106 | 107 | {% if cookiecutter.use_s3 == 'y' -%} 108 | minio: 109 | image: minio/minio 110 | ports: 111 | - "9000:9000" 112 | - "9001:9001" 113 | volumes: 114 | - minio_data:/data 115 | environment: 116 | MINIO_ROOT_USER: {{ cookiecutter.project_slug }} 117 | MINIO_ROOT_PASSWORD: {{ cookiecutter.project_slug }} 118 | command: server --console-address ":9001" /data 119 | healthcheck: 120 | test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] 121 | interval: 5s 122 | timeout: 5s 123 | retries: 3 124 | 125 | createbuckets: 126 | image: minio/mc 127 | depends_on: 128 | minio: 129 | condition: service_healthy 130 | entrypoint: > 131 | /bin/sh -c " 132 | sleep 5 && 133 | /usr/bin/mc config host add myminio http://minio:9000 {{ cookiecutter.project_slug }} {{ cookiecutter.project_slug }} && 134 | /usr/bin/mc mb myminio/{{ cookiecutter.project_slug }}-dev && 135 | /usr/bin/mc anonymous set download myminio/{{ cookiecutter.project_slug }}-dev && 136 | exit 0; 137 | " 138 | {% endif %} 139 | 140 | volumes: 141 | postgres_data: 142 | redis_data: 143 | {% if cookiecutter.use_s3 == 'y' -%} 144 | minio_data: 145 | {% endif %} 146 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/blog/blog_posts.html: -------------------------------------------------------------------------------- 1 | {{ '{% extends "base_landing.html" %}' }} 2 | {{ '{% load webpack_loader static %}' }} 3 | 4 | {{ '{% block meta %}' }} 5 | {{ cookiecutter.project_name }} Blog 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | {{ '{% endblock meta %}' }} 25 | 26 | {{ '{% block content %}' }} 27 |
28 |
29 |

{{ cookiecutter.project_name }} Blog

30 | {{ '{% if blog_posts %}' }} 31 | 46 | {{ '{% else %}' }} 47 |

No blog posts available at the moment.

48 | {{ '{% endif %}' }} 49 |
50 |
51 | {{ '{% endblock content %}' }} 52 | 53 | {{ '{% block schema %}' }} 54 | 70 | {{ '{% endblock schema %}' }} 71 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/src/controllers/toc_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus"; 2 | 3 | export default class extends Controller { 4 | static targets = ["list", "content", "sidebar"]; 5 | 6 | connect() { 7 | this.generateTableOfContents(); 8 | this.highlightCurrentSection(); 9 | this.boundHandleScroll = this.handleScroll.bind(this); 10 | window.addEventListener("scroll", this.boundHandleScroll, { passive: true }); 11 | } 12 | 13 | disconnect() { 14 | window.removeEventListener("scroll", this.boundHandleScroll); 15 | } 16 | 17 | generateTableOfContents() { 18 | if (!this.hasContentTarget || !this.hasListTarget) { 19 | return; 20 | } 21 | 22 | const headings = this.contentTarget.querySelectorAll("h2"); 23 | 24 | if (headings.length === 0) { 25 | if (this.hasSidebarTarget) { 26 | this.sidebarTarget.style.display = "none"; 27 | } 28 | return; 29 | } 30 | 31 | const tocItems = []; 32 | 33 | headings.forEach((heading) => { 34 | const headingText = heading.textContent.trim(); 35 | 36 | let headingId = heading.id; 37 | if (!headingId) { 38 | headingId = this.generateSlug(headingText); 39 | heading.id = headingId; 40 | } 41 | 42 | const listItem = document.createElement("li"); 43 | 44 | const link = document.createElement("a"); 45 | link.href = `#${headingId}`; 46 | link.textContent = headingText; 47 | link.dataset.tocTarget = "link"; 48 | link.dataset.section = headingId; 49 | link.className = `block py-1.5 pl-3 text-sm text-gray-600 border-l-2 border-gray-200 transition-colors hover:text-gray-900 hover:border-gray-400`; 50 | 51 | link.addEventListener("click", (event) => { 52 | event.preventDefault(); 53 | this.scrollToSection(headingId); 54 | }); 55 | 56 | listItem.appendChild(link); 57 | tocItems.push(listItem); 58 | }); 59 | 60 | this.listTarget.innerHTML = ""; 61 | tocItems.forEach(item => this.listTarget.appendChild(item)); 62 | } 63 | 64 | generateSlug(text) { 65 | return text 66 | .toLowerCase() 67 | .replace(/[^\w\s-]/g, "") 68 | .replace(/\s+/g, "-") 69 | .replace(/-+/g, "-") 70 | .trim(); 71 | } 72 | 73 | scrollToSection(sectionId) { 74 | const section = document.getElementById(sectionId); 75 | if (section) { 76 | const yOffset = -80; 77 | const elementPosition = section.getBoundingClientRect().top; 78 | const offsetPosition = elementPosition + window.pageYOffset + yOffset; 79 | 80 | window.scrollTo({ 81 | top: offsetPosition, 82 | behavior: "smooth" 83 | }); 84 | 85 | this.updateActiveLink(sectionId); 86 | } 87 | } 88 | 89 | handleScroll() { 90 | this.highlightCurrentSection(); 91 | } 92 | 93 | highlightCurrentSection() { 94 | if (!this.hasContentTarget) { 95 | return; 96 | } 97 | 98 | const headings = this.contentTarget.querySelectorAll("h2"); 99 | const scrollPosition = window.scrollY + 100; 100 | 101 | let currentSectionId = ""; 102 | 103 | headings.forEach((heading) => { 104 | const headingPosition = heading.offsetTop; 105 | if (scrollPosition >= headingPosition) { 106 | currentSectionId = heading.id; 107 | } 108 | }); 109 | 110 | if (currentSectionId) { 111 | this.updateActiveLink(currentSectionId); 112 | } 113 | } 114 | 115 | updateActiveLink(activeSectionId) { 116 | const links = this.element.querySelectorAll("[data-toc-target='link']"); 117 | 118 | links.forEach((link) => { 119 | const isActive = link.dataset.section === activeSectionId; 120 | 121 | if (isActive) { 122 | link.classList.remove("border-gray-200", "text-gray-600"); 123 | link.classList.add("border-red-600", "text-red-600", "font-medium"); 124 | } else { 125 | link.classList.remove("border-red-600", "text-red-600", "font-medium"); 126 | link.classList.add("border-gray-200", "text-gray-600"); 127 | } 128 | }); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/components/messages.html: -------------------------------------------------------------------------------- 1 | {{ "{% if messages %}" }} 2 |
3 | {{ "{% for message in messages %}" }} 4 |
5 |
6 |
7 | 8 | 9 | 10 | 11 |
12 |
13 |

14 | {{ "{{ message }}" }} 15 |

16 |
17 |
18 | 24 |
25 |
26 |
27 | {{ "{% endfor %}" }} 28 |
29 | 30 | 79 | {{ "{% endif %}" }} 80 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/pages/landing-page.html: -------------------------------------------------------------------------------- 1 | {{ '{% extends "base_landing.html" %}' }} 2 | 3 | {{ "{% block content %}" }} 4 |
5 |
6 |
7 |

8 | {{ cookiecutter.project_description }} 9 |

10 | 18 |
19 |
20 |
21 | 22 | 23 |
24 |
25 |
26 |

Frequently asked questions

27 |
28 |
29 |
30 | 41 |
42 | 43 |
44 |

Our platform includes everything you need to get started quickly. All plans include core features, with premium plans offering advanced functionality and priority support.

45 |
46 |
47 |
48 |
49 |
50 | 61 |
62 | 67 |
68 |
69 |
70 |
71 |
72 | 73 | {{ "{% endblock content %}" }} 74 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/src/controllers/feedback_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus"; 2 | import { showMessage } from "../utils/messages"; 3 | 4 | export default class extends Controller { 5 | static targets = ["toggleButton", "overlay", "formContainer", "feedbackInput"]; 6 | 7 | connect() { 8 | // Initialize the controller 9 | this.isOpen = false; 10 | 11 | // Bind keyboard event handlers 12 | this.handleKeydownBound = this.handleKeydown.bind(this); 13 | document.addEventListener("keydown", this.handleKeydownBound); 14 | } 15 | 16 | disconnect() { 17 | // Clean up event listeners when controller disconnects 18 | document.removeEventListener("keydown", this.handleKeydownBound); 19 | } 20 | 21 | toggleFeedback() { 22 | if (this.isOpen) { 23 | this.closeFeedback(); 24 | } else { 25 | this.openFeedback(); 26 | } 27 | } 28 | 29 | openFeedback() { 30 | // Display the overlay 31 | this.overlayTarget.classList.remove("opacity-0", "pointer-events-none"); 32 | this.overlayTarget.classList.add("opacity-100", "pointer-events-auto"); 33 | 34 | // Scale up the form with animation 35 | setTimeout(() => { 36 | this.formContainerTarget.classList.remove("scale-95"); 37 | this.formContainerTarget.classList.add("scale-100"); 38 | }, 10); 39 | 40 | // Focus the input field 41 | setTimeout(() => { 42 | this.feedbackInputTarget.focus(); 43 | }, 300); 44 | 45 | this.isOpen = true; 46 | } 47 | 48 | closeFeedback() { 49 | // Scale down the form with animation 50 | this.formContainerTarget.classList.remove("scale-100"); 51 | this.formContainerTarget.classList.add("scale-95"); 52 | 53 | // Hide the overlay with animation 54 | setTimeout(() => { 55 | this.overlayTarget.classList.remove("opacity-100", "pointer-events-auto"); 56 | this.overlayTarget.classList.add("opacity-0", "pointer-events-none"); 57 | }, 100); 58 | 59 | this.isOpen = false; 60 | } 61 | 62 | closeIfClickedOutside(event) { 63 | // Close if clicked outside the form 64 | if (event.target === this.overlayTarget) { 65 | this.closeFeedback(); 66 | } 67 | } 68 | 69 | handleKeydown(event) { 70 | // Close with Escape key 71 | if (event.key === "Escape" && this.isOpen) { 72 | event.preventDefault(); 73 | this.closeFeedback(); 74 | } 75 | 76 | // Submit with Enter key when focused on the textarea (unless Shift is pressed for multiline) 77 | if (event.key === "Enter" && !event.shiftKey && this.isOpen && 78 | document.activeElement === this.feedbackInputTarget) { 79 | event.preventDefault(); 80 | this.submitFeedback(event); 81 | } 82 | } 83 | 84 | submitFeedback(event) { 85 | event.preventDefault(); 86 | 87 | const feedback = this.feedbackInputTarget.value.trim(); 88 | 89 | if (!feedback) { 90 | return; 91 | } 92 | 93 | // Add loading state 94 | const submitButton = event.target.tagName === 'BUTTON' ? event.target : this.element.querySelector('button[type="submit"]'); 95 | const originalButtonText = submitButton?.textContent || 'Submit'; 96 | if (submitButton) { 97 | submitButton.disabled = true; 98 | submitButton.textContent = 'Submitting...'; 99 | } 100 | 101 | fetch('/api/submit-feedback', { 102 | method: 'POST', 103 | headers: { 104 | 'Content-Type': 'application/json', 105 | 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value 106 | }, 107 | body: JSON.stringify({ feedback, page: window.location.pathname }), 108 | }) 109 | .then(response => { 110 | if (!response.ok) { 111 | throw new Error(`Server responded with ${response.status}: ${response.statusText}`); 112 | } 113 | return response.json(); 114 | }) 115 | .then(data => { 116 | this.resetForm(); 117 | this.closeFeedback(); 118 | showMessage(data.message || "Feedback submitted successfully", 'success'); 119 | }) 120 | .catch((error) => { 121 | console.error('Error:', error); 122 | showMessage(error.message || "Failed to submit feedback. Please try again later.", 'error'); 123 | // Reset loading state on error 124 | if (submitButton) { 125 | submitButton.disabled = false; 126 | submitButton.textContent = originalButtonText; 127 | } 128 | }); 129 | } 130 | 131 | resetForm() { 132 | this.feedbackInputTarget.value = ""; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/render.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | - type: web 3 | name: {{ cookiecutter.project_slug }}-web 4 | runtime: python 5 | buildCommand: | 6 | pip install -r requirements.txt && 7 | npm ci && 8 | npm run build && 9 | python manage.py collectstatic --noinput 10 | startCommand: python manage.py migrate && gunicorn {{ cookiecutter.project_slug }}.wsgi:application --bind 0.0.0.0:$PORT --workers 2 --threads 2 --timeout 120 11 | plan: free 12 | healthCheckPath: / 13 | 14 | envVars: 15 | - fromGroup: app-env 16 | - key: POSTGRES_DB 17 | fromDatabase: 18 | name: {{ cookiecutter.project_slug }}-db 19 | property: database 20 | - key: POSTGRES_USER 21 | fromDatabase: 22 | name: {{ cookiecutter.project_slug }}-db 23 | property: user 24 | - key: POSTGRES_PASSWORD 25 | fromDatabase: 26 | name: {{ cookiecutter.project_slug }}-db 27 | property: password 28 | - key: POSTGRES_HOST 29 | fromDatabase: 30 | name: {{ cookiecutter.project_slug }}-db 31 | property: host 32 | - key: POSTGRES_PORT 33 | fromDatabase: 34 | name: {{ cookiecutter.project_slug }}-db 35 | property: port 36 | - key: REDIS_HOST 37 | fromService: 38 | type: keyvalue 39 | name: {{ cookiecutter.project_slug }}-redis 40 | property: host 41 | - key: REDIS_PORT 42 | fromService: 43 | type: keyvalue 44 | name: {{ cookiecutter.project_slug }}-redis 45 | property: port 46 | - key: REDIS_PASSWORD 47 | fromService: 48 | type: keyvalue 49 | name: {{ cookiecutter.project_slug }}-redis 50 | property: password 51 | 52 | - type: web 53 | name: {{ cookiecutter.project_slug }}-workers 54 | runtime: python 55 | buildCommand: | 56 | pip install -r requirements.txt 57 | startCommand: python manage.py qcluster & python -m http.server $PORT 58 | plan: free 59 | healthCheckPath: / 60 | 61 | envVars: 62 | - fromGroup: app-env 63 | - key: POSTGRES_DB 64 | fromDatabase: 65 | name: {{ cookiecutter.project_slug }}-db 66 | property: database 67 | - key: POSTGRES_USER 68 | fromDatabase: 69 | name: {{ cookiecutter.project_slug }}-db 70 | property: user 71 | - key: POSTGRES_PASSWORD 72 | fromDatabase: 73 | name: {{ cookiecutter.project_slug }}-db 74 | property: password 75 | - key: POSTGRES_HOST 76 | fromDatabase: 77 | name: {{ cookiecutter.project_slug }}-db 78 | property: host 79 | - key: POSTGRES_PORT 80 | fromDatabase: 81 | name: {{ cookiecutter.project_slug }}-db 82 | property: port 83 | - key: REDIS_HOST 84 | fromService: 85 | type: keyvalue 86 | name: {{ cookiecutter.project_slug }}-redis 87 | property: host 88 | - key: REDIS_PORT 89 | fromService: 90 | type: keyvalue 91 | name: {{ cookiecutter.project_slug }}-redis 92 | property: port 93 | - key: REDIS_PASSWORD 94 | fromService: 95 | type: keyvalue 96 | name: {{ cookiecutter.project_slug }}-redis 97 | property: password 98 | 99 | - type: keyvalue 100 | name: {{ cookiecutter.project_slug }}-redis 101 | plan: free 102 | ipAllowList: [] 103 | maxmemoryPolicy: allkeys-lfu 104 | 105 | databases: 106 | - name: {{ cookiecutter.project_slug }}-db 107 | databaseName: {{ cookiecutter.project_slug }} 108 | user: {{ cookiecutter.project_slug }}_user 109 | plan: free 110 | 111 | 112 | envVarGroups: 113 | - name: app-env 114 | envVars: 115 | - key: PYTHON_VERSION 116 | value: "3.11.6" 117 | - key: ENVIRONMENT 118 | value: prod 119 | - key: DEBUG 120 | value: "False" 121 | - key: SECRET_KEY 122 | generateValue: true 123 | 124 | # Site URL - auto-generated by Render 125 | - key: SITE_URL 126 | value: $RENDER_EXTERNAL_URL 127 | 128 | {% if cookiecutter.use_s3 == 'y' %} 129 | # Optional. If not specified, local FileSystemStorage is used for media files. 130 | - key: AWS_S3_ENDPOINT_URL 131 | value: "" 132 | - key: AWS_ACCESS_KEY_ID 133 | value: "" 134 | - key: AWS_SECRET_ACCESS_KEY 135 | value: "" 136 | {% endif %} 137 | 138 | # Basic email setup - can be configured later 139 | - key: MAILGUN_API_KEY 140 | value: "" 141 | 142 | # Redis Database Number 143 | - key: REDIS_DB 144 | value: "0" 145 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/account/password_reset_from_key.html: -------------------------------------------------------------------------------- 1 | {{ "{% extends 'base_landing.html' %}" }} 2 | {{ "{% load widget_tweaks %}" }} 3 | 4 | {{ "{% block content %}" }} 5 |
6 |
7 |
8 |

9 | {% raw %}{% if token_fail %}{% endraw %} 10 | Bad Token 11 | {% raw %}{% else %}{% endraw %} 12 | {% raw %}{% if form %}{% endraw %} 13 | Set your new password 14 | {% raw %}{% else %}{% endraw %} 15 | Your password is now changed 16 | {% raw %}{% endif %}{% endraw %} 17 | {% raw %}{% endif %}{% endraw %} 18 |

19 | {% raw %}{% if token_fail %}{% endraw %} 20 |

21 | The password reset link was invalid, possibly because it has already been used. Please request a new password reset. 22 |

23 | {% raw %}{% else %}{% endraw %} 24 | {% raw %}{% if form %}{% endraw %} 25 |

26 | Enter your new password below. 27 |

28 | {% raw %}{% endif %}{% endraw %} 29 | {% raw %}{% endif %}{% endraw %} 30 |
31 | 32 | {% raw %}{% if token_fail %}{% endraw %} 33 | 39 | {% raw %}{% else %}{% endraw %} 40 | {% raw %}{% if form %}{% endraw %} 41 |
42 | {{ "{% csrf_token %}" }} 43 | {{ "{{ form.non_field_errors | safe }}" }} 44 | 45 |
46 |
47 | {{ "{{ form.password1.errors | safe }}" }} 48 | 49 | {{ '{% render_field form.password1 placeholder="New password" id="password1" name="password1" type="password" autocomplete="new-password" required=True class="block relative px-3 py-2 w-full placeholder-gray-500 text-gray-900 rounded-none rounded-t-md border border-gray-300 appearance-none focus:outline-none focus:ring-{{cookiecutter.project_main_color}}-500 focus:border-{{cookiecutter.project_main_color}}-500 focus:z-10 sm:text-sm" %}' }} 50 |
51 |
52 | {{ "{{ form.password2.errors | safe }}" }} 53 | 54 | {{ '{% render_field form.password2 placeholder="Confirm new password" id="password2" name="password2" type="password" autocomplete="new-password" required=True class="block relative px-3 py-2 w-full placeholder-gray-500 text-gray-900 rounded-none rounded-b-md border border-gray-300 appearance-none focus:outline-none focus:ring-{{cookiecutter.project_main_color}}-500 focus:border-{{cookiecutter.project_main_color}}-500 focus:z-10 sm:text-sm" %}' }} 55 |
56 |
57 | 58 |
59 | 63 |
64 |
65 | {% raw %}{% endif %}{% endraw %} 66 | {% raw %}{% endif %}{% endraw %} 67 |
68 |
69 | {{ "{% endblock content %}" }} 70 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/apps/core/tasks.py: -------------------------------------------------------------------------------- 1 | {% if cookiecutter.use_posthog == 'y' -%} 2 | import json 3 | from urllib.parse import unquote 4 | 5 | import posthog 6 | {% endif %} 7 | 8 | import requests 9 | from django.conf import settings 10 | 11 | from apps.core.models import Profile 12 | from {{ cookiecutter.project_slug }}.utils import get_{{ cookiecutter.project_slug }}_logger 13 | 14 | logger = get_{{ cookiecutter.project_slug }}_logger(__name__) 15 | 16 | {% if cookiecutter.use_buttondown == 'y' -%} 17 | def add_email_to_buttondown(email, tag): 18 | if not settings.BUTTONDOWN_API_KEY: 19 | return "Buttondown API key not found." 20 | 21 | data = { 22 | "email_address": str(email), 23 | "metadata": {"source": tag}, 24 | "tags": [tag], 25 | "referrer_url": "https://{{ cookiecutter.project_slug }}.com", 26 | "type": "regular", 27 | } 28 | 29 | r = requests.post( 30 | "https://api.buttondown.email/v1/subscribers", 31 | headers={"Authorization": f"Token {settings.BUTTONDOWN_API_KEY}"}, 32 | json=data, 33 | ) 34 | 35 | return r.json() 36 | {% endif %} 37 | 38 | {% if cookiecutter.use_posthog == 'y' -%} 39 | def try_create_posthog_alias(profile_id: int, cookies: dict, source_function: str = None) -> str: 40 | if not settings.POSTHOG_API_KEY: 41 | return "PostHog API key not found." 42 | 43 | base_log_data = { 44 | "profile_id": profile_id, 45 | "cookies": cookies, 46 | "source_function": source_function, 47 | } 48 | 49 | profile = Profile.objects.get(id=profile_id) 50 | email = profile.user.email 51 | 52 | base_log_data["email"] = email 53 | base_log_data["profile_id"] = profile_id 54 | 55 | posthog_cookie = cookies.get(f"ph_{settings.POSTHOG_API_KEY}_posthog") 56 | if not posthog_cookie: 57 | logger.warning("[Try Create Posthog Alias] No PostHog cookie found.", **base_log_data) 58 | return f"No PostHog cookie found for profile {profile_id}." 59 | base_log_data["posthog_cookie"] = posthog_cookie 60 | 61 | logger.info("[Try Create Posthog Alias] Setting PostHog alias", **base_log_data) 62 | 63 | cookie_dict = json.loads(unquote(posthog_cookie)) 64 | frontend_distinct_id = cookie_dict.get("distinct_id") 65 | 66 | if frontend_distinct_id: 67 | posthog.alias(frontend_distinct_id, email) 68 | posthog.alias(frontend_distinct_id, str(profile_id)) 69 | 70 | logger.info("[Try Create Posthog Alias] Set PostHog alias", **base_log_data) 71 | 72 | 73 | def track_event( 74 | profile_id: int, event_name: str, properties: dict, source_function: str = None 75 | ) -> str: 76 | if not settings.POSTHOG_API_KEY: 77 | return "PostHog API key not found." 78 | 79 | base_log_data = { 80 | "profile_id": profile_id, 81 | "event_name": event_name, 82 | "properties": properties, 83 | "source_function": source_function, 84 | } 85 | 86 | try: 87 | profile = Profile.objects.get(id=profile_id) 88 | except Profile.DoesNotExist: 89 | logger.error("[TrackEvent] Profile not found.", **base_log_data) 90 | return f"Profile with id {profile_id} not found." 91 | 92 | posthog.capture( 93 | profile.user.email, 94 | event=event_name, 95 | properties={ 96 | "profile_id": profile.id, 97 | "email": profile.user.email, 98 | "current_state": profile.state, 99 | **properties, 100 | }, 101 | ) 102 | 103 | logger.info("[TrackEvent] Tracked event", **base_log_data) 104 | 105 | return f"Tracked event {event_name} for profile {profile_id}" 106 | {% endif %} 107 | 108 | 109 | def track_state_change( 110 | profile_id: int, from_state: str, to_state: str, metadata: dict = None 111 | ) -> None: 112 | from apps.core.models import Profile, ProfileStateTransition 113 | 114 | base_log_data = { 115 | "profile_id": profile_id, 116 | "from_state": from_state, 117 | "to_state": to_state, 118 | "metadata": metadata, 119 | } 120 | 121 | try: 122 | profile = Profile.objects.get(id=profile_id) 123 | except Profile.DoesNotExist: 124 | logger.error("[TrackStateChange] Profile not found.", **base_log_data) 125 | return f"Profile with id {profile_id} not found." 126 | 127 | if from_state != to_state: 128 | logger.info("[TrackStateChange] Tracking state change", **base_log_data) 129 | ProfileStateTransition.objects.create( 130 | profile=profile, 131 | from_state=from_state, 132 | to_state=to_state, 133 | backup_profile_id=profile_id, 134 | metadata=metadata, 135 | ) 136 | profile.state = to_state 137 | profile.save(update_fields=["state"]) 138 | 139 | return f"Tracked state change from {from_state} to {to_state} for profile {profile_id}" 140 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/pages/uses.html: -------------------------------------------------------------------------------- 1 | {{ '{% extends "base_landing.html" %}' }} 2 | {{ "{% load webpack_loader static %}" }} 3 | 4 | {{ "{% block meta %}" }} 5 | Technologies We Use | {{ cookiecutter.project_name }} 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {{ "{% endblock meta %}" }} 22 | 23 | {{ "{% block content %}" }} 24 |
25 |

Tech I Use

26 |

27 | I run 28 | multiple projects 29 | at the same time and using reliable and simple tech is the key to running everything smoothly. Here's what I use: 30 |

31 |
    32 |
  • Sentry: For error tracking and performance monitoring, ensuring a smooth user experience.
  • 33 |
  • Hetzner: Our cloud infrastructure provider, hosting our servers and services.
  • 34 |
  • Buttondown: for the newsletter.
  • 35 |
  • Django: Our primary web framework, providing a robust foundation for our backend.
  • 36 |
  • Django-Q2: A task queue and scheduler for Django, helping us manage background tasks efficiently.
  • 37 |
  • CapRover: Our deployment and server management tool, simplifying our DevOps processes.
  • 38 |
  • GitHub: For version control and collaborative development of our codebase.
  • 39 |
  • Redis: An in-memory data structure store, used as a database, cache, and message broker.
  • 40 |
  • PostgreSQL: Our primary relational database management system.
  • 41 |
  • Anthropic: Leveraging AI capabilities to enhance our services.
  • 42 |
  • Replicate: For AI model deployment and management.
  • 43 |
  • StimulusJS: A modest JavaScript framework for the "sprinkles of interactivity" in our frontend.
  • 44 |
  • WhiteNoise: For efficient serving of static files.
  • 45 |
  • Logfire: For all my logging needs.
  • 46 |
47 |

If you have any questions about our technology choices or are interested in learning more, feel free to contact us at contact@builtwithdjango.com.

48 |
49 | {{ "{% endblock content %}" }} 50 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/src/styles/pygments.css: -------------------------------------------------------------------------------- 1 | /* Documentation code block styling - Monokai theme */ 2 | .codehilite { 3 | background: #272822; 4 | border-radius: 0.5rem; 5 | margin: 1.5rem 0; 6 | overflow: hidden; 7 | box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); 8 | } 9 | 10 | .codehilite pre { 11 | background: #272822; 12 | color: #f8f8f2; 13 | padding: 1rem; 14 | margin: 0; 15 | overflow-x: auto; 16 | font-size: 0.875rem; 17 | line-height: 1.7; 18 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; 19 | } 20 | 21 | .codehilite code { 22 | background: transparent; 23 | padding: 0; 24 | border-radius: 0; 25 | font-size: inherit; 26 | color: #f8f8f2; 27 | } 28 | 29 | /* Syntax highlighting colors - Monokai (modified for better bash visibility) */ 30 | .codehilite .hll { background-color: #49483e; } 31 | .codehilite .c { color: #75715e; } /* Comment */ 32 | .codehilite .err { color: #960050; background-color: #1e0010; } /* Error */ 33 | .codehilite .k { color: #66d9ef; } /* Keyword */ 34 | .codehilite .l { color: #ae81ff; } /* Literal */ 35 | .codehilite .n { color: #f8f8f2; } /* Name */ 36 | .codehilite .o { color: #fd971f; } /* Operator - changed from red to orange */ 37 | .codehilite .p { color: #f8f8f2; } /* Punctuation */ 38 | .codehilite .ch { color: #75715e; } /* Comment.Hashbang */ 39 | .codehilite .cm { color: #75715e; } /* Comment.Multiline */ 40 | .codehilite .cp { color: #75715e; } /* Comment.Preproc */ 41 | .codehilite .cpf { color: #75715e; } /* Comment.PreprocFile */ 42 | .codehilite .c1 { color: #75715e; } /* Comment.Single */ 43 | .codehilite .cs { color: #75715e; } /* Comment.Special */ 44 | .codehilite .gd { color: #f92672; } /* Generic.Deleted */ 45 | .codehilite .ge { font-style: italic; } /* Generic.Emph */ 46 | .codehilite .gi { color: #a6e22e; } /* Generic.Inserted */ 47 | .codehilite .gs { font-weight: bold; } /* Generic.Strong */ 48 | .codehilite .gu { color: #75715e; } /* Generic.Subheading */ 49 | .codehilite .kc { color: #66d9ef; } /* Keyword.Constant */ 50 | .codehilite .kd { color: #66d9ef; } /* Keyword.Declaration */ 51 | .codehilite .kn { color: #fd971f; } /* Keyword.Namespace - changed from red to orange */ 52 | .codehilite .kp { color: #66d9ef; } /* Keyword.Pseudo */ 53 | .codehilite .kr { color: #66d9ef; } /* Keyword.Reserved */ 54 | .codehilite .kt { color: #66d9ef; } /* Keyword.Type */ 55 | .codehilite .ld { color: #e6db74; } /* Literal.Date */ 56 | .codehilite .m { color: #ae81ff; } /* Literal.Number */ 57 | .codehilite .s { color: #e6db74; } /* Literal.String */ 58 | .codehilite .na { color: #a6e22e; } /* Name.Attribute */ 59 | .codehilite .nb { color: #a6e22e; } /* Name.Builtin - bash commands like cd, echo */ 60 | .codehilite .nc { color: #a6e22e; } /* Name.Class */ 61 | .codehilite .no { color: #66d9ef; } /* Name.Constant */ 62 | .codehilite .nd { color: #a6e22e; } /* Name.Decorator */ 63 | .codehilite .ni { color: #f8f8f2; } /* Name.Entity */ 64 | .codehilite .ne { color: #a6e22e; } /* Name.Exception */ 65 | .codehilite .nf { color: #a6e22e; } /* Name.Function */ 66 | .codehilite .nl { color: #f8f8f2; } /* Name.Label */ 67 | .codehilite .nn { color: #f8f8f2; } /* Name.Namespace */ 68 | .codehilite .nx { color: #a6e22e; } /* Name.Other */ 69 | .codehilite .py { color: #f8f8f2; } /* Name.Property */ 70 | .codehilite .nt { color: #fd971f; } /* Name.Tag - changed from red to orange */ 71 | .codehilite .nv { color: #f8f8f2; } /* Name.Variable */ 72 | .codehilite .ow { color: #fd971f; } /* Operator.Word - changed from red to orange */ 73 | .codehilite .w { color: #f8f8f2; } /* Text.Whitespace */ 74 | .codehilite .mb { color: #ae81ff; } /* Literal.Number.Bin */ 75 | .codehilite .mf { color: #ae81ff; } /* Literal.Number.Float */ 76 | .codehilite .mh { color: #ae81ff; } /* Literal.Number.Hex */ 77 | .codehilite .mi { color: #ae81ff; } /* Literal.Number.Integer */ 78 | .codehilite .mo { color: #ae81ff; } /* Literal.Number.Oct */ 79 | .codehilite .sa { color: #e6db74; } /* Literal.String.Affix */ 80 | .codehilite .sb { color: #e6db74; } /* Literal.String.Backtick */ 81 | .codehilite .sc { color: #e6db74; } /* Literal.String.Char */ 82 | .codehilite .dl { color: #e6db74; } /* Literal.String.Delimiter */ 83 | .codehilite .sd { color: #e6db74; } /* Literal.String.Doc */ 84 | .codehilite .s2 { color: #e6db74; } /* Literal.String.Double */ 85 | .codehilite .se { color: #ae81ff; } /* Literal.String.Escape */ 86 | .codehilite .sh { color: #e6db74; } /* Literal.String.Heredoc */ 87 | .codehilite .si { color: #e6db74; } /* Literal.String.Interpol */ 88 | .codehilite .sx { color: #e6db74; } /* Literal.String.Other */ 89 | .codehilite .sr { color: #e6db74; } /* Literal.String.Regex */ 90 | .codehilite .s1 { color: #e6db74; } /* Literal.String.Single */ 91 | .codehilite .ss { color: #e6db74; } /* Literal.String.Symbol */ 92 | .codehilite .bp { color: #f8f8f2; } /* Name.Builtin.Pseudo */ 93 | .codehilite .fm { color: #a6e22e; } /* Name.Function.Magic */ 94 | .codehilite .vc { color: #f8f8f2; } /* Name.Variable.Class */ 95 | .codehilite .vg { color: #f8f8f2; } /* Name.Variable.Global */ 96 | .codehilite .vi { color: #f8f8f2; } /* Name.Variable.Instance */ 97 | .codehilite .vm { color: #f8f8f2; } /* Name.Variable.Magic */ 98 | .codehilite .il { color: #ae81ff; } /* Literal.Number.Integer.Long */ 99 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/README.md: -------------------------------------------------------------------------------- 1 |

2 | {{ cookiecutter.project_name }} Logo 3 |

4 | 5 | 6 |
7 | {{ cookiecutter.project_name }} 8 | {{ cookiecutter.project_description }} 9 |
10 | 11 | *** 12 | 13 | ## Overview 14 | 15 | - Add info about your project here 16 | 17 | *** 18 | 19 | ## TOC 20 | 21 | - [Overview](#overview) 22 | - [TOC](#toc) 23 | - [Deployment](#deployment) 24 | - [Render](#render) 25 | - [Docker Compose](#docker-compose) 26 | - [Pure Python / Django deployment](#pure-python--django-deployment) 27 | - [Custom Deployment on Caprover](#custom-deployment-on-caprover) 28 | - [Local Development](#local-development) 29 | 30 | *** 31 | 32 | ## Deployment 33 | 34 | ### Render 35 | 36 | [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo={{ cookiecutter.repo_url }}) 37 | 38 | **Note:** This should work out of the box with Render's free tier if you provide the AI API keys. Here's what you need to know about the limitations: 39 | 40 | - **Worker Service Limitation**: The worker service is not a dedicated worker type (those are only available on paid plans). For the free tier, I had to use a web service through a small hack, but it works fine for most use cases. 41 | 42 | - **Memory Constraints**: The free web service has a 512 MB RAM limit, which can cause issues with **automated background tasks only**. When you add a project, it runs a suite of background tasks to analyze your website, generate articles, keywords, and other content. These automated processes can hit memory limits and potentially cause failures. 43 | 44 | - **Manual Tasks Work Fine**: However, if you perform tasks manually (like generating a single article), these typically use the web service instead of the worker and should work reliably since it's one request at a time. 45 | 46 | - **Upgrade Recommendation**: If you do upgrade to a paid plan, use the actual worker service instead of the web service workaround for better automated task reliability. 47 | 48 | **Reality Check**: The website functionality should be usable on the free tier - you'll only pay for API costs. Manual operations work fine, but automated background tasks (especially when adding multiple projects) may occasionally fail due to memory constraints. It's not super comfortable for heavy automated use, but perfectly functional for manual content generation. 49 | 50 | If you know of any other services like Render that allow deployment via a button and provide free Redis, Postgres, and web services, please let me know in the [Issues]({{ cookiecutter.repo_url }}/issues) section. I can try to create deployments for those. Bear in mind that free services are usually not large enough to run this application reliably. 51 | 52 | 53 | ### Docker Compose 54 | 55 | This should also be pretty streamlined. On your server you can create a folder in which you will have 2 files: 56 | 57 | 1. `.env` 58 | 59 | Copy the contents of `.env.example` into `.env` and update all the necessary values. 60 | 61 | 2. `docker-compose-prod.yml` 62 | 63 | Copy the contents of `docker-compose-prod.yml` into `docker-compose-prod.yml` and run the suggested command from the top of the `docker-compose-prod.yml` file. 64 | 65 | How you are going to expose the backend container is up to you. I usually do it via Nginx Reverse Proxy with `http://{{ cookiecutter.project_slug }}-backend-1:80` UPSTREAM_HTTP_ADDRESS. 66 | 67 | 68 | ### Pure Python / Django deployment 69 | 70 | Not recommended due to not being too safe for production and not being tested by me. 71 | 72 | If you are not into Docker or Render and just wanto to run this via regular commands you will need to have 5 processes running: 73 | - `python manage.py collectstatic --noinput && python manage.py migrate && gunicorn ${PROJECT_NAME}.wsgi:application --bind 0.0.0.0:80 --workers 3 --threads 2` 74 | - `python manage.py qcluster` 75 | - `npm install && npm run start` 76 | - `postgres` 77 | - `redis` 78 | 79 | You'd still need to make sure .env has correct values. 80 | 81 | ### Custom Deployment on Caprover 82 | 83 | 1. Create 4 apps on CapRover. 84 | - `{{ cookiecutter.project_slug }}` 85 | - `{{ cookiecutter.project_slug }}-workers` 86 | - `{{ cookiecutter.project_slug }}-postgres` 87 | - `{{ cookiecutter.project_slug }}-redis` 88 | 89 | 2. Create a new CapRover app token for: 90 | - `{{ cookiecutter.project_slug }}` 91 | - `{{ cookiecutter.project_slug }}-workers` 92 | 93 | 3. Add Environment Variables to those same apps from `.env`. 94 | 95 | 4. Create a new GitHub Actions secret with the following: 96 | - `CAPROVER_SERVER` 97 | - `CAPROVER_APP_TOKEN` 98 | - `WORKERS_APP_TOKEN` 99 | - `REGISTRY_TOKEN` 100 | 101 | 5. Then just push main branch. 102 | 103 | 6. Github Workflow in this repo should take care of the rest. 104 | 105 | ## Local Development 106 | 107 | 1. Update the name of the `.env.example` to `.env` and update relevant variables. 108 | 2. Run `poetry export -f requirements.txt --output requirements.txt --without-hashes` 109 | 3. Run `poetry run python manage.py makemigrations` 110 | 4. Run `make serve` 111 | 5. Run `make restart-worker` just in case, it sometimes has troubles connecting to REDIS on first deployment. 112 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/pages/user-settings.html: -------------------------------------------------------------------------------- 1 | {{ "{% extends 'base_app.html' %}" }} 2 | {{ "{% load webpack_loader static %}" }} 3 | {{ "{% load widget_tweaks %}" }} 4 | 5 | {{ "{% block meta %}" }} 6 | {{ cookiecutter.project_name }} - User Settings 7 | {{ "{% endblock meta %}" }} 8 | 9 | {{ "{% block content %}" }} 10 |
11 | {{ '{% include "components/confirm-email.html" with email_verified=email_verified %}' }} 12 |
13 |
14 |

Settings

15 |
16 |
17 | {% if cookiecutter.use_stripe == 'y' -%} 18 | {{ "{% if not has_subscription %}" }} 19 |
20 |

Upgrade Your Account

21 |

Choose a plan that suits your needs:

22 | 42 |
43 | {{ "{% endif %}" }} 44 | {% endif %} 45 | 46 | 47 |
48 | {{ "{% csrf_token %}" }} 49 | 50 |
51 |
52 |

Personal Information

53 |
54 |
55 |
56 |
57 | {{ "{{ form.non_field_errors }}" }} 58 |
59 | {{ "{{ form.first_name.errors | safe }}" }} 60 | 61 | {{ '{% render_field form.first_name id="first-name" name="first-name" type="text" autocomplete="given-name" class="block mt-1 w-full rounded-md border-gray-300 shadow-sm focus:border-{{ cookiecutter.project_main_color }}-500 focus:ring-{{ cookiecutter.project_main_color }}-500 sm:text-sm" %}' }} 62 |
63 | 64 |
65 | {{ "{{ form.last_name.errors | safe }}" }} 66 | 67 | {{ '{% render_field form.last_name id="last-name" name="last-name" type="text" autocomplete="family-name" class="block mt-1 w-full rounded-md border-gray-300 shadow-sm focus:border-{{ cookiecutter.project_main_color }}-500 focus:ring-{{ cookiecutter.project_main_color }}-500 sm:text-sm" %}' }} 68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 | 76 | 81 | 82 |
83 |
84 | 87 |
88 |
89 |
90 | 91 |
92 | {{ "{% endblock content %}" }} 93 | --------------------------------------------------------------------------------