├── .flake8 ├── .gitconfig ├── .gitignore ├── .isort.cfg ├── .pre-commit-config.yaml ├── LICENSE ├── Procfile ├── README.md ├── Startup_Organizer.postman_collection.json ├── build_docker_env.bat ├── build_docker_env.sh ├── docker-compose.yml ├── docker └── django │ ├── Dockerfile │ ├── celery │ ├── beat_start.sh │ └── flower_start.sh │ ├── django_entrypoint.sh │ └── jupyter_entrypoint.sh ├── pyproject.toml ├── requirements.txt ├── requirements ├── base.txt ├── development.txt └── production.txt ├── runtime.txt └── src ├── 0.0 Generate Data.ipynb ├── 01.06 Data Factories.ipynb ├── 01.07 Property-test serializers with Hypothesis.ipynb ├── 2.04 User Content-Types Permissions and Groups.ipynb ├── 3.04_Generate_OAuth_2_Application_Data.ipynb ├── 4.02_Optimize_Database_Connections.ipynb ├── 5.02_Pagination.ipynb ├── blog ├── __init__.py ├── admin.py ├── apps.py ├── feeds.py ├── forms.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── routers.py ├── serializers.py ├── sitemaps.py ├── tests │ ├── __init__.py │ ├── factories.py │ ├── test_admin.py │ ├── test_api_views.py │ ├── test_forms.py │ ├── test_models.py │ ├── test_serializers.py │ └── test_views.py ├── urls.py ├── views.py └── viewsets.py ├── config ├── __init__.py ├── celery.py ├── checks.py ├── settings │ ├── base.py │ ├── development.py │ └── production.py ├── sitemaps.py ├── test_utils.py ├── urls.py ├── views.py └── wsgi.py ├── manage.py ├── organizer ├── __init__.py ├── admin.py ├── apps.py ├── forms.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_startup_logo.py │ └── __init__.py ├── models.py ├── routers.py ├── serializers.py ├── sitemaps.py ├── tests │ ├── __init__.py │ ├── factories.py │ ├── test_admin.py │ ├── test_api_views.py │ ├── test_forms.py │ ├── test_models.py │ ├── test_serializers.py │ └── test_views.py ├── urls.py ├── view_mixins.py ├── views.py └── viewsets.py ├── static_content ├── css │ ├── normalize.css │ ├── skeleton.css │ └── style.css └── images │ ├── logo.png │ └── rss.png ├── static_root ├── android-chrome-72x72.png ├── apple-touch-icon.png ├── browserconfig.xml ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── mstile-150x150.png ├── safari-pinned-tab.svg └── site.webmanifest ├── templates ├── base.html ├── django_registration │ ├── activation_email_body.txt │ ├── activation_email_subject.txt │ ├── activation_failed.html │ ├── base.html │ ├── registration_complete.html │ └── registration_form.html ├── newslink │ ├── base.html │ ├── confirm_delete.html │ └── form.html ├── post │ ├── base.html │ ├── confirm_delete.html │ ├── detail.html │ ├── form.html │ ├── list.html │ ├── post_archive_month.html │ └── post_archive_year.html ├── root.html ├── startup │ ├── base.html │ ├── confirm_delete.html │ ├── detail.html │ ├── form.html │ └── list.html ├── tag │ ├── base.html │ ├── confirm_delete.html │ ├── detail.html │ ├── form.html │ └── list.html └── user │ ├── account.html │ ├── base.html │ ├── login.html │ ├── password_change_form.html │ ├── password_reset_confirm.html │ ├── password_reset_email.txt │ ├── password_reset_form.html │ └── password_reset_subject.txt └── user ├── __init__.py ├── admin.py ├── apps.py ├── forms.py ├── migrations ├── 0001_initial.py └── __init__.py ├── models.py ├── signals.py ├── test_oauth_routes.py ├── test_oauth_workflows.py ├── tests ├── __init__.py ├── test_account_views.py ├── test_auth_views.py └── test_password_views.py ├── urls.py └── views.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude=settings.py, wsgi.py, manage.py, */migrations/* 3 | ignore = B950, D104, D105, D106, D400, E203, E266, E501, N803, N806, W503 4 | max-complexity = 10 5 | max-line-length = 60 6 | select = B, B9, C, D, E, F, N, W 7 | ignore-names = setUp, tearDown, setUpClass, tearDownClass, setUpTestData 8 | -------------------------------------------------------------------------------- /.gitconfig: -------------------------------------------------------------------------------- 1 | [alias] 2 | prev = checkout HEAD^ 3 | next = !git checkout `git rev-list HEAD..$(git branch --contains | tail -1) | tail -1` 4 | ci = commit 5 | co = checkout 6 | st = status 7 | ll = log --oneline 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .docker-env 2 | 3 | # https://github.com/github/gitignore/blob/master/Python.gitignore 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # pyenv 79 | .python-version 80 | 81 | # celery beat schedule file 82 | celerybeat-schedule 83 | 84 | # SageMath parsed files 85 | *.sage.py 86 | 87 | # Environments 88 | .env 89 | .venv 90 | env/ 91 | venv/ 92 | ENV/ 93 | env.bak/ 94 | venv.bak/ 95 | 96 | # Spyder project settings 97 | .spyderproject 98 | .spyproject 99 | 100 | # Rope project settings 101 | .ropeproject 102 | 103 | # mkdocs documentation 104 | /site 105 | 106 | # mypy 107 | .mypy_cache/ 108 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | balanced_wrapping=true 3 | combine_as_imports=true 4 | force_add=true 5 | force_grid_wrap=0 6 | include_trailing_comma=true 7 | indent=' ' 8 | line_length=60 9 | multi_line_output=3 10 | not_skip=__init__.py 11 | known_third_party = django, django_extensions, django_registration, environ, factory, faker, pytz, rest_framework, test_plus 12 | known_first_party = blog, config, contact, organizer, suorganizer, user 13 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.0.0 4 | hooks: 5 | - id: check-added-large-files 6 | args: [--maxkb=500] 7 | - id: check-byte-order-marker 8 | - id: check-case-conflict 9 | - id: check-json 10 | - id: check-merge-conflict 11 | - id: check-symlinks 12 | - id: debug-statements 13 | - id: detect-private-key 14 | - id: end-of-file-fixer 15 | - id: mixed-line-ending 16 | args: [--fix=lf] 17 | - id: requirements-txt-fixer 18 | - id: trailing-whitespace 19 | - repo: https://github.com/pre-commit/mirrors-isort 20 | rev: v4.3.4 21 | hooks: 22 | - id: isort 23 | - repo: https://github.com/ambv/black 24 | rev: 18.9b0 25 | hooks: 26 | - id: black 27 | language_version: python3.6 28 | - repo: local 29 | hooks: 30 | - id: flake8 31 | entry: flake8 32 | language: system 33 | name: flake8 34 | types: [python] 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2019, Andrew Pinkham 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | release: pip freeze && python src/manage.py migrate 2 | web: gunicorn config.wsgi --chdir src --log-file - 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Read Me 2 | 3 | This repository contains the code for the third class in [Andrew 4 | Pinkham]'s [Python Web Development] series, titled *Advanced Web 5 | Development in Python with Django*. The series is published by Pearson 6 | and may be bought on [InformIT] or viewed on [Safari Books Online]. The 7 | series is for intermediate programmers new to web development or Django. 8 | 9 | [Andrew Pinkham]: https://andrewsforge.com 10 | [Python Web Development]: https://pywebdev.com 11 | [InformIT]: https://pywebdev.com/buy-22-3/ 12 | [Safari Books Online]: https://pywebdev.com/safari-22-3/ 13 | 14 | Andrew may be reached at [JamBon Software] for consulting and training. 15 | 16 | [JamBon Software]: https://www.jambonsw.com 17 | 18 | ## Table of Contents 19 | 20 | - [Changes Made Post-Recording](#changes-made-post-recording) 21 | - [Technical Requirements](#technical-requirements) 22 | - [Getting Started Instructions](#getting-started-instructions) 23 | - [Docker Setup](#docker-setup) 24 | - [Local Setup](#local-setup) 25 | - [Walking the Repository](#walking-the-Repository) 26 | - [Extra Problems](#extra-problems) 27 | - [Testing the Code](#testing-the-code) 28 | - [Deploying the Code](#deploying-the-code) 29 | 30 | ## Changes Made Post-Recording 31 | 32 | 1. The asynchronous code has been upgraded to work with Starlette 0.13 33 | and now works with ASGI 3.0 34 | 35 | NB: The extra code for resizing images using Celery (mentioned in Lesson 6) 36 | will be added in March 2020. 37 | 38 | [🔝 Up to Table of Contents](#table-of-contents) 39 | 40 | ## Technical Requirements 41 | 42 | - [Python] 3.6+ (with SQLite3 support) 43 | - [pip] 19+ 44 | - a virtual environment (e.g.: [`venv`], [`virtualenvwrapper`]) 45 | - Optional: 46 | - [Docker] 17.12+ with [Docker-Compose] (or—if unavailable—[PostgreSQL] 10) 47 | 48 | 49 | [Python]: https://www.python.org/downloads/ 50 | [pip]: https://pip.pypa.io/en/stable/installing/ 51 | [`venv`]:https://docs.python.org/3/library/venv.html 52 | [`virtualenvwrapper`]: https://virtualenvwrapper.readthedocs.io/en/latest/install.html 53 | [Docker]: https://www.docker.com/get-started 54 | [Docker-Compose]: https://docs.docker.com/compose/ 55 | [PostgreSQL]: https://www.postgresql.org/ 56 | 57 | All other technical requirements are installed by `pip` using the 58 | requirement files included in the repository. This includes [Django 2.2]. 59 | 60 | [Django 2.2]: https://docs.djangoproject.com/en/2.2/ 61 | 62 | [🔝 Up to Table of Contents](#table-of-contents) 63 | 64 | ## Getting Started Instructions 65 | 66 | For a full guide to using this code please refer to Lesson 2 of the 67 | second class. The lesson demonstrates how to get started locally as well 68 | as how to use the Docker setup. 69 | 70 | If you are **unable to run Docker** on your machine skip to the [Local 71 | Setup](#local-setup) section. 72 | 73 | ### Docker Setup 74 | 75 | The use of Docker images allows us to avoid installing all of our 76 | dependencies—including PostgeSQL—locally. Furthermore, as discussed 77 | in second class, it helps with parity between our development and 78 | production environments. 79 | 80 | Our Docker containers expect the existence of an environment file. To 81 | generate it on *nix systems please invoke the `build_docker_env.sh` 82 | script. 83 | 84 | ```shell 85 | ./build_docker_env.sh 86 | ``` 87 | 88 | On Windows please invoke the batch file. 89 | 90 | ``` 91 | build_docker_env 92 | ``` 93 | 94 | If you run into problems please refer to the videos for why we use this 95 | and what is needed in the event these scripts do not work. 96 | 97 | To run the Docker containers use the command below. 98 | 99 | ```shell 100 | docker-compose up 101 | ``` 102 | 103 | If you wish to run the servers in the background use the `-d` 104 | (**d**etached) flag, as demonstrated below. 105 | 106 | ```shell 107 | docker-compose up -d 108 | ``` 109 | 110 | To turn off the server use Control-C in the terminal window. If running 111 | in the background use the command below. 112 | 113 | ```shell 114 | docker-compose down 115 | ``` 116 | 117 | To remove all of the assets created by Docker to run the server use the 118 | command below. 119 | 120 | ```shell 121 | docker-compose down --volumes --rmi local 122 | ``` 123 | 124 | The `--volumes` flag may be shortened to `-v`. 125 | 126 | [🔝 Up to Table of Contents](#table-of-contents) 127 | 128 | ### Local Setup 129 | 130 | Use `pip` to install your development dependencies. 131 | 132 | ```console 133 | $ python3 -m pip install -r requirements/development.txt 134 | ``` 135 | 136 | If you have checked out to an earlier part of the code note that you 137 | will need to use `requirements.txt` instead of 138 | `requirements/development.txt`. 139 | 140 | You will need to define the`SECRET_KEY` environment variable. If you 141 | would like to use PostgreSQL locally you will need to set 142 | `DATABASE_URL`. 143 | 144 | ```shell 145 | export SECRET_KEY=`head -c 75 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 50` 146 | # replace the variables in <> below 147 | export DATABASE_URL='postgres://:@:5432/' 148 | ``` 149 | 150 | [🔝 Up to Table of Contents](#table-of-contents) 151 | 152 | ## Walking the Repository 153 | 154 | To make perusing the code in this repository as simple as possible the 155 | project defines its own `.gitconfig` file with custom commands 156 | (aliases). 157 | 158 | To enable the commands you must first point your local git 159 | configuration at the file provided. Either of the two commands below 160 | should work. 161 | 162 | ```shell 163 | # relative path 164 | git config --local include.path "../.gitconfig" 165 | # absolute path - *nix only! 166 | git config --local include.path "`builtin pwd`/.gitconfig" 167 | ``` 168 | 169 | This will enable the following git commands: 170 | 171 | - `git next`: Move to the next example/commit 172 | - `git prev`: Move to the previous example/commit 173 | - `git ci`: shortcut for `git commit` 174 | - `git co`: shortcut for `git checkout` 175 | - `git st`: shortcut for `git status` 176 | - `git ll`: shortcut for `git log --oneline` 177 | 178 | These commands can be used on any of the branches in this 179 | repository. 180 | 181 | [🔝 Up to Table of Contents](#table-of-contents) 182 | -------------------------------------------------------------------------------- /build_docker_env.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | set PG_DB=webdev22videos3 4 | set PG_PASSWORD=%RANDOM%%RANDOM%%RANDOM% 5 | set PG_SERVICE_NAME=postgres 6 | set PG_USER=webdev22videos3_user 7 | set SKEY=%RANDOM%%RANDOM%%RANDOM%%RANDOM%%RANDOM%%RANDOM% 8 | 9 | Echo POSTGRES_DB=%PG_DB% > .docker-env 10 | Echo POSTGRES_PASSWORD=%PG_PASSWORD% >> .docker-env 11 | Echo POSTGRES_USER=%PG_USER% >> .docker-env 12 | Echo DATABASE_URL=postgres://%PG_USER%:%PG_PASSWORD%@%PG_SERVICE_NAME%:5432/%PG_DB% >> .docker-env 13 | Echo MEMCACHE_URL=pymemcache://memcached:11211 >> .docker-env 14 | Echo SECRET_KEY=%SKEY% >> .docker-env 15 | -------------------------------------------------------------------------------- /build_docker_env.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | PG_DB=webdev22videos3 4 | PG_PASSWORD=`head -c 18 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 12` 5 | PG_SERVICE_NAME=postgres 6 | PG_USER=webdev22videos3_user 7 | 8 | echo "POSTGRES_DB=$PG_DB 9 | POSTGRES_PASSWORD=$PG_PASSWORD 10 | POSTGRES_USER=$PG_USER 11 | DATABASE_URL=postgres://$PG_USER:$PG_PASSWORD@$PG_SERVICE_NAME:5432/$PG_DB 12 | MEMCACHE_URL=pymemcache://memcached:11211 13 | CELERY_BROKER_URL=redis://redis:6379/0 14 | CELERY_FLOWER_USER=pywebdev 15 | CELERY_FLOWER_PASSWORD=pywebdev 16 | SECRET_KEY=`head -c 75 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 50`" > .docker-env 17 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | volumes: 4 | postgres_data_dev: {} 5 | postgres_backup_dev: {} 6 | redis_data: {} 7 | 8 | services: 9 | memcached: 10 | image: memcached:1.5.16 11 | 12 | redis: 13 | image: redis:5.0.5-buster 14 | volumes: 15 | - redis_data:/data 16 | command: redis-server --appendonly yes 17 | 18 | postgres: 19 | image: postgres:10.9-alpine 20 | env_file: ./.docker-env 21 | volumes: 22 | - postgres_data_dev:/var/lib/postgresql/data 23 | - postgres_backup_dev:/backups 24 | 25 | django: 26 | &django 27 | init: true 28 | build: 29 | context: . 30 | dockerfile: ./docker/django/Dockerfile 31 | depends_on: 32 | - memcached 33 | - postgres 34 | env_file: ./.docker-env 35 | ports: 36 | - "8000:8000" 37 | volumes: 38 | - ./src:/app 39 | 40 | jupyter: 41 | <<: *django 42 | depends_on: 43 | - postgres 44 | ports: 45 | - "8888:8888" 46 | command: jupyter_entrypoint.sh 47 | 48 | 49 | celeryworker: 50 | <<: *django 51 | depends_on: 52 | - redis 53 | - postgres 54 | ports: [] 55 | command: celery -A config worker -l INFO 56 | 57 | celerybeat: 58 | <<: *django 59 | depends_on: 60 | - redis 61 | - postgres 62 | ports: [] 63 | command: /start-celerybeat 64 | 65 | flower: 66 | <<: *django 67 | ports: 68 | - "5555:5555" 69 | command: /start-flower 70 | -------------------------------------------------------------------------------- /docker/django/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM revolutionsystems/python:3.7.4-wee-optimized-lto 2 | 3 | WORKDIR /app 4 | 5 | ENV PYTHONUNBUFFERED 1 6 | RUN python3.7 -m pip install -U pip setuptools 7 | 8 | COPY requirements /tmp/requirements 9 | RUN python3.7 -m pip install -U --no-cache-dir \ 10 | -r /tmp/requirements/development.txt \ 11 | pylibmc==1.6.1 12 | 13 | COPY docker/django/celery/beat_start.sh /start-celerybeat 14 | RUN chmod +x /start-celerybeat 15 | 16 | COPY docker/django/celery/flower_start.sh /start-flower 17 | RUN chmod +x /start-flower 18 | 19 | COPY docker/django/jupyter_entrypoint.sh /usr/local/bin/jupyter_entrypoint.sh 20 | RUN chmod +x /usr/local/bin/jupyter_entrypoint.sh 21 | 22 | COPY docker/django/django_entrypoint.sh /usr/local/bin/django_entrypoint.sh 23 | RUN chmod +x /usr/local/bin/django_entrypoint.sh 24 | 25 | ENTRYPOINT ["django_entrypoint.sh"] 26 | -------------------------------------------------------------------------------- /docker/django/celery/beat_start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -o errexit 4 | set -o nounset 5 | 6 | 7 | rm -f './celerybeat.pid' 8 | celery -A config beat -l INFO 9 | -------------------------------------------------------------------------------- /docker/django/celery/flower_start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -o errexit 4 | set -o nounset 5 | 6 | celery flower \ 7 | --app=config \ 8 | --broker="${CELERY_BROKER_URL}" \ 9 | --basic_auth="${CELERY_FLOWER_USER}:${CELERY_FLOWER_PASSWORD}" 10 | -------------------------------------------------------------------------------- /docker/django/django_entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | if [ "$#" = 0 ] 4 | then 5 | python3.7 -m pip freeze 6 | fi 7 | 8 | postgres_ready() { 9 | python3.7 << END 10 | from sys import exit 11 | from psycopg2 import connect, OperationalError 12 | try: 13 | connect( 14 | dbname="$POSTGRES_DB", 15 | user="$POSTGRES_USER", 16 | password="$POSTGRES_PASSWORD", 17 | host="postgres", 18 | ) 19 | except OperationalError as error: 20 | print(error) 21 | exit(-1) 22 | exit(0) 23 | END 24 | } 25 | 26 | until postgres_ready; do 27 | >&2 echo "Postgres is unavailable - sleeping" 28 | sleep 3 29 | done; 30 | 31 | >&2 echo "Postgres is available" 32 | 33 | if [ "$#" = 0 ] 34 | then 35 | >&2 echo "No command detected; running default commands" 36 | >&2 echo "Running migrations" 37 | python3.7 manage.py migrate --noinput 38 | >&2 echo "\n\nStarting development server: 127.0.0.1:8000\n\n" 39 | python3.7 manage.py runserver 0.0.0.0:8000 40 | else 41 | >&2 echo "Command detected; running command" 42 | exec "$@" 43 | fi 44 | -------------------------------------------------------------------------------- /docker/django/jupyter_entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | postgres_ready() { 4 | python3.7 << END 5 | from sys import exit 6 | from psycopg2 import connect, OperationalError 7 | try: 8 | connect( 9 | dbname="$POSTGRES_DB", 10 | user="$POSTGRES_USER", 11 | password="$POSTGRES_PASSWORD", 12 | host="postgres", 13 | ) 14 | except OperationalError as error: 15 | print(error) 16 | exit(-1) 17 | exit(0) 18 | END 19 | } 20 | 21 | until postgres_ready; do 22 | >&2 echo "Postgres is unavailable - sleeping" 23 | sleep 3 24 | done; 25 | 26 | >&2 echo "Postgres is available" 27 | 28 | python3.7 manage.py shell_plus --notebook 29 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 60 3 | py36 = true 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -r requirements/production.txt 2 | -------------------------------------------------------------------------------- /requirements/base.txt: -------------------------------------------------------------------------------- 1 | amqp==2.5.1 2 | argon2-cffi==19.1.0 3 | babel==2.7.0 4 | billiard==3.6.1.0 5 | boto3==1.9.215 6 | botocore==1.12.215 7 | celery==4.3.0 8 | certifi==2019.6.16 9 | cffi==1.12.3 10 | chardet==3.0.4 11 | confusable-homoglyphs==3.2.0 12 | django-cors-middleware==1.4.0 13 | django-environ==0.4.5 14 | django-extensions==2.2.1 15 | django-improved-user==1.0.0 16 | django-oauth-toolkit==1.2.0 17 | django-registration==3.0.1 18 | django-storages==1.7.1 19 | django-url-checks==0.2.0 20 | Django>=2.2,<2.3 21 | djangorestframework==3.10.2 22 | docutils==0.15.2 23 | flower==0.9.3 24 | idna==2.8 25 | ipython==7.7.0 26 | jmespath==0.9.4 27 | kombu==4.6.4 28 | oauthlib==3.1.0 29 | pycparser==2.19 30 | pytz==2019.2 31 | redis==3.3.8 32 | requests==2.22.0 33 | s3transfer==0.2.1 34 | tornado==5.1.1 35 | urllib3==1.25.3 36 | vine==1.3.0 37 | whitenoise==4.1.3 38 | -------------------------------------------------------------------------------- /requirements/development.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | 3 | django-debug-toolbar==2.0 4 | django-test-plus==1.3.1 5 | factory_boy==2.12.0 6 | Faker==2.0.0 7 | flake8==3.7.8 8 | flake8-bugbear==19.3.0 9 | flake8-docstrings==1.3.0 10 | jupyter==1.0.0 11 | pep8-naming==0.8.2 12 | pre-commit==1.17.0 13 | psycopg2-binary==2.8.3 14 | -------------------------------------------------------------------------------- /requirements/production.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | 3 | Brotli==1.0.7 4 | gunicorn==19.9.0 5 | psycopg2>=2.8,<2.9 --no-binary psycopg2 6 | pylibmc==1.6.1 7 | -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.7.4 2 | -------------------------------------------------------------------------------- /src/01.07 Property-test serializers with Hypothesis.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Hypothesis Demonstration" 8 | ] 9 | }, 10 | { 11 | "cell_type": "code", 12 | "execution_count": 1, 13 | "metadata": {}, 14 | "outputs": [], 15 | "source": [ 16 | "import string\n", 17 | "from hypothesis.strategies import text, booleans" 18 | ] 19 | }, 20 | { 21 | "cell_type": "code", 22 | "execution_count": 2, 23 | "metadata": {}, 24 | "outputs": [], 25 | "source": [ 26 | "text_strategy = text()" 27 | ] 28 | }, 29 | { 30 | "cell_type": "code", 31 | "execution_count": 3, 32 | "metadata": {}, 33 | "outputs": [ 34 | { 35 | "data": { 36 | "text/plain": [ 37 | "'\\r$\\x19'" 38 | ] 39 | }, 40 | "execution_count": 3, 41 | "metadata": {}, 42 | "output_type": "execute_result" 43 | } 44 | ], 45 | "source": [ 46 | "text_strategy.example()" 47 | ] 48 | }, 49 | { 50 | "cell_type": "code", 51 | "execution_count": 4, 52 | "metadata": {}, 53 | "outputs": [ 54 | { 55 | "data": { 56 | "text/plain": [ 57 | "'\\U000b1abd\\x14*\\U000d45a5\\x0c'" 58 | ] 59 | }, 60 | "execution_count": 4, 61 | "metadata": {}, 62 | "output_type": "execute_result" 63 | } 64 | ], 65 | "source": [ 66 | "text_strategy.example()" 67 | ] 68 | }, 69 | { 70 | "cell_type": "code", 71 | "execution_count": 5, 72 | "metadata": {}, 73 | "outputs": [ 74 | { 75 | "data": { 76 | "text/plain": [ 77 | "'𥀧\\U00105eb8+\\x18\\U000f7711'" 78 | ] 79 | }, 80 | "execution_count": 5, 81 | "metadata": {}, 82 | "output_type": "execute_result" 83 | } 84 | ], 85 | "source": [ 86 | "text_strategy.example()" 87 | ] 88 | }, 89 | { 90 | "cell_type": "code", 91 | "execution_count": 6, 92 | "metadata": {}, 93 | "outputs": [], 94 | "source": [ 95 | "text_strategy = text(\n", 96 | " alphabet=(string.ascii_letters+string.digits),\n", 97 | " min_size=1,\n", 98 | " max_size=8,\n", 99 | ")" 100 | ] 101 | }, 102 | { 103 | "cell_type": "code", 104 | "execution_count": 7, 105 | "metadata": {}, 106 | "outputs": [ 107 | { 108 | "data": { 109 | "text/plain": [ 110 | "'7'" 111 | ] 112 | }, 113 | "execution_count": 7, 114 | "metadata": {}, 115 | "output_type": "execute_result" 116 | } 117 | ], 118 | "source": [ 119 | "text_strategy.example()" 120 | ] 121 | }, 122 | { 123 | "cell_type": "code", 124 | "execution_count": 8, 125 | "metadata": {}, 126 | "outputs": [ 127 | { 128 | "data": { 129 | "text/plain": [ 130 | "'0'" 131 | ] 132 | }, 133 | "execution_count": 8, 134 | "metadata": {}, 135 | "output_type": "execute_result" 136 | } 137 | ], 138 | "source": [ 139 | "text_strategy.example()" 140 | ] 141 | }, 142 | { 143 | "cell_type": "code", 144 | "execution_count": 9, 145 | "metadata": {}, 146 | "outputs": [], 147 | "source": [ 148 | "text_bool_strategy = text() | booleans()" 149 | ] 150 | }, 151 | { 152 | "cell_type": "code", 153 | "execution_count": 10, 154 | "metadata": {}, 155 | "outputs": [ 156 | { 157 | "data": { 158 | "text/plain": [ 159 | "'\\U000b5f50\\U0003108b\\U00038daa\\U00031fed鄯+'" 160 | ] 161 | }, 162 | "execution_count": 10, 163 | "metadata": {}, 164 | "output_type": "execute_result" 165 | } 166 | ], 167 | "source": [ 168 | "text_bool_strategy.example()" 169 | ] 170 | }, 171 | { 172 | "cell_type": "code", 173 | "execution_count": 12, 174 | "metadata": { 175 | "scrolled": true 176 | }, 177 | "outputs": [ 178 | { 179 | "data": { 180 | "text/plain": [ 181 | "True" 182 | ] 183 | }, 184 | "execution_count": 12, 185 | "metadata": {}, 186 | "output_type": "execute_result" 187 | } 188 | ], 189 | "source": [ 190 | "text_bool_strategy.example()" 191 | ] 192 | }, 193 | { 194 | "cell_type": "markdown", 195 | "metadata": {}, 196 | "source": [ 197 | "# Hypothesis for Django" 198 | ] 199 | }, 200 | { 201 | "cell_type": "code", 202 | "execution_count": 13, 203 | "metadata": {}, 204 | "outputs": [], 205 | "source": [ 206 | "from django.db.models import SlugField\n", 207 | "from django_extensions.db.fields import AutoSlugField\n", 208 | "\n", 209 | "from hypothesis.extra.django import (\n", 210 | " from_field,\n", 211 | " from_model,\n", 212 | " register_field_strategy,\n", 213 | ")\n", 214 | "from organizer.models import Tag" 215 | ] 216 | }, 217 | { 218 | "cell_type": "code", 219 | "execution_count": 14, 220 | "metadata": {}, 221 | "outputs": [], 222 | "source": [ 223 | "# Hypothesis doesn't know anything about AutoSlugField\n", 224 | "# we tell it to use the same strategy as Django's SlugField\n", 225 | "register_field_strategy(\n", 226 | " AutoSlugField, from_field(SlugField())\n", 227 | ")" 228 | ] 229 | }, 230 | { 231 | "cell_type": "code", 232 | "execution_count": 15, 233 | "metadata": {}, 234 | "outputs": [], 235 | "source": [ 236 | "tag_strategy = from_model(Tag)" 237 | ] 238 | }, 239 | { 240 | "cell_type": "code", 241 | "execution_count": 16, 242 | "metadata": {}, 243 | "outputs": [], 244 | "source": [ 245 | "tag = tag_strategy.example()" 246 | ] 247 | }, 248 | { 249 | "cell_type": "code", 250 | "execution_count": 17, 251 | "metadata": {}, 252 | "outputs": [ 253 | { 254 | "data": { 255 | "text/plain": [ 256 | "organizer.models.Tag" 257 | ] 258 | }, 259 | "execution_count": 17, 260 | "metadata": {}, 261 | "output_type": "execute_result" 262 | } 263 | ], 264 | "source": [ 265 | "type(tag)" 266 | ] 267 | }, 268 | { 269 | "cell_type": "code", 270 | "execution_count": 18, 271 | "metadata": {}, 272 | "outputs": [ 273 | { 274 | "data": { 275 | "text/plain": [ 276 | "'𝠗'" 277 | ] 278 | }, 279 | "execution_count": 18, 280 | "metadata": {}, 281 | "output_type": "execute_result" 282 | } 283 | ], 284 | "source": [ 285 | "tag.name" 286 | ] 287 | }, 288 | { 289 | "cell_type": "code", 290 | "execution_count": 19, 291 | "metadata": {}, 292 | "outputs": [ 293 | { 294 | "data": { 295 | "text/plain": [ 296 | "'-33'" 297 | ] 298 | }, 299 | "execution_count": 19, 300 | "metadata": {}, 301 | "output_type": "execute_result" 302 | } 303 | ], 304 | "source": [ 305 | "tag.slug" 306 | ] 307 | } 308 | ], 309 | "metadata": { 310 | "kernelspec": { 311 | "display_name": "Django Shell-Plus", 312 | "language": "python", 313 | "name": "django_extensions" 314 | }, 315 | "language_info": { 316 | "codemirror_mode": { 317 | "name": "ipython", 318 | "version": 3 319 | }, 320 | "file_extension": ".py", 321 | "mimetype": "text/x-python", 322 | "name": "python", 323 | "nbconvert_exporter": "python", 324 | "pygments_lexer": "ipython3", 325 | "version": "3.7.4" 326 | } 327 | }, 328 | "nbformat": 4, 329 | "nbformat_minor": 2 330 | } 331 | -------------------------------------------------------------------------------- /src/3.04_Generate_OAuth_2_Application_Data.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "from django.contrib.auth import get_user_model\n", 10 | "from django.urls import reverse\n", 11 | "from oauth2_provider.models import get_application_model\n", 12 | "\n", 13 | "OAuth_App = get_application_model()\n", 14 | "User = get_user_model()" 15 | ] 16 | }, 17 | { 18 | "cell_type": "code", 19 | "execution_count": 2, 20 | "metadata": {}, 21 | "outputs": [], 22 | "source": [ 23 | "# User.objects.filter(email='resource_owner@jambonsw.com').delete()" 24 | ] 25 | }, 26 | { 27 | "cell_type": "code", 28 | "execution_count": 3, 29 | "metadata": {}, 30 | "outputs": [], 31 | "source": [ 32 | "basic_user = User.objects.create_user(\n", 33 | " email='resource_owner@jambonsw.com',\n", 34 | " password='securepassword!',\n", 35 | " full_name='Resource Owner',\n", 36 | " short_name='owner',\n", 37 | ")" 38 | ] 39 | }, 40 | { 41 | "cell_type": "code", 42 | "execution_count": 4, 43 | "metadata": {}, 44 | "outputs": [ 45 | { 46 | "data": { 47 | "text/plain": [ 48 | "('password', 'authorization-code')" 49 | ] 50 | }, 51 | "execution_count": 4, 52 | "metadata": {}, 53 | "output_type": "execute_result" 54 | } 55 | ], 56 | "source": [ 57 | "OAuth_App.GRANT_PASSWORD, OAuth_App.GRANT_AUTHORIZATION_CODE" 58 | ] 59 | }, 60 | { 61 | "cell_type": "code", 62 | "execution_count": 5, 63 | "metadata": {}, 64 | "outputs": [], 65 | "source": [ 66 | "pw_app = OAuth_App.objects.create(\n", 67 | " user=basic_user,\n", 68 | " name=\"Password Grant Example App\",\n", 69 | " client_type=OAuth_App.CLIENT_PUBLIC,\n", 70 | " authorization_grant_type=OAuth_App.GRANT_PASSWORD,\n", 71 | " redirect_uris=\"https://example.com/\",\n", 72 | ")\n", 73 | "code_app = OAuth_App.objects.create(\n", 74 | " user=basic_user,\n", 75 | " name=\"Auth Code Example App\",\n", 76 | " client_type=OAuth_App.CLIENT_PUBLIC,\n", 77 | " authorization_grant_type=OAuth_App.GRANT_AUTHORIZATION_CODE,\n", 78 | " redirect_uris=\"https://example.com/\",\n", 79 | ")" 80 | ] 81 | }, 82 | { 83 | "cell_type": "code", 84 | "execution_count": 6, 85 | "metadata": {}, 86 | "outputs": [ 87 | { 88 | "data": { 89 | "text/plain": [ 90 | "'95wzK3695Ew8Z8cFJsyszMkNMZuJFTLXyCfYYrnH'" 91 | ] 92 | }, 93 | "execution_count": 6, 94 | "metadata": {}, 95 | "output_type": "execute_result" 96 | } 97 | ], 98 | "source": [ 99 | "pw_app.client_id" 100 | ] 101 | }, 102 | { 103 | "cell_type": "code", 104 | "execution_count": 7, 105 | "metadata": {}, 106 | "outputs": [ 107 | { 108 | "data": { 109 | "text/plain": [ 110 | "'IYtQqM5eki515AfeRtqof1JPb8yg5ZQMEz9gdnHafFgMYhbR8flaC61DRJON5jqKVVNr0CTa9n9cJhcRG7CgnNPkXPylCRnjh7XH4ffbt6hrzqL9zJ9xGsw2aBA9m6jV'" 111 | ] 112 | }, 113 | "execution_count": 7, 114 | "metadata": {}, 115 | "output_type": "execute_result" 116 | } 117 | ], 118 | "source": [ 119 | "pw_app.client_secret" 120 | ] 121 | }, 122 | { 123 | "cell_type": "code", 124 | "execution_count": 8, 125 | "metadata": {}, 126 | "outputs": [ 127 | { 128 | "data": { 129 | "text/plain": [ 130 | "'J6ZGvbUjuEr79OCEvbgaKq7FHM5FRNFPywsE4TXF'" 131 | ] 132 | }, 133 | "execution_count": 8, 134 | "metadata": {}, 135 | "output_type": "execute_result" 136 | } 137 | ], 138 | "source": [ 139 | "code_app.client_id" 140 | ] 141 | }, 142 | { 143 | "cell_type": "code", 144 | "execution_count": 9, 145 | "metadata": {}, 146 | "outputs": [ 147 | { 148 | "data": { 149 | "text/plain": [ 150 | "'G3zHvM33Moe9JIaeVrlh8zKjzZWP2lZGDtPzzkfL9F0nlko0wdkdS3SASfhqEhrWFPaaj0MoQaKQujS4u0t85v6x1aMGD88lkeunhxV5rgcVD7oMu6qJ6IsT15beiHoz'" 151 | ] 152 | }, 153 | "execution_count": 9, 154 | "metadata": {}, 155 | "output_type": "execute_result" 156 | } 157 | ], 158 | "source": [ 159 | "code_app.client_secret" 160 | ] 161 | }, 162 | { 163 | "cell_type": "code", 164 | "execution_count": 10, 165 | "metadata": { 166 | "scrolled": true 167 | }, 168 | "outputs": [ 169 | { 170 | "data": { 171 | "text/plain": [ 172 | "'/o/token/'" 173 | ] 174 | }, 175 | "execution_count": 10, 176 | "metadata": {}, 177 | "output_type": "execute_result" 178 | } 179 | ], 180 | "source": [ 181 | "reverse(\"oauth2_provider:token\")" 182 | ] 183 | }, 184 | { 185 | "cell_type": "code", 186 | "execution_count": 11, 187 | "metadata": {}, 188 | "outputs": [ 189 | { 190 | "data": { 191 | "text/plain": [ 192 | "'/o/authorize/'" 193 | ] 194 | }, 195 | "execution_count": 11, 196 | "metadata": {}, 197 | "output_type": "execute_result" 198 | } 199 | ], 200 | "source": [ 201 | "reverse(\"oauth2_provider:authorize\")" 202 | ] 203 | }, 204 | { 205 | "cell_type": "code", 206 | "execution_count": 12, 207 | "metadata": {}, 208 | "outputs": [], 209 | "source": [ 210 | "# OAuth_App.objects.all().delete()" 211 | ] 212 | } 213 | ], 214 | "metadata": { 215 | "kernelspec": { 216 | "display_name": "Django Shell-Plus", 217 | "language": "python", 218 | "name": "django_extensions" 219 | }, 220 | "language_info": { 221 | "codemirror_mode": { 222 | "name": "ipython", 223 | "version": 3 224 | }, 225 | "file_extension": ".py", 226 | "mimetype": "text/x-python", 227 | "name": "python", 228 | "nbconvert_exporter": "python", 229 | "pygments_lexer": "ipython3", 230 | "version": "3.7.4" 231 | } 232 | }, 233 | "nbformat": 4, 234 | "nbformat_minor": 2 235 | } 236 | -------------------------------------------------------------------------------- /src/blog/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jambonrose/python-web-dev-22-3/6621f4450661b7691771fc04f80bc324c0234f6c/src/blog/__init__.py -------------------------------------------------------------------------------- /src/blog/admin.py: -------------------------------------------------------------------------------- 1 | """Configuration of Blog Admin panel""" 2 | from django.contrib import admin 3 | 4 | from .models import Post 5 | 6 | admin.site.register(Post) 7 | -------------------------------------------------------------------------------- /src/blog/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class BlogConfig(AppConfig): 5 | name = "blog" 6 | -------------------------------------------------------------------------------- /src/blog/feeds.py: -------------------------------------------------------------------------------- 1 | """Generate feeds to blog posts""" 2 | from django.contrib.syndication.views import Feed 3 | from django.urls import reverse_lazy 4 | from django.utils.feedgenerator import ( 5 | Atom1Feed, 6 | Rss201rev2Feed, 7 | ) 8 | 9 | from .models import Post 10 | 11 | 12 | class BasePostFeedMixin: 13 | """Base class for Atom/RSS feeds""" 14 | 15 | title = "Latest Startup Organizer Blog Posts" 16 | link = reverse_lazy("post_list") 17 | description = subtitle = ( 18 | "Stay up to date on the " "hottest startup news." 19 | ) 20 | 21 | def items(self): 22 | """List of items in the feed""" 23 | # uses Post.Meta.ordering 24 | return Post.objects.all()[:10] 25 | 26 | def item_title(self, item): 27 | """Feed item title for each item""" 28 | return item.title.title() 29 | 30 | def item_description(self, item): 31 | """Content of the feeed item""" 32 | return item.short_text() 33 | 34 | def item_link(self, item): 35 | """Link to the actual content""" 36 | return item.get_absolute_url() 37 | 38 | 39 | class AtomPostFeed(BasePostFeedMixin, Feed): 40 | """Feed for Atom syndication format""" 41 | 42 | feed_type = Atom1Feed 43 | 44 | 45 | class Rss2PostFeed(BasePostFeedMixin, Feed): 46 | """Feed for RSS (Rich Site Summary) format""" 47 | 48 | feed_type = Rss201rev2Feed 49 | -------------------------------------------------------------------------------- /src/blog/forms.py: -------------------------------------------------------------------------------- 1 | """Forms for the Blog app""" 2 | from django.forms import ModelForm 3 | 4 | from .models import Post 5 | 6 | 7 | class PostForm(ModelForm): 8 | """HTML form for Post objects""" 9 | 10 | class Meta: 11 | model = Post 12 | fields = "__all__" 13 | -------------------------------------------------------------------------------- /src/blog/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1 on 2018-08-05 00:57 2 | 3 | import datetime 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [("organizer", "0001_initial")] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="Post", 17 | fields=[ 18 | ( 19 | "id", 20 | models.AutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("title", models.CharField(max_length=63)), 28 | ( 29 | "slug", 30 | models.SlugField( 31 | help_text="A label for URL config", 32 | max_length=63, 33 | unique_for_month="pub_date", 34 | ), 35 | ), 36 | ("text", models.TextField()), 37 | ( 38 | "pub_date", 39 | models.DateField( 40 | default=datetime.date.today, 41 | verbose_name="date published", 42 | ), 43 | ), 44 | ( 45 | "startups", 46 | models.ManyToManyField( 47 | related_name="blog_posts", 48 | to="organizer.Startup", 49 | ), 50 | ), 51 | ( 52 | "tags", 53 | models.ManyToManyField( 54 | related_name="blog_posts", 55 | to="organizer.Tag", 56 | ), 57 | ), 58 | ], 59 | options={ 60 | "verbose_name": "blog post", 61 | "ordering": ["-pub_date", "title"], 62 | "get_latest_by": "pub_date", 63 | }, 64 | ) 65 | ] 66 | -------------------------------------------------------------------------------- /src/blog/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jambonrose/python-web-dev-22-3/6621f4450661b7691771fc04f80bc324c0234f6c/src/blog/migrations/__init__.py -------------------------------------------------------------------------------- /src/blog/models.py: -------------------------------------------------------------------------------- 1 | """Django data models for news 2 | 3 | Django Model Documentation: 4 | https://docs.djangoproject.com/en/2.2/topics/db/models/ 5 | https://docs.djangoproject.com/en/2.2/ref/models/options/ 6 | https://docs.djangoproject.com/en/2.2/internals/contributing/writing-code/coding-style/#model-style 7 | Django Field Reference: 8 | https://docs.djangoproject.com/en/2.2/ref/models/fields/ 9 | https://docs.djangoproject.com/en/2.2/ref/models/fields/#charfield 10 | https://docs.djangoproject.com/en/2.2/ref/models/fields/#datefield 11 | https://docs.djangoproject.com/en/2.2/ref/models/fields/#manytomanyfield 12 | https://docs.djangoproject.com/en/2.2/ref/models/fields/#slugfield 13 | https://docs.djangoproject.com/en/2.2/ref/models/fields/#textfield 14 | 15 | """ 16 | from datetime import date 17 | 18 | from django.db.models import ( 19 | CharField, 20 | DateField, 21 | ManyToManyField, 22 | Model, 23 | SlugField, 24 | TextField, 25 | ) 26 | from django.urls import reverse 27 | 28 | from organizer.models import Startup, Tag 29 | 30 | 31 | class Post(Model): 32 | """Blog post; news article about startups""" 33 | 34 | title = CharField(max_length=63) 35 | slug = SlugField( 36 | max_length=63, 37 | help_text="A label for URL config", 38 | unique_for_month="pub_date", 39 | ) 40 | text = TextField() 41 | pub_date = DateField( 42 | "date published", default=date.today 43 | ) 44 | tags = ManyToManyField(Tag, related_name="blog_posts") 45 | startups = ManyToManyField( 46 | Startup, related_name="blog_posts" 47 | ) 48 | 49 | class Meta: 50 | get_latest_by = "pub_date" 51 | ordering = ["-pub_date", "title"] 52 | verbose_name = "blog post" 53 | 54 | def __str__(self): 55 | date_string = self.pub_date.strftime("%Y-%m-%d") 56 | return f"{self.title} on {date_string}" 57 | 58 | def get_absolute_url(self): 59 | """Return URL to detail page of Post""" 60 | return reverse( 61 | "post_detail", 62 | kwargs={ 63 | "year": self.pub_date.year, 64 | "month": self.pub_date.month, 65 | "slug": self.slug, 66 | }, 67 | ) 68 | 69 | def get_update_url(self): 70 | """Return URL to update page of Post""" 71 | return reverse( 72 | "post_update", 73 | kwargs={ 74 | "year": self.pub_date.year, 75 | "month": self.pub_date.month, 76 | "slug": self.slug, 77 | }, 78 | ) 79 | 80 | def get_delete_url(self): 81 | """Return URL to delete page of Post""" 82 | return reverse( 83 | "post_delete", 84 | kwargs={ 85 | "year": self.pub_date.year, 86 | "month": self.pub_date.month, 87 | "slug": self.slug, 88 | }, 89 | ) 90 | 91 | def short_text(self): 92 | """Generate short blurb based on text""" 93 | if len(self.text) > 20: 94 | short = " ".join(self.text.split()[:20]) 95 | short += " ..." 96 | else: 97 | short = self.text 98 | return short 99 | -------------------------------------------------------------------------------- /src/blog/routers.py: -------------------------------------------------------------------------------- 1 | """URL Paths and Routers for Blog App""" 2 | from rest_framework.routers import SimpleRouter 3 | 4 | from .viewsets import PostViewSet 5 | 6 | 7 | class PostRouter(SimpleRouter): 8 | """Override the SimpleRouter for blog posts 9 | 10 | DRF's routers expect there to only be a single variable 11 | for finding objects. However, our blog posts needs 12 | three! We therefore override the Router's behavior to 13 | make it do what we want. 14 | 15 | The big question: was it worth switching to a ViewSet 16 | and Router over our previous config for this? 17 | """ 18 | 19 | def get_lookup_regex(self, *args, **kwargs): 20 | """Return regular expression pattern for URL path 21 | 22 | This is the equivalent of the simple path: 23 | // 24 | """ 25 | return ( 26 | r"(?P\d+)/" 27 | r"(?P\d+)/" 28 | r"(?P[\w\-]+)" 29 | ) 30 | 31 | 32 | post_router = PostRouter() 33 | post_router.register( 34 | "blog", PostViewSet, base_name="api-post" 35 | ) 36 | urlpatterns = post_router.urls 37 | -------------------------------------------------------------------------------- /src/blog/serializers.py: -------------------------------------------------------------------------------- 1 | """Serializers for th Blog App 2 | 3 | Serializer Documentation 4 | http://www.django-rest-framework.org/api-guide/serializers/ 5 | http://www.django-rest-framework.org/api-guide/fields/ 6 | http://www.django-rest-framework.org/api-guide/relations/ 7 | """ 8 | 9 | from rest_framework.reverse import reverse 10 | from rest_framework.serializers import ( 11 | HyperlinkedRelatedField, 12 | ModelSerializer, 13 | SerializerMethodField, 14 | ) 15 | 16 | from organizer.models import Startup, Tag 17 | 18 | from .models import Post 19 | 20 | 21 | class PostSerializer(ModelSerializer): 22 | """Serialize Post data""" 23 | 24 | url = SerializerMethodField() 25 | tags = HyperlinkedRelatedField( 26 | lookup_field="slug", 27 | many=True, 28 | queryset=Tag.objects.all(), 29 | view_name="api-tag-detail", 30 | ) 31 | startups = HyperlinkedRelatedField( 32 | lookup_field="slug", 33 | many=True, 34 | queryset=Startup.objects.all(), 35 | view_name="api-startup-detail", 36 | ) 37 | 38 | class Meta: 39 | model = Post 40 | exclude = ("id",) 41 | 42 | def get_url(self, post): 43 | """Return full API URL for serialized POST object""" 44 | return reverse( 45 | "api-post-detail", 46 | kwargs=dict( 47 | year=post.pub_date.year, 48 | month=post.pub_date.month, 49 | slug=post.slug, 50 | ), 51 | request=self.context["request"], 52 | ) 53 | -------------------------------------------------------------------------------- /src/blog/sitemaps.py: -------------------------------------------------------------------------------- 1 | """Sitemap for Blog Post pages""" 2 | from datetime import date 3 | from itertools import chain 4 | from math import log10 5 | from operator import itemgetter 6 | 7 | from django.contrib.sitemaps import Sitemap 8 | from django.urls import reverse 9 | 10 | from .models import Post 11 | 12 | 13 | class PostSitemap(Sitemap): 14 | """Sitemap for blog posts""" 15 | 16 | def items(self): 17 | """List Elements in sitemap""" 18 | return Post.objects.all() 19 | 20 | def lastmod(self, post): 21 | """When blog post was last modified""" 22 | return post.pub_date 23 | 24 | def priority(self, post): 25 | """Return numerical priority of post. 26 | 27 | 1.0 is most important 28 | 0.0 is least important 29 | 0.5 is the default 30 | """ 31 | period = 90 # days 32 | timedelta = date.today() - post.pub_date 33 | # 86400 seconds in a day 34 | # 86400 = 60 seconds * 60 minutes * 24 hours 35 | # use floor division 36 | days = timedelta.total_seconds() // 86400 37 | if days == 0: 38 | return 1.0 39 | elif 0 < days <= period: 40 | # n(d) = normalized(days) 41 | # n(1) = 0.5 42 | # n(period) = 0 43 | normalized = log10(period / days) / log10( 44 | period ** 2 45 | ) 46 | normalized = round(normalized, 2) 47 | return normalized + 0.5 48 | else: 49 | return 0.5 50 | 51 | 52 | class PostArchiveSitemap(Sitemap): 53 | """Sitemap for blog post date views""" 54 | 55 | def items(self): 56 | """Generate year/month for which we have content""" 57 | year_dates = ( 58 | Post.objects.all() 59 | .dates("pub_date", "year", order="DESC") 60 | .iterator() 61 | ) 62 | month_dates = ( 63 | Post.objects.all() 64 | .dates("pub_date", "month", order="DESC") 65 | .iterator() 66 | ) 67 | year_tuples = map(lambda d: (d, "y"), year_dates) 68 | month_tuples = map(lambda d: (d, "m"), month_dates) 69 | return sorted( 70 | chain(month_tuples, year_tuples), 71 | key=itemgetter(0), 72 | reverse=True, 73 | ) 74 | 75 | def location(self, date_tuple): 76 | """Generate URLs for the year/month archives""" 77 | archive_date, archive_type = date_tuple 78 | if archive_type == "y": 79 | return reverse( 80 | "post_archive_year", 81 | kwargs={"year": archive_date.year}, 82 | ) 83 | elif archive_type == "m": 84 | return reverse( 85 | "post_archive_month", 86 | kwargs={ 87 | "year": archive_date.year, 88 | "month": archive_date.month, 89 | }, 90 | ) 91 | else: 92 | raise NotImplementedError( 93 | "{} did not recognize " 94 | "{} denoted '{}'.".format( 95 | self.__class__.__name__, 96 | "archive_type", 97 | archive_type, 98 | ) 99 | ) 100 | -------------------------------------------------------------------------------- /src/blog/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jambonrose/python-web-dev-22-3/6621f4450661b7691771fc04f80bc324c0234f6c/src/blog/tests/__init__.py -------------------------------------------------------------------------------- /src/blog/tests/factories.py: -------------------------------------------------------------------------------- 1 | """Factory classes for blog models""" 2 | from random import randint 3 | 4 | from factory import ( 5 | DjangoModelFactory, 6 | Faker, 7 | Sequence, 8 | post_generation, 9 | ) 10 | 11 | from organizer.tests.factories import ( 12 | StartupFactory, 13 | TagFactory, 14 | ) 15 | 16 | from ..models import Post 17 | 18 | 19 | class PostFactory(DjangoModelFactory): 20 | """Factory for Blog Post data""" 21 | 22 | title = Faker( 23 | "sentence", nb_words=3, variable_nb_words=True 24 | ) 25 | slug = Sequence(lambda n: f"slug-{n}") 26 | text = Faker( 27 | "paragraph", 28 | nb_sentences=3, 29 | variable_nb_sentences=True, 30 | ) 31 | pub_date = Faker("date_this_decade", before_today=True) 32 | 33 | class Meta: 34 | model = Post 35 | 36 | @post_generation 37 | def tags( # noqa: N805 38 | post, create, extracted, **kwargs # noqa: B902 39 | ): 40 | """Add related tag objects to Post""" 41 | if create: 42 | if extracted is not None: 43 | tag_list = extracted 44 | else: # generate Tag objects randomly 45 | tag_list = map( 46 | lambda f: f(), 47 | [TagFactory] * randint(0, 5), 48 | ) 49 | for tag in tag_list: 50 | post.tags.add(tag) 51 | 52 | @post_generation 53 | def startups( # noqa: N805 54 | post, create, extracted, **kwargs # noqa: B902 55 | ): 56 | """Add related startup objects to Post""" 57 | if create: 58 | if extracted is not None: 59 | startup_list = extracted 60 | else: # generate Startup objects randomly 61 | startup_list = map( 62 | lambda f: f(), 63 | [StartupFactory] * randint(0, 2), 64 | ) 65 | for startup in startup_list: 66 | post.startups.add(startup) 67 | -------------------------------------------------------------------------------- /src/blog/tests/test_admin.py: -------------------------------------------------------------------------------- 1 | """Test the Admin functionality of the blog App""" 2 | from django.contrib.auth import get_user_model 3 | from test_plus import TestCase 4 | 5 | from config.test_utils import get_instance_data, omit_keys 6 | from organizer.tests.factories import ( 7 | StartupFactory, 8 | TagFactory, 9 | ) 10 | 11 | from ..models import Post 12 | from .factories import PostFactory 13 | 14 | 15 | def get_post_data(post): 16 | """Strip post of unchecked fields""" 17 | return omit_keys( 18 | "id", "tags", "startups", get_instance_data(post) 19 | ) 20 | 21 | 22 | class AdminTests(TestCase): 23 | """Test Suite for PostAdmin""" 24 | 25 | @classmethod 26 | def setUpTestData(cls): 27 | """Generate test data for entire suite""" 28 | User = get_user_model() 29 | cls.test_user = User.objects.create_superuser( 30 | email="admin@example.com", password="password" 31 | ) 32 | cls.p1_pk = PostFactory().pk 33 | cls.t1_pk = TagFactory().pk 34 | cls.s1_pk = StartupFactory().pk 35 | 36 | def test_post_list_get(self): 37 | """Is the admin list of Posts available?""" 38 | with self.login(self.test_user): 39 | self.get_check_200("admin:blog_post_changelist") 40 | 41 | def test_post_add_get(self): 42 | """Is the admin add form for Posts available?""" 43 | with self.login(self.test_user): 44 | self.get_check_200("admin:blog_post_add") 45 | 46 | def test_post_add_post(self): 47 | """Can new Posts be created?""" 48 | self.assertEqual(Post.objects.count(), 1) 49 | post_data = get_post_data(PostFactory.build()) 50 | # the tag and startup below are created in setUpTestData 51 | data = dict( 52 | tags=[self.t1_pk], 53 | startups=[self.s1_pk], 54 | **post_data, 55 | ) 56 | with self.login(self.test_user): 57 | self.post("admin:blog_post_add", data=data) 58 | self.assertEqual(Post.objects.count(), 2) 59 | 60 | def test_post_change_get(self): 61 | """Is the admin Post change-form available?""" 62 | # the Post is created in setUpTestData 63 | with self.login(self.test_user): 64 | self.get_check_200( 65 | "admin:blog_post_change", 66 | object_id=self.p1_pk, 67 | ) 68 | 69 | def test_post_change_post(self): 70 | """Can existing Posts be modified?""" 71 | p2 = PostFactory() 72 | post_data = get_post_data(PostFactory.build()) 73 | self.assertNotEqual(get_post_data(p2), post_data) 74 | # the tag and startup below are created in setUpTestData 75 | data = dict( 76 | tags=[self.t1_pk], 77 | startups=[self.s1_pk], 78 | **post_data, 79 | ) 80 | with self.login(self.test_user): 81 | self.post( 82 | "admin:blog_post_change", 83 | data=data, 84 | object_id=p2.pk, 85 | ) 86 | self.response_302() 87 | p2.refresh_from_db() 88 | self.assertEqual(get_post_data(p2), post_data) 89 | self.assertEqual(Post.objects.count(), 2) 90 | 91 | def test_post_delete_get(self): 92 | """Is the admin Post delete-form available?""" 93 | # the Post is created in setUpTestData 94 | with self.login(self.test_user): 95 | self.get_check_200( 96 | "admin:blog_post_delete", 97 | object_id=self.p1_pk, 98 | ) 99 | 100 | def test_post_delete_post(self): 101 | """Can Posts be deleted?""" 102 | # the Post is created in setUpTestData 103 | p2_pk = PostFactory().pk 104 | with self.login(self.test_user): 105 | self.post( 106 | "admin:blog_post_delete", 107 | object_id=p2_pk, 108 | data=dict(post="yes"), 109 | ) 110 | self.response_302() 111 | self.assertFalse( 112 | Post.objects.filter(id=p2_pk).exists() 113 | ) 114 | 115 | def test_post_history_get(self): 116 | """Is a Post's history available?""" 117 | # the Post is created in setUpTestData 118 | with self.login(self.test_user): 119 | self.get_check_200( 120 | "admin:blog_post_history", 121 | object_id=self.p1_pk, 122 | ) 123 | -------------------------------------------------------------------------------- /src/blog/tests/test_forms.py: -------------------------------------------------------------------------------- 1 | """Tests for all forms in the Organizer app""" 2 | from django.test import TestCase 3 | 4 | from config.test_utils import get_instance_data 5 | from organizer.tests.factories import ( 6 | StartupFactory, 7 | TagFactory, 8 | ) 9 | 10 | from ..forms import PostForm 11 | from ..models import Post 12 | from .factories import PostFactory 13 | 14 | 15 | class PostFormTests(TestCase): 16 | """Tests for PostForm""" 17 | 18 | def test_creation(self): 19 | """Can we save new posts based on input?""" 20 | tag = TagFactory() 21 | startup = StartupFactory() 22 | post = PostFactory.build() 23 | self.assertFalse( 24 | Post.objects.filter(slug=post.slug).exists() 25 | ) 26 | bounded_form = PostForm( 27 | data={ 28 | **get_instance_data(post), 29 | "tags": [tag.pk], 30 | "startups": [startup.pk], 31 | } 32 | ) 33 | self.assertTrue( 34 | bounded_form.is_valid(), bounded_form.errors 35 | ) 36 | bounded_form.save() 37 | self.assertTrue( 38 | Post.objects.filter(slug=post.slug).exists() 39 | ) 40 | 41 | def test_update(self): 42 | """Can we update posts based on input?""" 43 | tag = TagFactory() 44 | startup = StartupFactory() 45 | post = PostFactory(tags=[tag], startups=[startup]) 46 | self.assertNotEqual(post.title, "django") 47 | pform = PostForm( 48 | instance=post, 49 | data=dict( 50 | get_instance_data(post), 51 | title="django", 52 | tags=[tag.pk], 53 | startups=[startup.pk], 54 | ), 55 | ) 56 | self.assertTrue(pform.is_valid(), pform.errors) 57 | pform.save() 58 | post.refresh_from_db() 59 | self.assertEqual(post.title, "django") 60 | -------------------------------------------------------------------------------- /src/blog/tests/test_models.py: -------------------------------------------------------------------------------- 1 | """Test for blog app""" 2 | from datetime import date 3 | 4 | from django.core.exceptions import ValidationError 5 | from django.test import TestCase 6 | 7 | from config.test_utils import ( 8 | get_concrete_field_names, 9 | reverse, 10 | ) 11 | from organizer.models import Startup, Tag 12 | from organizer.tests.factories import ( 13 | StartupFactory, 14 | TagFactory, 15 | ) 16 | 17 | from ..models import Post 18 | from .factories import PostFactory 19 | 20 | 21 | class PostModelTests(TestCase): 22 | """Tests for the Post model""" 23 | 24 | def test_post_concrete_fields(self): 25 | """Do we find the expected fields on the Post model?""" 26 | field_names = get_concrete_field_names(Post) 27 | expected_field_names = [ 28 | "id", 29 | "title", 30 | "slug", 31 | "text", 32 | "pub_date", 33 | ] 34 | self.assertEqual(field_names, expected_field_names) 35 | 36 | def test_post_m2m_fields(self): 37 | """Are Posts Many-To-Many with Tags and Startups?""" 38 | post_m2m_fields = [ 39 | field.name 40 | for field in Post._meta.get_fields() 41 | if not field.auto_created and field.many_to_many 42 | ] 43 | self.assertEqual( 44 | post_m2m_fields, ["tags", "startups"] 45 | ) 46 | self.assertIs( 47 | Post._meta.get_field("tags").related_model, Tag 48 | ) 49 | self.assertIs( 50 | Post._meta.get_field("startups").related_model, 51 | Startup, 52 | ) 53 | 54 | def test_str(self): 55 | """Do Posts clearly represent themselves?""" 56 | p = PostFactory( 57 | title="b", pub_date=date(2017, 1, 1) 58 | ) 59 | self.assertEqual(str(p), "b on 2017-01-01") 60 | 61 | def test_absolute_url(self): 62 | """Do Posts link to their detail view?""" 63 | p = PostFactory() 64 | self.assertEqual( 65 | p.get_absolute_url(), 66 | reverse( 67 | "post_detail", 68 | year=p.pub_date.year, 69 | month=p.pub_date.month, 70 | slug=p.slug, 71 | ), 72 | ) 73 | 74 | def test_post_list_order(self): 75 | """Are posts ordered by date?""" 76 | PostFactory(title="b", pub_date=date(2017, 1, 1)) 77 | PostFactory(title="a", pub_date=date(2016, 1, 1)) 78 | PostFactory(title="a", pub_date=date(2017, 1, 1)) 79 | PostFactory(title="d", pub_date=date(2018, 1, 1)) 80 | post_name_list = [ 81 | (name, p_date.year) 82 | for name, p_date in Post.objects.values_list( 83 | "title", "pub_date" 84 | ) 85 | ] 86 | expected_name_list = [ 87 | ("d", 2018), 88 | ("a", 2017), 89 | ("b", 2017), 90 | ("a", 2016), 91 | ] 92 | self.assertEqual(post_name_list, expected_name_list) 93 | 94 | def test_post_slug_uniqueness(self): 95 | """Are Posts with identical slugs in the same month disallowed?""" 96 | kwargs = dict(slug="a", pub_date=date(2018, 1, 1)) 97 | PostFactory(**kwargs) 98 | with self.assertRaises(ValidationError): 99 | PostFactory.build(**kwargs).validate_unique() 100 | 101 | def test_get_latest(self): 102 | """Can managers get the latest Post?""" 103 | PostFactory(title="b", pub_date=date(2017, 1, 1)) 104 | PostFactory(title="a", pub_date=date(2016, 1, 1)) 105 | PostFactory(title="a", pub_date=date(2017, 1, 1)) 106 | latest = PostFactory( 107 | title="d", pub_date=date(2018, 1, 1) 108 | ) 109 | found = Post.objects.latest() 110 | self.assertEqual(latest, found) 111 | 112 | def test_delete(self): 113 | """Does deleting a post leave related objects?""" 114 | tags = TagFactory.create_batch(5) 115 | startups = StartupFactory.create_batch(3, tags=tags) 116 | post = PostFactory(tags=tags, startups=startups) 117 | 118 | self.assertIn(tags[0], post.tags.all()) 119 | self.assertIn(startups[0], post.startups.all()) 120 | self.assertEqual( 121 | Tag.objects.count(), 122 | 5, 123 | "Unexpected initial condition", 124 | ) 125 | self.assertEqual( 126 | Startup.objects.count(), 127 | 3, 128 | "Unexpected initial condition", 129 | ) 130 | 131 | post.delete() 132 | 133 | self.assertEqual( 134 | Tag.objects.count(), 5, "Unexpected change" 135 | ) 136 | self.assertEqual( 137 | Startup.objects.count(), 3, "Unexpected change" 138 | ) 139 | -------------------------------------------------------------------------------- /src/blog/urls.py: -------------------------------------------------------------------------------- 1 | """URL paths for Blog App""" 2 | from django.urls import path 3 | 4 | from .views import ( 5 | PostArchiveMonth, 6 | PostArchiveYear, 7 | PostCreate, 8 | PostDelete, 9 | PostDetail, 10 | PostList, 11 | PostUpdate, 12 | ) 13 | 14 | urlpatterns = [ 15 | path("", PostList.as_view(), name="post_list"), 16 | path( 17 | "create/", PostCreate.as_view(), name="post_create" 18 | ), 19 | path( 20 | "/", 21 | PostArchiveYear.as_view(), 22 | name="post_archive_year", 23 | ), 24 | path( 25 | "//", 26 | PostArchiveMonth.as_view(), 27 | name="post_archive_month", 28 | ), 29 | path( 30 | "///", 31 | PostDetail.as_view(), 32 | name="post_detail", 33 | ), 34 | path( 35 | "///delete/", 36 | PostDelete.as_view(), 37 | name="post_delete", 38 | ), 39 | path( 40 | "///update/", 41 | PostUpdate.as_view(), 42 | name="post_update", 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /src/blog/views.py: -------------------------------------------------------------------------------- 1 | """Views for Blog App 2 | 3 | http://ccbv.co.uk/projects/Django/2.2/django.contrib.auth.mixins/PermissionRequiredMixin/ 4 | """ 5 | from django.contrib.auth.mixins import ( 6 | PermissionRequiredMixin, 7 | ) 8 | from django.shortcuts import get_object_or_404 9 | from django.urls import reverse_lazy 10 | from django.views.generic import ( 11 | ArchiveIndexView, 12 | CreateView, 13 | DeleteView, 14 | DetailView, 15 | MonthArchiveView, 16 | UpdateView, 17 | YearArchiveView, 18 | ) 19 | 20 | from .forms import PostForm 21 | from .models import Post 22 | 23 | 24 | class PostObjectMixin: 25 | """Django View mix-in to find blog posts""" 26 | 27 | model = Post 28 | 29 | def get_object(self, queryset=None): 30 | """Get a blog post using year, month, and slug 31 | 32 | http://ccbv.co.uk/SingleObjectMixin 33 | """ 34 | if queryset is None: 35 | queryset = self.get_queryset() 36 | 37 | year, month, slug = map( 38 | self.kwargs.get, ["year", "month", "slug"] 39 | ) 40 | if any(arg is None for arg in (year, month, slug)): 41 | raise AttributeError( 42 | f"View {self.__class__.__name__} must be" 43 | f"called with year, month, and slug for" 44 | f"Post objects" 45 | ) 46 | return get_object_or_404( 47 | queryset, 48 | pub_date__year=year, 49 | pub_date__month=month, 50 | slug=slug, 51 | ) 52 | 53 | 54 | class PostArchiveMonth(MonthArchiveView): 55 | """Display blog posts for particular month 56 | 57 | http://ccbv.co.uk/projects/Django/2.2/django.views.generic.dates/MonthArchiveView/ 58 | """ 59 | 60 | date_field = "pub_date" 61 | model = Post 62 | month_format = "%m" 63 | template_name = "post/post_archive_month.html" 64 | 65 | 66 | class PostArchiveYear(YearArchiveView): 67 | """Display blog posts for particular year 68 | 69 | http://ccbv.co.uk/projects/Django/2.2/django.views.generic.dates/YearArchiveView/ 70 | """ 71 | 72 | date_field = "pub_date" 73 | make_object_list = True 74 | model = Post 75 | template_name = "post/post_archive_year.html" 76 | 77 | 78 | class PostCreate(PermissionRequiredMixin, CreateView): 79 | """Create new blog posts""" 80 | 81 | form_class = PostForm 82 | model = Post 83 | permission_required = "blog.add_post" 84 | template_name = "post/form.html" 85 | extra_context = {"update": False} 86 | 87 | 88 | class PostDetail(PostObjectMixin, DetailView): 89 | """Display a single blog Post""" 90 | 91 | template_name = "post/detail.html" 92 | 93 | 94 | class PostDelete( 95 | PermissionRequiredMixin, PostObjectMixin, DeleteView 96 | ): 97 | """Delete a single blog post""" 98 | 99 | permission_required = "blog.delete_post" 100 | template_name = "post/confirm_delete.html" 101 | success_url = reverse_lazy("post_list") 102 | 103 | 104 | class PostList(ArchiveIndexView): 105 | """Display a list of blog Posts 106 | 107 | http://ccbv.co.uk/projects/Django/2.2/django.views.generic.dates/ArchiveIndexView/ 108 | """ 109 | 110 | allow_empty = True 111 | context_object_name = "post_list" 112 | date_field = "pub_date" 113 | make_object_list = True 114 | paginate_by = 5 115 | queryset = Post.objects.prefetch_related( 116 | "startups" 117 | ).prefetch_related("tags") 118 | template_name = "post/list.html" 119 | 120 | 121 | class PostUpdate( 122 | PermissionRequiredMixin, PostObjectMixin, UpdateView 123 | ): 124 | """Update existing blog posts""" 125 | 126 | form_class = PostForm 127 | permission_required = "blog.change_post" 128 | template_name = "post/form.html" 129 | extra_context = {"update": True} 130 | -------------------------------------------------------------------------------- /src/blog/viewsets.py: -------------------------------------------------------------------------------- 1 | """Viewsets for the Blog app""" 2 | from django.shortcuts import get_object_or_404 3 | from rest_framework.filters import OrderingFilter 4 | from rest_framework.pagination import CursorPagination 5 | from rest_framework.viewsets import ModelViewSet 6 | 7 | from .models import Post 8 | from .serializers import PostSerializer 9 | 10 | 11 | class PostViewSet(ModelViewSet): 12 | """A set of views for Post model""" 13 | 14 | filter_backends = [OrderingFilter] 15 | ordering = "-pub_date" 16 | ordering_fields = ["pub_date"] 17 | pagination_class = CursorPagination 18 | queryset = Post.objects.all() 19 | required_scopes = ["post"] 20 | serializer_class = PostSerializer 21 | 22 | def get_object(self): 23 | """Override DRF's generic method 24 | 25 | http://www.cdrf.co/3.7/rest_framework.viewsets/ModelViewSet.html#get_object 26 | """ 27 | month = self.kwargs.get("month") 28 | year = self.kwargs.get("year") 29 | slug = self.kwargs.get("slug") 30 | 31 | queryset = self.filter_queryset(self.get_queryset()) 32 | 33 | post = get_object_or_404( 34 | queryset, 35 | pub_date__year=year, 36 | pub_date__month=month, 37 | slug=slug, 38 | ) 39 | self.check_object_permissions(self.request, post) 40 | return post 41 | -------------------------------------------------------------------------------- /src/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jambonrose/python-web-dev-22-3/6621f4450661b7691771fc04f80bc324c0234f6c/src/config/__init__.py -------------------------------------------------------------------------------- /src/config/celery.py: -------------------------------------------------------------------------------- 1 | """Celery App creation""" 2 | import os 3 | 4 | from celery import Celery 5 | 6 | # set the default Django settings module for the 'celery' program. 7 | os.environ.setdefault( 8 | "DJANGO_SETTINGS_MODULE", "config.settings.development" 9 | ) 10 | 11 | app = Celery("suorganizer") 12 | 13 | # Using a string here means the worker doesn't have to serialize 14 | # the configuration object to child processes. 15 | # - namespace='CELERY' means all celery-related configuration keys 16 | # should have a `CELERY_` prefix. 17 | app.config_from_object( 18 | "django.conf:settings", namespace="CELERY" 19 | ) 20 | 21 | # Load task modules from all registered Django app configs. 22 | app.autodiscover_tasks() 23 | 24 | 25 | @app.task(bind=True) 26 | def debug_task(self, text): 27 | """Demo celery task""" 28 | print(text) 29 | print("Request: {0!r}".format(self.request)) 30 | -------------------------------------------------------------------------------- /src/config/checks.py: -------------------------------------------------------------------------------- 1 | """Checks for suorganizer project 2 | 3 | https://docs.djangoproject.com/en/2.2/topics/checks/ 4 | """ 5 | from django.apps import apps 6 | from django.core.checks import Tags, Warning, register 7 | 8 | 9 | @register(Tags.models) 10 | def check_model_str(app_configs=None, **kwargs): 11 | """Ensure all models define a __str__ method""" 12 | configs = ( 13 | app_configs 14 | if app_configs 15 | else apps.get_app_configs() 16 | ) 17 | problem_models = [ 18 | model 19 | for app in configs 20 | if not app.name.startswith( 21 | ( 22 | "corsheaders", 23 | "django.contrib", 24 | "oauth2_provider", 25 | ) 26 | ) 27 | for model in app.get_models() 28 | if "__str__" not in model.__dict__ 29 | ] 30 | return [ 31 | Warning( 32 | "All Models must have a __str__ method.", 33 | hint=( 34 | "See https://docs.djangoproject.com/" 35 | "en/2.2/ref/models/instances/#str" 36 | " for more information." 37 | ), 38 | obj=model, 39 | id="suorganizer.W001", 40 | ) 41 | for model in problem_models 42 | ] 43 | -------------------------------------------------------------------------------- /src/config/settings/development.py: -------------------------------------------------------------------------------- 1 | """Development settings for Startup Organizer""" 2 | from .base import * # noqa: F403 3 | 4 | INSTALLED_APPS.append("debug_toolbar") # noqa: F405 5 | 6 | DEBUG = ENV.bool("DEBUG", default=True) # noqa: F405 7 | 8 | MIDDLEWARE.insert( # noqa: F405 9 | 3, "debug_toolbar.middleware.DebugToolbarMiddleware" 10 | ) 11 | 12 | 13 | def show_toolbar(request): 14 | """Use env variable to decide when to show debug tooolbar""" 15 | return ENV.bool( # noqa: F405 16 | "SHOW_DEBUG_TOOLBAR", default=DEBUG 17 | ) 18 | 19 | 20 | DEBUG_TOOLBAR_CONFIG = { 21 | "SHOW_TOOLBAR_CALLBACK": show_toolbar 22 | } 23 | 24 | TEMPLATES[0]["OPTIONS"].update( # noqa: F405 25 | { 26 | "debug": ENV.bool( # noqa: F405 27 | "TEMPLATE_DEBUG", default=True 28 | ) 29 | } 30 | ) 31 | 32 | # https://github.com/evansd/whitenoise/issues/191 33 | # Normally set to settings.DEBUG, but tests run with DEBUG=FALSE! 34 | WHITENOISE_AUTOREFRESH = True 35 | WHITENOISE_USE_FINDERS = True 36 | -------------------------------------------------------------------------------- /src/config/settings/production.py: -------------------------------------------------------------------------------- 1 | """Django Settings for Production instances of the site""" 2 | from .base import * # noqa: F401 F403 3 | 4 | ###################################################################### 5 | # PRODUCTION SETTINGS 6 | ###################################################################### 7 | 8 | ADMINS = MANAGERS = [ 9 | # ('Your Name', 'name@email.com'), 10 | ] 11 | 12 | STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" 13 | 14 | ##################### 15 | # SECURITY SETTINGS # 16 | ##################### 17 | 18 | CSRF_COOKIE_HTTPONLY = True 19 | CSRF_COOKIE_SECURE = True 20 | 21 | SECURE_BROWSER_XSS_FILTER = True 22 | SECURE_CONTENT_TYPE_NOSNIFF = True 23 | 24 | SECURE_SSL_REDIRECT = True 25 | SECURE_HSTS_SECONDS = 3600 26 | SECURE_HSTS_INCLUDE_SUBDOMAINS = True 27 | 28 | SESSION_COOKIE_DOMAIN = None # not set on subdomains 29 | SESSION_COOKIE_HTTPONLY = True 30 | SESSION_COOKIE_NAME = "suorganizer_sessionid" 31 | SESSION_COOKIE_SECURE = True 32 | SESSION_EXPIRE_AT_BROWSER_CLOSE = True 33 | 34 | X_FRAME_OPTIONS = "DENY" 35 | -------------------------------------------------------------------------------- /src/config/sitemaps.py: -------------------------------------------------------------------------------- 1 | """Configure sitemaps for entire site""" 2 | from django.contrib.sitemaps import Sitemap 3 | from django.urls import reverse 4 | 5 | from blog.sitemaps import PostArchiveSitemap, PostSitemap 6 | from organizer.sitemaps import StartupSitemap, TagSitemap 7 | 8 | 9 | class RootSitemap(Sitemap): 10 | """Generate sitemap for pages not generated by DB""" 11 | 12 | priority = 0.6 13 | 14 | def items(self): 15 | """URL path names to include in sitemap""" 16 | return [ 17 | "post_list", 18 | "auth:login", 19 | "startup_list", 20 | "tag_list", 21 | ] 22 | 23 | def location(self, url_name): 24 | """Reverse location of URL paths""" 25 | return reverse(url_name) 26 | 27 | 28 | sitemaps = { 29 | "post-archives": PostArchiveSitemap, 30 | "posts": PostSitemap, 31 | "roots": RootSitemap, 32 | "startups": StartupSitemap, 33 | "tags": TagSitemap, 34 | } 35 | -------------------------------------------------------------------------------- /src/config/test_utils.py: -------------------------------------------------------------------------------- 1 | """Utility code for tests 2 | 3 | I'm breaking the rules here for simplicity: this should not 4 | be in config, as this is not configuration for the project. 5 | However, introducing an app specifically for test utilities 6 | would overcomplicate the work we're doing, so it's going 7 | here instead. 8 | 9 | """ 10 | from contextlib import contextmanager 11 | from functools import reduce 12 | from operator import or_ 13 | 14 | from django.contrib.auth import get_user_model 15 | from django.contrib.auth.models import Permission 16 | from django.core.exceptions import ImproperlyConfigured 17 | from django.db.models import Q 18 | from django.db.models.fields.related import ManyToManyField 19 | from django.test import RequestFactory 20 | from rest_framework.reverse import reverse as rf_reverse 21 | 22 | User = get_user_model() 23 | USERNAME_FIELD = getattr(User, "USERNAME_FIELD", "username") 24 | 25 | 26 | def lmap(*args, **kwargs): 27 | """Shortcut to return list when mapping""" 28 | return list(map(*args, **kwargs)) 29 | 30 | 31 | def omit_keys(*args): 32 | """Remove keys from a dictionary""" 33 | *keys, dict_obj = args 34 | return { 35 | field: value 36 | for field, value in dict_obj.items() 37 | if field not in keys 38 | } 39 | 40 | 41 | def reverse(name, *args, **kwargs): 42 | """Shorter Reverse function for the very lazy tester""" 43 | full_url = kwargs.pop("full", False) 44 | uri = rf_reverse(name, args=args, kwargs=kwargs) 45 | if "request" not in kwargs and full_url: 46 | return f"http://testserver{uri}" 47 | return uri 48 | 49 | 50 | def context_kwarg(path): 51 | """Build context for Serializers 52 | 53 | Not necessary from the outset, but the use of 54 | Hyperlinked fields and serializers necessitates the 55 | inclusion of a request. This utility is pre-empting 56 | that requirement. 57 | """ 58 | return { 59 | "context": {"request": RequestFactory().get(path)} 60 | } 61 | 62 | 63 | @contextmanager 64 | def auth_user(testcase): 65 | """Create new user and log them in 66 | 67 | permissions should be a string identifying a permission, 68 | e.g. contenttypes.add_contenttype or contenttypes.* 69 | or else a list of such strings 70 | """ 71 | password = getattr( 72 | testcase, "password", "securepassword!" 73 | ) 74 | if ( 75 | not hasattr(testcase, "user_factory") 76 | or testcase.user_factory is None 77 | ): 78 | raise ImproperlyConfigured( 79 | "Testcase must specify a user factory " 80 | "to use the perm_user context" 81 | ) 82 | test_user = testcase.user_factory(password=password) 83 | credentials = { 84 | USERNAME_FIELD: getattr(test_user, USERNAME_FIELD), 85 | "password": password, 86 | } 87 | success = testcase.client.login(**credentials) 88 | testcase.assertTrue( 89 | success, 90 | "login failed with credentials=%r" % (credentials), 91 | ) 92 | yield test_user 93 | testcase.client.logout() 94 | 95 | 96 | def get_perms(string_perm): 97 | """Build Q() of permission identified by string_perm 98 | 99 | expects: contenttypes.add_contenttype or contenttypes.* 100 | """ 101 | if "." not in string_perm: 102 | raise ImproperlyConfigured( 103 | "The permission in the perms argument needs to be either " 104 | "app_label.codename or app_label.* " 105 | "(e.g. contenttypes.add_contenttype or contenttypes.*)" 106 | ) 107 | app_label, codename = string_perm.split(".") 108 | if codename == "*": 109 | return Q(content_type__app_label=app_label) 110 | else: 111 | return Q( 112 | content_type__app_label=app_label, 113 | codename=codename, 114 | ) 115 | 116 | 117 | @contextmanager 118 | def perm_user(testcase, permissions): 119 | """Create new user with permissions and log them in 120 | 121 | permissions should be a string identifying a permission, 122 | e.g. contenttypes.add_contenttype or contenttypes.* 123 | or else a list of such strings 124 | """ 125 | with auth_user(testcase) as test_user: 126 | if isinstance(permissions, str): 127 | test_user.user_permissions.add( 128 | *list( 129 | Permission.objects.filter( 130 | get_perms(permissions) 131 | ) 132 | ) 133 | ) 134 | else: 135 | test_user.user_permissions.add( 136 | *list( 137 | Permission.objects.filter( 138 | reduce( 139 | or_, 140 | ( 141 | get_perms(perms) 142 | for perms in permissions 143 | ), 144 | ) 145 | ) 146 | ) 147 | ) 148 | yield test_user 149 | 150 | 151 | def get_concrete_field_names(Model): 152 | """Return all of the concrete field names for a Model 153 | 154 | https://docs.djangoproject.com/en/2.2/ref/models/meta/ 155 | 156 | """ 157 | return [ 158 | field.name 159 | for field in Model._meta.get_fields() 160 | if field.concrete 161 | and ( 162 | not ( 163 | field.is_relation 164 | or field.one_to_one 165 | or ( 166 | field.many_to_one 167 | and field.related_model 168 | ) 169 | ) 170 | ) 171 | ] 172 | 173 | 174 | def get_instance_data(model_instance, related_value="pk"): 175 | """Return a dict of fields for the model_instance instance 176 | 177 | Effectively a simple form of serialization 178 | 179 | """ 180 | from django.db.models import DateField, DateTimeField 181 | 182 | model_fields = model_instance._meta.get_fields() 183 | 184 | # add basic fields 185 | concrete_fields = [ 186 | field 187 | for field in model_fields 188 | if field.concrete 189 | and not isinstance( 190 | field, (DateField, DateTimeField) 191 | ) 192 | ] 193 | instance_data = { 194 | field.name: field.value_from_object(model_instance) 195 | for field in concrete_fields 196 | if field.value_from_object(model_instance) 197 | is not None 198 | } 199 | 200 | # special case for datefields to ensure a string, not an object 201 | concrete_date_fields = [ 202 | field 203 | for field in model_fields 204 | if field.concrete 205 | and isinstance(field, (DateField, DateTimeField)) 206 | ] 207 | for field in concrete_date_fields: 208 | instance_data[field.name] = str( 209 | field.value_from_object(model_instance) 210 | ) 211 | 212 | # add many-to-many fields 213 | # the `isinstance` check avoids ManyToManyRel 214 | m2m_fields = [ 215 | field 216 | for field in model_fields 217 | if field.many_to_many 218 | and isinstance(field, ManyToManyField) 219 | ] 220 | if model_instance.pk is None: 221 | for field in m2m_fields: 222 | instance_data[field.name] = [] 223 | else: 224 | for field in m2m_fields: 225 | instance_data[field.name] = [ 226 | getattr(obj, related_value) 227 | for obj in field.value_from_object( 228 | model_instance 229 | ) 230 | ] 231 | 232 | return instance_data 233 | -------------------------------------------------------------------------------- /src/config/urls.py: -------------------------------------------------------------------------------- 1 | """Root URL Configuration for Startup Organizer Project""" 2 | from django.conf import settings 3 | from django.contrib import admin 4 | from django.contrib.sitemaps.views import ( 5 | index as site_index_view, 6 | sitemap as sitemap_view, 7 | ) 8 | from django.urls import include, path 9 | from django.views.generic import TemplateView 10 | 11 | from blog import urls as blog_urls 12 | from blog.feeds import AtomPostFeed, Rss2PostFeed 13 | from blog.routers import urlpatterns as blog_api_urls 14 | from organizer import urls as organizer_urls 15 | from organizer.routers import ( 16 | urlpatterns as organizer_api_urls, 17 | ) 18 | from user import urls as user_urls 19 | 20 | from .sitemaps import sitemaps as sitemaps_dict 21 | from .views import RootApiView, test_celery 22 | 23 | root_api_url = [ 24 | path("", RootApiView.as_view(), name="api-root") 25 | ] 26 | api_urls = root_api_url + blog_api_urls + organizer_api_urls 27 | 28 | 29 | urlpatterns = [ 30 | path("admin/", admin.site.urls), 31 | path("api/v1/", include(api_urls)), 32 | path("atom/", AtomPostFeed(), name="post_atom_feed"), 33 | path("blog/", include(blog_urls)), 34 | path( 35 | "o/", 36 | include( 37 | "oauth2_provider.urls", 38 | namespace="oauth2_provider", 39 | ), 40 | ), 41 | path("rss/", Rss2PostFeed(), name="post_rss_feed"), 42 | path( 43 | "sitemap.xml", 44 | site_index_view, 45 | {"sitemaps": sitemaps_dict}, 46 | name="sitemap", 47 | ), 48 | path( 49 | "sitemap-
.xml", 50 | sitemap_view, 51 | {"sitemaps": sitemaps_dict}, 52 | name="django.contrib.sitemaps.views.sitemap", 53 | ), 54 | path("test/", test_celery), 55 | path("", include(organizer_urls)), 56 | path( 57 | "", include((user_urls, "auth"), namespace="auth") 58 | ), 59 | path( 60 | "", 61 | TemplateView.as_view(template_name="root.html"), 62 | name="site_root", 63 | ), 64 | ] 65 | 66 | if settings.DEBUG: 67 | import debug_toolbar 68 | 69 | urlpatterns = [ 70 | path("__debug__/", include(debug_toolbar.urls)) 71 | ] + urlpatterns 72 | -------------------------------------------------------------------------------- /src/config/views.py: -------------------------------------------------------------------------------- 1 | """Site Views 2 | 3 | (Views that do not belong in any app but are needed by site.) 4 | 5 | I'm breaking the rules here for simplicity: this should not 6 | be in config, as this is not configuration for the project. 7 | However, introducing an app specifically for a single view 8 | would overcomplicate the work we're doing, so it's going 9 | here instead. 10 | """ 11 | 12 | from django.http import HttpResponse 13 | from rest_framework.response import Response 14 | from rest_framework.reverse import reverse 15 | from rest_framework.status import HTTP_200_OK 16 | from rest_framework.views import APIView 17 | 18 | from oauth2_provider.contrib.rest_framework.permissions import ( 19 | IsAuthenticatedOrTokenHasScope, 20 | ) 21 | 22 | from .celery import debug_task 23 | 24 | 25 | def test_celery(request): 26 | """Use Celery debug task""" 27 | debug_task.delay("Hello from Celery") 28 | return HttpResponse("Hello from Django") 29 | 30 | 31 | class RootApiView(APIView): 32 | """Direct users to other API endpoints""" 33 | 34 | permission_classes = [IsAuthenticatedOrTokenHasScope] 35 | 36 | def get(self, request, *args, **kwargs): 37 | """Build & display links to other endpoints""" 38 | api_endpoints = [ 39 | # (name, url_name), 40 | ("tag", "api-tag-list"), 41 | ("startup", "api-startup-list"), 42 | ("newslink", "api-newslink-list"), 43 | ("blog", "api-post-list"), 44 | ] 45 | data = { 46 | name: reverse( 47 | url_name, 48 | request=request, 49 | format=kwargs.get("format", None), 50 | ) 51 | for (name, url_name) in api_endpoints 52 | } 53 | return Response(data=data, status=HTTP_200_OK) 54 | -------------------------------------------------------------------------------- /src/config/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for config 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/2.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault( 15 | "DJANGO_SETTINGS_MODULE", "config.settings" 16 | ) 17 | 18 | application = get_wsgi_application() 19 | -------------------------------------------------------------------------------- /src/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault( 7 | "DJANGO_SETTINGS_MODULE", 8 | "config.settings.development", 9 | ) 10 | try: 11 | from django.core.management import ( 12 | execute_from_command_line, 13 | ) 14 | except ImportError as exc: 15 | raise ImportError( 16 | "Couldn't import Django. Are you sure it's installed and " 17 | "available on your PYTHONPATH environment variable? Did you " 18 | "forget to activate a virtual environment?" 19 | ) from exc 20 | execute_from_command_line(sys.argv) 21 | -------------------------------------------------------------------------------- /src/organizer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jambonrose/python-web-dev-22-3/6621f4450661b7691771fc04f80bc324c0234f6c/src/organizer/__init__.py -------------------------------------------------------------------------------- /src/organizer/admin.py: -------------------------------------------------------------------------------- 1 | """Configuration of Organizer Admin panel""" 2 | from django.contrib import admin 3 | 4 | from .models import NewsLink, Startup, Tag 5 | 6 | admin.site.register(NewsLink) 7 | 8 | 9 | @admin.register(Tag) 10 | class TagAdmin(admin.ModelAdmin): 11 | """Configure Tag panel""" 12 | 13 | list_display = ("name", "slug") 14 | 15 | 16 | @admin.register(Startup) 17 | class StartupAdmin(admin.ModelAdmin): 18 | """Configure Startup panel""" 19 | 20 | list_display = ("name", "slug") 21 | prepopulated_fields = {"slug": ("name",)} 22 | -------------------------------------------------------------------------------- /src/organizer/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class OrganizerConfig(AppConfig): 5 | name = "organizer" 6 | -------------------------------------------------------------------------------- /src/organizer/forms.py: -------------------------------------------------------------------------------- 1 | """Forms for the Organizer app""" 2 | from django.core.exceptions import ValidationError 3 | from django.forms import ModelForm 4 | from django.forms.widgets import HiddenInput 5 | 6 | from .models import NewsLink, Startup, Tag 7 | 8 | 9 | class LowercaseNameMixin: 10 | """Form cleaner to lower case of name field""" 11 | 12 | def clean_name(self): 13 | """Ensure Tag name is always lowercase""" 14 | return self.cleaned_data["name"].lower() 15 | 16 | 17 | class SlugCleanMixin: 18 | """Mixin class to ensure slug field is not create""" 19 | 20 | def clean_slug(self): 21 | """Ensure slug is not 'create' 22 | 23 | This is an oversimplification!!! See the following 24 | link for how to raise the error correctly. 25 | 26 | https://docs.djangoproject.com/en/2.2/ref/forms/validation/#raising-validationerror 27 | 28 | """ 29 | slug = self.cleaned_data["slug"] 30 | if slug == "create": 31 | raise ValidationError( 32 | "Slug may not be 'create'." 33 | ) 34 | return slug 35 | 36 | 37 | class TagForm(LowercaseNameMixin, ModelForm): 38 | """HTML form for Tag objects""" 39 | 40 | class Meta: 41 | model = Tag 42 | fields = "__all__" # name only, no slug! 43 | 44 | 45 | class StartupForm( 46 | LowercaseNameMixin, SlugCleanMixin, ModelForm 47 | ): 48 | """HTML form for Startup objects""" 49 | 50 | class Meta: 51 | model = Startup 52 | fields = "__all__" 53 | 54 | 55 | class NewsLinkForm(ModelForm): 56 | """HTML form for NewsLink objects""" 57 | 58 | class Meta: 59 | model = NewsLink 60 | fields = "__all__" 61 | widgets = {"startup": HiddenInput()} 62 | 63 | def clean_slug(self): 64 | """Avoid URI conflicts with paths in app""" 65 | slug = self.cleaned_data["slug"] 66 | if slug in ["delete", "update", "add_article"]: 67 | raise ValidationError( 68 | f"Slug may not be '{slug}'." 69 | ) 70 | return slug 71 | -------------------------------------------------------------------------------- /src/organizer/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | import django.db.models.deletion 2 | from django.db import migrations, models 3 | from django_extensions.db.fields import AutoSlugField 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="Tag", 15 | fields=[ 16 | ( 17 | "id", 18 | models.AutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ( 26 | "name", 27 | models.CharField( 28 | max_length=31, unique=True 29 | ), 30 | ), 31 | ( 32 | "slug", 33 | AutoSlugField( 34 | blank=True, 35 | editable=False, 36 | help_text="A label for URL config.", 37 | max_length=31, 38 | populate_from=["name"], 39 | ), 40 | ), 41 | ], 42 | options={"ordering": ["name"]}, 43 | ), 44 | migrations.CreateModel( 45 | name="Startup", 46 | fields=[ 47 | ( 48 | "id", 49 | models.AutoField( 50 | auto_created=True, 51 | primary_key=True, 52 | serialize=False, 53 | verbose_name="ID", 54 | ), 55 | ), 56 | ( 57 | "name", 58 | models.CharField( 59 | db_index=True, max_length=31 60 | ), 61 | ), 62 | ( 63 | "slug", 64 | models.SlugField( 65 | help_text="A label for URL config.", 66 | max_length=31, 67 | unique=True, 68 | ), 69 | ), 70 | ("description", models.TextField()), 71 | ( 72 | "founded_date", 73 | models.DateField( 74 | verbose_name="date founded" 75 | ), 76 | ), 77 | ( 78 | "contact", 79 | models.EmailField(max_length=254), 80 | ), 81 | ( 82 | "website", 83 | models.URLField(max_length=255), 84 | ), 85 | ( 86 | "tags", 87 | models.ManyToManyField( 88 | to="organizer.Tag" 89 | ), 90 | ), 91 | ], 92 | options={ 93 | "ordering": ["name"], 94 | "get_latest_by": "founded_date", 95 | }, 96 | ), 97 | migrations.CreateModel( 98 | name="NewsLink", 99 | fields=[ 100 | ( 101 | "id", 102 | models.AutoField( 103 | auto_created=True, 104 | primary_key=True, 105 | serialize=False, 106 | verbose_name="ID", 107 | ), 108 | ), 109 | ("title", models.CharField(max_length=63)), 110 | ("slug", models.SlugField(max_length=63)), 111 | ( 112 | "pub_date", 113 | models.DateField( 114 | verbose_name="date published" 115 | ), 116 | ), 117 | ("link", models.URLField(max_length=255)), 118 | ( 119 | "startup", 120 | models.ForeignKey( 121 | on_delete=django.db.models.deletion.CASCADE, 122 | to="organizer.Startup", 123 | ), 124 | ), 125 | ], 126 | options={ 127 | "verbose_name": "news article", 128 | "ordering": ["-pub_date"], 129 | "get_latest_by": "pub_date", 130 | }, 131 | ), 132 | migrations.AlterUniqueTogether( 133 | name="newslink", 134 | unique_together={("slug", "startup")}, 135 | ), 136 | ] 137 | -------------------------------------------------------------------------------- /src/organizer/migrations/0002_startup_logo.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.4 on 2019-08-23 21:54 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("organizer", "0001_initial")] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="startup", 13 | name="logo", 14 | field=models.FileField(null=True, upload_to=""), 15 | ) 16 | ] 17 | -------------------------------------------------------------------------------- /src/organizer/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jambonrose/python-web-dev-22-3/6621f4450661b7691771fc04f80bc324c0234f6c/src/organizer/migrations/__init__.py -------------------------------------------------------------------------------- /src/organizer/models.py: -------------------------------------------------------------------------------- 1 | """Django data models for organizing startup company data 2 | 3 | Django Model Documentation: 4 | https://docs.djangoproject.com/en/2.2/topics/db/models/ 5 | https://docs.djangoproject.com/en/2.2/ref/models/options/ 6 | https://docs.djangoproject.com/en/2.2/internals/contributing/writing-code/coding-style/#model-style 7 | Django Field Reference: 8 | https://docs.djangoproject.com/en/2.2/ref/models/fields/ 9 | https://docs.djangoproject.com/en/2.2/ref/models/fields/#charfield 10 | https://docs.djangoproject.com/en/2.2/ref/models/fields/#datefield 11 | https://docs.djangoproject.com/en/2.2/ref/models/fields/#emailfield 12 | https://docs.djangoproject.com/en/2.2/ref/models/fields/#foreignkey 13 | https://docs.djangoproject.com/en/2.2/ref/models/fields/#manytomanyfield 14 | https://docs.djangoproject.com/en/2.2/ref/models/fields/#slugfield 15 | https://docs.djangoproject.com/en/2.2/ref/models/fields/#textfield 16 | https://docs.djangoproject.com/en/2.2/ref/models/fields/#urlfield 17 | 18 | AutoSlugField Reference: 19 | https://django-extensions.readthedocs.io/en/latest/field_extensions.html 20 | 21 | """ 22 | from django.db.models import ( 23 | CASCADE, 24 | CharField, 25 | DateField, 26 | EmailField, 27 | FileField, 28 | ForeignKey, 29 | ManyToManyField, 30 | Model, 31 | SlugField, 32 | TextField, 33 | URLField, 34 | ) 35 | from django.urls import reverse 36 | from django_extensions.db.fields import AutoSlugField 37 | 38 | 39 | class Tag(Model): 40 | """Labels to help categorize data""" 41 | 42 | name = CharField(max_length=31, unique=True) 43 | slug = AutoSlugField( 44 | help_text="A label for URL config.", 45 | max_length=31, 46 | populate_from=["name"], 47 | ) 48 | 49 | class Meta: 50 | ordering = ["name"] 51 | 52 | def __str__(self): 53 | return self.name 54 | 55 | def get_absolute_url(self): 56 | """Return URL to detail page of Tag""" 57 | return reverse( 58 | "tag_detail", kwargs={"slug": self.slug} 59 | ) 60 | 61 | def get_update_url(self): 62 | """Return URL to update page of Tag""" 63 | return reverse( 64 | "tag_update", kwargs={"slug": self.slug} 65 | ) 66 | 67 | def get_delete_url(self): 68 | """Return URL to delete page of Tag""" 69 | return reverse( 70 | "tag_delete", kwargs={"slug": self.slug} 71 | ) 72 | 73 | 74 | class Startup(Model): 75 | """Data about a Startup company""" 76 | 77 | name = CharField(max_length=31, db_index=True) 78 | slug = SlugField( 79 | max_length=31, 80 | unique=True, 81 | help_text="A label for URL config.", 82 | ) 83 | # https://docs.djangoproject.com/en/2.2/ref/models/fields/#filefield 84 | logo = FileField(null=True) 85 | description = TextField() 86 | founded_date = DateField("date founded") 87 | contact = EmailField() 88 | website = URLField( 89 | max_length=255 # https://tools.ietf.org/html/rfc3986 90 | ) 91 | tags = ManyToManyField(Tag) 92 | 93 | class Meta: 94 | get_latest_by = "founded_date" 95 | ordering = ["name"] 96 | 97 | def __str__(self): 98 | return self.name 99 | 100 | def get_absolute_url(self): 101 | """Return URL to detail page of Startup""" 102 | return reverse( 103 | "startup_detail", kwargs={"slug": self.slug} 104 | ) 105 | 106 | def get_update_url(self): 107 | """Return URL to update page of Startup""" 108 | return reverse( 109 | "startup_update", kwargs={"slug": self.slug} 110 | ) 111 | 112 | def get_delete_url(self): 113 | """Return URL to delete page of Startup""" 114 | return reverse( 115 | "startup_delete", kwargs={"slug": self.slug} 116 | ) 117 | 118 | def get_newslink_create_url(self): 119 | """Return URL to detail page of Startup""" 120 | return reverse( 121 | "newslink_create", 122 | kwargs={"startup_slug": self.slug}, 123 | ) 124 | 125 | 126 | class NewsLink(Model): 127 | """Link to external sources about a Startup""" 128 | 129 | title = CharField(max_length=63) 130 | slug = SlugField(max_length=63) 131 | pub_date = DateField("date published") 132 | link = URLField( 133 | max_length=255 # https://tools.ietf.org/html/rfc3986 134 | ) 135 | startup = ForeignKey(Startup, on_delete=CASCADE) 136 | 137 | class Meta: 138 | get_latest_by = "pub_date" 139 | ordering = ["-pub_date"] 140 | unique_together = ("slug", "startup") 141 | verbose_name = "news article" 142 | 143 | def __str__(self): 144 | return f"{self.startup}: {self.title}" 145 | 146 | def get_absolute_url(self): 147 | """Return URL to detail page of Startup""" 148 | return reverse( 149 | "startup_detail", 150 | kwargs={"slug": self.startup.slug}, 151 | ) 152 | 153 | def get_update_url(self): 154 | """Return URL to update page of Startup""" 155 | return reverse( 156 | "newslink_update", 157 | kwargs={ 158 | "startup_slug": self.startup.slug, 159 | "newslink_slug": self.slug, 160 | }, 161 | ) 162 | 163 | def get_delete_url(self): 164 | """Return URL to delete page of Startup""" 165 | return reverse( 166 | "newslink_delete", 167 | kwargs={ 168 | "startup_slug": self.startup.slug, 169 | "newslink_slug": self.slug, 170 | }, 171 | ) 172 | -------------------------------------------------------------------------------- /src/organizer/routers.py: -------------------------------------------------------------------------------- 1 | """URL Paths and Routers for Organizer App""" 2 | from rest_framework.routers import SimpleRouter 3 | 4 | from .viewsets import ( 5 | NewsLinkViewSet, 6 | StartupViewSet, 7 | TagViewSet, 8 | ) 9 | 10 | 11 | class NewsLinkRouter(SimpleRouter): 12 | """Override the SimpleRouter for articles 13 | 14 | DRF's routers expect there to only be a single variable 15 | for finding objects. However, our NewsLinks needs 16 | two! We therefore override the Router's behavior to 17 | make it do what we want. 18 | 19 | The big question: was it worth switching to a ViewSet 20 | and Router over our previous config for this? 21 | """ 22 | 23 | def get_lookup_regex(self, *args, **kwargs): 24 | """Return regular expression pattern for URL path 25 | 26 | This is the (rough) equivalent of the simple path: 27 | / 28 | """ 29 | return ( 30 | r"(?P[^/.]+)/" 31 | r"(?P[^/.]+)" 32 | ) 33 | 34 | 35 | api_router = SimpleRouter() 36 | api_router.register("tag", TagViewSet, base_name="api-tag") 37 | api_router.register( 38 | "startup", StartupViewSet, base_name="api-startup" 39 | ) 40 | 41 | nl_router = NewsLinkRouter() 42 | nl_router.register( 43 | "newslink", NewsLinkViewSet, base_name="api-newslink" 44 | ) 45 | 46 | urlpatterns = api_router.urls + nl_router.urls 47 | -------------------------------------------------------------------------------- /src/organizer/serializers.py: -------------------------------------------------------------------------------- 1 | """Serializers for the Organizer App 2 | 3 | Serializer Documentation 4 | http://www.django-rest-framework.org/api-guide/serializers/ 5 | http://www.django-rest-framework.org/api-guide/fields/ 6 | http://www.django-rest-framework.org/api-guide/relations/ 7 | """ 8 | from rest_framework.reverse import reverse 9 | from rest_framework.serializers import ( 10 | HyperlinkedModelSerializer, 11 | HyperlinkedRelatedField, 12 | ModelSerializer, 13 | SerializerMethodField, 14 | ) 15 | 16 | from .models import NewsLink, Startup, Tag 17 | 18 | 19 | class TagSerializer(HyperlinkedModelSerializer): 20 | """Serialize Tag data""" 21 | 22 | class Meta: 23 | model = Tag 24 | fields = "__all__" 25 | extra_kwargs = { 26 | "url": { 27 | "lookup_field": "slug", 28 | "view_name": "api-tag-detail", 29 | } 30 | } 31 | 32 | 33 | class StartupSerializer(HyperlinkedModelSerializer): 34 | """Serialize Startup data""" 35 | 36 | tags = HyperlinkedRelatedField( 37 | lookup_field="slug", 38 | many=True, 39 | read_only=True, 40 | view_name="api-tag-detail", 41 | ) 42 | 43 | class Meta: 44 | model = Startup 45 | fields = "__all__" 46 | extra_kwargs = { 47 | "url": { 48 | "lookup_field": "slug", 49 | "view_name": "api-startup-detail", 50 | } 51 | } 52 | 53 | 54 | class NewsLinkSerializer(ModelSerializer): 55 | """Serialize NewsLink data""" 56 | 57 | url = SerializerMethodField() 58 | startup = HyperlinkedRelatedField( 59 | queryset=Startup.objects.all(), 60 | lookup_field="slug", 61 | view_name="api-startup-detail", 62 | ) 63 | 64 | class Meta: 65 | model = NewsLink 66 | exclude = ("id",) 67 | 68 | def get_url(self, newslink): 69 | """Build full URL for NewsLink API detail""" 70 | return reverse( 71 | "api-newslink-detail", 72 | kwargs=dict( 73 | startup_slug=newslink.startup.slug, 74 | newslink_slug=newslink.slug, 75 | ), 76 | request=self.context["request"], 77 | ) 78 | -------------------------------------------------------------------------------- /src/organizer/sitemaps.py: -------------------------------------------------------------------------------- 1 | """Sitemaps for Tags and Startups""" 2 | from django.contrib.sitemaps import GenericSitemap, Sitemap 3 | 4 | from .models import Startup, Tag 5 | 6 | TagSitemap = GenericSitemap({"queryset": Tag.objects.all()}) 7 | 8 | 9 | class StartupSitemap(Sitemap): 10 | """Sitemap for Startup pages""" 11 | 12 | def items(self): 13 | """All of our StartupSitemap 14 | 15 | Django uses get_absolute_url to generate links 16 | """ 17 | return Startup.objects.all() 18 | 19 | def lastmod(self, startup): 20 | """Use Startup or Newslink to indicate when page was last modified""" 21 | if startup.newslink_set.exists(): 22 | return startup.newslink_set.latest().pub_date 23 | else: 24 | return startup.founded_date 25 | -------------------------------------------------------------------------------- /src/organizer/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jambonrose/python-web-dev-22-3/6621f4450661b7691771fc04f80bc324c0234f6c/src/organizer/tests/__init__.py -------------------------------------------------------------------------------- /src/organizer/tests/factories.py: -------------------------------------------------------------------------------- 1 | """Factory classes for organizer models""" 2 | from random import randint 3 | 4 | from factory import ( 5 | DjangoModelFactory, 6 | Faker, 7 | Sequence, 8 | SubFactory, 9 | post_generation, 10 | ) 11 | 12 | from ..models import NewsLink, Startup, Tag 13 | 14 | 15 | class TagFactory(DjangoModelFactory): 16 | """Factory for Tags (labels)""" 17 | 18 | name = Sequence(lambda n: f"name-{n}") 19 | slug = Sequence(lambda n: f"slug-{n}") 20 | 21 | class Meta: 22 | model = Tag 23 | 24 | 25 | class StartupFactory(DjangoModelFactory): 26 | """Factory for startup company data""" 27 | 28 | name = Sequence(lambda n: f"name-{n}") 29 | slug = Sequence(lambda n: f"slug-{n}") 30 | description = Faker("catch_phrase") 31 | founded_date = Faker( 32 | "date_this_decade", before_today=True 33 | ) 34 | contact = Faker("company_email") 35 | website = Faker("url") 36 | 37 | class Meta: 38 | model = Startup 39 | 40 | @post_generation 41 | def tags( # noqa: N805 42 | startup, create, extracted, **kwargs # noqa: B902 43 | ): 44 | """Add related tag objects to Startup""" 45 | if create: 46 | if extracted is not None: 47 | tag_list = extracted 48 | else: # generate Tag objects randomly 49 | tag_list = map( 50 | lambda f: f(), 51 | [TagFactory] * randint(0, 5), 52 | ) 53 | for tag in tag_list: 54 | startup.tags.add(tag) 55 | 56 | 57 | class NewsLinkFactory(DjangoModelFactory): 58 | """Factory for article links""" 59 | 60 | title = Faker( 61 | "sentence", nb_words=3, variable_nb_words=True 62 | ) 63 | slug = Sequence(lambda n: f"slug-{n}") 64 | pub_date = Faker("date_this_decade", before_today=True) 65 | link = Faker("uri") 66 | startup = SubFactory(StartupFactory) 67 | 68 | class Meta: 69 | model = NewsLink 70 | -------------------------------------------------------------------------------- /src/organizer/tests/test_forms.py: -------------------------------------------------------------------------------- 1 | """Tests for all forms in the Organizer app""" 2 | from django.test import TestCase 3 | 4 | from config.test_utils import get_instance_data, omit_keys 5 | 6 | from ..forms import NewsLinkForm, StartupForm, TagForm 7 | from ..models import NewsLink, Startup, Tag 8 | from .factories import ( 9 | NewsLinkFactory, 10 | StartupFactory, 11 | TagFactory, 12 | ) 13 | 14 | 15 | class TagFormTests(TestCase): 16 | """Tests for TagForm""" 17 | 18 | def test_creation(self): 19 | """Can we save new tags based on input?""" 20 | self.assertFalse( 21 | Tag.objects.filter(name="django").exists() 22 | ) 23 | bounded_form = TagForm(data=dict(name="Django")) 24 | self.assertTrue( 25 | bounded_form.is_valid(), bounded_form.errors 26 | ) 27 | bounded_form.save() 28 | self.assertTrue( 29 | Tag.objects.filter(name="django").exists() 30 | ) 31 | 32 | # AutoSlugField does not have a field in ModelForms! 33 | # def test_slug_validation(self): 34 | # """Do we error if slug is create?""" 35 | # tform = TagForm( 36 | # data=dict(name="django", slug="create") 37 | # ) 38 | # self.assertFalse(tform.is_valid()) 39 | 40 | def test_update(self): 41 | """Can we save new tags based on input?""" 42 | tag = TagFactory() 43 | self.assertNotEqual(tag.name, "django") 44 | tform = TagForm( 45 | data=dict(name="Django"), instance=tag 46 | ) 47 | self.assertTrue(tform.is_valid(), tform.errors) 48 | tform.save() 49 | tag.refresh_from_db() 50 | self.assertEqual(tag.name, "django") 51 | 52 | 53 | class StartupFormTests(TestCase): 54 | """Tests for StartupForm""" 55 | 56 | def test_creation(self): 57 | """Can we save new startups based on input?""" 58 | tag = TagFactory() 59 | startup = StartupFactory.build() 60 | self.assertFalse( 61 | Startup.objects.filter( 62 | slug=startup.slug 63 | ).exists() 64 | ) 65 | bounded_form = StartupForm( 66 | data={ 67 | **get_instance_data(startup), 68 | "tags": [tag.pk], 69 | } 70 | ) 71 | self.assertTrue( 72 | bounded_form.is_valid(), bounded_form.errors 73 | ) 74 | bounded_form.save() 75 | self.assertTrue( 76 | Startup.objects.filter( 77 | slug=startup.slug 78 | ).exists() 79 | ) 80 | 81 | def test_slug_validation(self): 82 | """Do we error if slug is create?""" 83 | data = omit_keys( 84 | "slug", 85 | get_instance_data(StartupFactory.build()), 86 | ) 87 | sform = StartupForm({**data, "slug": "create"}) 88 | self.assertFalse(sform.is_valid()) 89 | 90 | def test_update(self): 91 | """Can we updated startups based on input?""" 92 | tag = TagFactory() 93 | startup = StartupFactory(tags=[tag]) 94 | self.assertNotEqual(startup.name, "django") 95 | sform = StartupForm( 96 | instance=startup, 97 | data=dict( 98 | omit_keys( 99 | "name", get_instance_data(startup) 100 | ), 101 | name="django", 102 | tags=[tag.pk], 103 | ), 104 | ) 105 | self.assertTrue(sform.is_valid(), sform.errors) 106 | sform.save() 107 | startup.refresh_from_db() 108 | self.assertEqual(startup.name, "django") 109 | 110 | 111 | class NewsLinkFormTests(TestCase): 112 | """Tests for NewsLinkForm""" 113 | 114 | def test_startup_hidden_field(self): 115 | """Ensure that the Startup field is hidden""" 116 | self.assertIn( 117 | "startup", 118 | [ 119 | field.name 120 | for field in NewsLinkForm().hidden_fields() 121 | ], 122 | ) 123 | 124 | def test_creation(self): 125 | """Can we save new newslinks based on input?""" 126 | startup = StartupFactory() 127 | newslink = NewsLinkFactory.build() 128 | self.assertFalse( 129 | NewsLink.objects.filter( 130 | slug=newslink.slug 131 | ).exists() 132 | ) 133 | bounded_form = NewsLinkForm( 134 | data={ 135 | **get_instance_data(newslink), 136 | "startup": startup.pk, 137 | } 138 | ) 139 | self.assertTrue( 140 | bounded_form.is_valid(), bounded_form.errors 141 | ) 142 | bounded_form.save() 143 | self.assertTrue( 144 | NewsLink.objects.filter( 145 | slug=newslink.slug 146 | ).exists() 147 | ) 148 | 149 | def test_update(self): 150 | """Can we updated newslinks based on input?""" 151 | startup = StartupFactory() 152 | newslink = NewsLinkFactory(startup=startup) 153 | self.assertNotEqual(newslink.title, "django") 154 | nl_form = NewsLinkForm( 155 | instance=newslink, 156 | data=dict( 157 | omit_keys( 158 | "name", get_instance_data(newslink) 159 | ), 160 | title="django", 161 | startups=[startup.pk], 162 | ), 163 | ) 164 | self.assertTrue(nl_form.is_valid(), nl_form.errors) 165 | nl_form.save() 166 | newslink.refresh_from_db() 167 | self.assertEqual(newslink.title, "django") 168 | 169 | def test_slug_validation(self): 170 | """Do we error if slug conflicts with URL?""" 171 | conflicts = ["delete", "update", "add_article"] 172 | startup = StartupFactory() 173 | for url_path in conflicts: 174 | with self.subTest(slug=url_path): 175 | nl_form = NewsLinkForm( 176 | dict( 177 | get_instance_data( 178 | NewsLinkFactory.build() 179 | ), 180 | slug=url_path, 181 | startup=startup.pk, 182 | ) 183 | ) 184 | self.assertFalse(nl_form.is_valid()) 185 | -------------------------------------------------------------------------------- /src/organizer/tests/test_serializers.py: -------------------------------------------------------------------------------- 1 | """Tests for Serializers in the Organizer App""" 2 | from django.test import TestCase 3 | 4 | from config.test_utils import ( 5 | context_kwarg, 6 | get_instance_data, 7 | omit_keys, 8 | reverse, 9 | ) 10 | 11 | from ..models import Startup, Tag 12 | from ..serializers import ( 13 | NewsLinkSerializer, 14 | StartupSerializer, 15 | TagSerializer, 16 | ) 17 | from .factories import ( 18 | NewsLinkFactory, 19 | StartupFactory, 20 | TagFactory, 21 | ) 22 | 23 | 24 | class TagSerializerTests(TestCase): 25 | """Test Serialization of Tags in TagSerializer""" 26 | 27 | def test_serialization(self): 28 | """Does an existing Tag serialize correctly?""" 29 | tag = TagFactory() 30 | tag_url = reverse( 31 | "api-tag-detail", slug=tag.slug, full=True 32 | ) 33 | s_tag = TagSerializer(tag, **context_kwarg(tag_url)) 34 | self.assertEqual( 35 | omit_keys("url", s_tag.data), 36 | omit_keys("id", get_instance_data(tag)), 37 | ) 38 | self.assertEqual(s_tag.data["url"], tag_url) 39 | 40 | def test_deserialization(self): 41 | """Can we deserialize data to a Tag model?""" 42 | tag_data = get_instance_data(TagFactory.build()) 43 | s_tag = TagSerializer( 44 | data=tag_data, **context_kwarg("/api/v1/tag/") 45 | ) 46 | self.assertTrue(s_tag.is_valid(), s_tag.errors) 47 | tag = s_tag.save() 48 | self.assertTrue( 49 | Tag.objects.filter(pk=tag.pk).exists() 50 | ) 51 | 52 | def test_invalid_deserialization(self): 53 | """Does the serializer validate data?""" 54 | s_tag = TagSerializer( 55 | data={}, **context_kwarg("/api/v1/tag/") 56 | ) 57 | self.assertFalse(s_tag.is_valid()) 58 | 59 | 60 | class StartupSerializerTests(TestCase): 61 | """Test Serialization of Startups in StartupSerializer""" 62 | 63 | def test_serialization(self): 64 | """Does an existing Startup serialize correctly?""" 65 | tag_list = TagFactory.create_batch(3) 66 | startup = StartupFactory(tags=tag_list) 67 | startup_url = reverse( 68 | "api-startup-detail", 69 | slug=startup.slug, 70 | full=True, 71 | ) 72 | s_startup = StartupSerializer( 73 | startup, **context_kwarg(startup_url) 74 | ) 75 | self.assertEqual( 76 | omit_keys("url", "tags", s_startup.data), 77 | omit_keys( 78 | "id", "tags", get_instance_data(startup) 79 | ), 80 | ) 81 | tag_urls = [ 82 | reverse( 83 | "api-tag-detail", slug=tag.slug, full=True 84 | ) 85 | for tag in tag_list 86 | ] 87 | self.assertCountEqual( 88 | s_startup.data["tags"], tag_urls 89 | ) 90 | self.assertEqual(s_startup.data["url"], startup_url) 91 | 92 | def test_deserialization(self): 93 | """Can we deserialize data to a Startup model?""" 94 | startup_data = get_instance_data( 95 | StartupFactory.build() 96 | ) 97 | tag_urls = [ 98 | reverse("api-tag-detail", slug=tag.slug) 99 | for tag in TagFactory.build_batch(3) 100 | + TagFactory.create_batch(2) 101 | ] 102 | data = dict(startup_data, tags=tag_urls) 103 | s_startup = StartupSerializer( 104 | data=data, **context_kwarg("/api/v1/startup/") 105 | ) 106 | self.assertTrue( 107 | s_startup.is_valid(), msg=s_startup.errors 108 | ) 109 | self.assertEqual( 110 | Startup.objects.count(), 111 | 0, 112 | "Unexpected initial condition", 113 | ) 114 | self.assertEqual( 115 | Tag.objects.count(), 116 | 2, 117 | "Unexpected initial condition", 118 | ) 119 | startup = s_startup.save() 120 | self.assertEqual( 121 | Startup.objects.count(), 122 | 1, 123 | "Serialized Startup not saved", 124 | ) 125 | self.assertCountEqual( 126 | startup.tags.values_list("slug", flat=True), 127 | [], 128 | "Startup had tags associated with it", 129 | ) 130 | self.assertEqual( 131 | Tag.objects.count(), 2, "Serialized Tags saved" 132 | ) 133 | 134 | def test_invalid_deserialization(self): 135 | """Does the serializer validate data?""" 136 | s_startup = StartupSerializer( 137 | data={}, **context_kwarg("/api/v1/startup/") 138 | ) 139 | self.assertFalse(s_startup.is_valid()) 140 | 141 | 142 | class NewsLinkSerializerTests(TestCase): 143 | """Test Serialization of NewsLinks in NewsLinkSerializer""" 144 | 145 | def test_serialization(self): 146 | """Does an existing NewsLink serialize correctly?""" 147 | nl = NewsLinkFactory() 148 | nl_url = f"/api/v1/newslink/{nl.slug}" 149 | s_nl = NewsLinkSerializer( 150 | nl, **context_kwarg(nl_url) 151 | ) 152 | self.assertNotIn("id", s_nl.data) 153 | self.assertIn("url", s_nl.data) 154 | self.assertEqual( 155 | omit_keys("url", "startup", s_nl.data), 156 | omit_keys( 157 | "id", "startup", get_instance_data(nl) 158 | ), 159 | ) 160 | self.assertEqual( 161 | self.client.get(s_nl.data["url"]).status_code, 162 | 200, 163 | ) 164 | self.assertEqual( 165 | self.client.get( 166 | s_nl.data["startup"] 167 | ).status_code, 168 | 200, 169 | ) 170 | 171 | def test_deserialization(self): 172 | """Can we deserialize data to a NewsLink model?""" 173 | startup_url = reverse( 174 | "api-startup-detail", 175 | slug=StartupFactory().slug, 176 | full=True, 177 | ) 178 | nl_data = omit_keys( 179 | "startup", 180 | get_instance_data(NewsLinkFactory.build()), 181 | ) 182 | data = dict(**nl_data, startup=startup_url) 183 | s_nl = NewsLinkSerializer( 184 | data=data, **context_kwarg("/api/v1/newslink/") 185 | ) 186 | self.assertTrue(s_nl.is_valid(), msg=s_nl.errors) 187 | 188 | def test_invalid_deserialization(self): 189 | """Does the serializer validate data?""" 190 | s_nl = NewsLinkSerializer( 191 | data={}, **context_kwarg("/api/v1/newslink/") 192 | ) 193 | self.assertFalse(s_nl.is_valid()) 194 | -------------------------------------------------------------------------------- /src/organizer/urls.py: -------------------------------------------------------------------------------- 1 | """URL paths for Organizer App""" 2 | from django.urls import path 3 | 4 | from .views import ( 5 | NewsLinkCreate, 6 | NewsLinkDelete, 7 | NewsLinkDetail, 8 | NewsLinkUpdate, 9 | StartupCreate, 10 | StartupDelete, 11 | StartupDetail, 12 | StartupList, 13 | StartupUpdate, 14 | TagCreate, 15 | TagDelete, 16 | TagDetail, 17 | TagList, 18 | TagUpdate, 19 | ) 20 | 21 | urlpatterns = [ 22 | path( 23 | "startup/", 24 | StartupList.as_view(), 25 | name="startup_list", 26 | ), 27 | path( 28 | "startup/create/", 29 | StartupCreate.as_view(), 30 | name="startup_create", 31 | ), 32 | path( 33 | "startup//", 34 | StartupDetail.as_view(), 35 | name="startup_detail", 36 | ), 37 | path( 38 | "startup//delete/", 39 | StartupDelete.as_view(), 40 | name="startup_delete", 41 | ), 42 | path( 43 | "startup//update/", 44 | StartupUpdate.as_view(), 45 | name="startup_update", 46 | ), 47 | path( 48 | "startup//add_article/", 49 | NewsLinkCreate.as_view(), 50 | name="newslink_create", 51 | ), 52 | path( 53 | "startup///", 54 | NewsLinkDetail.as_view(), 55 | name="newslink_detail", 56 | ), 57 | path( 58 | "startup///" 59 | "delete/", 60 | NewsLinkDelete.as_view(), 61 | name="newslink_delete", 62 | ), 63 | path( 64 | "startup///" 65 | "update/", 66 | NewsLinkUpdate.as_view(), 67 | name="newslink_update", 68 | ), 69 | path("tag/", TagList.as_view(), name="tag_list"), 70 | path( 71 | "tag/create/", 72 | TagCreate.as_view(), 73 | name="tag_create", 74 | ), 75 | path( 76 | "tag//", 77 | TagDetail.as_view(), 78 | name="tag_detail", 79 | ), 80 | path( 81 | "tag//update/", 82 | TagUpdate.as_view(), 83 | name="tag_update", 84 | ), 85 | path( 86 | "tag//delete/", 87 | TagDelete.as_view(), 88 | name="tag_delete", 89 | ), 90 | ] 91 | -------------------------------------------------------------------------------- /src/organizer/view_mixins.py: -------------------------------------------------------------------------------- 1 | """Mix-in classes for Organizer Views""" 2 | from django.core.exceptions import SuspiciousOperation 3 | from django.shortcuts import get_object_or_404 4 | 5 | from .models import NewsLink, Startup 6 | 7 | 8 | class NewsLinkContextMixin: 9 | """Add Startup to template context in NewsLink views""" 10 | 11 | def get_context_data(self, **kwargs): 12 | """Dynamically add to template context 13 | 14 | http://ccbv.co.uk/ContextMixin 15 | """ 16 | startup = get_object_or_404( 17 | Startup, slug=self.kwargs.get("startup_slug") 18 | ) 19 | return super().get_context_data( 20 | startup=startup, **kwargs 21 | ) 22 | 23 | 24 | class NewsLinkObjectMixin: 25 | """Django View mix-in to find NewsLinks""" 26 | 27 | model = NewsLink 28 | 29 | def get_object(self, queryset=None): 30 | """Get NewsLink from database 31 | 32 | http://ccbv.co.uk/SingleObjectMixin 33 | """ 34 | if queryset is None: 35 | if hasattr(self, "get_queryset"): 36 | queryset = self.get_queryset() 37 | else: 38 | queryset = self.model.objects.all() 39 | 40 | # Django's View class puts URI kwargs in dictionary 41 | startup_slug = self.kwargs.get("startup_slug") 42 | newslink_slug = self.kwargs.get("newslink_slug") 43 | 44 | if startup_slug is None or newslink_slug is None: 45 | raise AttributeError( 46 | f"View {self.__class__.__name__} must be" 47 | f"called with a slug for a Startup and a" 48 | f"slug for a NewsLink objects." 49 | ) 50 | 51 | return get_object_or_404( 52 | queryset, 53 | startup__slug=startup_slug, 54 | slug=newslink_slug, 55 | ) 56 | 57 | 58 | class VerifyStartupFkToUriMixin: 59 | """Mixin to verify Startup data in NewsLink views 60 | 61 | NewsLink views to create and update specify the Startup 62 | slug in the URI. However, for simplicity when 63 | interacting with the NewsLinkForm, the form also has a 64 | field for the Startup object. This class ensures that 65 | the Startup referred to by the URI and by the 66 | NewsLinkForm field is one and the same. 67 | """ 68 | 69 | def verify_startup_fk_matches_uri(self): 70 | """Raise HTTP 400 if Startup data mismatched""" 71 | startup = get_object_or_404( 72 | Startup, slug=self.kwargs.get("startup_slug") 73 | ) 74 | form_startup_pk = self.request.POST.get("startup") 75 | if str(startup.pk) != form_startup_pk: 76 | raise SuspiciousOperation( 77 | "Startup Form PK and URI do not match" 78 | ) 79 | 80 | def post(self, request, *args, **kwargs): 81 | """Check Startup data before form submission process 82 | 83 | - Raise HTTP 400 if Startup data mismatched 84 | - Hook into Generic Views for rest of work 85 | """ 86 | self.verify_startup_fk_matches_uri() 87 | return super().post(request, *args, **kwargs) 88 | -------------------------------------------------------------------------------- /src/organizer/views.py: -------------------------------------------------------------------------------- 1 | """Views for Organizer App 2 | 3 | http://ccbv.co.uk/projects/Django/2.2/django.contrib.auth.mixins/PermissionRequiredMixin/ 4 | """ 5 | from django.contrib.auth.mixins import ( 6 | PermissionRequiredMixin, 7 | ) 8 | from django.shortcuts import get_object_or_404 9 | from django.urls import reverse_lazy 10 | from django.views.generic import ( 11 | CreateView, 12 | DeleteView, 13 | DetailView, 14 | ListView, 15 | RedirectView, 16 | UpdateView, 17 | ) 18 | 19 | from .forms import NewsLinkForm, StartupForm, TagForm 20 | from .models import NewsLink, Startup, Tag 21 | from .view_mixins import ( 22 | NewsLinkContextMixin, 23 | NewsLinkObjectMixin, 24 | VerifyStartupFkToUriMixin, 25 | ) 26 | 27 | 28 | class NewsLinkCreate( 29 | PermissionRequiredMixin, 30 | VerifyStartupFkToUriMixin, 31 | NewsLinkContextMixin, 32 | CreateView, 33 | ): 34 | """Create a link to an article about a startup""" 35 | 36 | extra_context = {"update": False} 37 | form_class = NewsLinkForm 38 | model = NewsLink 39 | permission_required = "organizer.add_newslink" 40 | template_name = "newslink/form.html" 41 | 42 | def get_initial(self): 43 | """Pre-select Startup in NewsLinkForm""" 44 | startup = get_object_or_404( 45 | Startup, slug=self.kwargs.get("startup_slug") 46 | ) 47 | return dict( 48 | super().get_initial(), startup=startup.pk 49 | ) 50 | 51 | 52 | class NewsLinkDelete( 53 | PermissionRequiredMixin, 54 | NewsLinkObjectMixin, 55 | NewsLinkContextMixin, 56 | DeleteView, 57 | ): 58 | """Delete a link to an article about a startup""" 59 | 60 | permission_required = "organizer.delete_newslink" 61 | template_name = "newslink/confirm_delete.html" 62 | 63 | def get_success_url(self): 64 | """Return the detail page of the Startup parent 65 | 66 | http://ccbv.co.uk/DeletionMixin 67 | """ 68 | startup = get_object_or_404( 69 | Startup, slug=self.kwargs.get("startup_slug") 70 | ) 71 | return startup.get_absolute_url() 72 | 73 | 74 | class NewsLinkDetail(NewsLinkObjectMixin, RedirectView): 75 | """Redirect to Startup Detail page 76 | 77 | http://ccbv.co.uk/RedirectView/ 78 | """ 79 | 80 | def get_redirect_url(self, *args, **kwargs): 81 | """Redirect user to Startup page""" 82 | return self.get_object().get_absolute_url() 83 | 84 | 85 | class NewsLinkUpdate( 86 | PermissionRequiredMixin, 87 | VerifyStartupFkToUriMixin, 88 | NewsLinkObjectMixin, 89 | NewsLinkContextMixin, 90 | UpdateView, 91 | ): 92 | """Update a link to an article about a startup""" 93 | 94 | extra_context = {"update": True} 95 | form_class = NewsLinkForm 96 | permission_required = "organizer.change_newslink" 97 | template_name = "newslink/form.html" 98 | 99 | 100 | class TagList(ListView): 101 | """Display a list of Tags""" 102 | 103 | paginate_by = 3 # 3 items per page 104 | queryset = Tag.objects.all() 105 | template_name = "tag/list.html" 106 | 107 | 108 | class TagDetail(DetailView): 109 | """Display a single Tag""" 110 | 111 | queryset = Tag.objects.all() 112 | template_name = "tag/detail.html" 113 | 114 | 115 | class TagCreate(PermissionRequiredMixin, CreateView): 116 | """Create new Tags via HTML form""" 117 | 118 | form_class = TagForm 119 | model = Tag 120 | permission_required = "organizer.add_tag" 121 | template_name = "tag/form.html" 122 | extra_context = {"update": False} 123 | 124 | 125 | class TagUpdate(PermissionRequiredMixin, UpdateView): 126 | """Update a Tag via HTML form""" 127 | 128 | form_class = TagForm 129 | model = Tag 130 | permission_required = "organizer.change_tag" 131 | template_name = "tag/form.html" 132 | extra_context = {"update": True} 133 | 134 | 135 | class TagDelete(PermissionRequiredMixin, DeleteView): 136 | """Confirm and delete a Tag via HTML Form""" 137 | 138 | model = Tag 139 | permission_required = "organizer.delete_tag" 140 | template_name = "tag/confirm_delete.html" 141 | success_url = reverse_lazy("tag_list") 142 | 143 | 144 | class StartupCreate(PermissionRequiredMixin, CreateView): 145 | """Create new Startups via HTML form""" 146 | 147 | form_class = StartupForm 148 | model = Startup 149 | permission_required = "organizer.add_startup" 150 | template_name = "startup/form.html" 151 | extra_context = {"update": False} 152 | 153 | 154 | class StartupDelete(PermissionRequiredMixin, DeleteView): 155 | """Confirm and delete a Startup via HTML Form""" 156 | 157 | model = Startup 158 | permission_required = "organizer.delete_startup" 159 | template_name = "startup/confirm_delete.html" 160 | success_url = reverse_lazy("startup_list") 161 | 162 | 163 | class StartupList(ListView): 164 | """Display a list of Startups""" 165 | 166 | queryset = Startup.objects.all() 167 | template_name = "startup/list.html" 168 | 169 | 170 | class StartupDetail(DetailView): 171 | """Display a single Startup""" 172 | 173 | queryset = Startup.objects.all() 174 | template_name = "startup/detail.html" 175 | 176 | 177 | class StartupUpdate(PermissionRequiredMixin, UpdateView): 178 | """Update a Startup via HTML form""" 179 | 180 | form_class = StartupForm 181 | model = Startup 182 | permission_required = "organizer.change_startup" 183 | template_name = "startup/form.html" 184 | extra_context = {"update": True} 185 | -------------------------------------------------------------------------------- /src/organizer/viewsets.py: -------------------------------------------------------------------------------- 1 | """Viewsets for the Organizer App""" 2 | from django.shortcuts import get_object_or_404 3 | from rest_framework.decorators import action 4 | from rest_framework.pagination import PageNumberPagination 5 | from rest_framework.response import Response 6 | from rest_framework.status import ( 7 | HTTP_204_NO_CONTENT, 8 | HTTP_400_BAD_REQUEST, 9 | ) 10 | from rest_framework.viewsets import ModelViewSet 11 | 12 | from .models import NewsLink, Startup, Tag 13 | from .serializers import ( 14 | NewsLinkSerializer, 15 | StartupSerializer, 16 | TagSerializer, 17 | ) 18 | 19 | 20 | class TagViewSet(ModelViewSet): 21 | """A set of views for the Tag model""" 22 | 23 | lookup_field = "slug" 24 | pagination_class = PageNumberPagination 25 | queryset = Tag.objects.all() 26 | required_scopes = ["tag"] 27 | serializer_class = TagSerializer 28 | 29 | 30 | class StartupViewSet(ModelViewSet): 31 | """A set of views for the Startup model""" 32 | 33 | lookup_field = "slug" 34 | queryset = Startup.objects.all() 35 | required_scopes = ["startup"] 36 | serializer_class = StartupSerializer 37 | 38 | @action(detail=True, methods=["HEAD", "GET", "POST"]) 39 | def tags(self, request, slug=None): 40 | """Relate a POSTed Tag to Startup in URI""" 41 | startup = self.get_object() 42 | if request.method in ("HEAD", "GET"): 43 | s_tag = TagSerializer( 44 | startup.tags, 45 | many=True, 46 | context={"request": request}, 47 | ) 48 | return Response(s_tag.data) 49 | tag_slug = request.data.get("slug") 50 | if not tag_slug: 51 | return Response( 52 | "Slug of Tag must be specified", 53 | status=HTTP_400_BAD_REQUEST, 54 | ) 55 | tag = get_object_or_404(Tag, slug__iexact=tag_slug) 56 | startup.tags.add(tag) 57 | return Response(status=HTTP_204_NO_CONTENT) 58 | 59 | 60 | class NewsLinkViewSet(ModelViewSet): 61 | """A set of views for the Startup model""" 62 | 63 | queryset = NewsLink.objects.all() 64 | required_scopes = ["newslink"] 65 | serializer_class = NewsLinkSerializer 66 | 67 | def get_object(self): 68 | """Override DRF's generic method 69 | 70 | http://www.cdrf.co/3.7/rest_framework.viewsets/ModelViewSet.html#get_object 71 | """ 72 | startup_slug = self.kwargs.get("startup_slug") 73 | newslink_slug = self.kwargs.get("newslink_slug") 74 | 75 | queryset = self.filter_queryset(self.get_queryset()) 76 | 77 | newslink = get_object_or_404( 78 | queryset, 79 | slug=newslink_slug, 80 | startup__slug=startup_slug, 81 | ) 82 | self.check_object_permissions( 83 | self.request, newslink 84 | ) 85 | return newslink 86 | -------------------------------------------------------------------------------- /src/static_content/css/style.css: -------------------------------------------------------------------------------- 1 | .desktop { 2 | display: none; 3 | } 4 | 5 | @media (min-width: 550px) { 6 | .mobile { 7 | display: none; 8 | } 9 | .desktop { 10 | display: block; 11 | } 12 | } 13 | 14 | .center { 15 | text-align: center; 16 | } 17 | 18 | .errorlist { 19 | color: #dd1144; 20 | } 21 | 22 | .helptext { 23 | display: block; 24 | padding: 0; 25 | margin: -1em 0 2em; 26 | color: #555; 27 | } 28 | 29 | html { 30 | height: 100%; 31 | background-color: #555; 32 | } 33 | 34 | body { 35 | background-color: white; 36 | } 37 | 38 | body>div>header { 39 | text-align: center;; 40 | } 41 | 42 | header h1 { 43 | display: inline-block; 44 | font-weight: normal; 45 | font-style: normal; 46 | width: 100%; 47 | margin-top: 0; 48 | padding-top: 0; 49 | } 50 | 51 | .logo { 52 | padding-top: 50px; 53 | background-image: url("/static/images/logo.png"); 54 | background-size: 50px; 55 | background-repeat: no-repeat; 56 | background-position: top; 57 | } 58 | 59 | @media (min-width: 750px) { 60 | .logo { 61 | padding-top: 0; 62 | background-size: contain; 63 | background-position: 0% 0%; 64 | } 65 | } 66 | 67 | @media (min-width: 1000px) { 68 | .logo { 69 | background-position: 10% 0%; 70 | } 71 | } 72 | @media (min-width: 1200px) { 73 | .logo { 74 | background-position: 15% 0%; 75 | } 76 | } 77 | 78 | nav { 79 | border-top: 1px solid #eee; 80 | width: 100%; 81 | border-bottom: 1px solid #eee; 82 | margin: 0 0 1em; 83 | padding: 0; 84 | } 85 | 86 | nav ul { 87 | margin: 0; 88 | padding: 1em 0; 89 | list-style-type: none; 90 | text-align: center; 91 | } 92 | 93 | nav ul li { 94 | display: inline; 95 | padding: 0; 96 | margin: 0 0.25em; 97 | font-weight: 600; 98 | text-transform: uppercase; 99 | text-align: center; 100 | font-size: 1rem; 101 | letter-spacing: .10rem; 102 | } 103 | 104 | @media (min-width: 400px) { 105 | nav ul li { 106 | letter-spacing: .15rem; 107 | font-size: 1.2rem; 108 | margin: 0 0.5em; 109 | } 110 | } 111 | 112 | @media (min-width: 550px) { 113 | nav ul li { 114 | margin: 0 0.75em; 115 | } 116 | } 117 | 118 | 119 | nav ul li a { 120 | color: #555; 121 | text-decoration: none; 122 | background-color: transparent; 123 | cursor: pointer; 124 | } 125 | 126 | .messages { 127 | width: 100%; 128 | text-align: center; 129 | list-style: none; 130 | } 131 | 132 | .messages .success { 133 | width: 100%; 134 | color: #FFF; 135 | background-color: #33C3F0; 136 | border-color: #33C3F0; } 137 | } 138 | 139 | .messages .error { 140 | width: 100%; 141 | color: #FFF; 142 | background-color: #dd1144; 143 | border-color: #dd1144; } 144 | } 145 | 146 | article h2 { 147 | margin: 0; 148 | padding: 0; 149 | } 150 | 151 | article h3 { 152 | margin: 0; 153 | padding: 0; 154 | } 155 | 156 | .read-more { 157 | padding: 0; 158 | margin: -2em 0 0; 159 | } 160 | 161 | .inline { 162 | list-style-type: none; 163 | padding: 0; 164 | margin: 0 0 -0.5em; 165 | } 166 | 167 | .inline li { 168 | display: inline; 169 | } 170 | 171 | .latest-posts { 172 | display: none; 173 | } 174 | 175 | @media (min-width: 550px) { 176 | .latest_posts { 177 | display: block; 178 | } 179 | } 180 | 181 | article footer h3 { 182 | display: inline; 183 | font-size: 1.8rem; 184 | line-height: 1.5; 185 | letter-spacing: -.05rem; 186 | } 187 | 188 | article footer ul { 189 | display: inline; 190 | margin: 0; 191 | padding: 1em 0; 192 | list-style-type: none; 193 | } 194 | 195 | article footer li { 196 | display: inline; 197 | padding: 0; 198 | margin: 0 0.5em; 199 | font-weight: 600; 200 | text-transform: uppercase; 201 | font-size: 1rem; 202 | letter-spacing: .10rem; 203 | } 204 | 205 | .pagination ul { 206 | width: 100%; 207 | margin: 0; 208 | padding: 1em 0; 209 | list-style-type: none; 210 | } 211 | 212 | .pagination li { 213 | display: inline-block; 214 | padding: 0; 215 | margin: 0 1em; 216 | font-weight: 600; 217 | text-transform: uppercase; 218 | font-size: 1.2rem; 219 | letter-spacing: .10rem; 220 | } 221 | 222 | .pagination { 223 | margin: 0; 224 | padding: 1em 0; 225 | list-style-type: none; 226 | text-align: center; 227 | 228 | } 229 | 230 | body>footer { 231 | width: 100%; 232 | background-color: #555; 233 | font-size: 1.1rem; 234 | letter-spacing: .05rem; 235 | font-weight: 600; 236 | text-transform: uppercase; 237 | text-align: center; 238 | padding: 1em 0; 239 | margin: 1em 0; 240 | } 241 | 242 | body>footer p { 243 | margin-bottom: 0.5rem; 244 | } 245 | 246 | body>footer ul { 247 | margin: 0; 248 | padding: 0; 249 | } 250 | 251 | body>footer li{ 252 | display: inline; 253 | padding: 0; 254 | margin: 0 1em; 255 | } 256 | 257 | .feed { 258 | margin-left: 3px; 259 | padding: 0 0 0 19px; 260 | background: url("/static/images/rss.png") no-repeat 0 50%; 261 | } 262 | 263 | /* Larger than mobile */ 264 | @media (min-width: 400px) {} 265 | 266 | /* Larger than phablet (also point when grid becomes active) */ 267 | @media (min-width: 550px) {} 268 | 269 | /* Larger than tablet */ 270 | @media (min-width: 750px) {} 271 | 272 | /* Larger than desktop */ 273 | @media (min-width: 1000px) {} 274 | 275 | /* Larger than Desktop HD */ 276 | @media (min-width: 1200px) {} 277 | -------------------------------------------------------------------------------- /src/static_content/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jambonrose/python-web-dev-22-3/6621f4450661b7691771fc04f80bc324c0234f6c/src/static_content/images/logo.png -------------------------------------------------------------------------------- /src/static_content/images/rss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jambonrose/python-web-dev-22-3/6621f4450661b7691771fc04f80bc324c0234f6c/src/static_content/images/rss.png -------------------------------------------------------------------------------- /src/static_root/android-chrome-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jambonrose/python-web-dev-22-3/6621f4450661b7691771fc04f80bc324c0234f6c/src/static_root/android-chrome-72x72.png -------------------------------------------------------------------------------- /src/static_root/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jambonrose/python-web-dev-22-3/6621f4450661b7691771fc04f80bc324c0234f6c/src/static_root/apple-touch-icon.png -------------------------------------------------------------------------------- /src/static_root/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #2d89ef 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/static_root/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jambonrose/python-web-dev-22-3/6621f4450661b7691771fc04f80bc324c0234f6c/src/static_root/favicon-16x16.png -------------------------------------------------------------------------------- /src/static_root/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jambonrose/python-web-dev-22-3/6621f4450661b7691771fc04f80bc324c0234f6c/src/static_root/favicon-32x32.png -------------------------------------------------------------------------------- /src/static_root/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jambonrose/python-web-dev-22-3/6621f4450661b7691771fc04f80bc324c0234f6c/src/static_root/favicon.ico -------------------------------------------------------------------------------- /src/static_root/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jambonrose/python-web-dev-22-3/6621f4450661b7691771fc04f80bc324c0234f6c/src/static_root/mstile-150x150.png -------------------------------------------------------------------------------- /src/static_root/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/static_root/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-72x72.png", 7 | "sizes": "72x72", 8 | "type": "image/png" 9 | } 10 | ], 11 | "theme_color": "#ffffff", 12 | "background_color": "#ffffff", 13 | "display": "standalone" 14 | } 15 | -------------------------------------------------------------------------------- /src/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% block title %}Startup Organizer{% endblock %} 7 | 8 | 10 | 12 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 24 | 27 | 28 | 29 |
30 | 39 | 40 | {% if messages %} 41 |
42 |
43 |
    44 | {% for message in messages %} 45 | {% if message.tags %} 46 |
  • 47 | {% else %} 48 |
  • 49 | {% endif %} 50 | {{ message }}
  • 51 | {% endfor %} 52 |
53 |
54 |
55 | {% endif %} 56 | 57 |
58 |
59 | 72 |
73 |
74 | 75 |
76 |
77 |

Startup Organizer

78 |
79 |
80 | 81 | 89 | 90 |
91 | {% block content %}{% endblock %} 92 |
93 | 94 |
95 | 96 |