├── .env.dist ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── bin ├── git └── test ├── docs ├── lab1.md ├── lab2.md └── project_overview.md ├── manage.py ├── project ├── __init__.py ├── config │ ├── __init__.py │ ├── asgi.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── data │ ├── __init__.py │ ├── apps.py │ ├── author.py │ ├── category.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ └── fake_data.py │ ├── markdown.py │ ├── subscribers.py │ └── subscription_notifications.py ├── newsletter │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── forms.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ ├── send_notifications.py │ │ │ └── test_send_notifications.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_remove_post_is_draft_post_is_published_and_more.py │ │ ├── 0003_post_is_public_post_summary.py │ │ ├── 0004_alter_subscription_options_and_more.py │ │ ├── 0005_auto_20220809_0012.py │ │ ├── 0006_alter_category_options_alter_subscription_categories.py │ │ ├── 0007_alter_subscriptionnotification_options_and_more.py │ │ ├── 0008_alter_post_categories.py │ │ ├── 0009_post_open_graph_description_post_open_graph_image.py │ │ ├── 0010_subscriptionnotification_read.py │ │ ├── 0011_alter_post_options_remove_category_category_unq_slug_and_more.py │ │ ├── 0012_alter_category_created_alter_post_created_and_more.py │ │ └── __init__.py │ ├── models.py │ ├── operations.py │ ├── receivers.py │ ├── syndication.py │ ├── templatetags │ │ ├── __init__.py │ │ └── newsletter_utils.py │ ├── test.py │ ├── test_admin.py │ ├── test_forms.py │ ├── test_models.py │ ├── test_operations.py │ ├── test_receivers.py │ ├── test_syndication.py │ ├── test_templatetags.py │ ├── test_views.py │ ├── urls.py │ └── views.py ├── templates │ ├── 403.html │ ├── 404.html │ ├── 500.html │ ├── base.html │ ├── django │ │ └── forms │ │ │ ├── p.html │ │ │ └── widgets │ │ │ └── select.html │ ├── inclusion_tags │ │ └── nice_datetime.html │ ├── landing.html │ ├── posts │ │ ├── detail.html │ │ ├── includes │ │ │ └── list_item.html │ │ └── list.html │ ├── registration │ │ ├── activate.html │ │ ├── activation_complete.html │ │ ├── activation_complete_admin_pending.html │ │ ├── activation_email.html │ │ ├── activation_email.txt │ │ ├── activation_email_subject.txt │ │ ├── admin_approve.html │ │ ├── admin_approve_complete.html │ │ ├── admin_approve_complete_email.html │ │ ├── admin_approve_complete_email.txt │ │ ├── admin_approve_complete_email_subject.txt │ │ ├── admin_approve_email.html │ │ ├── admin_approve_email.txt │ │ ├── admin_approve_email_subject.txt │ │ ├── login.html │ │ ├── logout.html │ │ ├── password_change_done.html │ │ ├── password_change_form.html │ │ ├── password_reset_complete.html │ │ ├── password_reset_confirm.html │ │ ├── password_reset_done.html │ │ ├── password_reset_email.html │ │ ├── password_reset_form.html │ │ ├── registration_base.html │ │ ├── registration_closed.html │ │ ├── registration_complete.html │ │ ├── registration_form.html │ │ ├── resend_activation_complete.html │ │ └── resend_activation_form.html │ ├── staff │ │ ├── analytics.html │ │ └── post_form.html │ └── subscription │ │ └── update.html └── tests │ ├── __init__.py │ └── runner.py ├── requirements.in ├── requirements.txt ├── setup.cfg └── static ├── .gitkeep └── fomantic ├── fomantic-ui-2.8.8.semantic.min.css ├── fomantic-ui-2.8.8.semantic.min.js ├── jquery.min.js └── themes ├── basic └── assets │ └── fonts │ ├── icons.eot │ ├── icons.svg │ ├── icons.ttf │ └── icons.woff ├── default └── assets │ ├── fonts │ ├── brand-icons.eot │ ├── brand-icons.svg │ ├── brand-icons.ttf │ ├── brand-icons.woff │ ├── brand-icons.woff2 │ ├── icons.eot │ ├── icons.svg │ ├── icons.ttf │ ├── icons.woff │ ├── icons.woff2 │ ├── outline-icons.eot │ ├── outline-icons.svg │ ├── outline-icons.ttf │ ├── outline-icons.woff │ └── outline-icons.woff2 │ └── images │ └── flags.png ├── github └── assets │ └── fonts │ ├── octicons-local.ttf │ ├── octicons.svg │ ├── octicons.ttf │ └── octicons.woff └── material └── assets └── fonts ├── icons.eot ├── icons.svg ├── icons.ttf ├── icons.woff └── icons.woff2 /.env.dist: -------------------------------------------------------------------------------- 1 | DEBUG=True 2 | SECRET_KEY='django-insecure-#3*_ltg+zl_f!q=t^+x#n-inv7d1@e#i9b-nb328&a0u52z5xp' 3 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - lab-1.1 8 | - lab-1.2 9 | - lab-1.3 10 | - lab-2.1 11 | - lab-2.2 12 | - lab-2.3 13 | - lab-2.4 14 | pull_request: 15 | 16 | jobs: 17 | tests: 18 | name: Tests Python ${{ matrix.python-version }} on ${{ matrix.os }} 19 | runs-on: ${{ matrix.os }} 20 | 21 | strategy: 22 | matrix: 23 | os: [ubuntu-latest, windows-latest, macos-latest] 24 | python-version: ['3.11', '3.12', '3.13'] 25 | 26 | env: 27 | SECRET_KEY: very-unstrustworthy 28 | 29 | steps: 30 | - uses: actions/checkout@v4 31 | 32 | - uses: actions/setup-python@v5 33 | with: 34 | python-version: ${{ matrix.python-version }} 35 | cache: pip 36 | cache-dependency-path: 'requirements.txt' 37 | 38 | - name: Install dependencies 39 | run: | 40 | python -m pip install --upgrade pip setuptools wheel 41 | python -m pip install -r requirements.txt 42 | 43 | - name: Run tests 44 | run: | 45 | python -m manage test 46 | 47 | - name: Run lab tests 48 | run: | 49 | python -m manage test --tag lab_test 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Django static and media directories 2 | staticfiles/ 3 | media/ 4 | 5 | # PyCharm project files 6 | .idea/ 7 | 8 | # Byte-compiled / optimized / DLL files 9 | __pycache__/ 10 | *.py[cod] 11 | *$py.class 12 | 13 | # C extensions 14 | *.so 15 | 16 | # Distribution / packaging 17 | .Python 18 | build/ 19 | develop-eggs/ 20 | dist/ 21 | downloads/ 22 | eggs/ 23 | .eggs/ 24 | lib/ 25 | lib64/ 26 | parts/ 27 | sdist/ 28 | var/ 29 | wheels/ 30 | pip-wheel-metadata/ 31 | share/python-wheels/ 32 | *.egg-info/ 33 | .installed.cfg 34 | *.egg 35 | MANIFEST 36 | 37 | # PyInstaller 38 | # Usually these files are written by a python script from a template 39 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 40 | *.manifest 41 | *.spec 42 | 43 | # Installer logs 44 | pip-log.txt 45 | pip-delete-this-directory.txt 46 | 47 | # Unit test / coverage reports 48 | htmlcov/ 49 | .tox/ 50 | .nox/ 51 | .coverage 52 | .coverage.* 53 | .cache 54 | nosetests.xml 55 | coverage.xml 56 | *.cover 57 | *.py,cover 58 | .hypothesis/ 59 | .pytest_cache/ 60 | 61 | # Translations 62 | *.mo 63 | *.pot 64 | 65 | # Django stuff: 66 | *.log 67 | local_settings.py 68 | db.sqlite3 69 | db.sqlite3-journal 70 | 71 | # Flask stuff: 72 | instance/ 73 | .webassets-cache 74 | 75 | # Scrapy stuff: 76 | .scrapy 77 | 78 | # Sphinx documentation 79 | docs/_build/ 80 | 81 | # PyBuilder 82 | target/ 83 | 84 | # Jupyter Notebook 85 | .ipynb_checkpoints 86 | 87 | # IPython 88 | profile_default/ 89 | ipython_config.py 90 | 91 | # pyenv 92 | .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 102 | __pypackages__/ 103 | 104 | # Celery stuff 105 | celerybeat-schedule 106 | celerybeat.pid 107 | 108 | # SageMath parsed files 109 | *.sage.py 110 | 111 | # Environments 112 | .env 113 | .venv 114 | env/ 115 | venv/ 116 | ENV/ 117 | env.bak/ 118 | venv.bak/ 119 | 120 | # Spyder project settings 121 | .spyderproject 122 | .spyproject 123 | 124 | # Rope project settings 125 | .ropeproject 126 | 127 | # mkdocs documentation 128 | /site 129 | 130 | # mypy 131 | .mypy_cache/ 132 | .dmypy.json 133 | dmypy.json 134 | 135 | # Pyre type checker 136 | .pyre/ 137 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: check-yaml 6 | - id: end-of-file-fixer 7 | - id: trailing-whitespace 8 | - id: mixed-line-ending 9 | - repo: https://github.com/pycqa/flake8 10 | rev: 7.2.0 11 | hooks: 12 | - id: flake8 13 | - repo: https://github.com/pycqa/doc8 14 | rev: v1.1.2 15 | hooks: 16 | - id: doc8 17 | - repo: https://github.com/asottile/pyupgrade 18 | rev: v3.20.0 19 | hooks: 20 | - id: pyupgrade 21 | args: [--py38-plus] 22 | - repo: https://github.com/adamchainz/django-upgrade 23 | rev: 1.25.0 24 | hooks: 25 | - id: django-upgrade 26 | args: [--target-version, "3.2"] 27 | - repo: https://github.com/pycqa/isort 28 | rev: 6.0.1 29 | hooks: 30 | - id: isort 31 | - repo: https://github.com/pre-commit/pygrep-hooks 32 | rev: v1.10.0 33 | hooks: 34 | - id: python-check-blanket-noqa 35 | - id: python-check-mock-methods 36 | - id: python-no-eval 37 | - id: python-no-log-warn 38 | - repo: https://github.com/psf/black 39 | rev: 25.1.0 40 | hooks: 41 | - id: black 42 | language_version: python3 43 | entry: black --target-version=py38 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # "It doesn't work" - A Djangonaut's Debugging Toolkit 2 | 3 | Welcome to the tutorial! I really appreciate the time you've spent so far 4 | and the time you spend in the future on this tutorial. 5 | 6 | The purpose of this tutorial is to expose you to some of the tools 7 | available to you as a Djangonaut to aid you in your debugging adventures. 8 | 9 | If you didn't attend the talk or would like to view the slides, [you 10 | can find them here](https://docs.google.com/presentation/d/1dmeFD5kGsukQaMinU0BJQyyCeu5ZGR4drc0pJilT10Y/edit?usp=share_link). 11 | 12 | ## Preamble to the setup 13 | 14 | This tutorial is written to give you the experience of working on a real 15 | project. There are tests and adjacent functionality. The tests are written 16 | so that they all pass for each lab, which means they confirm the bugs you 17 | will be finding. 18 | 19 | I suggest reviewing the [Project Overview page](docs/project_overview.md) 20 | which has high level information on the models. It should make it easier 21 | to understand the project. 22 | 23 | Regarding debugging methods, a very useful one is to bisect commits or 24 | find the last known good version, then compare what doesn't work. That 25 | is very easy to do in this environment, but I strongly suggest that you 26 | don't do that unless you're stuck. 27 | 28 | ## How the Labs will work 29 | 30 | There are a total of 7 labs, numbered 1.1-1.3, 2.1-2.4 (it made sense at 31 | the time). Each is structured with a Report, a set of Facts, an 32 | Investigation section, a Conclusion and finally Further Consideration. 33 | 34 | I suggest using the Investigation section, but if you'd like to avoid any 35 | hints in your debugging effort then stop reading after the Report and Facts 36 | sections. 37 | 38 | The Conclusion section will have the solution(s) while Further Consideration 39 | is reserved for asking you to reflect on your own projects. 40 | 41 | 42 | ## Getting Setup 43 | 44 | This section will provide you with the minimal setup for this tutorial. 45 | If you have a preference on setting up a project differently and know that 46 | it works, go for it! Do what you're most comfortable. 47 | 48 | 1. Have Python 3.11+ installed. My assumption is that you have your own 49 | setup decided upon. However, if you're unsure where to go for 50 | this, I'd recommend using https://www.python.org/downloads/ for today. 51 | 1. From the terminal or command prompt, clone the project. 52 | ```shell 53 | git clone git@github.com:tim-schilling/debug-tutorial.git 54 | ``` 55 | If you don't have a SSH key setup, you can use: 56 | ```shell 57 | git clone https://github.com/tim-schilling/debug-tutorial.git 58 | ``` 59 | If you don't have git installed, you can 60 | [download the zip file](https://github.com/tim-schilling/debug-tutorial/archive/refs/heads/main.zip) 61 | and unpack it. Be aware, though, that you won't be able to complete some 62 | of the instructions in the lab without access to the git history. Cloning 63 | the repo is recommended. 64 | 1. Change into project directory 65 | ```shell 66 | cd debug-tutorial 67 | ``` 68 | If you downloaded the zip file, you'll need to ``cd`` to the location where 69 | you unpacked the archive. 70 | 71 | 72 | --- 73 | 74 | ### Windows warning 75 | 76 | If you are comfortable on Windows with Python and have setup multiple projects, ignore this 77 | warning. Do what's comfortable. 78 | 79 | If not, I highly recommend using the Windows Subsystem for Linux 80 | ([docs](https://learn.microsoft.com/en-us/windows/wsl/about)). If you do, the 81 | rest of the instructions will work for you. If you don't have access to that 82 | please scroll down to [Windows non-WSL Setup](#windows-non-wsl-setup). 83 | 84 | --- 85 | 86 | ## MacOS, Linux, Windows WSL Setup 87 | 88 | 1. From the terminal, create a virtual environment. ([venv docs](https://docs.python.org/3/library/venv.html#venv-def)) 89 | ```shell 90 | python -m venv venv 91 | ``` 92 | 1. Active the virtual environment. 93 | ```shell 94 | source venv/bin/activate 95 | ``` 96 | 1. Install the project dependencies. 97 | ```shell 98 | pip install -r requirements.txt 99 | ``` 100 | 1. Create a new .env file. 101 | ```shell 102 | cp .env.dist .env 103 | ``` 104 | If you use this for literally anything but this tutorial, please 105 | change the ``SECRET_KEY`` value in your ``.env`` file. 106 | 1. Create the database for the project. 107 | ```shell 108 | python manage.py migrate 109 | ``` 110 | 1. Verify the tests currently pass, if they don't and you're not sure why, 111 | please ask. 112 | ```shell 113 | python manage.py test 114 | ``` 115 | 1. Create the fake data. This will take a few minutes. 116 | ```shell 117 | python manage.py fake_data 118 | ``` 119 | 1. Create your own super user account. Follow the prompts. 120 | ```shell 121 | python manage.py createsuperuser 122 | ``` 123 | 1. Run the development web server. 124 | ```shell 125 | python manage.py runserver 126 | ``` 127 | 1. Verify the following pages load: 128 | * http://127.0.0.1:8000/ 129 | * http://127.0.0.1:8000/p/ 130 | * http://127.0.0.1:8000/p/author-up-language-push-162/ 131 | 1. Log into the admin ([link](http://127.0.0.1:8000/admin/)) with your super user. 132 | 1. Verify the following pages load: 133 | * http://127.0.0.1:8000/post/create/ 134 | * http://127.0.0.1:8000/analytics/ 135 | 136 | **BOOM!** You're done with the setup. Now that we've accomplished 137 | the boring stuff, let's get to the dopamine rushes. I mean the bugs. 138 | 139 | Proceed to [Lab 1](docs/lab1.md). 140 | 141 | 142 | ## Windows non-WSL Setup 143 | 144 | 1. From the command prompt, create a virtual environment. 145 | ```shell 146 | python3 -m venv venv 147 | ``` 148 | 1. Activate the project 149 | ```shell 150 | .\venv\Scripts\activate 151 | ``` 152 | 1. Install the project dependencies. 153 | ```shell 154 | pip install -r requirements.txt 155 | ``` 156 | 1. Create a new .env file. 157 | ```shell 158 | copy .env.dist .env 159 | ``` 160 | If you use this for literally anything but this tutorial, please 161 | change the ``SECRET_KEY`` value in your ``.env`` file. 162 | 1. Create the database for the project. 163 | ```shell 164 | python -m manage migrate 165 | ``` 166 | 1. Verify the tests currently pass, if they don't and you're not sure why, 167 | please ask. 168 | ```shell 169 | python -m manage test 170 | ``` 171 | 1. Create the fake data. This will take a few minutes. 172 | ```shell 173 | python -m manage fake_data 174 | ``` 175 | 1. Create your own super user account. Follow the prompts. 176 | ```shell 177 | python -m manage createsuperuser 178 | ``` 179 | 1. Run the development web server. 180 | ```shell 181 | python -m manage runserver 182 | ``` 183 | 1. Verify the following pages load: 184 | * http://127.0.0.1:8000/ 185 | * http://127.0.0.1:8000/p/ 186 | * http://127.0.0.1:8000/p/author-up-language-push-162/ 187 | 1. Log into the admin ([link](http://127.0.0.1:8000/admin/)) with your super user. 188 | 1. Verify the following pages load: 189 | * http://127.0.0.1:8000/post/create/ 190 | * http://127.0.0.1:8000/analytics/ 191 | 192 | Proceed to [Lab 1](docs/lab1.md). 193 | -------------------------------------------------------------------------------- /bin/git: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # To rebase on main, run: 4 | # ./bin/git 'rebase main' 5 | 6 | branches=$(eval "git branch | grep 'lab' | xargs") 7 | 8 | git checkout main 9 | for branch in $branches 10 | do 11 | eval "git $1 ${branch}" 12 | done 13 | git checkout main 14 | -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | branches=$(eval "git branch | grep 'lab' | xargs") 4 | 5 | git checkout main 6 | for branch in $branches 7 | do 8 | echo "Testing branch ${branch}" 9 | eval "git checkout ${branch}" 10 | python manage.py test 11 | python manage.py test --tag lab_test 12 | done 13 | git checkout main 14 | -------------------------------------------------------------------------------- /docs/lab1.md: -------------------------------------------------------------------------------- 1 | 2 | # Lab 1 3 | 4 | Let's put those basics to the test. 5 | 6 | ## Lab 1.1 7 | 8 | Change to the correct branch: 9 | 10 | ```shell 11 | git checkout lab-1.1 12 | ``` 13 | 14 | ### Report 15 | 16 | The detail view for the post "Author up language push." is broken. 17 | 18 | To reproduce: 19 | 1. Browse to the [posts page](http://127.0.0.1:8000/p/). 20 | 1. Click on "Read" for the post with the title "Author up language push." 21 | 1. "It doesn't work!" 22 | 23 | ### Facts 24 | 25 | Let's consider what we know: 26 | 27 | - The error message is ``Post matching query does not exist.``, implying the 28 | QuerySet does not contain a Post matching the filters. 29 | - The line that causes the error is on line 80:``post = posts.get(title=lookup)`` 30 | - We know the post exists, we can find it in the 31 | [admin](http://127.0.0.1:8000/admin/newsletter/post/?q=Author+up+language+push.) 32 | - This impacts more than just the post in the report. The detail 33 | view is broken for all posts. 34 | 35 | 36 | ### Investigation 37 | 38 | - What is being used to look up the Post instance? 39 | - Where is that lookup value coming from? 40 | - How does the link generate the URL to supply that lookup value? 41 | 42 | 43 | ### Conclusion 44 | 45 | In this scenario the Post's slug field is being used to generate the 46 | URL, while the view expects the title to be passed in. 47 | 48 | This is an example of a bug in which the error report provides the majority 49 | of the information we need, but we have to read closely and correctly 50 | interpret the information. Try to avoid making assumptions about what you 51 | expect the error to be. Sometimes we'll see an exception type and think, 52 | "Oh, that obviously has to be in XYZ." But upon reading the actual error 53 | message and looking at the stacktrace we discover it's from ABC. 54 | 55 | To solve this, we should pass ``post.slug`` into the calls for generating 56 | ``newsletter:view_post``. I'd also argue that the URL parameter should be 57 | renamed to ``slug`` to be more explicit about what is expected. 58 | 59 | For example, it's easier to spot the bug in this code during a code review: 60 | 61 | ```python 62 | post = posts.get(title=slug) 63 | ``` 64 | 65 | than 66 | 67 | ```python 68 | post = posts.get(title=lookup) 69 | ``` 70 | 71 | ### Further consideration 72 | 73 | This view uses ``post = posts.get(...)`` to fetch a single instance. When 74 | that doesn't exist, it's resulting in an uncaught exception which causes a 500 75 | error. Instead we should use ``post = get_object_or_404(posts, ...)`` to get a 76 | more friendly and less noisy 404 response. But consider what this error report 77 | would look like if that's how the code worked; we wouldn't have an error message 78 | or a stacktrace. We simply would see that the view is returning our HTTP 404 template 79 | implying the Post doesn't exist when we know it does. 80 | 81 | What I'd like you to think about for a minute is how would you approach this problem 82 | in that scenario (the view is returning a 404 response when it shouldn't)? 83 | 84 | 85 | ## Lab 1.2 86 | 87 | This lab covers a very common error, but hard to diagnosis when it's an 88 | [unknown unknown](https://www.techtarget.com/whatis/definition/unknown-unknown). 89 | 90 | Change to the correct branch: 91 | 92 | ```shell 93 | git checkout lab-1.2 94 | ``` 95 | 96 | ### Report 97 | 98 | Creating posts is broken. The open graph image doesn't get uploaded! 99 | 100 | 101 | To reproduce: 102 | 1. Browse to the [create post page](http://127.0.0.1:8000/post/create/). 103 | 1. Fill out the form fields with some filler information. 104 | 1. Select an image for "Open graph image" 105 | 1. Click save. 106 | 1. The update form does not contain the file for the Open graph image field. 107 | 1. "It doesn't work!" 108 | 109 | ### Facts 110 | 111 | Let's consider what we know: 112 | 113 | - The form is hitting the correct view and is using the form class we 114 | expect since the post is created with the non-file fields. 115 | 116 | 117 | ### Investigation 118 | 119 | - Does the file show up on the server side? 120 | - Using the IDE's debugger, a ``breakpoint()`` line or a print statement, inspect 121 | [``request.FILES``](https://docs.djangoproject.com/en/4.1/ref/request-response/#django.http.HttpRequest.FILES). 122 | - Are there any files included? Is ``"open_graph_image"`` a key? 123 | - Is the file being sent from the browser to the server? 124 | - We can use the browser's Developer Tool's Network panel to inspect the request. 125 | - Browse to the [create view](http://127.0.0.1:8000/post/create/). 126 | - Open the developer tools, click on the networking panel. 127 | - Populate and submit the form. 128 | - Look for the image content in the request. 129 | - What value is being sent? Does it look like a file or the name of the file? 130 | - Can we create a Post with an Open graph image [in the admin](http://127.0.0.1:8000/admin/newsletter/post/add/)? 131 | - What is different between creating a Post in the admin versus 132 | creating a Post in our own application? 133 | - Compare the rendered ```` elements for ``open_graph_image``. 134 | - Compare the containing ``form`` element on the for those inputs. 135 | 136 | ### Conclusion 137 | 138 | In this scenario the ``
`` element on that's used for creating/updating 139 | a Post is missing the [proper ``enctype="multipart/form-data""`` attribute](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/enctype). 140 | This is 100% one of those, need to remember parts for web development. 141 | 142 | However, by asking the above questions, we can determine that this attribute is what's 143 | needed without knowing about it in the first place. You're never going to know 144 | everything about everything so you will benefit from developing skills that 145 | help you reveal those unknown unknowns. 146 | 147 | By starting with the fact that the server is not getting a file, we know the 148 | browser isn't doing what we expected. From there, we can compare a broken case 149 | to a working case, the application's create view to the admin's add Post view. 150 | Comparing those requests, we saw that the admin request was including binary data 151 | while the application view simply sent the string of the file name. 152 | 153 | The next step is the most difficult jump in this lab. We either need to 154 | understand how HTML encodes forms/data or have a discerning eye to compare the 155 | two forms and spot the differences then experiment to identify which of those 156 | differences is the cause of the problem. 157 | 158 | Knowing what the ``enctype`` attribute does will always be faster 159 | than investigative trial and error, but the skills used in that investigative 160 | trial and error can be reused on other problems. 161 | 162 | 163 | ### Further consideration 164 | 165 | How can you prevent this type of error from occurring in your applications? What 166 | can you do during development, testing or code reviews to catch this before it 167 | makes it way into production? 168 | 169 | 170 | 171 | ## Lab 1.3 172 | 173 | Change to the correct branch: 174 | 175 | ```shell 176 | git checkout lab-1.3 177 | ``` 178 | 179 | ### Report 180 | 181 | The list of posts is broken! The datetime strings should generally be in 182 | order of most recent to oldest, but they appear jumbled. 183 | 184 | 185 | To reproduce: 186 | 1. Browse to the [list posts](http://127.0.0.1:8000/p/) view. 187 | 1. The dates are ordered from most recent to oldest, but posts such as 188 | "Campaign expect page information wrong more." and "Example 189 | become begin wish painting economic." 190 | appear out of order in comparison to "Skill fight girl north 191 | production thus a." and "New star by chair environmental family Congress degree." 192 | 1. "It doesn't work!" 193 | 194 | ### Facts 195 | 196 | Let's consider what we know: 197 | 198 | - Either the posts are being returned in a jumbled order or the datetime 199 | string is being rendered incorrectly. 200 | 201 | ### Investigation 202 | 203 | - How are the posts being ordered for ``newsletter.views.list_posts``? 204 | - What is rendering the datetime string on the page? 205 | 206 | ### Conclusion 207 | 208 | The posts are being ordered correctly, ``publish_at`` first, falling back to 209 | ``created`` when unset. Therefore the template must be rendering incorrectly. 210 | This can be confirmed by comparing the fields of the posts that render 211 | [correctly](http://127.0.0.1:8000/admin/newsletter/post/?slug=skill-fight-girl-north-production-thus-a-58113) 212 | and [incorrectly](http://127.0.0.1:8000/admin/newsletter/post/?slug=campaign-expect-page-information-wrong-more-8656). 213 | From the admin, we can see the correctly rendering Post does not have a value 214 | for ``publish_at``, while the incorrectly rendering Post does have a value 215 | for ``publish_at``. We can see that the ``publish_at`` value is significantly 216 | more recent than the ``created`` value which explains why it appears in the 217 | list of posts at the top. 218 | 219 | We can also infer that since the ``publish_at`` is the ``order_by`` value, 220 | that it should also be the value used when rendering the datetime string. 221 | 222 | Now we know that the template is likely using the ``created`` field to render 223 | the datetime string when it shouldn't be. However, the template that's used 224 | to render the individual posts doesn't contain ``post.created`` explicitly. 225 | But we do see that there's a custom template tag that's rendering the datetime 226 | called ``nice_datetime``. Looking at that code, we indeed find the 227 | ``timestamp`` variable being set to the value of ``post.created`` when it 228 | should be ``post.publish_date``. 229 | 230 | 231 | ### Further consideration 232 | 233 | This is a straightforward example with an underlying concept. You must 234 | be able to switch between assuming that some part of the code works and knowing 235 | that some part of the code must contain an error. Was there a time when you 236 | incorrectly assumed a bug could not be in a component only to find that your 237 | assumption was wrong? Why were you so confident in that assumption? How can 238 | you learn to hold these opinions more loosely in the future? 239 | 240 | 241 | --- 242 | 243 | Good work! 244 | 245 | I hope you were able to find something to take away from 246 | this lab. Proceed to [Lab 2](lab2.md). 247 | -------------------------------------------------------------------------------- /docs/lab2.md: -------------------------------------------------------------------------------- 1 | 2 | # Lab 2 3 | 4 | Let's kick it up a notch with some [Django Debug Toolbar](https://github.com/jazzband/django-debug-toolbar/)! 5 | 6 | ## Lab 2.1 7 | 8 | Change to the correct branch: 9 | 10 | ```shell 11 | git checkout lab-2.1 12 | ``` 13 | 14 | ### Report 15 | 16 | The site seems to be running slower lately. Please make the site fast again! 17 | 18 | To reproduce: 19 | 1. Browse to the [posts page](http://127.0.0.1:8000/p/). 20 | 1. Browse to a [post's page](http://127.0.0.1:8000/p/author-up-language-push-162/). 21 | 1. Browse to the [posts admin page](http://127.0.0.1:8000/admin/newsletter/post/). 22 | 1. Why are these slow? 23 | 24 | Note, given that we're dealing with SQLite locally, the "slowness" is largely 25 | imaginary so please play along. Additionally the Post detail view has caching. 26 | The cache can be cleared by opening a Django shell (``python -m manage shell``) 27 | and running: 28 | 29 | ```python 30 | from django.core.cache import cache 31 | cache.clear() 32 | ``` 33 | 34 | ### Facts 35 | 36 | Let's consider what we know: 37 | 38 | - The pages are rendering correctly and there are no errors. 39 | - The pages were rendering "fast" earlier, but over time as the data set has grown 40 | they have slowed down. 41 | 42 | 43 | ### Investigation 44 | 45 | - What queries are running? 46 | - Use the Django Debug Toolbar's SQL Panel 47 | - In a properly working case, the count should be relatively low, we're only rendering 48 | one type of data on the page. How many do we see? 49 | - Should a query be using an index? 50 | - Fields that are likely to qualify for indexes are those that are used in 51 | filtering and ordering. 52 | - Use the SQL Panel's "Explain" button to view the database's breakdown 53 | of the query. Look for portions that use ``SCAN`` without mention of an 54 | index. This means they are iterating over the entire table comparing all 55 | the values. 56 | 57 | 58 | ### Conclusion 59 | 60 | Admittedly, this is a hard area to know the "fix" for. Performance optimization 61 | is a never ending, relentless battle. 62 | 63 | #### Post listing admin 64 | The admin page suffers from a [N+1 problem](https://ddg.gg?q=N%2B1+django) and 65 | needs to make use of [``prefetch_related``](https://docs.djangoproject.com/en/4.1/ref/models/querysets/#django.db.models.query.QuerySet.prefetch_related) 66 | since it renders each category of the post on the page. This can be chained on 67 | the QuerySet by overriding ``ModelAdmin.get_queryset``. The need for 68 | ``prefetch_related`` is evident from the 100 duplicated queries that are fetching data from the table 69 | ``newsletter_post_categories``. That table is the intermediate table used with 70 | a ``models.ManyToManyField``. There is a slight wrinkle in that the categories 71 | are being rendered in order of the categories' titles. In order to push that 72 | to the database, you must use a [``Prefetch``](https://docs.djangoproject.com/en/4.1/ref/models/querysets/#django.db.models.Prefetch) 73 | object that specifies: 74 | 75 | ```python 76 | def get_queryset(self, request): 77 | return super().get_queryset(request).prefetch_related( 78 | Prefetch( 79 | 'categories', 80 | queryset=Category.objects.all().order_by('title') 81 | ) 82 | ) 83 | ``` 84 | 85 | This means ``PostAdmin.categories_list`` would change to: 86 | 87 | ```python 88 | @admin.decorators.display(description="Categories") 89 | def categories_list(self, obj): 90 | return ", ".join(category.title for category in obj.categories.all()) 91 | ``` 92 | 93 | 94 | There should also be an indexes on 95 | ``created``. Arguments could be made to add indexes on the fields that are 96 | search-able and ``is_published``. I would stop at ``created`` because that's used 97 | on the default loading. Any other cases would need to be proven as common 98 | occurrences. 99 | 100 | #### Post listing 101 | 102 | The listing of posts also suffers from an N+1 problem, though it could use 103 | the more simple approach of ``prefetch_related("categories")``. This is because 104 | the template ``posts/includes/list_item.html`` doesn't order the categories 105 | in ``{% for category in post.categories.all %}``. 106 | 107 | There are two facets to this view that could benefit from an index. The ordering 108 | based on ``Coalesce('publish_at', 'created')`` and the filter on ``is_published=True``. 109 | This scenario could benefit from a multi-column index. The typical maxim is 110 | to apply that index on the most generic columns first, to the more specific. In 111 | this case, it would first be ``is_published``, then the coalesced datetime fields: 112 | 113 | This would look like: 114 | 115 | ```python 116 | 117 | class Post(TimestampedModel): 118 | # ... 119 | class Meta: 120 | # ... 121 | indexes = [ 122 | models.Index( 123 | F("is_published"), Coalesce("publish_at", "created").desc(), 124 | name='published_posts_idx' 125 | ) 126 | ] 127 | ``` 128 | 129 | Interestingly enough (I'm not a DBA), the above only covers the count 130 | portion of the view. The pagination that selects the data does not hit 131 | this index and requires a different index. 132 | 133 | ```python 134 | 135 | class Post(TimestampedModel): 136 | # ... 137 | class Meta: 138 | # ... 139 | indexes = [ 140 | models.Index( 141 | Coalesce("publish_at", "created").desc(), 142 | name='recent_first_idx' 143 | ) 144 | ] 145 | ``` 146 | 147 | As a peer, I would probably resort to only using ``recent_first_idx`` and 148 | waiting until the site slowed down and required a more finely tuned index. 149 | However, there is the argument to be made that both are necessary. 150 | 151 | #### Post detail 152 | 153 | This view generates a fixed number of queries, but does a scan on the 154 | entire table for the slug field, ``SCAN TABLE newsletter_post``. The solution 155 | here is to either use ``CharField(..., db_index=True)`` or switch to 156 | ``SlugField(...)`` which automatically creates an index under the hood. 157 | 158 | 159 | ### Further consideration 160 | 161 | Please keep in mind to avoid pre-optimizations. This exercise exists to help 162 | you go to the furthest extent possible in those cases where we need to 163 | squeeze all the speedy goodness out of the application. 164 | 165 | That said, consider the application(s) you work on. What are the most 166 | frequently used parts? What do you have in place to catch slowness? 167 | Could you benefit from using [``assertNumQueries(...)``](https://docs.djangoproject.com/en/4.1/topics/testing/tools/#django.test.TransactionTestCase.assertNumQueries)? 168 | 169 | 170 | ## Lab 2.2 171 | 172 | Change to the correct branch: 173 | 174 | ```shell 175 | git checkout lab-2.2 176 | ``` 177 | 178 | ### Report 179 | 180 | The analytics view specifically is running slow. Can you take a look at it? 181 | 182 | To reproduce: 183 | 1. Browse to the [analytics page](http://127.0.0.1:8000/analytics/). 184 | 1. Why has this become slow? 185 | 186 | Note, if you're running a nice machine, this slowness may be imaginary. So please 187 | humor me and pretend it's slow. 188 | 189 | ### Facts 190 | 191 | Let's consider what we know: 192 | 193 | - The page was rendering correctly and there are no errors. 194 | - The page was rendering "fast" when it was first introduced, but has since slowed 195 | down. 196 | 197 | 198 | ### Investigation 199 | 200 | - Do we have a fixed number of queries running? 201 | - Are the queries running in a reasonable amount of time? 202 | - Is there a section of code that's running slowly? 203 | - Enable the Profiling panel and refresh the page. 204 | - If the slowness isn't obviously from the SQL panel, we need to see where time 205 | is being spent in the application. 206 | - Look for long durations or a high number of iterations. 207 | - Can the code be refactored to run more efficiently? 208 | 209 | ### Conclusion 210 | 211 | The solution here is to rework the entire Analytics view to push the calculations 212 | into the database. The view is doing simple filter and aggregate work so it makes 213 | little sense to pull the entire model instance out of the database for each row. 214 | Additionally, it's creating a new datetime instance in ``determine_buckets`` and 215 | performing three logical comparisons for every ``Post`` and ``Subscription``. Not 216 | to mention ``analyze_categorized_model`` does another three logical comparisons 217 | for each instance. 218 | 219 | The Profiling panel highlights the lines of code that exist in our project to 220 | help draw our eyes to where the problem is most likely caused. 221 | 222 | ### Further consideration 223 | 224 | Similar to the previous lab, knowing when to use a profiler can be difficult. 225 | Rather than applying it everywhere, it makes the most sense to start with either 226 | knowingly slow areas or critical areas. Take a moment to reflect on your own 227 | coding journey and any times that a profiler would have been helpful. 228 | 229 | 230 | ## Lab 2.3 231 | 232 | Change to the correct branch: 233 | 234 | ```shell 235 | git checkout lab-2.3 236 | ``` 237 | 238 | ### Report 239 | 240 | I think the analytics view is broken. The values don't match what I'd expect 241 | to see, can you look into them? 242 | 243 | To reproduce: 244 | 1. Browse to the [analytics page](http://127.0.0.1:8000/analytics/). 245 | 1. It doesn't work. 246 | 247 | ### Facts 248 | 249 | Let's consider what we know: 250 | 251 | - The page is rendering correctly but the data may be wrong. 252 | - It's unknown if this was ever working correctly, but it certainly is wrong now. 253 | 254 | 255 | ### Investigation 256 | 257 | - What values should we expect to see on the analytics page? 258 | - For this you need to inspect the database somehow, here are a few options. 259 | - Open up a repl with ``python -m manage shell`` and query the data. 260 | - ```python 261 | from datetime import timedelta 262 | from django.utils import timezone 263 | from project.newsletter.models import Post, Subscription 264 | print( 265 | "Posts", 266 | Post.objects.filter( 267 | created__gte=timezone.now() - timedelta(days=180) 268 | ).count() 269 | ) 270 | print( 271 | "Subscriptions", 272 | Subscription.objects.filter( 273 | created__gte=timezone.now() - timedelta(days=180) 274 | ).count() 275 | ) 276 | ``` 277 | - Use the SQLite browser by opening the file directly (you must have SQLite already installed). 278 | ```sqlite 279 | SELECT COUNT(*) FROM newsletter_post WHERE created >= date('now','-180 day'); 280 | SELECT COUNT(*) FROM newsletter_subscription WHERE created >= date('now','-180 day'); 281 | ``` 282 | - Open a SQLite shell with ``python -m manage dbshell`` (you must have SQLite already installed). 283 | ```sqlite 284 | SELECT COUNT(*) FROM newsletter_post WHERE created >= date('now','-180 day'); 285 | SELECT COUNT(*) FROM newsletter_subscription WHERE created >= date('now','-180 day'); 286 | ``` 287 | - What is different in the query that causes the Post count to return correctly, 288 | but not the Subscription count? 289 | - Use the SQL Panel to inspect the query that is counting the objects. 290 | - Click on the "+" button on the left to expand the query. 291 | - It may be easier to read clicking on the "Select" button. 292 | - You can also improve the readability by copying and pasting the query into 293 | an online formatter such as [sqlformat.org](https://sqlformat.org) 294 | - Does removing ``categories__isnull=False`` from the ``Subscription`` QuerySet 295 | resolve the issue? 296 | - Does adding ``categories__isnull=False`` to the ``Post`` QuerySet cause 297 | an unexpected result? 298 | - Why does the inclusion of the join, ``LEFT OUTER JOIN "newsletter_subscription_categories"`` 299 | cause duplicates? 300 | - This is because the joined table may have multiple matches for any Subscription 301 | row causing the ``Count`` function to find more than one, leading to an inflated 302 | count. 303 | - This can be fixed by using an appropriate ``GROUP BY`` clause in the SQL. 304 | - What does the Django ORM's ``Count`` expression offer in terms of parameters? 305 | - You can use the [docs](https://docs.djangoproject.com/en/4.1/ref/models/querysets/#id9) 306 | or inspect [the code](https://github.com/django/django/blob/stable/4.1.x/django/db/models/aggregates.py#L145-L149) 307 | (right click on ``Count`` and choose "Go To Definition") in 308 | your IDE if you're using PyCharm or VSCode. 309 | - We can see that ``Count`` subclasses [``Aggregate`` which has ``distinct`` as 310 | a param](https://github.com/django/django/blob/e151df24ae2b0a388fc334a6f1dcb31110d5819a/django/db/models/aggregates.py#L25-L35). 311 | 312 | ### Conclusion 313 | 314 | The solution here is to use ``distinct=True`` in the call to ``Count``. Traversing 315 | many to many relationships or reverse foreign key relationships can be tricky. You 316 | don't want to use ``distinct()`` and ``distinct=True`` everywhere because if it's 317 | unnecessary, you're needlessly slowing down your application. 318 | 319 | This problem highlights one of the underlying issues with using an ORM. It 320 | abstracts away the underlying SQL and makes it easy to misunderstand what the 321 | database is doing. 322 | 323 | ### Further consideration 324 | 325 | This bug is very insidious, it's easy to miss in development and a code review. 326 | It's possible to write a test that misses it if the data is setup such 327 | that there is only one category for each subscription. Finally, a developer may 328 | not be familiar with the data to know when a value "looks wrong" resulting in 329 | the bug being found downstream by the actual users. Can you think of some practices 330 | that would help avoid this bug? 331 | 332 | 333 | ## Lab 2.4 334 | 335 | Change to the correct branch: 336 | 337 | ```shell 338 | git checkout lab-2.4 339 | ``` 340 | 341 | ### Report 342 | 343 | Thank you for adding caching to the site recently, but I think you broke something. 344 | A post that shouldn't be public is available to the public now. 345 | 346 | To reproduce: 347 | 1. Log into your staff account and browse to the [published posts](http://127.0.0.1:8000/p/). 348 | 1. Use an incognito window to also view the [published posts](http://127.0.0.1:8000/p/). 349 | 1. In the incognito window, click to read a post. 350 | 1. In the staff authenticated window, click "Set to private" for the post opened 351 | in the incognito window. 352 | 1. In the incognito window, refresh the page. 353 | 1. This should 404, but it's still available to the public. 354 | 355 | 356 | ### Facts 357 | 358 | Let's consider what we know: 359 | 360 | - The page was properly requiring authenticated users for private 361 | posts before caching was added. 362 | - We only set the cached response for ``view_post`` when a ``Post`` 363 | is public. 364 | - We are clearing the cache for a ``Post`` when it's updated via a 365 | signal handler in ``receivers.py``. 366 | 367 | ### Investigation 368 | 369 | - Why does the post still return despite being private? 370 | - Use the Django Debug Toolbar's Cache Panel to inspect what cache 371 | operations are occurring. 372 | - Does the page return a 404 when the cache isn't set? 373 | - Open a Django shell ``python -m manage shell``: 374 | ```python 375 | from django.core.cache import cache 376 | cache.clear() 377 | ``` 378 | - Refresh the page to see if it 404's. 379 | - Does updating a post to be private via the update view result in the 380 | cache being busted? 381 | 1. You can edit a post via the "Edit" link near the top of the detail page. 382 | Otherwise the URL is ``http://127.0.0.1:8000/post//update/`` 383 | 1. Save the post. 384 | - The response is a redirect to avoid multiple posts. 385 | - However, this means the toolbar is presenting you the data for the 301 386 | redirect response, not your POST request. 387 | 1. Click on the History Panel 388 | 1. Find the POST request to the ``.../update/`` URL and click "Switch". 389 | 1. Click on the Cache Panel and view the operations. 390 | - Does "Set to private" / "Set to public" delete the cache instance? 391 | 1. Browse to the [post listing page](http://127.0.0.1:8000/p/). 392 | 1. Click "Set to private" or "Set to public" 393 | 1. Click the History Panel. 394 | 1. Find the POST to the ``.../toggle_privacy/`` URL and click "Switch". 395 | 1. Inspect the Cache Panel for operations. 396 | - What is different between how ``toggle_post_privacy`` and ``update_post`` 397 | save the data changes? 398 | 399 | ### Conclusion 400 | 401 | The root cause here is that ``Post.objects.filter(...).update()`` does not 402 | trigger the ``post_save`` signal. This means the cache key is not deleted 403 | leading to posts continuing to be publically available. 404 | 405 | There are a couple of solutions. One is to clear the cache in ``toggle_post_privacy``. 406 | Another would be to avoid using ``.update()``, but only change the public 407 | field via: 408 | 409 | ```python 410 | post = get_objects_or_404(...) 411 | post.is_public != post.is_public 412 | # Include updated to keep our updated timestamp fresh. 413 | post.save(update_fields=['is_public', 'updated']) 414 | ``` 415 | 416 | The above will only change the fields we intended to change. It does mean 417 | a second database query (1 to fetch, 1 to update), but that's pretty minor. 418 | 419 | A third option is to stop using on ``post_save`` for cache invalidation 420 | and to handle all that logic manually within functions in the 421 | ``operations.py`` module. This approach is has more philosophical 422 | implications that you'd need to sort out. Such as, what do you do about 423 | ``ModelForm`` instances since they mutate the database? 424 | 425 | 426 | ### Further consideration 427 | 428 | You probably already know the typical questions I am going to ask. 429 | So I won't ask them. After using the Django Debug Toolbar throughout 430 | this lab, what would make your life better as a developer? Could you 431 | find some time to submit an issue and open a PR? 432 | -------------------------------------------------------------------------------- /docs/project_overview.md: -------------------------------------------------------------------------------- 1 | # Project Overview 2 | 3 | This section is to provide a high level overview of the project. You 4 | don't need to have a perfect mental model of the project, but knowing 5 | how things generally fit together will be helpful. 6 | 7 | ## Model Relationships 8 | 9 | There are only a few models, but they are inter-related. They are 10 | Post, Category, User, Subscription, SubscriptionNotification. 11 | 12 | - ``Post`` has a M2M (many to many) to ``Category``. 13 | - ``Post`` has a FK (foreign key / 1 to many) to ``User``. 14 | - ``User`` has a nullable 1:1 (one to one) to ``Subscription``. 15 | - ``Subscription`` has a M2M to ``Category`` 16 | - ``SubscriptionNotification`` has a FK to ``Subscription``. 17 | - ``SubscriptionNotification`` has a FK to ``Post``. 18 | 19 | ```mermaid 20 | erDiagram 21 | Post }o--o{ Category : "0..* to 0..*" 22 | Post ||--o{ User : "1 to 0..*" 23 | User ||--o| Subscription : "1 to 0..1" 24 | Subscription }o--o{ Category : "0..* to 0..*" 25 | SubscriptionNotification ||--|{ Subscription : "1 to 1..*" 26 | SubscriptionNotification ||--|{ Post : "1 to 1..*" 27 | ``` 28 | 29 | ## Post 30 | 31 | The Post model is worth discussing briefly as it's the center of the 32 | project. The fields to be aware of are: 33 | 34 | - ``is_public`` - Controls whether an unauthenticated user can view the post on the site. 35 | - ``is_published`` - Signals that a post is no longer a draft and should be accessible to non-staff users. 36 | - ``publish_at`` - Allows the author to schedule a post to be published in the future. 37 | - ``publish_date`` - This is a calculated property that identfies the Post's publish datetime. 38 | It returns ``publish_at`` if set otherwise ``created``. 39 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.config.settings") 9 | 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/project/__init__.py -------------------------------------------------------------------------------- /project/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/project/config/__init__.py -------------------------------------------------------------------------------- /project/config/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for newsletter project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.config.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /project/config/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for newsletter project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.0.5. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.0/ref/settings/ 11 | """ 12 | 13 | import os 14 | from pathlib import Path 15 | 16 | import environ 17 | from django.urls import reverse_lazy 18 | 19 | env = environ.Env( 20 | # set casting, default value 21 | DEBUG=(bool, False) 22 | ) 23 | 24 | BASE_DIR = Path(__file__).resolve().parent.parent.parent 25 | 26 | environ.Env.read_env(os.path.join(BASE_DIR, ".env")) 27 | 28 | SECRET_KEY = env("SECRET_KEY") 29 | 30 | # SECURITY WARNING: don't run with debug turned on in production! 31 | DEBUG = env("DEBUG") 32 | 33 | ALLOWED_HOSTS = [] 34 | 35 | 36 | # Application definition 37 | 38 | INSTALLED_APPS = [ 39 | "django.contrib.admin", 40 | "django.contrib.auth", 41 | "django.contrib.contenttypes", 42 | "django.contrib.sites", 43 | "django.forms", 44 | "django.contrib.humanize", 45 | "django.contrib.sessions", 46 | "django.contrib.messages", 47 | "django.contrib.staticfiles", 48 | # Third-party apps 49 | "anymail", 50 | "martor", 51 | # Project apps 52 | "project.newsletter.apps.NewsletterAppConfig", 53 | "project.data.apps.DataAppConfig", 54 | ] 55 | 56 | MIDDLEWARE = [ 57 | "django.middleware.security.SecurityMiddleware", 58 | "django.contrib.sessions.middleware.SessionMiddleware", 59 | "django.middleware.common.CommonMiddleware", 60 | "django.middleware.csrf.CsrfViewMiddleware", 61 | "django.contrib.auth.middleware.AuthenticationMiddleware", 62 | "django.contrib.messages.middleware.MessageMiddleware", 63 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 64 | ] 65 | 66 | ROOT_URLCONF = "project.config.urls" 67 | 68 | FORM_RENDERER = "django.forms.renderers.TemplatesSetting" 69 | 70 | TEMPLATES = [ 71 | { 72 | "BACKEND": "django.template.backends.django.DjangoTemplates", 73 | "DIRS": [BASE_DIR / "project/templates"], 74 | "APP_DIRS": True, 75 | "OPTIONS": { 76 | "debug": True, 77 | "context_processors": [ 78 | "django.template.context_processors.debug", 79 | "django.template.context_processors.request", 80 | "django.contrib.auth.context_processors.auth", 81 | "django.contrib.messages.context_processors.messages", 82 | ], 83 | }, 84 | }, 85 | ] 86 | 87 | ASGI_APPLICATION = "project.config.asgi.application" 88 | 89 | SITE_ID = 1 90 | 91 | 92 | # Database 93 | # https://docs.djangoproject.com/en/4.0/ref/settings/#databases 94 | 95 | DATABASES = { 96 | "default": { 97 | "ENGINE": "django.db.backends.sqlite3", 98 | "NAME": BASE_DIR / "db.sqlite3", 99 | } 100 | } 101 | 102 | 103 | # Password validation 104 | # https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators 105 | 106 | AUTH_PASSWORD_VALIDATORS = [ 107 | { 108 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 109 | }, 110 | { 111 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 112 | }, 113 | { 114 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 115 | }, 116 | { 117 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 118 | }, 119 | ] 120 | 121 | 122 | # Internationalization 123 | # https://docs.djangoproject.com/en/4.0/topics/i18n/ 124 | 125 | LANGUAGE_CODE = "en-us" 126 | 127 | TIME_ZONE = "UTC" 128 | 129 | USE_I18N = True 130 | 131 | USE_TZ = True 132 | 133 | 134 | # Static files (CSS, JavaScript, Images) 135 | # https://docs.djangoproject.com/en/4.0/howto/static-files/ 136 | 137 | STATIC_URL = "static/" 138 | STATICFILES_DIRS = [ 139 | BASE_DIR / "static", 140 | ] 141 | STATIC_ROOT = BASE_DIR / "static-files" 142 | 143 | MEDIA_URL = "media/" 144 | MEDIA_ROOT = BASE_DIR / "media" 145 | 146 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 147 | 148 | 149 | # Security 150 | CSRF_COOKIE_SECURE = True 151 | SESSION_COOKIE_SECURE = False # This is only for local development 152 | SECURE_SSL_REDIRECT = False 153 | 154 | 155 | # File and Data upload settings 156 | DATA_UPLOAD_MAX_MEMORY_SIZE = 5242880 # 5MB 157 | FILE_UPLOAD_MAX_MEMORY_SIZE = 5242880 # 5MB 158 | 159 | # Email settings 160 | EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" 161 | 162 | # Login URL setting 163 | LOGIN_URL = reverse_lazy("auth_login") 164 | LOGIN_REDIRECT_URL = reverse_lazy("newsletter:landing") 165 | 166 | # Martor / Markdown editor 167 | 168 | # Choices are: "semantic", "bootstrap" 169 | MARTOR_THEME = "semantic" 170 | MARTOR_UPLOAD_PATH = "images/uploads/" 171 | MARTOR_UPLOAD_URL = reverse_lazy("newsletter:markdown_uploader") 172 | 173 | MARTOR_ALTERNATIVE_SEMANTIC_JS_FILE = "fomantic/fomantic-ui-2.8.8.semantic.min.js" 174 | MARTOR_ALTERNATIVE_SEMANTIC_CSS_FILE = "fomantic/fomantic-ui-2.8.8.semantic.min.css" 175 | MARTOR_ALTERNATIVE_JQUERY_JS_FILE = "fomantic/jquery.min.js" 176 | 177 | 178 | # Test settings 179 | TEST_RUNNER = "project.tests.runner.ProjectTestRunner" 180 | 181 | if DEBUG: # pragma: no cover 182 | # Debug Toolbar settings 183 | INSTALLED_APPS += ["debug_toolbar"] 184 | MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware") 185 | INTERNAL_IPS = ["127.0.0.1", "10.0.2.2"] 186 | 187 | LOGGING = { 188 | "version": 1, 189 | "disable_existing_loggers": False, 190 | "formatters": { 191 | "verbose": { 192 | "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}", 193 | "style": "{", 194 | }, 195 | "simple": { 196 | "format": "{levelname} {message}", 197 | "style": "{", 198 | }, 199 | }, 200 | "filters": { 201 | "require_debug_true": { 202 | "()": "django.utils.log.RequireDebugTrue", 203 | }, 204 | }, 205 | "handlers": { 206 | "console": { 207 | "level": "INFO", 208 | "filters": ["require_debug_true"], 209 | "class": "logging.StreamHandler", 210 | "formatter": "simple", 211 | }, 212 | "mail_admins": { 213 | "level": "ERROR", 214 | "class": "django.utils.log.AdminEmailHandler", 215 | }, 216 | "error_file": { 217 | "level": "DEBUG", 218 | "class": "logging.FileHandler", 219 | "filename": "error.log", 220 | }, 221 | }, 222 | "loggers": { 223 | "django": { 224 | "handlers": ["console"], 225 | "propagate": True, 226 | }, 227 | "django.request": { 228 | "handlers": ["mail_admins"], 229 | "level": "ERROR", 230 | "propagate": True, 231 | }, 232 | "project": { 233 | "handlers": ["console"], 234 | "level": "INFO", 235 | }, 236 | }, 237 | } 238 | 239 | 240 | # Django Debug Toolbar on Windows hack 241 | # https://stackoverflow.com/a/16355034/1637351 242 | # This really should be done by the user in their windows registry. 243 | if DEBUG: 244 | import mimetypes 245 | 246 | mimetypes.add_type("text/javascript", ".js", True) 247 | -------------------------------------------------------------------------------- /project/config/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.conf.urls.static import static 3 | from django.contrib import admin 4 | from django.urls import include, path 5 | 6 | import project.newsletter.urls 7 | 8 | urlpatterns = ( 9 | [ 10 | path("admin/", admin.site.urls), 11 | path("account/", include("registration.backends.simple.urls")), 12 | path("i18n/", include("django.conf.urls.i18n")), 13 | path("martor/", include("martor.urls")), 14 | path("", include(project.newsletter.urls)), 15 | ] 16 | + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 17 | + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 18 | ) 19 | 20 | if settings.DEBUG: 21 | urlpatterns += [ # pragma: no cover 22 | path("__debug__/", include("debug_toolbar.urls")), 23 | ] 24 | -------------------------------------------------------------------------------- /project/config/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for newsletter project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.config.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /project/data/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/project/data/__init__.py -------------------------------------------------------------------------------- /project/data/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DataAppConfig(AppConfig): 5 | name = "project.data" 6 | -------------------------------------------------------------------------------- /project/data/author.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | 3 | 4 | def generate_data() -> User: 5 | if user := User.objects.filter(is_superuser=True).order_by("date_joined").first(): 6 | return user 7 | 8 | user, _ = User.objects.get_or_create( 9 | username="default_user", 10 | defaults={"first_name": "Django", "last_name": "Pythonista"}, 11 | ) 12 | return user 13 | -------------------------------------------------------------------------------- /project/data/category.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from project.newsletter.models import Category 4 | 5 | 6 | @dataclass 7 | class CategoryData: 8 | career: Category 9 | family: Category 10 | social: Category 11 | technical: Category 12 | 13 | 14 | def generate_data() -> CategoryData: 15 | social, _ = Category.objects.update_or_create( 16 | slug="social", defaults={"title": "Social"} 17 | ) 18 | technical, _ = Category.objects.update_or_create( 19 | slug="technical", defaults={"title": "Technical"} 20 | ) 21 | career, _ = Category.objects.update_or_create( 22 | slug="career", defaults={"title": "Career"} 23 | ) 24 | family, _ = Category.objects.update_or_create( 25 | slug="family", defaults={"title": "Family"} 26 | ) 27 | return CategoryData( 28 | career=career, 29 | family=family, 30 | social=social, 31 | technical=technical, 32 | ) 33 | -------------------------------------------------------------------------------- /project/data/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/project/data/management/__init__.py -------------------------------------------------------------------------------- /project/data/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/project/data/management/commands/__init__.py -------------------------------------------------------------------------------- /project/data/management/commands/fake_data.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from contextlib import contextmanager 3 | 4 | from django.core.management.base import BaseCommand 5 | from django.utils import timezone 6 | 7 | from project.data import ( 8 | author, 9 | category, 10 | markdown, 11 | subscribers, 12 | subscription_notifications, 13 | ) 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | @contextmanager 19 | def log(event): 20 | start = timezone.now() 21 | logger.info(f"{event} - start") 22 | yield 23 | end = timezone.now() 24 | logger.info(f"{event} - end, {(end-start).total_seconds()}s") 25 | 26 | 27 | class Command(BaseCommand): 28 | """Generate fake data for the newsletter app.""" 29 | 30 | def handle(self, *args, **options): 31 | with log("Categories"): 32 | categories = category.generate_data() 33 | 34 | with log("Posts"): 35 | markdown.generate_data( 36 | author.generate_data(), 37 | categories.social, 38 | [categories.career, categories.family, categories.technical], 39 | ) 40 | with log("Subscribers"): 41 | subscribers.generate_data(categories) 42 | 43 | with log("Subscription Notifications"): 44 | subscription_notifications.generate_data() 45 | -------------------------------------------------------------------------------- /project/data/markdown.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from itertools import cycle 3 | 4 | from faker import Faker 5 | from faker.utils.text import slugify 6 | from mdgen import MarkdownPostProvider 7 | from mdgen.core import MarkdownImageGenerator 8 | 9 | from project.newsletter.models import Post 10 | 11 | fake = Faker() 12 | fake.add_provider(MarkdownPostProvider) 13 | # Seed the randomization to support consistent randomization. 14 | Faker.seed(2022) 15 | image_generator = MarkdownImageGenerator() 16 | 17 | 18 | def header(level=1): 19 | lead = "#" * level 20 | return lead + fake.sentence() 21 | 22 | 23 | def short_photo_update(): 24 | return "\n\n".join( 25 | [ 26 | header(level=1), 27 | fake.paragraph(), 28 | image_generator.new_image( 29 | fake.sentence(), 30 | f"https://picsum.photos/{fake.pyint(200, 500)}", 31 | fake.text(), 32 | ), 33 | fake.paragraph(), 34 | ] 35 | ) 36 | 37 | 38 | def generate_data(user, image_category, post_categories): 39 | image_posts = [] 40 | for i in range(1500): 41 | title = fake.sentence() 42 | slug = slugify(title) + f"-{fake.pyint(10, 99999)}" 43 | publish_at = ( 44 | fake.date_time_between_dates( 45 | datetime_start=datetime(2020, 1, 1, tzinfo=timezone.utc), 46 | datetime_end=datetime(2022, 10, 12, tzinfo=timezone.utc), 47 | tzinfo=timezone.utc, 48 | ) 49 | if fake.pybool() 50 | else None 51 | ) 52 | created = fake.date_time_between_dates( 53 | datetime_start=datetime(2020, 1, 1, tzinfo=timezone.utc), 54 | datetime_end=datetime(2022, 10, 12, tzinfo=timezone.utc), 55 | tzinfo=timezone.utc, 56 | ) 57 | if publish_at and publish_at < created: 58 | created = publish_at 59 | image_posts.append( 60 | Post( 61 | created=created, 62 | author=user, 63 | title=title, 64 | slug=slug, 65 | summary=fake.paragraph(), 66 | content=short_photo_update(), 67 | is_public=True, 68 | is_published=True, 69 | publish_at=publish_at, 70 | ) 71 | ) 72 | Post.objects.bulk_create(image_posts, batch_size=500, ignore_conflicts=True) 73 | 74 | category_through = Post.categories.through 75 | category_through.objects.bulk_create( 76 | [ 77 | category_through(category_id=image_category.id, post_id=post_id) 78 | for post_id in Post.objects.filter(categories__isnull=True).values_list( 79 | "id", flat=True 80 | ) 81 | ], 82 | batch_size=500, 83 | ignore_conflicts=True, 84 | ) 85 | 86 | general_posts = [] 87 | for i in range(1500): 88 | title = fake.sentence() 89 | slug = slugify(title) + f"-{fake.pyint(10, 9999)}" 90 | publish_at = ( 91 | fake.date_time_between_dates( 92 | datetime_start=datetime(2020, 1, 1, tzinfo=timezone.utc), 93 | datetime_end=datetime(2022, 10, 12, tzinfo=timezone.utc), 94 | tzinfo=timezone.utc, 95 | ) 96 | if fake.pybool() 97 | else None 98 | ) 99 | created = fake.date_time_between_dates( 100 | datetime_start=datetime(2020, 1, 1, tzinfo=timezone.utc), 101 | datetime_end=datetime(2022, 10, 12, tzinfo=timezone.utc), 102 | tzinfo=timezone.utc, 103 | ) 104 | if publish_at and publish_at < created: 105 | created = publish_at 106 | general_posts.append( 107 | Post( 108 | created=created, 109 | author=user, 110 | title=title, 111 | slug=slug, 112 | summary=fake.paragraph(), 113 | content=fake.post("medium"), 114 | is_public=True, 115 | is_published=True, 116 | publish_at=publish_at, 117 | ) 118 | ) 119 | Post.objects.bulk_create(general_posts, batch_size=500, ignore_conflicts=True) 120 | 121 | category_cycle = cycle(post_categories) 122 | category_through.objects.bulk_create( 123 | [ 124 | category_through(category_id=next(category_cycle).id, post_id=post_id) 125 | for post_id in Post.objects.filter(categories__isnull=True).values_list( 126 | "id", flat=True 127 | ) 128 | ], 129 | batch_size=500, 130 | ignore_conflicts=True, 131 | ) 132 | -------------------------------------------------------------------------------- /project/data/subscribers.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from datetime import datetime, timezone 3 | from functools import partial 4 | 5 | from django.contrib.auth.models import User 6 | from faker import Faker 7 | 8 | from project.data.category import CategoryData 9 | from project.newsletter.models import Subscription 10 | 11 | fake = Faker() 12 | # Seed the randomization to support consistent randomization. 13 | Faker.seed(2022) 14 | 15 | USER_COUNT = 100 16 | 17 | 18 | def generate_data(categories: CategoryData): 19 | for i in range(0, USER_COUNT, 100): 20 | users = [] 21 | for _ in range(i, i + 100): 22 | user = User(first_name=fake.first_name(), last_name=fake.last_name()) 23 | user.username = ( 24 | f"{user.first_name}.{user.last_name}.{fake.pyint(max_value=999)}" 25 | ) 26 | user.email = f"{user.username}@example.com" 27 | user.date_joined = fake.date_time_between_dates( 28 | datetime_start=datetime(2020, 1, 1, tzinfo=timezone.utc), 29 | datetime_end=datetime(2022, 10, 12, tzinfo=timezone.utc), 30 | tzinfo=timezone.utc, 31 | ) 32 | users.append(user) 33 | User.objects.bulk_create(users, ignore_conflicts=True) 34 | user_ids = ( 35 | User.objects.exclude(is_staff=True) 36 | .filter(email__endswith="@example.com") 37 | .order_by("username") 38 | .values_list("id", flat=True) 39 | ) 40 | category_ids = OrderedDict( 41 | [ 42 | (categories.career.id, 0.3), 43 | (categories.family.id, 0.9), 44 | (categories.social.id, 0.5), 45 | (categories.technical.id, 0.7), 46 | ] 47 | ) 48 | get_category_ids = partial(fake.random_elements, elements=category_ids, unique=True) 49 | 50 | for i in range(0, USER_COUNT, 50): 51 | created_map = { 52 | user_id: fake.date_time_between_dates( 53 | datetime_start=datetime(2020, 1, 1, tzinfo=timezone.utc), 54 | datetime_end=datetime(2022, 10, 12, tzinfo=timezone.utc), 55 | tzinfo=timezone.utc, 56 | ) 57 | for user_id in user_ids[i : i + 50] 58 | } 59 | Subscription.objects.bulk_create( 60 | [Subscription(user_id=user_id) for user_id in created_map.keys()], 61 | ignore_conflicts=True, 62 | ) 63 | subscriptions = list(Subscription.objects.filter(user__in=user_ids[i : i + 50])) 64 | for subscription in subscriptions: 65 | subscription.created = subscription.updated = created_map[ 66 | subscription.user_id 67 | ] 68 | Subscription.objects.bulk_update(subscriptions, fields=["created", "updated"]) 69 | 70 | through_model = Subscription.categories.through 71 | through_model.objects.bulk_create( 72 | [ 73 | through_model(subscription_id=subscription.id, category_id=category_id) 74 | for subscription in subscriptions 75 | for category_id in get_category_ids() 76 | ], 77 | ignore_conflicts=True, 78 | ) 79 | -------------------------------------------------------------------------------- /project/data/subscription_notifications.py: -------------------------------------------------------------------------------- 1 | from django.db.models import F 2 | 3 | from project.newsletter.models import Post, Subscription, SubscriptionNotification 4 | 5 | 6 | def generate_data(): 7 | posts = list(Post.objects.published().recent_first()[:100]) 8 | date = max(p.publish_date for p in posts) 9 | 10 | subscriber_ids = ( 11 | Subscription.objects.filter( 12 | categories__posts__in=posts, 13 | user__date_joined__lte=date, 14 | ) 15 | .values_list("id", flat=True) 16 | .distinct() 17 | ) 18 | 19 | SubscriptionNotification.objects.bulk_create( 20 | [ 21 | SubscriptionNotification(post=post, subscription_id=id, sent=date) 22 | for id in subscriber_ids 23 | for post in posts 24 | ], 25 | batch_size=10000, 26 | ) 27 | SubscriptionNotification.objects.filter(post__in=posts).update( 28 | created=F("sent"), 29 | updated=F("sent"), 30 | ) 31 | Post.objects.filter(id__in=[post.id for post in posts]).update( 32 | notifications_sent=date 33 | ) 34 | -------------------------------------------------------------------------------- /project/newsletter/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/project/newsletter/__init__.py -------------------------------------------------------------------------------- /project/newsletter/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from project.newsletter.models import ( 4 | Category, 5 | Post, 6 | Subscription, 7 | SubscriptionNotification, 8 | ) 9 | 10 | 11 | @admin.register(Category) 12 | class CategoryAdmin(admin.ModelAdmin): 13 | list_display = ["title", "slug"] 14 | search_fields = ["title", "slug"] 15 | 16 | 17 | @admin.register(Post) 18 | class PostAdmin(admin.ModelAdmin): 19 | list_display = [ 20 | "title", 21 | "slug", 22 | "is_published", 23 | "publish_at", 24 | "categories_list", 25 | "created", 26 | "updated", 27 | ] 28 | list_filter = ["is_published", "categories"] 29 | search_fields = ["title", "slug", "content"] 30 | ordering = ["-created"] 31 | raw_id_fields = ["author"] 32 | readonly_fields = ["created", "updated"] 33 | 34 | @admin.decorators.display(description="Categories") 35 | def categories_list(self, obj): 36 | return ", ".join( 37 | category.title for category in obj.categories.order_by("title") 38 | ) 39 | 40 | def get_changeform_initial_data(self, request): 41 | return {"author": request.user} 42 | 43 | 44 | @admin.register(Subscription) 45 | class SubscriptionAdmin(admin.ModelAdmin): 46 | list_display = ["user_email", "categories_list"] 47 | list_select_related = ["user"] 48 | search_fields = ["categories__title", "user__username", "user__email"] 49 | raw_id_fields = ["user"] 50 | readonly_fields = ["created", "updated"] 51 | 52 | @admin.decorators.display(description="Categories") 53 | def categories_list(self, obj): 54 | return ", ".join(category.title for category in obj.categories.all()) 55 | 56 | @admin.decorators.display(ordering="user__email") 57 | def user_email(self, obj): 58 | return obj.user.email 59 | 60 | 61 | @admin.register(SubscriptionNotification) 62 | class SubscriptionNotificationAdmin(admin.ModelAdmin): 63 | list_display = ["user_email", "post", "sent", "created"] 64 | list_select_related = ["subscription__user", "post"] 65 | raw_id_fields = ["post", "subscription"] 66 | readonly_fields = ["created", "updated"] 67 | 68 | @admin.decorators.display(ordering="subscription__user__email") 69 | def user_email(self, obj): 70 | return obj.subscription.user.email 71 | -------------------------------------------------------------------------------- /project/newsletter/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class NewsletterAppConfig(AppConfig): 5 | name = "project.newsletter" 6 | 7 | def ready(self): 8 | from project.newsletter import receivers # noqa: F401 9 | -------------------------------------------------------------------------------- /project/newsletter/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from project.newsletter.models import Category, Post, Subscription 4 | 5 | 6 | class SubscriptionForm(forms.ModelForm): 7 | categories = forms.ModelMultipleChoiceField( 8 | queryset=Category.objects.all(), to_field_name="slug" 9 | ) 10 | 11 | class Meta: 12 | model = Subscription 13 | fields = ["categories"] 14 | 15 | 16 | class PostForm(forms.ModelForm): 17 | class Meta: 18 | model = Post 19 | fields = [ 20 | "title", 21 | "slug", 22 | "categories", 23 | "content", 24 | "summary", 25 | "is_public", 26 | "is_published", 27 | "publish_at", 28 | "open_graph_description", 29 | "open_graph_image", 30 | ] 31 | -------------------------------------------------------------------------------- /project/newsletter/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/project/newsletter/management/__init__.py -------------------------------------------------------------------------------- /project/newsletter/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/project/newsletter/management/commands/__init__.py -------------------------------------------------------------------------------- /project/newsletter/management/commands/send_notifications.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.contrib.sites.shortcuts import get_current_site 4 | from django.core.mail import send_mail 5 | from django.core.management.base import BaseCommand 6 | from django.db import transaction 7 | from django.utils import timezone 8 | from django.utils.translation import gettext_lazy as _ 9 | 10 | from project.newsletter.models import Post, Subscription, SubscriptionNotification 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | SUBJECT = _("{name} has made a new post - {title}") 16 | MESSAGE = _("There's a new post. You can view it at: {url}") 17 | 18 | 19 | class Command(BaseCommand): 20 | """Send notifications for posts that are published.""" 21 | 22 | def iterate_subscription_notifications(self): 23 | """ 24 | Iterate over subscriptions needing notifications per post. 25 | 26 | Will create a SubscriptionNotification for each Post-Subscription 27 | pair if it doesn't exist and yield the tuple when the notification 28 | has not been sent. 29 | 30 | The post will have its notifications_sent property set even if 31 | there are no subscriptions for the post. This prevents notifications 32 | from being sent for future subscribers to a given category when the 33 | post already exists. 34 | """ 35 | posts = ( 36 | Post.objects.published() 37 | .needs_notifications_sent() 38 | .select_related("author") 39 | .select_for_update(of=("id", "notifications_sent", "updated")) 40 | ) 41 | with transaction.atomic(): 42 | for post in posts: 43 | subscriptions = Subscription.objects.needs_notifications_sent(post) 44 | # Create all notifications so we can safely iterate 45 | # on sent=False using select_for_update 46 | SubscriptionNotification.objects.bulk_create( 47 | [ 48 | SubscriptionNotification(subscription=subscription, post=post) 49 | for subscription in subscriptions 50 | ], 51 | ignore_conflicts=True, 52 | batch_size=500, 53 | ) 54 | notifications = ( 55 | SubscriptionNotification.objects.needs_notifications_sent_for_post( 56 | post 57 | ) 58 | .annotate_email() 59 | .select_for_update(of=("id", "sent", "updated")) 60 | ) 61 | for notification in notifications: 62 | if notification.email: 63 | yield post, notification 64 | notification.sent = notification.updated = timezone.now() 65 | notification.save(update_fields=["sent", "updated"]) 66 | post.notifications_sent = post.updated = timezone.now() 67 | post.save(update_fields=["notifications_sent", "updated"]) 68 | 69 | def handle(self, *args, **options): 70 | Post.objects.needs_publishing().update( 71 | is_published=True, updated=timezone.now() 72 | ) 73 | for post, notification in self.iterate_subscription_notifications(): 74 | subject = SUBJECT.format(name=post.author.get_full_name(), title=post.title) 75 | message = MESSAGE.format( 76 | url=f"https://{get_current_site(None).domain}{post.get_absolute_url()}" 77 | ) 78 | send_mail( 79 | subject, 80 | message, 81 | from_email=None, 82 | recipient_list=[notification.email], 83 | ) 84 | -------------------------------------------------------------------------------- /project/newsletter/management/commands/test_send_notifications.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.contrib.auth.models import User 4 | from django.core.management import call_command 5 | from django.test import TestCase 6 | from django.utils import timezone 7 | 8 | from project.newsletter.management.commands.send_notifications import Command 9 | from project.newsletter.models import Category, Post, Subscription 10 | 11 | 12 | class TestSendNotifications(TestCase): 13 | def setUp(self) -> None: 14 | self.command = Command() 15 | 16 | def test_iterate_subscription_notifications(self): 17 | category = Category.objects.create(title="Cat", slug="cat") 18 | author = User.objects.create(username="author") 19 | subscription1 = Subscription.objects.create( 20 | user=User.objects.create( 21 | username="subscriber1", email="subscriber1@example.com" 22 | ) 23 | ) 24 | subscription2 = Subscription.objects.create( 25 | user=User.objects.create(username="subscriber2") 26 | ) 27 | subscription3 = Subscription.objects.create( 28 | user=User.objects.create(username="subscriber3") 29 | ) 30 | subscription1.categories.set([category]) 31 | subscription2.categories.set([category]) 32 | subscription3.categories.set([category]) 33 | 34 | post = Post.objects.create( 35 | author=author, 36 | title="title", 37 | slug="slug", 38 | is_published=True, 39 | content="content", 40 | ) 41 | post.categories.set([category]) 42 | notification1 = subscription1.notifications.create(post=post) 43 | subscription2.notifications.create(post=post, sent=timezone.now()) 44 | 45 | self.assertEqual( 46 | list(self.command.iterate_subscription_notifications()), 47 | [(post, notification1)], 48 | ) 49 | post.refresh_from_db() 50 | self.assertIsNotNone(post.notifications_sent) 51 | self.assertEqual(subscription3.notifications.count(), 1) 52 | 53 | @patch("project.newsletter.management.commands.send_notifications.send_mail") 54 | def test_email(self, send_mail): 55 | category = Category.objects.create(title="Cat", slug="cat") 56 | author = User.objects.create( 57 | username="author", first_name="Alex", last_name="Star" 58 | ) 59 | subscription = Subscription.objects.create( 60 | user=User.objects.create(username="subscriber", email="alex@example.com") 61 | ) 62 | subscription.categories.set([category]) 63 | 64 | post = Post.objects.create( 65 | author=author, 66 | title="title", 67 | slug="slug", 68 | is_published=True, 69 | content="content", 70 | ) 71 | post.categories.set([category]) 72 | 73 | call_command("send_notifications") 74 | 75 | self.assertIsNotNone(subscription.notifications.get().sent) 76 | send_mail.assert_called_once_with( 77 | "Alex Star has made a new post - title", 78 | "There's a new post. You can view it at: https://example.com/p/slug/", 79 | from_email=None, 80 | recipient_list=["alex@example.com"], 81 | ) 82 | -------------------------------------------------------------------------------- /project/newsletter/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.5 on 2022-07-07 00:33 2 | 3 | import django.db.models.deletion 4 | import martor.models 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name="Category", 20 | fields=[ 21 | ( 22 | "id", 23 | models.BigAutoField( 24 | auto_created=True, 25 | primary_key=True, 26 | serialize=False, 27 | verbose_name="ID", 28 | ), 29 | ), 30 | ("created", models.DateTimeField(auto_now_add=True)), 31 | ("updated", models.DateTimeField(auto_now=True)), 32 | ("title", models.CharField(max_length=512)), 33 | ( 34 | "slug", 35 | models.SlugField( 36 | allow_unicode=True, 37 | help_text="Unique URL-friendly identifier.", 38 | max_length=100, 39 | ), 40 | ), 41 | ], 42 | options={"verbose_name_plural": "categories"}, 43 | ), 44 | migrations.CreateModel( 45 | name="Post", 46 | fields=[ 47 | ( 48 | "id", 49 | models.BigAutoField( 50 | auto_created=True, 51 | primary_key=True, 52 | serialize=False, 53 | verbose_name="ID", 54 | ), 55 | ), 56 | ("created", models.DateTimeField(auto_now_add=True)), 57 | ("updated", models.DateTimeField(auto_now=True)), 58 | ("title", models.CharField(max_length=512)), 59 | ( 60 | "slug", 61 | models.SlugField( 62 | allow_unicode=True, 63 | help_text="Unique URL-friendly identifier.", 64 | max_length=100, 65 | ), 66 | ), 67 | ("content", martor.models.MartorField()), 68 | ("is_draft", models.BooleanField(default=True)), 69 | ( 70 | "publish_at", 71 | models.DateTimeField( 72 | blank=True, 73 | help_text="If set and Is Draft is false, the post will be available after the given value.", 74 | null=True, 75 | ), 76 | ), 77 | ( 78 | "author", 79 | models.ForeignKey( 80 | on_delete=django.db.models.deletion.PROTECT, 81 | related_name="posts", 82 | to=settings.AUTH_USER_MODEL, 83 | ), 84 | ), 85 | ( 86 | "categories", 87 | models.ManyToManyField(blank=True, to="newsletter.category"), 88 | ), 89 | ], 90 | ), 91 | migrations.CreateModel( 92 | name="Subscription", 93 | fields=[ 94 | ( 95 | "id", 96 | models.BigAutoField( 97 | auto_created=True, 98 | primary_key=True, 99 | serialize=False, 100 | verbose_name="ID", 101 | ), 102 | ), 103 | ("created", models.DateTimeField(auto_now_add=True)), 104 | ("updated", models.DateTimeField(auto_now=True)), 105 | ( 106 | "categories", 107 | models.ManyToManyField(blank=True, to="newsletter.category"), 108 | ), 109 | ( 110 | "user", 111 | models.OneToOneField( 112 | on_delete=django.db.models.deletion.CASCADE, 113 | related_name="subscription", 114 | to=settings.AUTH_USER_MODEL, 115 | ), 116 | ), 117 | ], 118 | options={ 119 | "abstract": False, 120 | }, 121 | ), 122 | migrations.CreateModel( 123 | name="SubscriptionNotification", 124 | fields=[ 125 | ( 126 | "id", 127 | models.BigAutoField( 128 | auto_created=True, 129 | primary_key=True, 130 | serialize=False, 131 | verbose_name="ID", 132 | ), 133 | ), 134 | ("created", models.DateTimeField(auto_now_add=True)), 135 | ("updated", models.DateTimeField(auto_now=True)), 136 | ("sent", models.DateTimeField(blank=True, null=True)), 137 | ( 138 | "post", 139 | models.ForeignKey( 140 | on_delete=django.db.models.deletion.CASCADE, 141 | related_name="subscription_notifications", 142 | to="newsletter.post", 143 | ), 144 | ), 145 | ( 146 | "subscription", 147 | models.ForeignKey( 148 | on_delete=django.db.models.deletion.CASCADE, 149 | related_name="notifications", 150 | to="newsletter.subscription", 151 | ), 152 | ), 153 | ], 154 | options={ 155 | "abstract": False, 156 | }, 157 | ), 158 | migrations.AddConstraint( 159 | model_name="category", 160 | constraint=models.UniqueConstraint( 161 | fields=("slug",), name="category_unq_slug" 162 | ), 163 | ), 164 | migrations.AddConstraint( 165 | model_name="post", 166 | constraint=models.UniqueConstraint(fields=("slug",), name="post_unq_slug"), 167 | ), 168 | ] 169 | -------------------------------------------------------------------------------- /project/newsletter/migrations/0002_remove_post_is_draft_post_is_published_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.5 on 2022-07-07 01:27 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("newsletter", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name="post", 15 | name="is_draft", 16 | ), 17 | migrations.AddField( 18 | model_name="post", 19 | name="is_published", 20 | field=models.BooleanField(default=False), 21 | ), 22 | migrations.AlterField( 23 | model_name="post", 24 | name="publish_at", 25 | field=models.DateTimeField( 26 | blank=True, 27 | help_text="If set and Is Published is True, the post will be available after the given value.", 28 | null=True, 29 | ), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /project/newsletter/migrations/0003_post_is_public_post_summary.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.5 on 2022-07-10 17:16 2 | 3 | import martor.models 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("newsletter", "0002_remove_post_is_draft_post_is_published_and_more"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="post", 16 | name="is_public", 17 | field=models.BooleanField(default=True), 18 | ), 19 | migrations.AddField( 20 | model_name="post", 21 | name="summary", 22 | field=martor.models.MartorField(default=True), 23 | preserve_default=False, 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /project/newsletter/migrations/0004_alter_subscription_options_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.5 on 2022-07-10 17:35 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("newsletter", "0003_post_is_public_post_summary"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name="subscription", 15 | options={"ordering": ["-created"]}, 16 | ), 17 | migrations.AlterModelOptions( 18 | name="subscriptionnotification", 19 | options={"ordering": ["-created"]}, 20 | ), 21 | migrations.AlterField( 22 | model_name="subscription", 23 | name="categories", 24 | field=models.ManyToManyField( 25 | blank=True, related_name="subscriptions", to="newsletter.category" 26 | ), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /project/newsletter/migrations/0005_auto_20220809_0012.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.5 on 2022-08-09 00:12 2 | 3 | from django.db import migrations 4 | 5 | 6 | def create_site(apps, schema_editor): # pragma: nocover 7 | Site = apps.get_model("sites", "Site") 8 | site = Site.objects.filter(domain="example.com").first() 9 | if site: 10 | site.name = "Debug Newsletter" 11 | site.domain = "127.0.0.1:8000" 12 | site.save() 13 | 14 | 15 | class Migration(migrations.Migration): 16 | 17 | dependencies = [ 18 | ("newsletter", "0004_alter_subscription_options_and_more"), 19 | ("sites", "0002_alter_domain_unique"), 20 | ] 21 | 22 | operations = [ 23 | migrations.RunPython(create_site, migrations.RunPython.noop), 24 | ] 25 | -------------------------------------------------------------------------------- /project/newsletter/migrations/0006_alter_category_options_alter_subscription_categories.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.5 on 2022-08-16 01:46 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("newsletter", "0005_auto_20220809_0012"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name="category", 15 | options={"ordering": ["title"], "verbose_name_plural": "categories"}, 16 | ), 17 | migrations.AlterField( 18 | model_name="subscription", 19 | name="categories", 20 | field=models.ManyToManyField( 21 | blank=True, 22 | help_text="An email will be sent when post matching one of these categories is published.", 23 | related_name="subscriptions", 24 | to="newsletter.category", 25 | ), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /project/newsletter/migrations/0007_alter_subscriptionnotification_options_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.5 on 2022-08-17 00:54 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("newsletter", "0006_alter_category_options_alter_subscription_categories"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name="subscriptionnotification", 15 | options={}, 16 | ), 17 | migrations.AddField( 18 | model_name="post", 19 | name="notifications_sent", 20 | field=models.DateTimeField( 21 | blank=True, 22 | help_text="If set, all notifications are considered to have been sent and will not be sent again.", 23 | null=True, 24 | ), 25 | ), 26 | migrations.AddConstraint( 27 | model_name="subscriptionnotification", 28 | constraint=models.UniqueConstraint( 29 | fields=("post", "subscription"), name="subscript_notif_uniq" 30 | ), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /project/newsletter/migrations/0008_alter_post_categories.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.5 on 2022-08-20 12:52 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("newsletter", "0007_alter_subscriptionnotification_options_and_more"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="post", 15 | name="categories", 16 | field=models.ManyToManyField( 17 | blank=True, related_name="posts", to="newsletter.category" 18 | ), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /project/newsletter/migrations/0009_post_open_graph_description_post_open_graph_image.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.5 on 2022-08-30 00:18 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("newsletter", "0008_alter_post_categories"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="post", 15 | name="open_graph_description", 16 | field=models.TextField( 17 | blank=True, help_text="Used for SEO purposes and social media sharing." 18 | ), 19 | ), 20 | migrations.AddField( 21 | model_name="post", 22 | name="open_graph_image", 23 | field=models.ImageField( 24 | blank=True, 25 | help_text="Used for SEO purposes and social media sharing.", 26 | null=True, 27 | upload_to="open_graph/", 28 | ), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /project/newsletter/migrations/0010_subscriptionnotification_read.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.5 on 2022-09-03 20:15 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("newsletter", "0009_post_open_graph_description_post_open_graph_image"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="subscriptionnotification", 15 | name="read", 16 | field=models.DateTimeField(blank=True, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /project/newsletter/migrations/0011_alter_post_options_remove_category_category_unq_slug_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.5 on 2022-09-10 15:56 2 | 3 | import re 4 | 5 | import django.core.validators 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ("newsletter", "0010_subscriptionnotification_read"), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterModelOptions( 17 | name="post", 18 | options={"ordering": ["-created"]}, 19 | ), 20 | migrations.RemoveConstraint( 21 | model_name="category", 22 | name="category_unq_slug", 23 | ), 24 | migrations.RemoveConstraint( 25 | model_name="post", 26 | name="post_unq_slug", 27 | ), 28 | migrations.AlterField( 29 | model_name="category", 30 | name="slug", 31 | field=models.CharField( 32 | help_text="Unique URL-friendly identifier.", 33 | max_length=100, 34 | validators=[ 35 | django.core.validators.RegexValidator( 36 | re.compile("^[-\\w]+\\Z"), 37 | "Enter a valid “slug” consisting of Unicode letters, numbers, underscores, or hyphens.", 38 | "invalid", 39 | ) 40 | ], 41 | ), 42 | ), 43 | migrations.AlterField( 44 | model_name="post", 45 | name="slug", 46 | field=models.CharField( 47 | help_text="Unique URL-friendly identifier.", 48 | max_length=100, 49 | validators=[ 50 | django.core.validators.RegexValidator( 51 | re.compile("^[-\\w]+\\Z"), 52 | "Enter a valid “slug” consisting of Unicode letters, numbers, underscores, or hyphens.", 53 | "invalid", 54 | ) 55 | ], 56 | ), 57 | ), 58 | ] 59 | -------------------------------------------------------------------------------- /project/newsletter/migrations/0012_alter_category_created_alter_post_created_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.1 on 2022-09-26 03:16 2 | 3 | import django.utils.timezone 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ( 11 | "newsletter", 12 | "0011_alter_post_options_remove_category_category_unq_slug_and_more", 13 | ), 14 | ] 15 | 16 | operations = [ 17 | migrations.AlterField( 18 | model_name="category", 19 | name="created", 20 | field=models.DateTimeField( 21 | default=django.utils.timezone.now, editable=False 22 | ), 23 | ), 24 | migrations.AlterField( 25 | model_name="post", 26 | name="created", 27 | field=models.DateTimeField( 28 | default=django.utils.timezone.now, editable=False 29 | ), 30 | ), 31 | migrations.AlterField( 32 | model_name="subscription", 33 | name="created", 34 | field=models.DateTimeField( 35 | default=django.utils.timezone.now, editable=False 36 | ), 37 | ), 38 | migrations.AlterField( 39 | model_name="subscriptionnotification", 40 | name="created", 41 | field=models.DateTimeField( 42 | default=django.utils.timezone.now, editable=False 43 | ), 44 | ), 45 | ] 46 | -------------------------------------------------------------------------------- /project/newsletter/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/project/newsletter/migrations/__init__.py -------------------------------------------------------------------------------- /project/newsletter/models.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from django.contrib.auth.models import AnonymousUser, User 4 | from django.core import validators 5 | from django.db import models 6 | from django.db.models import Exists, F, OuterRef 7 | from django.db.models.functions import Coalesce 8 | from django.urls import reverse 9 | from django.utils import timezone 10 | from django.utils.translation import gettext_lazy as _ 11 | from martor.models import MartorField 12 | 13 | 14 | class TimestampedModel(models.Model): 15 | """Abstract model that adds created and updated timestamps.""" 16 | 17 | created = models.DateTimeField(default=timezone.now, editable=False) 18 | updated = models.DateTimeField(auto_now=True) 19 | 20 | class Meta: 21 | abstract = True 22 | ordering = ["-created"] 23 | 24 | 25 | class Category(TimestampedModel): 26 | """Categories of content.""" 27 | 28 | title = models.CharField(max_length=512) 29 | slug = models.CharField( 30 | max_length=100, 31 | validators=[validators.validate_unicode_slug], 32 | help_text=_("Unique URL-friendly identifier."), 33 | ) 34 | 35 | class Meta: 36 | verbose_name_plural = _("categories") 37 | ordering = ["title"] 38 | 39 | def __str__(self): 40 | return self.title 41 | 42 | def __repr__(self): 43 | return f"" 44 | 45 | 46 | class PostQuerySet(models.QuerySet): 47 | def recent_first(self): 48 | """Order by so most recently published or created are first.""" 49 | return self.order_by(Coalesce("publish_at", "created").desc()) 50 | 51 | def public(self): 52 | """Limit to those that are published.""" 53 | return self.filter(is_public=True) 54 | 55 | def published(self): 56 | """Limit to those that are published.""" 57 | return self.filter(is_published=True) 58 | 59 | def unpublished(self): 60 | """Limit to those that are unpublished.""" 61 | return self.filter(is_published=False) 62 | 63 | def needs_publishing(self, now=None): 64 | """Limit to those that aren't published, but are scheduled to be.""" 65 | now = now or timezone.now() 66 | return self.filter(is_published=False, publish_at__lte=now) 67 | 68 | def needs_notifications_sent(self): 69 | """Limit to those that have yet to send out notifications to their subscribers.""" 70 | return self.filter(notifications_sent__isnull=True) 71 | 72 | def in_category(self, category: Category): 73 | """Limit to the given category""" 74 | return self.filter(categories=category) 75 | 76 | def in_relevant_categories(self, subscription): 77 | """ 78 | Limit to the categories for the subscription 79 | 80 | :param subscription: The Subscription instance. 81 | :return: a Post QuerySet. 82 | """ 83 | return self.filter(categories__subscriptions=subscription).distinct() 84 | 85 | def annotate_is_unread(self, user): 86 | """ 87 | Annotate is_unread onto the Post queryset. 88 | 89 | If the user is authenticated, determine if the user was sent a 90 | notification regarding the post, but hasn't read it. 91 | 92 | If the user is unauthenticated (AnonymousUser), then mark all 93 | posts as "read". 94 | 95 | :param user: The User instance. 96 | :return: a Post QuerySet. 97 | """ 98 | if isinstance(user, AnonymousUser): 99 | return self.annotate(is_unread=models.Value(False)) 100 | return self.annotate( 101 | is_unread=Exists( 102 | SubscriptionNotification.objects.filter( 103 | post_id=OuterRef("id"), 104 | subscription__user=user, 105 | sent__isnull=False, 106 | read__isnull=True, 107 | ) 108 | ) 109 | ) 110 | 111 | 112 | class Post(TimestampedModel): 113 | """A piece of content to be drafted and published.""" 114 | 115 | title = models.CharField(max_length=512) 116 | slug = models.CharField( 117 | max_length=100, 118 | validators=[validators.validate_unicode_slug], 119 | help_text=_("Unique URL-friendly identifier."), 120 | ) 121 | author = models.ForeignKey(User, related_name="posts", on_delete=models.PROTECT) 122 | content = MartorField() 123 | summary = MartorField() 124 | categories = models.ManyToManyField(Category, related_name="posts", blank=True) 125 | is_public = models.BooleanField(default=True) 126 | is_published = models.BooleanField(default=False) 127 | publish_at = models.DateTimeField( 128 | null=True, 129 | blank=True, 130 | help_text=_( 131 | "If set and Is Published is True, the post will be available after the given value." 132 | ), 133 | ) 134 | notifications_sent = models.DateTimeField( 135 | null=True, 136 | blank=True, 137 | help_text=_( 138 | "If set, all notifications are considered to have been sent and will not be sent again." 139 | ), 140 | ) 141 | open_graph_description = models.TextField( 142 | blank=True, help_text=_("Used for SEO purposes and social media sharing.") 143 | ) 144 | open_graph_image = models.ImageField( 145 | null=True, 146 | blank=True, 147 | upload_to="open_graph/", 148 | help_text=_("Used for SEO purposes and social media sharing."), 149 | ) 150 | objects = models.Manager.from_queryset(PostQuerySet)() 151 | 152 | def __str__(self): 153 | return self.title 154 | 155 | def __repr__(self): 156 | return f"" 157 | 158 | @property 159 | def publish_date(self): 160 | return self.publish_at or self.created 161 | 162 | def get_absolute_url(self): 163 | return reverse("newsletter:view_post", kwargs={"slug": self.slug}) 164 | 165 | 166 | class SubscriptionQuerySet(models.QuerySet): 167 | def for_user(self, user: User) -> Optional["Subscription"]: 168 | """ 169 | Fetch the subscription for the user if it exists. 170 | :param user: The User instance. 171 | :return: The subscription instance if it exists. 172 | """ 173 | return self.filter(user=user).first() 174 | 175 | def needs_notifications_sent(self, post: Post): 176 | """ 177 | Limit to those that need to send notifications for the post. 178 | 179 | :param post: The Post instance. 180 | :return: a Subscription QuerySet. 181 | """ 182 | return self.filter( 183 | models.Q(notifications__post=post, notifications__sent__isnull=True) 184 | | models.Q(notifications__post__isnull=True), 185 | categories__posts=post, 186 | user__date_joined__lte=post.publish_date, 187 | ) 188 | 189 | 190 | class Subscription(TimestampedModel): 191 | """A user's subscriptions to be notified of content of specific categories.""" 192 | 193 | user = models.OneToOneField( 194 | User, related_name="subscription", on_delete=models.CASCADE 195 | ) 196 | categories = models.ManyToManyField( 197 | Category, 198 | related_name="subscriptions", 199 | blank=True, 200 | help_text=_( 201 | "An email will be sent when post matching one of these categories is published." 202 | ), 203 | ) 204 | objects = models.Manager.from_queryset(SubscriptionQuerySet)() 205 | 206 | def __repr__(self): 207 | return f"" 208 | 209 | 210 | class SubscriptionNotificationQuerySet(models.QuerySet): 211 | def needs_notifications_sent_for_post(self, post: Post): 212 | """ 213 | Limit to those that need to send notifications for the post. 214 | 215 | :param post: The Post instance. 216 | :return: a SubscriptionNotification QuerySet. 217 | """ 218 | return self.filter(post=post, sent__isnull=True) 219 | 220 | def annotate_email(self): 221 | """ 222 | Annotate the SubscriptionNotification QuerySet with the subscriber's email. 223 | 224 | :return: a SubscriptionNotification QuerySet. 225 | """ 226 | return self.annotate(email=F("subscription__user__email")) 227 | 228 | 229 | class SubscriptionNotification(TimestampedModel): 230 | """ 231 | A log of notifications sent per post per subscription. 232 | 233 | When a post is published, any subscribers to the categories of the post 234 | should be notified of the post. 235 | """ 236 | 237 | class Meta: 238 | constraints = [ 239 | models.UniqueConstraint( 240 | fields=["post", "subscription"], name="subscript_notif_uniq" 241 | ) 242 | ] 243 | 244 | subscription = models.ForeignKey( 245 | Subscription, related_name="notifications", on_delete=models.CASCADE 246 | ) 247 | post = models.ForeignKey( 248 | Post, related_name="subscription_notifications", on_delete=models.CASCADE 249 | ) 250 | sent = models.DateTimeField(null=True, blank=True) 251 | read = models.DateTimeField(null=True, blank=True) 252 | objects = models.Manager.from_queryset(SubscriptionNotificationQuerySet)() 253 | 254 | def __repr__(self): 255 | return f"" 256 | -------------------------------------------------------------------------------- /project/newsletter/operations.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file contains create/update/writing operations. 3 | """ 4 | 5 | from datetime import timedelta 6 | 7 | from django.core.cache import cache 8 | from django.utils import timezone 9 | from martor.views import User 10 | 11 | from project.newsletter.models import Post, SubscriptionNotification 12 | 13 | 14 | def mark_as_read(post: Post, user: User): 15 | """ 16 | Mark the given post as read for the given user. 17 | 18 | :param post: The unread Post instance. 19 | :param user: The User instance. 20 | :return: None 21 | """ 22 | SubscriptionNotification.objects.filter( 23 | post=post, 24 | subscription__user=user, 25 | read__isnull=True, 26 | ).update(read=timezone.now(), updated=timezone.now()) 27 | 28 | 29 | def check_is_trending(post: Post): 30 | """ 31 | Determine if the given post is trending. 32 | 33 | :param post: The Post instance. 34 | :return: bool 35 | """ 36 | key = f"post.trending.{post.slug}" 37 | now = timezone.now() 38 | hour_ago = now - timedelta(hours=1) 39 | views = [timestamp for timestamp in cache.get(key, []) if timestamp >= hour_ago] 40 | is_trending = len(views) > 5 41 | views.append(now) 42 | cache.set(key, views, timeout=600) 43 | return is_trending 44 | -------------------------------------------------------------------------------- /project/newsletter/receivers.py: -------------------------------------------------------------------------------- 1 | from django.core.cache import cache 2 | from django.db.models.signals import post_save 3 | from django.dispatch import receiver 4 | 5 | from project.newsletter.models import Post 6 | 7 | 8 | @receiver(post_save, sender=Post) 9 | def on_post_save(instance, raw, created, **kwargs): 10 | if not raw and not created: 11 | cache.delete(f"post.detail.{instance.slug}") 12 | -------------------------------------------------------------------------------- /project/newsletter/syndication.py: -------------------------------------------------------------------------------- 1 | from django.contrib.syndication.views import Feed 2 | from django.urls import reverse 3 | 4 | from project.newsletter.models import Category, Post 5 | 6 | 7 | class RecentPostsFeed(Feed): 8 | title = "Newsletter posts" 9 | description = "Categorized newsletter posts from Tim." 10 | 11 | def items(self): 12 | return ( 13 | Post.objects.recent_first() 14 | .published() 15 | .public() 16 | .prefetch_related("categories")[:30] 17 | ) 18 | 19 | def item_title(self, item): 20 | return item.title 21 | 22 | def item_description(self, item): 23 | return "" 24 | 25 | def item_pubdate(self, item): 26 | """ 27 | Takes an item, as returned by items(), and returns the item's 28 | pubdate. 29 | """ 30 | return item.publish_date 31 | 32 | def item_categories(self, item): 33 | """ 34 | Takes an item, as returned by items(), and returns the item's 35 | categories. 36 | """ 37 | return [category.title for category in item.categories.all()] 38 | 39 | 40 | class RecentCategorizedPostsFeed(RecentPostsFeed): 41 | description = "Categorized newsletter posts from Tim." 42 | 43 | def get_object(self, request, slug): 44 | return Category.objects.get(slug=slug) 45 | 46 | def item_title(self, obj): 47 | return f"{obj.title} newsletter posts" 48 | 49 | def item_link(self, obj): 50 | return reverse("newsletter:list_posts") + f"?category={obj.slug}" 51 | 52 | def items(self, obj): 53 | return ( 54 | Post.objects.in_category(obj) 55 | .recent_first() 56 | .published() 57 | .public() 58 | .prefetch_related("categories")[:30] 59 | ) 60 | -------------------------------------------------------------------------------- /project/newsletter/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/project/newsletter/templatetags/__init__.py -------------------------------------------------------------------------------- /project/newsletter/templatetags/newsletter_utils.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.core.paginator import Paginator 4 | from django.template import Library 5 | from django.utils import timezone 6 | 7 | from project.newsletter.models import Post 8 | 9 | register = Library() 10 | 11 | 12 | @register.filter 13 | def is_ellipsis(value): 14 | """ 15 | Determine if the value is an ellipsis 16 | """ 17 | return value == Paginator.ELLIPSIS 18 | 19 | 20 | @register.inclusion_tag("inclusion_tags/nice_datetime.html") 21 | def nice_datetime(post: Post, is_unread: bool): 22 | """ 23 | Format the datetime in the locale the user prefers with styling. 24 | """ 25 | now = timezone.now() 26 | week_ago = timezone.now() - timedelta(days=7) 27 | timestamp = post.publish_date 28 | is_recent = week_ago <= timestamp <= now 29 | return { 30 | "is_unread": is_unread, 31 | "is_recent": is_recent, 32 | "timestamp": timestamp, 33 | } 34 | -------------------------------------------------------------------------------- /project/newsletter/test.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import timedelta 3 | 4 | from django.contrib.auth.models import User 5 | from django.test import Client, RequestFactory, TestCase 6 | from django.utils import timezone 7 | 8 | from project.newsletter.models import Category, Post, Subscription 9 | 10 | 11 | @dataclass 12 | class TestData: 13 | author: User 14 | career: Category 15 | social: Category 16 | all_post: Post 17 | career_post: Post 18 | private_post: Post 19 | subscription: Subscription 20 | 21 | 22 | def create_test_data() -> TestData: 23 | author = User.objects.create_user( 24 | username="author", 25 | email="author@example.com", 26 | first_name="Author", 27 | last_name="Name", 28 | ) 29 | career = Category.objects.create(title="Career", slug="career") 30 | social = Category.objects.create(title="Social", slug="social") 31 | all_post = Post.objects.create( 32 | title="All category post", 33 | slug="all-post", 34 | author=author, 35 | content="# Title", 36 | summary="## Summary", 37 | is_published=True, 38 | is_public=True, 39 | notifications_sent=timezone.now(), 40 | ) 41 | all_post.created -= timedelta(minutes=3) 42 | all_post.save(update_fields=["created"]) 43 | all_post.categories.set([career, social]) 44 | career_post = Post.objects.create( 45 | title="Career post", 46 | slug="career-post", 47 | author=author, 48 | content="# Title", 49 | summary="## Summary", 50 | is_published=True, 51 | is_public=True, 52 | notifications_sent=timezone.now(), 53 | ) 54 | career_post.created -= timedelta(minutes=2) 55 | career_post.save(update_fields=["created"]) 56 | career_post.categories.set([career]) 57 | private_post = Post.objects.create( 58 | title="Private post", 59 | slug="private-post", 60 | author=author, 61 | content="# Title", 62 | summary="## Summary", 63 | is_published=True, 64 | is_public=False, 65 | notifications_sent=timezone.now(), 66 | ) 67 | private_post.created -= timedelta(minutes=1) 68 | private_post.save(update_fields=["created"]) 69 | private_post.categories.set([social]) 70 | 71 | subscriber = User.objects.create_user( 72 | username="subscriber", 73 | email="subscriber@example.com", 74 | first_name="Subscriber", 75 | last_name="Name", 76 | ) 77 | subscription = Subscription.objects.create(user=subscriber) 78 | subscription.categories.set([career, social]) 79 | 80 | return TestData( 81 | author=author, 82 | career=career, 83 | social=social, 84 | all_post=all_post, 85 | career_post=career_post, 86 | private_post=private_post, 87 | subscription=subscription, 88 | ) 89 | 90 | 91 | class DataTestCase(TestCase): 92 | @classmethod 93 | def setUpTestData(cls) -> None: 94 | cls.data = create_test_data() 95 | 96 | def setUp(self) -> None: 97 | self.rf = RequestFactory() 98 | self.user = User.objects.create_superuser( 99 | username="admin", 100 | email="admin@example.com", 101 | password="test", 102 | first_name="Admin", 103 | last_name="User", 104 | ) 105 | self.client = Client() 106 | -------------------------------------------------------------------------------- /project/newsletter/test_admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from martor.tests.models import Post 3 | 4 | from project.newsletter.admin import ( 5 | PostAdmin, 6 | SubscriptionAdmin, 7 | SubscriptionNotificationAdmin, 8 | ) 9 | from project.newsletter.models import Subscription, SubscriptionNotification 10 | from project.newsletter.test import DataTestCase 11 | 12 | 13 | class TestPostAdmin(DataTestCase): 14 | def test_categories_list(self): 15 | model_admin = PostAdmin(Post, admin.site) 16 | self.assertEqual( 17 | model_admin.categories_list(self.data.all_post), "Career, Social" 18 | ) 19 | self.assertEqual(model_admin.categories_list(self.data.career_post), "Career") 20 | 21 | def test_get_changeform_initial_data(self): 22 | model_admin = PostAdmin(Post, admin.site) 23 | request = self.rf.get("/") 24 | request.user = self.user 25 | self.assertEqual( 26 | model_admin.get_changeform_initial_data(request), 27 | {"author": self.user}, 28 | ) 29 | 30 | 31 | class TestSubscriptionAdmin(DataTestCase): 32 | def test_categories_list(self): 33 | model_admin = SubscriptionAdmin(Subscription, admin.site) 34 | self.assertEqual( 35 | model_admin.categories_list(self.data.subscription), "Career, Social" 36 | ) 37 | 38 | def test_user_email(self): 39 | model_admin = SubscriptionAdmin(Subscription, admin.site) 40 | self.assertEqual( 41 | model_admin.user_email(self.data.subscription), 42 | "subscriber@example.com", 43 | ) 44 | 45 | 46 | class TestSubscriptionNotificationAdmin(DataTestCase): 47 | def test_user_email(self): 48 | notification = SubscriptionNotification.objects.create( 49 | post=self.data.all_post, 50 | subscription=self.data.subscription, 51 | ) 52 | model_admin = SubscriptionNotificationAdmin( 53 | SubscriptionNotification, admin.site 54 | ) 55 | self.assertEqual( 56 | model_admin.user_email(notification), 57 | "subscriber@example.com", 58 | ) 59 | -------------------------------------------------------------------------------- /project/newsletter/test_forms.py: -------------------------------------------------------------------------------- 1 | from project.newsletter.forms import SubscriptionForm 2 | from project.newsletter.test import DataTestCase 3 | 4 | 5 | class TestSubscriptionForm(DataTestCase): 6 | def test_categories_field_uses_slug_for_value(self): 7 | form = SubscriptionForm() 8 | self.assertEqual( 9 | form.fields["categories"].prepare_value(self.data.social), 10 | self.data.social.slug, 11 | ) 12 | -------------------------------------------------------------------------------- /project/newsletter/test_models.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.contrib.auth.models import AnonymousUser, User 4 | from django.utils import timezone 5 | 6 | from project.newsletter.models import Post, Subscription, SubscriptionNotification 7 | from project.newsletter.test import DataTestCase 8 | 9 | 10 | class TestCategory(DataTestCase): 11 | def test_str(self): 12 | self.assertEqual(str(self.data.career), self.data.career.title) 13 | 14 | 15 | class TestPost(DataTestCase): 16 | def setUp(self) -> None: 17 | super().setUp() 18 | self.post = Post.objects.create( 19 | author=self.data.author, 20 | slug="test-post", 21 | title="Test Post", 22 | content="content", 23 | ) 24 | self.post.categories.set([self.data.career, self.data.social]) 25 | 26 | def test_str(self): 27 | self.assertEqual(str(self.data.all_post), self.data.all_post.title) 28 | 29 | def test_publish_date(self): 30 | self.assertEqual(self.post.publish_date, self.post.created) 31 | self.post.publish_at = timezone.now() 32 | self.assertEqual(self.post.publish_date, self.post.publish_at) 33 | 34 | def test_get_absolute_url(self): 35 | self.assertEqual(self.data.all_post.get_absolute_url(), "/p/all-post/") 36 | 37 | def test_recent_first(self): 38 | # Create a new post that's a copy of all_post 39 | self.assertEqual(Post.objects.recent_first().first(), self.post) 40 | # Set publish_at to a value that's older than private_post's created 41 | self.post.publish_at = timezone.now() - timedelta(days=10) 42 | self.post.save() 43 | self.assertEqual(Post.objects.recent_first().first(), self.data.private_post) 44 | 45 | def test_public(self): 46 | self.assertTrue(Post.objects.public().filter(id=self.data.all_post.id).exists()) 47 | self.assertFalse( 48 | Post.objects.public().filter(id=self.data.private_post.id).exists() 49 | ) 50 | 51 | def test_published(self): 52 | self.post.is_published = False 53 | self.post.save() 54 | self.assertTrue( 55 | Post.objects.published().filter(id=self.data.all_post.id).exists() 56 | ) 57 | self.assertFalse(Post.objects.published().filter(id=self.post.id).exists()) 58 | 59 | def test_unpublished(self): 60 | self.post.is_published = False 61 | self.post.save() 62 | self.assertFalse( 63 | Post.objects.unpublished().filter(id=self.data.all_post.id).exists() 64 | ) 65 | self.assertTrue(Post.objects.unpublished().filter(id=self.post.id).exists()) 66 | 67 | def test_needs_publishing(self): 68 | self.post.is_published = False 69 | self.post.publish_at = timezone.now() + timedelta(days=1) 70 | self.post.save() 71 | self.assertFalse( 72 | Post.objects.needs_publishing().filter(id=self.post.id).exists() 73 | ) 74 | # Verify the `or param` path 75 | self.assertTrue( 76 | Post.objects.needs_publishing(timezone.now() + timedelta(days=1, minutes=1)) 77 | .filter(id=self.post.id) 78 | .exists() 79 | ) 80 | # Exclude it via publish_at 81 | self.post.is_published = True 82 | self.post.save() 83 | self.assertFalse( 84 | Post.objects.needs_publishing().filter(id=self.post.id).exists() 85 | ) 86 | 87 | def test_needs_notifications_sent(self): 88 | self.assertTrue( 89 | Post.objects.needs_notifications_sent().filter(id=self.post.id).exists() 90 | ) 91 | self.post.notifications_sent = timezone.now() 92 | self.post.save() 93 | self.assertFalse( 94 | Post.objects.needs_notifications_sent().filter(id=self.post.id).exists() 95 | ) 96 | 97 | def test_in_category(self): 98 | self.assertTrue( 99 | Post.objects.in_category(self.data.social) 100 | .filter(id=self.data.all_post.id) 101 | .exists() 102 | ) 103 | self.assertTrue( 104 | Post.objects.in_category(self.data.career) 105 | .filter(id=self.data.all_post.id) 106 | .exists() 107 | ) 108 | self.assertTrue( 109 | Post.objects.in_category(self.data.social) 110 | .filter(id=self.data.private_post.id) 111 | .exists() 112 | ) 113 | self.assertFalse( 114 | Post.objects.in_category(self.data.career) 115 | .filter(id=self.data.private_post.id) 116 | .exists() 117 | ) 118 | 119 | def test_in_relevant_categories(self): 120 | self.assertTrue( 121 | Post.objects.in_relevant_categories(self.data.subscription) 122 | .filter(id=self.data.all_post.id) 123 | .exists() 124 | ) 125 | self.assertTrue( 126 | Post.objects.in_relevant_categories(self.data.subscription) 127 | .filter(id=self.data.private_post.id) 128 | .exists() 129 | ) 130 | 131 | user = User.objects.create_user(username="in_relevant_categories") 132 | subscription = Subscription.objects.create(user=user) 133 | subscription.categories.set([self.data.social]) 134 | self.assertTrue( 135 | Post.objects.in_relevant_categories(subscription) 136 | .filter(id=self.data.all_post.id) 137 | .exists() 138 | ) 139 | self.assertFalse( 140 | Post.objects.in_relevant_categories(subscription) 141 | .filter(id=self.data.career_post.id) 142 | .exists() 143 | ) 144 | self.assertTrue( 145 | Post.objects.in_relevant_categories(subscription) 146 | .filter(id=self.data.private_post.id) 147 | .exists() 148 | ) 149 | 150 | def test_annotate_is_unread(self): 151 | notification = SubscriptionNotification.objects.create( 152 | subscription=self.data.subscription, 153 | post=self.data.all_post, 154 | ) 155 | # Test unsent 156 | self.assertFalse( 157 | Post.objects.annotate_is_unread(self.data.subscription.user) 158 | .get(id=self.data.all_post.id) 159 | .is_unread 160 | ) 161 | notification.sent = timezone.now() 162 | notification.save() 163 | # Test valid case 164 | self.assertTrue( 165 | Post.objects.annotate_is_unread(self.data.subscription.user) 166 | .get(id=self.data.all_post.id) 167 | .is_unread 168 | ) 169 | # Test unauthenticated user case 170 | self.assertFalse( 171 | Post.objects.annotate_is_unread(AnonymousUser()) 172 | .get(id=self.data.all_post.id) 173 | .is_unread 174 | ) 175 | notification.read = timezone.now() 176 | notification.save() 177 | # Test read notification case 178 | self.assertFalse( 179 | Post.objects.annotate_is_unread(self.data.subscription.user) 180 | .get(id=self.data.all_post.id) 181 | .is_unread 182 | ) 183 | 184 | 185 | class TestSubscription(DataTestCase): 186 | def test_for_user(self): 187 | self.assertEqual( 188 | Subscription.objects.for_user(self.data.subscription.user), 189 | self.data.subscription, 190 | ) 191 | self.assertEqual(Subscription.objects.for_user(self.data.author), None) 192 | 193 | def test_needs_notifications_sent(self): 194 | user = User.objects.create_user( 195 | username="needs_notifications_sent", 196 | date_joined=timezone.now() - timedelta(minutes=2), 197 | ) 198 | subscription = Subscription.objects.create(user=user) 199 | subscription.categories.set([self.data.social]) 200 | post = Post.objects.create( 201 | author=self.data.author, 202 | title="Needs notifications", 203 | slug="needs-notifications", 204 | content="content", 205 | publish_at=timezone.now() - timedelta(minutes=1), 206 | ) 207 | post.categories.set([self.data.social]) 208 | self.assertTrue( 209 | Subscription.objects.needs_notifications_sent(post) 210 | .filter(id=subscription.id) 211 | .exists() 212 | ) 213 | 214 | notification = subscription.notifications.create(post=post, sent=timezone.now()) 215 | self.assertFalse( 216 | Subscription.objects.needs_notifications_sent(post) 217 | .filter(id=subscription.id) 218 | .exists() 219 | ) 220 | notification.sent = None 221 | notification.save() 222 | self.assertTrue( 223 | Subscription.objects.needs_notifications_sent(post) 224 | .filter(id=subscription.id) 225 | .exists() 226 | ) 227 | 228 | subscription.categories.set([self.data.career]) 229 | self.assertFalse( 230 | Subscription.objects.needs_notifications_sent(post) 231 | .filter(id=subscription.id) 232 | .exists() 233 | ) 234 | subscription.categories.set([self.data.social]) 235 | self.assertTrue( 236 | Subscription.objects.needs_notifications_sent(post) 237 | .filter(id=subscription.id) 238 | .exists() 239 | ) 240 | user.date_joined = timezone.now() 241 | user.save() 242 | self.assertFalse( 243 | Subscription.objects.needs_notifications_sent(post) 244 | .filter(id=subscription.id) 245 | .exists() 246 | ) 247 | -------------------------------------------------------------------------------- /project/newsletter/test_operations.py: -------------------------------------------------------------------------------- 1 | from django.core.cache import cache 2 | 3 | from project.newsletter import operations 4 | from project.newsletter.models import SubscriptionNotification 5 | from project.newsletter.test import DataTestCase 6 | 7 | 8 | class TestMarkAsRead(DataTestCase): 9 | def test_mark_as_read(self): 10 | notification = SubscriptionNotification.objects.create( 11 | subscription=self.data.subscription, 12 | post=self.data.all_post, 13 | ) 14 | operations.mark_as_read(self.data.all_post, self.data.subscription.user) 15 | notification.refresh_from_db() 16 | self.assertIsNotNone(notification.read) 17 | 18 | 19 | class TestCheckIsTrending(DataTestCase): 20 | def test_check_is_trending(self): 21 | cache.delete(f"post.trending.{self.data.all_post.slug}") 22 | for i in range(6): 23 | self.assertFalse(operations.check_is_trending(self.data.all_post)) 24 | self.assertTrue(operations.check_is_trending(self.data.all_post)) 25 | cache.delete(f"post.trending.{self.data.all_post.slug}") 26 | -------------------------------------------------------------------------------- /project/newsletter/test_receivers.py: -------------------------------------------------------------------------------- 1 | from django.core.cache import cache 2 | from django.test import Client 3 | from django.urls import reverse 4 | 5 | from project.newsletter.models import Post 6 | from project.newsletter.test import DataTestCase 7 | 8 | 9 | class TestPostOnSave(DataTestCase): 10 | def test_clears_cache_on_save(self): 11 | 12 | post = Post.objects.create( 13 | slug="receiver", 14 | title="receiver", 15 | author=self.data.author, 16 | is_public=True, 17 | is_published=True, 18 | ) 19 | client = Client() 20 | with self.assertNumQueries(1): 21 | response = client.get( 22 | reverse("newsletter:view_post", kwargs={"slug": post.slug}) 23 | ) 24 | self.assertEqual(response.status_code, 200) 25 | 26 | self.assertIsNotNone(cache.get(f"post.detail.{post.slug}")) 27 | # Trigger the receiver 28 | post.save() 29 | self.assertIsNone(cache.get(f"post.detail.{post.slug}")) 30 | with self.assertNumQueries(1): 31 | response = client.get( 32 | reverse("newsletter:view_post", kwargs={"slug": post.slug}) 33 | ) 34 | self.assertEqual(response.status_code, 200) 35 | -------------------------------------------------------------------------------- /project/newsletter/test_syndication.py: -------------------------------------------------------------------------------- 1 | from project.newsletter.syndication import RecentCategorizedPostsFeed, RecentPostsFeed 2 | from project.newsletter.test import DataTestCase 3 | 4 | 5 | class TestRecentPostsFeed(DataTestCase): 6 | def setUp(self) -> None: 7 | super().setUp() 8 | self.feed = RecentPostsFeed() 9 | 10 | def test_items(self): 11 | self.assertEqual(self.feed.items().first(), self.data.career_post) 12 | 13 | def test_item_title(self): 14 | self.assertEqual( 15 | self.feed.item_title(self.data.career_post), self.data.career_post.title 16 | ) 17 | 18 | def test_item_description(self): 19 | self.assertEqual(self.feed.item_description(self.data.career_post), "") 20 | 21 | def test_item_pubdate(self): 22 | self.assertEqual( 23 | self.feed.item_pubdate(self.data.career_post), self.data.career_post.created 24 | ) 25 | 26 | def test_item_categories(self): 27 | self.assertEqual( 28 | self.feed.item_categories(self.data.all_post), 29 | [self.data.career.title, self.data.social.title], 30 | ) 31 | 32 | 33 | class TestRecentCategorizedPostsFeed(DataTestCase): 34 | def setUp(self) -> None: 35 | super().setUp() 36 | self.feed = RecentCategorizedPostsFeed() 37 | 38 | def test_get_object(self): 39 | self.assertEqual( 40 | self.feed.get_object(self.rf.get("/"), self.data.career.slug), 41 | self.data.career, 42 | ) 43 | 44 | def test_item_title(self): 45 | self.assertEqual( 46 | self.feed.item_title(self.data.career), "Career newsletter posts" 47 | ) 48 | 49 | def test_item_link(self): 50 | self.assertEqual(self.feed.item_link(self.data.career), "/p/?category=career") 51 | 52 | def test_items(self): 53 | self.assertEqual( 54 | self.feed.items(self.data.career).first(), self.data.career_post 55 | ) 56 | -------------------------------------------------------------------------------- /project/newsletter/test_templatetags.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.core.paginator import Paginator 4 | from django.test import SimpleTestCase, TestCase 5 | from django.utils import timezone 6 | 7 | from project.newsletter.models import Post 8 | from project.newsletter.templatetags.newsletter_utils import is_ellipsis, nice_datetime 9 | 10 | 11 | class TestIsEllipsis(SimpleTestCase): 12 | def test_is_ellipsis(self): 13 | self.assertTrue(is_ellipsis(Paginator.ELLIPSIS)) 14 | self.assertFalse(is_ellipsis("...")) 15 | 16 | 17 | class TestNiceDatetime(TestCase): 18 | def test_nice_datetime(self): 19 | post = Post(created=timezone.now()) 20 | actual = nice_datetime(post, is_unread=True) 21 | 22 | self.assertEqual( 23 | actual, {"timestamp": post.created, "is_recent": True, "is_unread": True} 24 | ) 25 | 26 | post.publish_at = timezone.now() - timedelta(days=7, minutes=1) 27 | actual = nice_datetime(post, is_unread=False) 28 | self.assertEqual( 29 | actual, 30 | {"timestamp": post.publish_at, "is_recent": False, "is_unread": False}, 31 | ) 32 | -------------------------------------------------------------------------------- /project/newsletter/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | 3 | from project.newsletter import syndication, views 4 | 5 | app_name = "newsletter" 6 | urlpatterns = [ 7 | path("markdown/uploader/", views.markdown_uploader, name="markdown_uploader"), 8 | path("", views.landing, name="landing"), 9 | path("account/", views.update_subscription, name="update_subscription"), 10 | path("analytics/", views.analytics, name="analytics"), 11 | path("post/unpublished/", views.unpublished_posts, name="unpublished_posts"), 12 | path("post/create/", views.create_post, name="create_post"), 13 | path("post//update/", views.update_post, name="update_post"), 14 | path( 15 | "post//toggle_privacy/", 16 | views.toggle_post_privacy, 17 | name="toggle_post_privacy", 18 | ), 19 | path( 20 | "p/", 21 | include( 22 | [ 23 | path("", views.list_posts, name="list_posts"), 24 | path("/", views.view_post, name="view_post"), 25 | ] 26 | ), 27 | ), 28 | path( 29 | "rss/", 30 | include( 31 | [ 32 | path("", syndication.RecentPostsFeed()), 33 | path("/", syndication.RecentCategorizedPostsFeed()), 34 | ] 35 | ), 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /project/newsletter/views.py: -------------------------------------------------------------------------------- 1 | import os 2 | import uuid 3 | from datetime import timedelta 4 | 5 | from django.conf import settings 6 | from django.contrib import messages 7 | from django.contrib.admin.views.decorators import staff_member_required 8 | from django.contrib.auth.decorators import login_required 9 | from django.core.cache import cache 10 | from django.core.files.base import ContentFile 11 | from django.core.files.storage import default_storage 12 | from django.core.paginator import Paginator 13 | from django.db.models import Case, Count, F, Q, Value, When 14 | from django.http import Http404, HttpResponse, JsonResponse 15 | from django.shortcuts import get_object_or_404, redirect, render 16 | from django.utils import timezone 17 | from django.utils.translation import gettext_lazy as _ 18 | from django.views.decorators.http import require_http_methods 19 | from martor.utils import LazyEncoder 20 | 21 | from project.newsletter import operations 22 | from project.newsletter.forms import PostForm, SubscriptionForm 23 | from project.newsletter.models import Category, Post, Subscription 24 | 25 | LIST_POSTS_PAGE_SIZE = 100 26 | 27 | 28 | @require_http_methods(["GET"]) 29 | def landing(request): 30 | """ 31 | The landing page view. 32 | 33 | Render the public posts or the most recent posts an authenticated 34 | user is subscribed for. 35 | """ 36 | posts = ( 37 | Post.objects.recent_first() 38 | .published() 39 | .annotate_is_unread(request.user) 40 | .prefetch_related("categories") 41 | ) 42 | if request.user.is_authenticated and ( 43 | subscription := Subscription.objects.for_user(request.user) 44 | ): 45 | posts = posts.in_relevant_categories(subscription) 46 | else: 47 | posts = posts.public() 48 | return render(request, "landing.html", {"posts": posts[:3]}) 49 | 50 | 51 | @require_http_methods(["GET"]) 52 | def list_posts(request): 53 | """ 54 | The post lists view. 55 | """ 56 | posts = ( 57 | Post.objects.recent_first() 58 | .published() 59 | .annotate_is_unread(request.user) 60 | .prefetch_related("categories") 61 | ) 62 | if not request.user.is_authenticated: 63 | posts = posts.public() 64 | paginator = Paginator(posts, LIST_POSTS_PAGE_SIZE) 65 | page_number = request.GET.get("page") 66 | page_obj = paginator.get_page(page_number) 67 | page_range = paginator.get_elided_page_range(page_obj.number) 68 | return render( 69 | request, "posts/list.html", {"page": page_obj, "page_range": page_range} 70 | ) 71 | 72 | 73 | @require_http_methods(["GET"]) 74 | def view_post(request, slug): 75 | """ 76 | The post detail view. 77 | """ 78 | post = cache.get(f"post.detail.{slug}", None) 79 | if request.user.is_authenticated or not post: 80 | posts = Post.objects.published().annotate_is_unread(request.user) 81 | if not request.user.is_authenticated: 82 | posts = posts.public() 83 | post = get_object_or_404(posts, slug=slug) 84 | if post.is_unread: 85 | operations.mark_as_read(post, request.user) 86 | if post.is_public: 87 | cache.set(f"post.detail.{slug}", post, timeout=600) 88 | is_trending = operations.check_is_trending(post) 89 | return render( 90 | request, 91 | "posts/detail.html", 92 | { 93 | "post": post, 94 | "is_trending": is_trending, 95 | "open_graph_url": request.build_absolute_uri(post.get_absolute_url()), 96 | }, 97 | ) 98 | 99 | 100 | @require_http_methods(["GET", "POST"]) 101 | @login_required() 102 | def update_subscription(request): 103 | instance = Subscription.objects.filter(user=request.user).first() 104 | form = SubscriptionForm(instance=instance) 105 | if request.method == "POST": 106 | form = SubscriptionForm(request.POST, instance=instance) 107 | if form.is_valid(): 108 | if not instance: 109 | form.instance.user = request.user 110 | form.save() 111 | messages.success(request, "Your subscription changes have been saved.") 112 | return redirect("newsletter:list_posts") 113 | return render(request, "subscription/update.html", {"form": form}) 114 | 115 | 116 | @staff_member_required(login_url=settings.LOGIN_URL) 117 | @require_http_methods(["GET"]) 118 | def unpublished_posts(request): 119 | """ 120 | The post lists view for unpublished posts 121 | """ 122 | posts = Post.objects.recent_first().unpublished().prefetch_related("categories") 123 | paginator = Paginator(posts, LIST_POSTS_PAGE_SIZE) 124 | page_number = request.GET.get("page") 125 | page_obj = paginator.get_page(page_number) 126 | page_range = paginator.get_elided_page_range(page_obj.number) 127 | return render( 128 | request, "posts/list.html", {"page": page_obj, "page_range": page_range} 129 | ) 130 | 131 | 132 | @staff_member_required(login_url=settings.LOGIN_URL) 133 | @require_http_methods(["GET", "POST"]) 134 | def create_post(request): 135 | """ 136 | Staff create post view 137 | """ 138 | form = PostForm() 139 | if request.method == "POST": 140 | form = PostForm(request.POST, files=request.FILES) 141 | if form.is_valid(): 142 | form.instance.author = request.user 143 | post = form.save() 144 | messages.success(request, f"Post '{post.title}' was created successfully.") 145 | return redirect("newsletter:update_post", slug=post.slug) 146 | return render(request, "staff/post_form.html", {"form": form, "post": None}) 147 | 148 | 149 | @staff_member_required(login_url=settings.LOGIN_URL) 150 | @require_http_methods(["GET", "POST"]) 151 | def update_post(request, slug): 152 | """ 153 | Staff update post view 154 | """ 155 | post = get_object_or_404(Post, slug=slug) 156 | form = PostForm(instance=post) 157 | if request.method == "POST": 158 | form = PostForm(request.POST, instance=post, files=request.FILES) 159 | if form.is_valid(): 160 | form.instance.author = request.user 161 | post = form.save() 162 | messages.success(request, f"Post '{post.title}' was updated successfully.") 163 | return redirect("newsletter:update_post", slug=post.slug) 164 | return render(request, "staff/post_form.html", {"form": form, "post": post}) 165 | 166 | 167 | @staff_member_required(login_url=settings.LOGIN_URL) 168 | @require_http_methods(["POST"]) 169 | def toggle_post_privacy(request, slug): 170 | """ 171 | Toggle Post.is_public and redirect back to next url or list view. 172 | """ 173 | updated = ( 174 | Post.objects.filter(slug=slug) 175 | .annotate( 176 | inverted_is_public=Case( 177 | When(is_public=True, then=Value(False)), default=Value(True) 178 | ) 179 | ) 180 | .update(is_public=F("inverted_is_public")) 181 | ) 182 | if not updated: 183 | raise Http404 184 | messages.success(request, f"Post slug={slug} was updated.") 185 | if url := request.GET.get("next"): 186 | return redirect(url) 187 | return redirect("newsletter:list_posts") 188 | 189 | 190 | @staff_member_required(login_url=settings.LOGIN_URL) 191 | @require_http_methods(["GET"]) 192 | def analytics(request): 193 | """ 194 | The post detail view. 195 | """ 196 | now = timezone.now() 197 | subscription_aggregates = Subscription.objects.all().aggregate( 198 | subscriptions=Count("user", distinct=True, filter=Q(categories__isnull=False)), 199 | subscriptions_30_days=Count( 200 | "id", 201 | filter=Q(categories__isnull=False, created__gte=now - timedelta(days=30)), 202 | distinct=True, 203 | ), 204 | subscriptions_90_days=Count( 205 | "id", 206 | filter=Q(categories__isnull=False, created__gte=now - timedelta(days=90)), 207 | distinct=True, 208 | ), 209 | subscriptions_180_days=Count( 210 | "id", 211 | filter=Q(categories__isnull=False, created__gte=now - timedelta(days=180)), 212 | distinct=True, 213 | ), 214 | ) 215 | subscription_category_aggregates = dict( 216 | Category.objects.annotate(count=Count("subscriptions")) 217 | .order_by("title") 218 | .values_list("title", "count") 219 | ) 220 | post_aggregates = Post.objects.all().aggregate( 221 | posts=Count("id"), 222 | posts_30_days=Count( 223 | "id", 224 | filter=Q(created__gte=now - timedelta(days=30)), 225 | ), 226 | posts_90_days=Count( 227 | "id", 228 | filter=Q(created__gte=now - timedelta(days=90)), 229 | ), 230 | posts_180_days=Count( 231 | "id", 232 | filter=Q(created__gte=now - timedelta(days=180)), 233 | ), 234 | ) 235 | post_category_aggregates = dict( 236 | Category.objects.annotate(count=Count("posts")) 237 | .order_by("title") 238 | .values_list("title", "count") 239 | ) 240 | 241 | return render( 242 | request, 243 | "staff/analytics.html", 244 | { 245 | "aggregates": { 246 | "Subscriptions": subscription_aggregates["subscriptions"], 247 | "Subscriptions (30 days)": subscription_aggregates[ 248 | "subscriptions_30_days" 249 | ], 250 | "Subscriptions (90 days)": subscription_aggregates[ 251 | "subscriptions_90_days" 252 | ], 253 | "Subscriptions (180 days)": subscription_aggregates[ 254 | "subscriptions_180_days" 255 | ], 256 | "Posts": post_aggregates["posts"], 257 | "Posts (30 days)": post_aggregates["posts_30_days"], 258 | "Posts (90 days)": post_aggregates["posts_90_days"], 259 | "Posts (180 days)": post_aggregates["posts_180_days"], 260 | }, 261 | "subscription_category_aggregates": subscription_category_aggregates, 262 | "post_category_aggregates": post_category_aggregates, 263 | }, 264 | ) 265 | 266 | 267 | @staff_member_required(login_url=settings.LOGIN_URL) 268 | @require_http_methods(["POST"]) 269 | def markdown_uploader(request): 270 | """ 271 | Markdown image upload for locale storage 272 | and represent as json to markdown editor. 273 | 274 | Taken from https://github.com/agusmakmun/django-markdown-editor/wiki 275 | """ 276 | if "markdown-image-upload" in request.FILES: 277 | image = request.FILES["markdown-image-upload"] 278 | image_types = [ 279 | "image/png", 280 | "image/jpg", 281 | "image/jpeg", 282 | "image/pjpeg", 283 | "image/gif", 284 | ] 285 | if image.content_type not in image_types: 286 | return JsonResponse( 287 | {"status": 405, "error": _("Bad image format.")}, 288 | encoder=LazyEncoder, 289 | status=405, 290 | ) 291 | 292 | if image.size > settings.FILE_UPLOAD_MAX_MEMORY_SIZE: 293 | to_MB = settings.FILE_UPLOAD_MAX_MEMORY_SIZE / (1024 * 1024) 294 | return JsonResponse( 295 | { 296 | "status": 405, 297 | "error": _("Maximum image file is %(size)s MB.") % {"size": to_MB}, 298 | }, 299 | encoder=LazyEncoder, 300 | status=405, 301 | ) 302 | 303 | img_uuid = "{}-{}".format(uuid.uuid4().hex[:10], image.name.replace(" ", "-")) 304 | tmp_file = os.path.join(settings.MARTOR_UPLOAD_PATH, img_uuid) 305 | def_path = default_storage.save(tmp_file, ContentFile(image.read())) 306 | img_url = os.path.join(settings.MEDIA_URL, def_path) 307 | 308 | return JsonResponse({"status": 200, "link": img_url, "name": image.name}) 309 | return HttpResponse(_("Invalid request!")) 310 | -------------------------------------------------------------------------------- /project/templates/403.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Page not found | {{ block.super }}{% endblock %} 4 | 5 | {% block content %} 6 |
7 |

