├── .circleci └── config.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── Procfile ├── README.md ├── apiary.apib ├── app.json ├── docker-compose.yml ├── dredd.yml ├── manage.py ├── polls ├── __init__.py ├── features.py ├── fixtures │ └── initial_data.json ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── cleanup.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── resource.py ├── settings.py ├── tests.py ├── urls.py ├── views.py └── wsgi.py └── swagger.yaml /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | aliases: 4 | - &pollsapi-image 5 | image: circleci/python:3.7 6 | environment: 7 | DATABASE_URL: postgresql://root@localhost/circle_test?sslmode=disable 8 | PORT: 8000 9 | 10 | - &database-image 11 | image: circleci/postgres:9.6.2 12 | environment: 13 | POSTGRES_USER: root 14 | POSTGRES_DB: circle_test 15 | 16 | - &bootstrap 17 | run: 18 | command: | 19 | pipenv install 20 | pipenv run python manage.py migrate 21 | pipenv run python manage.py loaddata initial_data 22 | 23 | workflows: 24 | version: 2 25 | 26 | pollsapi: 27 | jobs: 28 | - test 29 | - test-dredd 30 | 31 | jobs: 32 | test: 33 | docker: 34 | - <<: *pollsapi-image 35 | - <<: *database-image 36 | 37 | steps: 38 | - checkout 39 | - <<: *bootstrap 40 | - run: 41 | command: | 42 | pipenv run python manage.py test 43 | 44 | test-dredd: 45 | docker: 46 | - <<: *pollsapi-image 47 | image: circleci/python:3.7-node-browsers 48 | - <<: *database-image 49 | 50 | steps: 51 | - checkout 52 | - run: 53 | command: npm install dredd --no-optional 54 | - <<: *bootstrap 55 | - run: 56 | command: | 57 | pipenv run ./node_modules/.bin/dredd 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | venv 25 | *.sqlite 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | 45 | # Translations 46 | *.mo 47 | *.pot 48 | 49 | # Django stuff: 50 | *.log 51 | 52 | # Sphinx documentation 53 | docs/_build/ 54 | 55 | # PyBuilder 56 | target/ 57 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7 2 | 3 | RUN pip install pipenv 4 | WORKDIR /usr/src/app 5 | COPY Pipfile* ./ 6 | RUN pipenv install --system --deploy 7 | 8 | COPY . . 9 | 10 | CMD pipenv run gunicorn --bind :$PORT polls.wsgi --log-file - 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Apiary, LTD 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | dj-database-url = "*" 8 | django-cors-headers = "*" 9 | gunicorn = ">=19.5.0" 10 | Django = "*" 11 | jsonschema = "*" 12 | psycopg2-binary = "*" 13 | python-mimeparse = "*" 14 | 15 | [dev-packages] 16 | 17 | [requires] 18 | python_version = "3.7" 19 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "e4edc40dad5f7426db11cb73407b82b889b71e6ca93a9f92229fad168b66bec4" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.7" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "attrs": { 20 | "hashes": [ 21 | "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", 22 | "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" 23 | ], 24 | "version": "==20.3.0" 25 | }, 26 | "dj-database-url": { 27 | "hashes": [ 28 | "sha256:4aeaeb1f573c74835b0686a2b46b85990571159ffc21aa57ecd4d1e1cb334163", 29 | "sha256:851785365761ebe4994a921b433062309eb882fedd318e1b0fcecc607ed02da9" 30 | ], 31 | "index": "pypi", 32 | "version": "==0.5.0" 33 | }, 34 | "django": { 35 | "hashes": [ 36 | "sha256:2484f115891ab1a0e9ae153602a641fbc15d7894c036d79fb78662c0965d7954", 37 | "sha256:2569f9dc5f8e458a5e988b03d6b7a02bda59b006d6782f4ea0fd590ed7336a64" 38 | ], 39 | "index": "pypi", 40 | "version": "==2.2.20" 41 | }, 42 | "django-cors-headers": { 43 | "hashes": [ 44 | "sha256:84933651fbbde8f2bc084bef2f077b79db1ec1389432f21dd661eaae6b3d6a95", 45 | "sha256:a8b2772582e8025412f4d4b54b617d8b707076ffd53a2b961bd24f10ec207a7c" 46 | ], 47 | "index": "pypi", 48 | "version": "==3.2.0" 49 | }, 50 | "gunicorn": { 51 | "hashes": [ 52 | "sha256:0806b5e8a2eb8ba9ac1be65d7b743ec896fc25f5d6cb16c5e051540157b315bb", 53 | "sha256:ef69dea4814df95e64e3f40b47b7ffedc6911c5009233be9d01cfd0d14aa3f50" 54 | ], 55 | "index": "pypi", 56 | "version": "==20.0.0" 57 | }, 58 | "importlib-metadata": { 59 | "hashes": [ 60 | "sha256:c9db46394197244adf2f0b08ec5bc3cf16757e9590b02af1fca085c16c0d600a", 61 | "sha256:d2d46ef77ffc85cbf7dac7e81dd663fde71c45326131bea8033b9bad42268ebe" 62 | ], 63 | "markers": "python_version < '3.8'", 64 | "version": "==3.10.0" 65 | }, 66 | "jsonschema": { 67 | "hashes": [ 68 | "sha256:4e5b3cf8216f577bee9ce139cbe72eca3ea4f292ec60928ff24758ce626cd163", 69 | "sha256:c8a85b28d377cc7737e46e2d9f2b4f44ee3c0e1deac6bf46ddefc7187d30797a" 70 | ], 71 | "index": "pypi", 72 | "version": "==3.2.0" 73 | }, 74 | "psycopg2-binary": { 75 | "hashes": [ 76 | "sha256:040234f8a4a8dfd692662a8308d78f63f31a97e1c42d2480e5e6810c48966a29", 77 | "sha256:086f7e89ec85a6704db51f68f0dcae432eff9300809723a6e8782c41c2f48e03", 78 | "sha256:18ca813fdb17bc1db73fe61b196b05dd1ca2165b884dd5ec5568877cabf9b039", 79 | "sha256:19dc39616850342a2a6db70559af55b22955f86667b5f652f40c0e99253d9881", 80 | "sha256:2166e770cb98f02ed5ee2b0b569d40db26788e0bf2ec3ae1a0d864ea6f1d8309", 81 | "sha256:3a2522b1d9178575acee4adf8fd9f979f9c0449b00b4164bb63c3475ea6528ed", 82 | "sha256:3aa773580f85a28ffdf6f862e59cb5a3cc7ef6885121f2de3fca8d6ada4dbf3b", 83 | "sha256:3b5deaa3ee7180585a296af33e14c9b18c218d148e735c7accf78130765a47e3", 84 | "sha256:407af6d7e46593415f216c7f56ba087a9a42bd6dc2ecb86028760aa45b802bd7", 85 | "sha256:4c3c09fb674401f630626310bcaf6cd6285daf0d5e4c26d6e55ca26a2734e39b", 86 | "sha256:4c6717962247445b4f9e21c962ea61d2e884fc17df5ddf5e35863b016f8a1f03", 87 | "sha256:50446fae5681fc99f87e505d4e77c9407e683ab60c555ec302f9ac9bffa61103", 88 | "sha256:5057669b6a66aa9ca118a2a860159f0ee3acf837eda937bdd2a64f3431361a2d", 89 | "sha256:5dd90c5438b4f935c9d01fcbad3620253da89d19c1f5fca9158646407ed7df35", 90 | "sha256:659c815b5b8e2a55193ede2795c1e2349b8011497310bb936da7d4745652823b", 91 | "sha256:69b13fdf12878b10dc6003acc8d0abf3ad93e79813fd5f3812497c1c9fb9be49", 92 | "sha256:7a1cb80e35e1ccea3e11a48afe65d38744a0e0bde88795cc56a4d05b6e4f9d70", 93 | "sha256:7e6e3c52e6732c219c07bd97fff6c088f8df4dae3b79752ee3a817e6f32e177e", 94 | "sha256:7f42a8490c4fe854325504ce7a6e4796b207960dabb2cbafe3c3959cb00d1d7e", 95 | "sha256:84156313f258eafff716b2961644a4483a9be44a5d43551d554844d15d4d224e", 96 | "sha256:8578d6b8192e4c805e85f187bc530d0f52ba86c39172e61cd51f68fddd648103", 97 | "sha256:890167d5091279a27e2505ff0e1fb273f8c48c41d35c5b92adbf4af80e6b2ed6", 98 | "sha256:98e10634792ac0e9e7a92a76b4991b44c2325d3e7798270a808407355e7bb0a1", 99 | "sha256:9aadff9032e967865f9778485571e93908d27dab21d0fdfdec0ca779bb6f8ad9", 100 | "sha256:9f24f383a298a0c0f9b3113b982e21751a8ecde6615494a3f1470eb4a9d70e9e", 101 | "sha256:a73021b44813b5c84eda4a3af5826dd72356a900bac9bd9dd1f0f81ee1c22c2f", 102 | "sha256:afd96845e12638d2c44d213d4810a08f4dc4a563f9a98204b7428e567014b1cd", 103 | "sha256:b73ddf033d8cd4cc9dfed6324b1ad2a89ba52c410ef6877998422fcb9c23e3a8", 104 | "sha256:b8f490f5fad1767a1331df1259763b3bad7d7af12a75b950c2843ba319b2415f", 105 | "sha256:dbc5cd56fff1a6152ca59445178652756f4e509f672e49ccdf3d79c1043113a4", 106 | "sha256:eac8a3499754790187bb00574ab980df13e754777d346f85e0ff6df929bcd964", 107 | "sha256:eaed1c65f461a959284649e37b5051224f4db6ebdc84e40b5e65f2986f101a08" 108 | ], 109 | "index": "pypi", 110 | "version": "==2.8.4" 111 | }, 112 | "pyrsistent": { 113 | "hashes": [ 114 | "sha256:2e636185d9eb976a18a8a8e96efce62f2905fea90041958d8cc2a189756ebf3e" 115 | ], 116 | "version": "==0.17.3" 117 | }, 118 | "python-mimeparse": { 119 | "hashes": [ 120 | "sha256:76e4b03d700a641fd7761d3cd4fdbbdcd787eade1ebfac43f877016328334f78", 121 | "sha256:a295f03ff20341491bfe4717a39cd0a8cc9afad619ba44b77e86b0ab8a2b8282" 122 | ], 123 | "index": "pypi", 124 | "version": "==1.6.0" 125 | }, 126 | "pytz": { 127 | "hashes": [ 128 | "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da", 129 | "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798" 130 | ], 131 | "version": "==2021.1" 132 | }, 133 | "six": { 134 | "hashes": [ 135 | "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", 136 | "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" 137 | ], 138 | "version": "==1.15.0" 139 | }, 140 | "sqlparse": { 141 | "hashes": [ 142 | "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0", 143 | "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8" 144 | ], 145 | "version": "==0.4.1" 146 | }, 147 | "typing-extensions": { 148 | "hashes": [ 149 | "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918", 150 | "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c", 151 | "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f" 152 | ], 153 | "markers": "python_version < '3.8'", 154 | "version": "==3.7.4.3" 155 | }, 156 | "zipp": { 157 | "hashes": [ 158 | "sha256:3607921face881ba3e026887d8150cca609d517579abe052ac81fc5aeffdbd76", 159 | "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098" 160 | ], 161 | "version": "==3.4.1" 162 | } 163 | }, 164 | "develop": {} 165 | } 166 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn polls.wsgi --log-file - 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Polls API 2 | 3 | [![Apiary Documentation](https://img.shields.io/badge/Apiary-Documented-blue.svg)](http://docs.pollsapi.apiary.io/) 4 | [![Circle CI Status](https://img.shields.io/circleci/project/apiaryio/polls-api.svg)](https://circleci.com/gh/apiaryio/polls-api/tree/master) 5 | 6 | This is a Python implementation of a Polls API, an API that allows consumers to 7 | view polls and vote in them. Take a look at the 8 | [API Documentation](http://docs.pollsapi.apiary.io/). We've 9 | deployed an instance of this [API](https://polls.apiblueprint.org/) for testing. 10 | 11 | ## Development Environment 12 | 13 | You can configure a development environment with the following: 14 | 15 | **NOTE**: *These steps assume you have Python along with [pipenv](https://docs.pipenv.org/install/) installed.* 16 | 17 | ```bash 18 | $ pipenv install 19 | $ pipenv run python manage.py migrate 20 | ``` 21 | 22 | ### Running the tests 23 | 24 | ```bash 25 | $ python manage.py test 26 | ``` 27 | 28 | ### Running the development server 29 | 30 | ```bash 31 | $ pipenv run python manage.py runserver 32 | ``` 33 | 34 | ### Running dredd 35 | 36 | Providing [dredd](http://dredd.readthedocs.org/en/latest/) has been 37 | installed, you can run the following to run dredd against the Polls API: 38 | 39 | ```bash 40 | $ pipenv run dredd 41 | ``` 42 | 43 | ### Running via docker 44 | 45 | #### Running the development server 46 | 47 | ```bash 48 | $ docker-compose up web 49 | $ open "http://$(docker-machine ip default):8080" 50 | ``` 51 | 52 | #### Running tests in Docker 53 | 54 | ```bash 55 | $ docker-compose run shell python manage.py test 56 | ``` 57 | 58 | ## Deploying 59 | 60 | ### Deploying on Heroku 61 | 62 | Click the button below to automatically set up the Polls API in an app 63 | running on your Heroku account. 64 | 65 | [![Deploy to Heroku](https://www.herokucdn.com/deploy/button.png)](https://heroku.com/deploy?template=https://github.com/apiaryio/polls-api) 66 | 67 | Once you've deployed, you can easily clone the application and alter the 68 | configuration to disable features: 69 | 70 | ```bash 71 | $ heroku clone -a new-app-name 72 | $ heroku config:set POLLS_CAN_VOTE_QUESTION=false 73 | $ heroku config:set POLLS_CAN_CREATE_QUESTION=false 74 | $ heroku config:set POLLS_CAN_DELETE_QUESTION=false 75 | ``` 76 | 77 | ### Deploying on Heroku using Docker 78 | 79 | If you'd like to, you may use Docker on Heroku instead. Refer to the [Heroku 80 | Container Registry and Runtime 81 | Guide](https://devcenter.heroku.com/articles/container-registry-and-runtime#getting-started) 82 | for detailed instructions. Providing you have Docker installed you can follow 83 | the following steps: 84 | 85 | ```bash 86 | $ heroku container:login 87 | $ heroku container:push web 88 | $ heroku container:release web 89 | ``` 90 | 91 | ## License 92 | 93 | polls-api is released under the MIT license. See [LICENSE](LICENSE). 94 | 95 | -------------------------------------------------------------------------------- /apiary.apib: -------------------------------------------------------------------------------- 1 | FORMAT: 1A 2 | HOST: https://polls.apiblueprint.org/ 3 | 4 | # Polls 5 | 6 | Polls is a simple web service that allows consumers to view polls and vote in them. 7 | 8 | # Polls API Root [/] 9 | 10 | This resource does not have any attributes. Instead it offers the initial API affordances in the form of the links in the JSON body. 11 | 12 | It is recommend to follow the “url” link values or Link headers to get to resources instead of constructing your own URLs to keep your client decoupled from implementation details. 13 | 14 | ## Retrieve the Entry Point [GET] 15 | 16 | + Response 200 (application/json) 17 | 18 | { 19 | "questions_url": "/questions" 20 | } 21 | 22 | ## Group Question 23 | 24 | Resource related to questions in the API. 25 | 26 | ## Question [/questions/{question_id}] 27 | 28 | A Question object has the following attributes. 29 | 30 | - question 31 | - published_at 32 | - url 33 | - choices (an array of Choice objects). 34 | 35 | + Parameters 36 | + question_id: 1 (required, number) - ID of the Question in form of an integer 37 | 38 | ### View a question detail [GET] 39 | 40 | + Response 200 (application/json) 41 | 42 | { 43 | "question": "Favourite programming language?", 44 | "published_at": "2014-11-11T08:40:51.620Z", 45 | "url": "/questions/1", 46 | "choices": [ 47 | { 48 | "choice": "Swift", 49 | "url": "/questions/1/choices/1", 50 | "votes": 2048 51 | }, { 52 | "choice": "Python", 53 | "url": "/questions/1/choices/2", 54 | "votes": 1024 55 | }, { 56 | "choice": "Objective-C", 57 | "url": "/questions/1/choices/3", 58 | "votes": 512 59 | }, { 60 | "choice": "Ruby", 61 | "url": "/questions/1/choices/4", 62 | "votes": 256 63 | } 64 | ] 65 | } 66 | 67 | ## Choice [/questions/{question_id}/choices/{choice_id}] 68 | 69 | + Parameters 70 | + question_id: 1 (required, number) - ID of the Question in form of an integer 71 | + choice_id: 1 (required, number) - ID of the Choice in form of an integer 72 | 73 | ### Vote on a Choice [POST] 74 | 75 | This action allows you to vote on a question's choice. 76 | 77 | + Response 201 (application/json) 78 | 79 | { 80 | "url": "/questions/1/choices/1", 81 | "votes": 1, 82 | "choice": "Swift" 83 | } 84 | 85 | 86 | ## Questions collection [/questions{?page}] 87 | 88 | Again, instead of constructing the URLs for the next page. It is **highly** recommended that you follow the `next` link header in the response. 89 | 90 | + Parameters 91 | + page: 1 (optional, number) - The page of questions to return 92 | 93 | ### List all questions [GET] 94 | 95 | + Response 200 (application/json) 96 | 97 | + Headers 98 | 99 | Link: ; rel="next" 100 | 101 | + Body 102 | 103 | [ 104 | { 105 | "question": "Favourite programming language?", 106 | "published_at": "2014-11-11T08:40:51.620Z", 107 | "url": "/questions/1", 108 | "choices": [ 109 | { 110 | "choice": "Swift", 111 | "url": "/questions/1/choices/1", 112 | "votes": 2048 113 | }, { 114 | "choice": "Python", 115 | "url": "/questions/1/choices/2", 116 | "votes": 1024 117 | }, { 118 | "choice": "Objective-C", 119 | "url": "/questions/1/choices/3", 120 | "votes": 512 121 | }, { 122 | "choice": "Ruby", 123 | "url": "/questions/1/choices/4", 124 | "votes": 256 125 | } 126 | ] 127 | } 128 | ] 129 | 130 | ### Create a new question [POST] 131 | 132 | You can create your own question using this action. It takes a JSON dictionary containing a question and a collection of answers in the form of choices. 133 | 134 | - question (string) - The question 135 | - choices (array[string]) - A collection of choices. 136 | 137 | + Request (application/json) 138 | 139 | { 140 | "question": "Favourite programming language?", 141 | "choices": [ 142 | "Swift", 143 | "Python", 144 | "Objective-C", 145 | "Ruby" 146 | ] 147 | } 148 | 149 | + Response 201 (application/json) 150 | 151 | + Headers 152 | 153 | Location: /questions/2 154 | 155 | + Body 156 | 157 | { 158 | "question": "Favourite programming language?", 159 | "published_at": "2014-11-11T08:40:51.620Z", 160 | "url": "/questions/2", 161 | "choices": [ 162 | { 163 | "choice": "Swift", 164 | "url": "/questions/2/choices/1", 165 | "votes": 0 166 | }, { 167 | "choice": "Python", 168 | "url": "/questions/2/choices/2", 169 | "votes": 0 170 | }, { 171 | "choice": "Objective-C", 172 | "url": "/questions/2/choices/3", 173 | "votes": 0 174 | }, { 175 | "choice": "Ruby", 176 | "url": "/questions/2/choices/4", 177 | "votes": 0 178 | } 179 | ] 180 | } 181 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Polls API", 3 | "description": "Polls is a simple web service that allows consumers to view polls and vote in them.", 4 | "repository": "https://github.com/apiaryio/polls-api", 5 | "website": "https://github.com/apiaryio/polls-api", 6 | "keywords": [ 7 | "api", 8 | "python", 9 | "django" 10 | ], 11 | "addons": [ 12 | "heroku-postgresql:hobby-dev" 13 | ], 14 | "scripts": { 15 | "postdeploy": "python manage.py migrate" 16 | }, 17 | "env": { 18 | "POLLS_CAN_CREATE_QUESTION": "true", 19 | "POLLS_CAN_DELETE_QUESTION": "true", 20 | "POLLS_CAN_VOTE_QUESTION": "true", 21 | "DISABLE_COLLECTSTATIC": "1" 22 | }, 23 | "image": "heroku/python" 24 | } 25 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | web: 2 | build: . 3 | command: 'gunicorn polls.wsgi --log-file -' 4 | working_dir: /usr/src/app 5 | environment: 6 | PORT: 8080 7 | DATABASE_URL: 'postgres://postgres:@herokuPostgresql:5432/postgres' 8 | ports: 9 | - '8080:8080' 10 | links: 11 | - herokuPostgresql 12 | shell: 13 | build: . 14 | command: bash 15 | working_dir: /app/user 16 | environment: 17 | PORT: 8080 18 | DATABASE_URL: 'postgres://postgres:@herokuPostgresql:5432/postgres' 19 | ports: 20 | - '8080:8080' 21 | links: 22 | - herokuPostgresql 23 | volumes: 24 | - '.:/app/user' 25 | herokuPostgresql: 26 | image: postgres 27 | 28 | -------------------------------------------------------------------------------- /dredd.yml: -------------------------------------------------------------------------------- 1 | server: env SECURE_SSL_REDIRECT=false python manage.py runserver 2 | server-wait: 3 3 | color: true 4 | path: [] 5 | blueprint: apiary.apib 6 | endpoint: 'http://localhost:8000' 7 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'polls.settings') 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == '__main__': 21 | main() 22 | -------------------------------------------------------------------------------- /polls/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apiaryio/polls-api/5329f23343759eb29c13eb9dfa03e6fd83a33025/polls/__init__.py -------------------------------------------------------------------------------- /polls/features.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from polls.settings import CAN_CREATE_QUESTION, CAN_DELETE_QUESTION, CAN_VOTE_QUESTION 4 | 5 | with open('polls/fixtures/initial_data.json') as fp: 6 | initial_data = json.load(fp) 7 | initial_questions = filter(lambda m: m['model'] == 'polls.question', initial_data) 8 | initial_question_pks = map(lambda m: m['pk'], initial_questions) 9 | 10 | 11 | def is_feature_enabled(feature_key, request, default=False): 12 | return default 13 | 14 | 15 | def can_create_question(request): 16 | return is_feature_enabled('question.create', request, CAN_CREATE_QUESTION) 17 | 18 | 19 | def can_delete_question(question, request): 20 | if question.pk in initial_question_pks: 21 | return False 22 | 23 | return is_feature_enabled('question.delete', request, CAN_DELETE_QUESTION) 24 | 25 | 26 | def can_vote_choice(request): 27 | return is_feature_enabled('choice.vote', request, CAN_VOTE_QUESTION) 28 | -------------------------------------------------------------------------------- /polls/fixtures/initial_data.json: -------------------------------------------------------------------------------- 1 | [{"fields": {"published_at": "2015-05-27T21:22:26.431Z", "question_text": "Favourite programming language?"}, "model": "polls.question", "pk": 1}, {"fields": {"published_at": "2015-05-27T21:22:26.457Z", "question_text": "Who is the best Avenger?"}, "model": "polls.question", "pk": 2}, {"fields": {"published_at": "2015-05-27T21:22:26.486Z", "question_text": "Favourite tea type?"}, "model": "polls.question", "pk": 3}, {"fields": {"published_at": "2015-05-27T21:22:26.512Z", "question_text": "Best fruit?"}, "model": "polls.question", "pk": 4}, {"fields": {"published_at": "2015-05-27T21:22:26.557Z", "question_text": "Console of choice?"}, "model": "polls.question", "pk": 5}, {"fields": {"published_at": "2015-05-27T21:22:26.576Z", "question_text": "Favourite colour?"}, "model": "polls.question", "pk": 6}, {"fields": {"published_at": "2015-05-27T21:22:26.601Z", "question_text": "Bacon?"}, "model": "polls.question", "pk": 7}, {"fields": {"published_at": "2015-05-27T21:22:26.619Z", "question_text": "Transport of choice?"}, "model": "polls.question", "pk": 8}, {"fields": {"published_at": "2015-05-27T21:22:26.648Z", "question_text": "Favourite hot beverage?"}, "model": "polls.question", "pk": 9}, {"fields": {"published_at": "2015-05-27T21:22:26.670Z", "question_text": "Game Genre"}, "model": "polls.question", "pk": 10}, {"fields": {"choice_text": "Swift", "question": 1}, "model": "polls.choice", "pk": 1}, {"fields": {"choice_text": "Python", "question": 1}, "model": "polls.choice", "pk": 2}, {"fields": {"choice_text": "Objective-C", "question": 1}, "model": "polls.choice", "pk": 3}, {"fields": {"choice_text": "Ruby", "question": 1}, "model": "polls.choice", "pk": 4}, {"fields": {"choice_text": "C", "question": 1}, "model": "polls.choice", "pk": 5}, {"fields": {"choice_text": "C++", "question": 1}, "model": "polls.choice", "pk": 6}, {"fields": {"choice_text": "JavaScript", "question": 1}, "model": "polls.choice", "pk": 7}, {"fields": {"choice_text": "Iron Man", "question": 2}, "model": "polls.choice", "pk": 8}, {"fields": {"choice_text": "Thor", "question": 2}, "model": "polls.choice", "pk": 9}, {"fields": {"choice_text": "Hulk", "question": 2}, "model": "polls.choice", "pk": 10}, {"fields": {"choice_text": "Captain America", "question": 2}, "model": "polls.choice", "pk": 11}, {"fields": {"choice_text": "Black Widow", "question": 2}, "model": "polls.choice", "pk": 12}, {"fields": {"choice_text": "Hawkeye", "question": 2}, "model": "polls.choice", "pk": 13}, {"fields": {"choice_text": "Vision", "question": 2}, "model": "polls.choice", "pk": 14}, {"fields": {"choice_text": "War Machine", "question": 2}, "model": "polls.choice", "pk": 15}, {"fields": {"choice_text": "Scarlet Witch", "question": 2}, "model": "polls.choice", "pk": 16}, {"fields": {"choice_text": "Black Tea", "question": 3}, "model": "polls.choice", "pk": 17}, {"fields": {"choice_text": "Green Tea", "question": 3}, "model": "polls.choice", "pk": 18}, {"fields": {"choice_text": "Oolong Tea", "question": 3}, "model": "polls.choice", "pk": 19}, {"fields": {"choice_text": "Matcha", "question": 3}, "model": "polls.choice", "pk": 20}, {"fields": {"choice_text": "White Tea", "question": 3}, "model": "polls.choice", "pk": 21}, {"fields": {"choice_text": "Pu-erh", "question": 3}, "model": "polls.choice", "pk": 22}, {"fields": {"choice_text": "Herbal", "question": 3}, "model": "polls.choice", "pk": 23}, {"fields": {"choice_text": "\ud83c\udf45", "question": 4}, "model": "polls.choice", "pk": 24}, {"fields": {"choice_text": "\ud83c\udf48", "question": 4}, "model": "polls.choice", "pk": 25}, {"fields": {"choice_text": "\ud83c\udf4d", "question": 4}, "model": "polls.choice", "pk": 26}, {"fields": {"choice_text": "\ud83c\udf52", "question": 4}, "model": "polls.choice", "pk": 27}, {"fields": {"choice_text": "\ud83c\udf46", "question": 4}, "model": "polls.choice", "pk": 28}, {"fields": {"choice_text": "\ud83c\udf49", "question": 4}, "model": "polls.choice", "pk": 29}, {"fields": {"choice_text": "\ud83c\udf4e", "question": 4}, "model": "polls.choice", "pk": 30}, {"fields": {"choice_text": "\ud83c\udf53", "question": 4}, "model": "polls.choice", "pk": 31}, {"fields": {"choice_text": "\ud83c\udf3d", "question": 4}, "model": "polls.choice", "pk": 32}, {"fields": {"choice_text": "\ud83c\udf4a", "question": 4}, "model": "polls.choice", "pk": 33}, {"fields": {"choice_text": "\ud83c\udf4f", "question": 4}, "model": "polls.choice", "pk": 34}, {"fields": {"choice_text": "\ud83c\udf60", "question": 4}, "model": "polls.choice", "pk": 35}, {"fields": {"choice_text": "\ud83c\udf4b", "question": 4}, "model": "polls.choice", "pk": 36}, {"fields": {"choice_text": "\ud83c\udf50", "question": 4}, "model": "polls.choice", "pk": 37}, {"fields": {"choice_text": "\ud83c\udf47", "question": 4}, "model": "polls.choice", "pk": 38}, {"fields": {"choice_text": "\ud83c\udf4c", "question": 4}, "model": "polls.choice", "pk": 39}, {"fields": {"choice_text": "\ud83c\udf51", "question": 4}, "model": "polls.choice", "pk": 40}, {"fields": {"choice_text": "\ud83c\udf53", "question": 4}, "model": "polls.choice", "pk": 41}, {"fields": {"choice_text": "\ud83c\udf52", "question": 4}, "model": "polls.choice", "pk": 42}, {"fields": {"choice_text": "PlayStation 4", "question": 5}, "model": "polls.choice", "pk": 43}, {"fields": {"choice_text": "Wii U", "question": 5}, "model": "polls.choice", "pk": 44}, {"fields": {"choice_text": "Xbox One", "question": 5}, "model": "polls.choice", "pk": 45}, {"fields": {"choice_text": "Red", "question": 6}, "model": "polls.choice", "pk": 46}, {"fields": {"choice_text": "Orange", "question": 6}, "model": "polls.choice", "pk": 47}, {"fields": {"choice_text": "Yellow", "question": 6}, "model": "polls.choice", "pk": 48}, {"fields": {"choice_text": "Green", "question": 6}, "model": "polls.choice", "pk": 49}, {"fields": {"choice_text": "Cyan", "question": 6}, "model": "polls.choice", "pk": 50}, {"fields": {"choice_text": "Blue", "question": 6}, "model": "polls.choice", "pk": 51}, {"fields": {"choice_text": "Violet", "question": 6}, "model": "polls.choice", "pk": 52}, {"fields": {"choice_text": "\ud83c\uddec\ud83c\udde7", "question": 7}, "model": "polls.choice", "pk": 53}, {"fields": {"choice_text": "\ud83c\uddfa\ud83c\uddf8", "question": 7}, "model": "polls.choice", "pk": 54}, {"fields": {"choice_text": "\ud83c\udde8\ud83c\udde6", "question": 7}, "model": "polls.choice", "pk": 55}, {"fields": {"choice_text": "\u2708\ufe0f", "question": 8}, "model": "polls.choice", "pk": 56}, {"fields": {"choice_text": "\ud83d\ude81", "question": 8}, "model": "polls.choice", "pk": 57}, {"fields": {"choice_text": "\ud83d\ude80", "question": 8}, "model": "polls.choice", "pk": 58}, {"fields": {"choice_text": "\ud83d\ude97", "question": 8}, "model": "polls.choice", "pk": 59}, {"fields": {"choice_text": "\ud83d\ude8e", "question": 8}, "model": "polls.choice", "pk": 60}, {"fields": {"choice_text": "\ud83d\ude88", "question": 8}, "model": "polls.choice", "pk": 61}, {"fields": {"choice_text": "\ud83d\ude83", "question": 8}, "model": "polls.choice", "pk": 62}, {"fields": {"choice_text": "\u26f5\ufe0f", "question": 8}, "model": "polls.choice", "pk": 63}, {"fields": {"choice_text": "\ud83d\udea0", "question": 8}, "model": "polls.choice", "pk": 64}, {"fields": {"choice_text": "Tea", "question": 9}, "model": "polls.choice", "pk": 65}, {"fields": {"choice_text": "Coffee", "question": 9}, "model": "polls.choice", "pk": 66}, {"fields": {"choice_text": "Apple Cider", "question": 9}, "model": "polls.choice", "pk": 67}, {"fields": {"choice_text": "Hot Chocolate", "question": 9}, "model": "polls.choice", "pk": 68}, {"fields": {"choice_text": "Action", "question": 10}, "model": "polls.choice", "pk": 69}, {"fields": {"choice_text": "Shooter", "question": 10}, "model": "polls.choice", "pk": 70}, {"fields": {"choice_text": "Action-adventure", "question": 10}, "model": "polls.choice", "pk": 71}, {"fields": {"choice_text": "Role-playing", "question": 10}, "model": "polls.choice", "pk": 72}, {"fields": {"choice_text": "Simulation", "question": 10}, "model": "polls.choice", "pk": 73}, {"fields": {"choice_text": "Strategy", "question": 10}, "model": "polls.choice", "pk": 74}, {"fields": {"choice_text": "Sports", "question": 10}, "model": "polls.choice", "pk": 75}] -------------------------------------------------------------------------------- /polls/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apiaryio/polls-api/5329f23343759eb29c13eb9dfa03e6fd83a33025/polls/management/__init__.py -------------------------------------------------------------------------------- /polls/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apiaryio/polls-api/5329f23343759eb29c13eb9dfa03e6fd83a33025/polls/management/commands/__init__.py -------------------------------------------------------------------------------- /polls/management/commands/cleanup.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import timedelta 3 | 4 | from django.core.management.base import BaseCommand 5 | from django.utils import timezone 6 | 7 | from polls.models import Question, Vote 8 | 9 | 10 | class Command(BaseCommand): 11 | help = 'Removes questions older than an hour except initial data' 12 | 13 | def handle(self, *args, **kwargs): 14 | with open('polls/fixtures/initial_data.json') as fp: 15 | initial_data = json.load(fp) 16 | 17 | initial_questions = filter( 18 | lambda m: m['model'] == 'polls.question', initial_data 19 | ) 20 | initial_question_pks = map(lambda m: m['pk'], initial_questions) 21 | one_hour_ago = timezone.now() - timedelta(hours=1) 22 | qs = Question.objects.exclude(id__in=initial_question_pks).filter( 23 | published_at__lt=one_hour_ago 24 | ) 25 | 26 | print('Deleting {} questions'.format(qs.count())) 27 | qs.delete() 28 | 29 | qs = Vote.objects.all() 30 | 31 | print('Deleting {} votes'.format(qs.count())) 32 | qs.delete() 33 | -------------------------------------------------------------------------------- /polls/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.6 on 2016-05-24 23:23 3 | from __future__ import unicode_literals 4 | 5 | import django.db.models.deletion 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Choice', 18 | fields=[ 19 | ( 20 | 'id', 21 | models.AutoField( 22 | auto_created=True, 23 | primary_key=True, 24 | serialize=False, 25 | verbose_name='ID', 26 | ), 27 | ), 28 | ('choice_text', models.CharField(max_length=140)), 29 | ], 30 | ), 31 | migrations.CreateModel( 32 | name='Question', 33 | fields=[ 34 | ( 35 | 'id', 36 | models.AutoField( 37 | auto_created=True, 38 | primary_key=True, 39 | serialize=False, 40 | verbose_name='ID', 41 | ), 42 | ), 43 | ('question_text', models.CharField(max_length=140)), 44 | ('published_at', models.DateTimeField(auto_now_add=True)), 45 | ], 46 | options={ 47 | 'ordering': ('-published_at',), 48 | 'get_latest_by': 'published_at', 49 | }, 50 | ), 51 | migrations.CreateModel( 52 | name='Vote', 53 | fields=[ 54 | ( 55 | 'id', 56 | models.AutoField( 57 | auto_created=True, 58 | primary_key=True, 59 | serialize=False, 60 | verbose_name='ID', 61 | ), 62 | ), 63 | ( 64 | 'choice', 65 | models.ForeignKey( 66 | on_delete=django.db.models.deletion.CASCADE, 67 | related_name='votes', 68 | to='polls.Choice', 69 | ), 70 | ), 71 | ], 72 | ), 73 | migrations.AddField( 74 | model_name='choice', 75 | name='question', 76 | field=models.ForeignKey( 77 | on_delete=django.db.models.deletion.CASCADE, 78 | related_name='choices', 79 | to='polls.Question', 80 | ), 81 | ), 82 | ] 83 | -------------------------------------------------------------------------------- /polls/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apiaryio/polls-api/5329f23343759eb29c13eb9dfa03e6fd83a33025/polls/migrations/__init__.py -------------------------------------------------------------------------------- /polls/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Question(models.Model): 5 | question_text = models.CharField(max_length=140) 6 | published_at = models.DateTimeField(auto_now_add=True) 7 | 8 | class Meta: 9 | get_latest_by = 'published_at' 10 | ordering = ('-published_at',) 11 | 12 | def __str__(self): 13 | return self.question_text 14 | 15 | 16 | class Choice(models.Model): 17 | question = models.ForeignKey( 18 | Question, on_delete=models.CASCADE, related_name='choices' 19 | ) 20 | choice_text = models.CharField(max_length=140) 21 | 22 | def __str__(self): 23 | return self.choice_text 24 | 25 | def vote(self): 26 | """ 27 | Create a vote on this choice. 28 | """ 29 | return Vote.objects.create(choice=self) 30 | 31 | 32 | class Vote(models.Model): 33 | choice = models.ForeignKey(Choice, on_delete=models.CASCADE, related_name='votes') 34 | -------------------------------------------------------------------------------- /polls/resource.py: -------------------------------------------------------------------------------- 1 | import json 2 | from collections import namedtuple 3 | 4 | from django.core.paginator import EmptyPage, Paginator 5 | from django.http import Http404, HttpResponse 6 | from django.utils.cache import patch_cache_control, patch_vary_headers 7 | from django.views.generic import View 8 | from mimeparse import MimeTypeParseException, best_match 9 | 10 | Attribute = namedtuple('Attribute', ('name', 'category')) 11 | Action = namedtuple('Action', ('method', 'attributes')) 12 | 13 | 14 | class SingleObjectMixin(object): 15 | model = None 16 | 17 | def get_object(self): 18 | if not getattr(self, 'obj', None): 19 | self.obj = self.model.objects.get(pk=self.kwargs['pk']) 20 | 21 | return self.obj 22 | 23 | 24 | class Resource(View): 25 | uri = None 26 | cache_max_age = None 27 | 28 | def get_attributes(self): 29 | return {} 30 | 31 | def get_relations(self): 32 | """ 33 | Returns a dictionary of relations, key must be a string and the value 34 | should be another resource or a list of resources. 35 | """ 36 | return {} 37 | 38 | def can_embed(self, relation): 39 | """ 40 | When this method returns `True` for a given relation, we will embed it in 41 | the response when applicable. 42 | """ 43 | return True 44 | 45 | def get_actions(self): 46 | """ 47 | Returns a dictionary of actions 48 | """ 49 | return {} 50 | 51 | def get_uri(self): 52 | return self.uri 53 | 54 | def content_handlers(self): 55 | return { 56 | 'application/json': to_json, 57 | 'application/hal+json': to_hal, 58 | 'application/vnd.hal+json': to_hal, 59 | 'application/vnd.siren+json': to_siren, 60 | } 61 | 62 | def get(self, request, *args, **kwargs): 63 | content_type = self.determine_content_type(request) 64 | handlers = self.content_handlers() 65 | handler = handlers[str(content_type)] 66 | response = HttpResponse(json.dumps(handler(self)), content_type) 67 | patch_vary_headers(response, ['Accept']) 68 | if self.cache_max_age is not None: 69 | patch_cache_control(response, max_age=self.cache_max_age) 70 | 71 | if str(content_type) == 'application/json': 72 | # Add a Link header 73 | can_embed_relation = lambda relation: not self.can_embed(relation[0]) 74 | relations = filter(can_embed_relation, self.get_relations().items()) 75 | relation_to_link = lambda relation: '<{}>; rel="{}"'.format( 76 | relation[1].get_uri(), relation[0] 77 | ) 78 | links = list(map(relation_to_link, relations)) 79 | if len(links) > 0: 80 | response['Link'] = ', '.join(links) 81 | 82 | if str(content_type) != 'application/vnd.siren+json': 83 | # Add an Allow header 84 | methods = ['HEAD', 'GET'] + list( 85 | map(lambda a: a.method, self.get_actions().values()) 86 | ) 87 | response['allow'] = ', '.join(methods) 88 | 89 | return response 90 | 91 | def determine_content_type(self, request): 92 | content_types = [ 93 | 'application/vnd.siren+json', 94 | 'application/vnd.hal+json', 95 | 'application/hal+json', 96 | 'application/json', 97 | ] 98 | 99 | accept = request.META.get('HTTP_ACCEPT', '*/*') 100 | 101 | try: 102 | content_type = best_match(content_types, accept) 103 | except MimeTypeParseException: 104 | content_type = None 105 | 106 | if not content_type: 107 | return content_types[-1] 108 | 109 | return content_type 110 | 111 | 112 | class CollectionResource(Resource): 113 | model = None 114 | resource = None # A resource class that inherits from SingleObjectMixin 115 | relation = 'objects' 116 | paginate_by = 20 117 | 118 | def __init__(self, page=None): 119 | self.page = page 120 | super(CollectionResource, self).__init__() 121 | 122 | def get_uri(self): 123 | if self.page is not None: 124 | return '{}?page={}'.format(self.uri, self.page) 125 | return self.uri 126 | 127 | def get_objects(self): 128 | return self.model.objects.all() 129 | 130 | def get_paginator(self): 131 | return Paginator(self.get_objects(), self.paginate_by) 132 | 133 | def get_resources(self, objects): 134 | def to_resource(obj): 135 | resource = self.resource() 136 | resource.obj = obj 137 | resource.request = self.request 138 | return resource 139 | 140 | return list(map(to_resource, objects)) 141 | 142 | def get_relations(self): 143 | paginator = self.get_paginator() 144 | 145 | try: 146 | page_number = int(self.request.GET.get('page', 1)) 147 | except ValueError: 148 | raise Http404() 149 | 150 | try: 151 | page = paginator.page(page_number) 152 | except EmptyPage: 153 | raise Http404() 154 | 155 | relations = {self.relation: self.get_resources(page)} 156 | 157 | relations['first'] = self.__class__() 158 | 159 | if page.has_next(): 160 | relations['next'] = self.__class__(page.next_page_number()) 161 | 162 | if page.has_previous(): 163 | relations['prev'] = self.__class__(page.previous_page_number()) 164 | 165 | if page.has_other_pages(): 166 | relations['last'] = self.__class__(paginator.num_pages) 167 | 168 | return relations 169 | 170 | def content_handlers(self): 171 | """ 172 | Override `content_handlers` to change JSON handler to return arrays 173 | """ 174 | 175 | handlers = super(CollectionResource, self).content_handlers() 176 | handlers['application/json'] = lambda resource: list( 177 | map(to_json, self.get_relations()[self.relation]) 178 | ) 179 | return handlers 180 | 181 | def can_embed(self, relation): 182 | return relation not in ('next', 'prev', 'first', 'last') 183 | 184 | 185 | def to_json(resource): 186 | document = resource.get_attributes() 187 | document['url'] = resource.get_uri() 188 | 189 | for relation, related_resource in resource.get_relations().items(): 190 | if isinstance(related_resource, list): 191 | if resource.can_embed(relation): 192 | document[relation] = list(map(to_json, related_resource)) 193 | else: 194 | document[relation] = list( 195 | map(lambda r: {'url': r.get_uri()}, related_resource) 196 | ) 197 | else: 198 | if resource.can_embed(relation): 199 | document[relation] = to_json(related_resource) 200 | else: 201 | document['{}_url'.format(relation)] = related_resource.get_uri() 202 | 203 | return document 204 | 205 | 206 | def to_hal(resource): 207 | document = resource.get_attributes() 208 | relations = resource.get_relations() 209 | 210 | embed = {} 211 | links = {} 212 | 213 | for relation in relations: 214 | related_resource = relations[relation] 215 | 216 | if resource.can_embed(relation) or isinstance(related_resource, list): 217 | if isinstance(related_resource, list): 218 | embed[relation] = list(map(to_hal, related_resource)) 219 | else: 220 | embed[relation] = to_hal(related_resource) 221 | else: 222 | href = related_resource.get_uri() 223 | links[relation] = {'href': href} 224 | 225 | links['self'] = {'href': resource.get_uri()} 226 | 227 | document['_links'] = links 228 | if len(embed): 229 | document['_embed'] = embed 230 | 231 | return document 232 | 233 | 234 | def to_siren_relation(relation): 235 | def inner(resource): 236 | document = to_siren(resource) 237 | document['rel'] = [relation] 238 | return document 239 | 240 | return inner 241 | 242 | 243 | def to_siren(resource): 244 | def to_siren_link(relation): 245 | def inner(resource): 246 | return {'rel': [relation], 'href': resource.get_uri()} 247 | 248 | return inner 249 | 250 | document = {} 251 | 252 | attributes = resource.get_attributes() 253 | if len(attributes): 254 | document['properties'] = attributes 255 | 256 | links = [] 257 | entities = [] 258 | for relation, related_resource in resource.get_relations().items(): 259 | if resource.can_embed(relation): 260 | if isinstance(related_resource, list): 261 | entities += list(map(to_siren_relation(relation), related_resource)) 262 | else: 263 | entity = to_siren_relation(relation)(related_resource) 264 | entities.append(entity) 265 | else: 266 | if isinstance(related_resource, list): 267 | items = list(map(to_siren_link(relation), related_resource)) 268 | links += items 269 | else: 270 | links.append(to_siren_link(relation)(related_resource)) 271 | 272 | links.append(to_siren_link('self')(resource)) 273 | document['links'] = links 274 | 275 | if len(entities): 276 | document['entities'] = entities 277 | 278 | actions = [] 279 | 280 | for name, action in resource.get_actions().items(): 281 | action_dict = { 282 | 'name': name, 283 | 'method': action.method, 284 | 'href': resource.get_uri(), 285 | 'type': 'application/json', 286 | } 287 | 288 | if action.attributes: 289 | 290 | def to_field(attribute): 291 | return { 292 | 'name': attribute.name, 293 | 'type': attribute.category, 294 | 'title': attribute.name.capitalize(), 295 | } 296 | 297 | action_dict['fields'] = list(map(to_field, action.attributes)) 298 | 299 | actions.append(action_dict) 300 | 301 | if len(actions): 302 | document['actions'] = actions 303 | 304 | return document 305 | -------------------------------------------------------------------------------- /polls/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for polls project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.2.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.2/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | import dj_database_url 16 | 17 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 18 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 19 | 20 | 21 | # Quick-start development settings - unsuitable for production 22 | # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ 23 | 24 | # SECURITY WARNING: keep the secret key used in production secret! 25 | SECRET_KEY = 'm=%-=98*jf5hjfyjui+%5azyzr4z-$3b)q$5#1ys@6#!-#!n&e' 26 | 27 | # SECURITY WARNING: don't run with debug turned on in production! 28 | DEBUG = False 29 | 30 | # Honor the 'X-Forwarded-Proto' header for request.is_secure() 31 | SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') 32 | ALLOWED_HOSTS = ['*'] 33 | 34 | SECURE_CONTENT_TYPE_NOSNIFF = True 35 | SECURE_BROWSER_XSS_FILTER = True 36 | 37 | 38 | # Application definition 39 | 40 | INSTALLED_APPS = [ 41 | 'corsheaders', 42 | 'polls', 43 | ] 44 | 45 | MIDDLEWARE = [ 46 | 'django.middleware.cache.UpdateCacheMiddleware', 47 | 'corsheaders.middleware.CorsMiddleware', 48 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 49 | 'django.middleware.common.CommonMiddleware', 50 | 'django.middleware.cache.FetchFromCacheMiddleware', 51 | 'django.middleware.security.SecurityMiddleware', 52 | ] 53 | 54 | ROOT_URLCONF = 'polls.urls' 55 | 56 | TEMPLATES = [ 57 | { 58 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 59 | 'DIRS': [], 60 | 'APP_DIRS': True, 61 | 'OPTIONS': { 62 | 'context_processors': [ 63 | 'django.template.context_processors.debug', 64 | 'django.template.context_processors.request', 65 | ], 66 | }, 67 | }, 68 | ] 69 | 70 | WSGI_APPLICATION = 'polls.wsgi.application' 71 | 72 | 73 | # Database 74 | # https://docs.djangoproject.com/en/2.2/ref/settings/#databases 75 | 76 | DATABASES = { 77 | 'default': dj_database_url.config(conn_max_age=500), 78 | } 79 | 80 | 81 | # Internationalization 82 | # https://docs.djangoproject.com/en/2.2/topics/i18n/ 83 | 84 | LANGUAGE_CODE = 'en-us' 85 | 86 | TIME_ZONE = 'UTC' 87 | 88 | USE_I18N = True 89 | 90 | USE_L10N = True 91 | 92 | USE_TZ = True 93 | 94 | 95 | # Static files (CSS, JavaScript, Images) 96 | # https://docs.djangoproject.com/en/2.2/howto/static-files/ 97 | 98 | STATIC_URL = '/static/' 99 | 100 | 101 | # Cache 102 | 103 | CACHES = { 104 | 'default': { 105 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 106 | 'LOCATION': 'pollsapi', 107 | } 108 | } 109 | 110 | CACHE_MIDDLEWARE_SECONDS = 10 111 | 112 | 113 | def get_env(key, default=True): 114 | value = os.environ.get(key, default) 115 | return ( 116 | value is True 117 | or value.lower() == 'true' 118 | or value == '1' 119 | or value.lower() == 'yes' 120 | ) 121 | 122 | 123 | # Security Middleware 124 | if not DEBUG: 125 | SECURE_SSL_REDIRECT = get_env('SECURE_SSL_REDIRECT') 126 | 127 | 128 | # Polls API Features 129 | 130 | # Enables the ability to create a question 131 | CAN_CREATE_QUESTION = get_env('POLLS_CAN_CREATE_QUESTION') 132 | 133 | # Enables the ability to delete a question 134 | CAN_DELETE_QUESTION = get_env('POLLS_CAN_DELETE_QUESTION') 135 | 136 | # Enables the ability to vote on a question 137 | CAN_VOTE_QUESTION = get_env('POLLS_CAN_VOTE_QUESTION') 138 | 139 | 140 | X_FRAME_OPTIONS = 'DENY' 141 | 142 | # CORS Headers 143 | 144 | CORS_ORIGIN_ALLOW_ALL = True 145 | -------------------------------------------------------------------------------- /polls/tests.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.http import HttpRequest 4 | from django.test import Client, TestCase 5 | 6 | from polls.models import Choice, Question 7 | from polls.resource import Action, Resource 8 | from polls.views import QuestionResource 9 | 10 | 11 | class ResourceTestCase(TestCase): 12 | def test_json_includes_allow_header(self): 13 | class TestAllowActionResource(Resource): 14 | def get_actions(self): 15 | return {'delete': Action(method='DELETE', attributes=None)} 16 | 17 | request = HttpRequest() 18 | request.META['HTTP_ACCEPT'] = 'application/json' 19 | response = TestAllowActionResource().get(request) 20 | 21 | self.assertEqual(response['Allow'], 'HEAD, GET, DELETE') 22 | 23 | def test_invalid_accept_header(self): 24 | class TestResource(Resource): 25 | pass 26 | 27 | request = HttpRequest() 28 | request.META['HTTP_ACCEPT'] = 'application' 29 | response = TestResource().get(request) 30 | 31 | self.assertEqual(response.status_code, 200) 32 | 33 | 34 | class RootTestCase(TestCase): 35 | def test_supports_cors(self): 36 | client = Client() 37 | response = client.options('/', HTTP_ORIGIN='https://example.com/', secure=True) 38 | 39 | self.assertEqual(response.status_code, 200) 40 | self.assertEqual(response['Access-Control-Allow-Origin'], '*') 41 | 42 | 43 | class QuestionListTestCase(TestCase): 44 | def setUp(self): 45 | self.client = Client() 46 | 47 | def test_unfound_page(self): 48 | response = self.client.get('/questions?page=5', secure=True) 49 | 50 | self.assertEqual(response.status_code, 404) 51 | 52 | def test_non_numeric_page(self): 53 | response = self.client.get('/questions?page=one', secure=True) 54 | 55 | self.assertEqual(response.status_code, 404) 56 | 57 | 58 | class CreateQuestionTestCase(TestCase): 59 | def setUp(self): 60 | self.client = Client() 61 | 62 | def test_creating_question(self): 63 | response = self.client.post( 64 | '/questions', 65 | '{"question": "Test Question?", "choices": ["A", "B", "C"]}', 66 | content_type='application/json', 67 | secure=True, 68 | ) 69 | 70 | self.assertEqual(response.status_code, 201) 71 | 72 | question = Question.objects.latest() 73 | self.assertEqual(question.question_text, 'Test Question?') 74 | 75 | choice_a, choice_b, choice_c = question.choices.order_by('choice_text') 76 | self.assertEqual(choice_a.choice_text, 'A') 77 | self.assertEqual(choice_b.choice_text, 'B') 78 | self.assertEqual(choice_c.choice_text, 'C') 79 | 80 | def test_creating_duplicate_question_doesnt_duplicate(self): 81 | """ 82 | Creating two identical questions should result in a single question 83 | """ 84 | 85 | original_question_count = len(Question.objects.all()) 86 | 87 | response1 = self.client.post( 88 | '/questions', 89 | '{"question": "Test Question?", "choices": ["A", "B", "C"]}', 90 | content_type='application/json', 91 | secure=True, 92 | ) 93 | response2 = self.client.post( 94 | '/questions', 95 | '{"question": "Test Question?", "choices": ["A", "B", "C"]}', 96 | content_type='application/json', 97 | secure=True, 98 | ) 99 | 100 | self.assertEqual(response1.status_code, 201) 101 | self.assertEqual(response2.status_code, 200) 102 | self.assertEqual(len(Question.objects.all()), original_question_count + 1) 103 | 104 | def test_creating_similar_questions_creates(self): 105 | """ 106 | Creating two similar questions should result in two questions 107 | """ 108 | 109 | original_question_count = len(Question.objects.all()) 110 | 111 | response1 = self.client.post( 112 | '/questions', 113 | '{"question": "Test Question?", "choices": ["A", "B", "C"]}', 114 | content_type='application/json', 115 | secure=True, 116 | ) 117 | response2 = self.client.post( 118 | '/questions', 119 | '{"question": "Test Question?", "choices": ["D", "E", "F"]}', 120 | content_type='application/json', 121 | secure=True, 122 | ) 123 | 124 | self.assertEqual(response1.status_code, 201) 125 | self.assertEqual(response2.status_code, 201) 126 | self.assertEqual(len(Question.objects.all()), original_question_count + 2) 127 | 128 | def test_creating_question_without_body(self): 129 | response = self.client.post( 130 | '/questions', content_type='application/json', secure=True 131 | ) 132 | 133 | self.assertEqual(response.status_code, 400) 134 | 135 | def test_creating_question_with_invalid_question(self): 136 | response = self.client.post( 137 | '/questions', 138 | '{"question": null, "choices": ["A", "B"]}', 139 | content_type='application/json', 140 | secure=True, 141 | ) 142 | 143 | self.assertEqual(response.status_code, 400) 144 | 145 | def test_creating_question_with_invalid_choices(self): 146 | response = self.client.post( 147 | '/questions', 148 | '{"question": "Test Question?", "choices": ["A", "B", null]}', 149 | content_type='application/json', 150 | secure=True, 151 | ) 152 | 153 | self.assertEqual(response.status_code, 400) 154 | 155 | def test_creating_question_with_few_choices(self): 156 | response = self.client.post( 157 | '/questions', 158 | '{"question": "Test Question?", "choices": ["A"]}', 159 | content_type='application/json', 160 | secure=True, 161 | ) 162 | 163 | self.assertEqual(response.status_code, 400) 164 | 165 | 166 | class QuestionDetailTestCase(TestCase): 167 | def setUp(self): 168 | self.client = Client() 169 | 170 | def test_choices_ordered_by_votes_then_alphabetical(self): 171 | question = Question.objects.create( 172 | question_text='Are choices ordered correctly?' 173 | ) 174 | yes_choice = Choice.objects.create(question=question, choice_text='Yes') 175 | no_choice = Choice.objects.create(question=question, choice_text='No') 176 | resource = QuestionResource() 177 | resource.obj = question 178 | 179 | def get_choices(): 180 | return list(map(lambda r: r.obj, resource.get_relations()['choices'])) 181 | 182 | self.assertEqual(get_choices(), [no_choice, yes_choice]) 183 | 184 | yes_choice.vote() 185 | self.assertEqual(get_choices(), [yes_choice, no_choice]) 186 | 187 | def test_unfound_page(self): 188 | response = self.client.get('/questions/1337', secure=True) 189 | 190 | self.assertEqual(response.status_code, 404) 191 | 192 | def test_deleting_question(self): 193 | question = Question.objects.create(question_text='Can I delete a question?') 194 | response = self.client.delete('/questions/{}'.format(question.pk), secure=True) 195 | 196 | self.assertEqual(response.status_code, 204) 197 | 198 | def test_deleting_unknown_question(self): 199 | response = self.client.delete('/questions/1234', secure=True) 200 | 201 | self.assertEqual(response.status_code, 404) 202 | 203 | 204 | class ChoiceDetailTestCase(TestCase): 205 | def setUp(self): 206 | self.client = Client() 207 | 208 | def test_get_choice(self): 209 | question = Question.objects.create(question_text='Testing Question?') 210 | Choice.objects.create(question=question, choice_text='Best Choice') 211 | 212 | response = self.client.get('/questions/1/choices/1', secure=True) 213 | 214 | self.assertEqual(response.status_code, 200) 215 | self.assertEqual( 216 | json.loads(response.content), 217 | {'url': '/questions/1/choices/1', 'choice': 'Best Choice', 'votes': 0}, 218 | ) 219 | 220 | def test_get_missing_choice(self): 221 | question = Question.objects.create(question_text='Testing Question?') 222 | Choice.objects.create(question=question, choice_text='Best Choice') 223 | 224 | response = self.client.get('/questions/1/choices/100', secure=True) 225 | 226 | self.assertEqual(response.status_code, 404) 227 | 228 | def test_get_missing_question(self): 229 | response = self.client.get('/questions/100/choices/1', secure=True) 230 | 231 | self.assertEqual(response.status_code, 404) 232 | 233 | def test_vote_choice(self): 234 | question = Question.objects.create(question_text='Testing Question?') 235 | choice = Choice.objects.create(question=question, choice_text='Best Choice') 236 | 237 | path = '/questions/{}/choices/{}'.format(question.pk, choice.pk) 238 | 239 | response = self.client.post(path, secure=True) 240 | 241 | self.assertEqual(response.status_code, 201) 242 | self.assertEqual( 243 | json.loads(response.content), 244 | {'url': path, 'choice': 'Best Choice', 'votes': 1}, 245 | ) 246 | 247 | def test_vote_unknown_choice(self): 248 | response = self.client.post('/questions/1/choices/5', secure=True) 249 | 250 | self.assertEqual(response.status_code, 404) 251 | 252 | 253 | class HealthCheckTests(TestCase): 254 | def setUp(self): 255 | self.client = Client() 256 | 257 | def test_healthy(self): 258 | response = self.client.get('/healthcheck', secure=True) 259 | 260 | self.assertEqual(response.status_code, 200) 261 | self.assertEqual(response['Content-Type'], 'application/health+json') 262 | self.assertEqual( 263 | json.loads(response.content), 264 | { 265 | 'status': 'ok', 266 | }, 267 | ) 268 | -------------------------------------------------------------------------------- /polls/urls.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | from django.db import connections 3 | from django.db.utils import OperationalError 4 | from django.http import JsonResponse 5 | from django.urls import path 6 | 7 | from polls.views import ( 8 | ChoiceResource, 9 | QuestionCollectionResource, 10 | QuestionResource, 11 | RootResource, 12 | ) 13 | 14 | 15 | def healthcheck_view(request): 16 | content_type = 'application/health+json' 17 | database_accessible = True 18 | 19 | try: 20 | connections['default'].cursor() 21 | except ImproperlyConfigured: 22 | # Database is not configured (DATABASE_URL may not be set) 23 | database_accessible = False 24 | except OperationalError: 25 | # Database is not accessible 26 | database_accessible = False 27 | 28 | if database_accessible: 29 | return JsonResponse({'status': 'ok'}, content_type=content_type) 30 | 31 | return JsonResponse({'status': 'fail'}, status=503, content_type=content_type) 32 | 33 | 34 | def error_view(request): 35 | raise Exception('Test exception') 36 | 37 | 38 | urlpatterns = [ 39 | path('', RootResource.as_view()), 40 | path('questions', QuestionCollectionResource.as_view()), 41 | path('questions/', QuestionResource.as_view()), 42 | path('questions//choices/', ChoiceResource.as_view()), 43 | path('healthcheck', healthcheck_view), 44 | path('500', error_view), 45 | ] 46 | -------------------------------------------------------------------------------- /polls/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import jsonschema 4 | from django.db.models import Count 5 | from django.http import Http404, HttpResponse 6 | 7 | from polls.features import can_create_question, can_delete_question, can_vote_choice 8 | from polls.models import Choice, Question 9 | from polls.resource import ( 10 | Action, 11 | Attribute, 12 | CollectionResource, 13 | Resource, 14 | SingleObjectMixin, 15 | ) 16 | 17 | 18 | class RootResource(Resource): 19 | uri = '/' 20 | cache_max_age = 3600 21 | 22 | def get_relations(self): 23 | return { 24 | 'questions': QuestionCollectionResource(), 25 | } 26 | 27 | def can_embed(self, relation): 28 | return False 29 | 30 | 31 | class QuestionResource(Resource, SingleObjectMixin): 32 | model = Question 33 | 34 | def get_uri(self): 35 | return '/questions/{}'.format(self.get_object().pk) 36 | 37 | def get_attributes(self): 38 | question = self.get_object() 39 | 40 | return { 41 | 'question': question.question_text, 42 | 'published_at': question.published_at.isoformat(), 43 | } 44 | 45 | def get_relations(self): 46 | choices = ( 47 | self.get_object() 48 | .choices.annotate(vote_count=Count('votes')) 49 | .order_by('-vote_count', 'choice_text') 50 | ) 51 | 52 | def choice_resource(choice): 53 | resource = ChoiceResource() 54 | resource.obj = choice 55 | resource.request = getattr(self, 'request', None) 56 | return resource 57 | 58 | return { 59 | 'choices': list(map(choice_resource, choices)), 60 | } 61 | 62 | def get_actions(self): 63 | actions = {} 64 | 65 | if can_delete_question(self.get_object(), self.request): 66 | actions['delete'] = Action(method='DELETE', attributes=None) 67 | 68 | return actions 69 | 70 | def get(self, *args, **kwargs): 71 | try: 72 | self.get_object() 73 | except self.model.DoesNotExist: 74 | raise Http404() 75 | 76 | return super(QuestionResource, self).get(*args, **kwargs) 77 | 78 | def delete(self, request, *args, **kwargs): 79 | try: 80 | question = self.get_object() 81 | except self.model.DoesNotExist: 82 | raise Http404() 83 | 84 | if not can_delete_question(question, request): 85 | return self.http_method_not_allowed(request) 86 | 87 | question.delete() 88 | return HttpResponse(status=204) 89 | 90 | 91 | class ChoiceResource(Resource, SingleObjectMixin): 92 | model = Choice 93 | 94 | def get_uri(self): 95 | choice = self.get_object() 96 | return '/questions/{}/choices/{}'.format(choice.question.pk, choice.pk) 97 | 98 | def get_attributes(self): 99 | choice = self.get_object() 100 | 101 | if not hasattr(choice, 'vote_count'): 102 | choice.vote_count = choice.votes.count() 103 | 104 | return { 105 | 'choice': choice.choice_text, 106 | 'votes': choice.vote_count, 107 | } 108 | 109 | def get_actions(self): 110 | actions = {} 111 | 112 | if can_vote_choice(self.request): 113 | actions['vote'] = Action(method='POST', attributes=None) 114 | 115 | return actions 116 | 117 | def get(self, *args, **kwargs): 118 | try: 119 | self.get_object() 120 | except self.model.DoesNotExist: 121 | raise Http404() 122 | 123 | return super(ChoiceResource, self).get(*args, **kwargs) 124 | 125 | def post(self, request, *args, **kwargs): 126 | if not can_vote_choice(self.request): 127 | return self.http_method_not_allowed(request) 128 | 129 | try: 130 | choice = self.get_object() 131 | except self.model.DoesNotExist: 132 | raise Http404('Choice does not exist') 133 | 134 | choice.vote() 135 | response = self.get(request) 136 | response.status_code = 201 137 | return response 138 | 139 | 140 | class QuestionCollectionResource(CollectionResource): 141 | resource = QuestionResource 142 | model = Question 143 | relation = 'questions' 144 | uri = '/questions' 145 | 146 | request_body_schema = { 147 | 'type': 'object', 148 | 'properties': { 149 | 'question': {'type': 'string'}, 150 | 'choices': {'type': 'array', 'items': {'type': 'string'}, 'minItems': 2}, 151 | }, 152 | 'required': ['question', 'choices'], 153 | } 154 | 155 | def get_actions(self): 156 | actions = {} 157 | 158 | if can_create_question(self.request): 159 | actions['create'] = Action( 160 | method='POST', 161 | attributes=( 162 | Attribute(name='question', category='text'), 163 | Attribute(name='choices', category='array[text]'), 164 | ), 165 | ) 166 | 167 | return actions 168 | 169 | def post(self, request): 170 | if not can_create_question(self.request): 171 | return self.http_method_not_allowed(request) 172 | 173 | try: 174 | body = json.loads(request.body) 175 | except ValueError: 176 | return HttpResponse(status=400) 177 | 178 | try: 179 | jsonschema.validate(body, self.request_body_schema) 180 | except jsonschema.ValidationError: 181 | return HttpResponse(status=400) 182 | 183 | question_text = body.get('question') 184 | choices = body.get('choices') 185 | 186 | question, created = self.get_or_create(question_text, choices) 187 | resource = self.resource() 188 | resource.obj = question 189 | resource.request = request 190 | response = resource.get(request) 191 | if created: 192 | response.status_code = 201 193 | response['Location'] = resource.get_uri() 194 | return response 195 | 196 | def create_question(self, question_text, choice_texts): 197 | question = Question(question_text=question_text) 198 | question.save() 199 | 200 | for choice_text in choice_texts: 201 | Choice(question=question, choice_text=choice_text).save() 202 | 203 | return question 204 | 205 | def get_or_create(self, question_text, choice_texts): 206 | try: 207 | question = Question.objects.filter(question_text=question_text).first() 208 | except Question.DoesNotExist: 209 | question = None 210 | 211 | if question: 212 | choices = list( 213 | map(lambda c: c.choice_text, question.choices.order_by('choice_text')) 214 | ) 215 | if choices == sorted(choice_texts): 216 | return (question, False) 217 | 218 | return (self.create_question(question_text, choice_texts), True) 219 | -------------------------------------------------------------------------------- /polls/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for polls 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('DJANGO_SETTINGS_MODULE', 'polls.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /swagger.yaml: -------------------------------------------------------------------------------- 1 | swagger: '2.0' 2 | info: 3 | version: '1.0' 4 | title: Polls 5 | description: Polls is a simple web service that allows consumers to view polls and vote in them. 6 | license: 7 | name: MIT 8 | url: http://github.com/apiaryio/polls-api/blob/master/LICENSE 9 | host: polls.apiblueprint.org 10 | basePath: / 11 | schemes: 12 | - https 13 | consumes: 14 | - application/json 15 | produces: 16 | - application/json 17 | paths: 18 | /: 19 | x-summary: Polls API Root 20 | x-description: This resource does not have any attributes. Instead it offers the initial API affordances in the form of the links in the JSON body.\nIt is recommend to follow the “url” link values or Link headers to get to resources instead of constructing your own URLs to keep your client decoupled from implementation details. 21 | get: 22 | description: "" 23 | summary: Retrieve the Entry Point 24 | parameters: [] 25 | responses: 26 | 200: 27 | description: "" 28 | schema: 29 | type: object 30 | properties: 31 | questions_url: 32 | type: string 33 | required: 34 | - questions_url 35 | /questions/{question_id}: 36 | x-summary: Question 37 | x-description: >- 38 | A Question object has the following attributes. 39 | 40 | - question 41 | - published_at 42 | - url 43 | - choices (an array of Choice objects). 44 | parameters: 45 | - name: question_id 46 | in: path 47 | required: true 48 | type: number 49 | format: int32 50 | description: ID of the Question in form of an integer 51 | enum: 52 | - 1 53 | get: 54 | tags: 55 | - Question 56 | description: "" 57 | summary: View a question detail 58 | responses: 59 | 200: 60 | description: "" 61 | schema: 62 | $ref: '#/definitions/Question' 63 | /questions/{question_id}/choices/{choice_id}: 64 | x-summary: Choice 65 | parameters: 66 | - name: question_id 67 | in: path 68 | required: true 69 | type: number 70 | format: int32 71 | description: ID of the Question in form of an integer 72 | enum: 73 | - 1 74 | - name: choice_id 75 | in: path 76 | required: true 77 | type: number 78 | format: int32 79 | description: ID of the Choice in form of an integer 80 | enum: 81 | - 1 82 | post: 83 | tags: 84 | - Question 85 | description: This action allows you to vote on a question's choice. 86 | summary: Vote on a Choice 87 | responses: 88 | 201: 89 | description: "" 90 | schema: 91 | $ref: '#/definitions/Choice' 92 | /questions: 93 | x-summary: Questions collection 94 | x-description: Again, instead of constructing the URLs for the next page. It is **highly** recommended that you follow the `next` link header in the response. 95 | parameters: 96 | - name: page 97 | in: query 98 | required: false 99 | type: number 100 | format: int32 101 | description: The page of questions to return 102 | get: 103 | tags: 104 | - Question 105 | description: "" 106 | summary: List all questions 107 | responses: 108 | 200: 109 | description: "" 110 | schema: 111 | type: array 112 | items: 113 | $ref: '#/definitions/Question' 114 | post: 115 | tags: 116 | - Question 117 | description: >- 118 | You can create your own question using this action. It takes a JSON dictionary containing a question and a collection of answers in the form of choices. 119 | 120 | - question (string) - The question 121 | - choices (array[string]) - A collection of choices. 122 | summary: Create a new question 123 | parameters: 124 | - name: body 125 | in: body 126 | required: true 127 | schema: 128 | $ref: '#/definitions/QuestionRequest' 129 | responses: 130 | 201: 131 | description: "" 132 | schema: 133 | $ref: '#/definitions/Question' 134 | definitions: 135 | Question: 136 | title: Question 137 | type: object 138 | properties: 139 | question: 140 | type: string 141 | published_at: 142 | type: string 143 | url: 144 | type: string 145 | choices: 146 | type: array 147 | items: 148 | $ref: '#/definitions/Choice' 149 | required: 150 | - question 151 | - published_at 152 | - url 153 | - choices 154 | Choice: 155 | title: Choice 156 | type: object 157 | properties: 158 | url: 159 | type: string 160 | votes: 161 | type: integer 162 | format: int32 163 | choice: 164 | type: string 165 | required: 166 | - url 167 | - votes 168 | - choice 169 | QuestionRequest: 170 | title: Question Request 171 | type: object 172 | properties: 173 | question: 174 | type: string 175 | choices: 176 | type: array 177 | items: 178 | type: string 179 | required: 180 | - question 181 | - choices 182 | --------------------------------------------------------------------------------