├── .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 | {{ article.image }} 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 |
16 | {% if article.image %} 17 | 18 | {{ article.image }} 19 | 20 | {% endif %} 21 |
22 | 23 |

24 | {{ article.title }} 25 |

26 |
27 | 28 | {% if article.content %}

{{ article.content|truncatewords:20 }}.

{% endif %} 29 | View 30 |
31 |
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 | --------------------------------------------------------------------------------