8 | Sorry, you do not have permissions to view this page. 9 |

10 |
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /project/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Page not found | {{ block.super }}{% endblock %} 4 | 5 | {% block content %} 6 |
7 |

8 | Sorry, this page does not exist. 9 |

10 |
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /project/templates/500.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Error | {{ block.super }}{% endblock %} 4 | 5 | {% block content %} 6 |
7 |

8 | Sorry, a problem was encountered. Someone has been alerted. 9 |

10 |
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /project/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | {% block title %}Debug Newsletter{% endblock %} 5 | 6 | 7 | {% block open_graph %}{% endblock %} 8 | {% block css %}{% endblock %} 9 | 10 | 11 |
12 | 49 |
50 | 51 |
52 | {% block messages %} 53 | {% if messages %} 54 |
55 | {% for message in messages %} 56 |
57 |

{{ message }}

58 |
59 | {% endfor %} 60 |
61 | {% endif %} 62 | {% endblock %} 63 | 64 | {% block content %}{% endblock %} 65 |
66 | 67 | 72 | {% csrf_token %} 73 | 74 | 75 | 76 | 97 | {% block js %}{% endblock %} 98 | 99 | 100 | -------------------------------------------------------------------------------- /project/templates/django/forms/p.html: -------------------------------------------------------------------------------- 1 | {% if form.non_field_errors %} 2 |
3 |
We had some issues
4 |
    5 | {% for error in form.non_field_errors %} 6 |
  • {{ error|escape }}
  • 7 | {% endfor %} 8 |
