├── .dockerignore
├── .github
└── workflows
│ ├── all.yaml
│ ├── container.yaml
│ ├── infra.yaml
│ ├── mysql-init.yaml
│ ├── staticfiles.yaml
│ ├── test-django-mysql.yaml
│ └── test-django-postgres.yaml
├── .gitignore
├── .vscode
└── settings.json
├── Dockerfile
├── LICENSE
├── Makefile
├── README.md
├── articles
├── __init__.py
├── admin.py
├── apps.py
├── management
│ ├── __init__.py
│ └── commands
│ │ ├── __init__.py
│ │ └── backup_articles.py
├── migrations
│ ├── 0001_initial.py
│ ├── 0002_article_updated_by.py
│ └── __init__.py
├── models.py
├── templates
│ └── articles
│ │ ├── article_detail.html
│ │ └── article_list.html
├── tests.py
├── urls.py
├── utils.py
└── views.py
├── cfe-django-blog.code-workspace
├── cfeblog
├── __init__.py
├── asgi.py
├── dbs
│ ├── __init__.py
│ ├── mysql.py
│ └── postgres.py
├── settings.py
├── storages
│ ├── __init__.py
│ ├── backends.py
│ ├── client.py
│ ├── conf.py
│ ├── mixins.py
│ └── services
│ │ ├── __init__.py
│ │ └── linode.py
├── urls.py
└── wsgi.py
├── config
└── entrypoint.sh
├── devops
├── ansible
│ ├── django-app
│ │ ├── handlers
│ │ │ └── main.yaml
│ │ └── tasks
│ │ │ └── main.yaml
│ ├── docker-install
│ │ ├── handlers
│ │ │ └── main.yaml
│ │ └── tasks
│ │ │ └── main.yaml
│ ├── inventory.ini
│ ├── main.yaml
│ ├── nginx-lb
│ │ ├── handlers
│ │ │ └── main.yaml
│ │ └── tasks
│ │ │ └── main.yaml
│ └── templates
│ │ ├── docker-compose.yaml.jinja2
│ │ └── nginx-lb.conf.jinja
└── tf
│ ├── .terraform.lock.hcl
│ ├── linodes.tf
│ ├── locals.tf
│ ├── main.tf
│ ├── outputs.tf
│ ├── templates
│ └── ansible-inventory.tpl
│ └── variables.tf
├── docker-compose.prod.yaml
├── docker-compose.yaml
├── fixtures
├── articles.json
└── auth.json
├── manage.py
├── mediafiles
└── articles
│ └── hello-world
│ └── 07472194-a6d5-11ec-887f-acde48001122.jpg
├── requirements.txt
├── staticfiles
└── empty.txt
├── staticroot
└── empty.txt
└── templates
├── base.html
└── navbar.html
/.dockerignore:
--------------------------------------------------------------------------------
1 | devops/
2 |
3 | devops/linode/terraform/backend
4 | staticroot/admin/
5 | # Byte-compiled / optimized / DLL files
6 | __pycache__/
7 | *.py[cod]
8 | *$py.class
9 |
10 | # C extensions
11 | *.so
12 |
13 | # Distribution / packaging
14 | .Python
15 | build/
16 | develop-eggs/
17 | dist/
18 | downloads/
19 | eggs/
20 | .eggs/
21 | lib/
22 | lib64/
23 | parts/
24 | sdist/
25 | var/
26 | wheels/
27 | pip-wheel-metadata/
28 | share/python-wheels/
29 | *.egg-info/
30 | .installed.cfg
31 | *.egg
32 | MANIFEST
33 |
34 | # PyInstaller
35 | # Usually these files are written by a python script from a template
36 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
37 | *.manifest
38 | *.spec
39 |
40 | # Installer logs
41 | pip-log.txt
42 | pip-delete-this-directory.txt
43 |
44 | # Unit test / coverage reports
45 | htmlcov/
46 | .tox/
47 | .nox/
48 | .coverage
49 | .coverage.*
50 | .cache
51 | nosetests.xml
52 | coverage.xml
53 | *.cover
54 | *.py,cover
55 | .hypothesis/
56 | .pytest_cache/
57 |
58 | # Translations
59 | *.mo
60 | *.pot
61 |
62 | # Django stuff:
63 | *.log
64 | local_settings.py
65 | db.sqlite3
66 | db.sqlite3-journal
67 |
68 | # Flask stuff:
69 | instance/
70 | .webassets-cache
71 |
72 | # Scrapy stuff:
73 | .scrapy
74 |
75 | # Sphinx documentation
76 | docs/_build/
77 |
78 | # PyBuilder
79 | target/
80 |
81 | # Jupyter Notebook
82 | .ipynb_checkpoints
83 |
84 | # IPython
85 | profile_default/
86 | ipython_config.py
87 |
88 | # pyenv
89 | .python-version
90 |
91 | # pipenv
92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
95 | # install all needed dependencies.
96 | #Pipfile.lock
97 |
98 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
99 | __pypackages__/
100 |
101 | # Celery stuff
102 | celerybeat-schedule
103 | celerybeat.pid
104 |
105 | # SageMath parsed files
106 | *.sage.py
107 |
108 | # Environments
109 | .env
110 | .venv
111 | env/
112 | venv/
113 | ENV/
114 | env.bak/
115 | venv.bak/
116 |
117 | # Spyder project settings
118 | .spyderproject
119 | .spyproject
120 |
121 | # Rope project settings
122 | .ropeproject
123 |
124 | # mkdocs documentation
125 | /site
126 |
127 | # mypy
128 | .mypy_cache/
129 | .dmypy.json
130 | dmypy.json
131 |
132 | # Pyre type checker
133 | .pyre/
134 |
135 |
136 |
137 | # Local .terraform directories
138 | **/.terraform/*
139 |
140 | # .tfstate files
141 | *.tfstate
142 | *.tfstate.*
143 |
144 | # Crash log files
145 | crash.log
146 | crash.*.log
147 |
148 | # Exclude all .tfvars files, which are likely to contain sensitive data, such as
149 | # password, private keys, and other secrets. These should not be part of version
150 | # control as they are data points which are potentially sensitive and subject
151 | # to change depending on the environment.
152 | *.tfvars
153 | *.tfvars.json
154 |
155 | # Ignore override files as they are usually used to override resources locally and so
156 | # are not checked in
157 | override.tf
158 | override.tf.json
159 | *_override.tf
160 | *_override.tf.json
161 |
162 | # Include override files you do wish to add to version control using negated pattern
163 | # !example_override.tf
164 |
165 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
166 | # example: *tfplan*
167 |
168 | # Ignore CLI configuration files
169 | .terraformrc
170 | terraform.rc
--------------------------------------------------------------------------------
/.github/workflows/all.yaml:
--------------------------------------------------------------------------------
1 | name: 0 - Run Everything
2 |
3 |
4 | on:
5 | workflow_dispatch:
6 | # left in for reference only
7 | # push:
8 | # branches: [main]
9 | # pull_request:
10 | # branches: [main]
11 |
12 | jobs:
13 | test_django:
14 | uses: ./.github/workflows/test-django-mysql.yaml
15 | build_container:
16 | needs: test_django
17 | uses: ./.github/workflows/container.yaml
18 | secrets:
19 | DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
20 | DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
21 | DOCKERHUB_APP_NAME: ${{ secrets.DOCKERHUB_APP_NAME }}
22 | update_infra:
23 | needs: build_container
24 | uses: ./.github/workflows/infra.yaml
25 | secrets:
26 | ALLOWED_HOST: ${{ secrets.ALLOWED_HOST }}
27 | DJANGO_SECRET_KEY: ${{ secrets.DJANGO_SECRET_KEY }}
28 | DJANGO_VM_COUNT: ${{ secrets.DJANGO_VM_COUNT }}
29 | DOCKERHUB_APP_NAME: ${{ secrets.DOCKERHUB_APP_NAME }}
30 | DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
31 | DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
32 | LINODE_BUCKET_REGION: ${{ secrets.LINODE_BUCKET_REGION }}
33 | LINODE_BUCKET_ACCESS_KEY: ${{ secrets.LINODE_BUCKET_ACCESS_KEY }}
34 | LINODE_BUCKET_SECRET_KEY: ${{ secrets.LINODE_BUCKET_SECRET_KEY }}
35 | LINODE_IMAGE: ${{ secrets.LINODE_IMAGE }}
36 | LINODE_OBJECT_STORAGE_DEVOPS_BUCKET: ${{ secrets.LINODE_OBJECT_STORAGE_DEVOPS_BUCKET }}
37 | LINODE_OBJECT_STORAGE_DEVOPS_TF_KEY: ${{ secrets.LINODE_OBJECT_STORAGE_DEVOPS_TF_KEY }}
38 | LINODE_OBJECT_STORAGE_DEVOPS_ACCESS_KEY: ${{ secrets.LINODE_OBJECT_STORAGE_DEVOPS_ACCESS_KEY }}
39 | LINODE_OBJECT_STORAGE_DEVOPS_SECRET_KEY: ${{ secrets.LINODE_OBJECT_STORAGE_DEVOPS_SECRET_KEY }}
40 | LINODE_BUCKET: ${{ secrets.LINODE_BUCKET }}
41 | LINODE_PA_TOKEN: ${{ secrets.LINODE_PA_TOKEN }}
42 | MYSQL_DB_CERT: ${{ secrets.MYSQL_DB_CERT }}
43 | MYSQL_DATABASE: ${{ secrets.MYSQL_DATABASE }}
44 | MYSQL_HOST: ${{ secrets.MYSQL_HOST }}
45 | MYSQL_DB_ROOT_PASSWORD: ${{ secrets.MYSQL_DB_ROOT_PASSWORD }}
46 | MYSQL_DB_PORT: ${{ secrets.MYSQL_DB_PORT }}
47 | MYSQL_USER: ${{ secrets.MYSQL_USER }}
48 | ROOT_USER_PW: ${{ secrets.ROOT_USER_PW }}
49 | SSH_PUB_KEY: ${{ secrets.SSH_PUB_KEY }}
50 | SSH_DEVOPS_KEY_PUBLIC: ${{ secrets.SSH_DEVOPS_KEY_PUBLIC }}
51 | SSH_DEVOPS_KEY_PRIVATE: ${{ secrets.SSH_DEVOPS_KEY_PRIVATE }}
52 | collectstatic:
53 | needs: test_django
54 | uses: ./.github/workflows/staticfiles.yaml
55 | secrets:
56 | LINODE_BUCKET: ${{ secrets.LINODE_BUCKET }}
57 | LINODE_BUCKET_REGION: ${{ secrets.LINODE_BUCKET_REGION }}
58 | LINODE_BUCKET_ACCESS_KEY: ${{ secrets.LINODE_BUCKET_ACCESS_KEY }}
59 | LINODE_BUCKET_SECRET_KEY: ${{ secrets.LINODE_BUCKET_SECRET_KEY }}
--------------------------------------------------------------------------------
/.github/workflows/container.yaml:
--------------------------------------------------------------------------------
1 | name: 2 -Build App Container Image
2 |
3 | on:
4 | workflow_call:
5 | secrets:
6 | DOCKERHUB_USERNAME:
7 | required: true
8 | DOCKERHUB_TOKEN:
9 | required: true
10 | DOCKERHUB_APP_NAME:
11 | required: true
12 | workflow_dispatch:
13 |
14 | jobs:
15 | docker:
16 | runs-on: ubuntu-latest
17 | steps:
18 | - name: Checkout
19 | uses: actions/checkout@v2
20 | - name: Set up QEMU
21 | uses: docker/setup-qemu-action@v1
22 | - name: Set up Docker Buildx
23 | uses: docker/setup-buildx-action@v1
24 | - name: Login to DockerHub
25 | uses: docker/login-action@v1
26 | with:
27 | username: ${{ secrets.DOCKERHUB_USERNAME }}
28 | password: ${{ secrets.DOCKERHUB_TOKEN }}
29 | - name: Build container image
30 | run: |
31 | docker build -f Dockerfile \
32 | -t ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_APP_NAME }}:latest \
33 | -t ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_APP_NAME }}:${GITHUB_SHA::7}-${GITHUB_RUN_ID::5} \
34 | .
35 | - name: Push image
36 | run: |
37 | docker push ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_APP_NAME }} --all-tags
--------------------------------------------------------------------------------
/.github/workflows/infra.yaml:
--------------------------------------------------------------------------------
1 | name: 4 - Apply Infrastructure via Terraform and Ansible
2 |
3 | on:
4 | workflow_call:
5 | secrets:
6 | ALLOWED_HOST:
7 | required: false
8 | DJANGO_SECRET_KEY:
9 | required: true
10 | DJANGO_VM_COUNT:
11 | required: true
12 | DOCKERHUB_APP_NAME:
13 | required: true
14 | DOCKERHUB_TOKEN:
15 | required: true
16 | DOCKERHUB_USERNAME:
17 | required: true
18 | LINODE_BUCKET_REGION:
19 | required: true
20 | LINODE_BUCKET_ACCESS_KEY:
21 | required: true
22 | LINODE_BUCKET_SECRET_KEY:
23 | required: true
24 | LINODE_IMAGE:
25 | required: true
26 | LINODE_OBJECT_STORAGE_DEVOPS_BUCKET:
27 | required: true
28 | LINODE_OBJECT_STORAGE_DEVOPS_TF_KEY:
29 | required: true
30 | LINODE_OBJECT_STORAGE_DEVOPS_ACCESS_KEY:
31 | required: true
32 | LINODE_OBJECT_STORAGE_DEVOPS_SECRET_KEY:
33 | required: true
34 | LINODE_BUCKET:
35 | required: true
36 | LINODE_PA_TOKEN:
37 | required: true
38 | MYSQL_DB_CERT:
39 | required: true
40 | MYSQL_DATABASE:
41 | required: true
42 | MYSQL_HOST:
43 | required: true
44 | MYSQL_DB_ROOT_PASSWORD:
45 | required: true
46 | MYSQL_DB_PORT:
47 | required: true
48 | MYSQL_USER:
49 | required: true
50 | ROOT_USER_PW:
51 | required: true
52 | SSH_PUB_KEY:
53 | required: true
54 | SSH_DEVOPS_KEY_PUBLIC:
55 | required: true
56 | SSH_DEVOPS_KEY_PRIVATE:
57 | required: true
58 | workflow_dispatch:
59 |
60 | jobs:
61 | terraform_ansible:
62 | runs-on: ubuntu-latest
63 | steps:
64 | - name: Checkout
65 | uses: actions/checkout@v2
66 | - name: Setup Terraform
67 | uses: hashicorp/setup-terraform@v1
68 | with:
69 | terraform_version: 1.1.9
70 | - name: Add Terraform Backend for S3
71 | run: |
72 | cat << EOF > devops/tf/backend
73 | skip_credentials_validation = true
74 | skip_region_validation = true
75 | bucket="${{ secrets.LINODE_OBJECT_STORAGE_DEVOPS_BUCKET }}"
76 | key="${{ secrets.LINODE_OBJECT_STORAGE_DEVOPS_TF_KEY }}"
77 | region="us-southeast-1"
78 | endpoint="us-southeast-1.linodeobjects.com"
79 | access_key="${{ secrets.LINODE_OBJECT_STORAGE_DEVOPS_ACCESS_KEY }}"
80 | secret_key="${{ secrets.LINODE_OBJECT_STORAGE_DEVOPS_SECRET_KEY }}"
81 | EOF
82 | - name: Add Terraform TFVars
83 | run: |
84 | cat << EOF > devops/tf/terraform.tfvars
85 | linode_pa_token="${{ secrets.LINODE_PA_TOKEN }}"
86 | authorized_key="${{ secrets.SSH_DEVOPS_KEY_PUBLIC }}"
87 | root_user_pw="${{ secrets.ROOT_USER_PW }}"
88 | app_instance_vm_count="${{ secrets.DJANGO_VM_COUNT }}"
89 | linode_image="${{ secrets.LINODE_IMAGE }}"
90 | EOF
91 | - name: Terraform Init
92 | run: terraform -chdir=./devops/tf init -backend-config=backend
93 | - name: Terraform Validate
94 | run: terraform -chdir=./devops/tf validate -no-color
95 | - name: Terraform Apply Changes
96 | run: terraform -chdir=./devops/tf apply -auto-approve
97 | - name: Add MySQL Cert
98 | run: |
99 | mkdir -p certs
100 | cat << EOF > certs/db.crt
101 | ${{ secrets.MYSQL_DB_CERT }}
102 | EOF
103 | - name: Add SSH Keys
104 | run: |
105 | cat << EOF > devops/ansible/devops-key
106 | ${{ secrets.SSH_DEVOPS_KEY_PRIVATE }}
107 | EOF
108 | - name: Update devops private key permissions
109 | run: |
110 | chmod 400 devops/ansible/devops-key
111 | - name: Install Ansible
112 | run: |
113 | pip install ansible
114 | - name: Add Production Environment Variables to Instance
115 | run: |
116 | cat << EOF > .env.prod
117 | ALLOWED_HOST=${{ secrets.ALLOWED_HOST }}
118 | # required keys
119 | DJANGO_SECRET_KEY=${{ secrets.DJANGO_SECRET_KEY }}
120 | DATABASE_BACKEND=mysql
121 | DJANGO_DEBUG="0"
122 | DJANGO_STORAGE_SERVICE=linode
123 | # mysql db setup
124 | MYSQL_DATABASE=${{ secrets.MYSQL_DATABASE }}
125 | MYSQL_USER=${{ secrets.MYSQL_USER }}
126 | MYSQL_PASSWORD=${{ secrets.MYSQL_PASSWORD }}
127 | MYSQL_ROOT_PASSWORD=${{ secrets.MYSQL_DB_ROOT_PASSWORD }}
128 | MYSQL_TCP_PORT=${{ secrets.MYSQL_DB_PORT }}
129 | MYSQL_HOST=${{ secrets.MYSQL_DB_HOST }}
130 | # static files connection
131 | LINODE_BUCKET=${{ secrets.LINODE_BUCKET }}
132 | LINODE_BUCKET_REGION=${{ secrets.LINODE_BUCKET_REGION }}
133 | LINODE_BUCKET_ACCESS_KEY=${{ secrets.LINODE_BUCKET_ACCESS_KEY }}
134 | LINODE_BUCKET_SECRET_KEY=${{ secrets.LINODE_BUCKET_SECRET_KEY }}
135 | EOF
136 | - name: Adding or Override Ansible Config File
137 | run: |
138 | cat << EOF > devops/ansible/ansible.cfg
139 | [defaults]
140 | ansible_python_interpreter='/usr/bin/python3'
141 | deprecation_warnings=False
142 | inventory=./inventory.ini
143 | remote_user="root"
144 | host_key_checking=False
145 | private_key_file = ./devops-key
146 | retries=2
147 | EOF
148 | - name: Adding Ansible Variables
149 | run: |
150 | mkdir -p devops/ansible/vars/
151 | cat << EOF > devops/ansible/vars/main.yaml
152 | ---
153 | docker_appname: "${{ secrets.DOCKERHUB_APP_NAME }}"
154 | docker_token: "${{ secrets.DOCKERHUB_TOKEN }}"
155 | docker_username: "${{ secrets.DOCKERHUB_USERNAME }}"
156 | EOF
157 | - name: Run main playbook
158 | run: |
159 | ANSIBLE_CONFIG=devops/ansible/ansible.cfg ansible-playbook devops/ansible/main.yaml
--------------------------------------------------------------------------------
/.github/workflows/mysql-init.yaml:
--------------------------------------------------------------------------------
1 | name: MySQL Client to Configure Linode Database
2 |
3 | on:
4 | workflow_dispatch:
5 |
6 | jobs:
7 | mysql_client:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: Update packages
11 | run: sudo apt-get update
12 | - name: Install mysql client
13 | run: sudo apt-get install mysql-client -y
14 | - name: Mysql Version
15 | run: echo $(mysql --version)
16 | - name: Config Create
17 | run: |
18 | cat << EOF > db-config
19 | [client]
20 | user=${{ secrets.MYSQL_DB_ROOT_USER }}
21 | password=${{ secrets.MYSQL_DB_ROOT_PASSWORD }}
22 | host=${{ secrets.MYSQL_DB_HOST }}
23 | port=${{ secrets.MYSQL_DB_PORT }}
24 | EOF
25 | - name: Add SSL Cert
26 | run: |
27 | cat << EOF > db.crt
28 | ${{ secrets.MYSQL_DB_CERT }}
29 | EOF
30 | - name: SQL Init Script
31 | run: |
32 | cat << EOF > db-init.sql
33 | CREATE DATABASE IF NOT EXISTS ${{ secrets.MYSQL_DATABASE }} CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
34 | CREATE USER IF NOT EXISTS ${{ secrets.MYSQL_USER }} IDENTIFIED BY '${{ secrets.MYSQL_PASSWORD }}';
35 | SET GLOBAL time_zone = "+00:00";
36 | GRANT ALL PRIVILEGES ON ${{ secrets.MYSQL_DATABASE }}.* TO ${{ secrets.MYSQL_USER }};
37 | GRANT ALL PRIVILEGES ON \`test_${{ secrets.MYSQL_DATABASE }}\_%\`.* TO ${{ secrets.MYSQL_USER }};
38 | EOF
39 | - name: Run Command
40 | run: mysql --defaults-extra-file=db-config --ssl-ca=db.crt < db-init.sql
--------------------------------------------------------------------------------
/.github/workflows/staticfiles.yaml:
--------------------------------------------------------------------------------
1 | name: 3 - Static Files for Django
2 |
3 | # Controls when the workflow will run
4 | on:
5 | # Allows you to call this workflow within another workflow
6 | workflow_call:
7 | secrets:
8 | LINODE_BUCKET:
9 | required: true
10 | LINODE_BUCKET_REGION:
11 | required: true
12 | LINODE_BUCKET_ACCESS_KEY:
13 | required: true
14 | LINODE_BUCKET_SECRET_KEY:
15 | required: true
16 | workflow_dispatch:
17 |
18 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel
19 | jobs:
20 | # This workflow contains a single job called "build"
21 | django_staticfiles:
22 | # The type of runner that the job will run on
23 | runs-on: ubuntu-latest
24 | # Add in environment variables for the entire "build" job
25 | env:
26 | GITHUB_ACTIONS: true
27 | DATABASE_BACKEND: mysql
28 | DJANGO_SECRET_KEY: just-a-test-database
29 | DJANGO_STORAGE_SERVICE: linode
30 | LINODE_BUCKET: ${{ secrets.LINODE_BUCKET }}
31 | LINODE_BUCKET_REGION: ${{ secrets.LINODE_BUCKET_REGION }}
32 | LINODE_BUCKET_ACCESS_KEY: ${{ secrets.LINODE_BUCKET_ACCESS_KEY }}
33 | LINODE_BUCKET_SECRET_KEY: ${{ secrets.LINODE_BUCKET_SECRET_KEY }}
34 | steps:
35 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
36 | - name: Checkout code
37 | uses: actions/checkout@v2
38 | - name: Setup Python 3.10
39 | uses: actions/setup-python@v2
40 | with:
41 | python-version: "3.10"
42 | - name: Install requirements
43 | run: |
44 | pip install -r requirements.txt
45 | - name: Collect Static
46 | run: |
47 | python manage.py collectstatic --noinput
--------------------------------------------------------------------------------
/.github/workflows/test-django-mysql.yaml:
--------------------------------------------------------------------------------
1 | name: 1- Test Django & MySQL
2 |
3 | on:
4 | workflow_call:
5 | workflow_dispatch:
6 |
7 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel
8 | jobs:
9 | # This workflow contains a single job called "build"
10 | django_mysql:
11 | # The type of runner that the job will run on
12 | runs-on: ubuntu-latest
13 | # Change the default working directory relative to your repo root.
14 | defaults:
15 | run:
16 | working-directory: ./
17 | # Add in environment variables for the entire "build" job
18 | env:
19 | MYSQL_DATABASE: cfeblog_db
20 | MYSQL_USER: cfe_blog_user # do *not* use root; cannot re-create the root user
21 | MYSQL_ROOT_PASSWORD: 2mLTcmdPzU2LOa0TpAlLPoNf1XtIKsKvNn5WBiszczs
22 | MYSQL_TCP_PORT: 3306
23 | MYSQL_HOST: 127.0.0.1
24 | GITHUB_ACTIONS: true
25 | DJANGO_SECRET_KEY: test-key-not-good
26 | DATABASE_BACKEND: mysql
27 | services:
28 | mysql:
29 | image: mysql:8.0.28
30 | env:
31 | # references the environment variables set at the job-level
32 | MYSQL_DATABASE: ${{ env.MYSQL_DATABASE }}
33 | MYSQL_HOST: ${{ env.MYSQL_HOST }}
34 | MYSQL_USER: ${{ env.MYSQL_USER }}
35 | MYSQL_PASSWORD: ${{ env.MYSQL_ROOT_PASSWORD }}
36 | MYSQL_ROOT_PASSWORD: ${{ env.MYSQL_ROOT_PASSWORD }}
37 | ports:
38 | - 3306:3306
39 | options: --health-cmd="mysqladmin ping" --health-interval=10s
40 | steps:
41 | - name: Checkout code
42 | uses: actions/checkout@v2
43 | # When you're using python, this is a required step
44 | - name: Setup Python 3.10
45 | uses: actions/setup-python@v2
46 | with:
47 | # add quotes around version so 3.10 does not become 3.1
48 | python-version: "3.10"
49 | - name: Install requirements
50 | run: |
51 | pip install -r requirements.txt
52 | - name: Run Tests
53 | # Add additional step-specific environment variables
54 | env:
55 | DEBUG: "0"
56 | # must use the root user on MySQL to run tests
57 | MYSQL_USER: "root"
58 | # references the environment variables set at the job-level
59 | DATABASE_BACKEND: ${{ env.DATABASE_BACKEND }}
60 | DJANGO_SECRET_KEY: ${{ env.DJANGO_SECRET_KEY }}
61 | run: |
62 | python manage.py test
--------------------------------------------------------------------------------
/.github/workflows/test-django-postgres.yaml:
--------------------------------------------------------------------------------
1 | name: Postgres for Django Tests
2 |
3 | # Controls when the workflow will run
4 | on:
5 | # Allows you to call this workflow within another workflow
6 | workflow_call:
7 | # Allows you to run this workflow manually from the Actions tab
8 | workflow_dispatch:
9 | # Triggered based on the git event type
10 | # push:
11 | # branches: [main]
12 | # pull_request:
13 | # branches: [main]
14 |
15 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel
16 | jobs:
17 | # This workflow contains a single job called "build"
18 | django_postgres:
19 | # The type of runner that the job will run on
20 | runs-on: ubuntu-latest
21 | # Add in environment variables for the entire "build" job
22 | env:
23 | POSTGRES_USER: postgres
24 | POSTGRES_PASSWORD: postgres
25 | POSTGRES_HOST: localhost # default host value for the database
26 | POSTGRES_DB: djtesting
27 | POSTGRES_PORT: 5432
28 | GITHUB_ACTIONS: true
29 | DJANGO_SECRET_KEY: test-key-not-good
30 | DATABASE_BACKEND: postgres
31 | services:
32 | postgres_main:
33 | image: postgres:12
34 | env:
35 | POSTGRES_USER: ${{ env.POSTGRES_USER }}
36 | POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }}
37 | POSTGRES_DB: ${{ env.POSTGRES_DB }}
38 | ports:
39 | - 5432:5432
40 | options: >-
41 | --health-cmd pg_isready
42 | --health-interval 10s
43 | --health-timeout 5s
44 | --health-retries 5
45 | # If you want to test multiple python version(s)
46 | strategy:
47 | matrix:
48 | python-version: ["3.8", "3.9", "3.10"]
49 | # Steps represent a sequence of tasks that will be executed as part of the job
50 | steps:
51 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
52 | - name: Checkout code
53 | uses: actions/checkout@v2
54 | - name: Setup Python ${{ matrix.python-version }}
55 | uses: actions/setup-python@v2
56 | with:
57 | python-version: ${{ matrix.python-version }}
58 | - name: Install requirements
59 | run: |
60 | pip install -r requirements.txt
61 | pip install psycopg2
62 | - name: Run Tests
63 | # Step specific environment variables
64 | env:
65 | DEBUG: "0"
66 | run: |
67 | python manage.py test
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .env.prod
2 | certs/
3 | devops/ansible/ansible.cfg
4 | devops/ansible/vars/main.yaml
5 | # devops/
6 | db-init/
7 | scripts/
8 | .DS_Store
9 | keys/
10 | devops/tf/terraform.tfvars
11 | devops/tf/backend
12 | staticroot/admin/
13 | # Byte-compiled / optimized / DLL files
14 | __pycache__/
15 | *.py[cod]
16 | *$py.class
17 |
18 | # C extensions
19 | *.so
20 |
21 | # Distribution / packaging
22 | .Python
23 | build/
24 | develop-eggs/
25 | dist/
26 | downloads/
27 | eggs/
28 | .eggs/
29 | lib/
30 | lib64/
31 | parts/
32 | sdist/
33 | var/
34 | wheels/
35 | pip-wheel-metadata/
36 | share/python-wheels/
37 | *.egg-info/
38 | .installed.cfg
39 | *.egg
40 | MANIFEST
41 |
42 | # PyInstaller
43 | # Usually these files are written by a python script from a template
44 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
45 | *.manifest
46 | *.spec
47 |
48 | # Installer logs
49 | pip-log.txt
50 | pip-delete-this-directory.txt
51 |
52 | # Unit test / coverage reports
53 | htmlcov/
54 | .tox/
55 | .nox/
56 | .coverage
57 | .coverage.*
58 | .cache
59 | nosetests.xml
60 | coverage.xml
61 | *.cover
62 | *.py,cover
63 | .hypothesis/
64 | .pytest_cache/
65 |
66 | # Translations
67 | *.mo
68 | *.pot
69 |
70 | # Django stuff:
71 | *.log
72 | local_settings.py
73 | db.sqlite3
74 | db.sqlite3-journal
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 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
107 | __pypackages__/
108 |
109 | # Celery stuff
110 | celerybeat-schedule
111 | celerybeat.pid
112 |
113 | # SageMath parsed files
114 | *.sage.py
115 |
116 | # Environments
117 | .env
118 | .venv
119 | env/
120 | venv/
121 | ENV/
122 | env.bak/
123 | venv.bak/
124 |
125 | # Spyder project settings
126 | .spyderproject
127 | .spyproject
128 |
129 | # Rope project settings
130 | .ropeproject
131 |
132 | # mkdocs documentation
133 | /site
134 |
135 | # mypy
136 | .mypy_cache/
137 | .dmypy.json
138 | dmypy.json
139 |
140 | # Pyre type checker
141 | .pyre/
142 |
143 |
144 |
145 | # Local .terraform directories
146 | **/.terraform/*
147 |
148 | # .tfstate files
149 | *.tfstate
150 | *.tfstate.*
151 |
152 | # Crash log files
153 | crash.log
154 | crash.*.log
155 |
156 | # Exclude all .tfvars files, which are likely to contain sensitive data, such as
157 | # password, private keys, and other secrets. These should not be part of version
158 | # control as they are data points which are potentially sensitive and subject
159 | # to change depending on the environment.
160 | *.tfvars
161 | *.tfvars.json
162 |
163 | # Ignore override files as they are usually used to override resources locally and so
164 | # are not checked in
165 | override.tf
166 | override.tf.json
167 | *_override.tf
168 | *_override.tf.json
169 |
170 | # Include override files you do wish to add to version control using negated pattern
171 | # !example_override.tf
172 |
173 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
174 | # example: *tfplan*
175 |
176 | # Ignore CLI configuration files
177 | .terraformrc
178 | terraform.rc
179 |
180 |
181 | backups/
182 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "cSpell.words": [
3 | "cfeblog",
4 | "dotenv"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.10.3-slim
2 |
3 | # copy your local files to your
4 | # docker container
5 | COPY . /app
6 |
7 | # update your environment to work
8 | # within the folder you copied your
9 | # files above into
10 | WORKDIR /app
11 |
12 |
13 | # os requirements to ensure this
14 | # Django project runs with mysql
15 | # along with a few other deps
16 | RUN apt-get update && \
17 | apt-get install -y \
18 | locales \
19 | libmemcached-dev \
20 | default-libmysqlclient-dev \
21 | libjpeg-dev \
22 | zlib1g-dev \
23 | build-essential \
24 | python3-dev \
25 | python3-setuptools \
26 | gcc \
27 | make && \
28 | apt-get clean && \
29 | rm -rf /var/lib/apt/lists/*
30 |
31 | # libpq-dev is a postgressql client install, change as needed
32 | # default-libmysqlclient-dev is a mysql client, this is our current default
33 |
34 | ENV PYTHON_VERSION=3.10
35 | ENV DEBIAN_FRONTEND noninteractive
36 |
37 | # Locale Setup
38 | RUN locale-gen en_US.UTF-8
39 | ENV LANG en_US.UTF-8
40 | ENV LANGUAGE en_US:en
41 | ENV LC_ALL en_US.UTF-8`
42 | RUN sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \
43 | && locale-gen
44 | RUN dpkg-reconfigure locales
45 |
46 | # Create a Python 3.10 virtual environment in /opt.
47 | # /opt: is the default location for additional software packages.
48 | RUN python3.10 -m venv /opt/venv
49 |
50 | # Install requirements to new virtual environment
51 | # requirements.txt must have gunicorn & django
52 | RUN /opt/venv/bin/pip install pip --upgrade && \
53 | /opt/venv/bin/pip install -r requirements.txt && \
54 | chmod +x config/entrypoint.sh
55 |
56 | # entrypoint.sh to run our gunicorn instance
57 | CMD [ "/app/config/entrypoint.sh" ]
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Coding For Entrepreneurs
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | tf_console:
2 | terraform -chdir=devops/tf/ console
3 |
4 | tf_plan:
5 | terraform -chdir=devops/tf/ plan
6 |
7 | tf_apply:
8 | terraform -chdir=devops/tf/ apply
9 |
10 | tf_upgrade:
11 | terraform -chdir=devops/tf/ init -upgrade
12 |
13 | ansible:
14 | ANSIBLE_CONFIG=devops/ansible/ansible.cfg venv/bin/ansible-playbook devops/ansible/main.yaml
15 |
16 | infra_up:
17 | terraform -chdir=devops/tf/ apply
18 | ANSIBLE_CONFIG=devops/ansible/ansible.cfg venv/bin/ansible-playbook devops/ansible/main.yaml
19 |
20 | infra_down:
21 | terraform -chdir=devops/tf/ apply -destroy
22 |
23 | infra_init:
24 | terraform -chdir=devops/tf/ init -backend-config=backend
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # CFE Django Blog Deployment Code
2 |
3 | This is the official reference code for the project Deploying Django into production with Linode, Managed MySQL Database, GitHub Actions, Terraform, Ansible and more.
4 |
5 |
6 | Originally cloned from [this repo](https://github.com/codingforentrepreneurs/cfe-django-blog)
--------------------------------------------------------------------------------
/articles/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codingforentrepreneurs/deploy-django-linode-mysql/cdff300daa5241c46cfbca93e03e49acb5ac33ab/articles/__init__.py
--------------------------------------------------------------------------------
/articles/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 | # Register your models here.
4 | from .models import Article
5 |
6 |
7 | class ArticleAdmin(admin.ModelAdmin):
8 | list_display = ["title", "slug", "is_published"]
9 | raw_id_fields = ["user"]
10 | list_filter = [
11 | "publish_status",
12 | "user_publish_timestamp",
13 | "publish_timestamp",
14 | "updated",
15 | "timestamp",
16 | ]
17 | readonly_fields = [
18 | "publish_timestamp",
19 | "updated_by",
20 | "updated",
21 | "timestamp",
22 | ]
23 |
24 | class Meta:
25 | model = Article
26 |
27 | def save_model(self, request, obj, form, change):
28 | obj.updated_by = request.user
29 | super().save_model(request, obj, form, change)
30 |
31 |
32 | admin.site.register(Article, ArticleAdmin)
33 |
--------------------------------------------------------------------------------
/articles/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class ArticlesConfig(AppConfig):
5 | default_auto_field = 'django.db.models.BigAutoField'
6 | name = 'articles'
7 |
--------------------------------------------------------------------------------
/articles/management/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codingforentrepreneurs/deploy-django-linode-mysql/cdff300daa5241c46cfbca93e03e49acb5ac33ab/articles/management/__init__.py
--------------------------------------------------------------------------------
/articles/management/commands/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codingforentrepreneurs/deploy-django-linode-mysql/cdff300daa5241c46cfbca93e03e49acb5ac33ab/articles/management/commands/__init__.py
--------------------------------------------------------------------------------
/articles/management/commands/backup_articles.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.core import management
3 | from django.core.management.base import BaseCommand
4 |
5 | BASE_DIR = settings.BASE_DIR
6 | FIXTURES_DIR = BASE_DIR / "fixtures"
7 |
8 |
9 | class Command(BaseCommand):
10 | help = "Creates fixtures for articles and users."
11 |
12 | def handle(self, *args, **options):
13 | if not FIXTURES_DIR.exists():
14 | FIXTURES_DIR.mkdir(parents=True)
15 | apps = ["auth", "articles"]
16 | for app in apps:
17 | output_path = FIXTURES_DIR / f"{app}.json"
18 | relative_path = output_path.relative_to(BASE_DIR)
19 | with open(output_path, "w") as f:
20 | self.stdout.write(self.style.WARNING(f"Backing up app: {app}..."))
21 | management.call_command(
22 | "dumpdata",
23 | "auth.User",
24 | "articles",
25 | stdout=f,
26 | verbosity=1,
27 | )
28 | if output_path.exists():
29 | self.stdout.write(self.style.SUCCESS(f"{relative_path} is updated.\n"))
30 |
--------------------------------------------------------------------------------
/articles/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.12 on 2022-03-18 15:53
2 |
3 | import articles.utils
4 | from django.conf import settings
5 | from django.db import migrations, models
6 | import django.db.models.deletion
7 |
8 |
9 | class Migration(migrations.Migration):
10 |
11 | initial = True
12 |
13 | dependencies = [
14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15 | ]
16 |
17 | operations = [
18 | migrations.CreateModel(
19 | name='Article',
20 | fields=[
21 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
22 | ('title', models.CharField(max_length=120)),
23 | ('slug', models.SlugField(blank=True, null=True)),
24 | ('content', models.TextField(blank=True, null=True)),
25 | ('image', models.ImageField(blank=True, null=True, upload_to=articles.utils.get_article_image_upload_to)),
26 | ('publish_status', models.CharField(choices=[('pub', 'Publish'), ('dra', 'DRAFT'), ('pri', 'Private')], default='dra', max_length=3)),
27 | ('publish_timestamp', models.DateTimeField(blank=True, help_text='Field-driven publish timestamp', null=True)),
28 | ('user_publish_timestamp', models.DateTimeField(blank=True, help_text='User-defined publish timestamp', null=True)),
29 | ('timestamp', models.DateTimeField(auto_now_add=True)),
30 | ('updated', models.DateTimeField(auto_now=True)),
31 | ('user', models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, to=settings.AUTH_USER_MODEL)),
32 | ],
33 | options={
34 | 'ordering': ['-user_publish_timestamp', '-publish_timestamp', '-updated'],
35 | },
36 | ),
37 | ]
38 |
--------------------------------------------------------------------------------
/articles/migrations/0002_article_updated_by.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.12 on 2022-03-18 16:46
2 |
3 | from django.conf import settings
4 | from django.db import migrations, models
5 | import django.db.models.deletion
6 |
7 |
8 | class Migration(migrations.Migration):
9 |
10 | dependencies = [
11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12 | ('articles', '0001_initial'),
13 | ]
14 |
15 | operations = [
16 | migrations.AddField(
17 | model_name='article',
18 | name='updated_by',
19 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='editor', to=settings.AUTH_USER_MODEL),
20 | ),
21 | ]
22 |
--------------------------------------------------------------------------------
/articles/migrations/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codingforentrepreneurs/deploy-django-linode-mysql/cdff300daa5241c46cfbca93e03e49acb5ac33ab/articles/migrations/__init__.py
--------------------------------------------------------------------------------
/articles/models.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.db import models
3 | from django.db.models import Q
4 | from django.urls import reverse
5 | from django.utils import timezone
6 |
7 | from . import utils
8 |
9 | User = settings.AUTH_USER_MODEL # defaults to 'auth.User'
10 |
11 |
12 | class ArticleQuerySet(models.QuerySet):
13 | def published(self):
14 | now = timezone.now()
15 | return self.filter(publish_status=Article.ArticlePublishOptions.PUBLISH).filter(
16 | Q(publish_timestamp__lte=now) | Q(user_publish_timestamp__lte=now)
17 | )
18 |
19 | def pending_published(self):
20 | now = timezone.now()
21 | return self.filter(publish_status=Article.ArticlePublishOptions.PUBLISH).filter(
22 | Q(publish_timestamp__gt=now) | Q(user_publish_timestamp__gt=now)
23 | )
24 |
25 | def drafts(self):
26 | return self.filter(publish_status=Article.ArticlePublishOptions.DRAFT)
27 |
28 | def select_author(self):
29 | return self.select_related("user")
30 |
31 | def search(self, query=None):
32 | if query is None:
33 | return self.none()
34 | return self.filter(Q(title__icontains=query) | Q(content__icontains=query))
35 |
36 |
37 | class ArticleManager(models.Manager):
38 | def get_queryset(self):
39 | return ArticleQuerySet(self.model, using=self._db)
40 |
41 | def published(self):
42 | return self.get_queryset().published().select_author()
43 |
44 | def drafts(self):
45 | return self.get_queryset().drafts().select_author()
46 |
47 | def pending(self):
48 | return self.get_queryset().pending_published().select_author()
49 |
50 |
51 | class Article(models.Model):
52 | class ArticlePublishOptions(models.TextChoices):
53 | PUBLISH = "pub", "Publish"
54 | DRAFT = "dra", "DRAFT"
55 | PRIVATE = "pri", "Private"
56 |
57 | user = models.ForeignKey(User, default=1, on_delete=models.SET_DEFAULT)
58 | updated_by = models.ForeignKey(
59 | User, related_name="editor", null=True, blank=True, on_delete=models.SET_NULL
60 | )
61 | title = models.CharField(max_length=120)
62 | slug = models.SlugField(blank=True, null=True)
63 | content = models.TextField(blank=True, null=True)
64 | image = models.ImageField(
65 | upload_to=utils.get_article_image_upload_to, null=True, blank=True
66 | )
67 | publish_status = models.CharField(
68 | max_length=3,
69 | choices=ArticlePublishOptions.choices,
70 | default=ArticlePublishOptions.DRAFT,
71 | )
72 | publish_timestamp = models.DateTimeField(
73 | help_text="Field-driven publish timestamp",
74 | auto_now_add=False,
75 | auto_now=False,
76 | blank=True,
77 | null=True,
78 | )
79 | user_publish_timestamp = models.DateTimeField(
80 | help_text="User-defined publish timestamp",
81 | auto_now_add=False,
82 | auto_now=False,
83 | blank=True,
84 | null=True,
85 | )
86 | timestamp = models.DateTimeField(auto_now_add=True)
87 | updated = models.DateTimeField(auto_now=True)
88 |
89 | objects = ArticleManager()
90 |
91 | class Meta:
92 | ordering = ["-user_publish_timestamp", "-publish_timestamp", "-updated"]
93 |
94 | def get_absolute_url(self):
95 | return reverse("articles:article-detail", kwargs={"slug": self.slug})
96 |
97 | def get_image_url(self):
98 | if not self.image:
99 | return None
100 | return self.image.url
101 |
102 | @property
103 | def is_published(self):
104 | if not self.publish_status == Article.ArticlePublishOptions.PUBLISH:
105 | return False
106 | now = timezone.now()
107 | if self.user_publish_timestamp is not None:
108 | return self.user_publish_timestamp <= now
109 | return self.publish_timestamp <= now
110 |
111 | def save(self, *args, **kwargs):
112 | if not self.slug:
113 | self.slug = utils.unique_slug_generator(self)
114 | if self.user_publish_timestamp:
115 | """
116 | User has set user_publish_timestamp,
117 | Automatically set publish_timestamp
118 | """
119 | self.publish_timestamp = self.user_publish_timestamp
120 | if self.publish_status == Article.ArticlePublishOptions.PUBLISH:
121 | """
122 | User has set publish_status to PUBLISH,
123 | Automatically set publish_timestamp to
124 | now
125 | """
126 | if not self.publish_timestamp:
127 | self.publish_timestamp = timezone.now()
128 | super().save(*args, **kwargs)
129 |
--------------------------------------------------------------------------------
/articles/templates/articles/article_detail.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 |
4 | {% block head_title %}
5 | {{ article.title }} | Articles | {{ block.super }}
6 | {% endblock head_title %}
7 |
8 | {% block content %}
9 |
10 |
11 |
12 |
{{ article.title }}
13 | {% if article.image %}
14 |

15 | {% endif %}
16 |
17 | {{ article.content|linebreaks }}
18 |
19 |
20 |
21 |
22 |
23 | {% endblock content %}
--------------------------------------------------------------------------------
/articles/templates/articles/article_list.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 |
4 | {% block head_title %}
5 | {% if title %}{{ title }}{% else %}Articles{% endif %} | {{ block.super }}
6 | {% endblock head_title %}
7 |
8 | {% block content %}
9 |
10 |
11 |
12 |
{% if title %}{{ title }}{% else %}Articles{% endif %}
13 | {% if request.GET.q %}
Results for {{ request.GET.q }}
{% endif %}
14 | {% for article in object_list %}
15 |
32 | {% empty %}
33 |
No articles found.
34 |
35 | {% endfor %}
36 |
37 |
38 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | {% endblock content %}
--------------------------------------------------------------------------------
/articles/tests.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.test import TestCase
3 |
4 | from .models import Article
5 |
6 | FIXTURES_DIR = settings.BASE_DIR / "fixtures"
7 |
8 |
9 | class ArticleTestCase(TestCase):
10 | fixtures = [FIXTURES_DIR / "auth.json", FIXTURES_DIR / "articles.json"]
11 |
12 | def setUp(self):
13 | self.hello_world_obj = Article.objects.first()
14 |
15 | def test_article_count(self):
16 | qs = Article.objects.all()
17 | self.assertEqual(qs.count(), 3)
18 |
19 | def test_article_published_count(self):
20 | qs = Article.objects.all().published()
21 | self.assertEqual(qs.count(), 1)
22 |
23 | def test_article_pending_publish_count(self):
24 | qs = Article.objects.pending()
25 | self.assertEqual(qs.count(), 1)
26 |
27 | def test_article_draft_count(self):
28 | qs = Article.objects.drafts()
29 | self.assertEqual(qs.count(), 1)
30 |
31 | def test_unique_slug_feature(self):
32 | title = self.hello_world_obj.title
33 | slug = self.hello_world_obj.slug
34 | num_objs = 10
35 | new_obj = None
36 | for i in range(num_objs):
37 | _obj = Article.objects.create(title=title)
38 | if new_obj is None and (num_objs - 1 == i):
39 | new_obj = _obj
40 | self.assertNotEqual(new_obj.slug, slug)
41 | qs = Article.objects.filter(slug__iexact=slug)
42 | self.assertEqual(qs.count(), 1)
43 |
--------------------------------------------------------------------------------
/articles/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import path
2 |
3 | from . import views
4 |
5 | app_name = "articles"
6 | urlpatterns = [
7 | path("/", views.ArticleDetailView.as_view(), name="article-detail"),
8 | path("", views.ArticleListView.as_view(), name="article-list"),
9 | ]
10 |
--------------------------------------------------------------------------------
/articles/utils.py:
--------------------------------------------------------------------------------
1 | import pathlib
2 | import random
3 | import string
4 | import uuid
5 |
6 | from django.utils.text import slugify
7 |
8 |
9 | def get_article_image_upload_to(instance, filename):
10 | fpath = pathlib.Path(filename)
11 | fname = f"{uuid.uuid1()}{fpath.suffix}"
12 | slug = instance.slug
13 | if not slug:
14 | if instance.title:
15 | temp_slug = unique_slug_generator(instance)
16 | else:
17 | temp_slug = random_string_generator(size=15)
18 | slug = temp_slug
19 | return f"articles/{slug}/{fname}"
20 |
21 |
22 | def random_string_generator(size=10, chars=string.ascii_lowercase + string.digits):
23 | """
24 | Reference post https://cfe.sh/blog/random-string-generator-in-python/
25 | """
26 | return "".join(random.choice(chars) for _ in range(size))
27 |
28 |
29 | def unique_slug_generator(instance, new_slug=None):
30 | """
31 | Reference post https://cfe.sh/blog/a-unique-slug-generator-for-django/
32 | This is for a Django project and it assumes your instance
33 | has a model with a slug field and a title character (char) field.
34 | """
35 | if new_slug is not None:
36 | slug = new_slug
37 | else:
38 | slug = slugify(instance.title)
39 |
40 | Klass = instance.__class__
41 | qs_exists = Klass.objects.filter(slug=slug).exists()
42 | if qs_exists:
43 | new_slug = "{slug}-{randstr}".format(
44 | slug=slug, randstr=random_string_generator(size=4)
45 | )
46 | return unique_slug_generator(instance, new_slug=new_slug)
47 | return slug
48 |
--------------------------------------------------------------------------------
/articles/views.py:
--------------------------------------------------------------------------------
1 | from django.views import generic
2 |
3 | from .models import Article
4 |
5 |
6 | class ArticleHomeView(generic.ListView):
7 | queryset = Article.objects.published()
8 | paginate_by = 10
9 | template_name = "articles/article_list.html"
10 |
11 | def get_context_data(self, **kwargs):
12 | context = super().get_context_data(**kwargs)
13 | context["title"] = "Latest Articles"
14 | return context
15 |
16 |
17 | class ArticleListView(generic.ListView):
18 | queryset = Article.objects.published()
19 | paginate_by = 10
20 |
21 | def get_queryset(self):
22 | qs = super().get_queryset()
23 | query = self.request.GET.get("q")
24 | if query is not None:
25 | return qs.search(query=query)
26 | return qs
27 |
28 |
29 | class ArticleDetailView(generic.DetailView):
30 | queryset = Article.objects.published()
31 |
--------------------------------------------------------------------------------
/cfe-django-blog.code-workspace:
--------------------------------------------------------------------------------
1 | {
2 | "folders": [
3 | {
4 | "path": "."
5 | }
6 | ],
7 |
8 | "settings": {
9 | "python.defaultInterpreterPath": "venv/bin/python",
10 | "python.envFile": "${workspaceFolder}/.env"
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/cfeblog/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codingforentrepreneurs/deploy-django-linode-mysql/cdff300daa5241c46cfbca93e03e49acb5ac33ab/cfeblog/__init__.py
--------------------------------------------------------------------------------
/cfeblog/asgi.py:
--------------------------------------------------------------------------------
1 | """
2 | ASGI config for cfeblog 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/3.2/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', 'cfeblog.settings')
15 |
16 | application = get_asgi_application()
17 |
--------------------------------------------------------------------------------
/cfeblog/dbs/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codingforentrepreneurs/deploy-django-linode-mysql/cdff300daa5241c46cfbca93e03e49acb5ac33ab/cfeblog/dbs/__init__.py
--------------------------------------------------------------------------------
/cfeblog/dbs/mysql.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from django.conf import settings
4 |
5 | BASE_DIR = settings.BASE_DIR
6 | DEBUG= settings.DEBUG
7 |
8 | MYSQL_USER = os.environ.get("MYSQL_USER")
9 | MYSQL_PASSWORD = os.environ.get(
10 | "MYSQL_PASSWORD"
11 | ) # using the ROOT User Password for Local Tests
12 | MYSQL_DATABASE = os.environ.get("MYSQL_DATABASE")
13 | MYSQL_HOST = os.environ.get("MYSQL_HOST")
14 | MYSQL_TCP_PORT = os.environ.get("MYSQL_TCP_PORT")
15 | MYSQL_DB_IS_AVAIL = all(
16 | [MYSQL_USER, MYSQL_PASSWORD, MYSQL_DATABASE, MYSQL_HOST, MYSQL_TCP_PORT]
17 | )
18 | MYSQL_DB_CERT = BASE_DIR / "certs" / "db.crt"
19 |
20 | if MYSQL_DB_IS_AVAIL:
21 | DATABASES = {
22 | "default": {
23 | "ENGINE": "django.db.backends.mysql",
24 | "NAME": MYSQL_DATABASE,
25 | "USER": MYSQL_USER,
26 | "PASSWORD": MYSQL_PASSWORD,
27 | "HOST": MYSQL_HOST,
28 | "PORT": MYSQL_TCP_PORT,
29 | }
30 | }
31 | if MYSQL_DB_CERT.exists() and not DEBUG:
32 | DATABASES["default"]["OPTIONS"] = {"ssl": {"ca": f"{MYSQL_DB_CERT}"}}
33 |
--------------------------------------------------------------------------------
/cfeblog/dbs/postgres.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | POSTGRES_USER = os.environ.get("POSTGRES_USER")
4 | POSTGRES_PASSWORD = os.environ.get("POSTGRES_PASSWORD")
5 | POSTGRES_DB = os.environ.get("POSTGRES_DB")
6 | POSTGRES_HOST = os.environ.get("POSTGRES_HOST")
7 | POSTGRES_PORT = os.environ.get("POSTGRES_PORT")
8 | POSTGRES_DB_IS_AVAIL = all([
9 | POSTGRES_USER,
10 | POSTGRES_PASSWORD,
11 | POSTGRES_DB,
12 | POSTGRES_HOST,
13 | POSTGRES_PORT
14 | ])
15 | POSTGRES_DB_REQUIRE_SSL=os.environ.get("POSTGRES_DB_REQUIRE_SSL") == "true"
16 |
17 | if POSTGRES_DB_IS_AVAIL:
18 | DATABASES = {
19 | "default": {
20 | "ENGINE": "django.db.backends.postgresql",
21 | "NAME": POSTGRES_DB,
22 | "USER": POSTGRES_USER,
23 | "PASSWORD": POSTGRES_PASSWORD,
24 | "HOST": POSTGRES_HOST,
25 | "PORT": POSTGRES_PORT,
26 | }
27 | }
28 | if POSTGRES_DB_REQUIRE_SSL:
29 | DATABASES["default"]["OPTIONS"] = {
30 | "sslmode": "require"
31 | }
32 |
--------------------------------------------------------------------------------
/cfeblog/settings.py:
--------------------------------------------------------------------------------
1 | """
2 | Django settings for cfeblog project.
3 |
4 | Generated by 'django-admin startproject' using Django 3.2.12.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/3.2/topics/settings/
8 |
9 | For the full list of settings and their values, see
10 | https://docs.djangoproject.com/en/3.2/ref/settings/
11 | """
12 | import os
13 | from pathlib import Path
14 |
15 | from dotenv import load_dotenv
16 |
17 | # Build paths inside the project like this: BASE_DIR / 'subdir'.
18 | BASE_DIR = Path(__file__).resolve().parent.parent
19 | ENV_PATH = BASE_DIR / ".env"
20 |
21 | if ENV_PATH.exists():
22 | """
23 | Load in the .env file
24 | If it exists
25 | """
26 | load_dotenv(str(ENV_PATH))
27 |
28 | # Quick-start development settings - unsuitable for production
29 | # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
30 |
31 | # SECURITY WARNING: keep the secret key used in production secret!
32 | SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY")
33 |
34 | # SECURITY WARNING: don't run with debug turned on in production!
35 | DEBUG = str(os.environ.get("DJANGO_DEBUG")) == "1"
36 |
37 | ALLOWED_HOSTS = []
38 |
39 | # Domain name or other Github Action Host Value
40 | ALLOWED_HOST = os.environ.get("ALLOWED_HOST")
41 | if ALLOWED_HOST:
42 | ALLOWED_HOSTS += [ALLOWED_HOST]
43 |
44 | # Individual Web App Linode Host IP
45 | WEBAPP_NODE_HOST = os.environ.get("WEBAPP_NODE_HOST")
46 | if WEBAPP_NODE_HOST:
47 | ALLOWED_HOSTS += [WEBAPP_NODE_HOST]
48 |
49 | # Nginx Load Balancer Linode Host IP
50 | LOAD_BALANCER_HOST = os.environ.get("LOAD_BALANCER_HOST")
51 | if LOAD_BALANCER_HOST:
52 | ALLOWED_HOSTS += [LOAD_BALANCER_HOST]
53 |
54 | # Application definition
55 |
56 | INSTALLED_APPS = [
57 | "django.contrib.admin",
58 | "django.contrib.auth",
59 | "django.contrib.contenttypes",
60 | "django.contrib.sessions",
61 | "django.contrib.messages",
62 | "django.contrib.staticfiles",
63 | "articles",
64 | ]
65 |
66 | MIDDLEWARE = [
67 | "django.middleware.security.SecurityMiddleware",
68 | "django.contrib.sessions.middleware.SessionMiddleware",
69 | "django.middleware.common.CommonMiddleware",
70 | "django.middleware.csrf.CsrfViewMiddleware",
71 | "django.contrib.auth.middleware.AuthenticationMiddleware",
72 | "django.contrib.messages.middleware.MessageMiddleware",
73 | "django.middleware.clickjacking.XFrameOptionsMiddleware",
74 | ]
75 |
76 | ROOT_URLCONF = "cfeblog.urls"
77 |
78 | TEMPLATES = [
79 | {
80 | "BACKEND": "django.template.backends.django.DjangoTemplates",
81 | "DIRS": [
82 | BASE_DIR / "templates",
83 | ],
84 | "APP_DIRS": True,
85 | "OPTIONS": {
86 | "context_processors": [
87 | "django.template.context_processors.debug",
88 | "django.template.context_processors.request",
89 | "django.contrib.auth.context_processors.auth",
90 | "django.contrib.messages.context_processors.messages",
91 | ],
92 | },
93 | },
94 | ]
95 |
96 | WSGI_APPLICATION = "cfeblog.wsgi.application"
97 |
98 |
99 | # Database
100 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases
101 |
102 | DATABASES = {
103 | "default": {
104 | "ENGINE": "django.db.backends.sqlite3",
105 | "NAME": BASE_DIR / "db.sqlite3",
106 | }
107 | }
108 |
109 | DATABASE_BACKEND = os.environ.get("DATABASE_BACKEND")
110 | if DATABASE_BACKEND == "mysql":
111 | from .dbs.mysql import * # noqa
112 | elif DATABASE_BACKEND == "postgres":
113 | from .dbs.postgres import *
114 |
115 |
116 | # Password validation
117 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
118 |
119 | AUTH_PASSWORD_VALIDATORS = [
120 | {
121 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
122 | },
123 | {
124 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
125 | },
126 | {
127 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
128 | },
129 | {
130 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
131 | },
132 | ]
133 |
134 |
135 | # Internationalization
136 | # https://docs.djangoproject.com/en/3.2/topics/i18n/
137 |
138 | LANGUAGE_CODE = "en-us"
139 |
140 | TIME_ZONE = "UTC"
141 |
142 | USE_I18N = True
143 |
144 | USE_L10N = True
145 |
146 | USE_TZ = True
147 |
148 |
149 | # Static files (CSS, JavaScript, Images)
150 | # https://docs.djangoproject.com/en/3.2/howto/static-files/
151 |
152 | STATIC_URL = "/static/"
153 | STATICFILES_DIRS = [BASE_DIR / "staticfiles"]
154 | STATIC_ROOT = BASE_DIR / "staticroot"
155 | MEDIA_URL = "/media/"
156 | MEDIA_ROOT = BASE_DIR / "mediafiles"
157 | # Default primary key field type
158 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
159 |
160 | # use django-storages to serve & host
161 | # staticfiles and file/media uploads
162 | from .storages.conf import * # noqa
163 |
164 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
165 |
--------------------------------------------------------------------------------
/cfeblog/storages/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codingforentrepreneurs/deploy-django-linode-mysql/cdff300daa5241c46cfbca93e03e49acb5ac33ab/cfeblog/storages/__init__.py
--------------------------------------------------------------------------------
/cfeblog/storages/backends.py:
--------------------------------------------------------------------------------
1 | from storages.backends.s3boto3 import S3Boto3Storage
2 |
3 | from . import mixins
4 |
5 |
6 | class PublicS3Boto3Storage(mixins.DefaultACLMixin, S3Boto3Storage):
7 | location = 'static'
8 | default_acl = 'public-read'
9 |
10 |
11 | class MediaS3BotoStorage(mixins.DefaultACLMixin, S3Boto3Storage):
12 | location = 'media'
13 | default_acl = 'private'
14 |
--------------------------------------------------------------------------------
/cfeblog/storages/client.py:
--------------------------------------------------------------------------------
1 | import boto3
2 | from django.conf import settings
3 |
4 | AWS_ACCESS_KEY_ID = getattr(settings, "AWS_ACCESS_KEY_ID")
5 | AWS_SECRET_ACCESS_KEY = getattr(settings, 'AWS_SECRET_ACCESS_KEY')
6 | AWS_S3_REGION_NAME = getattr(settings, 'AWS_S3_REGION_NAME')
7 | AWS_STORAGE_BUCKET_NAME = getattr(settings, 'AWS_STORAGE_BUCKET_NAME')
8 | AWS_S3_ENDPOINT_URL = getattr(settings, 'AWS_S3_ENDPOINT_URL')
9 | def get_boto3_client(service='s3'):
10 | if not all([
11 | AWS_ACCESS_KEY_ID,
12 | AWS_SECRET_ACCESS_KEY,
13 | AWS_S3_REGION_NAME,
14 | AWS_S3_ENDPOINT_URL
15 | ]):
16 | return None
17 | return boto3.client(service,
18 | aws_access_key_id=AWS_ACCESS_KEY_ID,
19 | aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
20 | region_name=AWS_S3_REGION_NAME,
21 | endpoint_url = AWS_S3_ENDPOINT_URL
22 | )
23 |
24 |
25 | def get_boto3_resource(service='s3'):
26 | if not all([
27 | AWS_ACCESS_KEY_ID,
28 | AWS_SECRET_ACCESS_KEY,
29 | AWS_S3_REGION_NAME,
30 | AWS_S3_ENDPOINT_URL
31 | ]):
32 | return None
33 | session = boto3.Session(
34 | aws_access_key_id=AWS_ACCESS_KEY_ID,
35 | aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
36 | region_name=AWS_S3_REGION_NAME,
37 |
38 | )
39 | return session.resource(service_name=service,
40 | endpoint_url=AWS_S3_ENDPOINT_URL,)
41 |
42 |
43 | def get_storage_bucket(bucket_name=AWS_STORAGE_BUCKET_NAME):
44 | """
45 | Usage options:
46 | from cfeblog.storages import client
47 |
48 | my_bucket = client.get_storage_bucket()
49 |
50 | list objects:
51 | ```
52 | for file in my_bucket.objects.all():
53 | print(file.key)
54 | ```
55 |
56 | delete objects:
57 | ```
58 | my_bucket.objects.delete()
59 | ```
60 | """
61 | if not bucket_name:
62 | return None
63 | resource = get_boto3_resource(service='s3')
64 | if not resource:
65 | return None
66 | return resource.Bucket(bucket_name)
67 |
68 |
69 |
70 |
--------------------------------------------------------------------------------
/cfeblog/storages/conf.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | DJANGO_STORAGE_SERVICE = os.environ.get("DJANGO_STORAGE_SERVICE")
4 |
5 | if DJANGO_STORAGE_SERVICE is not None:
6 | """
7 | Set default options from django-storages
8 | if DJANGO_STORAGE_SERVICE key exists
9 | """
10 | # USER UPLOADED MEDIA
11 | DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
12 | # Staticfiles
13 | STATICFILES_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
14 |
15 |
16 | if DJANGO_STORAGE_SERVICE == 'linode':
17 | from .services.linode import * # noqa
18 |
--------------------------------------------------------------------------------
/cfeblog/storages/mixins.py:
--------------------------------------------------------------------------------
1 | class DefaultACLMixin():
2 | """
3 | Adds the ability to change default ACL for objects
4 | within a `S3Boto3Storage` class.
5 |
6 | Useful for having
7 | static files being public-read by default while
8 | user-uploaded files being private by default.
9 |
10 | # CANNED ACL Options come from
11 | # https://docs.aws.amazon.com/AmazonS3/latest/userguide/acl-overview.html#canned-acl
12 | """
13 |
14 | CANNED_ACL_OPTIONS = [
15 | 'private',
16 | 'public-read',
17 | 'public-read-write',
18 | 'aws-exec-read',
19 | 'authenticated-read',
20 | 'bucket-owner-read',
21 | 'bucket-owner-full-control'
22 | ]
23 | def get_default_settings(self):
24 | _settings = super().get_default_settings()
25 | _settings['default_acl'] = self.get_default_acl()
26 | return _settings
27 |
28 | def get_default_acl(self):
29 | _acl = self.default_acl or None
30 | if _acl is not None:
31 | if _acl not in self.CANNED_ACL_OPTIONS:
32 | acl_options = "\n\t".join(self.CANNED_ACL_OPTIONS)
33 | raise Exception(f"The default_acl of \"{_acl}\" is invalid. Please use one of the following:\n{acl_options}")
34 | return _acl
35 |
--------------------------------------------------------------------------------
/cfeblog/storages/services/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codingforentrepreneurs/deploy-django-linode-mysql/cdff300daa5241c46cfbca93e03e49acb5ac33ab/cfeblog/storages/services/__init__.py
--------------------------------------------------------------------------------
/cfeblog/storages/services/linode.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | LINODE_BUCKET = os.environ.get("LINODE_BUCKET")
4 | LINODE_BUCKET_REGION = os.environ.get("LINODE_BUCKET_REGION")
5 | LINODE_BUCKET_ACCESS_KEY = os.environ.get("LINODE_BUCKET_ACCESS_KEY")
6 | LINODE_BUCKET_SECRET_KEY = os.environ.get("LINODE_BUCKET_SECRET_KEY")
7 | LINODE_OBJECT_STORAGE_READY = all([LINODE_BUCKET, LINODE_BUCKET_REGION, LINODE_BUCKET_ACCESS_KEY, LINODE_BUCKET_SECRET_KEY])
8 |
9 | if LINODE_OBJECT_STORAGE_READY:
10 | AWS_S3_ENDPOINT_URL = f"https://{LINODE_BUCKET_REGION}.linodeobjects.com"
11 | AWS_ACCESS_KEY_ID = LINODE_BUCKET_ACCESS_KEY
12 | AWS_SECRET_ACCESS_KEY = LINODE_BUCKET_SECRET_KEY
13 | AWS_S3_REGION_NAME = LINODE_BUCKET_REGION
14 | AWS_S3_USE_SSL = True
15 | AWS_STORAGE_BUCKET_NAME = LINODE_BUCKET
16 | AWS_DEFAULT_ACL="public-read"
17 |
18 | DEFAULT_FILE_STORAGE = "cfeblog.storages.backends.MediaS3BotoStorage"
19 | STATICFILES_STORAGE = "cfeblog.storages.backends.PublicS3Boto3Storage"
20 |
--------------------------------------------------------------------------------
/cfeblog/urls.py:
--------------------------------------------------------------------------------
1 | from articles.views import ArticleHomeView
2 | from django.conf import settings
3 | from django.contrib import admin
4 | from django.urls import include, path
5 |
6 | urlpatterns = [
7 | path("", ArticleHomeView.as_view(), name="home"),
8 | path("admin/", admin.site.urls),
9 | path("articles/", include("articles.urls")),
10 | ]
11 |
12 |
13 | if settings.DEBUG:
14 | from django.conf.urls.static import static
15 |
16 | urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
17 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
18 |
--------------------------------------------------------------------------------
/cfeblog/wsgi.py:
--------------------------------------------------------------------------------
1 | """
2 | WSGI config for cfeblog 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/3.2/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", "cfeblog.settings")
15 |
16 | application = get_wsgi_application()
17 |
--------------------------------------------------------------------------------
/config/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | APP_PORT=${PORT:-8000}
3 | cd /app/
4 | /opt/venv/bin/python manage.py migrate
5 | /opt/venv/bin/gunicorn --worker-tmp-dir /dev/shm cfeblog.wsgi:application --bind "0.0.0.0:${APP_PORT}"
--------------------------------------------------------------------------------
/devops/ansible/django-app/handlers/main.yaml:
--------------------------------------------------------------------------------
1 |
2 | - name: docker-compose start django app
3 | shell: docker-compose -f /var/www/app/docker-compose.prod.yaml --profile app up -d --force-recreate
4 | timeout: 300 # 5 minutes
5 |
6 | - name: docker-compose force rebuild django app
7 | shell: |
8 | cd /var/www/app/
9 | docker-compose -f docker-compose.prod.yaml --profile app stop
10 | docker-compose rm -f
11 | docker-compose pull
12 | docker-compose -f docker-compose.prod.yaml --profile app up -d --force-recreate
13 |
14 | - name: docker-compose start redis
15 | shell: docker-compose -f /var/www/app/docker-compose.prod.yaml --profile redis up -d --force-recreate
16 |
17 | - name: docker-compose force rebuild redis
18 | shell: |
19 | cd /var/www/app/
20 | docker-compose -f docker-compose.prod.yaml --profile redis stop
21 | docker-compose rm -f
22 | docker-compose pull
23 | docker-compose -f docker-compose.prod.yaml --profile redis up -d --force-recreate
24 |
--------------------------------------------------------------------------------
/devops/ansible/django-app/tasks/main.yaml:
--------------------------------------------------------------------------------
1 | - name: Waiting to connect to remote
2 | wait_for_connection:
3 | sleep: 10
4 | timeout: 600
5 |
6 | - name: Ensure destination config dir exists
7 | file:
8 | path: /var/www/app/certs/
9 | state: directory
10 |
11 | - name: Copy .env file
12 | ansible.builtin.copy:
13 | src: "{{ playbook_dir | dirname | dirname }}/.env.prod"
14 | dest: /var/www/app/.env
15 |
16 | - name: Host to Env File
17 | shell: "echo \"\n\nWEBAPP_NODE_HOST={{ inventory_hostname }}\" >> /var/www/app/.env"
18 |
19 | - name: Load Balancer to Env File
20 | shell: "echo \"\n\nLOAD_BALANCER_HOST={{ groups['loadbalancer'][0] }}\" >> /var/www/app/.env"
21 | when: "groups['loadbalancer']|length == 1"
22 |
23 | - name: Copy db cert file
24 | ansible.builtin.copy:
25 | src: "{{ playbook_dir | dirname | dirname }}/certs/db.crt"
26 | dest: /var/www/app/certs/db.crt
27 |
28 | - name: Add Docker Compose
29 | template:
30 | src: ./templates/docker-compose.yaml.jinja2
31 | dest: /var/www/app/docker-compose.prod.yaml
--------------------------------------------------------------------------------
/devops/ansible/docker-install/handlers/main.yaml:
--------------------------------------------------------------------------------
1 | - name: exec docker script
2 | shell: /tmp/get-docker.sh
3 | notify: install docker compose
4 |
5 |
6 | - name: install docker compose
7 | pip:
8 | name:
9 | - docker-compose
10 | executable: pip3
11 |
12 |
13 |
--------------------------------------------------------------------------------
/devops/ansible/docker-install/tasks/main.yaml:
--------------------------------------------------------------------------------
1 | - name: Update Apt Cache
2 | apt:
3 | update_cache: yes
4 |
5 | - name: Install System Requirements
6 | apt:
7 | name: "{{ item }}"
8 | state: latest
9 | with_items:
10 | - curl
11 | - git
12 | - build-essential
13 | - python3-dev
14 | - python3-pip
15 | - python3-venv
16 |
17 |
18 | - name: Grab Docker Install Script
19 | get_url:
20 | url: https://get.docker.com
21 | dest: /tmp/get-docker.sh
22 | mode: 0755
23 | notify: exec docker script
24 |
25 | - name: Verify Docker Command
26 | shell: command -v docker >/dev/null 2>&1
27 | ignore_errors: true
28 | register: docker_exists
29 |
30 | - debug: msg="{{ docker_exists.rc }}"
31 |
32 | - name: Trigger docker install script if docker not running
33 | shell: echo "Docker command"
34 | when: docker_exists.rc != 0
35 | notify: exec docker script
36 |
37 |
38 | - name: Verify Docker Compose Command
39 | shell: command -v docker-compose >/dev/null 2>&1
40 | ignore_errors: true
41 | register: docker_compose_exists
42 |
43 | - debug: msg="{{ docker_compose_exists.rc }}"
44 |
45 | - name: Install docker-compose for Python 3 using pip3
46 | shell: echo "Install Docker Compose"
47 | when: docker_compose_exists.rc != 0
48 | notify: install docker compose
--------------------------------------------------------------------------------
/devops/ansible/inventory.ini:
--------------------------------------------------------------------------------
1 | [webapps]
2 | 173.255.195.38
3 |
4 | [loadbalancer]
5 | 104.237.140.75
6 |
7 | [redis]
8 | 96.126.122.99
9 |
10 | [workers]
11 | 173.255.206.177
12 |
--------------------------------------------------------------------------------
/devops/ansible/main.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | - hosts: webapps
3 | become: yes
4 | roles:
5 | - docker-install
6 | - django-app
7 | vars_files:
8 | - vars/main.yaml
9 | tasks:
10 | - name: Login to Docker via vars/main.yaml
11 | shell: "echo \"{{ docker_token }}\" | docker login -u {{ docker_username }} --password-stdin"
12 | - name: Run our Django app in the Background
13 | shell: echo "Running Docker Compose for Django App"
14 | notify: docker-compose start django app
15 |
16 | - hosts: loadbalancer
17 | become: yes
18 | roles:
19 | - nginx-lb
--------------------------------------------------------------------------------
/devops/ansible/nginx-lb/handlers/main.yaml:
--------------------------------------------------------------------------------
1 | - name: reload nginx
2 | service:
3 | name: nginx
4 | state: reloaded
5 |
6 | - name: restart nginx
7 | service:
8 | name: nginx
9 | state: restarted
--------------------------------------------------------------------------------
/devops/ansible/nginx-lb/tasks/main.yaml:
--------------------------------------------------------------------------------
1 |
2 | - name: Waiting to connect
3 | wait_for_connection:
4 | sleep: 10
5 | timeout: 600
6 |
7 | - name: Install Nginx
8 | apt:
9 | name: nginx
10 | state: present
11 | update_cache: yes
12 |
13 | - name: Ensure nginx is started and enabled to start at boot.
14 | service:
15 | name: nginx
16 | state: started
17 | enabled: yes
18 |
19 | - name: Add Nginx Config
20 | template:
21 | src: ./templates/nginx-lb.conf.jinja
22 | dest: /etc/nginx/sites-available/default
23 | notify: reload nginx
24 |
25 | - name: Enable New Nginx Config
26 | file:
27 | src: /etc/nginx/sites-available/default
28 | dest: /etc/nginx/sites-enabled/default
29 | state: link
--------------------------------------------------------------------------------
/devops/ansible/templates/docker-compose.yaml.jinja2:
--------------------------------------------------------------------------------
1 | version: "3.9"
2 | services:
3 | watchtower:
4 | image: index.docker.io/containrrr/watchtower:latest
5 | restart: always
6 | volumes:
7 | - /var/run/docker.sock:/var/run/docker.sock
8 | - /root/.docker/config.json:/config.json
9 | command: --interval 30
10 | profiles:
11 | - app
12 | app:
13 | image: index.docker.io/{{ docker_username }}/{{ docker_appname }}:latest
14 | restart: always
15 | env_file: ./.env
16 | container_name: {{ docker_appname }}
17 | environment:
18 | - PORT=8080
19 | ports:
20 | - "80:8080"
21 | expose:
22 | - 80
23 | volumes:
24 | - ./certs:/app/certs
25 | profiles:
26 | - app
--------------------------------------------------------------------------------
/devops/ansible/templates/nginx-lb.conf.jinja:
--------------------------------------------------------------------------------
1 | {% if groups['webapps'] %}
2 | upstream myproxy {
3 | {% for host in groups['webapps'] %}
4 | server {{ host }};
5 | {% endfor %}
6 | }
7 | {% endif %}
8 |
9 | server {
10 | listen 80;
11 | server_name localhost;
12 | root /var/www/html;
13 |
14 | {% if groups['webapps'] %}
15 | location / {
16 | proxy_pass http://myproxy;
17 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
18 | proxy_set_header Host $host;
19 | proxy_redirect off;
20 | }
21 | {% endif %}
22 | }
--------------------------------------------------------------------------------
/devops/tf/.terraform.lock.hcl:
--------------------------------------------------------------------------------
1 | # This file is maintained automatically by "terraform init".
2 | # Manual edits may be lost in future updates.
3 |
4 | provider "registry.terraform.io/hashicorp/local" {
5 | version = "2.2.3"
6 | hashes = [
7 | "h1:KmHz81iYgw9Xn2L3Carc2uAzvFZ1XsE7Js3qlVeC77k=",
8 | "zh:04f0978bb3e052707b8e82e46780c371ac1c66b689b4a23bbc2f58865ab7d5c0",
9 | "zh:6484f1b3e9e3771eb7cc8e8bab8b35f939a55d550b3f4fb2ab141a24269ee6aa",
10 | "zh:78a56d59a013cb0f7eb1c92815d6eb5cf07f8b5f0ae20b96d049e73db915b238",
11 | "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
12 | "zh:8aa9950f4c4db37239bcb62e19910c49e47043f6c8587e5b0396619923657797",
13 | "zh:996beea85f9084a725ff0e6473a4594deb5266727c5f56e9c1c7c62ded6addbb",
14 | "zh:9a7ef7a21f48fabfd145b2e2a4240ca57517ad155017e86a30860d7c0c109de3",
15 | "zh:a63e70ac052aa25120113bcddd50c1f3cfe61f681a93a50cea5595a4b2cc3e1c",
16 | "zh:a6e8d46f94108e049ad85dbed60354236dc0b9b5ec8eabe01c4580280a43d3b8",
17 | "zh:bb112ce7efbfcfa0e65ed97fa245ef348e0fd5bfa5a7e4ab2091a9bd469f0a9e",
18 | "zh:d7bec0da5c094c6955efed100f3fe22fca8866859f87c025be1760feb174d6d9",
19 | "zh:fb9f271b72094d07cef8154cd3d50e9aa818a0ea39130bc193132ad7b23076fd",
20 | ]
21 | }
22 |
23 | provider "registry.terraform.io/linode/linode" {
24 | version = "1.27.2"
25 | constraints = "1.27.2"
26 | hashes = [
27 | "h1:TjV5LDkQ1VLZiC9pj7VXnCQAGiExVL2CVYvMpVKrVZc=",
28 | "zh:090011635dc3c9eb408a1dcef5f492f0bb4e3edf5b7d9051ef2e6c7ca01dec61",
29 | "zh:2dc2a4bc24338189cab3ea317504277d916e6f2ed96dc40d9b105eea44d5f33d",
30 | "zh:3e4762a6645c5ccda42103192f2ab6e3b28d9fe409c3c36bd8faf2dc69748d14",
31 | "zh:46ace4de363782058128ebd128831b2132530a20aae6aa165b2badd62d39f86e",
32 | "zh:57b28a629f6ce3894110adeeb792bb8dbebd9610599307f16583f9ce5f5ea512",
33 | "zh:57e4fc411bcef5a36c5f3abd3a8dc300c9745e45c0a70a4f4d88a65e7f77c00c",
34 | "zh:65cd72916df2445a660dc656cd8e3abcd873afc1665dc81805f04df25892a9b5",
35 | "zh:86e271d2fe842693b6af1dd080c9006683b407d3a96edeee719653d30412459e",
36 | "zh:96ede9727d9e8864b9f003c3a1fac51f414d6ba8bec048924de29545d3cb29e9",
37 | "zh:a44ef9878fe3a6c3cdd08e17928b17a7475215de003ec3517069d6eec4b6f79a",
38 | "zh:cd121838ba9fa6bc6dbae81bad9693e063fc823cfa989257bcb5065101c7e15a",
39 | "zh:d2769d130feca8a88de1c1b336ffc5575284a54dfe9a105fc4a2928888a8efb6",
40 | "zh:dcd641d27231618a533314c661c5d73753c11871fb8345953c196088f9017820",
41 | "zh:f8cc0d1096393de0c39840d73a91a19a09d205e7140c91de312fa047bbcd4967",
42 | ]
43 | }
44 |
--------------------------------------------------------------------------------
/devops/tf/linodes.tf:
--------------------------------------------------------------------------------
1 | resource "linode_instance" "app_vm" {
2 | count = var.app_instance_vm_count > 0 ? var.app_instance_vm_count : 0
3 | image = var.linode_image
4 | region = "us-central"
5 | type = "g6-standard-1"
6 | authorized_keys = [ var.authorized_key ]
7 | root_pass = var.root_user_pw
8 | private_ip = true
9 | tags = ["app", "app-node"]
10 | }
11 |
12 |
13 | resource "linode_instance" "app_loadbalancer" {
14 | image = "linode/ubuntu20.04"
15 | label = "app_vm-load-balancer"
16 | region = "us-central"
17 | type = "g6-standard-2"
18 | authorized_keys = [ var.authorized_key]
19 | root_pass = var.root_user_pw
20 | private_ip = true
21 | tags = ["app", "app-lb"]
22 |
23 | lifecycle {
24 | prevent_destroy = true
25 | }
26 | }
27 |
28 | resource "linode_instance" "app_redis" {
29 | image = var.linode_image
30 | label = "app_redis-db"
31 | region = "us-central"
32 | type = "g6-standard-1"
33 | authorized_keys = [ var.authorized_key]
34 | root_pass = var.root_user_pw
35 | private_ip = true
36 | tags = ["app", "app-redis"]
37 |
38 | lifecycle {
39 | prevent_destroy = true
40 | }
41 |
42 | }
43 |
44 |
45 | resource "linode_instance" "app_worker" {
46 | count=1
47 | image = var.linode_image
48 | label = "app_worker-${count.index + 1}"
49 | region = "us-central"
50 | type = "g6-standard-1"
51 | authorized_keys = [ var.authorized_key]
52 | root_pass = var.root_user_pw
53 | private_ip = true
54 | tags = ["app", "app-worker"]
55 |
56 | depends_on = [linode_instance.app_redis]
57 | }
58 |
59 |
60 | resource "local_file" "ansible_inventory" {
61 | content = templatefile("${local.templates_dir}/ansible-inventory.tpl", {
62 | webapps=[for host in linode_instance.app_vm.*: "${host.ip_address}"]
63 | loadbalancer="${linode_instance.app_loadbalancer.ip_address}"
64 | redis="${linode_instance.app_redis.ip_address}"
65 | workers=[for host in linode_instance.app_worker.*: "${host.ip_address}"]
66 | })
67 | filename = "${local.ansible_dir}/inventory.ini"
68 |
69 | }
70 |
71 | resource "linode_domain" "tryiac_com" {
72 | type = "master"
73 | domain = "tryiac.com"
74 | soa_email = "hello@tryiac.com"
75 | tags = ["app", "app-domain"]
76 | }
77 |
78 | resource "linode_domain_record" "tryiac_com_root" {
79 | domain_id = linode_domain.tryiac_com.id
80 | name = ""
81 | record_type = "A"
82 | ttl_sec = 300
83 | target = "${linode_instance.app_loadbalancer.ip_address}"
84 | }
85 |
86 | resource "linode_domain_record" "tryiac_com_www" {
87 | domain_id = linode_domain.tryiac_com.id
88 | name = "www"
89 | record_type = "A"
90 | ttl_sec = 300
91 | target = "${linode_instance.app_loadbalancer.ip_address}"
92 | }
--------------------------------------------------------------------------------
/devops/tf/locals.tf:
--------------------------------------------------------------------------------
1 | locals {
2 | tf_dir = "${abspath(path.root)}"
3 | templates_dir = "${local.tf_dir}/templates"
4 | devops_dir = "${dirname(abspath(local.tf_dir))}"
5 | ansible_dir = "${local.devops_dir}/ansible"
6 | }
--------------------------------------------------------------------------------
/devops/tf/main.tf:
--------------------------------------------------------------------------------
1 | terraform {
2 | required_version = ">= 0.15"
3 | required_providers {
4 | linode = {
5 | source = "linode/linode"
6 | version = "1.27.2"
7 | }
8 | }
9 | backend "s3" {
10 | skip_credentials_validation = true
11 | skip_region_validation = true
12 | }
13 | }
14 |
15 | provider "linode" {
16 | token = var.linode_pa_token
17 | }
--------------------------------------------------------------------------------
/devops/tf/outputs.tf:
--------------------------------------------------------------------------------
1 | output "instances" {
2 | value = [for host in linode_instance.app_vm.*: "${host.label} : ${host.ip_address}"]
3 | }
4 |
5 | output "loadbalancer" {
6 | value = "${linode_instance.app_loadbalancer.ip_address}"
7 | }
8 |
9 | output "redisdb" {
10 | value = "${linode_instance.app_redis.ip_address}"
11 | }
--------------------------------------------------------------------------------
/devops/tf/templates/ansible-inventory.tpl:
--------------------------------------------------------------------------------
1 | [webapps]
2 | %{ for host in webapps ~}
3 | ${host}
4 | %{ endfor ~}
5 |
6 | [loadbalancer]
7 | ${loadbalancer}
8 |
9 | [redis]
10 | ${redis}
11 |
12 | [workers]
13 | %{ for host in workers ~}
14 | ${host}
15 | %{ endfor ~}
--------------------------------------------------------------------------------
/devops/tf/variables.tf:
--------------------------------------------------------------------------------
1 | variable "linode_pa_token" {
2 | sensitive = true
3 | }
4 |
5 | variable "authorized_key" {
6 | sensitive = true
7 | }
8 |
9 | variable "root_user_pw" {
10 | sensitive = true
11 | }
12 |
13 | variable "app_instance_vm_count" {
14 | default = 0
15 | }
16 |
17 | variable "linode_image" {
18 | default = "linode/ubuntu20.04"
19 | }
--------------------------------------------------------------------------------
/docker-compose.prod.yaml:
--------------------------------------------------------------------------------
1 | version: "3.9"
2 | services:
3 | watchtower:
4 | image: index.docker.io/containrrr/watchtower:latest
5 | restart: always
6 | volumes:
7 | - /var/run/docker.sock:/var/run/docker.sock
8 | - /root/.docker/config.json:/config.json
9 | command: --interval 30
10 | profiles:
11 | - app
12 | app:
13 | image: index.docker.io/codingforentrepreneurs/cfe-django-blog.com:latest
14 | restart: always
15 | env_file: ./.env
16 | container_name: prod_django_app
17 | environment:
18 | - PORT=8080
19 | ports:
20 | - "80:8080"
21 | expose:
22 | - 80
23 | volumes:
24 | - ./certs:/app/certs
25 | profiles:
26 | - app
27 | redis:
28 | image: redis
29 | restart: always
30 | ports:
31 | - "6379:6379"
32 | expose:
33 | - 6379
34 | volumes:
35 | - redis_data:/data
36 | entrypoint: redis-server --appendonly yes
37 | profiles:
38 | - redis
39 |
40 | volumes:
41 | redis_data:
--------------------------------------------------------------------------------
/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | version: "3.9"
2 |
3 | services:
4 | app:
5 | image: cfe-django-blog
6 | build:
7 | context: .
8 | dockerfile: Dockerfile
9 | restart: unless-stopped
10 | env_file: .env
11 | ports:
12 | - "8000:8000"
13 | profiles:
14 | - app_too
15 | depends_on:
16 | - mysql_db
17 | mysql_db:
18 | image: mysql:8.0.26
19 | restart: always
20 | env_file: .env
21 | ports:
22 | - "3307:3307"
23 | expose:
24 | - 3307
25 | volumes:
26 | - mysql-volume:/var/lib/mysql
27 | profiles:
28 | - main
29 | - app_too
30 |
31 | volumes:
32 | mysql-volume:
--------------------------------------------------------------------------------
/fixtures/articles.json:
--------------------------------------------------------------------------------
1 | [{"model": "auth.user", "pk": 1, "fields": {"password": "pbkdf2_sha256$260000$1dWmDpK3VG0gZEMecDQHDm$VmOB0WmbAl4KZ1L3dK8fI5xFRnQW9rLOQl9iT5JDZs0=", "last_login": "2022-03-18T15:54:09Z", "is_superuser": true, "username": "admin", "first_name": "", "last_name": "", "email": "", "is_staff": true, "is_active": true, "date_joined": "2022-03-18T15:53:53Z", "groups": [], "user_permissions": []}}, {"model": "articles.article", "pk": 1, "fields": {"user": 1, "updated_by": 1, "title": "Hello World", "slug": "hello-world", "content": "Welcome to CFE Django Blog\r\n\r\n\r\nI wanted an easy way to launch a blog project from Django at anytime. That's what this is all about.\r\n\r\nI'll continue to update the code on [github](https://github.com/codingforentrepreneurs/cfe-django-blog) to get it as production-ready as possible!\r\n\r\n\r\n\r\n- Photo by: [Vladislav Klapin](https://unsplash.com/photos/PVr9Gsj93Pc?utm_source=unsplash&utm_medium=referral&utm_content=creditShareLink)", "image": "articles/hello-world/07472194-a6d5-11ec-887f-acde48001122.jpg", "publish_status": "pub", "publish_timestamp": "2022-03-18T16:04:02.198Z", "user_publish_timestamp": null, "timestamp": "2022-03-18T16:04:02.200Z", "updated": "2022-03-18T16:49:46.327Z"}}, {"model": "articles.article", "pk": 2, "fields": {"user": 1, "updated_by": 1, "title": "Draft Post", "slug": "draft-post", "content": "This is an example Draft Post.", "image": "", "publish_status": "dra", "publish_timestamp": null, "user_publish_timestamp": null, "timestamp": "2022-03-18T16:19:35.781Z", "updated": "2022-03-18T16:49:49.954Z"}}, {"model": "articles.article", "pk": 3, "fields": {"user": 1, "updated_by": 1, "title": "Pending Publish Post", "slug": "pending-publish-post", "content": "This post will be published in 100 years. Excited to see it live!", "image": "", "publish_status": "pub", "publish_timestamp": "2122-03-18T16:20:00Z", "user_publish_timestamp": "2122-03-18T16:20:00Z", "timestamp": "2022-03-18T16:20:05.827Z", "updated": "2022-03-18T16:49:39.189Z"}}]
--------------------------------------------------------------------------------
/fixtures/auth.json:
--------------------------------------------------------------------------------
1 | [{"model": "auth.user", "pk": 1, "fields": {"password": "pbkdf2_sha256$260000$1dWmDpK3VG0gZEMecDQHDm$VmOB0WmbAl4KZ1L3dK8fI5xFRnQW9rLOQl9iT5JDZs0=", "last_login": "2022-03-18T15:54:09Z", "is_superuser": true, "username": "admin", "first_name": "", "last_name": "", "email": "", "is_staff": true, "is_active": true, "date_joined": "2022-03-18T15:53:53Z", "groups": [], "user_permissions": []}}, {"model": "articles.article", "pk": 1, "fields": {"user": 1, "updated_by": 1, "title": "Hello World", "slug": "hello-world", "content": "Welcome to CFE Django Blog\r\n\r\n\r\nI wanted an easy way to launch a blog project from Django at anytime. That's what this is all about.\r\n\r\nI'll continue to update the code on [github](https://github.com/codingforentrepreneurs/cfe-django-blog) to get it as production-ready as possible!\r\n\r\n\r\n\r\n- Photo by: [Vladislav Klapin](https://unsplash.com/photos/PVr9Gsj93Pc?utm_source=unsplash&utm_medium=referral&utm_content=creditShareLink)", "image": "articles/hello-world/07472194-a6d5-11ec-887f-acde48001122.jpg", "publish_status": "pub", "publish_timestamp": "2022-03-18T16:04:02.198Z", "user_publish_timestamp": null, "timestamp": "2022-03-18T16:04:02.200Z", "updated": "2022-03-18T16:49:46.327Z"}}, {"model": "articles.article", "pk": 2, "fields": {"user": 1, "updated_by": 1, "title": "Draft Post", "slug": "draft-post", "content": "This is an example Draft Post.", "image": "", "publish_status": "dra", "publish_timestamp": null, "user_publish_timestamp": null, "timestamp": "2022-03-18T16:19:35.781Z", "updated": "2022-03-18T16:49:49.954Z"}}, {"model": "articles.article", "pk": 3, "fields": {"user": 1, "updated_by": 1, "title": "Pending Publish Post", "slug": "pending-publish-post", "content": "This post will be published in 100 years. Excited to see it live!", "image": "", "publish_status": "pub", "publish_timestamp": "2122-03-18T16:20:00Z", "user_publish_timestamp": "2122-03-18T16:20:00Z", "timestamp": "2022-03-18T16:20:05.827Z", "updated": "2022-03-18T16:49:39.189Z"}}]
--------------------------------------------------------------------------------
/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', 'cfeblog.settings')
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 |
--------------------------------------------------------------------------------
/mediafiles/articles/hello-world/07472194-a6d5-11ec-887f-acde48001122.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codingforentrepreneurs/deploy-django-linode-mysql/cdff300daa5241c46cfbca93e03e49acb5ac33ab/mediafiles/articles/hello-world/07472194-a6d5-11ec-887f-acde48001122.jpg
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | django>=3.2,<4.0
2 | ## our production-grade webserver
3 | gunicorn
4 | ## loading in environment variables
5 | python-dotenv
6 | # for formatting code
7 | black
8 | ## for image file uploads in Django
9 | pillow
10 | ## for leveraging 3rd party static/meida file servers
11 | boto3
12 | django-storages
13 | ## for mysql databases
14 | mysqlclient
15 |
16 | ## uncomment for postgresql databases
17 | # psycopg2
18 | ## only uncomment if `psycopg2` doesn't install
19 | # psycopg2-binary
--------------------------------------------------------------------------------
/staticfiles/empty.txt:
--------------------------------------------------------------------------------
1 | empty for git
--------------------------------------------------------------------------------
/staticroot/empty.txt:
--------------------------------------------------------------------------------
1 | empty for git
--------------------------------------------------------------------------------
/templates/base.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | {% block head_title %}CFE Django Block{% endblock %}
12 |
13 |
14 | {% include 'navbar.html' %}
15 | {% block content %}
16 | Your templates are working, but your blocks are not.
17 | {% endblock %}
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
29 |
30 |
--------------------------------------------------------------------------------
/templates/navbar.html:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------