├── .env.example ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── documentation.md │ ├── feature_request.md │ ├── refactor_code.md │ └── style_change_request.md ├── codeql │ └── codeql-config.yml ├── dependabot.yml └── workflows │ ├── django-test-deploy-master.yaml │ └── django-test-pr-branch-no-deploy.yaml ├── .gitignore ├── LICENSE ├── Procfile ├── README.md ├── app ├── __init__.py ├── asgi.py ├── middleware.py ├── settings.py ├── sitemaps.py ├── storage_backends.py ├── urls.py ├── views.py └── wsgi.py ├── backups ├── .gitkeep ├── backup.sh ├── cleanup_backups.sh ├── crontab_schedule.txt ├── daily │ └── .gitkeep ├── monthly │ └── .gitkeep ├── uploadToDropbox.sh └── weekly │ └── .gitkeep ├── blog ├── __init__.py ├── admin.py ├── apps.py ├── context_processors.py ├── df.pkl ├── feeds.py ├── forms.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── import_posts.py │ │ └── recalculate_post_simularities.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_add_likes.py │ ├── 0003_add_views_to_Post.py │ ├── 0004_add_slug_field.py │ ├── 0005_add_images_to_post.py │ ├── 0006_change_content_field_to_richtextuploading.py │ ├── 0007_add_snippet_to_post_model.py │ ├── 0008_change_snippet_field_to_richtextfield.py │ ├── 0009_added_metadescription_to_post_model.py │ ├── 0010_add_length_constraints_to_text_fields.py │ ├── 0011_add_draft_post_boolean.py │ ├── 0012_add_metaimg.py │ ├── 0013_add_desc_to_category.py │ ├── 0014_removed_likes_posts_ipperson.py │ ├── 0015_add_ckeditor_fields.py │ ├── 0016_add_alt_txt_to_meta_img.py │ ├── 0017_add_temp_category_link_field.py │ ├── 0018_transfer_categories.py │ ├── 0019_remove_category_rename_category_link.py │ ├── 0020_changed_metaimg_alt_text_length.py │ ├── 0021_increase_post_title_length.py │ ├── 0022_remove_length_limit_snippet.py │ ├── 0023_change_default_text_meta_img.py │ ├── 0024_remove_mime_type_from_post.py │ ├── 0025_add_metaimg_attribution.py │ ├── 0026_change_image_type.py │ ├── 0027_bumpCharacterLimit.py │ ├── 0028_add_slug_field_to_category.py │ ├── 0029_slugify_category_names.py │ ├── 0030_remove_blank_nullable_from_category_slug.py │ ├── 0031_comment.py │ ├── 0032_remove_comment_body_comment_content.py │ ├── 0033_comment_date_updated.py │ ├── 0034_add_default_category.py │ ├── 0035_change_default_meta_img.py │ ├── 0036_add_default_users.py │ ├── 0037_create_default_site.py │ ├── 0038_add_default_first_post_and_comment.py │ ├── 0039_add_date_updated_to_Post.py │ ├── 0040_populate_date_updated_with_date_posted.py │ ├── 0041_disallow_null_values_date_updated.py │ ├── 0042_add_simularity_model.py │ ├── 0043_update_simularities_on_existing_posts.py │ ├── 0044_alter_post_metaimg.py │ └── __init__.py ├── models.py ├── signals.py ├── templates │ ├── 404.html │ ├── 500.html │ └── blog │ │ ├── all_posts.html │ │ ├── base.html │ │ ├── categories.html │ │ ├── comment │ │ ├── add_comment.html │ │ ├── comment_item.html │ │ └── update_comment.html │ │ ├── home.html │ │ ├── parts │ │ ├── about_me_card.html │ │ ├── breadcrumbs.html │ │ ├── chatbox.html │ │ ├── contact_me.html │ │ ├── footer.html │ │ ├── gpt_title_slug_generation.html │ │ ├── header.html │ │ ├── pagination.html │ │ ├── post_card.html │ │ ├── posts.html │ │ ├── posts_paginated.html │ │ └── sidebar.html │ │ ├── pgp-key.txt │ │ ├── post │ │ ├── add_post.html │ │ ├── edit_post.html │ │ ├── post_confirm_delete.html │ │ └── post_detail.html │ │ ├── privacy.html │ │ ├── search_posts.html │ │ ├── security.txt │ │ ├── status_page.html │ │ └── works_cited.html ├── templatetags │ ├── __init__.py │ ├── image_utils.py │ └── post_utils.py ├── urls.py ├── utils.py ├── validators.py └── views.py ├── config ├── .coveragerc ├── .pre-commit-config.yaml ├── awesome-django-blog-MacOS.code-workspace ├── pyproject.toml └── vscode │ ├── launch.json │ ├── settings.json │ └── tasks.json ├── coverage └── .gitkeep ├── docs ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── SECURITY.md ├── heroku_deploy.md ├── how_to.md └── pull_request_template.md ├── logs └── .gitkeep ├── manage.py ├── mediafiles └── default.webp ├── nltk.txt ├── requirements.txt ├── scripts ├── clean_image_urls.py └── move_images.py ├── static ├── convertImages ├── css │ ├── 404.css │ └── main.css ├── default.webp ├── django_ckeditor_5 │ ├── ckeditor.css │ ├── ckeditor_custom.css │ ├── prism-dark.css │ ├── prism-light.css │ └── prism.js ├── favicon │ ├── android-chrome-192x192.png │ ├── android-chrome-192x192.webp │ ├── android-chrome-512x512.png │ ├── android-chrome-512x512.webp │ ├── apple-touch-icon.png │ ├── apple-touch-icon.webp │ ├── favicon-16x16.png │ ├── favicon-16x16.webp │ ├── favicon-32x32.png │ ├── favicon-32x32.webp │ └── favicon.ico ├── iPhoneblogthedata.webp ├── icons │ ├── ESLintLogo.svg │ ├── NGINXlogo.svg │ ├── PytestLogo.svg │ ├── bootstrapLogo.svg │ ├── cloudflareLogo.svg │ ├── cypressLogo.svg │ ├── djangoLogo.svg │ ├── dockerLogo.svg │ ├── expressLogo.svg │ ├── gunicornLogo.svg │ ├── herokuLogo.svg │ ├── ip-address.png │ ├── ip-address.webp │ ├── leafletLogo.svg │ ├── linodeLogo.svg │ ├── linuxLogo.svg │ ├── mapboxLogo.svg │ ├── nodeLogo.svg │ ├── openlayersLogo.svg │ ├── postgresLogo.svg │ ├── prettierLogo.svg │ ├── pythonLogo.svg │ ├── seleniumLogo.svg │ ├── typescriptLogo.svg │ ├── vercelLogo.svg │ └── viteLogo.svg ├── img │ ├── Scarecrow.png │ └── Scarecrow.webp ├── js │ ├── addHeaderIdsAndLinks.js │ ├── characterCounter.js │ ├── charts │ │ ├── CPU_gauge.js │ │ └── echarts.min.js │ ├── chatbox.js │ ├── header.js │ └── htmx.min.js ├── logo.webp ├── logo_transparent_cropped.webp └── svg │ ├── chatbox-icon.svg │ ├── clock.svg │ ├── close-icon.svg │ ├── comment-icon.svg │ ├── github.svg │ ├── linkedin-share-btn.svg │ ├── man-avatar.svg │ ├── open-post-icon.svg │ ├── person-circle.svg │ ├── print-icon.svg │ ├── reddit-share-btn.svg │ ├── scroll-to-top.svg │ ├── send-icon.svg │ └── x-share-btn.svg ├── tests ├── __init__.py ├── base.py ├── test_context_processors.py ├── test_feeds.py ├── test_models.py ├── test_sitemaps.py ├── test_template_tags.py ├── test_urls.py ├── test_utils.py ├── test_validators.py ├── test_views.py └── utils.py ├── users ├── __init__.py ├── admin.py ├── apps.py ├── forms.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── signals.py ├── templates │ └── users │ │ ├── login.html │ │ ├── logout.html │ │ ├── password_reset.html │ │ ├── password_reset_complete.html │ │ ├── password_reset_confirm.html │ │ ├── password_reset_done.html │ │ ├── profile.html │ │ └── register.html └── views.py └── utilities ├── create_embeddings ├── export_posts.py ├── exported_posts │ └── .gitkeep ├── process_posts.py ├── save_vectors_to_pickle.py ├── test_embeddings.py ├── tokenize_posts_and_create_embeddings.py └── tokenize_posts_simple.py ├── seed_posts └── posts.json └── webp-convert-directory.sh /.env.example: -------------------------------------------------------------------------------- 1 | SECRET_KEY=generated_secret_key 2 | EMAIL_HOST_USER= # Used for sending password reset emails 3 | EMAIL_HOST_PASSWORD= # Used for sending password reset emails 4 | FROM_EMAIL= # Used for sending password reset emails 5 | ALLOWED_HOSTS="127.0.0.1 localhost" # In production, I use ".blogthedata.com .herokuapp.com" 6 | DJANGO_SETTINGS_MODULE=app.settings.dev # Change to app.settings.prod in production 7 | SITE_ID=1 # Change to 2 in production 8 | DB_ENGINE=django.db.backends.postgresql 9 | DB_NAME=my_db_name 10 | DB_USER=my_user 11 | DB_PASSWORD=my_password 12 | DB_HOST=localhost 13 | DB_PORT=5432 14 | USE_SQLITE=True 15 | LOGGING=True # Creates log files in app/logs 16 | GIT_TOKEN= # Used for Coveralls test coverage 17 | DEBUG=True # Set to False in production 18 | DROPBOX_ACCESS_TOKEN= # Used for optional Dropbox database backup 19 | LIVERELOAD=False # Set to False in production and if you don't want to use livereload 20 | USE_CLOUD=False # Set to True to use S3 for storage and CloudFront for CDN 21 | AWS_ACCESS_KEY_ID= 22 | AWS_SECRET_ACCESS_KEY= 23 | AWS_STORAGE_BUCKET_NAME= 24 | AWS_URL= # URL of the S3 bucket 25 | DJANGO_STATIC_HOST= # URL of the CloudFront distribution -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Context 11 | 12 | 13 | ## Steps to reproduce the issue 14 | 1. Go to '...' 15 | 2. Click on '....' 16 | 3. Scroll down to '....' 17 | See error 18 | 19 | ## Expected behavior 20 | 21 | 22 | ## Additional Context 23 | 24 | ## Environment 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation Update 3 | about: Improve the documentation 4 | title: "docs" 5 | labels: "documentation" 6 | assignees: "" 7 | --- 8 | ## What Needs Documenting? 9 | 10 | 11 | ## Record 12 | 13 | - [ ] I have read the Contributing Guidelines 14 | - [ ] I have searched the issues for a similar request 15 | - [ ] I want to work on this issue 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Context 11 | 12 | 13 | ## Ideal solution 14 | 15 | 16 | ## Workarounds 17 | 18 | 19 | ## Additional context and related issues 20 | 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/refactor_code.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Refactor code 3 | about: Use this label to create code refactoring tasks. 4 | title: "[Refactor] " 5 | labels: "refactor" 6 | assignees: "" 7 | --- 8 | 9 | ## Context 10 | 11 | 12 | ### File Name 13 | 16 | 17 | ### Record 18 | 19 | - [ ] I have read the Contributing Guidelines 20 | - [ ] I have searched the issues for a similar request 21 | - [ ] I want to work on this issue 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/style_change_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Improve Styling 3 | about: Use this label for styling changes. 4 | title: "[Update] [styling]" 5 | labels: "styling, enhancement" 6 | assignees: "" 7 | --- 8 | 9 | ## Context 10 | 11 | 12 | ### What's your idea to make it better? 13 | 14 | 15 | ### Screenshots 16 | 17 | 18 | ### Record 19 | 20 | - [ ] I have read the Contributing Guidelines 21 | - [ ] I have searched the issues for a similar request 22 | - [ ] I want to work on this issue 23 | -------------------------------------------------------------------------------- /.github/codeql/codeql-config.yml: -------------------------------------------------------------------------------- 1 | name: "Awesome Django Blog CodeQL Configuration" 2 | 3 | paths-ignore: 4 | - ".venv" 5 | - "tests" 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | # Maintain dependencies for pip package manager 9 | - package-ecosystem: pip 10 | directory: / 11 | schedule: 12 | interval: daily 13 | 14 | - package-ecosystem: github-actions 15 | directory: .github/workflows 16 | schedule: 17 | interval: daily 18 | -------------------------------------------------------------------------------- /.github/workflows/django-test-deploy-master.yaml: -------------------------------------------------------------------------------- 1 | name: Django Test Deploy Master 2 | on: 3 | push: 4 | branches: 5 | - master 6 | schedule: 7 | - cron: "0 3 * * 0-6" # Every day at 3am UTC 8 | 9 | env: 10 | DEBUG: False 11 | LOGGING: False 12 | DJANGO_SETTINGS_MODULE: app.settings 13 | USE_SQLITE: True 14 | SECRET_KEY: "Not applicable for tests" 15 | ALLOWED_HOSTS: "127.0.0.1 localhost" 16 | SITE_ID: 1 17 | 18 | jobs: 19 | build: 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - name: update-system-dependencies 26 | run: | 27 | sudo apt-get update -y 28 | 29 | - name: setup-python 30 | uses: actions/setup-python@v5 31 | with: 32 | python-version: 3.10.8 33 | cache: "pip" 34 | 35 | - name: install-python-virtualenv 36 | run: | 37 | python3 -m venv .venv 38 | source .venv/bin/activate 39 | pip install --upgrade pip 40 | pip install wheel 41 | pip install -r requirements.txt 42 | 43 | - name: lint-with-ruff 44 | run: | 45 | source .venv/bin/activate 46 | ruff --config ./config/pyproject.toml app 47 | 48 | - name: collect-static-files 49 | run: | 50 | source .venv/bin/activate 51 | python3 manage.py collectstatic --noinput 52 | 53 | - name: run-db-migrations 54 | run: | 55 | source .venv/bin/activate 56 | python3 manage.py migrate --noinput 57 | 58 | # - name: run-unit-tests-with-coverage 59 | # run: | 60 | # source .venv/bin/activate 61 | # coverage run --rcfile=config/.coveragerc -m pytest tests 62 | # coverage lcov --rcfile=config/.coveragerc 63 | 64 | # - name: coveralls 65 | # uses: coverallsapp/github-action@master 66 | # with: 67 | # github-token: ${{ secrets.github_token }} 68 | 69 | static_analysis: 70 | runs-on: ubuntu-latest 71 | permissions: 72 | actions: read 73 | contents: read 74 | security-events: write 75 | 76 | strategy: 77 | fail-fast: false 78 | matrix: 79 | language: ["javascript", "python"] 80 | 81 | steps: 82 | - uses: actions/checkout@v4 83 | 84 | - name: initialize-codeQL 85 | uses: github/codeql-action/init@v3 86 | with: 87 | config-file: ./.github/codeql/codeql-config.yml 88 | languages: ${{ matrix.language }} 89 | 90 | - name: perform-codeQL Analysis 91 | uses: github/codeql-action/analyze@v3 92 | -------------------------------------------------------------------------------- /.github/workflows/django-test-pr-branch-no-deploy.yaml: -------------------------------------------------------------------------------- 1 | name: Django Test PR Branch No Deploy 2 | on: 3 | push: 4 | branches-ignore: 5 | - master 6 | pull_request: 7 | branches: 8 | - "*" 9 | 10 | env: 11 | DEBUG: False 12 | LOGGING: False 13 | DJANGO_SETTINGS_MODULE: app.settings 14 | USE_SQLITE: True 15 | SECRET_KEY: "Not applicable for tests" 16 | ALLOWED_HOSTS: "127.0.0.1 localhost" 17 | SITE_ID: 1 18 | jobs: 19 | build: 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - name: update-system-dependencies 26 | run: | 27 | sudo apt-get update -y 28 | 29 | - name: setup-python 30 | uses: actions/setup-python@v5 31 | with: 32 | python-version: 3.10.8 33 | cache: "pip" 34 | 35 | - name: install-python-virtualenv 36 | run: | 37 | python3 -m venv .venv 38 | source .venv/bin/activate 39 | pip install --upgrade pip 40 | pip install wheel 41 | pip install -r requirements.txt 42 | 43 | - name: lint-with-ruff 44 | run: | 45 | source .venv/bin/activate 46 | ruff --config ./config/pyproject.toml app 47 | 48 | - name: collect-static-files 49 | run: | 50 | source .venv/bin/activate 51 | python3 manage.py collectstatic --noinput 52 | 53 | - name: run-db-migrations 54 | run: | 55 | source .venv/bin/activate 56 | python3 manage.py migrate --noinput 57 | 58 | - name: run-unit-tests-with-coverage 59 | run: | 60 | source .venv/bin/activate 61 | coverage run --rcfile=config/.coveragerc -m pytest tests 62 | 63 | static_analysis: 64 | runs-on: ubuntu-latest 65 | permissions: 66 | actions: read 67 | contents: read 68 | security-events: write 69 | 70 | strategy: 71 | fail-fast: false 72 | matrix: 73 | language: ["javascript", "python"] 74 | 75 | steps: 76 | - uses: actions/checkout@v4 77 | 78 | - name: initialize-codeQL 79 | uses: github/codeql-action/init@v3 80 | with: 81 | config-file: ./.github/codeql/codeql-config.yml 82 | languages: ${{ matrix.language }} 83 | 84 | - name: perform-codeQL Analysis 85 | uses: github/codeql-action/analyze@v3 86 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Posts and embeddings 2 | exported_posts/ 3 | processed/ 4 | 5 | # Virtual Environments 6 | .venv/ 7 | 8 | # Environ Variables File 9 | .env 10 | 11 | # Coverage files 12 | .coverage 13 | 14 | # Temp Files 15 | __pycache__/ 16 | .DS_Store 17 | 18 | # backups 19 | blogthedata*.sql 20 | linode*.img 21 | migration_backup 22 | 23 | # Static Files 24 | staticfiles/ 25 | mediafiles/* 26 | 27 | # But don't ignore default.webp 28 | !mediafiles/default.webp 29 | 30 | # Other 31 | *.log 32 | .vscode/ 33 | 34 | db.sqlite3 35 | db.sqlite3-journal 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 John Solly 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 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn app.wsgi:application --log-file - -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/app/__init__.py -------------------------------------------------------------------------------- /app/asgi.py: -------------------------------------------------------------------------------- 1 | from django.core.asgi import get_asgi_application 2 | import os 3 | 4 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") 5 | 6 | application = get_asgi_application() 7 | -------------------------------------------------------------------------------- /app/middleware.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponsePermanentRedirect 2 | 3 | class WwwRedirectMiddleware: 4 | def __init__(self, get_response): 5 | self.get_response = get_response 6 | 7 | def __call__(self, request): 8 | host = request.get_host().lower() 9 | if not host.startswith('www.') and host not in ('localhost', '127.0.0.1', 'testserver'): 10 | return HttpResponsePermanentRedirect( 11 | f"{request.scheme}://www.{host}{request.get_full_path()}" 12 | ) 13 | return self.get_response(request) -------------------------------------------------------------------------------- /app/sitemaps.py: -------------------------------------------------------------------------------- 1 | from django.contrib.sitemaps import Sitemap 2 | from blog.models import Post, Category 3 | from django.urls import reverse 4 | 5 | 6 | class PostSitemap(Sitemap): 7 | changefreq = "weekly" 8 | priority = 0.8 9 | 10 | def items(self): 11 | return Post.objects.active() 12 | 13 | def lastmod(self, obj): 14 | return obj.date_updated 15 | 16 | def location(self, obj): 17 | return obj.get_absolute_url() 18 | 19 | 20 | class CategorySitemap(Sitemap): 21 | changefreq = "weekly" 22 | priority = 0.6 23 | 24 | def items(self): 25 | return Category.objects.all() 26 | 27 | def location(self, obj): 28 | return obj.get_absolute_url() 29 | 30 | 31 | class StaticSitemap(Sitemap): 32 | changefreq = "monthly" 33 | priority = 0.3 34 | 35 | def items(self): 36 | return [ 37 | 'home', 38 | 'works-cited', 39 | 'privacy', 40 | 'status', 41 | 'all-posts', 42 | ] 43 | 44 | def location(self, item): 45 | return reverse(item) 46 | -------------------------------------------------------------------------------- /app/storage_backends.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from storages.backends.s3boto3 import S3Boto3Storage 3 | from django.core.files.storage import FileSystemStorage 4 | import logging 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class StaticStorage(S3Boto3Storage): 10 | location = "static" 11 | default_acl = "public-read" 12 | file_overwrite = True 13 | 14 | def url(self, name): 15 | """Override url method to use CloudFront domain""" 16 | logger.debug(f"StaticStorage.url called for {name}") 17 | if settings.USE_CLOUD and settings.STATIC_HOST: 18 | url = f"{settings.STATIC_HOST}/{self.location}/{name}" 19 | logger.debug(f"Returning CloudFront URL: {url}") 20 | return url 21 | url = super().url(name) 22 | logger.debug(f"Returning S3 URL: {url}") 23 | return url 24 | 25 | 26 | class PublicMediaStorage(S3Boto3Storage): 27 | location = "media" 28 | default_acl = "public-read" 29 | file_overwrite = False 30 | 31 | def url(self, name): 32 | """Override url method to use CloudFront domain""" 33 | logger.debug(f"PublicMediaStorage.url called for {name}") 34 | if settings.USE_CLOUD and settings.STATIC_HOST: 35 | url = f"{settings.STATIC_HOST}/{self.location}/{name}" 36 | logger.debug(f"Returning CloudFront URL: {url}") 37 | return url 38 | url = super().url(name) 39 | logger.debug(f"Returning S3 URL: {url}") 40 | return url 41 | 42 | 43 | class PrivateMediaStorage(S3Boto3Storage): 44 | location = "private" 45 | default_acl = "private" 46 | file_overwrite = False 47 | custom_domain = False 48 | 49 | 50 | class PostImageStorageBase: 51 | """Base mixin for post image storage to ensure consistent path handling""" 52 | def get_upload_path(self, filename): 53 | return f"post_imgs/{filename}" 54 | 55 | 56 | class PostImageStorageS3(PostImageStorageBase, S3Boto3Storage): 57 | location = "media" 58 | default_acl = "public-read" 59 | file_overwrite = False 60 | 61 | def _save(self, name, content): 62 | logger.debug(f"PostImageStorageS3._save called for {name}") 63 | name = self.get_upload_path(name) 64 | logger.debug(f"Saving with path: {name}") 65 | return super()._save(name, content) 66 | 67 | def url(self, name): 68 | """Override url method to use CloudFront domain""" 69 | logger.debug(f"PostImageStorageS3.url called for {name}") 70 | if settings.USE_CLOUD and settings.STATIC_HOST: 71 | url = f"{settings.STATIC_HOST}/{self.location}/{name}" 72 | logger.debug(f"Returning CloudFront URL: {url}") 73 | return url 74 | url = super().url(name) 75 | return url 76 | 77 | 78 | class PostImageStorageLocal(PostImageStorageBase, FileSystemStorage): 79 | location = settings.MEDIA_ROOT 80 | base_url = settings.MEDIA_URL 81 | 82 | def _save(self, name, content): 83 | name = self.get_upload_path(name) 84 | return super()._save(name, content) 85 | 86 | -------------------------------------------------------------------------------- /app/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.sitemaps.views import sitemap 3 | from django.urls import path, include 4 | from django.conf import settings 5 | from django.conf.urls.static import static 6 | from app.sitemaps import ( 7 | PostSitemap, 8 | CategorySitemap, 9 | StaticSitemap, 10 | ) 11 | from .views import ( 12 | works_cited_view, 13 | privacy_view, 14 | security_txt_view, 15 | security_pgp_key_view, 16 | ) 17 | from users.views import ( 18 | RegisterView, 19 | ProfileView, 20 | MyLoginView, 21 | MyLogoutView, 22 | MyPasswordResetView, 23 | MyPasswordResetDoneView, 24 | MyPasswordResetConfirmView, 25 | MyPasswordResetCompleteView, 26 | ) 27 | 28 | sitemaps = { 29 | "posts": PostSitemap, 30 | "categories": CategorySitemap, 31 | "static": StaticSitemap, 32 | } 33 | 34 | urlpatterns = [ 35 | path( 36 | "sitemap.xml", 37 | sitemap, 38 | {"sitemaps": sitemaps}, 39 | name="django.contrib.sitemaps.views.sitemap", 40 | ), 41 | path("works-cited", works_cited_view, name="works-cited"), 42 | path("privacy", privacy_view, name="privacy"), 43 | path("admin/", admin.site.urls), 44 | path(".well-known/security.txt", security_txt_view, name="security-txt"), 45 | path("pgp-key.txt", security_pgp_key_view, name="security-pgp-key-txt"), 46 | path("robots.txt", include("robots.urls")), 47 | path("", include("blog.urls")), 48 | path("register/", RegisterView.as_view(), name="register"), 49 | path("profile/", ProfileView.as_view(), name="profile"), 50 | path( 51 | "login/", 52 | MyLoginView.as_view(template_name="users/login.html"), 53 | name="login", 54 | ), 55 | path( 56 | "logout/", 57 | MyLogoutView.as_view(template_name="users/logout.html"), 58 | name="logout", 59 | ), 60 | path( 61 | "password-reset/", 62 | MyPasswordResetView.as_view(template_name="users/password_reset.html"), 63 | name="password_reset", 64 | ), 65 | path( 66 | "password-reset/done/", 67 | MyPasswordResetDoneView.as_view(template_name="users/password_reset_done.html"), 68 | name="password_reset_done", 69 | ), 70 | path( 71 | "password-reset-confirm///", 72 | MyPasswordResetConfirmView.as_view( 73 | template_name="users/password_reset_confirm.html" 74 | ), 75 | name="password_reset_confirm", 76 | ), 77 | path( 78 | "password-reset-complete/", 79 | MyPasswordResetCompleteView.as_view( 80 | template_name="users/password_reset_complete.html" 81 | ), 82 | name="password_reset_complete", 83 | ), 84 | ] 85 | if settings.DEBUG: # pragma: no cover 86 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 87 | 88 | handler404 = "app.views.handler_404" -------------------------------------------------------------------------------- /app/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | def handler_404(request, exception): 4 | return render(request, "404.html", status=404) 5 | 6 | def works_cited_view(request): 7 | return render( 8 | request, 9 | context={ 10 | "title": "Works Cited | Blogthedata.com", 11 | "description": "Curated list of inspirations & resources from a geospatial software engineer. Get insights on the latest techniques & create a successful blog.", 12 | }, 13 | template_name="blog/works_cited.html", 14 | ) 15 | 16 | 17 | def privacy_view(request): 18 | return render( 19 | request, 20 | context={ 21 | "title": "Privacy Policy | Blogthedata.com", 22 | "description": "I don't collect any information about you. I'm just a guy who likes to blog. You can read my privacy policy here.", 23 | }, 24 | template_name="blog/privacy.html", 25 | ) 26 | 27 | 28 | def security_txt_view(request): 29 | return render( 30 | request, 31 | "blog/security.txt", 32 | ) 33 | 34 | 35 | def security_pgp_key_view(request): 36 | return render( 37 | request, 38 | "blog/pgp-key.txt", 39 | ) 40 | -------------------------------------------------------------------------------- /app/wsgi.py: -------------------------------------------------------------------------------- 1 | from django.core.wsgi import get_wsgi_application 2 | import os 3 | 4 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") 5 | 6 | application = get_wsgi_application() 7 | -------------------------------------------------------------------------------- /backups/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/backups/.gitkeep -------------------------------------------------------------------------------- /backups/backup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Set the date for the backup file 4 | DATE=$(date +"%d_%m_%Y") 5 | 6 | # Define the backup directories 7 | DAILY_DIR=~/Documents/code/blogthedata/backups/daily 8 | WEEKLY_DIR=~/Documents/code/blogthedata/backups/weekly 9 | MONTHLY_DIR=~/Documents/code/blogthedata/backups/monthly 10 | 11 | # Run pg_dump on the remote machine and send the output directly to the host machine 12 | ssh john@198.74.48.211 "sudo -u postgres pg_dump blogthedata" > $DAILY_DIR/blogthedata_db_$DATE.sql 13 | 14 | # Check if it's the 7th day of the month 15 | if [ "$(date '+%d')" = 07 ] 16 | then 17 | cp $DAILY_DIR/blogthedata_db_$DATE.sql $WEEKLY_DIR/blogthedata_db_$DATE.sql 18 | fi 19 | 20 | # Check if it's the 1st day of the month 21 | if [ "$(date '+%d')" = 01 ] 22 | then 23 | cp $DAILY_DIR/blogthedata_db_$DATE.sql $MONTHLY_DIR/blogthedata_db_$DATE.sql 24 | fi 25 | 26 | -------------------------------------------------------------------------------- /backups/cleanup_backups.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Define the backup directories 4 | DAILY_DIR=~/Documents/code/blogthedata/backups/daily 5 | WEEKLY_DIR=~/Documents/code/blogthedata/backups/weekly 6 | MONTHLY_DIR=~/Documents/code/blogthedata/backups/monthly 7 | 8 | # Define the number of daily backups to keep 9 | DAILY_BACKUPS_TO_KEEP=7 10 | 11 | # Define the number of weekly backups to keep 12 | WEEKLY_BACKUPS_TO_KEEP=4 13 | 14 | # Define the number of monthly backups to keep 15 | MONTHLY_BACKUPS_TO_KEEP=12 16 | 17 | # Clean up old daily backups 18 | find $DAILY_DIR -type f -mtime +$DAILY_BACKUPS_TO_KEEP -exec rm {} \; 19 | 20 | # Clean up old weekly backups 21 | find $WEEKLY_DIR -type f -mtime +$(($DAILY_BACKUPS_TO_KEEP * 7 + $WEEKLY_BACKUPS_TO_KEEP)) -exec rm {} \; 22 | 23 | # Clean up old monthly backups 24 | find $MONTHLY_DIR -type f -mtime +$(($DAILY_BACKUPS_TO_KEEP * 7 + $WEEKLY_BACKUPS_TO_KEEP * 4 + $MONTHLY_BACKUPS_TO_KEEP * 30)) -exec rm {} \; 25 | -------------------------------------------------------------------------------- /backups/crontab_schedule.txt: -------------------------------------------------------------------------------- 1 | 0 0 * * * /path/to/script/cleanup_backups.sh 2 | 0 1 * * * /bin/bash /path/to/script/backup.sh 3 | 17 0 * * * /bin/bash ~/documents/blogthedata/backups/uploadToDropbox.sh -------------------------------------------------------------------------------- /backups/daily/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/backups/daily/.gitkeep -------------------------------------------------------------------------------- /backups/monthly/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/backups/monthly/.gitkeep -------------------------------------------------------------------------------- /backups/uploadToDropbox.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Load the environment variables from .env 4 | source $(dirname "$0")/../.env 5 | 6 | # Set the date for the backup file 7 | DATE=$(date +"%d_%m_%Y") 8 | 9 | # Define the backup directory 10 | BACKUP_DIR=$(dirname "$0")/.. 11 | 12 | # Zip up the backups directory 13 | zip -r $BACKUP_DIR/backups.zip $BACKUP_DIR/backups 14 | 15 | # Upload the zipped file to Dropbox 16 | curl -X POST https://content.dropboxapi.com/2/files/upload \ 17 | --header "Authorization: Bearer $DROPBOX_ACCESS_TOKEN" \ 18 | --header "Dropbox-API-Arg: {\"path\": \"/backups_$DATE.zip\",\"mode\": \"overwrite\",\"autorename\": false,\"mute\": false}" \ 19 | --header "Content-Type: application/octet-stream" 20 | -------------------------------------------------------------------------------- /backups/weekly/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/backups/weekly/.gitkeep -------------------------------------------------------------------------------- /blog/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/blog/__init__.py -------------------------------------------------------------------------------- /blog/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import Category, Post, Comment 3 | 4 | 5 | class CommentInline(admin.TabularInline): 6 | model = Comment 7 | extra = 0 8 | 9 | 10 | class PostAdmin(admin.ModelAdmin): 11 | inlines = [CommentInline] 12 | 13 | 14 | admin.site.register(Category) 15 | admin.site.register(Post) 16 | admin.site.register(Comment) 17 | -------------------------------------------------------------------------------- /blog/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class BlogConfig(AppConfig): 5 | name = "blog" 6 | 7 | def ready(self): 8 | import blog.signals 9 | -------------------------------------------------------------------------------- /blog/context_processors.py: -------------------------------------------------------------------------------- 1 | from .models import Category, Post 2 | from django.db.models import Count 3 | from django.urls import resolve 4 | from django.urls import reverse, Resolver404 5 | from django.shortcuts import get_object_or_404 6 | 7 | 8 | def category_renderer(request): 9 | category_qs = Category.objects.annotate(posts_count=Count("post")) 10 | try: 11 | current_category = request.resolver_match.kwargs["slug"] 12 | except (KeyError, AttributeError): 13 | current_category = "None" 14 | return { 15 | "category_qs": category_qs, 16 | "current_category": current_category, 17 | } 18 | 19 | 20 | def breadcrumbs(request): 21 | breadcrumbs = [{"name": "Home", "url": reverse("home")}] 22 | try: 23 | match = resolve(request.path_info) 24 | except Resolver404: 25 | return {"breadcrumbs": []} 26 | if match.url_name == "blog-category": 27 | category = get_object_or_404(Category, slug=match.kwargs["slug"]) 28 | breadcrumbs.append( 29 | { 30 | "name": category.name, 31 | "url": reverse(match.url_name, args=[match.kwargs["slug"]]), 32 | } 33 | ) 34 | elif match.url_name == "post-detail": 35 | post = get_object_or_404(Post, slug=match.kwargs["slug"]) 36 | breadcrumbs.append( 37 | { 38 | "name": post.category.name, 39 | "url": reverse("blog-category", args=[post.category.slug]), 40 | } 41 | ) 42 | breadcrumbs.append( 43 | { 44 | "name": post.title, 45 | "url": reverse(match.url_name, args=[match.kwargs["slug"]]), 46 | } 47 | ) 48 | elif match.url_name == "works-cited": 49 | breadcrumbs.append({"name": "Works Cited", "url": reverse(match.url_name)}) 50 | elif match.url_name == "privacy": 51 | breadcrumbs.append({"name": "Privacy Policy", "url": reverse(match.url_name)}) 52 | return {"breadcrumbs": breadcrumbs} -------------------------------------------------------------------------------- /blog/df.pkl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/blog/df.pkl -------------------------------------------------------------------------------- /blog/feeds.py: -------------------------------------------------------------------------------- 1 | from django.contrib.syndication.views import Feed 2 | from .models import Post 3 | from django.utils.feedgenerator import Atom1Feed 4 | 5 | 6 | class blogFeed(Feed): 7 | title = "blogthedata | Blog" 8 | link = "/rss/" 9 | description = "Latest blog posts from blogthedata" 10 | 11 | def items(self): 12 | return Post.objects.active().order_by("-date_updated")[:5] 13 | 14 | def item_title(self, item): 15 | return item.title 16 | 17 | def item_description(self, item): 18 | return item.metadesc 19 | 20 | def item_link(self, item): 21 | return item.get_absolute_url() 22 | 23 | 24 | class atomFeed(blogFeed): 25 | link = "/atom/" 26 | feed_type = Atom1Feed 27 | subtitle = blogFeed.description 28 | 29 | def item_author_name(self, item): 30 | return item.author.username 31 | 32 | def item_updateddate(self, item): 33 | return item.date_posted 34 | -------------------------------------------------------------------------------- /blog/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from .models import Post, Category, Comment 3 | from .validators import snippet_validator 4 | from django_ckeditor_5.widgets import CKEditor5Widget 5 | 6 | 7 | class PostForm(forms.ModelForm): 8 | class Meta: 9 | model = Post 10 | fields = ( 11 | "title", 12 | "slug", 13 | "category", 14 | "metadesc", 15 | "draft", 16 | "metaimg", 17 | "metaimg_alt_txt", 18 | "metaimg_attribution", 19 | "content", 20 | "snippet", 21 | ) 22 | 23 | widgets = { 24 | "title": forms.TextInput( 25 | attrs={ 26 | "autofocus": True, 27 | } 28 | ), 29 | "slug": forms.TextInput(), 30 | "category": forms.Select(), 31 | "metadesc": forms.TextInput(), 32 | "metaimg_alt_txt": forms.TextInput(), 33 | "metaimg_attribution": forms.TextInput(), 34 | "content": CKEditor5Widget( 35 | attrs={"class": "django_ckeditor_5"}, config_name="extends" 36 | ), 37 | "snippet": CKEditor5Widget( 38 | attrs={"class": "django_ckeditor_5"}, config_name="extends" 39 | ), 40 | } 41 | 42 | def __init__(self, *args, **kwargs): 43 | super(PostForm, self).__init__(*args, **kwargs) 44 | self.fields["category"].choices = Category.objects.all().values_list( 45 | "id", "name" 46 | ) 47 | self.fields["snippet"].validators.append(snippet_validator) 48 | 49 | 50 | class CommentForm(forms.ModelForm): 51 | class Meta: 52 | model = Comment 53 | fields = ["content"] 54 | widgets = { 55 | "content": forms.Textarea( 56 | attrs={"rows": 3, "placeholder": "Leave your thoughts here..."} 57 | ) 58 | } 59 | labels = {"content": ""} 60 | -------------------------------------------------------------------------------- /blog/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/blog/management/__init__.py -------------------------------------------------------------------------------- /blog/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/blog/management/commands/__init__.py -------------------------------------------------------------------------------- /blog/management/commands/import_posts.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | import json 3 | from blog.models import Post, Category 4 | 5 | 6 | class Command(BaseCommand): 7 | help = "Load a list of posts from a JSON file into the Post model" 8 | 9 | def add_arguments(self, parser): 10 | parser.add_argument("json_file", type=str, help="The JSON file to load") 11 | 12 | def handle(self, *args, **kwargs): 13 | json_file = kwargs["json_file"] 14 | with open(json_file, "r") as f: 15 | posts_json = json.load(f) 16 | 17 | for post_data in posts_json: 18 | category = Category.objects.get(slug=post_data["category_slug"]) 19 | 20 | post = Post( 21 | title=post_data["title"], 22 | content=post_data["content"], 23 | author_id=post_data["user_id"], 24 | category=category, 25 | ) 26 | post.save() 27 | 28 | self.stdout.write( 29 | self.style.SUCCESS(f"Successfully imported posts from {json_file}") 30 | ) 31 | -------------------------------------------------------------------------------- /blog/management/commands/recalculate_post_simularities.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from blog.models import Post 3 | from blog.utils import compute_similarity 4 | 5 | class Command(BaseCommand): 6 | help = 'Recalculates the similarity scores for all blog posts.' 7 | 8 | def handle(self, *args, **kwargs): 9 | self.stdout.write(self.style.SUCCESS('Starting similarity score update...')) 10 | 11 | for post in Post.objects.all(): 12 | compute_similarity(post.id) 13 | self.stdout.write(self.style.SUCCESS(f'Updated post {post.id}')) 14 | 15 | self.stdout.write(self.style.SUCCESS('All posts have been updated.')) 16 | -------------------------------------------------------------------------------- /blog/migrations/0002_add_likes.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2021-12-27 01:03 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("blog", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="IpPerson", 14 | fields=[ 15 | ( 16 | "id", 17 | models.AutoField( 18 | auto_created=True, 19 | primary_key=True, 20 | serialize=False, 21 | verbose_name="ID", 22 | ), 23 | ), 24 | ("ip", models.CharField(max_length=100)), 25 | ], 26 | ), 27 | migrations.AddField( 28 | model_name="post", 29 | name="likes", 30 | field=models.ManyToManyField( 31 | blank=True, related_name="post_likes", to="blog.IpPerson" 32 | ), 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /blog/migrations/0003_add_views_to_Post.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2021-12-28 06:42 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("blog", "0002_add_likes"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="post", 14 | name="views", 15 | field=models.ManyToManyField( 16 | blank=True, related_name="post_views", to="blog.IpPerson" 17 | ), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /blog/migrations/0004_add_slug_field.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2022-01-22 06:49 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("blog", "0003_add_views_to_Post"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="post", 14 | name="slug", 15 | field=models.SlugField(blank=True, null=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /blog/migrations/0005_add_images_to_post.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2022-02-07 16:43 2 | 3 | import ckeditor_uploader.fields 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("blog", "0004_add_slug_field"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="post", 15 | name="images", 16 | field=ckeditor_uploader.fields.RichTextUploadingField( 17 | blank=True, null=True 18 | ), 19 | ), 20 | migrations.AlterField( 21 | model_name="post", 22 | name="slug", 23 | field=models.SlugField(blank=True, null=True, unique=True), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /blog/migrations/0006_change_content_field_to_richtextuploading.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2022-03-07 01:29 2 | 3 | import ckeditor_uploader.fields 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("blog", "0005_add_images_to_post"), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name="post", 15 | name="images", 16 | ), 17 | migrations.AlterField( 18 | model_name="post", 19 | name="content", 20 | field=ckeditor_uploader.fields.RichTextUploadingField( 21 | blank=True, null=True 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /blog/migrations/0007_add_snippet_to_post_model.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2022-03-07 15:25 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("blog", "0006_change_content_field_to_richtextuploading"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="post", 14 | name="snippet", 15 | field=models.CharField(blank=True, max_length=500, null=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /blog/migrations/0008_change_snippet_field_to_richtextfield.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2022-03-07 15:58 2 | 3 | import ckeditor.fields 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("blog", "0007_add_snippet_to_post_model"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="post", 15 | name="snippet", 16 | field=ckeditor.fields.RichTextField(blank=True, max_length=500, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /blog/migrations/0009_added_metadescription_to_post_model.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2022-03-07 16:28 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("blog", "0008_change_snippet_field_to_richtextfield"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="post", 14 | name="metadesc", 15 | field=models.CharField(blank=True, max_length=160, null=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /blog/migrations/0010_add_length_constraints_to_text_fields.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2022-03-10 15:51 2 | 3 | import ckeditor.fields 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("blog", "0009_added_metadescription_to_post_model"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="post", 15 | name="metadesc", 16 | field=models.CharField(blank=True, max_length=140, null=True), 17 | ), 18 | migrations.AlterField( 19 | model_name="post", 20 | name="snippet", 21 | field=ckeditor.fields.RichTextField(blank=True, max_length=300, null=True), 22 | ), 23 | migrations.AlterField( 24 | model_name="post", 25 | name="title", 26 | field=models.CharField(max_length=60), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /blog/migrations/0011_add_draft_post_boolean.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2022-03-20 13:18 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("blog", "0010_add_length_constraints_to_text_fields"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="post", 14 | name="draft", 15 | field=models.BooleanField(default=False), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /blog/migrations/0012_add_metaimg.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2022-03-25 03:17 2 | 3 | from django.db import migrations, models 4 | import django_resized.forms 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("blog", "0011_add_draft_post_boolean"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="post", 15 | name="metaimg", 16 | field=django_resized.forms.ResizedImageField( 17 | crop=None, 18 | default="default.webp", 19 | force_format="WEBP", 20 | keep_meta=True, 21 | quality=75, 22 | scale=None, 23 | size=[1920, 1080], 24 | upload_to="post_metaimgs/", 25 | ), 26 | ), 27 | migrations.AddField( 28 | model_name="post", 29 | name="metaimg_mimetype", 30 | field=models.CharField(default="image/jpeg", max_length=20), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /blog/migrations/0013_add_desc_to_category.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2022-04-11 13:44 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("blog", "0012_add_metaimg"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="category", 14 | name="description", 15 | field=models.CharField(default="Hello World", max_length=140), 16 | preserve_default=False, 17 | ), 18 | migrations.AlterField( 19 | model_name="category", 20 | name="name", 21 | field=models.CharField(max_length=50), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /blog/migrations/0014_removed_likes_posts_ipperson.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.13 on 2022-05-25 22:45 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("blog", "0013_add_desc_to_category"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="post", 14 | name="likes", 15 | ), 16 | migrations.RemoveField( 17 | model_name="post", 18 | name="views", 19 | ), 20 | migrations.DeleteModel( 21 | name="Comment", 22 | ), 23 | migrations.DeleteModel( 24 | name="IpPerson", 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /blog/migrations/0015_add_ckeditor_fields.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.13 on 2022-05-28 18:45 2 | 3 | from django.db import migrations 4 | import django_ckeditor_5.fields 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("blog", "0014_removed_likes_posts_ipperson"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="post", 15 | name="content", 16 | field=django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True), 17 | ), 18 | migrations.AlterField( 19 | model_name="post", 20 | name="snippet", 21 | field=django_ckeditor_5.fields.CKEditor5Field( 22 | blank=True, max_length=300, null=True 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /blog/migrations/0016_add_alt_txt_to_meta_img.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.13 on 2022-05-30 21:27 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("blog", "0015_add_ckeditor_fields"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="post", 14 | name="metaimg_alt_txt", 15 | field=models.CharField(default="Meta Image", max_length=60), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /blog/migrations/0017_add_temp_category_link_field.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.13 on 2022-06-01 14:34 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("blog", "0016_add_alt_txt_to_meta_img"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="post", 15 | name="category_link", 16 | field=models.ForeignKey( 17 | null=True, 18 | on_delete=django.db.models.deletion.CASCADE, 19 | to="blog.category", 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /blog/migrations/0018_transfer_categories.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.13 on 2022-06-01 14:35 2 | 3 | from django.db import migrations 4 | 5 | 6 | def link_categories(apps, schema_editor): 7 | Post = apps.get_model("blog", "Post") 8 | Category = apps.get_model("blog", "Category") 9 | for post in Post.objects.all(): 10 | category, created = Category.objects.get_or_create(name=post.category) 11 | post.category_link = category 12 | post.save() 13 | 14 | 15 | class Migration(migrations.Migration): 16 | dependencies = [ 17 | ("blog", "0017_add_temp_category_link_field"), 18 | ] 19 | 20 | operations = [migrations.RunPython(link_categories)] 21 | -------------------------------------------------------------------------------- /blog/migrations/0019_remove_category_rename_category_link.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.13 on 2022-06-01 15:03 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("blog", "0018_transfer_categories"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="post", 14 | name="category", 15 | ), 16 | migrations.RenameField( 17 | model_name="post", 18 | old_name="category_link", 19 | new_name="category", 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /blog/migrations/0020_changed_metaimg_alt_text_length.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.13 on 2022-06-08 14:22 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("blog", "0019_remove_category_rename_category_link"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="post", 15 | name="category", 16 | field=models.ForeignKey( 17 | default=999, 18 | on_delete=django.db.models.deletion.CASCADE, 19 | to="blog.category", 20 | ), 21 | preserve_default=False, 22 | ), 23 | migrations.AlterField( 24 | model_name="post", 25 | name="metaimg_alt_txt", 26 | field=models.CharField(default="Meta Image", max_length=120), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /blog/migrations/0021_increase_post_title_length.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.6 on 2022-07-21 14:55 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("blog", "0020_changed_metaimg_alt_text_length"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="post", 14 | name="title", 15 | field=models.CharField(max_length=100), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /blog/migrations/0022_remove_length_limit_snippet.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.6 on 2022-07-21 16:03 2 | 3 | from django.db import migrations, models 4 | import django_ckeditor_5.fields 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("blog", "0021_increase_post_title_length"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="post", 15 | name="snippet", 16 | field=django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True), 17 | ), 18 | migrations.AlterField( 19 | model_name="post", 20 | name="title", 21 | field=models.CharField(max_length=110), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /blog/migrations/0023_change_default_text_meta_img.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.6 on 2022-07-24 22:09 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("blog", "0022_remove_length_limit_snippet"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="post", 14 | name="metaimg_alt_txt", 15 | field=models.CharField(default="John Solly Headshot", max_length=120), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /blog/migrations/0024_remove_mime_type_from_post.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.6 on 2022-07-26 19:33 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("blog", "0023_change_default_text_meta_img"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="post", 14 | name="metaimg_mimetype", 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /blog/migrations/0025_add_metaimg_attribution.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.6 on 2022-07-27 14:18 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("blog", "0024_remove_mime_type_from_post"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="post", 14 | name="metaimg_attribution", 15 | field=models.CharField(blank=True, max_length=200, null=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /blog/migrations/0026_change_image_type.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1 on 2022-08-13 06:23 2 | 3 | from django.db import migrations 4 | import django_resized.forms 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("blog", "0025_add_metaimg_attribution"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="post", 15 | name="metaimg", 16 | field=django_resized.forms.ResizedImageField( 17 | crop=None, 18 | default="default.webp", 19 | force_format="WEBP", 20 | keep_meta=True, 21 | quality=75, 22 | scale=None, 23 | size=[1920, 1080], 24 | upload_to="post_metaimgs/", 25 | ), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /blog/migrations/0027_bumpCharacterLimit.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2023-01-05 04:20 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("blog", "0026_change_image_type"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="post", 14 | name="metadesc", 15 | field=models.CharField(blank=True, max_length=500, null=True), 16 | ), 17 | migrations.AlterField( 18 | model_name="post", 19 | name="metaimg_alt_txt", 20 | field=models.CharField(default="John Solly Headshot", max_length=500), 21 | ), 22 | migrations.AlterField( 23 | model_name="post", 24 | name="metaimg_attribution", 25 | field=models.CharField(blank=True, max_length=500, null=True), 26 | ), 27 | migrations.AlterField( 28 | model_name="post", 29 | name="title", 30 | field=models.CharField(max_length=250), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /blog/migrations/0028_add_slug_field_to_category.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2023-01-28 19:00 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("blog", "0027_bumpCharacterLimit"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="category", 14 | name="slug", 15 | field=models.SlugField(blank=True, null=True, unique=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /blog/migrations/0029_slugify_category_names.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2023-01-28 19:01 2 | 3 | from django.db import migrations 4 | from django.utils.text import slugify 5 | 6 | 7 | def create_slugs(apps, schema_editor): 8 | Category = apps.get_model("blog", "Category") 9 | for category in Category.objects.all(): 10 | category.slug = slugify(category.name) 11 | category.save() 12 | 13 | 14 | class Migration(migrations.Migration): 15 | dependencies = [ 16 | ("blog", "0028_add_slug_field_to_category"), 17 | ] 18 | 19 | operations = [ 20 | migrations.RunPython(create_slugs), 21 | ] 22 | -------------------------------------------------------------------------------- /blog/migrations/0030_remove_blank_nullable_from_category_slug.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2023-01-28 19:06 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("blog", "0029_slugify_category_names"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="category", 14 | name="slug", 15 | field=models.SlugField(unique=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /blog/migrations/0031_comment.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.6 on 2023-06-07 19:32 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 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ("blog", "0030_remove_blank_nullable_from_category_slug"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="Comment", 17 | fields=[ 18 | ( 19 | "id", 20 | models.AutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("body", models.TextField()), 28 | ("date_posted", models.DateTimeField(auto_now_add=True)), 29 | ( 30 | "author", 31 | models.ForeignKey( 32 | on_delete=django.db.models.deletion.CASCADE, 33 | to=settings.AUTH_USER_MODEL, 34 | ), 35 | ), 36 | ( 37 | "post", 38 | models.ForeignKey( 39 | on_delete=django.db.models.deletion.CASCADE, 40 | related_name="comments", 41 | to="blog.post", 42 | ), 43 | ), 44 | ], 45 | options={ 46 | "ordering": ["date_posted"], 47 | }, 48 | ), 49 | ] 50 | -------------------------------------------------------------------------------- /blog/migrations/0032_remove_comment_body_comment_content.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.6 on 2023-06-07 20:18 2 | 3 | from django.db import migrations 4 | import django_ckeditor_5.fields 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("blog", "0031_comment"), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name="comment", 15 | name="body", 16 | ), 17 | migrations.AddField( 18 | model_name="comment", 19 | name="content", 20 | field=django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /blog/migrations/0033_comment_date_updated.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.2 on 2023-06-28 13:44 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("blog", "0032_remove_comment_body_comment_content"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="comment", 14 | name="date_updated", 15 | field=models.DateTimeField(auto_now=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /blog/migrations/0034_add_default_category.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | def create_default_category(apps, schema_editor): 5 | Category = apps.get_model("blog", "Category") 6 | name = "Uncategorized" 7 | slug = "uncategorized" 8 | try: 9 | Category.objects.get(slug=slug) 10 | except Category.DoesNotExist: 11 | Category.objects.create(name=name, slug=slug, description="Default category") 12 | 13 | 14 | class Migration(migrations.Migration): 15 | dependencies = [ 16 | ("blog", "0033_comment_date_updated"), 17 | ] 18 | 19 | operations = [ 20 | migrations.RunPython(create_default_category), 21 | ] 22 | -------------------------------------------------------------------------------- /blog/migrations/0035_change_default_meta_img.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.3 on 2023-08-30 06:10 2 | 3 | from django.db import migrations 4 | import django_resized.forms 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("blog", "0034_add_default_category"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="post", 15 | name="metaimg", 16 | field=django_resized.forms.ResizedImageField( 17 | crop=None, 18 | default="default.webp", 19 | force_format="WEBP", 20 | keep_meta=True, 21 | quality=75, 22 | scale=None, 23 | size=[1920, 1080], 24 | upload_to="post_metaimgs/", 25 | ), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /blog/migrations/0036_add_default_users.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | from django.core.exceptions import ObjectDoesNotExist 3 | 4 | 5 | def create_default_admin_and_post(apps, schema_editor): 6 | User = apps.get_model("auth", "User") 7 | Profile = apps.get_model("users", "Profile") 8 | 9 | # Create default admin user 10 | try: 11 | admin_user = User.objects.get(username="admin") 12 | except ObjectDoesNotExist: 13 | admin_user = User.objects.create_superuser( 14 | username="admin", password="admin", email="admin@example.com" 15 | ) 16 | Profile.objects.create(user=admin_user) 17 | 18 | # Create Comment only user 19 | try: 20 | comment_only_user = User.objects.get(username="comment_only") 21 | except ObjectDoesNotExist: 22 | comment_only_user = User.objects.create_user( 23 | username="comment_only", 24 | password="comment_only", 25 | email="comment_only@example.com", 26 | ) 27 | Profile.objects.create(user=comment_only_user) 28 | 29 | 30 | class Migration(migrations.Migration): 31 | dependencies = [("blog", "0035_change_default_meta_img"), ("users", "0001_initial")] 32 | 33 | operations = [ 34 | migrations.RunPython(create_default_admin_and_post), 35 | ] 36 | -------------------------------------------------------------------------------- /blog/migrations/0037_create_default_site.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | from django.contrib.sites.models import Site 3 | 4 | 5 | def create_default_site(apps, schema_editor): 6 | Site.objects.get_or_create(name="localhost", domain="localhost:8000") 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("blog", "0036_add_default_users"), 12 | ("sites", "0002_alter_domain_unique"), 13 | ] 14 | 15 | operations = [ 16 | migrations.RunPython(create_default_site), 17 | ] 18 | -------------------------------------------------------------------------------- /blog/migrations/0038_add_default_first_post_and_comment.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | from django.template.defaultfilters import slugify 3 | 4 | 5 | def create_default_post_and_comment(apps, schema_editor): 6 | Post = apps.get_model("blog", "Post") 7 | Category = apps.get_model("blog", "Category") 8 | Comment = apps.get_model("blog", "Comment") 9 | User = apps.get_model("auth", "User") 10 | 11 | # Get default category 12 | category = Category.objects.get(slug="uncategorized") 13 | 14 | # Use Admin user 15 | admin_user = User.objects.get(username="admin") 16 | 17 | # Create a default post 18 | post = Post.objects.create( 19 | title="Learn How to Use Awesome Django Blog", 20 | slug=slugify("Learn How to Use Awesome Django Blog"), 21 | category=category, 22 | content="""In this post, you will learn how to use Awesome Django Blog. 23 |