9 |
10 | {% endif %} 11 | 12 | {% for field in form %} 13 |
14 | {{ field.label_tag }} 15 | {{ field }} 16 | {% if field.help_text %} 17 |

{{ field.help_text|safe }}

18 | {% endif %} 19 |
20 | 21 | {% if field.errors %} 22 |
23 |
We had some issues
24 |
    25 | {% for error in field.errors %} 26 |
  • {{ error|escape }}
  • 27 | {% endfor %} 28 |
29 |
30 | {% endif %} 31 | {% endfor %} 32 | -------------------------------------------------------------------------------- /project/templates/django/forms/widgets/select.html: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /project/templates/inclusion_tags/nice_datetime.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | Render the datetime is a localized format. 3 | Takes the following context: 4 | 5 | timestamp: datetime instance 6 | is_unread: boolean 7 | is_recent: boolean 8 | {% endcomment %} 9 | {% load humanize %} 10 | 11 | 12 | {{ timestamp|naturaltime }} 13 | {% if is_recent %}*New*{% endif %} 14 | 15 | -------------------------------------------------------------------------------- /project/templates/landing.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 | 5 |
6 | 7 |
8 |

9 | Debug Newsletter 10 |

11 |

All of my thoughts, none of the social media

12 |
Subscribe
13 |
14 | 15 |
16 | 17 |
18 |
19 |
20 |
21 |

