├── .gitignore ├── CHANGELOG.md ├── README.md ├── cookiecutter.json ├── poetry.lock ├── pyproject.toml └── {{ cookiecutter.project_slug }} ├── .babelrc ├── .browserslistrc ├── .cursor ├── mcp.json └── rules │ ├── ai.mdc │ ├── frontend.mdc │ ├── stimulus-events.mdc │ └── stimulus-general.mdc ├── .env.example ├── .eslintrc ├── .flake8 ├── .github └── workflows │ ├── deploy-workers.yml │ └── deploy.yml ├── .gitignore ├── .nvmrc ├── .pre-commit-config.yaml ├── .stylelintrc.json ├── Dockerfile-python ├── Makefile ├── README.md ├── core ├── __init__.py ├── admin.py ├── api │ ├── auth.py │ ├── schemas.py │ └── views.py ├── apps.py ├── base_models.py ├── choices.py ├── forms.py ├── migrations │ ├── 0001_enable_extensions.py │ └── __init__.py ├── model_utils.py ├── models.py ├── signals.py ├── tasks.py ├── templatetags │ ├── __init__.py │ └── markdown_extras.py ├── tests │ ├── __init__.py │ ├── conftest.py │ └── test_views.py ├── urls.py ├── utils.py ├── views.py └── webhooks.py ├── deployment ├── Dockerfile.server ├── Dockerfile.workers └── entrypoint.sh ├── docker-compose.yml ├── docs ├── getting-started.md └── index.md ├── frontend ├── README.md ├── src │ ├── application │ │ └── index.js │ ├── controllers │ │ ├── feedback_controller.js │ │ ├── testing_controller.js │ │ └── user_settings_controller.js │ ├── styles │ │ └── index.css │ └── utils │ │ └── messages.js ├── templates │ ├── account │ │ ├── email_confirm.html │ │ ├── login.html │ │ ├── logout.html │ │ └── signup.html │ ├── base.html │ ├── blog │ │ ├── blog_post.html │ │ └── blog_posts.html │ ├── components │ │ ├── confirm-email.html │ │ ├── feedback.html │ │ └── messages.html │ ├── emails │ │ └── test_mjml.html │ └── pages │ │ ├── home.html │ │ ├── pricing.html │ │ ├── user-settings.html │ │ └── uses.html ├── vendors │ ├── .gitkeep │ └── images │ │ ├── .gitkeep │ │ ├── logo.png │ │ ├── sample.jpg │ │ ├── unknown-man.png │ │ └── webpack.png └── webpack │ ├── webpack.common.js │ ├── webpack.config.dev.js │ ├── webpack.config.prod.js │ └── webpack.config.watch.js ├── manage.py ├── mkdocs.yml ├── package.json ├── poetry.toml ├── postcss.config.js ├── pyproject.toml ├── pytest.ini ├── tailwind.config.js └── {{ cookiecutter.project_slug }} ├── __init__.py ├── asgi.py ├── requirements.txt ├── sentry_utils.py ├── settings.py ├── sitemaps.py ├── storages.py ├── urls.py ├── utils.py └── wsgi.py /.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 | -------------------------------------------------------------------------------- /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 | ## [0.0.3] - 2024-11-11 17 | ### Added 18 | - Fix missing orphan on User settings page. 19 | - Ignore the djlint " vs. ' error. 20 | - Add django-ninja (with Auth and test endpoint) 21 | - Update dependencies 22 | 23 | 24 | ## [0.0.2] - 2024-10-10 25 | ### Added 26 | - SEO tags + JSON-LD on all the pages 27 | - Optional Blog 28 | - All pages to the sitemap 29 | 30 | ## [0.0.1] - 2024-09-28 31 | ### Added 32 | - Sign-in with Github and Logout button don't go to separate screen anymore. 33 | 34 | ### Fixed 35 | - close button on messages now works fine 36 | -------------------------------------------------------------------------------- /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 | - Django 5 and Python 3.11 8 | - User authentication (regular + social) via django-allauth 9 | - Environment variables via django-environ 10 | - TailwindCSS & StimulusJS for frontend via Webpack 11 | - Will work with any DB of your choosing, as long as it is supported by django. 12 | - Comes with Postgres 17 with pgvector and pg_stat_statements pre-installed 13 | - Local deploy via docker-compose and makefile for rapid local development 14 | - Media storage with any S3 compatible service. 15 | - Comes with Minio both locally and in prod 16 | - Anymail for email sending with Mailgun (Mailhog for local) 17 | - Structlog for logging setup both for local (console) and prod (json) 18 | - Automated Deployment to Caprover via Github Actions 19 | - Messages handling 20 | - with nice tempalte component pre-installed 21 | - Sitemaps enabled 22 | - Testing with pytest 23 | - Pre-commit for code quality checks 24 | - Optimized SEO - Added all the necessary metatags and json-ld schema on all the pages with nice looking OG images. 25 | - API support with django-ninja 26 | - Way to collect feedback pre-installed via a nice widget 27 | 28 | Optional Integrations: 29 | - Social Authentication (Github) 30 | - Stripe for payments 31 | - Buttondown for newsletters 32 | - Github Auth for social sign-ins 33 | - Sentry integration 34 | - MJML for email templating 35 | - Logfire for prod and dev logging dashboards 36 | 37 | ## Roadmap 38 | - [ ] Drastically improve the documentation structure. Right now everything leaves in the Generated README file. 39 | -------------------------------------------------------------------------------- /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_social_auth": "y", 11 | "use_github_auth": "y", 12 | "use_buttondown": "y", 13 | "use_stripe": "y", 14 | "use_sentry": "y", 15 | "generate_blog": "y", 16 | "use_mjml": "y", 17 | "use_ai": "y", 18 | "use_logfire": "y" 19 | } 20 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. 2 | package = [] 3 | 4 | [metadata] 5 | lock-version = "2.0" 6 | python-versions = "^3.10" 7 | content-hash = "17ca553b0bb9298a6ed528dd21e544ca433179192dba32a9920168e1c199d74f" 8 | -------------------------------------------------------------------------------- /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.10" 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 }}/.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 }}/.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 }}/.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 }}/.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 }}/.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 }}/.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 }}/.env.example: -------------------------------------------------------------------------------- 1 | DEBUG=on 2 | ENVIRONMENT=dev 3 | 4 | SECRET_KEY="super-secret-key" 5 | 6 | ALLOWED_HOSTS=* 7 | CSRF_TRUSTED_ORIGINS=http://127.0.0.1:8000,http://localhost:8000 8 | 9 | DATABASE_URL=postgres://{{ cookiecutter.project_slug }}:{{ cookiecutter.project_slug }}@db:5432/{{ cookiecutter.project_slug }} 10 | REDIS_URL=redis://:{{ cookiecutter.project_slug }}@redis:6379/0 11 | 12 | {% if cookiecutter.use_github_auth == 'y' -%} 13 | # Get his values after creating a new app on GitHub: https://github.com/settings/applications/new 14 | # For Homepage URL use: http://localhost:8000 15 | # For Authorization callback URL use: http://localhost:8000/accounts/github/login/callback/ 16 | GITHUB_CLIENT_ID="" 17 | GITHUB_CLIENT_SECRET="" 18 | {%- endif %} 19 | 20 | AWS_S3_ENDPOINT_URL=http://localhost:9000 21 | AWS_ACCESS_KEY_ID={{ cookiecutter.project_slug }} 22 | AWS_SECRET_ACCESS_KEY={{ cookiecutter.project_slug }} 23 | 24 | {% if cookiecutter.use_sentry == 'y' -%} 25 | SENTRY_DSN= 26 | {%- endif %} 27 | MAILGUN_API_KEY= 28 | {% if cookiecutter.use_posthog == 'y' -%} 29 | POSTHOG_API_KEY= 30 | {%- endif %} 31 | {% if cookiecutter.use_buttondown == 'y' -%} 32 | BUTTONDOWN_API_KEY= 33 | {%- endif %} 34 | 35 | {% if cookiecutter.use_stripe == 'y' -%} 36 | STRIPE_LIVE_SECRET_KEY= 37 | STRIPE_TEST_SECRET_KEY= 38 | DJSTRIPE_WEBHOOK_SECRET= 39 | WEBHOOK_UUID= 40 | {%- endif %} 41 | 42 | {% if cookiecutter.use_mjml == 'y' -%} 43 | # Get these values here: https://mjml.io/api/ 44 | MJML_SECRET=... 45 | MJML_APPLICATION_ID=... 46 | {%- endif %} 47 | 48 | {% if cookiecutter.use_ai == 'y' -%} 49 | # depending on what AI provider you use, you can set the API key here. 50 | # We are using PydanticAI so use names suggested in their docs: 51 | # https://ai.pydantic.dev/models/ 52 | OPENAI_API_KEY= 53 | {%- endif %} 54 | 55 | {% if cookiecutter.use_logfire == 'y' -%} 56 | LOGFIRE_TOKEN= 57 | LOGFIRE_CONSOLE_SHOW_PROJECT_LINK=False 58 | {%- endif %} 59 | -------------------------------------------------------------------------------- /{{ 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 }}/.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length=120 -------------------------------------------------------------------------------- /{{ 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 }}/.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 }}/.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 }}/.nvmrc: -------------------------------------------------------------------------------- 1 | lts/gallium 2 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: .*migrations\/.* 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v4.4.0 5 | hooks: 6 | - id: check-yaml 7 | - id: end-of-file-fixer 8 | - id: trailing-whitespace 9 | 10 | - repo: https://github.com/psf/black 11 | rev: 22.12.0 12 | hooks: 13 | - id: black 14 | language_version: python3.9 15 | 16 | - repo: https://github.com/charliermarsh/ruff-pre-commit 17 | rev: 'v0.1.14' 18 | hooks: 19 | - id: ruff 20 | 21 | - repo: https://github.com/pycqa/isort 22 | rev: 5.12.0 23 | hooks: 24 | - id: isort 25 | name: isort (python) 26 | 27 | - repo: https://github.com/djlint/djLint 28 | rev: v1.35.2 29 | hooks: 30 | - id: djlint-django 31 | 32 | - repo: https://github.com/python-poetry/poetry 33 | rev: '1.4.1' 34 | hooks: 35 | - id: poetry-export 36 | args: [ 37 | "-f", "requirements.txt", 38 | "-o", "requirements.txt", 39 | "--without-hashes" 40 | ] 41 | -------------------------------------------------------------------------------- /{{ 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 }}/Dockerfile-python: -------------------------------------------------------------------------------- 1 | FROM python:3.11 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 }}/Makefile: -------------------------------------------------------------------------------- 1 | serve: 2 | docker-compose up -d --build 3 | docker compose logs -f backend 4 | 5 | shell: 6 | docker compose run --rm backend python ./manage.py shell_plus --ipython 7 | 8 | manage: 9 | docker compose run --rm backend python ./manage.py $(filter-out $@,$(MAKECMDGOALS)) 10 | 11 | makemigrations: 12 | docker compose run --rm backend python ./manage.py makemigrations 13 | 14 | migrate: 15 | docker compose run --rm backend python ./manage.py migrate 16 | 17 | test: 18 | docker compose run --rm backend pytest 19 | 20 | {% if cookiecutter.use_stripe == 'y' -%} 21 | test-webhook: 22 | docker compose run --rm stripe trigger customer.subscription.created 23 | 24 | stripe-sync: 25 | docker compose run --rm backend python ./manage.py djstripe_sync_models Product Price 26 | {% endif %} 27 | 28 | restart-worker: 29 | docker compose up -d workers --force-recreate 30 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/README.md: -------------------------------------------------------------------------------- 1 | 2 | # {{ cookiecutter.project_name }} 3 | 4 | ## Getting Started 5 | 6 | All the information on how to run, develop and update your new application can be found in the documentation. 7 | 8 | 1. Update the name of the `.env.example` to `.env` and update relevant variables. 9 | 10 | To start you'll need to run these commands: 11 | 1. `poetry install` 12 | 2. `poetry export -f requirements.txt --output requirements.txt --without-hashes` 13 | 3. `poetry run python manage.py makemigrations` 14 | 4. `make serve` : Make sure you have a Docker Engine running. I recommend OrbStack. 15 | 16 | ## Next steps 17 | - When everything is running, go to http://localhost:8000/ to check if the backend is running. 18 | - If you get an error about manifest.json, just restart containers by doing Ctrl+C in the terminal and `make serve` again. 19 | - You can sign up via regular signup. The first user will be made admin and superuser. 20 | - Go to http://localhost:8000/admin/ and update Site info (http://localhost:8000/admin/sites/site/1/change/) to 21 | - localhost:8000 (if you are developing locally, and real domain when you are in prod) 22 | - Your project name 23 | 24 | 25 | {% if cookiecutter.use_stripe == 'y' -%} 26 | ## Stripe 27 | - For local. When you run make serve for the first time, a stripe-cli container will be created. 28 | Looks at the logs for this container and at the top you will see a webhook secret generated. 29 | Copy this and add it to your `.env` file. 30 | 31 | The following notes are applicable only after you got the app running locally via `make serve`: 32 | - Add Test and Prod Secret keys in the admin panel: http://localhost:8000/admin/djstripe/apikey/ 33 | (djstripe will figure out if they are live or test keys automatically) 34 | - Create a webhook in Django admin panel: http://localhost:8000/admin/djstripe/webhookendpoint/ 35 | - you can't use localhost as the domain for the webhook, so use something like `https://statushen.xyz/webhook/` or a real one if you have it. It doesn't matter for local. 36 | - When creating a webhook in the admin, specify the latest version from here https://stripe.com/docs/api/versioning 37 | - Create your products in stripe (monthly, annual and one-time, for example), then sync them via `make stripe-sync` command. 38 | - Current (`user-settings.html` and `pricing.html`) template assumes you have 3 products: monthly, annual and one-time. 39 | I haven't found a reliable way to programmatcialy set this template. When you have created your products in Stripe and synced them, update the template with the correct plan id. 40 | {% endif %} 41 | 42 | {% if cookiecutter.use_logfire == 'y' %} 43 | ## Logfire 44 | To start using Logfire, checkout their docs: https://logfire.pydantic.dev/docs/ 45 | 46 | It will be simple: 47 | - Register 48 | - Create a project 49 | - Get a write token and add it to env vars in your prod environment 50 | {% endif %} 51 | 52 | ## Deployment 53 | 54 | 1. Create 4 apps on CapRover. 55 | - `{{ cookiecutter.project_slug }}` 56 | - `{{ cookiecutter.project_slug }}-workers` 57 | - `{{ cookiecutter.project_slug }}-postgres` 58 | - `{{ cookiecutter.project_slug }}-redis` 59 | 60 | 2. Create a new CapRover app token for: 61 | - `{{ cookiecutter.project_slug }}` 62 | - `{{ cookiecutter.project_slug }}-workers` 63 | 64 | 3. Add Environment Variables to those same apps from `.env`. 65 | 66 | 4. Create a new GitHub Actions secret with the following: 67 | - `CAPROVER_SERVER` 68 | - `CAPROVER_APP_TOKEN` 69 | - `WORKERS_APP_TOKEN` 70 | - `REGISTRY_TOKEN` 71 | 72 | 5. Then just push main branch. 73 | 74 | ## Notes 75 | - Don't forget to update the site domain and name on the Admin Panel. 76 | - If you made changes to tasks, you need to restart the worker container with `make restart-worker`. 77 | - If you will need to use from `pgvector` don't forget to run `CREATE EXTENSION vector;` in your db, before running any migrations with `VectorFields` 78 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasulkireev/django-saas-starter/796c140a6aa960c25ddabd5bbb63f6cde610b38e/{{ cookiecutter.project_slug }}/core/__init__.py -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/core/admin.py: -------------------------------------------------------------------------------- 1 | {% if cookiecutter.generate_blog == 'y' %} 2 | from django.contrib import admin 3 | 4 | from core.models import BlogPost 5 | 6 | admin.site.register(BlogPost) 7 | {% endif %} 8 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/core/api/auth.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from django.http import HttpRequest 4 | from ninja.security import HttpBearer 5 | 6 | from core.models import Profile 7 | 8 | 9 | class MultipleAuthSchema(HttpBearer): 10 | def authenticate(self, request: HttpRequest, token: Optional[str] = None) -> Optional[Profile]: 11 | # For session-based authentication (when using the web interface) 12 | if hasattr(request, "user") and request.user.is_authenticated: 13 | try: 14 | return request.user.profile 15 | except Profile.DoesNotExist: 16 | return None 17 | 18 | # For API token authentication (when using the API directly) 19 | if token: 20 | try: 21 | return Profile.objects.get(key=token) 22 | except Profile.DoesNotExist: 23 | return None 24 | 25 | return None 26 | 27 | def __call__(self, request): 28 | # Override to make authentication optional for session-based requests 29 | if hasattr(request, "user") and request.user.is_authenticated: 30 | return self.authenticate(request) 31 | 32 | return super().__call__(request) 33 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/core/api/schemas.py: -------------------------------------------------------------------------------- 1 | from ninja import Schema 2 | from typing import Optional 3 | 4 | {% if cookiecutter.generate_blog == 'y' %} 5 | from core.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 }}/core/api/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest 2 | from ninja import NinjaAPI 3 | from ninja.errors import HttpError 4 | 5 | from core.api.auth import MultipleAuthSchema 6 | from core.models import Feedback, {% if cookiecutter.generate_blog == 'y' %}BlogPost{% endif %} 7 | from core.api.schemas import ( 8 | SubmitFeedbackIn, 9 | SubmitFeedbackOut, 10 | {% if cookiecutter.generate_blog == 'y' %} 11 | BlogPostIn, 12 | BlogPostOut, 13 | {% endif %}, 14 | ProfileSettingsOut, 15 | UserSettingsOut, 16 | ) 17 | 18 | from {{ cookiecutter.project_slug }}.utils import get_{{ cookiecutter.project_slug }}_logger 19 | 20 | logger = get_{{ cookiecutter.project_slug }}_logger(__name__) 21 | 22 | api = NinjaAPI(auth=MultipleAuthSchema(), csrf=False) 23 | 24 | @api.post("/submit-feedback", response=SubmitFeedbackOut) 25 | def submit_feedback(request: HttpRequest, data: SubmitFeedbackIn): 26 | profile = request.auth 27 | try: 28 | Feedback.objects.create(profile=profile, feedback=data.feedback, page=data.page) 29 | return {"status": True, "message": "Feedback submitted successfully"} 30 | except Exception as e: 31 | logger.error("Failed to submit feedback", error=str(e), profile_id=profile.id) 32 | return {"status": False, "message": "Failed to submit feedback. Please try again."} 33 | 34 | {% if cookiecutter.generate_blog == 'y' %} 35 | @api.post("/blog-posts/submit", response=BlogPostOut) 36 | def submit_blog_post(request: HttpRequest, data: BlogPostIn): 37 | try: 38 | BlogPost.objects.create( 39 | title=data.title, 40 | description=data.description, 41 | slug=data.slug, 42 | tags=data.tags, 43 | content=data.content, 44 | status=data.status, 45 | # icon and image are ignored for now (file upload not handled) 46 | ) 47 | return BlogPostOut(status="success", message="Blog post submitted successfully.") 48 | except Exception as e: 49 | return BlogPostOut(status="failure", message=f"Failed to submit blog post: {str(e)}") 50 | {% endif %} 51 | 52 | @api.get("/user/settings", response=UserSettingsOut) 53 | def user_settings(request: HttpRequest): 54 | profile = request.auth 55 | try: 56 | profile_data = { 57 | "has_pro_subscription": profile.has_active_subscription, 58 | } 59 | data = {"profile": profile_data} 60 | 61 | return data 62 | except Exception as e: 63 | logger.error( 64 | "Error fetching user settings", 65 | error=str(e), 66 | profile_id=profile.id, 67 | exc_info=True, 68 | ) 69 | raise HttpError(500, "An unexpected error occurred.") 70 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/core/apps.py: -------------------------------------------------------------------------------- 1 | {% if cookiecutter.use_posthog == 'y' -%} 2 | import posthog 3 | {% endif %} 4 | from django.conf import settings 5 | from django.apps import AppConfig 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 CoreConfig(AppConfig): 12 | default_auto_field = "django.db.models.BigAutoField" 13 | name = "core" 14 | 15 | def ready(self): 16 | import core.signals # noqa 17 | {% if cookiecutter.use_stripe == 'y' -%} 18 | import core.webhooks # noqa 19 | {% endif %} 20 | 21 | {% if cookiecutter.use_posthog == 'y' -%} 22 | if settings.ENVIRONMENT == "prod": 23 | posthog.api_key = settings.POSTHOG_API_KEY 24 | posthog.host = "https://us.i.posthog.com" 25 | {% endif %} 26 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/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 }}/core/choices.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | {% if cookiecutter.use_stripe == 'y' %} 4 | class ProfileStates(models.TextChoices): 5 | STRANGER = "stranger" 6 | SIGNED_UP = "signed_up" 7 | SUBSCRIBED = "subscribed" 8 | CANCELLED = "cancelled" 9 | CHURNED = "churned" 10 | ACCOUNT_DELETED = "account_deleted" 11 | {% endif %} 12 | 13 | {% if cookiecutter.generate_blog == 'y' %} 14 | class BlogPostStatus(models.TextChoices): 15 | DRAFT = "draft" 16 | PUBLISHED = "published" 17 | {% endif %} 18 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/core/forms.py: -------------------------------------------------------------------------------- 1 | from allauth.account.forms import LoginForm, SignupForm 2 | from django import forms 3 | 4 | from core.models import Profile 5 | from 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 }}/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 vector;', 19 | reverse_sql='DROP EXTENSION vector;', 20 | ), 21 | migrations.RunSQL( 22 | sql='CREATE EXTENSION pg_stat_statements;', 23 | reverse_sql='DROP EXTENSION pg_stat_statements;', 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/core/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasulkireev/django-saas-starter/796c140a6aa960c25ddabd5bbb63f6cde610b38e/{{ cookiecutter.project_slug }}/core/migrations/__init__.py -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/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(10)) 7 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/core/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.db import models 3 | from django.urls import reverse 4 | 5 | from core.base_models import BaseModel 6 | from core.model_utils import generate_random_key 7 | {% if cookiecutter.use_stripe == 'y' %}from core.choices import ProfileStates{% endif %} 8 | {% if cookiecutter.generate_blog == 'y' %}from core.choices import BlogPostStatus{% endif %} 9 | from {{ cookiecutter.project_slug }}.utils import get_{{ cookiecutter.project_slug }}_logger 10 | logger = get_{{ cookiecutter.project_slug }}_logger(__name__) 11 | 12 | 13 | class Profile(BaseModel): 14 | user = models.OneToOneField(User, on_delete=models.CASCADE) 15 | key = models.CharField(max_length=10, unique=True, default=generate_random_key) 16 | 17 | {% if cookiecutter.use_stripe == 'y' %} 18 | subscription = models.ForeignKey( 19 | "djstripe.Subscription", 20 | null=True, 21 | blank=True, 22 | on_delete=models.SET_NULL, 23 | related_name="profile", 24 | help_text="The user's Stripe Subscription object, if it exists", 25 | ) 26 | product = models.ForeignKey( 27 | "djstripe.Product", 28 | null=True, 29 | blank=True, 30 | on_delete=models.SET_NULL, 31 | related_name="profile", 32 | help_text="The user's Stripe Product object, if it exists", 33 | ) 34 | customer = models.ForeignKey( 35 | "djstripe.Customer", 36 | null=True, 37 | blank=True, 38 | on_delete=models.SET_NULL, 39 | related_name="profile", 40 | help_text="The user's Stripe Customer object, if it exists", 41 | ) 42 | state = models.CharField( 43 | max_length=255, 44 | choices=ProfileStates.choices, 45 | default=ProfileStates.STRANGER, 46 | help_text="The current state of the user's profile", 47 | ) 48 | 49 | def track_state_change(self, to_state, metadata=None): 50 | from_state = self.current_state 51 | 52 | if from_state != to_state: 53 | logger.info( 54 | "Tracking State Change", from_state=from_state, to_state=to_state, profile_id=self.id, metadata=metadata 55 | ) 56 | ProfileStateTransition.objects.create( 57 | profile=self, from_state=from_state, to_state=to_state, backup_profile_id=self.id, metadata=metadata 58 | ) 59 | self.state = to_state 60 | self.save(update_fields=["state"]) 61 | 62 | @property 63 | def current_state(self): 64 | if not self.state_transitions.all().exists(): 65 | return ProfileStates.STRANGER 66 | latest_transition = self.state_transitions.latest("created_at") 67 | return latest_transition.to_state 68 | 69 | @property 70 | def has_active_subscription(self): 71 | return ( 72 | self.current_state 73 | in [ 74 | ProfileStates.SUBSCRIBED, 75 | ProfileStates.CANCELLED, 76 | ] 77 | or self.user.is_superuser 78 | ) 79 | 80 | class ProfileStateTransition(BaseModel): 81 | profile = models.ForeignKey(Profile, null=True, blank=True, on_delete=models.SET_NULL, related_name="state_transitions") 82 | from_state = models.CharField(max_length=255, choices=ProfileStates.choices) 83 | to_state = models.CharField(max_length=255, choices=ProfileStates.choices) 84 | backup_profile_id = models.IntegerField() 85 | metadata = models.JSONField(null=True, blank=True) 86 | 87 | {% endif %} 88 | 89 | {% if cookiecutter.generate_blog == 'y' %} 90 | class BlogPost(BaseModel): 91 | title = models.CharField(max_length=250) 92 | description = models.TextField(blank=True) 93 | slug = models.SlugField(max_length=250) 94 | tags = models.TextField() 95 | content = models.TextField() 96 | icon = models.ImageField(upload_to="blog_post_icons/", blank=True) 97 | image = models.ImageField(upload_to="blog_post_images/", blank=True) 98 | status = models.CharField( 99 | max_length=10, 100 | choices=BlogPostStatus.choices, 101 | default=BlogPostStatus.DRAFT, 102 | ) 103 | 104 | def __str__(self): 105 | return self.title 106 | 107 | def get_absolute_url(self): 108 | return reverse("blog_post", kwargs={"slug": self.slug}) 109 | {% endif %} 110 | 111 | class Feedback(BaseModel): 112 | profile = models.ForeignKey( 113 | Profile, 114 | null=True, 115 | blank=True, 116 | on_delete=models.CASCADE, 117 | related_name="feedback", 118 | help_text="The user who submitted the feedback", 119 | ) 120 | feedback = models.TextField( 121 | help_text="The feedback text", 122 | ) 123 | page = models.CharField( 124 | max_length=255, 125 | help_text="The page where the feedback was submitted", 126 | ) 127 | 128 | def __str__(self): 129 | return f"{self.profile.user.email}: {self.feedback}" 130 | 131 | def save(self, *args, **kwargs): 132 | is_new = self._state.adding 133 | super().save(*args, **kwargs) 134 | 135 | if is_new: 136 | from django.conf import settings 137 | from django.core.mail import send_mail 138 | 139 | subject = "New Feedback Submitted" 140 | message = f""" 141 | New feedback was submitted:\n\n 142 | User: {self.profile.user.email if self.profile else 'Anonymous'} 143 | Feedback: {self.feedback} 144 | Page: {self.page} 145 | """ 146 | from_email = settings.DEFAULT_FROM_EMAIL 147 | recipient_list = [settings.DEFAULT_FROM_EMAIL] 148 | 149 | send_mail(subject, message, from_email, recipient_list, fail_silently=True) 150 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/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 | from core.tasks import add_email_to_buttondown 8 | from core.models import Profile{% if cookiecutter.use_stripe == 'y' -%}, ProfileStates{% endif %} 9 | from {{ cookiecutter.project_slug }}.utils import get_{{ cookiecutter.project_slug }}_logger 10 | 11 | logger = get_{{ cookiecutter.project_slug }}_logger(__name__) 12 | 13 | 14 | @receiver(post_save, sender=User) 15 | def create_user_profile(sender, instance, created, **kwargs): 16 | if created: 17 | profile = Profile.objects.create(user=instance) 18 | {% if cookiecutter.use_stripe == 'y' -%} 19 | profile.track_state_change( 20 | to_state=ProfileStates.SIGNED_UP, 21 | ) 22 | {% endif %} 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 | 34 | 35 | {% if cookiecutter.use_buttondown == 'y' -%} 36 | @receiver(email_confirmed) 37 | def add_email_to_buttondown_on_confirm(sender, **kwargs): 38 | logger.info( 39 | "Adding new user to buttondown newsletter, on email confirmation", 40 | kwargs=kwargs, 41 | sender=sender, 42 | ) 43 | async_task(add_email_to_buttondown, kwargs["email_address"], tag="user") 44 | {% endif %} 45 | 46 | 47 | {% if cookiecutter.use_buttondown == 'y' and cookiecutter.use_social_auth == 'y' -%} 48 | @receiver(user_signed_up) 49 | def email_confirmation_callback(sender, request, user, **kwargs): 50 | if 'sociallogin' in kwargs: 51 | logger.info( 52 | "Adding new user to buttondown newsletter on social signup", 53 | kwargs=kwargs, 54 | sender=sender, 55 | ) 56 | email = kwargs['sociallogin'].user.email 57 | if email: 58 | async_task(add_email_to_buttondown, email, tag="user") 59 | {% endif %} 60 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/core/tasks.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from django.conf import settings 3 | 4 | from {{ cookiecutter.project_slug }}.utils import get_{{ cookiecutter.project_slug }}_logger 5 | 6 | logger = get_{{ cookiecutter.project_slug }}_logger(__name__) 7 | 8 | {% if cookiecutter.use_buttondown == 'y' -%} 9 | def add_email_to_buttondown(email, tag): 10 | data = { 11 | "email_address": str(email), 12 | "metadata": {"source": tag}, 13 | "tags": [tag], 14 | "referrer_url": "https://{{ cookiecutter.project_slug }}.app", 15 | "subscriber_type": "regular", 16 | } 17 | 18 | r = requests.post( 19 | "https://api.buttondown.email/v1/subscribers", 20 | headers={"Authorization": f"Token {settings.BUTTONDOWN_API_KEY}"}, 21 | json=data, 22 | ) 23 | 24 | return r.json() 25 | {% endif %} 26 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/core/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasulkireev/django-saas-starter/796c140a6aa960c25ddabd5bbb63f6cde610b38e/{{ cookiecutter.project_slug }}/core/templatetags/__init__.py -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/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 }}/core/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasulkireev/django-saas-starter/796c140a6aa960c25ddabd5bbb63f6cde610b38e/{{ cookiecutter.project_slug }}/core/tests/__init__.py -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/core/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.management import call_command 3 | 4 | def pytest_configure(config): 5 | settings.STORAGES['staticfiles']['BACKEND'] = 'django.contrib.staticfiles.storage.StaticFilesStorage' 6 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/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 }}/core/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from core import views 4 | from core.api.views import api 5 | 6 | urlpatterns = [ 7 | # pages 8 | path("", views.HomeView.as_view(), name="home"), 9 | path("settings", views.UserSettingsView.as_view(), name="settings"), 10 | {% if cookiecutter.generate_blog == 'y' -%} 11 | # blog 12 | path("blog", views.BlogView.as_view(), name="blog_posts"), 13 | path("blog/", views.BlogPostView.as_view(), name="blog_post"), 14 | {% endif %} 15 | # app 16 | path("api/", api.urls), 17 | # utils 18 | path("resend-confirmation/", views.resend_confirmation_email, name="resend_confirmation"), 19 | {% if cookiecutter.use_stripe == 'y' -%} 20 | # payments 21 | path("pricing", views.PricingView.as_view(), name="pricing"), 22 | path( 23 | "create-checkout-session///", 24 | views.create_checkout_session, 25 | name="user_upgrade_checkout_session", 26 | ), 27 | path("create-customer-portal/", views.create_customer_portal_session, name="create_customer_portal_session"), 28 | {% endif %} 29 | ] 30 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/core/utils.py: -------------------------------------------------------------------------------- 1 | from django.forms.utils import ErrorList 2 | 3 | from core.models import Profile 4 | 5 | from {{ cookiecutter.project_slug }}.utils import get_{{ cookiecutter.project_slug }}_logger 6 | 7 | logger = get_{{ cookiecutter.project_slug }}_logger(__name__) 8 | 9 | 10 | class DivErrorList(ErrorList): 11 | def __str__(self): 12 | return self.as_divs() 13 | 14 | def as_divs(self): 15 | if not self: 16 | return "" 17 | return f""" 18 |
19 |
20 |
21 | 22 | 25 |
26 |
27 | {''.join([f'

{e}

' for e in self])} 28 |
29 |
30 |
31 | """ 32 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/core/views.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlencode 2 | 3 | from django.http import HttpResponse 4 | {% if cookiecutter.use_stripe == 'y' -%} 5 | import stripe 6 | {% endif %} 7 | from allauth.account.models import EmailAddress 8 | from allauth.account.utils import send_email_confirmation 9 | from django.contrib.auth.decorators import login_required 10 | from django.contrib.auth.mixins import LoginRequiredMixin 11 | from django.contrib.messages.views import SuccessMessageMixin 12 | from django.shortcuts import redirect 13 | from django.conf import settings 14 | from django.contrib import messages 15 | from django.urls import reverse, reverse_lazy 16 | from django.views.generic import TemplateView, UpdateView, ListView, DetailView 17 | from django.template.loader import render_to_string 18 | from django.utils.html import strip_tags 19 | from django.core.mail import EmailMultiAlternatives 20 | 21 | {% if cookiecutter.use_stripe == 'y' -%} 22 | from djstripe import models as djstripe_models 23 | from core.choices import ProfileStates 24 | {% endif %} 25 | 26 | from core.forms import ProfileUpdateForm 27 | from core.models import Profile{% if cookiecutter.generate_blog == 'y' -%}, BlogPost{% endif %} 28 | 29 | from {{ cookiecutter.project_slug }}.utils import get_{{ cookiecutter.project_slug }}_logger 30 | 31 | {% if cookiecutter.use_stripe == 'y' -%} 32 | stripe.api_key = settings.STRIPE_SECRET_KEY 33 | {% endif %} 34 | 35 | logger = get_{{ cookiecutter.project_slug }}_logger(__name__) 36 | 37 | class HomeView(TemplateView): 38 | template_name = "pages/home.html" 39 | 40 | {% if cookiecutter.use_stripe == 'y' -%} 41 | def get_context_data(self, **kwargs): 42 | context = super().get_context_data(**kwargs) 43 | 44 | payment_status = self.request.GET.get("payment") 45 | if payment_status == "success": 46 | messages.success(self.request, "Thanks for subscribing, I hope you enjoy the app!") 47 | context["show_confetti"] = True 48 | elif payment_status == "failed": 49 | messages.error(self.request, "Something went wrong with the payment.") 50 | 51 | return context 52 | {% endif %} 53 | 54 | class UserSettingsView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): 55 | login_url = "account_login" 56 | model = Profile 57 | form_class = ProfileUpdateForm 58 | success_message = "User Profile Updated" 59 | success_url = reverse_lazy("settings") 60 | template_name = "pages/user-settings.html" 61 | 62 | def get_object(self): 63 | return self.request.user.profile 64 | 65 | def get_context_data(self, **kwargs): 66 | context = super().get_context_data(**kwargs) 67 | user = self.request.user 68 | 69 | email_address = EmailAddress.objects.get_for_user(user, user.email) 70 | context["email_verified"] = email_address.verified 71 | context["resend_confirmation_url"] = reverse("resend_confirmation") 72 | {% if cookiecutter.use_stripe == 'y' -%} 73 | context["has_subscription"] = user.profile.has_product_or_subscription 74 | {% endif %} 75 | 76 | 77 | return context 78 | 79 | 80 | 81 | 82 | @login_required 83 | def resend_confirmation_email(request): 84 | user = request.user 85 | send_email_confirmation(request, user, EmailAddress.objects.get_for_user(user, user.email)) 86 | 87 | return redirect("settings") 88 | 89 | 90 | {% if cookiecutter.use_stripe == 'y' -%} 91 | class PricingView(TemplateView): 92 | template_name = "pages/pricing.html" 93 | 94 | def get_context_data(self, **kwargs): 95 | context = super().get_context_data(**kwargs) 96 | 97 | if self.request.user.is_authenticated: 98 | try: 99 | profile = self.request.user.profile 100 | context["has_pro_subscription"] = profile.has_active_subscription 101 | except Profile.DoesNotExist: 102 | context["has_pro_subscription"] = False 103 | else: 104 | context["has_pro_subscription"] = False 105 | 106 | return context 107 | 108 | 109 | def create_checkout_session(request, pk, plan): 110 | user = request.user 111 | 112 | product = djstripe_models.Product.objects.get(name=plan) 113 | price = product.prices.filter(active=True).first() 114 | customer, _ = djstripe_models.Customer.get_or_create(subscriber=user) 115 | 116 | profile = user.profile 117 | profile.customer = customer 118 | profile.save(update_fields=["customer"]) 119 | 120 | base_success_url = request.build_absolute_uri(reverse("home")) 121 | base_cancel_url = request.build_absolute_uri(reverse("home")) 122 | 123 | success_params = {"payment": "success"} 124 | success_url = f"{base_success_url}?{urlencode(success_params)}" 125 | 126 | cancel_params = {"payment": "failed"} 127 | cancel_url = f"{base_cancel_url}?{urlencode(cancel_params)}" 128 | 129 | checkout_session = stripe.checkout.Session.create( 130 | customer=customer.id, 131 | payment_method_types=["card"], 132 | allow_promotion_codes=True, 133 | automatic_tax={"enabled": True}, 134 | line_items=[ 135 | { 136 | "price": price.id, 137 | "quantity": 1, 138 | } 139 | ], 140 | mode="subscription" if plan != "one-time" else "payment", 141 | success_url=success_url, 142 | cancel_url=cancel_url, 143 | customer_update={ 144 | "address": "auto", 145 | }, 146 | metadata={"user_id": user.id, "pk": pk, "price_id": price.id}, 147 | ) 148 | 149 | return redirect(checkout_session.url, code=303) 150 | 151 | 152 | @login_required 153 | def create_customer_portal_session(request): 154 | user = request.user 155 | customer = djstripe_models.Customer.objects.get(subscriber=user) 156 | 157 | session = stripe.billing_portal.Session.create( 158 | customer=customer.id, 159 | return_url=request.build_absolute_uri(reverse("home")), 160 | ) 161 | 162 | return redirect(session.url, code=303) 163 | {% endif %} 164 | 165 | 166 | {% if cookiecutter.generate_blog == 'y' -%} 167 | class BlogView(ListView): 168 | model = BlogPost 169 | template_name = "blog/blog_posts.html" 170 | context_object_name = "blog_posts" 171 | 172 | 173 | class BlogPostView(DetailView): 174 | model = BlogPost 175 | template_name = "blog/blog_post.html" 176 | context_object_name = "blog_post" 177 | {% endif %} 178 | 179 | {% if cookiecutter.use_mjml == 'y' -%} 180 | def test_mjml(request): 181 | html_content = render_to_string("emails/test_mjml.html", {}) 182 | text_content = strip_tags(html_content) 183 | 184 | email = EmailMultiAlternatives( 185 | "Subject", 186 | text_content, 187 | settings.DEFAULT_FROM_EMAIL, 188 | ["test@test.com"], 189 | ) 190 | email.attach_alternative(html_content, "text/html") 191 | email.send() 192 | 193 | return HttpResponse("Email sent") 194 | {% endif %} 195 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/core/webhooks.py: -------------------------------------------------------------------------------- 1 | {% if cookiecutter.use_stripe == 'y' -%} 2 | from djstripe.event_handlers import djstripe_receiver 3 | from djstripe.models import Customer, Event, Price, Product, Subscription 4 | 5 | from core.models import Profile, ProfileStates 6 | from {{ cookiecutter.project_slug }}.utils import get_{{ cookiecutter.project_slug }}_logger 7 | 8 | logger = get_{{ cookiecutter.project_slug }}_logger(__name__) 9 | 10 | 11 | @djstripe_receiver("customer.subscription.created") 12 | def handle_created_subscription(**kwargs): 13 | event_id = kwargs["event"].id 14 | event = Event.objects.get(id=event_id) 15 | 16 | customer = Customer.objects.get(id=event.data["object"]["customer"]) 17 | subscription = Subscription.objects.get(id=event.data["object"]["id"]) 18 | 19 | profile = Profile.objects.get(customer=customer) 20 | profile.subscription = subscription 21 | profile.save(update_fields=["subscription"]) 22 | 23 | profile.track_state_change( 24 | to_state=ProfileStates.SUBSCRIBED, 25 | metadata={"event": "subscription_created", "subscription_id": subscription.id, "stripe_event_id": event_id}, 26 | ) 27 | 28 | logger.info( 29 | "Subscription created and state updated for profile", 30 | profile_id=profile.id, 31 | webhook="handle_created_subscription", 32 | subscription_id=subscription.id, 33 | event_id=event_id, 34 | ) 35 | 36 | 37 | @djstripe_receiver("customer.subscription.updated") 38 | def handle_updated_subscription(**kwargs): 39 | event_id = kwargs["event"].id 40 | event = Event.objects.get(id=event_id) 41 | 42 | subscription_data = event.data["object"] 43 | 44 | customer_id = subscription_data["customer"] 45 | subscription_id = subscription_data["id"] 46 | 47 | logger.info( 48 | "Subscription updated", 49 | webhook="handle_updated_subscription", 50 | event_id=event_id, 51 | subscription_id=subscription_id, 52 | subscription_data=subscription_data, 53 | ) 54 | 55 | try: 56 | customer = Customer.objects.get(id=customer_id) 57 | subscription = Subscription.objects.get(id=subscription_id) 58 | profile = Profile.objects.get(customer=customer) 59 | 60 | if ( 61 | subscription_data.get("cancel_at_period_end") 62 | and subscription_data.get("cancellation_details", {}).get("reason") == "cancellation_requested" 63 | ): 64 | # The subscription has been cancelled and will end at the end of the current period 65 | profile.track_state_change( 66 | to_state=ProfileStates.CANCELLED, 67 | metadata={ 68 | "event": "subscription_cancelled", 69 | "subscription_id": subscription_id, 70 | "cancel_at": subscription_data.get("cancel_at"), 71 | "current_period_end": subscription_data.get("current_period_end"), 72 | "cancellation_feedback": subscription_data.get("cancellation_details", {}).get("feedback"), 73 | "cancellation_comment": subscription_data.get("cancellation_details", {}).get("comment"), 74 | }, 75 | ) 76 | 77 | logger.info( 78 | "Subscription cancelled for profile.", 79 | profile_id=profile.id, 80 | subscription_id=subscription_id, 81 | end_date=subscription_data.get("current_period_end"), 82 | ) 83 | 84 | profile.subscription = subscription 85 | profile.save(update_fields=["subscription"]) 86 | 87 | except (Customer.DoesNotExist, Subscription.DoesNotExist, Profile.DoesNotExist) as e: 88 | logger.error( 89 | "Error processing subscription update", 90 | event_id=event_id, 91 | subscription_id=subscription_id, 92 | customer_id=customer_id, 93 | error=str(e), 94 | ) 95 | 96 | 97 | @djstripe_receiver("customer.subscription.deleted") 98 | def handle_deleted_subscription(**kwargs): 99 | event_id = kwargs["event"].id 100 | event = Event.objects.get(id=event_id) 101 | 102 | subscription_data = event.data["object"] 103 | customer_id = subscription_data["customer"] 104 | subscription_id = subscription_data["id"] 105 | 106 | logger.info( 107 | "Subscription deleted event received", 108 | webhook="handle_deleted_subscription", 109 | event_id=event_id, 110 | subscription_id=subscription_id, 111 | subscription_data=subscription_data, 112 | ) 113 | 114 | try: 115 | customer = Customer.objects.get(id=customer_id) 116 | profile = Profile.objects.get(customer=customer) 117 | 118 | profile.track_state_change( 119 | to_state=ProfileStates.CHURNED, 120 | metadata={ 121 | "event": "subscription_deleted", 122 | "subscription_id": subscription_id, 123 | "ended_at": subscription_data.get("ended_at"), 124 | }, 125 | ) 126 | 127 | profile.subscription = None 128 | profile.save(update_fields=["subscription"]) 129 | 130 | logger.info( 131 | "Subscription deleted for profile.", 132 | profile_id=profile.id, 133 | subscription_id=subscription_id, 134 | ended_at=subscription_data.get("ended_at"), 135 | ) 136 | 137 | # TODO: Implement any necessary clean-up or follow-up actions 138 | # For example: Revoke access to paid features, send a farewell email, etc. 139 | 140 | except (Customer.DoesNotExist, Profile.DoesNotExist) as e: 141 | logger.error( 142 | "Error processing subscription deletion", 143 | event_id=event_id, 144 | subscription_id=subscription_id, 145 | customer_id=customer_id, 146 | error=str(e), 147 | ) 148 | 149 | @djstripe_receiver("checkout.session.completed") 150 | def handle_checkout_completed(**kwargs): 151 | logger.info("handle_checkout_completed webhook received", kwargs=kwargs) 152 | event_id = kwargs["event"].id 153 | event = Event.objects.get(id=event_id) 154 | 155 | checkout_data = event.data["object"] 156 | customer_id = checkout_data.get("customer") 157 | checkout_id = checkout_data.get("id") 158 | subscription_id = checkout_data.get("subscription") 159 | payment_status = checkout_data.get("payment_status") 160 | mode = checkout_data.get("mode") # 'subscription', 'payment', or 'setup' 161 | 162 | # Get metadata from checkout 163 | metadata = checkout_data.get("metadata", {}) 164 | price_id = metadata.get("price_id") 165 | 166 | logger.info( 167 | "Checkout session completed", 168 | webhook="handle_checkout_completed", 169 | event_id=event_id, 170 | checkout_id=checkout_id, 171 | customer_id=customer_id, 172 | payment_status=payment_status, 173 | mode=mode, 174 | metadata=metadata, 175 | ) 176 | 177 | if payment_status != "paid": 178 | logger.warning( 179 | "Checkout completed but payment not successful", 180 | event_id=event_id, 181 | checkout_id=checkout_id, 182 | payment_status=payment_status, 183 | ) 184 | return 185 | 186 | try: 187 | # Get the customer and profile 188 | customer = Customer.objects.get(id=customer_id) 189 | profile = Profile.objects.get(customer=customer) 190 | 191 | # Fields to update on the profile 192 | update_fields = [] 193 | 194 | if mode == "payment": 195 | # One-time payment checkout 196 | amount_total = checkout_data.get("amount_total") 197 | currency = checkout_data.get("currency") 198 | payment_intent = checkout_data.get("payment_intent") 199 | 200 | # Get the product associated with the price 201 | product = None 202 | product_data = {} 203 | 204 | if price_id: 205 | try: 206 | price = Price.objects.get(id=price_id) 207 | product = price.product 208 | 209 | # Update profile with product 210 | profile.product = product 211 | update_fields.append("product") 212 | 213 | product_data = {"product_id": product.id, "product_name": product.name} 214 | 215 | logger.info( 216 | "Associated product with profile from one-time payment", 217 | profile_id=profile.id, 218 | product_id=product.id, 219 | product_name=product.name, 220 | ) 221 | except Price.DoesNotExist: 222 | logger.warning("Price not found in database", price_id=price_id) 223 | except Exception as e: 224 | logger.error("Error retrieving product from price", price_id=price_id, error=str(e)) 225 | 226 | if update_fields: 227 | profile.save(update_fields=update_fields) 228 | 229 | profile.track_state_change( 230 | to_state=ProfileStates.SUBSCRIBED, 231 | metadata={ 232 | "event": "checkout_payment_completed", 233 | "payment_intent": payment_intent, 234 | "checkout_id": checkout_id, 235 | "amount": amount_total, 236 | "currency": currency, 237 | "price_id": price_id, 238 | "stripe_event_id": event_id, 239 | **product_data, 240 | }, 241 | ) 242 | 243 | logger.info( 244 | "User completed one-time payment", 245 | profile_id=profile.id, 246 | payment_intent=payment_intent, 247 | checkout_id=checkout_id, 248 | amount=amount_total, 249 | currency=currency, 250 | metadata=metadata, 251 | ) 252 | 253 | else: 254 | logger.info( 255 | "Checkout completed with unsupported mode", checkout_id=checkout_id, mode=mode, profile_id=profile.id 256 | ) 257 | 258 | except (Customer.DoesNotExist, Profile.DoesNotExist) as e: 259 | logger.error( 260 | "Error processing checkout completion: customer or profile not found", 261 | event_id=event_id, 262 | checkout_id=checkout_id, 263 | customer_id=customer_id, 264 | error=str(e), 265 | ) 266 | except Subscription.DoesNotExist as e: 267 | logger.error( 268 | "Error processing checkout completion: subscription not found", 269 | event_id=event_id, 270 | checkout_id=checkout_id, 271 | subscription_id=subscription_id, 272 | error=str(e), 273 | ) 274 | except Exception as e: 275 | logger.error( 276 | "Unexpected error processing checkout completion", 277 | event_id=event_id, 278 | checkout_id=checkout_id, 279 | error=str(e), 280 | ) 281 | {% endif %} 282 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/deployment/Dockerfile.server: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM node:16 AS build 3 | 4 | WORKDIR /app 5 | COPY . . 6 | 7 | RUN npm install 8 | RUN npm run build 9 | 10 | # Production stage 11 | FROM python:3.11 12 | 13 | WORKDIR /app 14 | 15 | COPY . . 16 | COPY --from=build /app/frontend/build/ ./frontend/build/ 17 | 18 | RUN pip install --no-cache-dir -r requirements.txt 19 | 20 | EXPOSE 80 21 | 22 | CMD ["deployment/entrypoint.sh", "-s"] 23 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/deployment/Dockerfile.workers: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM node:16 AS build 3 | 4 | WORKDIR /app 5 | COPY . . 6 | 7 | RUN npm install 8 | RUN npm run build 9 | 10 | # Production stage 11 | FROM python:3.11 12 | 13 | WORKDIR /app 14 | 15 | COPY . . 16 | COPY --from=build /app/frontend/build/ ./frontend/build/ 17 | 18 | RUN pip install --no-cache-dir -r requirements.txt 19 | 20 | EXPOSE 80 21 | 22 | CMD ["deployment/entrypoint.sh", "-w"] 23 | -------------------------------------------------------------------------------- /{{ 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 }}/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: rasulkireev/custom-postgres:17 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 | image: stripe/stripe-cli:latest 85 | command: [ 86 | "listen", 87 | "-H", "x-djstripe-webhook-secret: ${DJSTRIPE_WEBHOOK_SECRET}", 88 | "--forward-to", "http://backend:8000/stripe/webhook/${WEBHOOK_UUID}/" 89 | ] 90 | environment: 91 | - STRIPE_API_KEY=${STRIPE_TEST_SECRET_KEY} 92 | - STRIPE_DEVICE_NAME=djstripe_docker 93 | - DJSTRIPE_WEBHOOK_SECRET=${DJSTRIPE_WEBHOOK_SECRET} 94 | dns: 95 | - 8.8.8.8 96 | - 8.8.4.4 97 | env_file: 98 | - .env 99 | {% endif %} 100 | 101 | minio: 102 | image: minio/minio 103 | ports: 104 | - "9000:9000" 105 | - "9001:9001" 106 | volumes: 107 | - minio_data:/data 108 | environment: 109 | MINIO_ROOT_USER: {{ cookiecutter.project_slug }} 110 | MINIO_ROOT_PASSWORD: {{ cookiecutter.project_slug }} 111 | command: server --console-address ":9001" /data 112 | healthcheck: 113 | test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] 114 | interval: 5s 115 | timeout: 5s 116 | retries: 3 117 | 118 | createbuckets: 119 | image: minio/mc 120 | depends_on: 121 | minio: 122 | condition: service_healthy 123 | entrypoint: > 124 | /bin/sh -c " 125 | sleep 5 && 126 | /usr/bin/mc config host add myminio http://minio:9000 {{ cookiecutter.project_slug }} {{ cookiecutter.project_slug }} && 127 | /usr/bin/mc mb myminio/{{ cookiecutter.project_slug }}-dev && 128 | /usr/bin/mc anonymous set download myminio/{{ cookiecutter.project_slug }}-dev && 129 | exit 0; 130 | " 131 | 132 | volumes: 133 | postgres_data: 134 | redis_data: 135 | minio_data: 136 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/docs/getting-started.md: -------------------------------------------------------------------------------- 1 | ## 1 Dependencies 2 | 3 | To get these docs running you have already installed python deps with Poetry. Nice!. Now the only thing left to do is to install JS deps. For that I like to use `pnpm`. So run this command: 4 | 5 | ``` 6 | pnpm i 7 | ``` 8 | 9 | To get things going, let's try starting the deb frontend server. This will start the css and js complilation and will create a build folder. 10 | 11 | ``` 12 | pnpm run start 13 | ``` 14 | 15 | ## 2 Prepare the files 16 | 17 | - Change the `.env.example` file name to `.env`. 18 | 19 | 20 | ## 3 Creating the Database 21 | 22 | The next thing you want to do is to create and apply the migrations. First run: 23 | 24 | ``` 25 | poetry run python manage.py makemigrations 26 | ``` 27 | 28 | then 29 | 30 | ``` 31 | poetry run python manage.py migrate 32 | ``` 33 | 34 | This will create a SQLite database with all the necessary tables. 35 | 36 | ## 4 Start the dev server 37 | 38 | Start by building the frontend resource. You can do that by running: 39 | 40 | ``` 41 | npm run start 42 | ``` 43 | 44 | You should see something like this: 45 | 46 | ``` 47 | webpack 5.73.0 compiled successfully in 3254 ms 48 | ``` 49 | 50 | Note: 51 | Make sure you are running tht latest LTS Node. As of this writing it is 16. You can activate it with `nvm use 16` if you have nvm installed. 52 | 53 | Now let's start the python server by `poetry run python manage.py runserver` in a new terminal window (we don't want to close the npm stuff. You can do that by pressing Ctrl+N while in VS Code Terminal). If you need a primer on how to use poetry, check out [this blog post](https://builtwithdjango.com/blog/basic-django-setup) 54 | 55 | --- 56 | 57 | Et Voila, you should have a basic site running. 58 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/docs/index.md: -------------------------------------------------------------------------------- 1 | # Hello 2 | 3 | I'm glad you decided to give my boilerplate a go. I recommend you star with the "[Getting Started](/getting-started)" section. 4 | -------------------------------------------------------------------------------- /{{ 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 }}/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 }}/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 }}/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 }}/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 }}/frontend/src/styles/index.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | -------------------------------------------------------------------------------- /{{ 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/account/email_confirm.html: -------------------------------------------------------------------------------- 1 | {{ "{% extends 'base.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 }}/frontend/templates/account/login.html: -------------------------------------------------------------------------------- 1 | {{ "{% extends 'base.html' %}" }} 2 | {{ "{% load widget_tweaks %}" }} 3 | {{ "{% load socialaccount %}" }} 4 | 5 | {{ "{% block content %}" }} 6 |
7 |
8 |
9 |

10 | Sign in to your account 11 |

12 |

13 | Or 14 | 15 | sign up if you don't have one yet. 16 | 17 |

18 |
19 |
20 | {{ "{% csrf_token %}" }} 21 | {{ "{{ form.non_field_errors | safe }}" }} 22 | 23 |
24 |
25 | {{ "{{ form.login.errors | safe }}" }} 26 | 27 | {{ '{% render_field form.login placeholder="Username" id="username" name="username" type="text" autocomplete="username" 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" %}' }} 28 |
29 |
30 | {{ "{{ form.password.errors | safe }}" }} 31 | 32 | {{ '{% render_field form.password id="password" name="password" type="password" autocomplete="current-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" placeholder="Password" %}' }} 33 |
34 |
35 | 36 | {{ "{% if redirect_field_value %}" }} 37 | 38 | {{ "{% endif %}" }} 39 | 40 |
41 | 57 |
58 |
59 | 60 | {% if cookiecutter.use_social_auth == 'y' -%} 61 |
62 |
63 | 66 |
67 | Or continue with 68 |
69 |
70 | 71 | {% if cookiecutter.use_github_auth == 'y' -%} 72 |
73 |
74 | {{ "{% csrf_token %}" }} 75 | 81 |
82 |
83 | {% endif %} 84 | 85 |
86 | {% endif %} 87 | 88 |
89 |
90 | {{ "{% endblock content %}" }} 91 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/account/logout.html: -------------------------------------------------------------------------------- 1 | {{ "{% extends 'base.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 }}/frontend/templates/account/signup.html: -------------------------------------------------------------------------------- 1 | {{ '{% extends "base.html" %}' }} 2 | {{ "{% load widget_tweaks %}" }} 3 | {{' {% load socialaccount %}' }} 4 | 5 | {{ "{% block content %}" }} 6 |
7 |
8 |
9 |

10 | Create your account 11 |

12 |

13 | Or 14 | 15 | login if you have one already. 16 | 17 |

18 |
19 |
20 | {{ "{% csrf_token %}" }} 21 | 22 | {{ "{{ form.non_field_errors | safe }}" }} 23 | 24 |
25 |
26 | {{ "{{ form.username.errors | safe }}" }} 27 | 28 | {{ '{% render_field form.username placeholder="Username" id="username" name="username" type="text" autocomplete="username" 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" %}' }} 29 |
30 |
31 | {{ "{{ form.email.errors | safe }}" }} 32 | 33 | {{ '{% render_field form.email id="email" name="email" placeholder="Email" type="email" autocomplete="email" required="True" class="block relative px-3 py-2 w-full placeholder-gray-500 text-gray-900 rounded-none 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" %}' }} 34 |
35 |
36 | {{ "{{ form.password1.errors | safe }}" }} 37 | 38 | {{ '{% render_field form.password1 id="password1" name="password1" placeholder="Password" type="password" autocomplete="current-password" required="True" class="block relative px-3 py-2 w-full placeholder-gray-500 text-gray-900 rounded-none 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" %}' }} 39 |
40 |
41 | {{ "{{ form.password2.errors | safe }}" }} 42 | 43 | {{ '{% render_field form.password2 id="password2" name="password2" type="password" autocomplete="current-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" placeholder="Cofirm Password" %}' }} 44 |
45 |
46 | 47 | {{ '{% if redirect_field_value %}' }} 48 | 49 | {{ '{% endif %}' }} 50 | 51 |
52 | 68 |
69 |
70 | 71 | {% if cookiecutter.use_social_auth == 'y' -%} 72 |
73 |
74 | 77 |
78 | Or continue with 79 |
80 |
81 | 82 | {% if cookiecutter.use_github_auth == 'y' -%} 83 |
84 |
85 | {{ "{% csrf_token %}" }} 86 | 92 |
93 |
94 | {% endif %} 95 | 96 |
97 | {% endif %} 98 | 99 | 100 |
101 |
102 | {{ '{% endblock content %}' }} 103 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/base.html: -------------------------------------------------------------------------------- 1 | {{ "{% load webpack_loader static %}" }} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | {{ "{% block meta %}" }} 18 | {{ cookiecutter.project_name }} 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | {{ "{% endblock meta %}" }} 38 | 39 | {{ "{% stylesheet_pack 'index' %}" }} 40 | {{ "{% javascript_pack 'index' attrs='defer' %}" }} 41 | 42 | 43 | 44 | {{ "{% block default_messages %}" }} 45 | {{ "{% include 'components/messages.html' with messages=messages %}" }} 46 | {{ "{% endblock default_messages %}" }} 47 | 48 | 49 | {{ "{% if user.is_authenticated %}" }} 50 | {{ "{% include 'components/feedback.html' %}" }} 51 | {{ "{% endif %}" }} 52 | 53 |
54 |
55 | 230 |
231 | 232 |
233 | {{ "{% block content %}" }} 234 | {{ "{% endblock content %}" }} 235 |
236 | 237 |
238 |
239 |
240 |

241 | © 2025 LVTD, LLC. All rights reserved. 242 |

243 |
244 |
245 |
246 | 247 |
248 | 249 | {{ "{% block schema %}" }} 250 | 266 | {{ "{% endblock schema %}" }} 267 | 268 | 269 | 270 | 271 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/blog/blog_post.html: -------------------------------------------------------------------------------- 1 | {{ '{% extends "base.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 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/blog/blog_posts.html: -------------------------------------------------------------------------------- 1 | {{ '{% extends "base.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/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 }}/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 | -------------------------------------------------------------------------------- /{{ 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/emails/test_mjml.html: -------------------------------------------------------------------------------- 1 | {{ '{% load mjml %}' }} 2 | 3 | {{ '{% mjml %}' }} 4 | 5 | 6 | 7 | .test { 8 | display: inline-flex; 9 | padding: 4px 8px; 10 | border-radius: 6px; 11 | font-size: 14px; 12 | line-height: 18px; 13 | font-weight: 500; 14 | color: #047857; 15 | background-color: #ECFDF5; 16 | } 17 | 18 | 19 | 20 | 21 | 22 | 23 | 30 | {{ cookiecutter.project_name }} 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | {{ cookiecutter.project_description }} 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | Test 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | Friendly Ad 55 | 56 | 57 | You can test this email by clicking the button below. 58 | 59 | 60 | 61 | 62 | 63 | 64 | {{ '{% endmjml %}' }} 65 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/pages/home.html: -------------------------------------------------------------------------------- 1 | {{ '{% extends "base.html" %}' }} 2 | 3 | {{ "{% block content %}" }} 4 |
5 |
6 |
7 |

8 | {{ cookiecutter.project_description }} 9 |

10 | 18 |
19 |
20 |
21 | 22 | {% if cookiecutter.use_stripe == 'y' -%} 23 | {{ "{% if show_confetti %}" }} 24 | 25 | 32 | {{ "{% endif %}" }} 33 | 34 | {{ "{% endblock content %}" }} 35 | {% endif %} 36 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/pages/pricing.html: -------------------------------------------------------------------------------- 1 | {{ '{% extends "base.html" %}' }} 2 | {{ '{% load webpack_loader static %}' }} 3 | 4 | {{ '{% block meta %}' }} 5 | {{ cookiecutter.project_name }} - Pricing 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 |
30 |

Pricing

31 |

Free to use and self-host

32 |
33 |

Our app is completely free to use for anyone and can be self-hosted. For additional features and support, check out our paid plans below.

34 |
35 |
36 |
37 |

Monthly

38 |

39 | $10 40 | /month 41 |

42 | {{ "{% if user.is_authenticated %}" }} 43 | {{ "{% if has_pro_subscription %}" }} 44 | You are a pro! 45 | {{ "{% else %}" }} 46 | Buy plan 47 | {{ "{% endif %}" }} 48 | {{ "{% else %}" }} 49 | Buy plan 50 | {{ "{% endif %}" }} 51 |
    52 |
  • 53 | 56 | Remove the watermark 57 |
  • 58 |
  • 59 | 62 | More styling options 63 |
  • 64 |
  • 65 | 68 | 48-hour support response time 69 |
  • 70 |
71 |
72 |
73 |

Annual

74 |

75 | $100 76 | /year 77 |

78 | {{ "{% if user.is_authenticated %}" }} 79 | {{ "{% if has_pro_subscription %}" }} 80 | You are a pro! 81 | {{ "{% else %}" }} 82 | Buy plan 83 | {{ "{% endif %}" }} 84 | {{ "{% else %}" }} 85 | Buy plan 86 | {{ "{% endif %}" }} 87 |
    88 |
  • 89 | 92 | Remove the watermark 93 |
  • 94 |
  • 95 | 98 | More styling options 99 |
  • 100 |
  • 101 | 104 | 48-hour support response time 105 |
  • 106 |
107 |
108 |
109 |

One-time

110 |

111 | $500 112 |

113 | {{ "{% if user.is_authenticated %}" }} 114 | {{ "{% if has_pro_subscription %}" }} 115 | You are a pro! 116 | {{ "{% else %}" }} 117 | Buy plan 118 | {{ "{% endif %}" }} 119 | {{ "{% else %}" }} 120 | Buy plan 121 | {{ "{% endif %}" }} 122 |
    123 |
  • 124 | 127 | Remove the watermark 128 |
  • 129 |
  • 130 | 133 | More styling options 134 |
  • 135 |
  • 136 | 139 | 48-hour support response time 140 |
  • 141 |
142 |
143 |
144 |
145 |
146 |
147 | {{ '{% endblock content %}' }} 148 | 149 | {{ '{% block schema %}' }} 150 | 166 | {{ '{% endblock schema %}' }} 167 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/pages/user-settings.html: -------------------------------------------------------------------------------- 1 | {{ "{% extends 'base.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 | 18 | {% if cookiecutter.use_stripe == 'y' -%} 19 | {{ "{% if not has_subscription %}" }} 20 |
21 |

Upgrade Your Account

22 |

Choose a plan that suits your needs:

23 | 43 |
44 | {{ "{% else %}" }} 45 |
46 |

Manage Your Subscription

47 | 54 |
55 | {{ "{% endif %}" }} 56 | {% endif %} 57 | 58 |
59 |
60 |

Personal Information

61 |
62 |
63 | {{ "{% csrf_token %}" }} 64 |
65 |
66 |
67 | {{ "{{ form.non_field_errors }}" }} 68 |
69 | {{ "{{ form.first_name.errors | safe }}" }} 70 | 71 | {{ '{% 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" %}' }} 72 |
73 | 74 |
75 | {{ "{{ form.last_name.errors | safe }}" }} 76 | 77 | {{ '{% 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" %}' }} 78 |
79 | 80 |
81 | {{ "{{ form.email.errors | safe }}" }} 82 | 83 | {{ '{% render_field form.email id="email-address" name="email-address" type="email" autocomplete="email" 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" %}' }} 84 |
85 |
86 |
87 |
88 | 91 |
92 |
93 |
94 |
95 |
96 |
97 | 98 | 103 | 104 |
105 | {{ "{% endblock content %}" }} 106 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/templates/pages/uses.html: -------------------------------------------------------------------------------- 1 | {{ '{% extends "base.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/vendors/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasulkireev/django-saas-starter/796c140a6aa960c25ddabd5bbb63f6cde610b38e/{{ cookiecutter.project_slug }}/frontend/vendors/.gitkeep -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/vendors/images/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasulkireev/django-saas-starter/796c140a6aa960c25ddabd5bbb63f6cde610b38e/{{ cookiecutter.project_slug }}/frontend/vendors/images/.gitkeep -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/vendors/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasulkireev/django-saas-starter/796c140a6aa960c25ddabd5bbb63f6cde610b38e/{{ cookiecutter.project_slug }}/frontend/vendors/images/logo.png -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/vendors/images/sample.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasulkireev/django-saas-starter/796c140a6aa960c25ddabd5bbb63f6cde610b38e/{{ cookiecutter.project_slug }}/frontend/vendors/images/sample.jpg -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/vendors/images/unknown-man.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasulkireev/django-saas-starter/796c140a6aa960c25ddabd5bbb63f6cde610b38e/{{ cookiecutter.project_slug }}/frontend/vendors/images/unknown-man.png -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/frontend/vendors/images/webpack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasulkireev/django-saas-starter/796c140a6aa960c25ddabd5bbb63f6cde610b38e/{{ cookiecutter.project_slug }}/frontend/vendors/images/webpack.png -------------------------------------------------------------------------------- /{{ 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.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 }}/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 }}/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 }}/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 }}/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Minimal Django SaaS Cookicutter 2 | 3 | theme: 4 | name: "material" 5 | 6 | plugins: 7 | - mkdocstrings 8 | 9 | nav: 10 | - index.md 11 | - getting-started.md 12 | -------------------------------------------------------------------------------- /{{ 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 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | create = false 3 | -------------------------------------------------------------------------------- /{{ 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 | -------------------------------------------------------------------------------- /{{ 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.11" 9 | django-allauth = {extras = ["socialaccount"], version = "^64.0.0"} 10 | python-webpack-boilerplate = "^1.0.0" 11 | django-widget-tweaks = "^1.4.12" 12 | mkdocs = "^1.4.2" 13 | mkdocstrings = "^0.20.0" 14 | mkdocs-material = "^9.0.12" 15 | django = "^5.0.4" 16 | django-environ = "^0.11.2" 17 | psycopg2 = "^2.9.9" 18 | ipython = "^8.27.0" 19 | django-extensions = "^3.2.3" 20 | pillow = "^10.4.0" 21 | django-q2 = "^1.7.2" 22 | whitenoise = "^6.7.0" 23 | django-storages = {extras = ["s3"], version = "^1.14.4"} 24 | structlog = "^24.4.0" 25 | django-structlog = "^8.1.0" 26 | markdown = "^3.7" 27 | {% if cookiecutter.use_sentry == 'y' -%} 28 | sentry-sdk = {extras = ["django"], version = "^2.14.0"} 29 | structlog-sentry = "^2.2.1" 30 | {% endif -%} 31 | gunicorn = "^23.0.0" 32 | pytest = "^8.3.3" 33 | pytest-django = "^4.9.0" 34 | redis = "^5.0.8" 35 | django-anymail = {extras = ["mailgun"], version = "^12.0"} 36 | {% if cookiecutter.use_posthog == 'y' -%} 37 | posthog = "^3.6.6" 38 | {% endif -%} 39 | {% if cookiecutter.use_stripe == 'y' -%} 40 | dj-stripe = "^2.9.0" 41 | stripe = "^11.6.0" 42 | {% endif -%} 43 | django-ninja = "^1.3.0" 44 | {% if cookiecutter.use_mjml == 'y' -%} 45 | django-mjml = "^1.3" 46 | {% endif -%} 47 | {% if cookiecutter.use_ai == 'y' -%} 48 | pydantic-ai = "^0.2.9" 49 | {% endif -%} 50 | {% if cookiecutter.use_logfire == 'y' -%} 51 | logfire = "^3.6.4" 52 | {% endif -%} 53 | 54 | [tool.poetry.dev-dependencies] 55 | 56 | 57 | [tool.poetry.group.dev.dependencies] 58 | pylint = "^2.17.1" 59 | pylint-django = "^2.5.3" 60 | pre-commit = "^3.2.1" 61 | mkdocs = "^1.6.0" 62 | mkdocs-material = "^9.5.23" 63 | 64 | [tool.isort] 65 | profile = "django" 66 | combine_as_imports = true 67 | include_trailing_comma = true 68 | line_length = 120 69 | multi_line_output = 3 70 | 71 | [tool.black] 72 | line-length = 120 73 | target-version = ['py39'] 74 | include = '\.pyi?$' 75 | 76 | [tool.djlint] 77 | profile="django" 78 | ignore = "H031,H006,H023,H021,H011,T002" 79 | 80 | [tool.ruff] 81 | line-length = 120 82 | 83 | [build-system] 84 | requires = ["poetry-core>=1.0.0"] 85 | build-backend = "poetry.core.masonry.api" 86 | -------------------------------------------------------------------------------- /{{ 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 }}/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 }}/{{ cookiecutter.project_slug }}/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rasulkireev/django-saas-starter/796c140a6aa960c25ddabd5bbb63f6cde610b38e/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/__init__.py -------------------------------------------------------------------------------- /{{ 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 }}/requirements.txt: -------------------------------------------------------------------------------- 1 | arrow==1.2.3 ; python_version >= "3.10" and python_version < "4.0" 2 | asgiref==3.8.1 ; python_version >= "3.10" and python_version < "4.0" 3 | asttokens==2.4.1 ; python_version >= "3.10" and python_version < "4.0" 4 | babel==2.15.0 ; python_version >= "3.10" and python_version < "4.0" 5 | binaryornot==0.4.4 ; python_version >= "3.10" and python_version < "4.0" 6 | boto3==1.35.22 ; python_version >= "3.10" and python_version < "4.0" 7 | botocore==1.35.22 ; python_version >= "3.10" and python_version < "4.0" 8 | certifi==2022.12.7 ; python_version >= "3.10" and python_version < "4" 9 | cffi==1.15.1 ; python_version >= "3.10" and python_version < "4.0" 10 | chardet==5.1.0 ; python_version >= "3.10" and python_version < "4.0" 11 | charset-normalizer==3.0.1 ; python_version >= "3.10" and python_version < "4" 12 | click==8.1.3 ; python_version >= "3.10" and python_version < "4.0" 13 | colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" 14 | cookiecutter==2.1.1 ; python_version >= "3.10" and python_version < "4.0" 15 | cryptography==39.0.1 ; python_version >= "3.10" and python_version < "4.0" 16 | decorator==5.1.1 ; python_version >= "3.10" and python_version < "4.0" 17 | django-allauth[socialaccount]==64.0.0 ; python_version >= "3.10" and python_version < "4.0" 18 | django-environ==0.11.2 ; python_version >= "3.10" and python_version < "4" 19 | django-extensions==3.2.3 ; python_version >= "3.10" and python_version < "4.0" 20 | django-ipware==7.0.1 ; python_version >= "3.10" and python_version < "4.0" 21 | django-picklefield==3.2 ; python_version >= "3.10" and python_version < "4" 22 | django-q2==1.7.2 ; python_version >= "3.10" and python_version < "4" 23 | django-storages[s3]==1.14.4 ; python_version >= "3.10" and python_version < "4.0" 24 | django-structlog==8.1.0 ; python_version >= "3.10" and python_version < "4.0" 25 | django-widget-tweaks==1.4.12 ; python_version >= "3.10" and python_version < "4.0" 26 | django==5.0.4 ; python_version >= "3.10" and python_version < "4.0" 27 | exceptiongroup==1.2.2 ; python_version >= "3.10" and python_version < "3.11" 28 | executing==2.1.0 ; python_version >= "3.10" and python_version < "4.0" 29 | ghp-import==2.1.0 ; python_version >= "3.10" and python_version < "4.0" 30 | idna==3.4 ; python_version >= "3.10" and python_version < "4" 31 | ipython==8.27.0 ; python_version >= "3.10" and python_version < "4.0" 32 | jedi==0.19.1 ; python_version >= "3.10" and python_version < "4.0" 33 | jinja2-time==0.2.0 ; python_version >= "3.10" and python_version < "4.0" 34 | jinja2==3.1.2 ; python_version >= "3.10" and python_version < "4.0" 35 | jmespath==1.0.1 ; python_version >= "3.10" and python_version < "4.0" 36 | markdown==3.7 ; python_version >= "3.10" and python_version < "4.0" 37 | markupsafe==2.1.2 ; python_version >= "3.10" and python_version < "4.0" 38 | matplotlib-inline==0.1.7 ; python_version >= "3.10" and python_version < "4.0" 39 | mergedeep==1.3.4 ; python_version >= "3.10" and python_version < "4.0" 40 | mkdocs-autorefs==0.4.1 ; python_version >= "3.10" and python_version < "4.0" 41 | mkdocs-get-deps==0.2.0 ; python_version >= "3.10" and python_version < "4.0" 42 | mkdocs-material-extensions==1.3.1 ; python_version >= "3.10" and python_version < "4.0" 43 | mkdocs-material==9.5.23 ; python_version >= "3.10" and python_version < "4.0" 44 | mkdocs==1.6.0 ; python_version >= "3.10" and python_version < "4.0" 45 | mkdocstrings==0.20.0 ; python_version >= "3.10" and python_version < "4.0" 46 | oauthlib==3.2.2 ; python_version >= "3.10" and python_version < "4.0" 47 | packaging==23.0 ; python_version >= "3.10" and python_version < "4.0" 48 | paginate==0.5.6 ; python_version >= "3.10" and python_version < "4.0" 49 | parso==0.8.4 ; python_version >= "3.10" and python_version < "4.0" 50 | pathspec==0.12.1 ; python_version >= "3.10" and python_version < "4.0" 51 | pexpect==4.9.0 ; python_version >= "3.10" and python_version < "4.0" and (sys_platform != "win32" and sys_platform != "emscripten") 52 | pillow==10.4.0 ; python_version >= "3.10" and python_version < "4.0" 53 | platformdirs==3.2.0 ; python_version >= "3.10" and python_version < "4.0" 54 | prompt-toolkit==3.0.47 ; python_version >= "3.10" and python_version < "4.0" 55 | psycopg2==2.9.9 ; python_version >= "3.10" and python_version < "4.0" 56 | ptyprocess==0.7.0 ; python_version >= "3.10" and python_version < "4.0" and (sys_platform != "win32" and sys_platform != "emscripten") 57 | pure-eval==0.2.3 ; python_version >= "3.10" and python_version < "4.0" 58 | pycparser==2.21 ; python_version >= "3.10" and python_version < "4.0" 59 | pygments==2.18.0 ; python_version >= "3.10" and python_version < "4.0" 60 | pyjwt[crypto]==2.6.0 ; python_version >= "3.10" and python_version < "4.0" 61 | pymdown-extensions==10.4 ; python_version >= "3.10" and python_version < "4.0" 62 | python-dateutil==2.8.2 ; python_version >= "3.10" and python_version < "4.0" 63 | python-ipware==3.0.0 ; python_version >= "3.10" and python_version < "4.0" 64 | python-slugify==8.0.0 ; python_version >= "3.10" and python_version < "4.0" 65 | python-webpack-boilerplate==1.0.1 ; python_version >= "3.10" and python_version < "4.0" 66 | pyyaml-env-tag==0.1 ; python_version >= "3.10" and python_version < "4.0" 67 | pyyaml==6.0 ; python_version >= "3.10" and python_version < "4.0" 68 | regex==2022.10.31 ; python_version >= "3.10" and python_version < "4.0" 69 | requests-oauthlib==1.3.1 ; python_version >= "3.10" and python_version < "4.0" 70 | requests==2.28.2 ; python_version >= "3.10" and python_version < "4" 71 | s3transfer==0.10.2 ; python_version >= "3.10" and python_version < "4.0" 72 | six==1.16.0 ; python_version >= "3.10" and python_version < "4.0" 73 | sqlparse==0.4.3 ; python_version >= "3.10" and python_version < "4.0" 74 | stack-data==0.6.3 ; python_version >= "3.10" and python_version < "4.0" 75 | structlog==24.4.0 ; python_version >= "3.10" and python_version < "4.0" 76 | text-unidecode==1.3 ; python_version >= "3.10" and python_version < "4.0" 77 | traitlets==5.14.3 ; python_version >= "3.10" and python_version < "4.0" 78 | typing-extensions==4.12.2 ; python_version >= "3.10" and python_version < "3.12" 79 | tzdata==2022.7 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32" 80 | urllib3==1.26.14 ; python_version >= "3.10" and python_version < "4" 81 | watchdog==2.2.1 ; python_version >= "3.10" and python_version < "4.0" 82 | wcwidth==0.2.13 ; python_version >= "3.10" and python_version < "4.0" 83 | whitenoise==6.7.0 ; python_version >= "3.10" and python_version < "4.0" 84 | -------------------------------------------------------------------------------- /{{ 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 }}/{{ cookiecutter.project_slug }}/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for {{ cookiecutter.project_slug }} project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.0.4. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.0/ref/settings/ 11 | """ 12 | 13 | import os 14 | from pathlib import Path 15 | import environ 16 | import structlog 17 | import logging 18 | import structlog 19 | {% if cookiecutter.use_sentry == 'y' -%} 20 | import sentry_sdk 21 | from structlog_sentry import SentryProcessor 22 | from {{ cookiecutter.project_slug }}.sentry_utils import CustomLoggingIntegration 23 | from sentry_sdk.integrations.logging import LoggingIntegration 24 | {% endif %} 25 | {% if cookiecutter.use_logfire == 'y' -%} 26 | import logfire 27 | {% endif %} 28 | 29 | 30 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 31 | BASE_DIR = Path(__file__).resolve().parent.parent 32 | environ.Env.read_env(BASE_DIR / ".env") 33 | 34 | env = environ.Env( 35 | # set casting, default value 36 | DEBUG=(bool, False) 37 | ) 38 | 39 | ENVIRONMENT = env("ENVIRONMENT") 40 | 41 | {% if cookiecutter.use_logfire == 'y' -%} 42 | if ENVIRONMENT == "prod": 43 | logfire.configure(environment=ENVIRONMENT) 44 | {%- endif %} 45 | 46 | # Quick-start development settings - unsuitable for production 47 | # See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ 48 | 49 | # SECURITY WARNING: keep the secret key used in production secret! 50 | SECRET_KEY = env("SECRET_KEY") 51 | 52 | # SECURITY WARNING: don't run with debug turned on in production! 53 | DEBUG = env('DEBUG') 54 | 55 | ALLOWED_HOSTS = env.list("ALLOWED_HOSTS") 56 | CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS") 57 | 58 | INSTALLED_APPS = [ 59 | "django.contrib.admin", 60 | "django.contrib.auth", 61 | "django.contrib.contenttypes", 62 | "django.contrib.sessions", 63 | "django.contrib.messages", 64 | "django.contrib.staticfiles", 65 | "django.contrib.sites", 66 | "django.contrib.sitemaps", 67 | "webpack_boilerplate", 68 | "widget_tweaks", 69 | "anymail", 70 | {% if cookiecutter.use_stripe == 'y' -%} 71 | "djstripe", 72 | {% endif %} 73 | "allauth", 74 | "allauth.account", 75 | "allauth.socialaccount", 76 | {% if cookiecutter.use_github_auth == 'y' -%} 77 | "allauth.socialaccount.providers.github", 78 | {% endif %} 79 | "django_q", 80 | "django_extensions", 81 | {% if cookiecutter.use_mjml == 'y' -%} 82 | "mjml", 83 | {% endif %} 84 | "django_structlog", 85 | "core.apps.CoreConfig", 86 | ] 87 | 88 | MIDDLEWARE = [ 89 | "django.middleware.security.SecurityMiddleware", 90 | "whitenoise.middleware.WhiteNoiseMiddleware", 91 | "django.contrib.sessions.middleware.SessionMiddleware", 92 | "django.middleware.common.CommonMiddleware", 93 | "django.middleware.csrf.CsrfViewMiddleware", 94 | "django.contrib.auth.middleware.AuthenticationMiddleware", 95 | "django.contrib.messages.middleware.MessageMiddleware", 96 | "allauth.account.middleware.AccountMiddleware", 97 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 98 | # "django_structlog.middlewares.RequestMiddleware", 99 | ] 100 | 101 | ROOT_URLCONF = "{{ cookiecutter.project_slug }}.urls" 102 | 103 | TEMPLATES = [ 104 | { 105 | "BACKEND": "django.template.backends.django.DjangoTemplates", 106 | "DIRS": [str(BASE_DIR.joinpath("frontend", "templates"))], 107 | "APP_DIRS": True, 108 | "OPTIONS": { 109 | "context_processors": [ 110 | "django.template.context_processors.debug", 111 | "django.template.context_processors.request", 112 | "django.contrib.auth.context_processors.auth", 113 | "django.contrib.messages.context_processors.messages", 114 | ], 115 | }, 116 | }, 117 | ] 118 | 119 | WSGI_APPLICATION = "{{ cookiecutter.project_slug }}.wsgi.application" 120 | 121 | 122 | # Database 123 | # https://docs.djangoproject.com/en/4.0/ref/settings/#databases 124 | 125 | DATABASES = { 126 | "default": env.db_url(), 127 | } 128 | 129 | # Password validation 130 | # https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators 131 | 132 | AUTH_PASSWORD_VALIDATORS = [ 133 | { 134 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 135 | }, 136 | { 137 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 138 | }, 139 | { 140 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 141 | }, 142 | { 143 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 144 | }, 145 | ] 146 | 147 | 148 | # Internationalization 149 | # https://docs.djangoproject.com/en/4.0/topics/i18n/ 150 | 151 | LANGUAGE_CODE = "en-us" 152 | 153 | TIME_ZONE = "UTC" 154 | 155 | USE_I18N = True 156 | 157 | USE_TZ = True 158 | 159 | 160 | # Static files (CSS, JavaScript, Images) 161 | # https://docs.djangoproject.com/en/4.0/howto/static-files/ 162 | 163 | STATIC_URL = "/static/" 164 | 165 | STATIC_ROOT = BASE_DIR.joinpath("static/") 166 | 167 | STATICFILES_DIRS = [ 168 | BASE_DIR.joinpath("frontend/build"), 169 | ] 170 | 171 | bucket_name = f"{{ cookiecutter.project_slug }}-{ENVIRONMENT}" 172 | 173 | STORAGES = { 174 | "default": { 175 | "BACKEND": "storages.backends.s3.S3Storage", 176 | "OPTIONS": { 177 | "bucket_name": bucket_name, 178 | "default_acl": "public-read", 179 | "region_name": "eu-east-1", 180 | "endpoint_url": env("AWS_S3_ENDPOINT_URL"), 181 | "access_key": env("AWS_ACCESS_KEY_ID"), 182 | "secret_key": env("AWS_SECRET_ACCESS_KEY"), 183 | "querystring_auth": False, 184 | "file_overwrite": False, 185 | }, 186 | }, 187 | "staticfiles": { 188 | "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", 189 | }, 190 | } 191 | 192 | MEDIA_URL = f"{env('AWS_S3_ENDPOINT_URL')}/{bucket_name}/" 193 | MEDIA_ROOT = os.path.join(BASE_DIR, "media/") 194 | 195 | WEBPACK_LOADER = { 196 | "MANIFEST_FILE": BASE_DIR.joinpath("frontend/build/manifest.json"), 197 | } 198 | 199 | # Default primary key field type 200 | # https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field 201 | 202 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 203 | 204 | AUTHENTICATION_BACKENDS = [ 205 | "django.contrib.auth.backends.ModelBackend", 206 | "allauth.account.auth_backends.AuthenticationBackend", 207 | ] 208 | SITE_ID = 1 209 | 210 | EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" 211 | 212 | LOGIN_REDIRECT_URL = "home" 213 | ACCOUNT_LOGOUT_REDIRECT_URL = "home" 214 | 215 | ACCOUNT_USER_MODEL_USERNAME_FIELD = "username" 216 | ACCOUNT_AUTHENTICATION_METHOD = "username" 217 | ACCOUNT_USERNAME_REQUIRED = True 218 | ACCOUNT_EMAIL_REQUIRED = True 219 | ACCOUNT_UNIQUE_EMAIL = True 220 | ACCOUNT_SESSION_REMEMBER = True 221 | ACCOUNT_FORMS = { 222 | "signup": "core.forms.CustomSignUpForm", 223 | "login": "core.forms.CustomLoginForm", 224 | } 225 | if ENVIRONMENT == "prod": 226 | ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https" 227 | 228 | SOCIALACCOUNT_PROVIDERS = { 229 | {% if cookiecutter.use_github_auth == 'y' -%} 230 | "github": { 231 | "VERIFIED_EMAIL": True, 232 | "EMAIL_AUTHENTICATION": True, 233 | "AUTO_SIGNUP": True, 234 | "APP": { 235 | "client_id": env("GITHUB_CLIENT_ID"), 236 | "secret": env("GITHUB_CLIENT_SECRET"), 237 | }, 238 | }, 239 | {% endif %} 240 | } 241 | 242 | ANYMAIL = { 243 | "MAILGUN_API_KEY": env("MAILGUN_API_KEY"), 244 | "MAILGUN_SENDER_DOMAIN": "mg.{{ cookiecutter.project_slug }}.app", 245 | } 246 | DEFAULT_FROM_EMAIL = "Rasul from {{ cookiecutter.project_name }} " 247 | SERVER_EMAIL = "{{ cookiecutter.project_name }} Errors " 248 | 249 | if DEBUG: 250 | EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" 251 | EMAIL_HOST = "mailhog" # Use the service name from docker-compose 252 | EMAIL_PORT = 1025 253 | EMAIL_USE_TLS = False 254 | EMAIL_HOST_USER = "" 255 | EMAIL_HOST_PASSWORD = "" 256 | else: 257 | EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend" 258 | 259 | Q_CLUSTER = { 260 | "name": "{{ cookiecutter.project_slug }}-q", 261 | "timeout": 90, 262 | "retry": 120, 263 | "workers": 4, 264 | "max_attempts": 2, 265 | "redis": env("REDIS_URL"), 266 | } 267 | 268 | LOGGING = { 269 | "version": 1, 270 | "disable_existing_loggers": False, 271 | "formatters": { 272 | "json_formatter": { 273 | "()": structlog.stdlib.ProcessorFormatter, 274 | "processor": structlog.processors.JSONRenderer(), 275 | }, 276 | "plain_console": { 277 | "()": structlog.stdlib.ProcessorFormatter, 278 | "processor": structlog.dev.ConsoleRenderer(), 279 | }, 280 | "key_value": { 281 | "()": structlog.stdlib.ProcessorFormatter, 282 | "processor": structlog.processors.KeyValueRenderer(key_order=["timestamp", "level", "event", "logger"]), 283 | }, 284 | }, 285 | "handlers": { 286 | "console": { 287 | "class": "logging.StreamHandler", 288 | "formatter": "plain_console", 289 | "level": "DEBUG", 290 | }, 291 | "json_console": { 292 | "class": "logging.StreamHandler", 293 | "formatter": "json_formatter", 294 | "level": "DEBUG", 295 | }, 296 | }, 297 | "loggers": { 298 | "django_structlog": { 299 | "handlers": ["console"], 300 | "level": "INFO", 301 | "propagate": False, 302 | }, 303 | "{{ cookiecutter.project_slug }}": { 304 | "level": "DEBUG", 305 | "handlers": ["console"], 306 | "propagate": False, 307 | }, 308 | }, 309 | } 310 | 311 | structlog.configure( 312 | processors=[ 313 | structlog.contextvars.merge_contextvars, 314 | structlog.stdlib.filter_by_level, 315 | structlog.processors.TimeStamper(fmt="iso"), 316 | structlog.stdlib.add_logger_name, 317 | structlog.stdlib.add_log_level, 318 | {% if cookiecutter.use_sentry == 'y' -%} 319 | SentryProcessor(event_level=logging.ERROR), 320 | {%- endif %} 321 | structlog.stdlib.PositionalArgumentsFormatter(), 322 | {% if cookiecutter.use_logfire == 'y' -%} 323 | logfire.StructlogProcessor(), 324 | {%- endif %} 325 | structlog.processors.StackInfoRenderer(), 326 | structlog.processors.format_exc_info, 327 | structlog.processors.UnicodeDecoder(), 328 | structlog.stdlib.ProcessorFormatter.wrap_for_formatter, 329 | ], 330 | logger_factory=structlog.stdlib.LoggerFactory(), 331 | cache_logger_on_first_use=True, 332 | ) 333 | 334 | if ENVIRONMENT == "prod": 335 | LOGGING["loggers"]["{{ cookiecutter.project_slug }}"]["level"] = env("DJANGO_LOG_LEVEL", default="INFO") 336 | LOGGING["loggers"]["{{ cookiecutter.project_slug }}"]["handlers"] = ["json_console"] 337 | LOGGING["loggers"]["django_structlog"]["handlers"] = ["json_console"] 338 | 339 | {% if cookiecutter.use_sentry == 'y' -%} 340 | SENTRY_DSN = env("SENTRY_DSN") 341 | if ENVIRONMENT == "prod" and SENTRY_DSN: 342 | sentry_sdk.init( 343 | dsn=SENTRY_DSN, 344 | integrations=[ 345 | LoggingIntegration( 346 | level=None, 347 | event_level=None 348 | ), 349 | CustomLoggingIntegration(event_level=logging.ERROR) 350 | ], 351 | ) 352 | {% endif %} 353 | 354 | {% if cookiecutter.use_posthog == 'y' -%} 355 | POSTHOG_API_KEY = env("POSTHOG_API_KEY") 356 | {% endif %} 357 | 358 | {% if cookiecutter.use_buttondown == 'y' -%} 359 | BUTTONDOWN_API_KEY=env("BUTTONDOWN_API_KEY") 360 | {% endif %} 361 | 362 | {% if cookiecutter.use_stripe == 'y' -%} 363 | STRIPE_LIVE_SECRET_KEY = env("STRIPE_LIVE_SECRET_KEY") 364 | STRIPE_TEST_SECRET_KEY = env("STRIPE_TEST_SECRET_KEY") 365 | 366 | STRIPE_LIVE_MODE = False 367 | STRIPE_SECRET_KEY = STRIPE_TEST_SECRET_KEY 368 | if ENVIRONMENT == "prod": 369 | STRIPE_LIVE_MODE = True 370 | STRIPE_SECRET_KEY = STRIPE_LIVE_SECRET_KEY 371 | 372 | DJSTRIPE_WEBHOOK_SECRET = env("DJSTRIPE_WEBHOOK_SECRET") 373 | DJSTRIPE_FOREIGN_KEY_TO_FIELD = "id" 374 | {% endif %} 375 | 376 | {% if cookiecutter.use_mjml == 'y' -%} 377 | MJML_BACKEND_MODE = "httpserver" 378 | MJML_HTTPSERVERS = [ 379 | { 380 | "URL": "https://api.mjml.io/v1/render", 381 | "HTTP_AUTH": (env('MJML_APPLICATION_ID'), env("MJML_SECRET")), 382 | } 383 | ] 384 | {% endif %} 385 | -------------------------------------------------------------------------------- /{{ 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 core.models import BlogPost 7 | {% endif %} 8 | 9 | 10 | class StaticViewSitemap(sitemaps.Sitemap): 11 | """Generate Sitemap for the site""" 12 | 13 | priority = 0.9 14 | protocol = "https" 15 | 16 | def items(self): 17 | """Identify items that will be in the Sitemap 18 | 19 | Returns: 20 | List: urlNames that will be in the Sitemap 21 | """ 22 | return [ 23 | "home", 24 | "uses", 25 | {% if cookiecutter.use_stripe == 'y' -%} 26 | "pricing", 27 | {%- endif %} 28 | {% if cookiecutter.generate_blog == 'y' %} 29 | "blog_posts", 30 | {%- endif %} 31 | ] 32 | 33 | def location(self, item): 34 | """Get location for each item in the Sitemap 35 | 36 | Args: 37 | item (str): Item from the items function 38 | 39 | Returns: 40 | str: Url for the sitemap item 41 | """ 42 | return reverse(item) 43 | 44 | sitemaps = { 45 | "static": StaticViewSitemap, 46 | {% if cookiecutter.generate_blog == 'y' %} 47 | "blog": GenericSitemap( 48 | {"queryset": BlogPost.objects.all(), "date_field": "created_at"}, 49 | priority=0.85, 50 | protocol="https", 51 | ), 52 | {%- endif %} 53 | } 54 | -------------------------------------------------------------------------------- /{{ 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 }}/{{ 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 | 23 | urlpatterns = [ 24 | path("admin/", admin.site.urls), 25 | path("accounts/", include("allauth.urls")), 26 | path("anymail/", include("anymail.urls")), 27 | path("uses", TemplateView.as_view(template_name="pages/uses.html"), name="uses"), 28 | {% if cookiecutter.use_stripe == 'y' -%} 29 | path("stripe/", include("djstripe.urls", namespace="djstripe")), 30 | {% endif %} 31 | path("", include("core.urls")), 32 | path( 33 | "sitemap.xml", 34 | sitemap, 35 | {"sitemaps": sitemaps}, 36 | name="django.contrib.sitemaps.views.sitemap", 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /{{ 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 }}/{{ 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 | --------------------------------------------------------------------------------