Login and Edit Your Profile

24 | In order to add and edit posts, you need to be logged in. You can login by clicking the 25 | login button on the top right corner of the page. The default supersuer account is admin and the 26 | default password is admin. To change your password and profile photo, you can click the 27 | profile button on the top right corner of the page after you login. When you change your 28 | profile photo, the image will be stored in awesome-django-blog/media/profile_pics. 29 |

Create a New Post

30 | After you login, you can click the "New Post" button on the top right corner of the page. 31 |

Upload Images

32 | You can upload images by either copy/pasting into the editor or by clicking the image 33 | button on the toolbar. You can also upload images by dragging and dropping them into the 34 | editor. The post metaimage will be stored in awesome-django-blog/media/post_metaimgs. 35 | Any images added to the editor will be stored in awesome-django-blog/media/django_ckeditor_5. 36 | """, 37 | snippet="

Learn how to use all the features including creating posts, adding comments, and creating your first profile!

", 38 | author=admin_user, 39 | metaimg_alt_txt="Default Image Alt Text", 40 | ) 41 | 42 | # Create a default comment 43 | # Use comment_only user 44 | comment_only_user = User.objects.get(username="comment_only") 45 | 46 | Comment.objects.create( 47 | post=post, 48 | author=comment_only_user, 49 | content="This is an example of a comment. The default comment_only user can only create, update, and delete their own comments, not posts.", 50 | ) 51 | 52 | 53 | class Migration(migrations.Migration): 54 | dependencies = [ 55 | ("blog", "0037_create_default_site"), 56 | ] 57 | 58 | operations = [ 59 | migrations.RunPython(create_default_post_and_comment), 60 | ] 61 | -------------------------------------------------------------------------------- /blog/migrations/0039_add_date_updated_to_Post.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ("blog", "0038_add_default_first_post_and_comment"), 7 | ] 8 | 9 | operations = [ 10 | migrations.AddField( 11 | model_name="post", 12 | name="date_updated", 13 | field=models.DateTimeField(null=True), # Temporarily allow null values 14 | ), 15 | ] 16 | -------------------------------------------------------------------------------- /blog/migrations/0040_populate_date_updated_with_date_posted.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | def copy_date_posted_to_date_updated(apps, schema_editor): 5 | Post = apps.get_model("blog", "Post") 6 | for post in Post.objects.filter(date_updated__isnull=True): 7 | post.date_updated = post.date_posted 8 | post.save() 9 | 10 | 11 | class Migration(migrations.Migration): 12 | dependencies = [ 13 | ( 14 | "blog", 15 | "0039_add_date_updated_to_Post", 16 | ) 17 | ] 18 | 19 | operations = [ 20 | migrations.RunPython(copy_date_posted_to_date_updated), 21 | ] 22 | -------------------------------------------------------------------------------- /blog/migrations/0041_disallow_null_values_date_updated.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ( 7 | "blog", 8 | "0040_populate_date_updated_with_date_posted", 9 | ) 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="post", 15 | name="date_updated", 16 | field=models.DateTimeField( 17 | auto_now=True, null=False 18 | ), # Now disallow null values 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /blog/migrations/0042_add_simularity_model.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.5 on 2023-11-08 23:37 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("blog", "0041_disallow_null_values_date_updated"), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="Similarity", 15 | fields=[ 16 | ( 17 | "id", 18 | models.AutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ("score", models.FloatField()), 26 | ( 27 | "post1", 28 | models.ForeignKey( 29 | on_delete=django.db.models.deletion.CASCADE, 30 | related_name="similarities1", 31 | to="blog.post", 32 | ), 33 | ), 34 | ( 35 | "post2", 36 | models.ForeignKey( 37 | on_delete=django.db.models.deletion.CASCADE, 38 | related_name="similarities2", 39 | to="blog.post", 40 | ), 41 | ), 42 | ], 43 | ), 44 | migrations.AddConstraint( 45 | model_name="similarity", 46 | constraint=models.UniqueConstraint( 47 | fields=("post1", "post2"), name="unique_pair" 48 | ), 49 | ), 50 | ] 51 | -------------------------------------------------------------------------------- /blog/migrations/0043_update_simularities_on_existing_posts.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | from blog.utils import compute_similarity 3 | 4 | 5 | def update_similarity(apps, schema_editor): 6 | Post = apps.get_model("blog", "Post") 7 | for post in Post.objects.all(): 8 | compute_similarity(post.id) 9 | 10 | 11 | class Migration(migrations.Migration): 12 | dependencies = [ 13 | ("blog", "0042_add_simularity_model"), 14 | ] 15 | 16 | operations = [ 17 | migrations.RunPython(update_similarity), 18 | ] 19 | -------------------------------------------------------------------------------- /blog/migrations/0044_alter_post_metaimg.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.2 on 2024-11-08 14:32 2 | 3 | import django_resized.forms 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('blog', '0043_update_simularities_on_existing_posts'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='post', 16 | name='metaimg', 17 | field=django_resized.forms.ResizedImageField(crop=None, default='default.webp', force_format='WEBP', keep_meta=True, quality=75, scale=None, size=[1920, 1080], upload_to='post_metaimgs'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /blog/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/blog/migrations/__init__.py -------------------------------------------------------------------------------- /blog/signals.py: -------------------------------------------------------------------------------- 1 | from django.db.models.signals import post_save 2 | from django.dispatch import receiver 3 | from .utils import compute_similarity 4 | from .models import Post 5 | 6 | 7 | @receiver(post_save, sender=Post) 8 | def trigger_similarity_computation(sender, instance, **kwargs): 9 | compute_similarity(instance.id) 10 | -------------------------------------------------------------------------------- /blog/templates/404.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | Page Not Found 9 | 10 | 11 | 12 |
13 |
14 | 404 NOT FOUND 15 |
16 |
17 |
18 | scarecrow image 19 |
20 |
21 |

I have bad news for you...

22 |

23 | The page you are looking for might be removed or is temporarily 24 | unavailable 25 |

26 | BACK TO HOMEPAGE 27 |
28 |
29 |
30 |

31 | created by 32 | onlymanu-devChallenge.io 33 |

34 |
35 |
36 | 37 | 38 | -------------------------------------------------------------------------------- /blog/templates/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 16 | 17 | Server Error 18 | 19 | 20 | 21 |
22 |
23 | 500 SERVER ERROR 24 |
25 |
26 |
27 |

Oops! Something went wrong.

28 |

29 | We're experiencing some technical issues. Please try again later. 30 |

31 | BACK TO HOMEPAGE 32 |
33 |
34 |
35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /blog/templates/blog/all_posts.html: -------------------------------------------------------------------------------- 1 | {% extends "blog/base.html" %} 2 | {% load static %} 3 | 4 | {% block head %} 5 | 6 | {% endblock head %} 7 | 8 | {% block content %} 9 |
10 |

All Posts! {% if page_obj.number > 1 %} (Page {{ page_obj.number }}) {% endif %}

11 | {% include "blog/parts/posts_paginated.html" %} 12 |
13 | {% endblock content %} -------------------------------------------------------------------------------- /blog/templates/blog/categories.html: -------------------------------------------------------------------------------- 1 | {% extends "blog/base.html" %} 2 | {% load static %} 3 | {% block head %} 4 | 5 | {% endblock head %} 6 | 7 | {% block content %} 8 |
9 |

{{ category.name }}

10 |

{{ category.description }}

11 | {% include "blog/parts/posts.html" %} 12 |
13 | {% endblock content %} -------------------------------------------------------------------------------- /blog/templates/blog/comment/add_comment.html: -------------------------------------------------------------------------------- 1 | {% block content %} 2 |
3 | {% if user.is_authenticated %} 4 |

Comment

5 |
7 | {% csrf_token %} 8 | {{ comment_form.media }} 9 | {{ comment_form }} 10 | 11 |
12 | {% else %} 13 | Login to Comment 14 | {% endif %} 15 |
16 | {% endblock content %} -------------------------------------------------------------------------------- /blog/templates/blog/comment/comment_item.html: -------------------------------------------------------------------------------- 1 |
  • 2 |
    3 | 4 | {{ comment.author }} 5 | 6 | {{ comment.date_posted|date:"F d, Y" }} 7 | 8 | 9 |
    10 |
    {{ comment.content|safe|linebreaksbr }}
    11 | {% if comment.author == request.user %} 12 | 13 | Edit 14 |
    17 | 20 |
    21 |
    22 | {% endif %} 23 |
  • -------------------------------------------------------------------------------- /blog/templates/blog/comment/update_comment.html: -------------------------------------------------------------------------------- 1 | {% extends 'blog/post/post_detail.html' %} 2 | 3 | {% block content %} 4 |

    Update Comment

    5 |
    6 | {% csrf_token %} 7 | {{ form.as_p }} 8 | 9 |
    10 | {% endblock content %} 11 | -------------------------------------------------------------------------------- /blog/templates/blog/home.html: -------------------------------------------------------------------------------- 1 | {% extends "blog/base.html" %} {% load static %} 2 | 3 | {% block content %} 4 |
    5 | {% include "blog/parts/about_me_card.html" %} 6 |
    7 | 8 |
    9 |

    Latest Posts!

    10 | {% include "blog/parts/posts.html" %} 11 |
    12 | {% endblock content %} -------------------------------------------------------------------------------- /blog/templates/blog/parts/about_me_card.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 |
    3 |
    4 |
    5 | John Solly Profile Picture 7 |
    8 | John Solly Profile Picture 9 |
    10 |
    11 |
    12 |

    John Solly

    13 | 32 |

    33 | Hi, I'm John, a Software Engineer with a decade of experience building, deploying, and maintaining cloud-native geospatial solutions. 34 | I currently serve as a senior software engineer at HazardHub (A Guidewire Offering), where I work on a variety of infrastructure and application development projects. 35 |

    36 |

    37 | Throughout my career, I've built applications on platforms like Esri and Mapbox while also leveraging 38 | open-source GIS technologies such as OpenLayers, GeoServer, and GDAL. This blog is 39 | where I share useful articles with the GeoDev community. Check out my 40 | portfolio to see my latest work! 41 |

    42 |
    43 |
    44 |
    -------------------------------------------------------------------------------- /blog/templates/blog/parts/breadcrumbs.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /blog/templates/blog/parts/contact_me.html: -------------------------------------------------------------------------------- 1 |
    2 |

    Let's Build Something Together?

    3 |

    4 | Seeking a geospatial software engineer to enhance your development team? Let's connect to discuss how we can 5 | collaborate and drive success in your organization's projects. Reach out, and let's explore the potential together! 6 |

    7 | 14 |
    -------------------------------------------------------------------------------- /blog/templates/blog/parts/footer.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /blog/templates/blog/parts/gpt_title_slug_generation.html: -------------------------------------------------------------------------------- 1 |

    2 | Title Characters (Shoot for 50-60 characters): 3 |

    4 |

    5 | Slug Characters (Shoot for 3-5 catchy words seperated by hyphen): 7 |

    8 |

    9 | Metadesc Characters (Shoot for 110-160 characters): 10 |

    11 | 12 | 13 |
    14 | {% csrf_token %} 15 |
    16 | 19 | 23 | 26 |
    27 | 29 |
    -------------------------------------------------------------------------------- /blog/templates/blog/parts/pagination.html: -------------------------------------------------------------------------------- 1 | {% if is_paginated %} 2 | {% if page_obj.has_previous %} 3 | First 4 | 7 | {% endif %} 8 | 9 | {% for num in page_obj.paginator.page_range %} 10 | {% if page_obj.number == num %} 11 | {{ num }} 12 | {% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %} {{ num }} 14 | {% endif %} 15 | {% endfor %} 16 | {% if page_obj.has_next %} 17 | 20 | 21 | Last 22 | 23 | {% endif %} 24 | {% endif %} 25 | -------------------------------------------------------------------------------- /blog/templates/blog/parts/post_card.html: -------------------------------------------------------------------------------- 1 | {% load post_utils %} 2 | {% load static %} 3 | {% load image_utils %} 4 | 5 |
    6 | 7 |
    8 |
    9 |
    10 |
    11 | {{ post.metaimg_alt_txt }} 13 |
    {{ post.metaimg_alt_txt }}
    14 |
    15 |
    16 |
    17 |
    18 |
    19 |
    20 |

    {{ post.title }} 21 | {% if post.draft %} 22 | (Draft) 23 | {% endif %} 24 |

    25 |
    26 | 38 |
    39 | {{ post.snippet|safe }} 40 |
    41 |
    42 |
    43 | Comment Icon 44 |

    {{ post.comments.count }}

    45 |
    46 |
    47 |
    48 |
    49 |
    50 |
    51 |
    -------------------------------------------------------------------------------- /blog/templates/blog/parts/posts.html: -------------------------------------------------------------------------------- 1 | {% for post in posts %} 2 | {% include "blog/parts/post_card.html" %} 3 | {% if forloop.last %} 4 | {% if page_obj.has_next %} 5 | {% if not page_obj.number|divisibleby:"3" %} 6 | 7 | {% endif %} 8 | {% if page_obj.number|divisibleby:"3" %} 9 |
    10 | 11 |
    12 | {% endif %} 13 | {% endif %} 14 | {% endif %} 15 | {% endfor %} 16 | -------------------------------------------------------------------------------- /blog/templates/blog/parts/posts_paginated.html: -------------------------------------------------------------------------------- 1 | {% for post in posts %} 2 | {% load static %} 3 | {% load post_utils %} 4 | {% include 'blog/parts/post_card.html' %} 5 | {% endfor %} -------------------------------------------------------------------------------- /blog/templates/blog/parts/sidebar.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /blog/templates/blog/post/add_post.html: -------------------------------------------------------------------------------- 1 | {% extends "blog/base.html" %} {% load static %} 2 | 3 | {% block content %} 4 |

    Add Post

    5 | 6 |
    7 |
    8 |
    9 | {% csrf_token %} {{ form.media }} {{ form }} 10 | 13 | 14 | {% endblock content %} 15 |
    16 |
    -------------------------------------------------------------------------------- /blog/templates/blog/post/edit_post.html: -------------------------------------------------------------------------------- 1 | {% extends "blog/base.html" %} 2 | {% load static %} 3 | 4 | {% block head %} 5 | 6 | 16 | {% endblock head %} 17 | 18 | 19 | 20 | {% block content %} 21 |

    Update Post

    22 | 23 | {% include 'blog/parts/gpt_title_slug_generation.html' %} 24 | 25 |
    26 |
    27 |
    28 | {% csrf_token %} {{ form.media }} {{ form }} 29 | 32 | 33 | {% endblock content %} 34 |
    35 |
    -------------------------------------------------------------------------------- /blog/templates/blog/post/post_confirm_delete.html: -------------------------------------------------------------------------------- 1 | {% extends "blog/base.html" %} 2 | {% block content %} 3 |
    4 |
    5 | {% csrf_token %} 6 |
    7 | Delete Post 8 |

    Are you sure you want to delete the post "{{ object.title }}"?

    9 |
    10 |
    11 | 14 | Cancel 15 |
    16 |
    17 |
    18 | {% endblock %} -------------------------------------------------------------------------------- /blog/templates/blog/privacy.html: -------------------------------------------------------------------------------- 1 | {% extends "blog/base.html" %} 2 | {% block content %} 3 |
    4 |

    Privacy Policy

    5 |

    Thank you for visiting my personal website. This privacy policy explains how I protect your privacy while you use 6 | my website.

    7 | 8 |

    Information collection

    9 |

    I do not collect any user information, either directly or indirectly.

    10 | 11 |

    Cookies

    12 |

    I do not use any cookies on my website.

    13 | 14 |

    JavaScript and CSS

    15 |

    All JavaScript and CSS is self-hosted, and I have implemented a content security policy 17 | to ensure unsafe scripts cannot be executed

    18 | 19 |

    Third-party links

    20 |

    My website may include links to third-party websites. I am not responsible for the privacy practices of these 21 | websites. However, I make every effort to ensure that all third-party links include the 22 | rel="noopener noreferrer" attributes. This helps protect your privacy by preventing the third-party 23 | website from accessing any information about you or your browsing session. 24 |

    25 | 26 |

    Children's privacy

    27 |

    My website is not intended for children.

    28 | 29 |

    Updates

    30 |

    I reserve the right to make changes to this privacy policy. If I make any material changes, I will notify users by 31 | updating the policy here

    32 | 33 |

    Contact information

    34 |

    If you have any questions or concerns about my privacy policy, please contact 36 | me. 37 |

    38 |
    39 | {% endblock content %} -------------------------------------------------------------------------------- /blog/templates/blog/search_posts.html: -------------------------------------------------------------------------------- 1 | {% extends "blog/base.html" %} 2 | {% block content %} 3 | 4 |
    5 | {% if searched %} 6 |

    You searched for '{{ searched }}'

    7 |

    {{ num_results }} results found

    8 | {% else %} 9 |

    You didn't search for anything!

    10 | {% endif %} 11 | 12 | {% if posts %} 13 | {% include "blog/parts/posts_paginated.html" %} {% else %} 14 |

    I didn't find any posts matching that search 🤷‍♂️

    15 | {% endif %} 16 |
    17 | {% include "blog/parts/pagination.html" %} 18 | {% endblock content %} -------------------------------------------------------------------------------- /blog/templates/blog/security.txt: -------------------------------------------------------------------------------- 1 | Contact: mailto:john@blogthedata.com 2 | Expires: 2023-01-01T09:30:00.000Z 3 | Encryption: https://blogthedata.com/pgp-key.txt 4 | Preferred-Languages: en 5 | Canonical: https://blogthedata.com/.well-known/security.txt -------------------------------------------------------------------------------- /blog/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/blog/templatetags/__init__.py -------------------------------------------------------------------------------- /blog/templatetags/image_utils.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.conf import settings 3 | from django.utils.safestring import mark_safe 4 | import re 5 | import logging 6 | 7 | logger = logging.getLogger(__name__) 8 | register = template.Library() 9 | 10 | @register.filter 11 | def fix_image_urls(content): 12 | """ 13 | Takes content with image paths and converts them to full URLs based on storage backend. 14 | Handles relative paths, full mediafiles paths, and CloudFront URLs. 15 | 16 | When USE_CLOUD=True: 17 | "post_imgs/image.png" → "https://.cloudfront.net/media/post_imgs/image.png" 18 | "/mediafiles/post_imgs/image.png" → "https://.cloudfront.net/media/post_imgs/image.png" 19 | "https://.cloudfront.net/media/post_imgs/image.png" → "https://.cloudfront.net/media/post_imgs/image.png" 20 | 21 | When USE_CLOUD=False: 22 | "post_imgs/image.png" → "/mediafiles/post_imgs/image.png" 23 | "/mediafiles/post_imgs/image.png" → "/mediafiles/post_imgs/image.png" 24 | "https://.cloudfront.net/media/post_imgs/image.png" → "/mediafiles/post_imgs/image.png" 25 | """ 26 | if not content: 27 | return content 28 | 29 | # Pattern to match all possible formats including full CloudFront URLs 30 | cloudfront_pattern = re.escape(f"{settings.STATIC_HOST}/{settings.MEDIA_LOCATION}/") 31 | pattern = r'src="(?:' + cloudfront_pattern + r'|/mediafiles/|https://[^/]+/media/)?((?:post_imgs|uploads)/[^"]*)"' 32 | 33 | def replace_url(match): 34 | # Get the path part without any prefix 35 | path = match.group(1) 36 | logger.debug(f"Fixing image URL for path: {path}") 37 | 38 | if settings.USE_CLOUD: 39 | url = f"{settings.STATIC_HOST}/{settings.MEDIA_LOCATION}/{path}" 40 | logger.debug(f"Using CloudFront URL: {url}") 41 | return f'src="{url}"' 42 | else: 43 | url = f"/mediafiles/{path}" 44 | logger.debug(f"Using local URL: {url}") 45 | return f'src="{url}"' 46 | 47 | fixed_content = re.sub(pattern, replace_url, content) 48 | return mark_safe(fixed_content) 49 | 50 | @register.simple_tag 51 | def get_image_url(image_field): 52 | """ 53 | Returns the correct URL for an image field based on storage backend: 54 | - When USE_CLOUD=True: Use CloudFront URL with /media/ 55 | - When USE_CLOUD=False: Use /mediafiles/ 56 | """ 57 | if not image_field: 58 | return "" 59 | 60 | if settings.USE_CLOUD: 61 | return f"{settings.STATIC_HOST}/{settings.MEDIA_LOCATION}/{image_field.name}" 62 | else: 63 | return f"/mediafiles/{image_field.name}" 64 | -------------------------------------------------------------------------------- /blog/templatetags/post_utils.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | import readtime 3 | import html 4 | 5 | register = template.Library() 6 | 7 | 8 | def read(input_html): 9 | if not input_html: 10 | return "0 minutes" 11 | 12 | clean_html = html.escape(input_html) 13 | return readtime.of_html(clean_html) 14 | 15 | 16 | register.filter("readtime", read) 17 | -------------------------------------------------------------------------------- /blog/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | from django.conf import settings 3 | from django.conf.urls.static import static 4 | from django.views.decorators.cache import cache_page 5 | from .feeds import blogFeed, atomFeed 6 | from .views import ( 7 | HomeView, 8 | CreatePostView, 9 | CreateCommentView, 10 | CommentUpdateView, 11 | CommentDeleteView, 12 | PostDetailView, 13 | PostUpdateView, 14 | PostDeleteView, 15 | CategoryView, 16 | AllPostsView, 17 | SearchView, 18 | StatusView, 19 | generate_gpt_input_value, 20 | answer_question_with_GPT, 21 | ) 22 | 23 | urlpatterns = [ 24 | path("status/", cache_page(60)(StatusView.as_view()), name="status"), 25 | path("answer-with-gpt/", answer_question_with_GPT, name="answer-with-gpt"), 26 | path("generate-with-gpt/", generate_gpt_input_value, name="generate-with-gpt"), 27 | path("rss/", blogFeed(), name="rss"), 28 | path("atom/", atomFeed(), name="atom"), 29 | path("ckeditor5/", include("django_ckeditor_5.urls")), 30 | path("", HomeView.as_view(), name="home"), 31 | path("all-posts/", AllPostsView.as_view(), name="all-posts"), 32 | path("post/new", CreatePostView.as_view(), name="post-create"), 33 | path("post//", PostDetailView.as_view(), name="post-detail"), 34 | path("post//update", PostUpdateView.as_view(), name="post-update"), 35 | path("post//delete", PostDeleteView.as_view(), name="post-delete"), 36 | path("category//", CategoryView.as_view(), name="blog-category"), 37 | path("search/", SearchView.as_view(), name="blog-search"), 38 | path( 39 | "post//comment/new", 40 | CreateCommentView.as_view(), 41 | name="comment-create", 42 | ), 43 | path( 44 | "comment//update", 45 | CommentUpdateView.as_view(), 46 | name="comment-update", 47 | ), 48 | path( 49 | "comment//delete", 50 | CommentDeleteView.as_view(), 51 | name="comment-delete", 52 | ), 53 | ] 54 | if settings.DEBUG: # pragma: no cover 55 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 56 | -------------------------------------------------------------------------------- /blog/validators.py: -------------------------------------------------------------------------------- 1 | import re 2 | from django.core.exceptions import ValidationError 3 | from bs4 import BeautifulSoup 4 | 5 | link_media_regex = re.compile(r"]*>.*?|`[^`]*`|\*\*[^*]*\*\*") 6 | 7 | 8 | def snippet_validator(value, max_length=400): 9 | # Use BeautifulSoup to parse HTML and extract text content 10 | soup = BeautifulSoup(value, "html.parser") 11 | plain_text = soup.get_text(strip=True) 12 | plain_text_without_links = link_media_regex.sub("", plain_text) 13 | 14 | if len(plain_text_without_links) > max_length: 15 | raise ValidationError( 16 | f"The snippet cannot have more than {max_length} characters (excluding links and media)." 17 | ) 18 | 19 | return True 20 | -------------------------------------------------------------------------------- /config/.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | plugins = django_coverage_plugin 3 | data_file = ./coverage/.coverage 4 | omit = 5 | */tests/* 6 | */__init__.py 7 | app/app/settings/* 8 | app/blog/templates/blog/pgp-key.txt 9 | app/blog/templates/blog/parts/posts.html 10 | app/blog/templates/blog/base.html 11 | 12 | [lcov] 13 | output = ./coverage/lcov.info -------------------------------------------------------------------------------- /config/.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/charliermarsh/ruff-pre-commit 3 | # Ruff version. 4 | rev: "v0.0.246" 5 | hooks: 6 | - id: ruff 7 | args: ["--config", "config/pyproject.toml"] 8 | -------------------------------------------------------------------------------- /config/awesome-django-blog-MacOS.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": ".." 5 | } 6 | ], 7 | "settings": { 8 | "python.analysis.autoImportCompletions": false, 9 | "python.terminal.activateEnvInCurrentTerminal": true, 10 | "python.defaultInterpreterPath": "~/code/awesome-django-blog/.venv/bin/python3", 11 | "python.terminal.activateEnvironment": true, 12 | "editor.rulers": [79, 120], 13 | "files.exclude": { 14 | "**/__pycache__": true, 15 | "**/.git": false, 16 | "**/*.pyc": { 17 | "when": "$(basename).py" 18 | } 19 | }, 20 | "git.autofetch": true, 21 | "git.confirmSync": false, 22 | "git.enableSmartCommit": true, 23 | "explorer.confirmDragAndDrop": false, 24 | "explorer.confirmDelete": false, 25 | "editor.minimap.enabled": false, 26 | "window.autoDetectColorScheme": true, 27 | "terminal.integrated.enableMultiLinePasteWarning": false, 28 | "diffEditor.ignoreTrimWhitespace": false, 29 | "python.testing.unittestArgs": ["-v", "-s", "./app", "-p", "test*.py"], 30 | "python.testing.pytestEnabled": true, 31 | "[xml]": { 32 | "editor.defaultFormatter": "redhat.vscode-xml" 33 | }, 34 | "[html]": { 35 | "editor.defaultFormatter": "vscode.html-language-features" 36 | }, 37 | "editor.formatOnSave": false, 38 | "[python]": { 39 | "editor.defaultFormatter": "charliermarsh.ruff" 40 | }, 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /config/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | exclude = [ 3 | "apps.py", 4 | "*/settings/*", 5 | ] 6 | ignore = ["E402", "E501", "F403"] 7 | 8 | # F403 is ignored because I use * imports in settings files 9 | # E402 is ignored because If I change the order of imports, I get an error when running tests 10 | # E501 is ignored because sometimes I have long lines that I don't want to split 11 | -------------------------------------------------------------------------------- /config/vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Python: Current File", 6 | "type": "python", 7 | "request": "launch", 8 | "program": "${file}", 9 | "justMyCode": true 10 | }, 11 | { 12 | "name": "Django_Start + Livereload", 13 | "type": "python", 14 | "request": "launch", 15 | "program": "~/Documents/code/awesome-django-blog/app/manage.py", 16 | "django": true, 17 | "justMyCode": true, 18 | "args": ["runserver"], 19 | "preLaunchTask": "LiveReload", 20 | "postDebugTask": "StopTasks" 21 | }, 22 | { 23 | "name": "Launch_Chrome", 24 | "request": "launch", 25 | "type": "chrome", 26 | "runtimeArgs": ["--incognito"], 27 | "url": "http://127.0.0.1:8000/" 28 | } 29 | ], 30 | "compounds": [ 31 | { 32 | "name": "Runserver + Livereload + Chrome", 33 | "configurations": ["Django_Start", "Launch_Chrome"], 34 | "stopAll": true 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /config/vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": ".." 5 | } 6 | ], 7 | "settings": { 8 | "python.analysis.autoImportCompletions": false, 9 | "python.terminal.activateEnvInCurrentTerminal": true, 10 | "python.defaultInterpreterPath": "~/Documents/code/awesome-django-blog/.venv/bin/python3", 11 | "python.terminal.activateEnvironment": true, 12 | "editor.rulers": [79, 120], 13 | "files.exclude": { 14 | "**/__pycache__": true, 15 | "**/.git": false, 16 | "**/*.pyc": { 17 | "when": "$(basename).py" 18 | } 19 | }, 20 | "git.autofetch": true, 21 | "git.confirmSync": false, 22 | "git.enableSmartCommit": true, 23 | "explorer.confirmDragAndDrop": false, 24 | "explorer.confirmDelete": false, 25 | "editor.minimap.enabled": false, 26 | "window.autoDetectColorScheme": true, 27 | "terminal.integrated.enableMultiLinePasteWarning": false, 28 | "diffEditor.ignoreTrimWhitespace": false, 29 | "python.testing.unittestArgs": ["-v", "-s", "./app", "-p", "test*.py"], 30 | "python.testing.pytestEnabled": true, 31 | "editor.fontSize": 18, 32 | "[xml]": { 33 | "editor.defaultFormatter": "redhat.vscode-xml" 34 | }, 35 | "[html]": { 36 | "editor.defaultFormatter": "vscode.html-language-features" 37 | }, 38 | "editor.formatOnSave": true 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /config/vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "LiveReload", 6 | "type": "shell", 7 | "command": "${config:python.defaultInterpreterPath}", 8 | "args": ["manage.py", "livereload"], 9 | "options": { 10 | "cwd": "${workspaceFolder}/app" 11 | }, 12 | "isBackground": true, 13 | "problemMatcher": { 14 | "pattern": { 15 | "regexp": "." 16 | }, 17 | "background": { 18 | "activeOnStart": true, 19 | "beginsPattern": ".", 20 | "endsPattern": "." 21 | } 22 | } 23 | }, 24 | { 25 | "label": "StopTasks", 26 | "command": "echo ${input:terminate}", 27 | "type": "shell" 28 | } 29 | ], 30 | "inputs": [ 31 | { 32 | "id": "terminate", 33 | "type": "command", 34 | "command": "workbench.action.tasks.terminate", 35 | "args": "terminateAll" 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /coverage/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/coverage/.gitkeep -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Welcome to the Django Blog App! We welcome contributions from anyone who would like to help improve the app. Before getting started, please take a moment to review this guide to learn about how you can contribute. 4 | 5 | ## Project Description 6 | 7 | The Django Blog App is a fully functional blogging platform built with the Django web framework. It offers features such as blog post and comment management, category creation, and user authentication and authorization. The app is designed to be easily integrated into any existing Django project or used as a standalone app. We hope you find this app useful and welcome any contributions or suggestions for improvement. 8 | 9 | ## Issue Submission 10 | 11 | If you find any bugs, issues or errors while using the app, please submit a new issue through our issue tracker. When submitting an issue, please provide as much information as possible, including the steps to reproduce the issue, the expected behavior, and the actual behavior. We also provide templates for submitting bug reports and feature requests to help you get started. 12 | 13 | ## Contributions 14 | 15 | We welcome any contributions to the project, including bug fixes, feature requests, and general improvements. When contributing, please make sure to follow our coding standards, which include using ruff for code linting and code formatting. Additionally, we suggest using conventional commits for commit messages to maintain consistency in our commit history. 16 | 17 | ## Testing 18 | 19 | We use Pytest and coverage for testing. You can find the tests in the django_project/tests folder. We recommend installing the django-coverage plugin (included in requirements.txt) for more detailed test coverage reporting. 20 | 21 | ## Setting up the Development Environment 22 | 23 | To set up the development environment, please follow the instructions in the readme.md file. The instructions include information about installing the necessary dependencies and getting started with the app. 24 | 25 | ## Communication and Feedback 26 | 27 | We encourage open communication and feedback among contributors. If you have any questions or feedback, please do not hesitate to reach out to us via email or through the issue tracker. We also encourage all contributors to be respectful and professional in their interactions with other contributors. 28 | 29 | 30 | Want to work on this with me? DM me 31 | 32 | `@_jsolly` 33 | 34 | ## Step 1 35 | 36 | - **Option 1** 37 | 38 | - 🍴 Fork this repo! 39 | 40 | - **Option 2** 41 | - 👯 Clone to your local machine using: 42 | `https://github.com/jsolly/awesome-django-blog.git` 43 | 44 | ## Step 2 45 | 46 | - **HACK AWAY!** 🔨🔨🔨 47 | 48 | ## Step 3 49 | 50 | - 🔃 Create a new pull request using: 51 | 52 | `https://github.com/jsolly/awesome-django-blog/compare`. 53 | -------------------------------------------------------------------------------- /docs/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | Current | :white_check_mark: | 8 | 9 | ## Reporting a Vulnerability 10 | 11 | Due to the nature of this being a personal blog site, security breaches are typically low severity and priority. If you do see a way security could be improved, please reach out to me on Twitter [@_jsolly]([url](https://twitter.com/_jsolly)) 12 | 13 | Expect a response within 48 hours. 14 | -------------------------------------------------------------------------------- /docs/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | 5 | ## Changes Made 6 | 7 | - [ ] Added new feature 8 | - [ ] Fixed bug 9 | - [ ] Refactored code 10 | - [ ] Other (please describe): 11 | 12 | ## Test Plan 13 | 14 | 15 | ## Screenshots (if appropriate) 16 | 17 | 18 | ## Checklist 19 | 20 | - [ ] I have read the [contributing guidelines](link_to_contributing_guidelines) 21 | - [ ] I have added tests to cover my changes and they all pass in addition to the existing tests. 22 | - [ ] I have added documentation for my changes (if appropriate) 23 | 24 | ## Additional Information 25 | 26 | -------------------------------------------------------------------------------- /logs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/logs/.gitkeep -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | import os 4 | from pathlib import Path 5 | from dotenv import load_dotenv 6 | from django.core.management.utils import get_random_secret_key 7 | 8 | if "DYNO" not in os.environ: 9 | load_dotenv() 10 | 11 | 12 | def setup_env(): 13 | env_example_path = Path(".") / ".env.example" 14 | env_path = Path(".") / ".env" 15 | 16 | if env_path.exists(): 17 | print(".env file already exists. Exiting.") 18 | return 19 | 20 | if not env_example_path.exists(): 21 | print(".env.example file not found. Exiting.") 22 | return 23 | 24 | secret_key = get_random_secret_key() 25 | 26 | with open(env_example_path, "r") as example_file: 27 | env_content = example_file.readlines() 28 | 29 | with open(env_path, "w") as env_file: 30 | for line in env_content: 31 | key_value = line.strip().split("=", 1) 32 | if key_value[0] == "SECRET_KEY": 33 | env_file.write(f"SECRET_KEY={secret_key}\n") 34 | else: 35 | env_file.write( 36 | key_value[0] + "=" + key_value[1].split("#")[0].strip() + "\n" 37 | ) 38 | 39 | print(".env file created with a new SECRET_KEY and other values from .env.example.") 40 | 41 | 42 | # Make sure DJANGO_SETTINGS_MODULE is added to .env file 43 | def main(): 44 | if "setup_env" in sys.argv: 45 | setup_env() 46 | sys.argv.remove("setup_env") 47 | 48 | try: 49 | from django.core.management import execute_from_command_line 50 | except ImportError as exc: 51 | raise ImportError( 52 | "Couldn't import Django. Are you sure it's installed and " 53 | "available on your PYTHONPATH environment variable? Did you " 54 | "forget to activate a virtual environment?" 55 | ) from exc 56 | execute_from_command_line(sys.argv) 57 | 58 | 59 | if __name__ == "__main__": 60 | main() 61 | -------------------------------------------------------------------------------- /mediafiles/default.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/mediafiles/default.webp -------------------------------------------------------------------------------- /nltk.txt: -------------------------------------------------------------------------------- 1 | stopwords -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ## Core Dependencies 2 | beautifulSoup4==4.12.2 3 | Django==5.1.2 4 | django-ckeditor-5==0.2.15 5 | django-htmx==1.16.0 6 | django-resized==1.0.2 7 | django-robots==6.1 8 | feedparser==6.0.10 9 | filetype==1.2.0 10 | Pillow==10.3.0 11 | psycopg[binary]==3.2.3 12 | psutil==5.9.5 13 | python-dotenv==1.0.0 14 | pytz==2023.3 15 | readtime==3.0.0 16 | requests==2.32.3 17 | nltk==3.9.1 18 | 19 | ## Prod dependencies 20 | Brotli==1.1.0 21 | django-csp==3.7 22 | gunicorn==22.0.0 23 | django-sri==0.7.0 24 | whitenoise==6.7.0 25 | boto3==1.35.55 26 | django-storages==1.14.4 27 | django-htmlmin==0.11.0 28 | 29 | ## GPT Bot dependencies (Distances From Embeddings) 30 | matplotlib==3.9.1 31 | numpy==2.1.3 32 | openai==1.51.0 33 | pandas==2.2.3 34 | plotly==5.21.0 35 | scikit-learn==1.5.2 36 | scipy==1.14.1 37 | 38 | ## Legacy dependencies for migrations 39 | django-ckeditor==6.4.2 40 | 41 | 42 | ## Development Dependencies 43 | coverage==7.2.7 44 | django-coverage-plugin==3.1.0 45 | django-fastdev==1.12.0 46 | django-livereload-server==0.5.1 47 | pre-commit==3.8.0 48 | pytest==8.2.0 49 | ruff==0.1.5 50 | tiktoken==0.8.0 # Creating embeddings -------------------------------------------------------------------------------- /scripts/move_images.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from pathlib import Path 4 | 5 | def move_images(): 6 | # Define source and destination directories 7 | base_dir = Path(__file__).resolve().parent.parent # Get project root 8 | source_dir = base_dir / 'mediafiles' / 'uploads' 9 | dest_dir = base_dir / 'mediafiles' / 'post_imgs' 10 | 11 | print(f"\nScript starting...") 12 | print(f"Looking for images in: {source_dir}") 13 | print(f"Will move them to: {dest_dir}\n") 14 | 15 | if not source_dir.exists(): 16 | print(f"ERROR: Source directory {source_dir} does not exist!") 17 | return 18 | 19 | # Create destination directory if it doesn't exist 20 | dest_dir.mkdir(exist_ok=True, parents=True) 21 | print(f"Destination directory ready: {dest_dir}") 22 | 23 | files_moved = 0 24 | files_failed = 0 25 | 26 | # Walk through the source directory 27 | for root, dirs, files in os.walk(source_dir): 28 | root_path = Path(root) 29 | 30 | # Move each file 31 | for file in files: 32 | # Only move image files 33 | if file.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg')): 34 | source_file = root_path / file 35 | dest_file = dest_dir / file 36 | 37 | # Move the file 38 | try: 39 | shutil.move(str(source_file), str(dest_file)) 40 | print(f"✓ Moved: {source_file.name}") 41 | files_moved += 1 42 | except Exception as e: 43 | print(f"✗ Error moving {source_file.name}: {e}") 44 | files_failed += 1 45 | 46 | print(f"\nOperation complete!") 47 | print(f"Files moved successfully: {files_moved}") 48 | print(f"Files failed to move: {files_failed}") 49 | 50 | if __name__ == "__main__": 51 | move_images() -------------------------------------------------------------------------------- /static/convertImages: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | PARAMS=('-m 6 -q 70 -mt -af -progress') 4 | 5 | if [ $# -ne 0 ]; then 6 | PARAMS=$@; 7 | fi 8 | 9 | shopt -s nullglob nocaseglob extglob globstar 10 | 11 | for file in $PWD/**/*.@(jpg|jpeg|tif|tiff|png); do 12 | cwebp $PARAMS "$file" -o "${file%.*}".webp; 13 | done 14 | -------------------------------------------------------------------------------- /static/default.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/static/default.webp -------------------------------------------------------------------------------- /static/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/static/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /static/favicon/android-chrome-192x192.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/static/favicon/android-chrome-192x192.webp -------------------------------------------------------------------------------- /static/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/static/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /static/favicon/android-chrome-512x512.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/static/favicon/android-chrome-512x512.webp -------------------------------------------------------------------------------- /static/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/static/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /static/favicon/apple-touch-icon.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/static/favicon/apple-touch-icon.webp -------------------------------------------------------------------------------- /static/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/static/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /static/favicon/favicon-16x16.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/static/favicon/favicon-16x16.webp -------------------------------------------------------------------------------- /static/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/static/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /static/favicon/favicon-32x32.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/static/favicon/favicon-32x32.webp -------------------------------------------------------------------------------- /static/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/static/favicon/favicon.ico -------------------------------------------------------------------------------- /static/iPhoneblogthedata.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/static/iPhoneblogthedata.webp -------------------------------------------------------------------------------- /static/icons/ESLintLogo.svg: -------------------------------------------------------------------------------- 1 | ESLINTESLINT -------------------------------------------------------------------------------- /static/icons/NGINXlogo.svg: -------------------------------------------------------------------------------- 1 | NGINXNGINX -------------------------------------------------------------------------------- /static/icons/PytestLogo.svg: -------------------------------------------------------------------------------- 1 | PYTESTPYTEST -------------------------------------------------------------------------------- /static/icons/bootstrapLogo.svg: -------------------------------------------------------------------------------- 1 | BOOTSTRAPBOOTSTRAP -------------------------------------------------------------------------------- /static/icons/cloudflareLogo.svg: -------------------------------------------------------------------------------- 1 | CLOUDFLARECLOUDFLARE -------------------------------------------------------------------------------- /static/icons/cypressLogo.svg: -------------------------------------------------------------------------------- 1 | CYPRESSCYPRESS -------------------------------------------------------------------------------- /static/icons/djangoLogo.svg: -------------------------------------------------------------------------------- 1 | DJANGODJANGO -------------------------------------------------------------------------------- /static/icons/dockerLogo.svg: -------------------------------------------------------------------------------- 1 | DOCKERDOCKER -------------------------------------------------------------------------------- /static/icons/expressLogo.svg: -------------------------------------------------------------------------------- 1 | EXPRESSEXPRESS -------------------------------------------------------------------------------- /static/icons/herokuLogo.svg: -------------------------------------------------------------------------------- 1 | HEROKUHEROKU -------------------------------------------------------------------------------- /static/icons/ip-address.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/static/icons/ip-address.png -------------------------------------------------------------------------------- /static/icons/ip-address.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/static/icons/ip-address.webp -------------------------------------------------------------------------------- /static/icons/leafletLogo.svg: -------------------------------------------------------------------------------- 1 | LEAFLETLEAFLET -------------------------------------------------------------------------------- /static/icons/linodeLogo.svg: -------------------------------------------------------------------------------- 1 | LINODELINODE -------------------------------------------------------------------------------- /static/icons/mapboxLogo.svg: -------------------------------------------------------------------------------- 1 | MAPBOXMAPBOX -------------------------------------------------------------------------------- /static/icons/nodeLogo.svg: -------------------------------------------------------------------------------- 1 | NODE.JSNODE.JS -------------------------------------------------------------------------------- /static/icons/openlayersLogo.svg: -------------------------------------------------------------------------------- 1 | OPENLAYERSOPENLAYERS -------------------------------------------------------------------------------- /static/icons/prettierLogo.svg: -------------------------------------------------------------------------------- 1 | PRETTIERPRETTIER -------------------------------------------------------------------------------- /static/icons/pythonLogo.svg: -------------------------------------------------------------------------------- 1 | PYTHONPYTHON -------------------------------------------------------------------------------- /static/icons/seleniumLogo.svg: -------------------------------------------------------------------------------- 1 | SELENIUMSELENIUM -------------------------------------------------------------------------------- /static/icons/typescriptLogo.svg: -------------------------------------------------------------------------------- 1 | TYPESCRIPTTYPESCRIPT -------------------------------------------------------------------------------- /static/icons/vercelLogo.svg: -------------------------------------------------------------------------------- 1 | VERCELVERCEL -------------------------------------------------------------------------------- /static/icons/viteLogo.svg: -------------------------------------------------------------------------------- 1 | VITEVITE -------------------------------------------------------------------------------- /static/img/Scarecrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/static/img/Scarecrow.png -------------------------------------------------------------------------------- /static/img/Scarecrow.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/static/img/Scarecrow.webp -------------------------------------------------------------------------------- /static/js/addHeaderIdsAndLinks.js: -------------------------------------------------------------------------------- 1 | const addHeaderIDs = () => { 2 | const headers = document.querySelectorAll("h1, h2, h3"); 3 | headers.forEach((header) => { 4 | header.id = header.textContent 5 | .trim() 6 | .toLowerCase() 7 | .replace(/\s+/g, "-") 8 | .replace(/[^a-zA-Z0-9_\-]/g, ""); 9 | }); 10 | }; 11 | 12 | const addHeaderLinks = () => { 13 | const headers = document.querySelectorAll("h1, h2, h3"); 14 | headers.forEach((header) => { 15 | const headerId = header.getAttribute("id"); 16 | const link = document.createElement("a"); 17 | link.href = `#${headerId}`; 18 | link.classList.add("header-link"); 19 | link.innerHTML = " 🔗"; 20 | header.appendChild(link); 21 | link.addEventListener("click", () => { 22 | const linkUrl = link.href; 23 | navigator.clipboard.writeText(linkUrl).then( 24 | () => { 25 | console.log("Link URL copied to clipboard!"); 26 | }, 27 | (err) => { 28 | console.log("Unable to copy link URL: ", err); 29 | } 30 | ); 31 | }); 32 | }); 33 | }; 34 | 35 | document.addEventListener("DOMContentLoaded", () => { 36 | addHeaderIDs(); 37 | addHeaderLinks(); 38 | }); 39 | -------------------------------------------------------------------------------- /static/js/characterCounter.js: -------------------------------------------------------------------------------- 1 | function countCharacters() { 2 | function count(input, count) { 3 | const charCount = count.querySelector(".char-count"); 4 | charCount.textContent = input.value.length; 5 | input.addEventListener("input", () => { 6 | charCount.textContent = input.value.length; 7 | }); 8 | } 9 | 10 | const titleInput = document.querySelector("#id_title"); 11 | const titleCount = document.querySelector("#title-count"); 12 | count(titleInput, titleCount); 13 | 14 | const slugInput = document.querySelector("#id_slug"); 15 | const slugCount = document.querySelector("#slug-count"); 16 | count(slugInput, slugCount); 17 | 18 | const metadescInput = document.querySelector("#id_metadesc"); 19 | const metadescCount = document.querySelector("#metadesc-count"); 20 | count(metadescInput, metadescCount); 21 | } 22 | 23 | window.onload = countCharacters; 24 | -------------------------------------------------------------------------------- /static/js/charts/CPU_gauge.js: -------------------------------------------------------------------------------- 1 | function createChartDiv(id, width, height) { 2 | const chartDiv = document.createElement("div"); 3 | chartDiv.setAttribute("id", id); 4 | chartDiv.style.width = width; 5 | chartDiv.style.height = height; 6 | return chartDiv; 7 | } 8 | 9 | function initChart(chartDom) { 10 | const myChart = echarts.init(chartDom); 11 | 12 | const option = { 13 | series: [ 14 | { 15 | type: "gauge", 16 | min: 0, 17 | max: 100, 18 | splitNumber: 4, 19 | axisLine: { 20 | lineStyle: { 21 | width: 30, 22 | color: [ 23 | [0.3, "#67e0e3"], 24 | [0.7, "#37a2da"], 25 | [1, "#fd666d"], 26 | ], 27 | }, 28 | }, 29 | axisTick: { 30 | distance: -30, 31 | length: 8, 32 | lineStyle: { 33 | color: "#fff", 34 | width: 2, 35 | }, 36 | }, 37 | splitLine: { 38 | distance: -30, 39 | length: 30, 40 | lineStyle: { 41 | color: "#fff", 42 | width: 4, 43 | }, 44 | }, 45 | axisLabel: { 46 | color: "inherit", 47 | distance: 40, 48 | fontSize: 20, 49 | formatter: "{value}%", 50 | }, 51 | detail: { 52 | valueAnimation: true, 53 | formatter: "{value}%", 54 | color: "inherit", 55 | }, 56 | data: [ 57 | { 58 | value: 50, 59 | }, 60 | ], 61 | }, 62 | ], 63 | }; 64 | 65 | myChart.setOption(option); 66 | } 67 | 68 | const chartDiv = createChartDiv("hello", "600px", "400px"); 69 | const parentElement = document.querySelector(".system-metrics"); 70 | parentElement.appendChild(chartDiv); 71 | 72 | const chartDom = document.getElementById("hello"); 73 | initChart(chartDom); 74 | -------------------------------------------------------------------------------- /static/js/chatbox.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", () => { 2 | class Chatbox { 3 | constructor() { 4 | this.openButton = document.querySelector(".chatbox__button"); 5 | this.chatBox = document.querySelector(".chatbox__support"); 6 | this.sendButton = document.querySelector(".send__button"); 7 | this.closeButton = document.querySelector( 8 | ".chatbox__close--header button" 9 | ); 10 | this.state = false; 11 | } 12 | 13 | display() { 14 | this.openButton.addEventListener("click", () => this.toggleState()); 15 | this.closeButton.addEventListener("click", () => this.toggleState()); 16 | this.sendButton.addEventListener("click", () => this.onSendButton()); 17 | const textArea = this.chatBox.querySelector("textarea"); 18 | 19 | textArea.addEventListener("input", () => { 20 | textArea.style.height = "auto"; 21 | textArea.style.height = `${textArea.scrollHeight}px`; 22 | }); 23 | 24 | textArea.addEventListener("keyup", ({ key, shiftKey }) => { 25 | if (key === "Enter" && !shiftKey) { 26 | this.onSendButton(); 27 | } 28 | }); 29 | 30 | const observer = new MutationObserver(() => { 31 | textArea.style.height = "auto"; 32 | }); 33 | 34 | observer.observe(textArea, { 35 | childList: true, 36 | subtree: true, 37 | characterData: true, 38 | }); 39 | } 40 | 41 | toggleState() { 42 | this.state = !this.state; 43 | this.chatBox.classList.toggle("chatbox--active", this.state); 44 | document.getElementById("chatbox-icon").style.display = this.state 45 | ? "none" 46 | : "block"; 47 | } 48 | 49 | onSendButton() { 50 | const textField = this.chatBox.querySelector("#question-text-area"); 51 | const text = textField.value.trim(); 52 | if (text === "" || text === "\n") { 53 | return; 54 | } 55 | this.updateChatText(text); 56 | textField.value = ""; 57 | textField.style.height = "auto"; 58 | textField.dispatchEvent(new Event("input")); 59 | } 60 | 61 | updateChatText(text) { 62 | const chatmessages = this.chatBox.querySelector(".chatbox__messages"); 63 | const message = document.createElement("div"); 64 | message.classList.add("messages__item", "messages__item--user"); 65 | message.textContent = text; 66 | chatmessages.prepend(message); 67 | } 68 | } 69 | 70 | const chatbox = new Chatbox(); 71 | chatbox.display(); 72 | }); 73 | -------------------------------------------------------------------------------- /static/js/header.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", () => { 2 | const hamburger = document.querySelector(".hamburger"); 3 | 4 | hamburger.addEventListener("click", () => { 5 | console.log("click"); 6 | toggleMenu(); 7 | }); 8 | 9 | const dropdownToggles = document.querySelectorAll(".dropdown-toggle"); 10 | 11 | dropdownToggles.forEach((toggle) => { 12 | toggle.addEventListener("click", (event) => { 13 | event.preventDefault(); 14 | const dropdownMenu = toggle.nextElementSibling; 15 | dropdownMenu.classList.toggle("show"); 16 | }); 17 | }); 18 | }); 19 | 20 | function toggleMenu() { 21 | const menu = document.querySelector(".navbar-menu"); 22 | menu.classList.toggle("show-menu"); 23 | console.log(menu.classList); 24 | } 25 | -------------------------------------------------------------------------------- /static/logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/static/logo.webp -------------------------------------------------------------------------------- /static/logo_transparent_cropped.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/static/logo_transparent_cropped.webp -------------------------------------------------------------------------------- /static/svg/chatbox-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /static/svg/clock.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | -------------------------------------------------------------------------------- /static/svg/close-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /static/svg/comment-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | comment 3 | 4 | 5 | -------------------------------------------------------------------------------- /static/svg/github.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | -------------------------------------------------------------------------------- /static/svg/linkedin-share-btn.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /static/svg/man-avatar.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 23 | 24 | 26 | 27 | 29 | 30 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 45 | 46 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /static/svg/open-post-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /static/svg/person-circle.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /static/svg/print-icon.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 7 | -------------------------------------------------------------------------------- /static/svg/reddit-share-btn.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /static/svg/scroll-to-top.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | -------------------------------------------------------------------------------- /static/svg/send-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /static/svg/x-share-btn.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/tests/__init__.py -------------------------------------------------------------------------------- /tests/base.py: -------------------------------------------------------------------------------- 1 | from django import setup 2 | from dotenv import load_dotenv 3 | import os 4 | 5 | os.environ["USE_SQLITE"] = "True" 6 | os.environ["USE_CLOUD"] = "False" 7 | # Set the site id to 1 because we need to create a site object in the database 8 | os.environ["SITE_ID"] = "1" 9 | setup() 10 | 11 | load_dotenv() 12 | 13 | from django.test import TestCase 14 | from django.test.utils import setup_test_environment 15 | from django.contrib.auth.models import User 16 | from django.contrib.sites.models import Site 17 | from django.test.client import Client 18 | 19 | # Local imports 20 | from blog.models import Post, Category 21 | from .utils import create_unique_post, create_comment 22 | 23 | 24 | class SetUp(TestCase): 25 | setup_test_environment() 26 | Site.objects.get_or_create(domain='testserver', defaults={'name': 'Test Server'}) 27 | Site.objects.get_or_create(domain='www.testserver', defaults={'name': 'WWW Test Server'}) 28 | 29 | @classmethod 30 | def setUpTestData(cls): 31 | cls.admin_user_password = "admin" 32 | cls.comment_only_user_password = "comment_only" 33 | cls.default_category = Category.objects.get(slug="uncategorized") 34 | cls.admin_user = User.objects.get(username="admin") 35 | cls.comment_only_user = User.objects.get(username="comment_only") 36 | cls.first_post = create_unique_post() 37 | cls.first_comment = create_comment(cls.first_post) 38 | cls.draft_post = create_unique_post(draft=True) 39 | # Create a client that follows redirects 40 | cls.client = Client(follow=True) 41 | 42 | def tearDown(self): 43 | Post.objects.all().delete() 44 | 45 | def assertResponseAndTemplate(self, response, template_name, status_code=200): 46 | """Helper method to check response status and template after redirects""" 47 | if hasattr(response, 'redirect_chain') and response.redirect_chain: 48 | # Get the final response status from the last redirect 49 | final_url, final_status = response.redirect_chain[-1] 50 | self.assertEqual(final_status, status_code) 51 | else: 52 | # Direct response without redirects 53 | self.assertEqual(response.status_code, status_code) 54 | 55 | self.assertTemplateUsed(response, template_name) 56 | -------------------------------------------------------------------------------- /tests/test_feeds.py: -------------------------------------------------------------------------------- 1 | # Third-party Imports 2 | from django.urls import resolve, reverse 3 | import feedparser 4 | 5 | 6 | # Local application/library specific imports 7 | from .base import SetUp 8 | from blog.feeds import atomFeed, blogFeed 9 | 10 | 11 | class TestFeeds(SetUp): 12 | def test_blog_feed_url_is_resolved(self): 13 | self.assertTrue(isinstance(resolve(reverse("rss")).func, blogFeed)) 14 | 15 | def test_atom_feed_url_is_resolved(self): 16 | self.assertTrue(isinstance(resolve(reverse("atom")).func, atomFeed)) 17 | 18 | def test_feed_metadata(self): 19 | response = self.client.get("/rss/") 20 | feed = feedparser.parse(response.content) 21 | self.assertEqual(feed.feed.title, "blogthedata | Blog") 22 | self.assertEqual(feed.feed.description, "Latest blog posts from blogthedata") 23 | 24 | def test_rss_feed_items(self): 25 | response = self.client.get("/rss/") 26 | feed = feedparser.parse(response.content) 27 | self.assertTrue(hasattr(feed.entries[0], "title")) 28 | 29 | def test_atom_feed_items(self): 30 | response = self.client.get("/atom/") 31 | feed = feedparser.parse(response.content) 32 | self.assertTrue(hasattr(feed.entries[0], "title")) 33 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from .base import SetUp 2 | from django.urls import reverse 3 | from PIL import Image 4 | from blog.models import Post, Comment, Category 5 | from users.models import Profile 6 | from django.contrib.auth.models import User 7 | from django.core.files.uploadedfile import SimpleUploadedFile 8 | from io import BytesIO 9 | 10 | 11 | class TestModels(SetUp): 12 | def test_post_manager(self): 13 | active_posts = Post.objects.active() 14 | self.assertIsInstance(active_posts[0], Post) 15 | 16 | def test_category_absolute_url(self): 17 | test_category = Category.objects.get(name="Uncategorized") 18 | self.assertEqual( 19 | test_category.get_absolute_url(), 20 | "/category/uncategorized/", 21 | ) 22 | 23 | # Users Models 24 | def test_profile(self): 25 | admin = User.objects.get(username="admin") 26 | profile1 = Profile.objects.get(user=admin) 27 | self.assertEqual(str(profile1), f"{admin.username} Profile") 28 | self.assertEqual(profile1.get_absolute_url(), reverse("profile")) 29 | 30 | # Create a test image in memory 31 | width, height = 400, 400 32 | img = Image.new(mode="RGB", size=(width, height)) 33 | img_io = BytesIO() 34 | img.save(img_io, format='JPEG') 35 | img_file = SimpleUploadedFile( 36 | 'test_profile.jpg', 37 | img_io.getvalue(), 38 | content_type='image/jpeg' 39 | ) 40 | 41 | # Update profile with the test image 42 | profile1.image = img_file 43 | profile1.save() 44 | 45 | # Now the image should be resized to 300x300 46 | with Image.open(profile1.image.path) as img: 47 | self.assertLessEqual(img.height, 300) 48 | self.assertLessEqual(img.width, 300) 49 | 50 | def test_create_comment(self): 51 | test_post = Post.objects.first() 52 | admin = User.objects.get(username="admin") 53 | comment = Comment.objects.create( 54 | post=test_post, 55 | author=admin, 56 | content="Lorem ipsum dolor sit amet, consectetur adipiscing elit.", 57 | ) 58 | self.assertEqual(comment.post, test_post) 59 | self.assertEqual(comment.author, admin) 60 | self.assertEqual( 61 | comment.content, "Lorem ipsum dolor sit amet, consectetur adipiscing elit." 62 | ) 63 | # Delete the comment 64 | comment.delete() 65 | 66 | def test_comment_print_and_absolute_url(self): 67 | test_comment = self.first_post.comments.first() 68 | self.assertEqual( 69 | str(test_comment), f"Comment '{test_comment.content}' by admin" 70 | ) 71 | self.assertEqual( 72 | test_comment.get_absolute_url(), f"/post/{self.first_post.slug}/#comments" 73 | ) 74 | -------------------------------------------------------------------------------- /tests/test_sitemaps.py: -------------------------------------------------------------------------------- 1 | from .base import SetUp 2 | from django.urls import reverse 3 | 4 | from app.sitemaps import ( 5 | StaticSitemap, 6 | PostSitemap, 7 | CategorySitemap, 8 | ) 9 | 10 | 11 | class TestSitemaps(SetUp): 12 | def test_post_sitemap(self): 13 | sitemap = PostSitemap() 14 | item = sitemap.items()[0] 15 | self.assertTrue(reverse("post-detail", args=[item.slug])) 16 | self.assertTrue(sitemap.location(item)) 17 | self.assertEqual(sitemap.changefreq, "weekly") 18 | self.assertEqual(sitemap.priority, 0.8) 19 | self.assertEqual(sitemap.lastmod(item), item.date_updated) 20 | 21 | def test_category_sitemap(self): 22 | sitemap = CategorySitemap() 23 | item = sitemap.items()[0] 24 | self.assertTrue(reverse("blog-category", args=[item.slug])) 25 | self.assertTrue(sitemap.location(item)) 26 | self.assertEqual(sitemap.changefreq, "weekly") 27 | self.assertEqual(sitemap.priority, 0.6) 28 | 29 | def test_static_sitemap(self): 30 | sitemap = StaticSitemap() 31 | items = sitemap.items() 32 | 33 | # Test sitemap configuration 34 | self.assertEqual(sitemap.changefreq, "monthly") 35 | self.assertEqual(sitemap.priority, 0.3) 36 | 37 | # Test all static URLs are included 38 | expected_urls = ['home', 'works-cited', 'privacy', 'status', 'all-posts'] 39 | self.assertEqual(items, expected_urls) 40 | 41 | # Test each URL can be reversed and located 42 | for item in items: 43 | self.assertTrue(reverse(item)) 44 | self.assertEqual(sitemap.location(item), reverse(item)) 45 | -------------------------------------------------------------------------------- /tests/test_template_tags.py: -------------------------------------------------------------------------------- 1 | # Local application/library specific imports 2 | from blog.templatetags.post_utils import read 3 | from unittest import TestCase 4 | 5 | 6 | class TestTemplateTags(TestCase): 7 | def test_html_read_time_no_input(self): 8 | self.assertEqual(read(""), "0 minutes") 9 | -------------------------------------------------------------------------------- /tests/test_validators.py: -------------------------------------------------------------------------------- 1 | # Local application/library specific imports 2 | from .base import SetUp 3 | from blog.validators import snippet_validator 4 | 5 | # Third-party Imports 6 | from django.core.exceptions import ValidationError 7 | 8 | 9 | class TestValidators(SetUp): 10 | def test_snippet_validation_valid_with_links(self): 11 | max_length = 400 12 | valid_value_with_links = " ".join( 13 | [f'Link' for i in range(100)] 14 | ) 15 | 16 | self.assertTrue( 17 | snippet_validator(valid_value_with_links, max_length=max_length) 18 | ) 19 | 20 | def test_snippet_validation_valid_formatted_text(self): 21 | invalid_value = """

    Kick-start your career in geospatial sciences with our Remote Sensing Internship Program! Dive into hands-on projects, learn from industry experts, and make your mark on real-world applications. Apply now and shape the future of remote sensing!

    """ 22 | 23 | self.assertTrue(snippet_validator(invalid_value, max_length=400)) 24 | 25 | def test_snippet_validation_long_text_exceeding_max_length(self): 26 | max_length = 400 27 | long_text = ( 28 | "Lorem ipsum dolor sit amet, " 29 | "[Link](http://example.com) " 30 | "![Image](image.jpg) " 31 | "*emphasis* " 32 | "**strong** " * 30 33 | ) 34 | with self.assertRaises(ValidationError): 35 | snippet_validator(long_text, max_length=max_length) 36 | 37 | def test_snippet_validation_within_max_length(self): 38 | max_length = 400 39 | short_text = ( 40 | "Short text within the max length " 41 | "[Link](http://example.com) " 42 | "![Image](image.jpg) " 43 | "*emphasis* " 44 | "**strong**" 45 | ) 46 | self.assertTrue(snippet_validator(short_text, max_length=max_length)) 47 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from users.models import User 2 | from blog.models import Post, Category 3 | from django.db import transaction 4 | from django.contrib.messages import get_messages 5 | 6 | # Global counter variable 7 | post_counter = 0 8 | 9 | 10 | def message_in_response(response, message: str): 11 | for resp_message in get_messages(response.wsgi_request): 12 | if message == resp_message.message: 13 | return True 14 | return False 15 | 16 | 17 | @transaction.atomic # This decorator ensures the function runs in a single database transaction 18 | def create_unique_post( 19 | author="admin", 20 | category="uncategorized", 21 | title_base="Test Post", 22 | slug_base="test-post", 23 | metadesc="Default Meta Description", 24 | draft=False, 25 | snippet="Default Snippet", 26 | content="Default Content", 27 | ): 28 | global post_counter # Declare the global variable 29 | 30 | # Increment the counter value 31 | post_counter += 1 32 | 33 | title = f"{title_base} {post_counter}" 34 | slug = f"{slug_base}-{post_counter}" 35 | 36 | author = User.objects.get(username=author) 37 | category = Category.objects.get(slug=category) 38 | 39 | post = Post.objects.create( 40 | title=title, 41 | slug=slug, 42 | category=category, 43 | metadesc=metadesc, 44 | draft=draft, 45 | snippet=snippet, 46 | content=content, 47 | author=author, 48 | ) 49 | 50 | return post 51 | 52 | 53 | def create_comment(post, author="admin", content="Lorem Ipsum"): 54 | author = User.objects.get(username=author) 55 | return post.comments.create(author=author, content=content) 56 | 57 | 58 | def create_several_unqiue_posts(number_of_posts): 59 | for _ in range(number_of_posts): 60 | create_unique_post() 61 | -------------------------------------------------------------------------------- /users/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/users/__init__.py -------------------------------------------------------------------------------- /users/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import Profile 3 | 4 | # Register your models here. 5 | 6 | admin.site.register(Profile) 7 | -------------------------------------------------------------------------------- /users/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class UsersConfig(AppConfig): 5 | name = "users" 6 | 7 | def ready(self): 8 | import users.signals 9 | -------------------------------------------------------------------------------- /users/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib.auth.models import User 3 | from django.contrib.auth.forms import UserCreationForm 4 | from .models import Profile 5 | from django.contrib.auth import ( 6 | password_validation, 7 | ) 8 | from django.utils.translation import gettext_lazy as _ 9 | 10 | 11 | class UserRegisterForm(UserCreationForm): 12 | password1 = forms.CharField( 13 | label=_("Password"), 14 | strip=False, 15 | widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}), 16 | help_text=password_validation.password_validators_help_text_html(), 17 | ) 18 | password2 = forms.CharField( 19 | label=_("Password confirmation"), 20 | widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}), 21 | strip=False, 22 | help_text=_("Enter the same password as before, for verification."), 23 | ) 24 | secret_password = forms.CharField( 25 | widget=forms.TextInput( 26 | attrs={ 27 | "placeholder": "DM me on X (@_jsolly) for the secret password!", 28 | } 29 | ) 30 | ) 31 | 32 | class Meta: 33 | model = User 34 | 35 | fields = [ 36 | "secret_password", 37 | "username", 38 | "first_name", 39 | "last_name", 40 | "email", 41 | ] 42 | 43 | widgets = { 44 | "username": forms.TextInput(attrs={"autofocus": True}), 45 | "first_name": forms.TextInput(), 46 | "last_name": forms.TextInput(), 47 | "email": forms.EmailInput(), 48 | } 49 | 50 | 51 | class UserUpdateForm(forms.ModelForm): 52 | email = forms.EmailField() 53 | 54 | class Meta: 55 | model = User 56 | fields = ["username", "email"] 57 | 58 | 59 | class ProfileUpdateForm(forms.ModelForm): 60 | class Meta: 61 | model = Profile 62 | fields = ["image"] 63 | -------------------------------------------------------------------------------- /users/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2021-09-28 04:38 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 | initial = True 10 | 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="Profile", 18 | fields=[ 19 | ( 20 | "id", 21 | models.AutoField( 22 | auto_created=True, 23 | primary_key=True, 24 | serialize=False, 25 | verbose_name="ID", 26 | ), 27 | ), 28 | ( 29 | "image", 30 | models.ImageField(default="default.webp", upload_to="profile_pics"), 31 | ), 32 | ( 33 | "user", 34 | models.OneToOneField( 35 | on_delete=django.db.models.deletion.CASCADE, 36 | to=settings.AUTH_USER_MODEL, 37 | ), 38 | ), 39 | ], 40 | ), 41 | ] 42 | -------------------------------------------------------------------------------- /users/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/users/migrations/__init__.py -------------------------------------------------------------------------------- /users/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.models import User 3 | from django.urls import reverse 4 | from PIL import Image 5 | import os 6 | from io import BytesIO 7 | from django.core.files.storage import default_storage 8 | from django.core.files.base import ContentFile 9 | 10 | 11 | class Profile(models.Model): 12 | user = models.OneToOneField(User, on_delete=models.CASCADE) 13 | image = models.ImageField( 14 | default="default.webp", 15 | upload_to="profile_pics", 16 | storage=default_storage, 17 | ) 18 | 19 | def __str__(self): 20 | return f"{self.user.username} Profile" 21 | 22 | def get_absolute_url(self): 23 | return reverse("profile") 24 | 25 | def save(self, *args, **kwargs): 26 | super().save(*args, **kwargs) 27 | 28 | # Skip processing for default image 29 | if self.image.name == "default.webp": 30 | return 31 | 32 | if str(os.environ.get("USE_S3")).lower() == "true": 33 | try: 34 | img_read = default_storage.open(self.image.name, "rb") 35 | img = Image.open(img_read) 36 | 37 | if img.height > 300 or img.width > 300: 38 | output_size = (300, 300) 39 | img.thumbnail(output_size) 40 | 41 | buffer = BytesIO() 42 | img.save(buffer, format="JPEG") 43 | default_storage.save(self.image.name, ContentFile(buffer.getvalue())) 44 | except FileNotFoundError: 45 | # Handle the error or just pass if using default image 46 | pass 47 | else: 48 | with Image.open(self.image.path) as img: 49 | if img.height > 300 or img.width > 300: 50 | output_size = (300, 300) 51 | img.thumbnail(output_size) 52 | img.save(self.image.path) 53 | -------------------------------------------------------------------------------- /users/signals.py: -------------------------------------------------------------------------------- 1 | from django.db.models.signals import post_save 2 | from django.contrib.auth.models import User 3 | from django.dispatch import receiver 4 | from .models import Profile 5 | 6 | 7 | @receiver(post_save, sender=User) 8 | def create_profile(instance, created, **kwargs): 9 | print(f"Post save signal received for user {instance.id}: created={created}") 10 | if created: 11 | Profile.objects.create(user=instance) 12 | 13 | 14 | @receiver(post_save, sender=User) 15 | def save_profile(instance, **kwargs): 16 | instance.profile.save() 17 | -------------------------------------------------------------------------------- /users/templates/users/login.html: -------------------------------------------------------------------------------- 1 | {% extends "blog/base.html" %}{% block content %} 2 |

    Login

    3 |
    4 |
    5 | {% csrf_token %} 6 |
    7 | {{ form.as_p }} 8 |
    9 |
    10 | 13 | 14 | Forgot Password? 15 | 16 |
    17 |
    18 |
    19 |
    20 | 21 | Need An Account? 22 | Sign Up Now 23 | 24 |
    25 |
    26 | {% endblock content %} -------------------------------------------------------------------------------- /users/templates/users/logout.html: -------------------------------------------------------------------------------- 1 | {% extends "blog/base.html" %} 2 | 3 | {% block content %} 4 |
    5 | 6 | Want to log in again? Log in again 7 | 8 |
    9 | {% endblock content %} -------------------------------------------------------------------------------- /users/templates/users/password_reset.html: -------------------------------------------------------------------------------- 1 | {% extends "blog/base.html" %} 2 | {% block content %} 3 |
    4 |
    5 | {% csrf_token %} 6 |
    7 |

    Reset Password

    8 | {{ form.as_p }} 9 |
    10 |
    11 | 14 |
    15 |
    16 |
    17 | {% endblock content %} -------------------------------------------------------------------------------- /users/templates/users/password_reset_complete.html: -------------------------------------------------------------------------------- 1 | {% extends "blog/base.html" %} 2 | 3 | {% block content %} 4 |
    Your password has been set successfully!
    5 | Sign In Here 6 | {% endblock content %} -------------------------------------------------------------------------------- /users/templates/users/password_reset_confirm.html: -------------------------------------------------------------------------------- 1 | {% extends "blog/base.html" %} 2 | 3 | {% block content %} 4 |
    5 |
    6 | {% csrf_token %} 7 |
    8 | Reset Password 9 | {{ form.as_p }} 10 |
    11 |
    12 | 13 |
    14 |
    15 |
    16 | {% endblock content %} -------------------------------------------------------------------------------- /users/templates/users/password_reset_done.html: -------------------------------------------------------------------------------- 1 | {% extends "blog/base.html" %} 2 | 3 | {% block content %} 4 |
    5 | An email has been sent with instructions to reset your password 6 |
    7 | {% endblock content %} -------------------------------------------------------------------------------- /users/templates/users/profile.html: -------------------------------------------------------------------------------- 1 | {% extends "blog/base.html" %} 2 | 3 | {% block content %} 4 |
    5 |
    6 | 7 |
    8 | 9 |

    {{ user.email }}

    10 |
    11 |
    12 |
    13 | {% csrf_token %} 14 |
    15 | Profile Info 16 | {{ u_form.as_p }} {{ p_form.as_p }} 17 |
    18 |
    19 | 22 |
    23 |
    24 |
    25 | {% endblock content %} -------------------------------------------------------------------------------- /users/templates/users/register.html: -------------------------------------------------------------------------------- 1 | {% extends "blog/base.html" %} 2 | {% block content %} 3 |

    Sign Up

    4 |
    5 | {% csrf_token %} 6 |
    7 | 8 | Join Today (DM me 9 | 10 | @_jsolly 11 | 12 | for the secret password!) 13 | 14 | {{ form.as_p }} 15 |
    16 |
    17 | 20 |
    21 |
    22 |
    23 |
    24 | 25 | Already Have An Account? 26 | Sign In 27 | 28 |
    29 | 30 | {% endblock content %} -------------------------------------------------------------------------------- /utilities/create_embeddings/export_posts.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import sys 3 | import os 4 | import json 5 | import psycopg 6 | from django.core.wsgi import get_wsgi_application 7 | from blog.models import Category 8 | from django.contrib.auth.models import User 9 | 10 | 11 | BASE_DIR = Path(__file__).resolve().parents[2] 12 | app_path = BASE_DIR / "app" 13 | sys.path.append(str(app_path)) 14 | os.environ["DJANGO_SETTINGS_MODULE"] = "app.settings.dev" 15 | 16 | application = get_wsgi_application() 17 | 18 | # Database connection details 19 | db_config = { 20 | "dbname": "blogthedata", 21 | "user": os.environ["POSTGRES_USER"], 22 | "password": os.environ["POSTGRES_PASS"], 23 | "host": "localhost", 24 | "port": "5432", 25 | } 26 | 27 | 28 | def fetch_posts(conn): 29 | with conn.cursor() as cur: 30 | cur.execute( 31 | "SELECT title, slug, category_id, metadesc, draft, metaimg, metaimg_alt_txt, metaimg_attribution, content, snippet, date_posted, author_id FROM blog_post" 32 | ) 33 | return cur.fetchall() 34 | 35 | 36 | def write_post_to_json(post, export_dir): 37 | post_dict = { 38 | "title": post["title"], 39 | "slug": post["slug"], 40 | "category": Category.objects.get(id=post["category_id"]).name, 41 | "metadesc": post["metadesc"], 42 | "draft": post["draft"], 43 | "metaimg": post["metaimg"], 44 | "metaimg_alt_txt": post["metaimg_alt_txt"], 45 | "metaimg_attribution": post["metaimg_attribution"], 46 | "content": post["content"], 47 | "snippet": post["snippet"], 48 | "date_posted": str(post["date_posted"]), 49 | "author": User.objects.get(id=post["author_id"]).username, 50 | } 51 | filename = f"{post['slug']}.json" 52 | filepath = export_dir / filename 53 | with filepath.open("w") as f: 54 | json.dump(post_dict, f) 55 | 56 | 57 | def main(): 58 | with psycopg.connect(**db_config) as conn: 59 | posts = fetch_posts(conn) 60 | export_dir = BASE_DIR / "utilities/create_embeddings/exported_posts" 61 | for post in posts: 62 | write_post_to_json(post, export_dir) 63 | 64 | 65 | if __name__ == "__main__": 66 | main() 67 | -------------------------------------------------------------------------------- /utilities/create_embeddings/exported_posts/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsolly/awesome-django-blog/f2dafcea66dd7175cb00e07323fcbed729972da9/utilities/create_embeddings/exported_posts/.gitkeep -------------------------------------------------------------------------------- /utilities/create_embeddings/process_posts.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from pathlib import Path 3 | import json 4 | 5 | 6 | def remove_newlines(text): 7 | text = text.replace("\n", " ") 8 | text = text.replace("\\n", " ") 9 | text = text.replace(" ", " ") 10 | text = text.replace(" ", " ") 11 | return text 12 | 13 | 14 | # This is the blogthedata directory if you cloned the repo 15 | BASE_DIR = Path(__file__).resolve().parent.parent 16 | 17 | # Create a list to store the posts 18 | posts = [] 19 | 20 | # Get all the JSON files in the exported_posts directory 21 | posts_path = BASE_DIR / "utilities/create_embeddings/exported_posts" 22 | filepaths = [ 23 | path 24 | for path in posts_path.iterdir() 25 | if path.is_file() and path.name.endswith(".json") 26 | ] 27 | 28 | # Loop through the list of file paths and read the JSON data 29 | for filepath in filepaths: 30 | with filepath.open("r") as f: 31 | data = json.load(f) 32 | 33 | # Append the post fields to the list of posts 34 | posts.append( 35 | { 36 | "title": data["title"], 37 | "category": data["category"], 38 | "date_posted": data["date_posted"], 39 | "author": data["author"], 40 | "content": data["content"], 41 | } 42 | ) 43 | 44 | # Create a dataframe from the list of posts 45 | df = pd.DataFrame(posts) 46 | 47 | """ 48 | Set the content column to be the raw text with the newlines removed. 49 | We add the title to the content for additional context. 50 | """ 51 | df["content"] = df.apply( 52 | lambda row: f"{row.title}. {remove_newlines(row.content)}", axis=1 53 | ) 54 | 55 | # Save the dataframe to a JSON file in the processed directory 56 | json_path = BASE_DIR / "utilities/create_embeddings/processed/processed_posts.json" 57 | df.to_json(json_path, orient="records") 58 | 59 | # Print the first few rows of the dataframe 60 | print(df.head()) 61 | -------------------------------------------------------------------------------- /utilities/create_embeddings/save_vectors_to_pickle.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import numpy as np 3 | import json 4 | import pickle 5 | 6 | from pathlib import Path 7 | 8 | BASE_DIR = Path(__file__).resolve().parent.parent 9 | embeddings_path = BASE_DIR / "utilities/create_embeddings/processed/embeddings.json" 10 | df_pickle_path = BASE_DIR / "utilities/create_embeddings/processed/df.pkl" 11 | 12 | 13 | # Load the JSON file as a list of dictionaries 14 | with open(embeddings_path, "r") as f: 15 | data = json.load(f) 16 | 17 | # Create a dataframe from the list of dictionaries 18 | df = pd.DataFrame(data) 19 | 20 | # Convert the embeddings column from string to numpy array 21 | df["embeddings"] = df["embeddings"].apply(np.array) 22 | 23 | # Pickle the dataframe for future use 24 | with open(df_pickle_path, "wb") as f: 25 | pickle.dump(df, f) 26 | 27 | # Verify that the pickle file was created successfully by loading it and printing the head of the dataframe 28 | with open(df_pickle_path, "rb") as f: 29 | df_pickle = pickle.load(f) 30 | 31 | print(df_pickle.head()) 32 | -------------------------------------------------------------------------------- /utilities/create_embeddings/tokenize_posts_simple.py: -------------------------------------------------------------------------------- 1 | import tiktoken 2 | import pandas as pd 3 | from pathlib import Path 4 | import json 5 | import matplotlib.pyplot as plt 6 | 7 | # Load the cl100k_base tokenizer which is designed to work with the ada-002 model 8 | tokenizer = tiktoken.get_encoding("cl100k_base") 9 | 10 | BASE_DIR = Path(__file__).resolve().parent.parent.parent # Three levels up 11 | 12 | # Load the JSON file as a list of dictionaries 13 | with open( 14 | BASE_DIR / "utilities/create_embeddings/processed/processed_posts.json", 15 | "r", 16 | ) as f: 17 | data = json.load(f) 18 | 19 | # Create a dataframe from the list of dictionaries 20 | df = pd.DataFrame( 21 | data, columns=["title", "category", "date_posted", "author", "content"] 22 | ) 23 | 24 | # Tokenize the text and save the number of tokens to a new column 25 | df["n_tokens"] = df.content.apply(lambda x: len(tokenizer.encode(x))) 26 | 27 | # Visualize the distribution of the number of tokens per row using a histogram 28 | df.n_tokens.hist() 29 | # print the length of the longest line 30 | plt.show() 31 | -------------------------------------------------------------------------------- /utilities/webp-convert-directory.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | PARAMS=('-m 6 -q 70 -mt -af -progress') 4 | 5 | if [ $# -ne 0 ]; then 6 | PARAMS=$@; 7 | fi 8 | 9 | # Get the directory where this script is located 10 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 11 | 12 | # Change to that directory 13 | cd "$DIR" 14 | 15 | shopt -s nullglob nocaseglob extglob 16 | 17 | echo "Starting WebP conversion..." 18 | 19 | # Search current directory and subdirectories, but only within the starting directory 20 | find . -type f \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.tif" -o -iname "*.tiff" \) | while read FILE; do 21 | echo "Converting: $FILE" 22 | cwebp $PARAMS "$FILE" -o "${FILE%.*}".webp 23 | done 24 | 25 | echo "Conversion complete! Press any key to exit..." 26 | read -n 1 -s --------------------------------------------------------------------------------