Subscribe to the content you care about

22 |

23 | We all have different interests. You can subscribe 24 | to our overlapping areas and not worry about the others. 25 |

26 |
27 |
28 |

Limit how often you're told

29 |

30 | I'm not here to spam you, but if it's too much you can reduce how often 31 | you're notified. 32 |

33 |
34 |
35 |
36 |
37 | 38 |
39 |
40 | {% for post in posts %} 41 | {% include "posts/includes/list_item.html" with post=post %} 42 | {% if not forloop.last %} 43 |
44 | {% endif %} 45 | {% endfor %} 46 |
47 |
48 | 49 | {% endblock %} 50 | -------------------------------------------------------------------------------- /project/templates/posts/detail.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load humanize %} 3 | {% load martortags %} 4 | 5 | {% block title %}{{ post.title }} | {{ block.super }}{% endblock %} 6 | 7 | {% block open_graph %} 8 | 9 | 10 | {% if post.open_graph_image %} 11 | 12 | {% endif %} 13 | 14 | 15 | {% endblock %} 16 | 17 | {% block content %} 18 |
19 | 37 |

38 | Posted {{ post.publish_date|naturaltime }} 39 |

40 | {{ post.content|safe_markdown }} 41 |
42 | 43 | {% endblock %} 44 | -------------------------------------------------------------------------------- /project/templates/posts/includes/list_item.html: -------------------------------------------------------------------------------- 1 | {% load martortags %} 2 | {% load newsletter_utils %} 3 | 4 |

{{ post.title }}

5 | 6 | 7 |
8 |
9 | {% nice_datetime post=post is_unread=post.is_unread %} 10 |
11 |
12 | 13 | {% for category in post.categories.all %} 14 | {{ category.title }}{% if not forloop.last %}, {% endif %} 15 | {% endfor %} 16 | 17 |
18 |
19 | 20 |

{{ post.summary|safe_markdown }}

21 |
22 |
23 | Read More 24 |
25 | {% if request.user.is_staff %} 26 |
27 | 28 | {% csrf_token %} 29 |
30 | Edit 31 | 32 |
33 | 34 |
35 | {% endif %} 36 |
37 | -------------------------------------------------------------------------------- /project/templates/posts/list.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load humanize %} 3 | {% load newsletter_utils %} 4 | 5 | {% block content %} 6 | 7 |
8 |
9 | 14 | 21 | {% for post in page %} 22 | {% include "posts/includes/list_item.html" with post=post %} 23 | {% if not forloop.last %} 24 |
25 | {% endif %} 26 | {% endfor %} 27 |
28 |
29 | 30 | {% endblock %} 31 | -------------------------------------------------------------------------------- /project/templates/registration/activate.html: -------------------------------------------------------------------------------- 1 | {% extends "registration/registration_base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans "Activation Failure" %}{% endblock %} 5 | 6 | {% block content %} 7 |
8 |

{% trans "Account activation failed." %}

9 |
10 | {% endblock %} 11 | 12 | 13 | {% comment %} 14 | **registration/activate.html** 15 | 16 | Used if account activation fails. With the default setup, has the following context: 17 | 18 | ``activation_key`` 19 | The activation key used during the activation attempt. 20 | {% endcomment %} 21 | -------------------------------------------------------------------------------- /project/templates/registration/activation_complete.html: -------------------------------------------------------------------------------- 1 | {% extends "registration/registration_base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans "Account Activated" %}{% endblock %} 5 | 6 | {% block content %} 7 |
8 |

9 | {% trans "Your account is now activated." %} 10 | {% if not user.is_authenticated %} 11 | {% trans "You can log in." %} 12 | {% endif %} 13 |

14 |
15 | {% endblock %} 16 | 17 | 18 | {% comment %} 19 | **registration/activation_complete.html** 20 | 21 | Used after successful account activation. This template has no context 22 | variables of its own, and should simply inform the user that their 23 | account is now active. 24 | {% endcomment %} 25 | -------------------------------------------------------------------------------- /project/templates/registration/activation_complete_admin_pending.html: -------------------------------------------------------------------------------- 1 | {% extends "registration/registration_base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans "Account Activated" %}{% endblock %} 5 | 6 | {% block content %} 7 |
8 |

9 | {% trans "Your account is now activated." %} 10 | {% if not user.is_authenticated %} 11 | {% trans "Once a site administrator activates your account you can login." %} 12 | {% endif %} 13 |

14 |
15 | {% endblock %} 16 | 17 | 18 | {% comment %} 19 | **registration/activation_complete.html** 20 | 21 | Used after successful account activation. This template has no context 22 | variables of its own, and should simply inform the user that their 23 | account is now active. 24 | {% endcomment %} 25 | -------------------------------------------------------------------------------- /project/templates/registration/activation_email.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | 4 | 5 | 6 | {{ site.name }} {% trans "registration" %} 7 | 8 | 9 | 10 |

11 | {% blocktrans with site_name=site.name %} 12 | You (or someone pretending to be you) have asked to register an account at 13 | {{ site_name }}. If this wasn't you, please ignore this email 14 | and your address will be removed from our records. 15 | {% endblocktrans %} 16 |

17 |

18 | {% blocktrans %} 19 | To activate this account, please click the following link within the next 20 | {{ expiration_days }} days: 21 | {% endblocktrans %} 22 |

23 | 24 |

25 | 26 | {{site.domain}}{% url 'registration_activate' activation_key %} 27 | 28 |

29 |

30 | {% blocktrans with site_name=site.name %} 31 | Sincerely, 32 | {{ site_name }} Management 33 | {% endblocktrans %} 34 |

35 | 36 | 37 | 38 | 39 | 40 | {% comment %} 41 | **registration/activation_email.html** 42 | 43 | Used to generate the html body of the activation email. Should display a 44 | link the user can click to activate the account. This template has the 45 | following context: 46 | 47 | ``activation_key`` 48 | The activation key for the new account. 49 | 50 | ``expiration_days`` 51 | The number of days remaining during which the account may be 52 | activated. 53 | 54 | ``site`` 55 | An object representing the site on which the user registered; 56 | depending on whether ``django.contrib.sites`` is installed, this 57 | may be an instance of either ``django.contrib.sites.models.Site`` 58 | (if the sites application is installed) or 59 | ``django.contrib.sites.requests.RequestSite`` (if not). Consult `the 60 | documentation for the Django sites framework 61 | `_ for 62 | details regarding these objects' interfaces. 63 | 64 | ``user`` 65 | The new user account 66 | 67 | ``request`` 68 | ``HttpRequest`` instance for better flexibility. 69 | For example it can be used to compute absolute register URL: 70 | 71 | {{ request.scheme }}://{{ request.get_host }}{% url 'registration_activate' activation_key %} 72 | {% endcomment %} 73 | -------------------------------------------------------------------------------- /project/templates/registration/activation_email.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% blocktrans with site_name=site.name %} 3 | You (or someone pretending to be you) have asked to register an account at 4 | {{ site_name }}. If this wasn't you, please ignore this email 5 | and your address will be removed from our records. 6 | {% endblocktrans %} 7 | {% blocktrans %} 8 | To activate this account, please click the following link within the next 9 | {{ expiration_days }} days: 10 | {% endblocktrans %} 11 | 12 | http://{{site.domain}}{% url 'registration_activate' activation_key %} 13 | 14 | {% blocktrans with site_name=site.name %} 15 | Sincerely, 16 | {{ site_name }} Management 17 | {% endblocktrans %} 18 | 19 | 20 | {% comment %} 21 | **registration/activation_email.txt** 22 | 23 | Used to generate the text body of the activation email. Should display a 24 | link the user can click to activate the account. This template has the 25 | following context: 26 | 27 | ``activation_key`` 28 | The activation key for the new account. 29 | 30 | ``expiration_days`` 31 | The number of days remaining during which the account may be 32 | activated. 33 | 34 | ``site`` 35 | An object representing the site on which the user registered; 36 | depending on whether ``django.contrib.sites`` is installed, this 37 | may be an instance of either ``django.contrib.sites.models.Site`` 38 | (if the sites application is installed) or 39 | ``django.contrib.sites.requests.RequestSite`` (if not). Consult `the 40 | documentation for the Django sites framework 41 | `_ for 42 | details regarding these objects' interfaces. 43 | 44 | ``user`` 45 | The new user account 46 | 47 | ``request`` 48 | ``HttpRequest`` instance for better flexibility. 49 | For example it can be used to compute absolute register URL: 50 | 51 | {{ request.scheme }}://{{ request.get_host }}{% url 'registration_activate' activation_key %} 52 | {% endcomment %} 53 | -------------------------------------------------------------------------------- /project/templates/registration/activation_email_subject.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% trans "Account activation on" %} {{ site.name }} 2 | 3 | 4 | {% comment %} 5 | **registration/activation_email_subject.txt** 6 | 7 | Used to generate the subject line of the activation email. Because the 8 | subject line of an email must be a single line of text, any output 9 | from this template will be forcibly condensed to a single line before 10 | being used. This template has the following context: 11 | 12 | ``activation_key`` 13 | The activation key for the new account. 14 | 15 | ``expiration_days`` 16 | The number of days remaining during which the account may be 17 | activated. 18 | 19 | ``site`` 20 | An object representing the site on which the user registered; 21 | depending on whether ``django.contrib.sites`` is installed, this 22 | may be an instance of either ``django.contrib.sites.models.Site`` 23 | (if the sites application is installed) or 24 | ``django.contrib.sites.requests.RequestSite`` (if not). Consult `the 25 | documentation for the Django sites framework 26 | `_ for 27 | details regarding these objects' interfaces. 28 | {% endcomment %} 29 | -------------------------------------------------------------------------------- /project/templates/registration/admin_approve.html: -------------------------------------------------------------------------------- 1 | {% extends "registration/registration_base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans "Approval Failure" %}{% endblock %} 5 | 6 | {% block content %} 7 |
8 |

{% trans "Account Approval failed." %}

9 |
10 | {% endblock %} 11 | 12 | 13 | {% comment %} 14 | **registration/admin_approve.html** 15 | 16 | Used if account activation fails. With the default setup, has the following context: 17 | 18 | ``activation_key`` 19 | The activation key used during the activation attempt. 20 | {% endcomment %} 21 | -------------------------------------------------------------------------------- /project/templates/registration/admin_approve_complete.html: -------------------------------------------------------------------------------- 1 | {% extends "registration/registration_base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans "Account Approved" %}{% endblock %} 5 | 6 | {% block content %} 7 |
8 |

9 | {% trans "The user's account is now approved." %} 10 |

11 |
12 | {% endblock %} 13 | 14 | 15 | {% comment %} 16 | **registration/admin_approve_complete.html** 17 | 18 | Used to generate the html body of the admin activation email. Should display a 19 | link for an admin to approve activation of the account. This template has the 20 | following context: 21 | 22 | ``site`` 23 | An object representing the site on which the user registered; 24 | depending on whether ``django.contrib.sites`` is installed, this 25 | may be an instance of either ``django.contrib.sites.models.Site`` 26 | (if the sites application is installed) or 27 | ``django.contrib.sites.requests.RequestSite`` (if not). Consult `the 28 | documentation for the Django sites framework 29 | `_ for 30 | details regarding these objects' interfaces. 31 | 32 | ``user`` 33 | The new user account 34 | 35 | {% endcomment %} 36 | -------------------------------------------------------------------------------- /project/templates/registration/admin_approve_complete_email.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | 4 | 5 | 6 | {{ site.name }} {% trans "admin approval" %} 7 | 8 | 9 | 10 |

11 | {% blocktrans %} 12 | Your account is now approved. You can 13 | {% endblocktrans %} 14 | {% trans "log in." %} 15 |

16 | 17 | 18 | 19 | 20 | 21 | {% comment %} 22 | **registration/admin_approve_complete_email.html** 23 | 24 | Used after successful account activation. This template has no context 25 | variables of its own, and should simply inform the user that their 26 | account is now active. 27 | {% endcomment %} 28 | -------------------------------------------------------------------------------- /project/templates/registration/admin_approve_complete_email.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% blocktrans %} 3 | Your account is now approved. You can log in using the following link 4 | {% endblocktrans %} 5 | http://{{site.domain}}{% url 'auth_login' %} 6 | 7 | {% comment %} 8 | **registration/admin_approve_complete_email.txt** 9 | 10 | Used after successful account activation. This template has no context 11 | variables of its own, and should simply inform the user that their 12 | account is now active. 13 | {% endcomment %} 14 | -------------------------------------------------------------------------------- /project/templates/registration/admin_approve_complete_email_subject.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% trans "Account activation on" %} {{ site.name }} 2 | 3 | 4 | {% comment %} 5 | **registration/admin_approve_complete_email_subject.txt** 6 | 7 | Used to generate the subject line of the admin approval complete email. Because 8 | the subject line of an email must be a single line of text, any output 9 | from this template will be forcibly condensed to a single line before 10 | being used. This template has the following context: 11 | 12 | ``site`` 13 | An object representing the site on which the user registered; 14 | depending on whether ``django.contrib.sites`` is installed, this 15 | may be an instance of either ``django.contrib.sites.models.Site`` 16 | (if the sites application is installed) or 17 | ``django.contrib.sites.requests.RequestSite`` (if not). Consult `the 18 | documentation for the Django sites framework 19 | `_ for 20 | details regarding these objects' interfaces. 21 | {% endcomment %} 22 | -------------------------------------------------------------------------------- /project/templates/registration/admin_approve_email.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | 4 | 5 | 6 | {{ site.name }} {% trans "registration" %} 7 | 8 | 9 | 10 |

11 | {% blocktrans with site_name=site.name %} 12 | The following user ({{ user }}) has asked to register an account at 13 | {{ site_name }}. 14 | {% endblocktrans %} 15 |

16 |

17 | {% blocktrans %} 18 | To approve this, please 19 | {% endblocktrans %} 20 | {% trans "click here" %}. 21 |

22 |

23 | {% blocktrans with site_name=site.name %} 24 | Sincerely, 25 | {{ site_name }} Management 26 | {% endblocktrans %} 27 |

28 | 29 | 30 | 31 | 32 | {% comment %} 33 | **registration/admin_approve_email.html** 34 | 35 | Used to generate the html body of the admin activation email. Should display a 36 | link for an admin to approve activation of the account. This template has the 37 | following context: 38 | 39 | ``profile_id`` 40 | The id of the registration profile requesting approval 41 | 42 | ``site`` 43 | An object representing the site on which the user registered; 44 | depending on whether ``django.contrib.sites`` is installed, this 45 | may be an instance of either ``django.contrib.sites.models.Site`` 46 | (if the sites application is installed) or 47 | ``django.contrib.sites.requests.RequestSite`` (if not). Consult `the 48 | documentation for the Django sites framework 49 | `_ for 50 | details regarding these objects' interfaces. 51 | 52 | ``user`` 53 | The new user account 54 | 55 | ``request`` 56 | ``HttpRequest`` instance for better flexibility. 57 | For example it can be used to compute absolute approval URL: 58 | 59 | {{ request.scheme }}://{{ request.get_host }}{% url 'registration_admin_approve' profile_id %} 60 | {% endcomment %} 61 | -------------------------------------------------------------------------------- /project/templates/registration/admin_approve_email.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% blocktrans with site_name=site.name %} 3 | The following user ({{ user }}) has asked to register an account at 4 | {{ site_name }}. 5 | {% endblocktrans %} 6 | {% blocktrans %} 7 | To approve this, please click the following link. 8 | {% endblocktrans %} 9 | 10 | http://{{site.domain}}{% url 'registration_admin_approve' profile_id %} 11 | -------------------------------------------------------------------------------- /project/templates/registration/admin_approve_email_subject.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% trans "Account approval on" %} {{ site.name }} 2 | -------------------------------------------------------------------------------- /project/templates/registration/login.html: -------------------------------------------------------------------------------- 1 | {% extends "registration/registration_base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans "Log in" %}{% endblock %} 5 | 6 | {% block content %} 7 |
8 | {% csrf_token %} 9 | {{ form.as_p }} 10 | 11 | 12 |
13 | 14 |

{% trans "Forgot your password?" %} {% trans "Reset it" %}.

15 |

{% trans "Not a member?" %} {% trans "Register" %}.

16 | {% endblock %} 17 | 18 | 19 | {% comment %} 20 | **registration/login.html** 21 | 22 | It's your responsibility to provide the login form in a template called 23 | registration/login.html by default. This template gets passed four 24 | template context variables: 25 | 26 | ``form`` 27 | A Form object representing the login form. See the forms 28 | documentation for more on Form objects. 29 | 30 | ``next`` 31 | The URL to redirect to after successful login. This may contain a 32 | query string, too. 33 | 34 | ``site`` 35 | The current Site, according to the SITE_ID setting. If you don't 36 | have the site framework installed, this will be set to an instance 37 | of RequestSite, which derives the site name and domain from the 38 | current HttpRequest. 39 | 40 | ``site_name`` 41 | An alias for site.name. If you don't have the site framework 42 | installed, this will be set to the value of 43 | request.META['SERVER_NAME']. For more on sites, see The 44 | "sites" framework. 45 | {% endcomment %} 46 | -------------------------------------------------------------------------------- /project/templates/registration/logout.html: -------------------------------------------------------------------------------- 1 | {% extends "registration/registration_base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans "Logged out" %}{% endblock %} 5 | 6 | {% block content %} 7 |
8 |

{% trans "Successfully logged out" %}.

9 |
10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /project/templates/registration/password_change_done.html: -------------------------------------------------------------------------------- 1 | {% extends "registration/registration_base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans "Password changed" %}{% endblock %} 5 | 6 | {% block content %} 7 |
8 |

{% trans "Password successfully changed!" %}

9 |
10 | {% endblock %} 11 | 12 | 13 | {# This is used by django.contrib.auth #} 14 | -------------------------------------------------------------------------------- /project/templates/registration/password_change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "registration/registration_base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans "Change password" %}{% endblock %} 5 | 6 | {% block content %} 7 | 8 |
9 | {% csrf_token %} 10 | {{ form.as_p }} 11 | 12 |
13 | {% endblock %} 14 | 15 | 16 | {# This is used by django.contrib.auth #} 17 | -------------------------------------------------------------------------------- /project/templates/registration/password_reset_complete.html: -------------------------------------------------------------------------------- 1 | {% extends "registration/registration_base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans "Password reset complete" %}{% endblock %} 5 | 6 | {% block content %} 7 |
8 |

9 | {% trans "Your password has been reset!" %} 10 | {% blocktrans %}You may now log in{% endblocktrans %}. 11 |

12 |
13 | {% endblock %} 14 | 15 | 16 | {# This is used by django.contrib.auth #} 17 | -------------------------------------------------------------------------------- /project/templates/registration/password_reset_confirm.html: -------------------------------------------------------------------------------- 1 | {% extends "registration/registration_base.html" %} 2 | {% load i18n %} 3 | 4 | {% block meta %} 5 | 7 | 8 | {% endblock %} 9 | 10 | {% block title %}{% trans "Confirm password reset" %}{% endblock %} 11 | 12 | {% block content %} 13 | {% if validlink %} 14 |

{% trans "Enter your new password below to reset your password:" %}

15 |
16 | {% csrf_token %} 17 | {{ form.as_p }} 18 | 19 |
20 | {% else %} 21 |
22 |

23 | Password reset unsuccessful. Please try again. 24 |

25 |
26 | {% endif %} 27 | {% endblock %} 28 | 29 | 30 | {# This is used by django.contrib.auth #} 31 | -------------------------------------------------------------------------------- /project/templates/registration/password_reset_done.html: -------------------------------------------------------------------------------- 1 | {% extends "registration/registration_base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans "Password reset" %}{% endblock %} 5 | 6 | {% block content %} 7 |
8 |

9 | {% blocktrans %} 10 | We have sent you an email with a link to reset your password. Please check 11 | your email and click the link to continue. 12 | {% endblocktrans %} 13 |

14 |
15 | {% endblock %} 16 | 17 | 18 | {# This is used by django.contrib.auth #} 19 | -------------------------------------------------------------------------------- /project/templates/registration/password_reset_email.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {% blocktrans %}Greetings{% endblocktrans %} {% if user.get_full_name %}{{ user.get_full_name }}{% else %}{{ user }}{% endif %}, 4 | 5 | {% blocktrans %} 6 | You are receiving this email because you (or someone pretending to be you) 7 | requested that your password be reset on the {{ domain }} site. If you do not 8 | wish to reset your password, please ignore this message. 9 | {% endblocktrans %} 10 | 11 | {% blocktrans %} 12 | To reset your password, please click the following link, or copy and paste it 13 | into your web browser: 14 | {% endblocktrans %} 15 | 16 | 17 | {{ protocol }}://{{ domain }}{% url 'auth_password_reset_confirm' uid token %} 18 | 19 | 20 | {% blocktrans %}Your username, in case you've forgotten:{% endblocktrans %} {{ user.get_username }} 21 | 22 | 23 | {% blocktrans %}Best regards{% endblocktrans %}, 24 | {{ site_name }} {% blocktrans %}Management{% endblocktrans %} 25 | 26 | 27 | {# This is used by django.contrib.auth #} 28 | -------------------------------------------------------------------------------- /project/templates/registration/password_reset_form.html: -------------------------------------------------------------------------------- 1 | {% extends "registration/registration_base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans "Reset password" %}{% endblock %} 5 | 6 | {% block content %} 7 |

8 | {% blocktrans %} 9 | Forgot your password? Enter your email in the form below and we'll send you instructions for creating a new one. 10 | {% endblocktrans %} 11 |

12 | 13 |
14 | {% csrf_token %} 15 | {{ form.as_p }} 16 | 17 |
18 | 19 | {% endblock %} 20 | 21 | 22 | {# This is used by django.contrib.auth #} 23 | -------------------------------------------------------------------------------- /project/templates/registration/registration_base.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | -------------------------------------------------------------------------------- /project/templates/registration/registration_closed.html: -------------------------------------------------------------------------------- 1 | {% extends "registration/registration_base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans "Registration is closed" %}{% endblock %} 5 | 6 | {% block content %} 7 |
8 |

{% trans "Sorry, but registration is closed at this moment. Come back later." %}

9 |
10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /project/templates/registration/registration_complete.html: -------------------------------------------------------------------------------- 1 | {% extends "registration/registration_base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans "Activation email sent" %}{% endblock %} 5 | 6 | {% block content %} 7 |
8 |

{% trans "Please check your email to complete the registration process." %}

9 |
10 | {% endblock %} 11 | 12 | 13 | {% comment %} 14 | **registration/registration_complete.html** 15 | 16 | Used after successful completion of the registration form. This 17 | template has no context variables of its own, and should simply inform 18 | the user that an email containing account-activation information has 19 | been sent. 20 | {% endcomment %} 21 | -------------------------------------------------------------------------------- /project/templates/registration/registration_form.html: -------------------------------------------------------------------------------- 1 | {% extends "registration/registration_base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans "Register for an account" %}{% endblock %} 5 | 6 | {% block content %} 7 |
8 | {% csrf_token %} 9 | {{ form.as_p }} 10 | 11 |
12 | {% endblock %} 13 | 14 | 15 | {% comment %} 16 | **registration/registration_form.html** 17 | Used to show the form users will fill out to register. By default, has 18 | the following context: 19 | 20 | ``form`` 21 | The registration form. This will be an instance of some subclass 22 | of ``django.forms.Form``; consult `Django's forms documentation 23 | `_ for 24 | information on how to display this in a template. 25 | {% endcomment %} 26 | -------------------------------------------------------------------------------- /project/templates/registration/resend_activation_complete.html: -------------------------------------------------------------------------------- 1 | {% extends "registration/registration_base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans "Account Activation Resent" %}{% endblock %} 5 | 6 | {% block content %} 7 |
8 |

9 | {% blocktrans %} 10 | We have sent an email to {{ email }} with further instructions. 11 | {% endblocktrans %} 12 |

13 |
14 | {% endblock %} 15 | 16 | 17 | {% comment %} 18 | **registration/resend_activation_complete.html** 19 | Used after form for resending account activation is submitted. By default has 20 | the following context: 21 | 22 | ``email`` 23 | The email address submitted in the resend activation form. 24 | {% endcomment %} 25 | -------------------------------------------------------------------------------- /project/templates/registration/resend_activation_form.html: -------------------------------------------------------------------------------- 1 | {% extends "registration/registration_base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans "Resend Activation Email" %}{% endblock %} 5 | 6 | {% block content %} 7 |
8 | {% csrf_token %} 9 | {{ form.as_p }} 10 | 11 |
12 | {% endblock %} 13 | 14 | 15 | {% comment %} 16 | **registration/resend_activation_form.html** 17 | Used to show the form users will fill out to resend the activation email. By 18 | default, has the following context: 19 | 20 | ``form`` 21 | The registration form. This will be an instance of some subclass 22 | of ``django.forms.Form``; consult `Django's forms documentation 23 | `_ for 24 | information on how to display this in a template. 25 | {% endcomment %} 26 | -------------------------------------------------------------------------------- /project/templates/staff/analytics.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load humanize %} 3 | {% load martortags %} 4 | {% load newsletter_utils %} 5 | 6 | {% block content %} 7 | 8 |
9 |
10 | 15 | 16 |

Subscriber Analytics

17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | {% for metric, value in aggregates.items %} 26 | 27 | 28 | 29 | 30 | {% endfor %} 31 | {% for category, value in subscription_category_aggregates.items %} 32 | 33 | 34 | 35 | 36 | {% endfor %} 37 | {% for category, value in post_category_aggregates.items %} 38 | 39 | 40 | 41 | 42 | {% endfor %} 43 | 44 |
MetricValue
{{ metric }}{{ value }}
{{ category }} subscriptions{{ value }}
{{ category }} posts{{ value }}
45 |
46 |
47 | 48 | {% endblock %} 49 | -------------------------------------------------------------------------------- /project/templates/staff/post_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n static %} 3 | {% load martortags %} 4 | 5 | {% block css %} 6 | 7 | 8 | 9 | {% endblock %} 10 | 11 | {% block content %} 12 | 13 |
14 | {% csrf_token %} 15 | {{ form.as_p }} 16 | 17 |
18 | 19 | {% endblock %} 20 | 21 | {% block js %} 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 37 | {% endblock %} 38 | -------------------------------------------------------------------------------- /project/templates/subscription/update.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | 4 | {% block title %}Subscription | {{ block.super }}{% endblock %} 5 | 6 | {% block content %} 7 |
8 | 9 | {% csrf_token %} 10 | {{ form.as_p }} 11 | 12 |
13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /project/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/project/tests/__init__.py -------------------------------------------------------------------------------- /project/tests/runner.py: -------------------------------------------------------------------------------- 1 | from django.test.runner import DiscoverRunner 2 | from django.test.utils import override_settings 3 | 4 | 5 | class ProjectTestRunner(DiscoverRunner): 6 | def __init__(self, *args, **kwargs): 7 | super().__init__(*args, **kwargs) 8 | # Automatically exclude lab_test tagged tests for normal runs. 9 | # This allows a student to run the test suite after completing the lab 10 | # without having the lab test error. 11 | if "lab_test" not in self.tags: 12 | self.exclude_tags.add("lab_test") 13 | 14 | def setup_databases(self, **kwargs): 15 | # Force to always delete the database if it exists 16 | interactive = self.interactive 17 | self.interactive = False 18 | try: 19 | return super().setup_databases(**kwargs) 20 | finally: 21 | self.interactive = interactive 22 | 23 | def run_tests(self, *args, **kwargs): 24 | 25 | with override_settings(**TEST_SETTINGS): 26 | return super().run_tests(*args, **kwargs) 27 | 28 | 29 | TEST_SETTINGS = { 30 | "PASSWORD_HASHERS": ["django.contrib.auth.hashers.MD5PasswordHasher"], 31 | } 32 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | pip-tools 2 | coverage 3 | django-anymail 4 | django-debug-toolbar 5 | django_coverage_plugin 6 | django-environ 7 | django-registration-redux 8 | Pillow 9 | martor 10 | pre-commit 11 | faker 12 | mdgen 13 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile --strip-extras 6 | # 7 | asgiref==3.8.1 8 | # via django 9 | bleach==6.2.0 10 | # via martor 11 | build==1.2.2.post1 12 | # via pip-tools 13 | certifi==2025.4.26 14 | # via requests 15 | cfgv==3.4.0 16 | # via pre-commit 17 | charset-normalizer==3.4.2 18 | # via requests 19 | click==8.2.1 20 | # via pip-tools 21 | coverage==7.8.2 22 | # via 23 | # -r requirements.in 24 | # django-coverage-plugin 25 | distlib==0.3.9 26 | # via virtualenv 27 | django==5.2.1 28 | # via 29 | # django-anymail 30 | # django-debug-toolbar 31 | # martor 32 | django-anymail==13.0 33 | # via -r requirements.in 34 | django-coverage-plugin==3.1.0 35 | # via -r requirements.in 36 | django-debug-toolbar==5.2.0 37 | # via -r requirements.in 38 | django-environ==0.12.0 39 | # via -r requirements.in 40 | django-registration-redux==2.13 41 | # via -r requirements.in 42 | faker==37.3.0 43 | # via 44 | # -r requirements.in 45 | # mdgen 46 | filelock==3.18.0 47 | # via virtualenv 48 | identify==2.6.12 49 | # via pre-commit 50 | idna==3.10 51 | # via requests 52 | markdown==3.5.2 53 | # via martor 54 | martor==1.6.45 55 | # via -r requirements.in 56 | mdgen==0.1.10 57 | # via -r requirements.in 58 | nodeenv==1.9.1 59 | # via pre-commit 60 | packaging==25.0 61 | # via build 62 | pillow==11.2.1 63 | # via -r requirements.in 64 | pip-tools==7.4.1 65 | # via -r requirements.in 66 | platformdirs==4.3.8 67 | # via virtualenv 68 | pre-commit==4.2.0 69 | # via -r requirements.in 70 | pyproject-hooks==1.2.0 71 | # via 72 | # build 73 | # pip-tools 74 | pyyaml==6.0.2 75 | # via pre-commit 76 | requests==2.32.3 77 | # via 78 | # django-anymail 79 | # martor 80 | sqlparse==0.5.3 81 | # via 82 | # django 83 | # django-debug-toolbar 84 | tzdata==2025.2 85 | # via 86 | # faker 87 | # martor 88 | urllib3==2.4.0 89 | # via 90 | # django-anymail 91 | # requests 92 | virtualenv==20.31.2 93 | # via pre-commit 94 | webencodings==0.5.1 95 | # via bleach 96 | wheel==0.45.1 97 | # via pip-tools 98 | 99 | # The following packages are considered to be unsafe in a requirements file: 100 | # pip 101 | # setuptools 102 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = Debug Tutorial 3 | version = 1.0.0 4 | description = An example Newsletter application used for a debugging tutorial. 5 | author = Tim Schilling 6 | author_email = schillingt@better-simple.com 7 | url = https://github.com/tim-schilling/debug-tutorial 8 | 9 | [coverage.html] 10 | skip_covered = True 11 | skip_empty = True 12 | 13 | [coverage:run] 14 | branch = True 15 | parallel = True 16 | source = project 17 | omit = 18 | manage.py 19 | project/config/asgi.py 20 | project/config/wsgi.py 21 | project/data/* 22 | plugins = django_coverage_plugin 23 | 24 | [coverage:django_coverage_plugin] 25 | template_extensions = html, txt 26 | 27 | [coverage:paths] 28 | source = project 29 | 30 | [coverage:report] 31 | show_missing = True 32 | 33 | [flake8] 34 | extend-ignore = E203, E501 35 | 36 | [isort] 37 | combine_as_imports = true 38 | profile = black 39 | -------------------------------------------------------------------------------- /static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/static/.gitkeep -------------------------------------------------------------------------------- /static/fomantic/themes/basic/assets/fonts/icons.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/static/fomantic/themes/basic/assets/fonts/icons.eot -------------------------------------------------------------------------------- /static/fomantic/themes/basic/assets/fonts/icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/static/fomantic/themes/basic/assets/fonts/icons.ttf -------------------------------------------------------------------------------- /static/fomantic/themes/basic/assets/fonts/icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/static/fomantic/themes/basic/assets/fonts/icons.woff -------------------------------------------------------------------------------- /static/fomantic/themes/default/assets/fonts/brand-icons.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/static/fomantic/themes/default/assets/fonts/brand-icons.eot -------------------------------------------------------------------------------- /static/fomantic/themes/default/assets/fonts/brand-icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/static/fomantic/themes/default/assets/fonts/brand-icons.ttf -------------------------------------------------------------------------------- /static/fomantic/themes/default/assets/fonts/brand-icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/static/fomantic/themes/default/assets/fonts/brand-icons.woff -------------------------------------------------------------------------------- /static/fomantic/themes/default/assets/fonts/brand-icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/static/fomantic/themes/default/assets/fonts/brand-icons.woff2 -------------------------------------------------------------------------------- /static/fomantic/themes/default/assets/fonts/icons.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/static/fomantic/themes/default/assets/fonts/icons.eot -------------------------------------------------------------------------------- /static/fomantic/themes/default/assets/fonts/icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/static/fomantic/themes/default/assets/fonts/icons.ttf -------------------------------------------------------------------------------- /static/fomantic/themes/default/assets/fonts/icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/static/fomantic/themes/default/assets/fonts/icons.woff -------------------------------------------------------------------------------- /static/fomantic/themes/default/assets/fonts/icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/static/fomantic/themes/default/assets/fonts/icons.woff2 -------------------------------------------------------------------------------- /static/fomantic/themes/default/assets/fonts/outline-icons.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/static/fomantic/themes/default/assets/fonts/outline-icons.eot -------------------------------------------------------------------------------- /static/fomantic/themes/default/assets/fonts/outline-icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/static/fomantic/themes/default/assets/fonts/outline-icons.ttf -------------------------------------------------------------------------------- /static/fomantic/themes/default/assets/fonts/outline-icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/static/fomantic/themes/default/assets/fonts/outline-icons.woff -------------------------------------------------------------------------------- /static/fomantic/themes/default/assets/fonts/outline-icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/static/fomantic/themes/default/assets/fonts/outline-icons.woff2 -------------------------------------------------------------------------------- /static/fomantic/themes/default/assets/images/flags.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/static/fomantic/themes/default/assets/images/flags.png -------------------------------------------------------------------------------- /static/fomantic/themes/github/assets/fonts/octicons-local.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/static/fomantic/themes/github/assets/fonts/octicons-local.ttf -------------------------------------------------------------------------------- /static/fomantic/themes/github/assets/fonts/octicons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/static/fomantic/themes/github/assets/fonts/octicons.ttf -------------------------------------------------------------------------------- /static/fomantic/themes/github/assets/fonts/octicons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/static/fomantic/themes/github/assets/fonts/octicons.woff -------------------------------------------------------------------------------- /static/fomantic/themes/material/assets/fonts/icons.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/static/fomantic/themes/material/assets/fonts/icons.eot -------------------------------------------------------------------------------- /static/fomantic/themes/material/assets/fonts/icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/static/fomantic/themes/material/assets/fonts/icons.ttf -------------------------------------------------------------------------------- /static/fomantic/themes/material/assets/fonts/icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/static/fomantic/themes/material/assets/fonts/icons.woff -------------------------------------------------------------------------------- /static/fomantic/themes/material/assets/fonts/icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-schilling/debug-tutorial/9e72eda0c1709116075c37eb0058eba80229b64f/static/fomantic/themes/material/assets/fonts/icons.woff2 --------------------------------------------------------------------------------