├── .coveragerc ├── .dockerignore ├── .gitignore ├── .idea ├── HelloDjango-rest-framework-tutorial.iml ├── codeStyles │ └── codeStyleConfig.xml ├── inspectionProfiles │ └── profiles_settings.xml ├── misc.xml ├── modules.xml └── vcs.xml ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── README.md ├── blog ├── __init__.py ├── admin.py ├── apps.py ├── elasticsearch2_ik_backend.py ├── feeds.py ├── filters.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── search_indexes.py ├── serializers.py ├── static │ └── blog │ │ ├── css │ │ ├── bootstrap.min.css │ │ ├── custom.css │ │ └── pace.css │ │ └── js │ │ ├── bootstrap.min.js │ │ ├── jquery-2.1.3.min.js │ │ ├── modernizr.custom.js │ │ ├── pace.min.js │ │ └── script.js ├── templatetags │ ├── __init__.py │ └── blog_extras.py ├── tests │ ├── __init__.py │ ├── test_api.py │ ├── test_models.py │ ├── test_serializers.py │ ├── test_smoke.py │ ├── test_templatetags.py │ ├── test_utils.py │ └── test_views.py ├── urls.py ├── utils.py └── views.py ├── blogproject ├── __init__.py ├── settings │ ├── __init__.py │ ├── common.py │ ├── local.py │ └── production.py ├── urls.py └── wsgi.py ├── comments ├── __init__.py ├── admin.py ├── apps.py ├── forms.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── serializers.py ├── templatetags │ ├── __init__.py │ └── comments_extras.py ├── tests │ ├── __init__.py │ ├── base.py │ ├── test_api.py │ ├── test_models.py │ ├── test_templatetags.py │ └── test_views.py ├── urls.py └── views.py ├── compose ├── local │ ├── Dockerfile │ └── start.sh └── production │ ├── django │ ├── Dockerfile │ └── start.sh │ ├── elasticsearch │ ├── Dockerfile │ ├── elasticsearch-analysis-ik-1.10.6.zip │ ├── elasticsearch-analysis-ik-5.6.16.zip │ └── elasticsearch.yml │ └── nginx │ ├── Dockerfile │ ├── hellodjango-rest-framework-tutorial.conf-tmpl │ └── sources.list ├── cover.jpg ├── database └── readme.md ├── fabfile.py ├── local.yml ├── manage.py ├── production.yml ├── requirements.txt ├── scripts ├── __init__.py ├── fake.py └── md.sample └── templates ├── base.html ├── blog ├── detail.html ├── inclusions │ ├── _archives.html │ ├── _categories.html │ ├── _recent_posts.html │ └── _tags.html └── index.html ├── comments ├── inclusions │ ├── _form.html │ └── _list.html └── preview.html ├── pure_pagination └── pagination.html └── search ├── indexes └── blog │ └── post_text.txt └── search.html /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = . 4 | omit = 5 | _credentials.py 6 | manage.py 7 | blogproject/settings/* 8 | fabfile.py 9 | scripts/fake.py 10 | */migrations/* 11 | blogproject\wsgi.py 12 | 13 | [report] 14 | show_missing = True 15 | skip_covered = True -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .* 2 | _credentials.py 3 | fabfile.py 4 | *.sqlite3 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # Environments 87 | .env 88 | .venv 89 | env/ 90 | venv/ 91 | ENV/ 92 | env.bak/ 93 | venv.bak/ 94 | 95 | # Spyder project settings 96 | .spyderproject 97 | .spyproject 98 | 99 | # Rope project settings 100 | .ropeproject 101 | 102 | # mkdocs documentation 103 | /site 104 | 105 | # mypy 106 | *.sqlite3 107 | media/ 108 | _credentials.py 109 | .production 110 | 111 | # Pycharm .idea folder, see following link to know which files should be ignored: 112 | # https://www.jetbrains.com/help/pycharm/synchronizing-and-sharing-settings.html#7e81d3cb 113 | .idea/workspace.xml 114 | .idea/dataSources.* 115 | .idea/tasks.xml 116 | .idea/dictionaries/ 117 | 118 | 119 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /.idea/HelloDjango-rest-framework-tutorial.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 27 | 28 | 29 | 32 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | fabric = "*" 8 | coverage = "*" 9 | 10 | [packages] 11 | django = "~=2.2" 12 | markdown = "*" 13 | gunicorn = "*" 14 | faker = "*" 15 | django-pure-pagination = "*" 16 | elasticsearch = ">=2,<3" 17 | django-haystack = "*" 18 | djangorestframework = "*" 19 | django-filter = "*" 20 | drf-haystack = "*" 21 | drf-extensions = "*" 22 | django-redis-cache = "*" 23 | drf-yasg = "*" 24 | 25 | [requires] 26 | python_version = "3" 27 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "4d79542f8aa950e6afe70fca1b6f6bb738ae02dd8f63963f7f28611935d1db5d" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "certifi": { 20 | "hashes": [ 21 | "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3", 22 | "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41" 23 | ], 24 | "version": "==2020.6.20" 25 | }, 26 | "chardet": { 27 | "hashes": [ 28 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 29 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" 30 | ], 31 | "version": "==3.0.4" 32 | }, 33 | "coreapi": { 34 | "hashes": [ 35 | "sha256:46145fcc1f7017c076a2ef684969b641d18a2991051fddec9458ad3f78ffc1cb", 36 | "sha256:bf39d118d6d3e171f10df9ede5666f63ad80bba9a29a8ec17726a66cf52ee6f3" 37 | ], 38 | "version": "==2.3.3" 39 | }, 40 | "coreschema": { 41 | "hashes": [ 42 | "sha256:5e6ef7bf38c1525d5e55a895934ab4273548629f16aed5c0a6caa74ebf45551f", 43 | "sha256:9503506007d482ab0867ba14724b93c18a33b22b6d19fb419ef2d239dd4a1607" 44 | ], 45 | "version": "==0.0.4" 46 | }, 47 | "django": { 48 | "hashes": [ 49 | "sha256:3e2f5d172215862abf2bac3138d8a04229d34dbd2d0dab42c6bf33876cc22323", 50 | "sha256:91f540000227eace0504a24f508de26daa756353aa7376c6972d7920bc339a3a" 51 | ], 52 | "index": "pypi", 53 | "version": "==2.2.15" 54 | }, 55 | "django-filter": { 56 | "hashes": [ 57 | "sha256:11e63dd759835d9ba7a763926ffb2662cf8a6dcb4c7971a95064de34dbc7e5af", 58 | "sha256:616848eab6fc50193a1b3730140c49b60c57a3eda1f7fc57fa8505ac156c6c75" 59 | ], 60 | "index": "pypi", 61 | "version": "==2.3.0" 62 | }, 63 | "django-haystack": { 64 | "hashes": [ 65 | "sha256:8b54bcc926596765d0a3383d693bcdd76109c7abb6b2323b3984a39e3576028c" 66 | ], 67 | "index": "pypi", 68 | "version": "==2.8.1" 69 | }, 70 | "django-pure-pagination": { 71 | "hashes": [ 72 | "sha256:02b42561b8afb09f1fb6ac6dc81db13374f5f748640f31c8160a374274b54713" 73 | ], 74 | "index": "pypi", 75 | "version": "==0.3.0" 76 | }, 77 | "django-redis-cache": { 78 | "hashes": [ 79 | "sha256:06d4e48545243883f88dc9263dda6c8a0012cb7d0cee2d8758d8917eca92cece", 80 | "sha256:b19ee6654cc2f2c89078c99255e07e19dc2dba8792351d76ba7ea899d465fbb0" 81 | ], 82 | "index": "pypi", 83 | "version": "==2.1.1" 84 | }, 85 | "djangorestframework": { 86 | "hashes": [ 87 | "sha256:6dd02d5a4bd2516fb93f80360673bf540c3b6641fec8766b1da2870a5aa00b32", 88 | "sha256:8b1ac62c581dbc5799b03e535854b92fc4053ecfe74bad3f9c05782063d4196b" 89 | ], 90 | "index": "pypi", 91 | "version": "==3.11.1" 92 | }, 93 | "drf-extensions": { 94 | "hashes": [ 95 | "sha256:9a76d59c8ecc2814860e94a0c96a26a824e392cd4550f2efa928af43c002a750", 96 | "sha256:a04cf188d27fdc13a1083a3ac9e4d72d3d93fcef76b3584191489c75d550c10d" 97 | ], 98 | "index": "pypi", 99 | "version": "==0.6.0" 100 | }, 101 | "drf-haystack": { 102 | "hashes": [ 103 | "sha256:58432b7ec140e3953b60ec3a5b6e7aecef02cf962b23f1fc55c40e5855003080", 104 | "sha256:7d4b24175bb75f894ad758b0ab2226cfcd9c2667e9d1eab4eb3752f36545e58c" 105 | ], 106 | "index": "pypi", 107 | "version": "==1.8.7" 108 | }, 109 | "drf-yasg": { 110 | "hashes": [ 111 | "sha256:5572e9d5baab9f6b49318169df9789f7399d0e3c7bdac8fdb8dfccf1d5d2b1ca", 112 | "sha256:7d7af27ad16e18507e9392b2afd6b218fbffc432ec8dbea053099a2241e184ff" 113 | ], 114 | "index": "pypi", 115 | "version": "==1.17.1" 116 | }, 117 | "elasticsearch": { 118 | "hashes": [ 119 | "sha256:bb8f9a365ba6650d599428538c8aed42033264661d8f7d353da59d5892305f72", 120 | "sha256:fead47ebfcaabd1c53dbfc21403eb99ac207eef76de8002fe11a1c8ec9589ce2" 121 | ], 122 | "index": "pypi", 123 | "version": "==2.4.1" 124 | }, 125 | "faker": { 126 | "hashes": [ 127 | "sha256:bc4b8c908dfcd84e4fe5d9fa2e52fbe17546515fb8f126909b98c47badf05658", 128 | "sha256:ff188c416864e3f7d8becd8f9ee683a4b4101a2a2d2bcdcb3e84bb1bdd06eaae" 129 | ], 130 | "index": "pypi", 131 | "version": "==4.1.2" 132 | }, 133 | "gunicorn": { 134 | "hashes": [ 135 | "sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626", 136 | "sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c" 137 | ], 138 | "index": "pypi", 139 | "version": "==20.0.4" 140 | }, 141 | "idna": { 142 | "hashes": [ 143 | "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", 144 | "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" 145 | ], 146 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 147 | "version": "==2.10" 148 | }, 149 | "importlib-metadata": { 150 | "hashes": [ 151 | "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83", 152 | "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070" 153 | ], 154 | "markers": "python_version < '3.8'", 155 | "version": "==1.7.0" 156 | }, 157 | "inflection": { 158 | "hashes": [ 159 | "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", 160 | "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2" 161 | ], 162 | "markers": "python_version >= '3.5'", 163 | "version": "==0.5.1" 164 | }, 165 | "itypes": { 166 | "hashes": [ 167 | "sha256:03da6872ca89d29aef62773672b2d408f490f80db48b23079a4b194c86dd04c6", 168 | "sha256:af886f129dea4a2a1e3d36595a2d139589e4dd287f5cab0b40e799ee81570ff1" 169 | ], 170 | "version": "==1.2.0" 171 | }, 172 | "jinja2": { 173 | "hashes": [ 174 | "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", 175 | "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" 176 | ], 177 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 178 | "version": "==2.11.2" 179 | }, 180 | "markdown": { 181 | "hashes": [ 182 | "sha256:1fafe3f1ecabfb514a5285fca634a53c1b32a81cb0feb154264d55bf2ff22c17", 183 | "sha256:c467cd6233885534bf0fe96e62e3cf46cfc1605112356c4f9981512b8174de59" 184 | ], 185 | "index": "pypi", 186 | "version": "==3.2.2" 187 | }, 188 | "markupsafe": { 189 | "hashes": [ 190 | "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", 191 | "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", 192 | "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", 193 | "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", 194 | "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", 195 | "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", 196 | "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", 197 | "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", 198 | "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", 199 | "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", 200 | "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", 201 | "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", 202 | "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", 203 | "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", 204 | "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", 205 | "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", 206 | "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", 207 | "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", 208 | "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", 209 | "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", 210 | "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", 211 | "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", 212 | "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", 213 | "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", 214 | "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", 215 | "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", 216 | "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", 217 | "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", 218 | "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", 219 | "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", 220 | "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", 221 | "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", 222 | "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" 223 | ], 224 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 225 | "version": "==1.1.1" 226 | }, 227 | "packaging": { 228 | "hashes": [ 229 | "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", 230 | "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" 231 | ], 232 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 233 | "version": "==20.4" 234 | }, 235 | "pyparsing": { 236 | "hashes": [ 237 | "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", 238 | "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" 239 | ], 240 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", 241 | "version": "==2.4.7" 242 | }, 243 | "python-dateutil": { 244 | "hashes": [ 245 | "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", 246 | "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" 247 | ], 248 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 249 | "version": "==2.8.1" 250 | }, 251 | "pytz": { 252 | "hashes": [ 253 | "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", 254 | "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048" 255 | ], 256 | "version": "==2020.1" 257 | }, 258 | "redis": { 259 | "hashes": [ 260 | "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2", 261 | "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24" 262 | ], 263 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 264 | "version": "==3.5.3" 265 | }, 266 | "requests": { 267 | "hashes": [ 268 | "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b", 269 | "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898" 270 | ], 271 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 272 | "version": "==2.24.0" 273 | }, 274 | "ruamel.yaml": { 275 | "hashes": [ 276 | "sha256:0962fd7999e064c4865f96fb1e23079075f4a2a14849bcdc5cdba53a24f9759b", 277 | "sha256:099c644a778bf72ffa00524f78dd0b6476bca94a1da344130f4bf3381ce5b954" 278 | ], 279 | "version": "==0.16.10" 280 | }, 281 | "ruamel.yaml.clib": { 282 | "hashes": [ 283 | "sha256:1e77424825caba5553bbade750cec2277ef130647d685c2b38f68bc03453bac6", 284 | "sha256:392b7c371312abf27fb549ec2d5e0092f7ef6e6c9f767bfb13e83cb903aca0fd", 285 | "sha256:4d55386129291b96483edcb93b381470f7cd69f97585829b048a3d758d31210a", 286 | "sha256:550168c02d8de52ee58c3d8a8193d5a8a9491a5e7b2462d27ac5bf63717574c9", 287 | "sha256:57933a6986a3036257ad7bf283529e7c19c2810ff24c86f4a0cfeb49d2099919", 288 | "sha256:615b0396a7fad02d1f9a0dcf9f01202bf9caefee6265198f252c865f4227fcc6", 289 | "sha256:77556a7aa190be9a2bd83b7ee075d3df5f3c5016d395613671487e79b082d784", 290 | "sha256:7aee724e1ff424757b5bd8f6c5bbdb033a570b2b4683b17ace4dbe61a99a657b", 291 | "sha256:8073c8b92b06b572e4057b583c3d01674ceaf32167801fe545a087d7a1e8bf52", 292 | "sha256:9c6d040d0396c28d3eaaa6cb20152cb3b2f15adf35a0304f4f40a3cf9f1d2448", 293 | "sha256:a0ff786d2a7dbe55f9544b3f6ebbcc495d7e730df92a08434604f6f470b899c5", 294 | "sha256:b1b7fcee6aedcdc7e62c3a73f238b3d080c7ba6650cd808bce8d7761ec484070", 295 | "sha256:b66832ea8077d9b3f6e311c4a53d06273db5dc2db6e8a908550f3c14d67e718c", 296 | "sha256:be018933c2f4ee7de55e7bd7d0d801b3dfb09d21dad0cce8a97995fd3e44be30", 297 | "sha256:d0d3ac228c9bbab08134b4004d748cf9f8743504875b3603b3afbb97e3472947", 298 | "sha256:d10e9dd744cf85c219bf747c75194b624cc7a94f0c80ead624b06bfa9f61d3bc", 299 | "sha256:ea4362548ee0cbc266949d8a441238d9ad3600ca9910c3fe4e82ee3a50706973", 300 | "sha256:ed5b3698a2bb241b7f5cbbe277eaa7fe48b07a58784fba4f75224fd066d253ad", 301 | "sha256:f9dcc1ae73f36e8059589b601e8e4776b9976effd76c21ad6a855a74318efd6e" 302 | ], 303 | "markers": "python_version < '3.9' and platform_python_implementation == 'CPython'", 304 | "version": "==0.2.0" 305 | }, 306 | "six": { 307 | "hashes": [ 308 | "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", 309 | "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" 310 | ], 311 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 312 | "version": "==1.15.0" 313 | }, 314 | "sqlparse": { 315 | "hashes": [ 316 | "sha256:022fb9c87b524d1f7862b3037e541f68597a730a8843245c349fc93e1643dc4e", 317 | "sha256:e162203737712307dfe78860cc56c8da8a852ab2ee33750e33aeadf38d12c548" 318 | ], 319 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 320 | "version": "==0.3.1" 321 | }, 322 | "text-unidecode": { 323 | "hashes": [ 324 | "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", 325 | "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93" 326 | ], 327 | "version": "==1.3" 328 | }, 329 | "uritemplate": { 330 | "hashes": [ 331 | "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f", 332 | "sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae" 333 | ], 334 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 335 | "version": "==3.0.1" 336 | }, 337 | "urllib3": { 338 | "hashes": [ 339 | "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a", 340 | "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461" 341 | ], 342 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", 343 | "version": "==1.25.10" 344 | }, 345 | "zipp": { 346 | "hashes": [ 347 | "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", 348 | "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" 349 | ], 350 | "markers": "python_version >= '3.6'", 351 | "version": "==3.1.0" 352 | } 353 | }, 354 | "develop": { 355 | "bcrypt": { 356 | "hashes": [ 357 | "sha256:5b93c1726e50a93a033c36e5ca7fdcd29a5c7395af50a6892f5d9e7c6cfbfb29", 358 | "sha256:63d4e3ff96188e5898779b6057878fecf3f11cfe6ec3b313ea09955d587ec7a7", 359 | "sha256:81fec756feff5b6818ea7ab031205e1d323d8943d237303baca2c5f9c7846f34", 360 | "sha256:a67fb841b35c28a59cebed05fbd3e80eea26e6d75851f0574a9273c80f3e9b55", 361 | "sha256:c95d4cbebffafcdd28bd28bb4e25b31c50f6da605c81ffd9ad8a3d1b2ab7b1b6", 362 | "sha256:cd1ea2ff3038509ea95f687256c46b79f5fc382ad0aa3664d200047546d511d1", 363 | "sha256:cdcdcb3972027f83fe24a48b1e90ea4b584d35f1cc279d76de6fc4b13376239d" 364 | ], 365 | "markers": "python_version >= '3.6'", 366 | "version": "==3.2.0" 367 | }, 368 | "cffi": { 369 | "hashes": [ 370 | "sha256:0da50dcbccd7cb7e6c741ab7912b2eff48e85af217d72b57f80ebc616257125e", 371 | "sha256:12a453e03124069b6896107ee133ae3ab04c624bb10683e1ed1c1663df17c13c", 372 | "sha256:15419020b0e812b40d96ec9d369b2bc8109cc3295eac6e013d3261343580cc7e", 373 | "sha256:15a5f59a4808f82d8ec7364cbace851df591c2d43bc76bcbe5c4543a7ddd1bf1", 374 | "sha256:23e44937d7695c27c66a54d793dd4b45889a81b35c0751ba91040fe825ec59c4", 375 | "sha256:29c4688ace466a365b85a51dcc5e3c853c1d283f293dfcc12f7a77e498f160d2", 376 | "sha256:57214fa5430399dffd54f4be37b56fe22cedb2b98862550d43cc085fb698dc2c", 377 | "sha256:577791f948d34d569acb2d1add5831731c59d5a0c50a6d9f629ae1cefd9ca4a0", 378 | "sha256:6539314d84c4d36f28d73adc1b45e9f4ee2a89cdc7e5d2b0a6dbacba31906798", 379 | "sha256:65867d63f0fd1b500fa343d7798fa64e9e681b594e0a07dc934c13e76ee28fb1", 380 | "sha256:672b539db20fef6b03d6f7a14b5825d57c98e4026401fce838849f8de73fe4d4", 381 | "sha256:6843db0343e12e3f52cc58430ad559d850a53684f5b352540ca3f1bc56df0731", 382 | "sha256:7057613efefd36cacabbdbcef010e0a9c20a88fc07eb3e616019ea1692fa5df4", 383 | "sha256:76ada88d62eb24de7051c5157a1a78fd853cca9b91c0713c2e973e4196271d0c", 384 | "sha256:837398c2ec00228679513802e3744d1e8e3cb1204aa6ad408b6aff081e99a487", 385 | "sha256:8662aabfeab00cea149a3d1c2999b0731e70c6b5bac596d95d13f643e76d3d4e", 386 | "sha256:95e9094162fa712f18b4f60896e34b621df99147c2cee216cfa8f022294e8e9f", 387 | "sha256:99cc66b33c418cd579c0f03b77b94263c305c389cb0c6972dac420f24b3bf123", 388 | "sha256:9b219511d8b64d3fa14261963933be34028ea0e57455baf6781fe399c2c3206c", 389 | "sha256:ae8f34d50af2c2154035984b8b5fc5d9ed63f32fe615646ab435b05b132ca91b", 390 | "sha256:b9aa9d8818c2e917fa2c105ad538e222a5bce59777133840b93134022a7ce650", 391 | "sha256:bf44a9a0141a082e89c90e8d785b212a872db793a0080c20f6ae6e2a0ebf82ad", 392 | "sha256:c0b48b98d79cf795b0916c57bebbc6d16bb43b9fc9b8c9f57f4cf05881904c75", 393 | "sha256:da9d3c506f43e220336433dffe643fbfa40096d408cb9b7f2477892f369d5f82", 394 | "sha256:e4082d832e36e7f9b2278bc774886ca8207346b99f278e54c9de4834f17232f7", 395 | "sha256:e4b9b7af398c32e408c00eb4e0d33ced2f9121fd9fb978e6c1b57edd014a7d15", 396 | "sha256:e613514a82539fc48291d01933951a13ae93b6b444a88782480be32245ed4afa", 397 | "sha256:f5033952def24172e60493b68717792e3aebb387a8d186c43c020d9363ee7281" 398 | ], 399 | "version": "==1.14.2" 400 | }, 401 | "coverage": { 402 | "hashes": [ 403 | "sha256:098a703d913be6fbd146a8c50cc76513d726b022d170e5e98dc56d958fd592fb", 404 | "sha256:16042dc7f8e632e0dcd5206a5095ebd18cb1d005f4c89694f7f8aafd96dd43a3", 405 | "sha256:1adb6be0dcef0cf9434619d3b892772fdb48e793300f9d762e480e043bd8e716", 406 | "sha256:27ca5a2bc04d68f0776f2cdcb8bbd508bbe430a7bf9c02315cd05fb1d86d0034", 407 | "sha256:28f42dc5172ebdc32622a2c3f7ead1b836cdbf253569ae5673f499e35db0bac3", 408 | "sha256:2fcc8b58953d74d199a1a4d633df8146f0ac36c4e720b4a1997e9b6327af43a8", 409 | "sha256:304fbe451698373dc6653772c72c5d5e883a4aadaf20343592a7abb2e643dae0", 410 | "sha256:30bc103587e0d3df9e52cd9da1dd915265a22fad0b72afe54daf840c984b564f", 411 | "sha256:40f70f81be4d34f8d491e55936904db5c527b0711b2a46513641a5729783c2e4", 412 | "sha256:4186fc95c9febeab5681bc3248553d5ec8c2999b8424d4fc3a39c9cba5796962", 413 | "sha256:46794c815e56f1431c66d81943fa90721bb858375fb36e5903697d5eef88627d", 414 | "sha256:4869ab1c1ed33953bb2433ce7b894a28d724b7aa76c19b11e2878034a4e4680b", 415 | "sha256:4f6428b55d2916a69f8d6453e48a505c07b2245653b0aa9f0dee38785939f5e4", 416 | "sha256:52f185ffd3291196dc1aae506b42e178a592b0b60a8610b108e6ad892cfc1bb3", 417 | "sha256:538f2fd5eb64366f37c97fdb3077d665fa946d2b6d95447622292f38407f9258", 418 | "sha256:64c4f340338c68c463f1b56e3f2f0423f7b17ba6c3febae80b81f0e093077f59", 419 | "sha256:675192fca634f0df69af3493a48224f211f8db4e84452b08d5fcebb9167adb01", 420 | "sha256:700997b77cfab016533b3e7dbc03b71d33ee4df1d79f2463a318ca0263fc29dd", 421 | "sha256:8505e614c983834239f865da2dd336dcf9d72776b951d5dfa5ac36b987726e1b", 422 | "sha256:962c44070c281d86398aeb8f64e1bf37816a4dfc6f4c0f114756b14fc575621d", 423 | "sha256:9e536783a5acee79a9b308be97d3952b662748c4037b6a24cbb339dc7ed8eb89", 424 | "sha256:9ea749fd447ce7fb1ac71f7616371f04054d969d412d37611716721931e36efd", 425 | "sha256:a34cb28e0747ea15e82d13e14de606747e9e484fb28d63c999483f5d5188e89b", 426 | "sha256:a3ee9c793ffefe2944d3a2bd928a0e436cd0ac2d9e3723152d6fd5398838ce7d", 427 | "sha256:aab75d99f3f2874733946a7648ce87a50019eb90baef931698f96b76b6769a46", 428 | "sha256:b1ed2bdb27b4c9fc87058a1cb751c4df8752002143ed393899edb82b131e0546", 429 | "sha256:b360d8fd88d2bad01cb953d81fd2edd4be539df7bfec41e8753fe9f4456a5082", 430 | "sha256:b8f58c7db64d8f27078cbf2a4391af6aa4e4767cc08b37555c4ae064b8558d9b", 431 | "sha256:c1bbb628ed5192124889b51204de27c575b3ffc05a5a91307e7640eff1d48da4", 432 | "sha256:c2ff24df02a125b7b346c4c9078c8936da06964cc2d276292c357d64378158f8", 433 | "sha256:c890728a93fffd0407d7d37c1e6083ff3f9f211c83b4316fae3778417eab9811", 434 | "sha256:c96472b8ca5dc135fb0aa62f79b033f02aa434fb03a8b190600a5ae4102df1fd", 435 | "sha256:ce7866f29d3025b5b34c2e944e66ebef0d92e4a4f2463f7266daa03a1332a651", 436 | "sha256:e26c993bd4b220429d4ec8c1468eca445a4064a61c74ca08da7429af9bc53bb0" 437 | ], 438 | "index": "pypi", 439 | "version": "==5.2.1" 440 | }, 441 | "cryptography": { 442 | "hashes": [ 443 | "sha256:0c608ff4d4adad9e39b5057de43657515c7da1ccb1807c3a27d4cf31fc923b4b", 444 | "sha256:0cbfed8ea74631fe4de00630f4bb592dad564d57f73150d6f6796a24e76c76cd", 445 | "sha256:124af7255ffc8e964d9ff26971b3a6153e1a8a220b9a685dc407976ecb27a06a", 446 | "sha256:384d7c681b1ab904fff3400a6909261cae1d0939cc483a68bdedab282fb89a07", 447 | "sha256:45741f5499150593178fc98d2c1a9c6722df88b99c821ad6ae298eff0ba1ae71", 448 | "sha256:4b9303507254ccb1181d1803a2080a798910ba89b1a3c9f53639885c90f7a756", 449 | "sha256:4d355f2aee4a29063c10164b032d9fa8a82e2c30768737a2fd56d256146ad559", 450 | "sha256:51e40123083d2f946794f9fe4adeeee2922b581fa3602128ce85ff813d85b81f", 451 | "sha256:8713ddb888119b0d2a1462357d5946b8911be01ddbf31451e1d07eaa5077a261", 452 | "sha256:8e924dbc025206e97756e8903039662aa58aa9ba357d8e1d8fc29e3092322053", 453 | "sha256:8ecef21ac982aa78309bb6f092d1677812927e8b5ef204a10c326fc29f1367e2", 454 | "sha256:8ecf9400d0893836ff41b6f977a33972145a855b6efeb605b49ee273c5e6469f", 455 | "sha256:9367d00e14dee8d02134c6c9524bb4bd39d4c162456343d07191e2a0b5ec8b3b", 456 | "sha256:a09fd9c1cca9a46b6ad4bea0a1f86ab1de3c0c932364dbcf9a6c2a5eeb44fa77", 457 | "sha256:ab49edd5bea8d8b39a44b3db618e4783ef84c19c8b47286bf05dfdb3efb01c83", 458 | "sha256:bea0b0468f89cdea625bb3f692cd7a4222d80a6bdafd6fb923963f2b9da0e15f", 459 | "sha256:bec7568c6970b865f2bcebbe84d547c52bb2abadf74cefce396ba07571109c67", 460 | "sha256:ce82cc06588e5cbc2a7df3c8a9c778f2cb722f56835a23a68b5a7264726bb00c", 461 | "sha256:dea0ba7fe6f9461d244679efa968d215ea1f989b9c1957d7f10c21e5c7c09ad6" 462 | ], 463 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 464 | "version": "==3.0" 465 | }, 466 | "fabric": { 467 | "hashes": [ 468 | "sha256:160331934ea60036604928e792fa8e9f813266b098ef5562aa82b88527740389", 469 | "sha256:24842d7d51556adcabd885ac3cf5e1df73fc622a1708bf3667bf5927576cdfa6" 470 | ], 471 | "index": "pypi", 472 | "version": "==2.5.0" 473 | }, 474 | "invoke": { 475 | "hashes": [ 476 | "sha256:87b3ef9d72a1667e104f89b159eaf8a514dbf2f3576885b2bbdefe74c3fb2132", 477 | "sha256:93e12876d88130c8e0d7fd6618dd5387d6b36da55ad541481dfa5e001656f134", 478 | "sha256:de3f23bfe669e3db1085789fd859eb8ca8e0c5d9c20811e2407fa042e8a5e15d" 479 | ], 480 | "version": "==1.4.1" 481 | }, 482 | "paramiko": { 483 | "hashes": [ 484 | "sha256:920492895db8013f6cc0179293147f830b8c7b21fdfc839b6bad760c27459d9f", 485 | "sha256:9c980875fa4d2cb751604664e9a2d0f69096643f5be4db1b99599fe114a97b2f" 486 | ], 487 | "version": "==2.7.1" 488 | }, 489 | "pycparser": { 490 | "hashes": [ 491 | "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", 492 | "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" 493 | ], 494 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 495 | "version": "==2.20" 496 | }, 497 | "pynacl": { 498 | "hashes": [ 499 | "sha256:06cbb4d9b2c4bd3c8dc0d267416aaed79906e7b33f114ddbf0911969794b1cc4", 500 | "sha256:11335f09060af52c97137d4ac54285bcb7df0cef29014a1a4efe64ac065434c4", 501 | "sha256:2fe0fc5a2480361dcaf4e6e7cea00e078fcda07ba45f811b167e3f99e8cff574", 502 | "sha256:30f9b96db44e09b3304f9ea95079b1b7316b2b4f3744fe3aaecccd95d547063d", 503 | "sha256:511d269ee845037b95c9781aa702f90ccc36036f95d0f31373a6a79bd8242e25", 504 | "sha256:537a7ccbea22905a0ab36ea58577b39d1fa9b1884869d173b5cf111f006f689f", 505 | "sha256:54e9a2c849c742006516ad56a88f5c74bf2ce92c9f67435187c3c5953b346505", 506 | "sha256:757250ddb3bff1eecd7e41e65f7f833a8405fede0194319f87899690624f2122", 507 | "sha256:7757ae33dae81c300487591c68790dfb5145c7d03324000433d9a2c141f82af7", 508 | "sha256:7c6092102219f59ff29788860ccb021e80fffd953920c4a8653889c029b2d420", 509 | "sha256:8122ba5f2a2169ca5da936b2e5a511740ffb73979381b4229d9188f6dcb22f1f", 510 | "sha256:9c4a7ea4fb81536c1b1f5cc44d54a296f96ae78c1ebd2311bd0b60be45a48d96", 511 | "sha256:cd401ccbc2a249a47a3a1724c2918fcd04be1f7b54eb2a5a71ff915db0ac51c6", 512 | "sha256:d452a6746f0a7e11121e64625109bc4468fc3100452817001dbe018bb8b08514", 513 | "sha256:ea6841bc3a76fa4942ce00f3bda7d436fda21e2d91602b9e21b7ca9ecab8f3ff", 514 | "sha256:f8851ab9041756003119368c1e6cd0b9c631f46d686b3904b18c0139f4419f80" 515 | ], 516 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 517 | "version": "==1.4.0" 518 | }, 519 | "six": { 520 | "hashes": [ 521 | "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", 522 | "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" 523 | ], 524 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 525 | "version": "==1.15.0" 526 | } 527 | } 528 | } 529 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | 5 |
HelloDjango-REST-framework-tutorial
6 | 完全免费、开源的 HelloDjango 系列教程之 django REST framework 博客开发
7 |

8 | 9 | 10 |

11 | WeiXin 12 | Sina Weibo 13 |

14 | 15 | 本项目延续自 [HelloDjango-blog-tutorial](https://github.com/HelloGitHub-Team/HelloDjango-blog-tutorial),如果对 django 基础不是很熟悉,建议先学习 [HelloDjango - Django博客教程(第二版)](https://www.zmrenwu.com/courses/hellodjango-blog-tutorial/),然后再进阶学习 django REST framework。 16 | 17 | 虽然项目延续自 [HelloDjango-blog-tutorial](https://github.com/HelloGitHub-Team/HelloDjango-blog-tutorial),但只要你已有 django 基础(ORM、类视图、表单等),就可以直接开启本教程。两个教程在内容上并无联系,只是本教程借用了上一个教程的项目结构以及数据模型(Model)的定义。 18 | 19 | ## 分支说明 20 | 21 | master 分支为项目的主分支,每一步关键功能的开发都对应一篇详细的教程,并和历史提交以及标签一一对应。例如第一篇教程对应第一个 commit,对应标签为 step1,依次类推。 22 | 23 | ## 资源列表 24 | 25 | - 教程首发 HelloGitHub 微信公众号和 [追梦人物的博客](https://www.zmrenwu.com/),在线学习地址:[HelloDjango - django REST framework 教程](https://www.zmrenwu.com/courses/django-rest-framework-tutorial/) 26 | - 上一个项目 HelloDjango-blog-tutorial 的 [源码仓库](https://github.com/HelloGitHub-Team/HelloDjango-blog-tutorial) 27 | 28 | ## 本地运行 29 | 30 | 可以使用 Virtualenv、Pipenv、Docker 等在本地运行项目,每种方式都只需运行简单的几条命令就可以了。 31 | 32 | > **注意:** 33 | > 34 | > 因为博客全文搜索功能依赖 Elasticsearch 服务,如果使用 Virtualenv 或者 Pipenv 启动项目而不想搭建 Elasticsearch 服务的话,请先设置环境变量 `ENABLE_HAYSTACK_REALTIME_SIGNAL_PROCESSOR=no` 以关闭实时索引,否则无法创建博客文章。如果关闭实时索引,全文搜索功能将不可用。 35 | > 36 | > Windows 设置环境变量的方式:`set ENABLE_HAYSTACK_REALTIME_SIGNAL_PROCESSOR=no` 37 | > 38 | > Linux 或者 macOS:`export ENABLE_HAYSTACK_REALTIME_SIGNAL_PROCESSOR=no` 39 | > 40 | > 使用 Docker 启动则无需设置,因为会自动启动一个包含 Elasticsearch 服务的 Docker 容器。 41 | 42 | 无论采用何种方式,先克隆代码到本地: 43 | 44 | ```bash 45 | $ git clone https://github.com/HelloGitHub-Team/HelloDjango-REST-framework-tutorial.git 46 | ``` 47 | 48 | ### Virtualenv 49 | 50 | 1. 创建虚拟环境并**激活虚拟环境**,具体方法可参考基础教程中的:[开始进入 django 开发之旅:使用虚拟环境](https://www.zmrenwu.com/courses/hellodjango-blog-tutorial/materials/59/#%E4%BD%BF%E7%94%A8%E8%99%9A%E6%8B%9F%E7%8E%AF%E5%A2%83) 51 | 52 | 2. 安装项目依赖 53 | 54 | ```bash 55 | $ cd HelloDjango-rest-framework-tutorial 56 | $ pip install -r requirements.txt 57 | ``` 58 | 59 | 3. 迁移数据库 60 | 61 | ```bash 62 | $ python manage.py migrate 63 | ``` 64 | 65 | 4. 创建后台管理员账户 66 | 67 | ```bash 68 | $ python manage.py createsuperuser 69 | ``` 70 | 71 | 具体请参阅基础教程中的 [创作后台开启,请开始你的表演](https://www.zmrenwu.com/courses/hellodjango-blog-tutorial/materials/65/)。 72 | 73 | 5. 运行开发服务器 74 | 75 | ```bash 76 | $ python manage.py runserver 77 | ``` 78 | 79 | 6. 浏览器访问 http://127.0.0.1:8000/admin,使用第 4 步创建的管理员账户登录后台发布文章,如何发布文章可参考基础教程中的:[创作后台开启,请开始你的表演](https://www.zmrenwu.com/courses/hellodjango-blog-tutorial/materials/65/)。 80 | 81 | 或者执行 fake 脚本批量生成测试数据: 82 | 83 | ```bash 84 | $ python -m scripts.fake 85 | ``` 86 | 87 | > 批量脚本会清除全部已有数据,包括第 4 步创建的后台管理员账户。脚本会再默认生成一个管理员账户,用户名和密码都是 admin。 88 | 89 | 9. 浏览器访问:http://127.0.0.1:8000,可进入到博客首页 90 | 91 | ### Pipenv 92 | 93 | 1. 安装 Pipenv(已安装可跳过) 94 | 95 | ```bash 96 | $ pip install pipenv 97 | ``` 98 | 99 | 2. 安装项目依赖 100 | 101 | ```bash 102 | $ cd HelloDjango-rest-framework-tutorial 103 | $ pipenv install --dev 104 | ``` 105 | 106 | 关于如何使用 Pipenv,参阅基础教程中:[开始进入 django 开发之旅](https://www.zmrenwu.com/courses/hellodjango-blog-tutorial/materials/59/) 的 Pipenv 创建和管理虚拟环境部分。 107 | 108 | 3. 迁移数据库 109 | 110 | 在项目根目录运行如下命令迁移数据库: 111 | ```bash 112 | $ pipenv run python manage.py migrate 113 | ``` 114 | 115 | 4. 创建后台管理员账户 116 | 117 | 在项目根目录运行如下命令创建后台管理员账户 118 | 119 | ```bash 120 | $ pipenv run python manage.py createsuperuser 121 | ``` 122 | 123 | 具体请参阅基础教程中的 [创作后台开启,请开始你的表演](https://www.zmrenwu.com/courses/hellodjango-blog-tutorial/materials/65/)。 124 | 125 | 5. 运行开发服务器 126 | 127 | 在项目根目录运行如下命令开启开发服务器: 128 | 129 | ```bash 130 | $ pipenv run python manage.py runserver 131 | ``` 132 | 133 | 6. 浏览器访问 http://127.0.0.1:8000/admin,使用第 4 步创建的管理员账户登录后台发布文章,如何发布文章可参考基础教程中的:[创作后台开启,请开始你的表演](https://www.zmrenwu.com/courses/hellodjango-blog-tutorial/materials/65/)。 134 | 135 | 或者执行 fake 脚本批量生成测试数据: 136 | 137 | ```bash 138 | $ pipenv run python -m scripts.fake 139 | ``` 140 | 141 | > 批量脚本会清除全部已有数据,包括第 4 步创建的后台管理员账户。脚本会再默认生成一个管理员账户,用户名和密码都是 admin。 142 | 143 | 7. 在浏览器访问:http://127.0.0.1:8000/,可进入到博客首页。 144 | 145 | ### Docker 146 | 147 | 1. 安装 Docker 和 Docker Compose 148 | 149 | 3. 构建和启动容器 150 | 151 | ```bash 152 | $ docker-compose -f local.yml build 153 | $ docker-compose -f local.yml up 154 | ``` 155 | 156 | 4. 创建后台管理员账户 157 | 158 | ```bash 159 | $ docker exec -it hellodjango_rest_framework_tutorial_local python manage.py createsuperuser 160 | ``` 161 | 162 | 其中 hellodjango_rest_framework_tutorial_local 为项目预定义容器名。 163 | 164 | 4. 浏览器访问 http://127.0.0.1:8000/admin,使用第 3 步创建的管理员账户登录后台发布文章,如何发布文章可参考基础教程中的:[创作后台开启,请开始你的表演](https://www.zmrenwu.com/courses/hellodjango-blog-tutorial/materials/65/)。 165 | 166 | 或者执行 fake 脚本批量生成测试数据: 167 | 168 | ```bash 169 | $ docker exec -it hellodjango_rest_framework_tutorial_local python -m scripts.fake 170 | ``` 171 | 172 | > 批量脚本会清除全部已有数据,包括第 3 步创建的后台管理员账户。脚本会再默认生成一个管理员账户,用户名和密码都是 admin。 173 | 174 | 5. 为 fake 脚本生成的博客文章创建索引,这样就可以使用 Elasticsearch 服务搜索文章 175 | 176 | ```bash 177 | $ docker exec -it hellodjango_rest_framework_tutorial_local python manage.py rebuild_index 178 | ``` 179 | 180 | > 通过 admin 后台添加的文章会自动创建索引。 181 | 182 | 6. 在浏览器访问:http://127.0.0.1:8000/,可进入到博客首页。 183 | 184 | ## 线上部署 185 | 186 | 拼命撰写中... 187 | 188 | ## 教程目录索引 189 | 190 | 1. [开篇](https://www.zmrenwu.com/courses/django-rest-framework-tutorial/) 191 | 2. [django-rest-framework 是什么鬼?](https://www.zmrenwu.com/courses/django-rest-framework-tutorial/materials/91/) 192 | 3. [初始化 RESTful API 风格的博客系统](https://www.zmrenwu.com/courses/django-rest-framework-tutorial/materials/92/) 193 | 4. [实现博客首页文章列表 API](https://www.zmrenwu.com/courses/django-rest-framework-tutorial/materials/93/) 194 | 5. [用类视图实现首页 API](https://www.zmrenwu.com/courses/django-rest-framework-tutorial/materials/94/) 195 | 6. [使用视图集简化代码](https://www.zmrenwu.com/courses/django-rest-framework-tutorial/materials/95/) 196 | 7. [分页](https://www.zmrenwu.com/courses/django-rest-framework-tutorial/materials/96/) 197 | 8. [文章详情 API](https://www.zmrenwu.com/courses/django-rest-framework-tutorial/materials/97/) 198 | 9. [在接口返回Markdown解析后的内容](https://www.zmrenwu.com/courses/django-rest-framework-tutorial/materials/98/) 199 | 10. [实现分类、标签、归档日期接口](https://www.zmrenwu.com/courses/django-rest-framework-tutorial/materials/99/) 200 | 11. [评论接口](https://www.zmrenwu.com/courses/django-rest-framework-tutorial/materials/100/) 201 | 12. [基于 drf-haystack 实现文章搜索接口](https://www.zmrenwu.com/courses/django-rest-framework-tutorial/materials/101/) 202 | 13. [加缓存为接口提速](https://www.zmrenwu.com/courses/django-rest-framework-tutorial/materials/102/) 203 | 14. [API 版本管理](https://www.zmrenwu.com/courses/django-rest-framework-tutorial/materials/103/) 204 | 15. [限制接口访问频率](https://www.zmrenwu.com/courses/django-rest-framework-tutorial/materials/104/) 205 | 16. [单元测试](https://www.zmrenwu.com/courses/django-rest-framework-tutorial/materials/105/) 206 | 17. [自动生成接口文档](https://www.zmrenwu.com/courses/django-rest-framework-tutorial/materials/106/) 207 | 208 | ## 公众号 209 |

210 |
211 | 欢迎关注 HelloGitHub 公众号,获取更多开源项目的资料和内容。 212 |

213 | 214 | 215 | ## QQ 群 216 | 217 | 加入 QQ 群和更多的 django 开发者进行交流: 218 | 219 | Django学习小组主群:696899473 220 | 221 | ## 版权声明 222 | 223 | 知识共享许可协议
本作品采用署名-非商业性使用-禁止演绎 4.0 国际 进行许可。 -------------------------------------------------------------------------------- /blog/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelloGitHub-Team/HelloDjango-REST-framework-tutorial/36f3534856e643f8ba6b6090728208f5c0c19cd2/blog/__init__.py -------------------------------------------------------------------------------- /blog/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import Post, Category, Tag 3 | 4 | 5 | class PostAdmin(admin.ModelAdmin): 6 | list_display = ['title', 'created_time', 'modified_time', 'views', 'category', 'author'] 7 | fields = ['title', 'body', 'excerpt', 'category', 'tags'] 8 | 9 | def save_model(self, request, obj, form, change): 10 | obj.author = request.user 11 | super().save_model(request, obj, form, change) 12 | 13 | 14 | admin.site.register(Post, PostAdmin) 15 | admin.site.register(Category) 16 | admin.site.register(Tag) 17 | -------------------------------------------------------------------------------- /blog/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class BlogConfig(AppConfig): 5 | name = 'blog' 6 | verbose_name = '博客' 7 | -------------------------------------------------------------------------------- /blog/elasticsearch2_ik_backend.py: -------------------------------------------------------------------------------- 1 | from haystack.backends.elasticsearch2_backend import Elasticsearch2SearchBackend, Elasticsearch2SearchEngine 2 | 3 | DEFAULT_FIELD_MAPPING = {'type': 'string', "analyzer": "ik_max_word", "search_analyzer": "ik_smart"} 4 | 5 | 6 | class Elasticsearch2IkSearchBackend(Elasticsearch2SearchBackend): 7 | 8 | def __init__(self, *args, **kwargs): 9 | self.DEFAULT_SETTINGS['settings']['analysis']['analyzer']['ik_analyzer'] = { 10 | "type": "custom", 11 | "tokenizer": "ik_max_word", 12 | } 13 | super(Elasticsearch2IkSearchBackend, self).__init__(*args, **kwargs) 14 | 15 | 16 | class Elasticsearch2IkSearchEngine(Elasticsearch2SearchEngine): 17 | backend = Elasticsearch2IkSearchBackend 18 | -------------------------------------------------------------------------------- /blog/feeds.py: -------------------------------------------------------------------------------- 1 | from django.contrib.syndication.views import Feed 2 | 3 | from .models import Post 4 | 5 | 6 | class AllPostsRssFeed(Feed): 7 | # 显示在聚合阅读器上的标题 8 | title = "HelloDjango-blog-tutorial" 9 | 10 | # 通过聚合阅读器跳转到网站的地址 11 | link = "/" 12 | 13 | # 显示在聚合阅读器上的描述信息 14 | description = "HelloDjango-blog-tutorial 全部文章" 15 | 16 | # 需要显示的内容条目 17 | def items(self): 18 | return Post.objects.all() 19 | 20 | # 聚合器中显示的内容条目的标题 21 | def item_title(self, item): 22 | return "[%s] %s" % (item.category, item.title) 23 | 24 | # 聚合器中显示的内容条目的描述 25 | def item_description(self, item): 26 | return item.body_html 27 | -------------------------------------------------------------------------------- /blog/filters.py: -------------------------------------------------------------------------------- 1 | from django_filters import rest_framework as drf_filters 2 | 3 | from .models import Category, Post, Tag 4 | 5 | 6 | class PostFilter(drf_filters.FilterSet): 7 | created_year = drf_filters.NumberFilter( 8 | field_name="created_time", lookup_expr="year", help_text="根据文章发表年份过滤文章列表。" 9 | ) 10 | created_month = drf_filters.NumberFilter( 11 | field_name="created_time", lookup_expr="month", help_text="根据文章发表月份过滤文章列表。" 12 | ) 13 | category = drf_filters.ModelChoiceFilter( 14 | queryset=Category.objects.all(), 15 | help_text="根据分类过滤文章列表。", 16 | ) 17 | tags = drf_filters.ModelMultipleChoiceFilter( 18 | queryset=Tag.objects.all(), 19 | help_text="根据标签过滤文章列表。", 20 | ) 21 | 22 | class Meta: 23 | model = Post 24 | fields = ["category", "tags", "created_year", "created_month"] 25 | -------------------------------------------------------------------------------- /blog/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.10 on 2020-04-12 13:30 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import django.utils.timezone 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='Category', 20 | fields=[ 21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('name', models.CharField(max_length=100, verbose_name='分类名')), 23 | ], 24 | options={ 25 | 'verbose_name': '分类', 26 | 'verbose_name_plural': '分类', 27 | }, 28 | ), 29 | migrations.CreateModel( 30 | name='Tag', 31 | fields=[ 32 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 33 | ('name', models.CharField(max_length=100, verbose_name='标签名')), 34 | ], 35 | options={ 36 | 'verbose_name': '标签', 37 | 'verbose_name_plural': '标签', 38 | }, 39 | ), 40 | migrations.CreateModel( 41 | name='Post', 42 | fields=[ 43 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 44 | ('title', models.CharField(max_length=70, verbose_name='标题')), 45 | ('body', models.TextField(verbose_name='正文')), 46 | ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), 47 | ('modified_time', models.DateTimeField(verbose_name='修改时间')), 48 | ('excerpt', models.CharField(blank=True, max_length=200, verbose_name='摘要')), 49 | ('views', models.PositiveIntegerField(default=0, editable=False)), 50 | ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')), 51 | ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.Category', verbose_name='分类')), 52 | ('tags', models.ManyToManyField(blank=True, to='blog.Tag', verbose_name='标签')), 53 | ], 54 | options={ 55 | 'verbose_name': '文章', 56 | 'verbose_name_plural': '文章', 57 | 'ordering': ['-created_time'], 58 | }, 59 | ), 60 | ] 61 | -------------------------------------------------------------------------------- /blog/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelloGitHub-Team/HelloDjango-REST-framework-tutorial/36f3534856e643f8ba6b6090728208f5c0c19cd2/blog/migrations/__init__.py -------------------------------------------------------------------------------- /blog/models.py: -------------------------------------------------------------------------------- 1 | import re 2 | from datetime import datetime 3 | 4 | import markdown 5 | from django.contrib.auth.models import User 6 | from django.core.cache import cache 7 | from django.db import models 8 | from django.db.models.signals import post_delete, post_save 9 | from django.urls import reverse 10 | from django.utils import timezone 11 | from django.utils.functional import cached_property 12 | from django.utils.html import strip_tags 13 | from django.utils.text import slugify 14 | from markdown.extensions.toc import TocExtension 15 | 16 | 17 | def generate_rich_content(value): 18 | md = markdown.Markdown( 19 | extensions=[ 20 | "markdown.extensions.extra", 21 | "markdown.extensions.codehilite", 22 | # 记得在顶部引入 TocExtension 和 slugify 23 | TocExtension(slugify=slugify), 24 | ] 25 | ) 26 | content = md.convert(value) 27 | m = re.search(r'
\s*\s*
', md.toc, re.S) 28 | toc = m.group(1) if m is not None else "" 29 | return {"content": content, "toc": toc} 30 | 31 | 32 | class Category(models.Model): 33 | """ 34 | Django 要求模型必须继承 models.Model 类。 35 | Category 只需要一个简单的分类名 name 就可以了。 36 | CharField 指定了分类名 name 的数据类型,CharField 是字符型, 37 | CharField 的 max_length 参数指定其最大长度,超过这个长度的分类名就不能被存入数据库。 38 | 当然 Django 还为我们提供了多种其它的数据类型,如日期时间类型 DateTimeField、整数类型 IntegerField 等等。 39 | Django 内置的全部类型可查看文档: 40 | https://docs.djangoproject.com/en/2.2/ref/models/fields/#field-types 41 | """ 42 | 43 | name = models.CharField("分类名", max_length=100) 44 | 45 | class Meta: 46 | verbose_name = "分类" 47 | verbose_name_plural = verbose_name 48 | 49 | def __str__(self): 50 | return self.name 51 | 52 | 53 | class Tag(models.Model): 54 | """ 55 | 标签 Tag 也比较简单,和 Category 一样。 56 | 再次强调一定要继承 models.Model 类! 57 | """ 58 | 59 | name = models.CharField("标签名", max_length=100) 60 | 61 | class Meta: 62 | verbose_name = "标签" 63 | verbose_name_plural = verbose_name 64 | 65 | def __str__(self): 66 | return self.name 67 | 68 | 69 | class Post(models.Model): 70 | """ 71 | 文章的数据库表稍微复杂一点,主要是涉及的字段更多。 72 | """ 73 | 74 | # 文章标题 75 | title = models.CharField("标题", max_length=70) 76 | 77 | # 文章正文,我们使用了 TextField。 78 | # 存储比较短的字符串可以使用 CharField,但对于文章的正文来说可能会是一大段文本,因此使用 TextField 来存储大段文本。 79 | body = models.TextField("正文") 80 | 81 | # 这两个列分别表示文章的创建时间和最后一次修改时间,存储时间的字段用 DateTimeField 类型。 82 | created_time = models.DateTimeField("创建时间", default=timezone.now) 83 | modified_time = models.DateTimeField("修改时间") 84 | 85 | # 文章摘要,可以没有文章摘要,但默认情况下 CharField 要求我们必须存入数据,否则就会报错。 86 | # 指定 CharField 的 blank=True 参数值后就可以允许空值了。 87 | excerpt = models.CharField("摘要", max_length=200, blank=True) 88 | 89 | # 这是分类与标签,分类与标签的模型我们已经定义在上面。 90 | # 我们在这里把文章对应的数据库表和分类、标签对应的数据库表关联了起来,但是关联形式稍微有点不同。 91 | # 我们规定一篇文章只能对应一个分类,但是一个分类下可以有多篇文章,所以我们使用的是 ForeignKey,即一对多的关联关系。 92 | # 且自 django 2.0 以后,ForeignKey 必须传入一个 on_delete 参数用来指定当关联的数据被删除时,被关联的数据的行为, 93 | # 我们这里假定当某个分类被删除时,该分类下全部文章也同时被删除,因此使用 models.CASCADE 参数,意为级联删除。 94 | # 而对于标签来说,一篇文章可以有多个标签,同一个标签下也可能有多篇文章,所以我们使用 ManyToManyField,表明这是多对多的关联关系。 95 | # 同时我们规定文章可以没有标签,因此为标签 tags 指定了 blank=True。 96 | # 如果你对 ForeignKey、ManyToManyField 不了解,请看教程中的解释,亦可参考官方文档: 97 | # https://docs.djangoproject.com/en/2.2/topics/db/models/#relationships 98 | category = models.ForeignKey(Category, verbose_name="分类", on_delete=models.CASCADE) 99 | tags = models.ManyToManyField(Tag, verbose_name="标签", blank=True) 100 | 101 | # 文章作者,这里 User 是从 django.contrib.auth.models 导入的。 102 | # django.contrib.auth 是 Django 内置的应用,专门用于处理网站用户的注册、登录等流程,User 是 Django 为我们已经写好的用户模型。 103 | # 这里我们通过 ForeignKey 把文章和 User 关联了起来。 104 | # 因为我们规定一篇文章只能有一个作者,而一个作者可能会写多篇文章,因此这是一对多的关联关系,和 Category 类似。 105 | author = models.ForeignKey(User, verbose_name="作者", on_delete=models.CASCADE) 106 | 107 | # 新增 views 字段记录阅读量 108 | views = models.PositiveIntegerField(default=0, editable=False) 109 | 110 | class Meta: 111 | verbose_name = "文章" 112 | verbose_name_plural = verbose_name 113 | ordering = ["-created_time"] 114 | 115 | def __str__(self): 116 | return self.title 117 | 118 | def save(self, *args, **kwargs): 119 | self.modified_time = timezone.now() 120 | 121 | # 首先实例化一个 Markdown 类,用于渲染 body 的文本。 122 | # 由于摘要并不需要生成文章目录,所以去掉了目录拓展。 123 | md = markdown.Markdown( 124 | extensions=["markdown.extensions.extra", "markdown.extensions.codehilite",] 125 | ) 126 | 127 | # 先将 Markdown 文本渲染成 HTML 文本 128 | # strip_tags 去掉 HTML 文本的全部 HTML 标签 129 | # 从文本摘取前 54 个字符赋给 excerpt 130 | self.excerpt = strip_tags(md.convert(self.body))[:54] 131 | 132 | super().save(*args, **kwargs) 133 | 134 | # 自定义 get_absolute_url 方法 135 | # 记得从 django.urls 中导入 reverse 函数 136 | def get_absolute_url(self): 137 | return reverse("blog:detail", kwargs={"pk": self.pk}) 138 | 139 | def increase_views(self): 140 | self.views += 1 141 | self.save(update_fields=["views"]) 142 | 143 | @property 144 | def toc(self): 145 | return self.rich_content.get("toc", "") 146 | 147 | @property 148 | def body_html(self): 149 | return self.rich_content.get("content", "") 150 | 151 | @cached_property 152 | def rich_content(self): 153 | return generate_rich_content(self.body) 154 | 155 | 156 | def change_post_updated_at(sender=None, instance=None, *args, **kwargs): 157 | cache.set("post_updated_at", datetime.utcnow()) 158 | 159 | 160 | post_save.connect(receiver=change_post_updated_at, sender=Post) 161 | post_delete.connect(receiver=change_post_updated_at, sender=Post) 162 | -------------------------------------------------------------------------------- /blog/search_indexes.py: -------------------------------------------------------------------------------- 1 | from haystack import indexes 2 | from .models import Post 3 | 4 | 5 | class PostIndex(indexes.SearchIndex, indexes.Indexable): 6 | text = indexes.CharField(document=True, use_template=True) 7 | 8 | def get_model(self): 9 | return Post 10 | 11 | def index_queryset(self, using=None): 12 | return self.get_model().objects.all() 13 | -------------------------------------------------------------------------------- /blog/serializers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from drf_haystack.serializers import HaystackSerializerMixin 3 | from rest_framework import serializers 4 | from rest_framework.fields import CharField 5 | 6 | from .models import Category, Post, Tag 7 | from .utils import Highlighter 8 | 9 | 10 | class CategorySerializer(serializers.ModelSerializer): 11 | class Meta: 12 | model = Category 13 | fields = [ 14 | "id", 15 | "name", 16 | ] 17 | 18 | 19 | class UserSerializer(serializers.ModelSerializer): 20 | class Meta: 21 | model = User 22 | fields = [ 23 | "id", 24 | "username", 25 | ] 26 | 27 | 28 | class TagSerializer(serializers.ModelSerializer): 29 | class Meta: 30 | model = Tag 31 | fields = [ 32 | "id", 33 | "name", 34 | ] 35 | 36 | 37 | class PostListSerializer(serializers.ModelSerializer): 38 | category = CategorySerializer() 39 | author = UserSerializer() 40 | 41 | class Meta: 42 | model = Post 43 | fields = [ 44 | "id", 45 | "title", 46 | "created_time", 47 | "excerpt", 48 | "category", 49 | "author", 50 | "views", 51 | ] 52 | 53 | 54 | class PostRetrieveSerializer(serializers.ModelSerializer): 55 | category = CategorySerializer() 56 | author = UserSerializer() 57 | tags = TagSerializer(many=True) 58 | toc = serializers.CharField(label="文章目录", help_text="HTML 格式,每个目录条目均由 li 标签包裹。") 59 | body_html = serializers.CharField( 60 | label="文章内容", help_text="HTML 格式,从 `body` 字段解析而来。" 61 | ) 62 | 63 | class Meta: 64 | model = Post 65 | fields = [ 66 | "id", 67 | "title", 68 | "body", 69 | "created_time", 70 | "modified_time", 71 | "excerpt", 72 | "views", 73 | "category", 74 | "author", 75 | "tags", 76 | "toc", 77 | "body_html", 78 | ] 79 | 80 | 81 | class HighlightedCharField(CharField): 82 | def to_representation(self, value): 83 | value = super().to_representation(value) 84 | request = self.context["request"] 85 | query = request.query_params["text"] 86 | highlighter = Highlighter(query) 87 | return highlighter.highlight(value) 88 | 89 | 90 | class PostHaystackSerializer(HaystackSerializerMixin, PostListSerializer): 91 | title = HighlightedCharField( 92 | label="标题", help_text="标题中包含的关键词已由 HTML 标签包裹,并添加了 class,前端可设置相应的样式来高亮关键。" 93 | ) 94 | summary = HighlightedCharField( 95 | source="body", 96 | label="摘要", 97 | help_text="摘要中包含的关键词已由 HTML 标签包裹,并添加了 class,前端可设置相应的样式来高亮关键。", 98 | ) 99 | 100 | class Meta(PostListSerializer.Meta): 101 | search_fields = ["text"] 102 | fields = [ 103 | "id", 104 | "title", 105 | "summary", 106 | "created_time", 107 | "excerpt", 108 | "category", 109 | "author", 110 | "views", 111 | ] 112 | -------------------------------------------------------------------------------- /blog/static/blog/css/custom.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Table of Contents 3 | * 4 | * 1.0 - Google Font 5 | * 2.0 - General Elements 6 | * 3.0 - Site Header 7 | * 3.1 - Logo 8 | * 3.2 - Main Navigation 9 | * 3.2.1 - Main Nav CSS 3 Hover Effect 10 | * 4.0 - Home/Blog 11 | * 4.1 - Read More Button CSS 3 style 12 | * 5.0 - Widget 13 | * 6.0 - Footer 14 | * 7.0 - Header Search Bar 15 | * 8.0 - Mobile Menu 16 | * 9.0 - Contact Page Social 17 | * 10.0 - Contact Form 18 | * 11.0 - Media Query 19 | * 12.0 - Comment 20 | * 13.0 - Pagination 21 | */ 22 | 23 | /** 24 | * 1.0 - Google Font 25 | */ 26 | 27 | /** 28 | * 2.0 - General Elements 29 | */ 30 | 31 | * { 32 | outline: none; 33 | } 34 | 35 | h1, 36 | h2, 37 | h3, 38 | h4, 39 | h5, 40 | h6 { 41 | margin-top: 0; 42 | } 43 | 44 | b { 45 | font-weight: 400; 46 | } 47 | 48 | a { 49 | color: #333; 50 | } 51 | 52 | a:hover, a:focus { 53 | text-decoration: none; 54 | color: #000; 55 | } 56 | 57 | ::selection { 58 | background-color: #eee; 59 | } 60 | 61 | body { 62 | color: #444; 63 | font-family: 'Lato', sans-serif; 64 | } 65 | 66 | p { 67 | font-family: 'Ubuntu', sans-serif; 68 | font-weight: 400; 69 | word-spacing: 1px; 70 | letter-spacing: 0.01em; 71 | } 72 | 73 | #single p, 74 | #page p { 75 | margin-bottom: 25px; 76 | } 77 | 78 | .page-title { 79 | text-align: center; 80 | } 81 | 82 | .title { 83 | margin-bottom: 30px; 84 | } 85 | 86 | figure { 87 | margin-bottom: 25px; 88 | } 89 | 90 | img { 91 | max-width: 100%; 92 | } 93 | 94 | .img-responsive-center img { 95 | margin: 0 auto; 96 | } 97 | 98 | .height-40px { 99 | margin-bottom: 40px; 100 | } 101 | 102 | /** 103 | * 3.0 - Site Header 104 | */ 105 | 106 | #site-header { 107 | background-color: #FFF; 108 | padding: 25px 20px; 109 | margin-bottom: 40px; 110 | border-bottom: 1px solid #e7e7e7; 111 | } 112 | 113 | .copyrights { 114 | text-indent: -9999px; 115 | height: 0; 116 | line-height: 0; 117 | font-size: 0; 118 | overflow: hidden; 119 | } 120 | 121 | /** 122 | * 3.1 - Logo 123 | */ 124 | 125 | .logo h1 a { 126 | color: #000; 127 | } 128 | 129 | .logo h1 a:hover { 130 | text-decoration: none; 131 | border-bottom: none; 132 | } 133 | 134 | .logo h1 { 135 | margin: 0; 136 | font-family: 'Lato', sans-serif; 137 | font-weight: 300; 138 | } 139 | 140 | /** 141 | * 3.2 - Main Navigation 142 | */ 143 | 144 | .main-nav { 145 | margin-top: 11px; 146 | max-width: 95%; 147 | } 148 | 149 | .main-nav a { 150 | color: #000000 !important; 151 | padding: 0 0 5px 0 !important; 152 | margin-right: 30px; 153 | font-family: 'Lato', sans-serif; 154 | font-weight: 300; 155 | font-size: 24px; 156 | } 157 | 158 | .main-nav a:active, 159 | .main-nav a:focus, 160 | .main-nav a:hover { 161 | background-color: transparent !important; 162 | border-bottom: 0; 163 | } 164 | 165 | .navbar-toggle { 166 | margin: 0; 167 | border: 0; 168 | padding: 0; 169 | margin-right: 25px; 170 | } 171 | 172 | .navbar-toggle span { 173 | font-size: 2em; 174 | color: #000; 175 | } 176 | 177 | /** 178 | * 3.2.1 - Main Nav CSS 3 Hover Effect 179 | */ 180 | 181 | .cl-effect-11 a { 182 | padding: 10px 0; 183 | color: #0972b4; 184 | text-shadow: none; 185 | } 186 | 187 | .cl-effect-11 a::before { 188 | position: absolute; 189 | top: 0; 190 | left: 0; 191 | overflow: hidden; 192 | padding: 0 0 5px 0 !important; 193 | max-width: 0; 194 | border-bottom: 1px solid #000; 195 | color: #000; 196 | content: attr(data-hover); 197 | white-space: nowrap; 198 | -webkit-transition: max-width 0.5s; 199 | -moz-transition: max-width 0.5s; 200 | transition: max-width 0.5s; 201 | } 202 | 203 | .cl-effect-11 a:hover::before, 204 | .cl-effect-11 a:focus::before { 205 | max-width: 100%; 206 | } 207 | 208 | /** 209 | * 4.0 - Home/Blog 210 | */ 211 | 212 | .content-body { 213 | padding-bottom: 4em; 214 | } 215 | 216 | .post { 217 | background: #fff; 218 | padding: 30px 30px 0; 219 | } 220 | 221 | .entry-title { 222 | text-align: center; 223 | font-size: 1.9em; 224 | margin-bottom: 20px; 225 | line-height: 1.6; 226 | padding: 10px 20px 0; 227 | } 228 | 229 | .entry-meta { 230 | text-align: center; 231 | color: #DDDDDD; 232 | font-size: 13px; 233 | margin-bottom: 30px; 234 | } 235 | 236 | .entry-content { 237 | font-size: 18px; 238 | line-height: 1.9; 239 | font-weight: 300; 240 | color: #000; 241 | } 242 | 243 | .post-category::after, 244 | .post-date::after, 245 | .post-author::after, 246 | .comments-link::after { 247 | content: ' ·'; 248 | color: #000; 249 | } 250 | 251 | /** 252 | * 4.1 - Read More Button CSS 3 style 253 | */ 254 | 255 | .read-more { 256 | font-family: 'Ubuntu', sans-serif; 257 | font-weight: 400; 258 | word-spacing: 1px; 259 | letter-spacing: 0.01em; 260 | text-align: center; 261 | margin-top: 20px; 262 | } 263 | 264 | .cl-effect-14 a { 265 | padding: 0 20px; 266 | height: 45px; 267 | line-height: 45px; 268 | position: relative; 269 | display: inline-block; 270 | margin: 15px 25px; 271 | letter-spacing: 1px; 272 | font-weight: 400; 273 | text-shadow: 0 0 1px rgba(255, 255, 255, 0.3); 274 | } 275 | 276 | .cl-effect-14 a::before, 277 | .cl-effect-14 a::after { 278 | position: absolute; 279 | width: 45px; 280 | height: 1px; 281 | background: #C3C3C3; 282 | content: ''; 283 | -webkit-transition: all 0.3s; 284 | -moz-transition: all 0.3s; 285 | transition: all 0.3s; 286 | pointer-events: none; 287 | } 288 | 289 | .cl-effect-14 a::before { 290 | top: 0; 291 | left: 0; 292 | -webkit-transform: rotate(90deg); 293 | -moz-transform: rotate(90deg); 294 | transform: rotate(90deg); 295 | -webkit-transform-origin: 0 0; 296 | -moz-transform-origin: 0 0; 297 | transform-origin: 0 0; 298 | } 299 | 300 | .cl-effect-14 a::after { 301 | right: 0; 302 | bottom: 0; 303 | -webkit-transform: rotate(90deg); 304 | -moz-transform: rotate(90deg); 305 | transform: rotate(90deg); 306 | -webkit-transform-origin: 100% 0; 307 | -moz-transform-origin: 100% 0; 308 | transform-origin: 100% 0; 309 | } 310 | 311 | .cl-effect-14 a:hover::before, 312 | .cl-effect-14 a:hover::after, 313 | .cl-effect-14 a:focus::before, 314 | .cl-effect-14 a:focus::after { 315 | background: #000; 316 | } 317 | 318 | .cl-effect-14 a:hover::before, 319 | .cl-effect-14 a:focus::before { 320 | left: 50%; 321 | -webkit-transform: rotate(0deg) translateX(-50%); 322 | -moz-transform: rotate(0deg) translateX(-50%); 323 | transform: rotate(0deg) translateX(-50%); 324 | } 325 | 326 | .cl-effect-14 a:hover::after, 327 | .cl-effect-14 a:focus::after { 328 | right: 50%; 329 | -webkit-transform: rotate(0deg) translateX(50%); 330 | -moz-transform: rotate(0deg) translateX(50%); 331 | transform: rotate(0deg) translateX(50%); 332 | } 333 | 334 | /** 335 | * 5.0 - Widget 336 | */ 337 | 338 | .widget { 339 | background: #fff; 340 | padding: 30px 0 0; 341 | } 342 | 343 | .widget-title { 344 | font-size: 1.5em; 345 | margin-bottom: 20px; 346 | line-height: 1.6; 347 | padding: 10px 0 0; 348 | font-weight: 400; 349 | } 350 | 351 | .widget-recent-posts ul { 352 | padding: 0; 353 | margin: 0; 354 | padding-left: 20px; 355 | } 356 | 357 | .widget-recent-posts ul li { 358 | list-style-type: none; 359 | position: relative; 360 | line-height: 170%; 361 | margin-bottom: 10px; 362 | } 363 | 364 | .widget-recent-posts ul li::before { 365 | content: '\f3d3'; 366 | font-family: "Ionicons"; 367 | position: absolute; 368 | left: -17px; 369 | top: 3px; 370 | font-size: 16px; 371 | color: #000; 372 | } 373 | 374 | .widget-archives ul { 375 | padding: 0; 376 | margin: 0; 377 | padding-left: 25px; 378 | } 379 | 380 | .widget-archives ul li { 381 | list-style-type: none; 382 | position: relative; 383 | line-height: 170%; 384 | margin-bottom: 10px; 385 | } 386 | 387 | .widget-archives ul li::before { 388 | content: '\f3f3'; 389 | font-family: "Ionicons"; 390 | position: absolute; 391 | left: -25px; 392 | top: 1px; 393 | font-size: 16px; 394 | color: #000; 395 | } 396 | 397 | .widget-category ul { 398 | padding: 0; 399 | margin: 0; 400 | padding-left: 25px; 401 | } 402 | 403 | .widget-category ul li { 404 | list-style-type: none; 405 | position: relative; 406 | line-height: 170%; 407 | margin-bottom: 10px; 408 | } 409 | 410 | .widget-category ul li::before { 411 | content: '\f3fe'; 412 | font-family: "Ionicons"; 413 | position: absolute; 414 | left: -25px; 415 | top: 1px; 416 | font-size: 18px; 417 | color: #000; 418 | } 419 | 420 | .widget-tag-cloud ul { 421 | padding: 0; 422 | margin: 0; 423 | margin-right: -10px; 424 | } 425 | 426 | .widget-tag-cloud ul li { 427 | list-style-type: none; 428 | font-size: 13px; 429 | display: inline-block; 430 | margin-right: 10px; 431 | margin-bottom: 10px; 432 | padding: 3px 8px; 433 | border: 1px solid #ddd; 434 | } 435 | 436 | .widget-content ul ul { 437 | margin-top: 10px; 438 | } 439 | 440 | .widget-content ul li { 441 | margin-bottom: 10px; 442 | } 443 | 444 | .rss { 445 | font-size: 21px; 446 | margin-top: 30px; 447 | } 448 | 449 | .rss a { 450 | color: #444; 451 | } 452 | 453 | /** 454 | * 6.0 - Footer 455 | */ 456 | 457 | #site-footer { 458 | padding-top: 10px; 459 | padding: 0 0 1.5em 0; 460 | } 461 | 462 | .copyright { 463 | text-align: center; 464 | padding-top: 1em; 465 | margin: 0; 466 | border-top: 1px solid #eee; 467 | color: #666; 468 | } 469 | 470 | /** 471 | * 7.0 - Header Search Bar 472 | */ 473 | 474 | #header-search-box { 475 | position: absolute; 476 | right: 38px; 477 | top: 8px; 478 | } 479 | 480 | .search-form { 481 | display: none; 482 | width: 25%; 483 | position: absolute; 484 | min-width: 200px; 485 | right: -6px; 486 | top: 33px; 487 | } 488 | 489 | #search-menu span { 490 | font-size: 20px; 491 | } 492 | 493 | #searchform { 494 | position: relative; 495 | border: 1px solid #ddd; 496 | min-height: 42px; 497 | } 498 | 499 | #searchform input[type=search] { 500 | width: 100%; 501 | border: none; 502 | position: absolute; 503 | left: 0; 504 | padding: 10px 30px 10px 10px; 505 | z-index: 99; 506 | } 507 | 508 | #searchform button { 509 | position: absolute; 510 | right: 6px; 511 | top: 4px; 512 | z-index: 999; 513 | background: transparent; 514 | border: 0; 515 | padding: 0; 516 | } 517 | 518 | #searchform button span { 519 | font-size: 22px; 520 | } 521 | 522 | #search-menu span.ion-ios-close-empty { 523 | font-size: 40px; 524 | line-height: 0; 525 | position: relative; 526 | top: -6px; 527 | } 528 | 529 | /** 530 | * 8.0 - Mobile Menu 531 | */ 532 | 533 | .overlay { 534 | position: fixed; 535 | width: 100%; 536 | height: 100%; 537 | top: 0; 538 | left: 0; 539 | background: #fff; 540 | } 541 | 542 | .overlay .overlay-close { 543 | position: absolute; 544 | right: 25px; 545 | top: 10px; 546 | padding: 0; 547 | overflow: hidden; 548 | border: none; 549 | color: transparent; 550 | background-color: transparent; 551 | z-index: 100; 552 | } 553 | 554 | .overlay-hugeinc.open .ion-ios-close-empty { 555 | color: #000; 556 | font-size: 50px; 557 | } 558 | 559 | .overlay nav { 560 | text-align: center; 561 | position: relative; 562 | top: 50%; 563 | height: 60%; 564 | font-size: 54px; 565 | -webkit-transform: translateY(-50%); 566 | transform: translateY(-50%); 567 | } 568 | 569 | .overlay ul { 570 | list-style: none; 571 | padding: 0; 572 | margin: 0 auto; 573 | display: inline-block; 574 | height: 100%; 575 | position: relative; 576 | } 577 | 578 | .overlay ul li { 579 | display: block; 580 | height: 20%; 581 | height: calc(100% / 5); 582 | min-height: 54px; 583 | } 584 | 585 | .overlay ul li a { 586 | font-weight: 300; 587 | display: block; 588 | -webkit-transition: color 0.2s; 589 | transition: color 0.2s; 590 | } 591 | 592 | .overlay ul li a:hover, 593 | .overlay ul li a:focus { 594 | color: #000; 595 | } 596 | 597 | .overlay-hugeinc { 598 | opacity: 0; 599 | visibility: hidden; 600 | -webkit-transition: opacity 0.5s, visibility 0s 0.5s; 601 | transition: opacity 0.5s, visibility 0s 0.5s; 602 | } 603 | 604 | .overlay-hugeinc.open { 605 | opacity: 1; 606 | visibility: visible; 607 | -webkit-transition: opacity 0.5s; 608 | transition: opacity 0.5s; 609 | } 610 | 611 | .overlay-hugeinc nav { 612 | -webkit-perspective: 1200px; 613 | perspective: 1200px; 614 | } 615 | 616 | .overlay-hugeinc nav ul { 617 | opacity: 0.4; 618 | -webkit-transform: translateY(-25%) rotateX(35deg); 619 | transform: translateY(-25%) rotateX(35deg); 620 | -webkit-transition: -webkit-transform 0.5s, opacity 0.5s; 621 | transition: transform 0.5s, opacity 0.5s; 622 | } 623 | 624 | .overlay-hugeinc.open nav ul { 625 | opacity: 1; 626 | -webkit-transform: rotateX(0deg); 627 | transform: rotateX(0deg); 628 | } 629 | 630 | .overlay-hugeinc.close nav ul { 631 | -webkit-transform: translateY(25%) rotateX(-35deg); 632 | transform: translateY(25%) rotateX(-35deg); 633 | } 634 | 635 | /** 636 | * 9.0 - Contact Page Social 637 | */ 638 | 639 | .social { 640 | list-style-type: none; 641 | padding: 0; 642 | margin: 0; 643 | text-align: center; 644 | } 645 | 646 | .social li { 647 | display: inline-block; 648 | margin-right: 10px; 649 | margin-bottom: 20px; 650 | } 651 | 652 | .social li a { 653 | border: 1px solid #888; 654 | font-size: 22px; 655 | color: #888; 656 | transition: all 0.3s ease-in; 657 | } 658 | 659 | .social li a:hover { 660 | background-color: #333; 661 | color: #fff; 662 | } 663 | 664 | .facebook a { 665 | padding: 12px 21px; 666 | } 667 | 668 | .twitter a { 669 | padding: 12px 15px; 670 | } 671 | 672 | .google-plus a { 673 | padding: 12px 15px; 674 | } 675 | 676 | .tumblr a { 677 | padding: 12px 20px; 678 | } 679 | 680 | /** 681 | * 10.0 - Contact Form 682 | */ 683 | 684 | .contact-form input, .comment-form input { 685 | border: 1px solid #aaa; 686 | margin-bottom: 15px; 687 | width: 100%; 688 | padding: 15px 15px; 689 | font-size: 16px; 690 | line-height: 100%; 691 | transition: 0.4s border-color linear; 692 | } 693 | 694 | .contact-form textarea, .comment-form textarea { 695 | border: 1px solid #aaa; 696 | margin-bottom: 15px; 697 | width: 100%; 698 | padding: 15px 15px; 699 | font-size: 16px; 700 | line-height: 20px !important; 701 | min-height: 183px; 702 | transition: 0.4s border-color linear; 703 | } 704 | 705 | .contact-form input:focus, .comment-form input:focus, 706 | .contact-form textarea:focus, .comment-form textarea:focus { 707 | border-color: #666; 708 | } 709 | 710 | .btn-send { 711 | background: none; 712 | border: 1px solid #aaa; 713 | cursor: pointer; 714 | padding: 25px 80px; 715 | display: inline-block; 716 | letter-spacing: 1px; 717 | position: relative; 718 | transition: all 0.3s; 719 | } 720 | 721 | .btn-5 { 722 | color: #666; 723 | height: 70px; 724 | min-width: 260px; 725 | line-height: 15px; 726 | font-size: 16px; 727 | overflow: hidden; 728 | -webkit-backface-visibility: hidden; 729 | -moz-backface-visibility: hidden; 730 | backface-visibility: hidden; 731 | } 732 | 733 | .btn-5 span { 734 | display: inline-block; 735 | width: 100%; 736 | height: 100%; 737 | -webkit-transition: all 0.3s; 738 | -webkit-backface-visibility: hidden; 739 | -moz-transition: all 0.3s; 740 | -moz-backface-visibility: hidden; 741 | transition: all 0.3s; 742 | backface-visibility: hidden; 743 | } 744 | 745 | .btn-5:before { 746 | position: absolute; 747 | height: 100%; 748 | width: 100%; 749 | line-height: 2.5; 750 | font-size: 180%; 751 | -webkit-transition: all 0.3s; 752 | -moz-transition: all 0.3s; 753 | transition: all 0.3s; 754 | } 755 | 756 | .btn-5:active:before { 757 | color: #703b87; 758 | } 759 | 760 | .btn-5b:hover span { 761 | -webkit-transform: translateX(200%); 762 | -moz-transform: translateX(200%); 763 | -ms-transform: translateX(200%); 764 | transform: translateX(200%); 765 | } 766 | 767 | .btn-5b:before { 768 | left: -100%; 769 | top: 0; 770 | } 771 | 772 | .btn-5b:hover:before { 773 | left: 0; 774 | } 775 | 776 | /** 777 | * 11.0 - Media Query 778 | */ 779 | 780 | @media (max-width: 991px) { 781 | .main-nav a { 782 | margin-right: 20px; 783 | } 784 | 785 | #header-search-box { 786 | position: absolute; 787 | right: 20px; 788 | } 789 | } 790 | 791 | @media (max-width: 767px) { 792 | #header-search-box { 793 | right: 20px; 794 | top: 9px; 795 | } 796 | 797 | .main-nav { 798 | margin-top: 2px; 799 | } 800 | 801 | .btn-5 span { 802 | display: none; 803 | } 804 | 805 | .btn-5b:before { 806 | left: 0; 807 | } 808 | } 809 | 810 | @media (max-width: 431px) { 811 | .logo h1 { 812 | margin-top: 8px; 813 | font-size: 24px; 814 | } 815 | 816 | .post { 817 | background: #fff; 818 | padding: 0 10px 0; 819 | } 820 | 821 | .more-link { 822 | font-size: 0.9em; 823 | line-height: 100%; 824 | } 825 | } 826 | 827 | @media screen and (max-height: 30.5em) { 828 | .overlay nav { 829 | height: 70%; 830 | font-size: 34px; 831 | } 832 | 833 | .overlay ul li { 834 | min-height: 34px; 835 | } 836 | } 837 | 838 | /** 839 | * 12.0 - Comment 840 | */ 841 | .comment-area { 842 | padding: 0 30px 0; 843 | } 844 | 845 | .comment-form { 846 | margin-top: 15px; 847 | } 848 | 849 | .comment-form .comment-btn { 850 | background-color: #fff; 851 | border: 1px solid #aaa; 852 | font-size: 16px; 853 | padding: 5px 10px; 854 | } 855 | 856 | .comment-list-panel { 857 | margin-top: 30px; 858 | } 859 | 860 | .comment-list { 861 | margin-top: 15px; 862 | } 863 | 864 | .comment-item:not(:last-child) { 865 | border-bottom: 1px #ccc solid; 866 | margin-bottom: 20px; 867 | padding-bottom: 20px; 868 | } 869 | 870 | .comment-item .nickname, 871 | .comment-item .submit-date { 872 | color: #777; 873 | font-size: 14px; 874 | } 875 | 876 | .comment-item .nickname:after { 877 | content: ' ·'; 878 | } 879 | 880 | .comment-item .text { 881 | padding-top: 5px; 882 | font-size: 16px; 883 | } 884 | 885 | /** 886 | * 13.0 - Pagination 887 | */ 888 | .pagination-simple { 889 | padding-left: 30px; 890 | font-size: 16px; 891 | } 892 | 893 | .pagination ul { 894 | list-style: none; 895 | } 896 | 897 | .pagination ul li { 898 | display: inline-block; 899 | font-size: 16px; 900 | margin-right: 5px; 901 | } 902 | 903 | .current a { 904 | color: red; 905 | } 906 | -------------------------------------------------------------------------------- /blog/static/blog/css/pace.css: -------------------------------------------------------------------------------- 1 | .pace .pace-progress { 2 | background: #000; 3 | position: fixed; 4 | z-index: 2000; 5 | top: 0; 6 | left: 0; 7 | height: 1px; 8 | transition: width 1s; 9 | } 10 | 11 | .pace-inactive { 12 | display: none; 13 | } -------------------------------------------------------------------------------- /blog/static/blog/js/modernizr.custom.js: -------------------------------------------------------------------------------- 1 | /* Modernizr 2.7.1 (Custom Build) | MIT & BSD 2 | * Build: http://modernizr.com/download/#-csstransitions-shiv-cssclasses-prefixed-testprop-testallprops-domprefixes-load 3 | */ 4 | ;window.Modernizr=function(a,b,c){function x(a){j.cssText=a}function y(a,b){return x(prefixes.join(a+";")+(b||""))}function z(a,b){return typeof a===b}function A(a,b){return!!~(""+a).indexOf(b)}function B(a,b){for(var d in a){var e=a[d];if(!A(e,"-")&&j[e]!==c)return b=="pfx"?e:!0}return!1}function C(a,b,d){for(var e in a){var f=b[a[e]];if(f!==c)return d===!1?a[e]:z(f,"function")?f.bind(d||b):f}return!1}function D(a,b,c){var d=a.charAt(0).toUpperCase()+a.slice(1),e=(a+" "+n.join(d+" ")+d).split(" ");return z(b,"string")||z(b,"undefined")?B(e,b):(e=(a+" "+o.join(d+" ")+d).split(" "),C(e,b,c))}var d="2.7.1",e={},f=!0,g=b.documentElement,h="modernizr",i=b.createElement(h),j=i.style,k,l={}.toString,m="Webkit Moz O ms",n=m.split(" "),o=m.toLowerCase().split(" "),p={},q={},r={},s=[],t=s.slice,u,v={}.hasOwnProperty,w;!z(v,"undefined")&&!z(v.call,"undefined")?w=function(a,b){return v.call(a,b)}:w=function(a,b){return b in a&&z(a.constructor.prototype[b],"undefined")},Function.prototype.bind||(Function.prototype.bind=function(b){var c=this;if(typeof c!="function")throw new TypeError;var d=t.call(arguments,1),e=function(){if(this instanceof e){var a=function(){};a.prototype=c.prototype;var f=new a,g=c.apply(f,d.concat(t.call(arguments)));return Object(g)===g?g:f}return c.apply(b,d.concat(t.call(arguments)))};return e}),p.csstransitions=function(){return D("transition")};for(var E in p)w(p,E)&&(u=E.toLowerCase(),e[u]=p[E](),s.push((e[u]?"":"no-")+u));return e.addTest=function(a,b){if(typeof a=="object")for(var d in a)w(a,d)&&e.addTest(d,a[d]);else{a=a.toLowerCase();if(e[a]!==c)return e;b=typeof b=="function"?b():b,typeof f!="undefined"&&f&&(g.className+=" "+(b?"":"no-")+a),e[a]=b}return e},x(""),i=k=null,function(a,b){function l(a,b){var c=a.createElement("p"),d=a.getElementsByTagName("head")[0]||a.documentElement;return c.innerHTML="x",d.insertBefore(c.lastChild,d.firstChild)}function m(){var a=s.elements;return typeof a=="string"?a.split(" "):a}function n(a){var b=j[a[h]];return b||(b={},i++,a[h]=i,j[i]=b),b}function o(a,c,d){c||(c=b);if(k)return c.createElement(a);d||(d=n(c));var g;return d.cache[a]?g=d.cache[a].cloneNode():f.test(a)?g=(d.cache[a]=d.createElem(a)).cloneNode():g=d.createElem(a),g.canHaveChildren&&!e.test(a)&&!g.tagUrn?d.frag.appendChild(g):g}function p(a,c){a||(a=b);if(k)return a.createDocumentFragment();c=c||n(a);var d=c.frag.cloneNode(),e=0,f=m(),g=f.length;for(;e",g="hidden"in a,k=a.childNodes.length==1||function(){b.createElement("a");var a=b.createDocumentFragment();return typeof a.cloneNode=="undefined"||typeof a.createDocumentFragment=="undefined"||typeof a.createElement=="undefined"}()}catch(c){g=!0,k=!0}})();var s={elements:d.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output progress section summary template time video",version:c,shivCSS:d.shivCSS!==!1,supportsUnknownElements:k,shivMethods:d.shivMethods!==!1,type:"default",shivDocument:r,createElement:o,createDocumentFragment:p};a.html5=s,r(b)}(this,b),e._version=d,e._domPrefixes=o,e._cssomPrefixes=n,e.testProp=function(a){return B([a])},e.testAllProps=D,e.prefixed=function(a,b,c){return b?D(a,b,c):D(a,"pfx")},g.className=g.className.replace(/(^|\s)no-js(\s|$)/,"$1$2")+(f?" js "+s.join(" "):""),e}(this,this.document),function(a,b,c){function d(a){return"[object Function]"==o.call(a)}function e(a){return"string"==typeof a}function f(){}function g(a){return!a||"loaded"==a||"complete"==a||"uninitialized"==a}function h(){var a=p.shift();q=1,a?a.t?m(function(){("c"==a.t?B.injectCss:B.injectJs)(a.s,0,a.a,a.x,a.e,1)},0):(a(),h()):q=0}function i(a,c,d,e,f,i,j){function k(b){if(!o&&g(l.readyState)&&(u.r=o=1,!q&&h(),l.onload=l.onreadystatechange=null,b)){"img"!=a&&m(function(){t.removeChild(l)},50);for(var d in y[c])y[c].hasOwnProperty(d)&&y[c][d].onload()}}var j=j||B.errorTimeout,l=b.createElement(a),o=0,r=0,u={t:d,s:c,e:f,a:i,x:j};1===y[c]&&(r=1,y[c]=[]),"object"==a?l.data=c:(l.src=c,l.type=a),l.width=l.height="0",l.onerror=l.onload=l.onreadystatechange=function(){k.call(this,r)},p.splice(e,0,u),"img"!=a&&(r||2===y[c]?(t.insertBefore(l,s?null:n),m(k,j)):y[c].push(l))}function j(a,b,c,d,f){return q=0,b=b||"j",e(a)?i("c"==b?v:u,a,b,this.i++,c,d,f):(p.splice(this.i++,0,a),1==p.length&&h()),this}function k(){var a=B;return a.loader={load:j,i:0},a}var l=b.documentElement,m=a.setTimeout,n=b.getElementsByTagName("script")[0],o={}.toString,p=[],q=0,r="MozAppearance"in l.style,s=r&&!!b.createRange().compareNode,t=s?l:n.parentNode,l=a.opera&&"[object Opera]"==o.call(a.opera),l=!!b.attachEvent&&!l,u=r?"object":l?"script":"img",v=l?"script":u,w=Array.isArray||function(a){return"[object Array]"==o.call(a)},x=[],y={},z={timeout:function(a,b){return b.length&&(a.timeout=b[0]),a}},A,B;B=function(a){function b(a){var a=a.split("!"),b=x.length,c=a.pop(),d=a.length,c={url:c,origUrl:c,prefixes:a},e,f,g;for(f=0;fb;b++)if(b in this&&this[b]===a)return b;return-1};for(t={catchupTime:500,initialRate:.03,minTime:500,ghostTime:500,maxProgressPerFrame:10,easeFactor:1.25,startOnPageLoad:!0,restartOnPushState:!0,restartOnRequestAfter:500,target:"body",elements:{checkInterval:100,selectors:["body"]},eventLag:{minSamples:10,sampleCount:3,lagThreshold:3},ajax:{trackMethods:["GET"],trackWebSockets:!0,ignoreURLs:[]}},B=function(){var a;return null!=(a="undefined"!=typeof performance&&null!==performance&&"function"==typeof performance.now?performance.now():void 0)?a:+new Date},D=window.requestAnimationFrame||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame||window.msRequestAnimationFrame,s=window.cancelAnimationFrame||window.mozCancelAnimationFrame,null==D&&(D=function(a){return setTimeout(a,50)},s=function(a){return clearTimeout(a)}),F=function(a){var b,c;return b=B(),(c=function(){var d;return d=B()-b,d>=33?(b=B(),a(d,function(){return D(c)})):setTimeout(c,33-d)})()},E=function(){var a,b,c;return c=arguments[0],b=arguments[1],a=3<=arguments.length?W.call(arguments,2):[],"function"==typeof c[b]?c[b].apply(c,a):c[b]},u=function(){var a,b,c,d,e,f,g;for(b=arguments[0],d=2<=arguments.length?W.call(arguments,1):[],f=0,g=d.length;g>f;f++)if(c=d[f])for(a in c)X.call(c,a)&&(e=c[a],null!=b[a]&&"object"==typeof b[a]&&null!=e&&"object"==typeof e?u(b[a],e):b[a]=e);return b},p=function(a){var b,c,d,e,f;for(c=b=0,e=0,f=a.length;f>e;e++)d=a[e],c+=Math.abs(d),b++;return c/b},w=function(a,b){var c,d,e;if(null==a&&(a="options"),null==b&&(b=!0),e=document.querySelector("[data-pace-"+a+"]")){if(c=e.getAttribute("data-pace-"+a),!b)return c;try{return JSON.parse(c)}catch(f){return d=f,"undefined"!=typeof console&&null!==console?console.error("Error parsing inline pace options",d):void 0}}},g=function(){function a(){}return a.prototype.on=function(a,b,c,d){var e;return null==d&&(d=!1),null==this.bindings&&(this.bindings={}),null==(e=this.bindings)[a]&&(e[a]=[]),this.bindings[a].push({handler:b,ctx:c,once:d})},a.prototype.once=function(a,b,c){return this.on(a,b,c,!0)},a.prototype.off=function(a,b){var c,d,e;if(null!=(null!=(d=this.bindings)?d[a]:void 0)){if(null==b)return delete this.bindings[a];for(c=0,e=[];cP;P++)J=T[P],C[J]===!0&&(C[J]=t[J]);i=function(a){function b(){return U=b.__super__.constructor.apply(this,arguments)}return Y(b,a),b}(Error),b=function(){function a(){this.progress=0}return a.prototype.getElement=function(){var a;if(null==this.el){if(a=document.querySelector(C.target),!a)throw new i;this.el=document.createElement("div"),this.el.className="pace pace-active",document.body.className=document.body.className.replace(/pace-done/g,""),document.body.className+=" pace-running",this.el.innerHTML='
\n
\n
\n
',null!=a.firstChild?a.insertBefore(this.el,a.firstChild):a.appendChild(this.el)}return this.el},a.prototype.finish=function(){var a;return a=this.getElement(),a.className=a.className.replace("pace-active",""),a.className+=" pace-inactive",document.body.className=document.body.className.replace("pace-running",""),document.body.className+=" pace-done"},a.prototype.update=function(a){return this.progress=a,this.render()},a.prototype.destroy=function(){try{this.getElement().parentNode.removeChild(this.getElement())}catch(a){i=a}return this.el=void 0},a.prototype.render=function(){var a,b;return null==document.querySelector(C.target)?!1:(a=this.getElement(),a.children[0].style.width=""+this.progress+"%",(!this.lastRenderedProgress||this.lastRenderedProgress|0!==this.progress|0)&&(a.children[0].setAttribute("data-progress-text",""+(0|this.progress)+"%"),this.progress>=100?b="99":(b=this.progress<10?"0":"",b+=0|this.progress),a.children[0].setAttribute("data-progress",""+b)),this.lastRenderedProgress=this.progress)},a.prototype.done=function(){return this.progress>=100},a}(),h=function(){function a(){this.bindings={}}return a.prototype.trigger=function(a,b){var c,d,e,f,g;if(null!=this.bindings[a]){for(f=this.bindings[a],g=[],d=0,e=f.length;e>d;d++)c=f[d],g.push(c.call(this,b));return g}},a.prototype.on=function(a,b){var c;return null==(c=this.bindings)[a]&&(c[a]=[]),this.bindings[a].push(b)},a}(),O=window.XMLHttpRequest,N=window.XDomainRequest,M=window.WebSocket,v=function(a,b){var c,d,e,f;f=[];for(d in b.prototype)try{e=b.prototype[d],f.push(null==a[d]&&"function"!=typeof e?a[d]=e:void 0)}catch(g){c=g}return f},z=[],Pace.ignore=function(){var a,b,c;return b=arguments[0],a=2<=arguments.length?W.call(arguments,1):[],z.unshift("ignore"),c=b.apply(null,a),z.shift(),c},Pace.track=function(){var a,b,c;return b=arguments[0],a=2<=arguments.length?W.call(arguments,1):[],z.unshift("track"),c=b.apply(null,a),z.shift(),c},I=function(a){var b;if(null==a&&(a="GET"),"track"===z[0])return"force";if(!z.length&&C.ajax){if("socket"===a&&C.ajax.trackWebSockets)return!0;if(b=a.toUpperCase(),Z.call(C.ajax.trackMethods,b)>=0)return!0}return!1},j=function(a){function b(){var a,c=this;b.__super__.constructor.apply(this,arguments),a=function(a){var b;return b=a.open,a.open=function(d,e){return I(d)&&c.trigger("request",{type:d,url:e,request:a}),b.apply(a,arguments)}},window.XMLHttpRequest=function(b){var c;return c=new O(b),a(c),c};try{v(window.XMLHttpRequest,O)}catch(d){}if(null!=N){window.XDomainRequest=function(){var b;return b=new N,a(b),b};try{v(window.XDomainRequest,N)}catch(d){}}if(null!=M&&C.ajax.trackWebSockets){window.WebSocket=function(a,b){var d;return d=null!=b?new M(a,b):new M(a),I("socket")&&c.trigger("request",{type:"socket",url:a,protocols:b,request:d}),d};try{v(window.WebSocket,M)}catch(d){}}}return Y(b,a),b}(h),Q=null,x=function(){return null==Q&&(Q=new j),Q},H=function(a){var b,c,d,e;for(e=C.ajax.ignoreURLs,c=0,d=e.length;d>c;c++)if(b=e[c],"string"==typeof b){if(-1!==a.indexOf(b))return!0}else if(b.test(a))return!0;return!1},x().on("request",function(b){var c,d,e,f,g;return f=b.type,e=b.request,g=b.url,H(g)?void 0:Pace.running||C.restartOnRequestAfter===!1&&"force"!==I(f)?void 0:(d=arguments,c=C.restartOnRequestAfter||0,"boolean"==typeof c&&(c=0),setTimeout(function(){var b,c,g,h,i,j;if(b="socket"===f?e.readyState<2:0<(h=e.readyState)&&4>h){for(Pace.restart(),i=Pace.sources,j=[],c=0,g=i.length;g>c;c++){if(J=i[c],J instanceof a){J.watch.apply(J,d);break}j.push(void 0)}return j}},c))}),a=function(){function a(){var a=this;this.elements=[],x().on("request",function(){return a.watch.apply(a,arguments)})}return a.prototype.watch=function(a){var b,c,d,e;return d=a.type,b=a.request,e=a.url,H(e)?void 0:(c="socket"===d?new m(b):new n(b),this.elements.push(c))},a}(),n=function(){function a(a){var b,c,d,e,f,g,h=this;if(this.progress=0,null!=window.ProgressEvent)for(c=null,a.addEventListener("progress",function(a){return h.progress=a.lengthComputable?100*a.loaded/a.total:h.progress+(100-h.progress)/2}),g=["load","abort","timeout","error"],d=0,e=g.length;e>d;d++)b=g[d],a.addEventListener(b,function(){return h.progress=100});else f=a.onreadystatechange,a.onreadystatechange=function(){var b;return 0===(b=a.readyState)||4===b?h.progress=100:3===a.readyState&&(h.progress=50),"function"==typeof f?f.apply(null,arguments):void 0}}return a}(),m=function(){function a(a){var b,c,d,e,f=this;for(this.progress=0,e=["error","open"],c=0,d=e.length;d>c;c++)b=e[c],a.addEventListener(b,function(){return f.progress=100})}return a}(),d=function(){function a(a){var b,c,d,f;for(null==a&&(a={}),this.elements=[],null==a.selectors&&(a.selectors=[]),f=a.selectors,c=0,d=f.length;d>c;c++)b=f[c],this.elements.push(new e(b))}return a}(),e=function(){function a(a){this.selector=a,this.progress=0,this.check()}return a.prototype.check=function(){var a=this;return document.querySelector(this.selector)?this.done():setTimeout(function(){return a.check()},C.elements.checkInterval)},a.prototype.done=function(){return this.progress=100},a}(),c=function(){function a(){var a,b,c=this;this.progress=null!=(b=this.states[document.readyState])?b:100,a=document.onreadystatechange,document.onreadystatechange=function(){return null!=c.states[document.readyState]&&(c.progress=c.states[document.readyState]),"function"==typeof a?a.apply(null,arguments):void 0}}return a.prototype.states={loading:0,interactive:50,complete:100},a}(),f=function(){function a(){var a,b,c,d,e,f=this;this.progress=0,a=0,e=[],d=0,c=B(),b=setInterval(function(){var g;return g=B()-c-50,c=B(),e.push(g),e.length>C.eventLag.sampleCount&&e.shift(),a=p(e),++d>=C.eventLag.minSamples&&a=100&&(this.done=!0),b===this.last?this.sinceLastUpdate+=a:(this.sinceLastUpdate&&(this.rate=(b-this.last)/this.sinceLastUpdate),this.catchup=(b-this.progress)/C.catchupTime,this.sinceLastUpdate=0,this.last=b),b>this.progress&&(this.progress+=this.catchup*a),c=1-Math.pow(this.progress/100,C.easeFactor),this.progress+=c*this.rate*a,this.progress=Math.min(this.lastProgress+C.maxProgressPerFrame,this.progress),this.progress=Math.max(0,this.progress),this.progress=Math.min(100,this.progress),this.lastProgress=this.progress,this.progress},a}(),K=null,G=null,q=null,L=null,o=null,r=null,Pace.running=!1,y=function(){return C.restartOnPushState?Pace.restart():void 0},null!=window.history.pushState&&(S=window.history.pushState,window.history.pushState=function(){return y(),S.apply(window.history,arguments)}),null!=window.history.replaceState&&(V=window.history.replaceState,window.history.replaceState=function(){return y(),V.apply(window.history,arguments)}),k={ajax:a,elements:d,document:c,eventLag:f},(A=function(){var a,c,d,e,f,g,h,i;for(Pace.sources=K=[],g=["ajax","elements","document","eventLag"],c=0,e=g.length;e>c;c++)a=g[c],C[a]!==!1&&K.push(new k[a](C[a]));for(i=null!=(h=C.extraSources)?h:[],d=0,f=i.length;f>d;d++)J=i[d],K.push(new J(C));return Pace.bar=q=new b,G=[],L=new l})(),Pace.stop=function(){return Pace.trigger("stop"),Pace.running=!1,q.destroy(),r=!0,null!=o&&("function"==typeof s&&s(o),o=null),A()},Pace.restart=function(){return Pace.trigger("restart"),Pace.stop(),Pace.start()},Pace.go=function(){var a;return Pace.running=!0,q.render(),a=B(),r=!1,o=F(function(b,c){var d,e,f,g,h,i,j,k,m,n,o,p,s,t,u,v;for(k=100-q.progress,e=o=0,f=!0,i=p=0,t=K.length;t>p;i=++p)for(J=K[i],n=null!=G[i]?G[i]:G[i]=[],h=null!=(v=J.elements)?v:[J],j=s=0,u=h.length;u>s;j=++s)g=h[j],m=null!=n[j]?n[j]:n[j]=new l(g),f&=m.done,m.done||(e++,o+=m.tick(b));return d=o/e,q.update(L.tick(b,d)),q.done()||f||r?(q.update(100),Pace.trigger("done"),setTimeout(function(){return q.finish(),Pace.running=!1,Pace.trigger("hide")},Math.max(C.ghostTime,Math.max(C.minTime-(B()-a),0)))):c()})},Pace.start=function(a){u(C,a),Pace.running=!0;try{q.render()}catch(b){i=b}return document.querySelector(".pace")?(Pace.trigger("start"),Pace.go()):setTimeout(Pace.start,50)},"function"==typeof define&&define.amd?define(function(){return Pace}):"object"==typeof exports?module.exports=Pace:C.startOnPageLoad&&Pace.start()}).call(this); -------------------------------------------------------------------------------- /blog/static/blog/js/script.js: -------------------------------------------------------------------------------- 1 | var searchvisible = 0; 2 | 3 | $("#search-menu").click(function(e){ 4 | //This stops the page scrolling to the top on a # link. 5 | e.preventDefault(); 6 | 7 | var val = $('#search-icon'); 8 | if(val.hasClass('ion-ios-search-strong')){ 9 | val.addClass('ion-ios-close-empty'); 10 | val.removeClass('ion-ios-search-strong'); 11 | } 12 | else{ 13 | val.removeClass('ion-ios-close-empty'); 14 | val.addClass('ion-ios-search-strong'); 15 | } 16 | 17 | 18 | if (searchvisible ===0) { 19 | //Search is currently hidden. Slide down and show it. 20 | $("#search-form").slideDown(200); 21 | $("#s").focus(); //Set focus on the search input field. 22 | searchvisible = 1; //Set search visible flag to visible. 23 | } 24 | 25 | else { 26 | //Search is currently showing. Slide it back up and hide it. 27 | $("#search-form").slideUp(200); 28 | searchvisible = 0; 29 | } 30 | }); 31 | 32 | /*! 33 | * classie - class helper functions 34 | * from bonzo https://github.com/ded/bonzo 35 | * 36 | * classie.has( elem, 'my-class' ) -> true/false 37 | * classie.add( elem, 'my-new-class' ) 38 | * classie.remove( elem, 'my-unwanted-class' ) 39 | * classie.toggle( elem, 'my-class' ) 40 | */ 41 | 42 | /*jshint browser: true, strict: true, undef: true */ 43 | /*global define: false */ 44 | 45 | ( function( window ) { 46 | 47 | 'use strict'; 48 | 49 | // class helper functions from bonzo https://github.com/ded/bonzo 50 | 51 | function classReg( className ) { 52 | return new RegExp("(^|\\s+)" + className + "(\\s+|$)"); 53 | } 54 | 55 | // classList support for class management 56 | // altho to be fair, the api sucks because it won't accept multiple classes at once 57 | var hasClass, addClass, removeClass; 58 | 59 | if ( 'classList' in document.documentElement ) { 60 | hasClass = function( elem, c ) { 61 | return elem.classList.contains( c ); 62 | }; 63 | addClass = function( elem, c ) { 64 | elem.classList.add( c ); 65 | }; 66 | removeClass = function( elem, c ) { 67 | elem.classList.remove( c ); 68 | }; 69 | } 70 | else { 71 | hasClass = function( elem, c ) { 72 | return classReg( c ).test( elem.className ); 73 | }; 74 | addClass = function( elem, c ) { 75 | if ( !hasClass( elem, c ) ) { 76 | elem.className = elem.className + ' ' + c; 77 | } 78 | }; 79 | removeClass = function( elem, c ) { 80 | elem.className = elem.className.replace( classReg( c ), ' ' ); 81 | }; 82 | } 83 | 84 | function toggleClass( elem, c ) { 85 | var fn = hasClass( elem, c ) ? removeClass : addClass; 86 | fn( elem, c ); 87 | } 88 | 89 | var classie = { 90 | // full names 91 | hasClass: hasClass, 92 | addClass: addClass, 93 | removeClass: removeClass, 94 | toggleClass: toggleClass, 95 | // short names 96 | has: hasClass, 97 | add: addClass, 98 | remove: removeClass, 99 | toggle: toggleClass 100 | }; 101 | 102 | // transport 103 | if ( typeof define === 'function' && define.amd ) { 104 | // AMD 105 | define( classie ); 106 | } else { 107 | // browser global 108 | window.classie = classie; 109 | } 110 | 111 | })( window ); 112 | 113 | (function() { 114 | var triggerBttn = document.getElementById( 'trigger-overlay' ), 115 | overlay = document.querySelector( 'div.overlay' ), 116 | closeBttn = overlay.querySelector( 'button.overlay-close' ); 117 | transEndEventNames = { 118 | 'WebkitTransition': 'webkitTransitionEnd', 119 | 'MozTransition': 'transitionend', 120 | 'OTransition': 'oTransitionEnd', 121 | 'msTransition': 'MSTransitionEnd', 122 | 'transition': 'transitionend' 123 | }, 124 | transEndEventName = transEndEventNames[ Modernizr.prefixed( 'transition' ) ], 125 | support = { transitions : Modernizr.csstransitions }; 126 | 127 | function toggleOverlay() { 128 | if( classie.has( overlay, 'open' ) ) { 129 | classie.remove( overlay, 'open' ); 130 | classie.add( overlay, 'close' ); 131 | var onEndTransitionFn = function( ev ) { 132 | if( support.transitions ) { 133 | if( ev.propertyName !== 'visibility' ) return; 134 | this.removeEventListener( transEndEventName, onEndTransitionFn ); 135 | } 136 | classie.remove( overlay, 'close' ); 137 | }; 138 | if( support.transitions ) { 139 | overlay.addEventListener( transEndEventName, onEndTransitionFn ); 140 | } 141 | else { 142 | onEndTransitionFn(); 143 | } 144 | } 145 | else if( !classie.has( overlay, 'close' ) ) { 146 | classie.add( overlay, 'open' ); 147 | } 148 | } 149 | 150 | triggerBttn.addEventListener( 'click', toggleOverlay ); 151 | closeBttn.addEventListener( 'click', toggleOverlay ); 152 | })(); -------------------------------------------------------------------------------- /blog/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelloGitHub-Team/HelloDjango-REST-framework-tutorial/36f3534856e643f8ba6b6090728208f5c0c19cd2/blog/templatetags/__init__.py -------------------------------------------------------------------------------- /blog/templatetags/blog_extras.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.db.models.aggregates import Count 3 | 4 | from ..models import Post, Category, Tag 5 | 6 | register = template.Library() 7 | 8 | 9 | @register.inclusion_tag('blog/inclusions/_recent_posts.html', takes_context=True) 10 | def show_recent_posts(context, num=5): 11 | return { 12 | 'recent_post_list': Post.objects.all()[:num], 13 | } 14 | 15 | 16 | @register.inclusion_tag('blog/inclusions/_archives.html', takes_context=True) 17 | def show_archives(context): 18 | return { 19 | 'date_list': Post.objects.dates('created_time', 'month', order='DESC'), 20 | } 21 | 22 | 23 | @register.inclusion_tag('blog/inclusions/_categories.html', takes_context=True) 24 | def show_categories(context): 25 | category_list = Category.objects.annotate(num_posts=Count('post')).filter(num_posts__gt=0) 26 | return { 27 | 'category_list': category_list, 28 | } 29 | 30 | 31 | @register.inclusion_tag('blog/inclusions/_tags.html', takes_context=True) 32 | def show_tags(context): 33 | tag_list = Tag.objects.annotate(num_posts=Count('post')).filter(num_posts__gt=0) 34 | return { 35 | 'tag_list': tag_list, 36 | } 37 | -------------------------------------------------------------------------------- /blog/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelloGitHub-Team/HelloDjango-REST-framework-tutorial/36f3534856e643f8ba6b6090728208f5c0c19cd2/blog/tests/__init__.py -------------------------------------------------------------------------------- /blog/tests/test_api.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from django.apps import apps 4 | from django.contrib.auth.models import User 5 | from django.core.cache import cache 6 | from django.urls import reverse 7 | from django.utils.timezone import utc 8 | from rest_framework import status 9 | from rest_framework.test import APITestCase 10 | 11 | from blog.models import Category, Post, Tag 12 | from blog.serializers import ( 13 | CategorySerializer, 14 | PostListSerializer, 15 | PostRetrieveSerializer, 16 | TagSerializer, 17 | ) 18 | from comments.models import Comment 19 | from comments.serializers import CommentSerializer 20 | 21 | 22 | class PostViewSetTestCase(APITestCase): 23 | def setUp(self): 24 | # 断开 haystack 的 signal,测试生成的文章无需生成索引 25 | apps.get_app_config("haystack").signal_processor.teardown() 26 | # 清除缓存,防止限流 27 | cache.clear() 28 | 29 | # 设置博客数据 30 | # post3 category2 tag2 2020-08-01 comment2 comment1 31 | # post2 category1 tag1 2020-07-31 32 | # post1 category1 tag1 2020-07-10 33 | user = User.objects.create_superuser( 34 | username="admin", email="admin@hellogithub.com", password="admin" 35 | ) 36 | self.cate1 = Category.objects.create(name="category 1") 37 | self.cate2 = Category.objects.create(name="category 2") 38 | self.tag1 = Tag.objects.create(name="tag1") 39 | self.tag2 = Tag.objects.create(name="tag2") 40 | 41 | self.post1 = Post.objects.create( 42 | title="title 1", 43 | body="post 1", 44 | category=self.cate1, 45 | author=user, 46 | created_time=datetime(year=2020, month=7, day=10).replace(tzinfo=utc), 47 | ) 48 | self.post1.tags.add(self.tag1) 49 | 50 | self.post2 = Post.objects.create( 51 | title="title 2", 52 | body="post 2", 53 | category=self.cate1, 54 | author=user, 55 | created_time=datetime(year=2020, month=7, day=31).replace(tzinfo=utc), 56 | ) 57 | self.post2.tags.add(self.tag1) 58 | 59 | self.post3 = Post.objects.create( 60 | title="title 3", 61 | body="post 3", 62 | category=self.cate2, 63 | author=user, 64 | created_time=datetime(year=2020, month=8, day=1).replace(tzinfo=utc), 65 | ) 66 | self.post3.tags.add(self.tag2) 67 | self.comment1 = Comment.objects.create( 68 | name="u1", 69 | email="u1@google.com", 70 | text="comment 1", 71 | post=self.post3, 72 | created_time=datetime(year=2020, month=8, day=2).replace(tzinfo=utc), 73 | ) 74 | self.comment2 = Comment.objects.create( 75 | name="u2", 76 | email="u1@apple.com", 77 | text="comment 2", 78 | post=self.post3, 79 | created_time=datetime(year=2020, month=8, day=3).replace(tzinfo=utc), 80 | ) 81 | 82 | def test_list_post(self): 83 | url = reverse("v1:post-list") 84 | response = self.client.get(url) 85 | self.assertEqual(response.status_code, status.HTTP_200_OK) 86 | serializer = PostListSerializer( 87 | instance=[self.post3, self.post2, self.post1], many=True 88 | ) 89 | self.assertEqual(response.data["results"], serializer.data) 90 | 91 | def test_list_post_filter_by_category(self): 92 | url = reverse("v1:post-list") 93 | response = self.client.get(url, {"category": self.cate1.pk}) 94 | self.assertEqual(response.status_code, status.HTTP_200_OK) 95 | serializer = PostListSerializer(instance=[self.post2, self.post1], many=True) 96 | self.assertEqual(response.data["results"], serializer.data) 97 | 98 | def test_list_post_filter_by_tag(self): 99 | url = reverse("v1:post-list") 100 | response = self.client.get(url, {"tags": self.tag1.pk}) 101 | self.assertEqual(response.status_code, status.HTTP_200_OK) 102 | serializer = PostListSerializer(instance=[self.post2, self.post1], many=True) 103 | self.assertEqual(response.data["results"], serializer.data) 104 | 105 | def test_list_post_filter_by_archive_date(self): 106 | url = reverse("v1:post-list") 107 | response = self.client.get(url, {"created_year": 2020, "created_month": 7}) 108 | self.assertEqual(response.status_code, status.HTTP_200_OK) 109 | serializer = PostListSerializer(instance=[self.post2, self.post1], many=True) 110 | self.assertEqual(response.data["results"], serializer.data) 111 | 112 | def test_retrieve_post(self): 113 | url = reverse("v1:post-detail", kwargs={"pk": self.post1.pk}) 114 | response = self.client.get(url) 115 | self.assertEqual(response.status_code, status.HTTP_200_OK) 116 | serializer = PostRetrieveSerializer(instance=self.post1) 117 | self.assertEqual(response.data, serializer.data) 118 | 119 | def test_retrieve_nonexistent_post(self): 120 | url = reverse("v1:post-detail", kwargs={"pk": 9999}) 121 | response = self.client.get(url) 122 | self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) 123 | 124 | def test_list_archive_dates(self): 125 | url = reverse("v1:post-archive-date") 126 | response = self.client.get(url) 127 | self.assertEqual(response.status_code, status.HTTP_200_OK) 128 | self.assertEqual(response.data, ["2020-08", "2020-07"]) 129 | 130 | def test_list_comments(self): 131 | url = reverse("v1:post-comment", kwargs={"pk": self.post3.pk}) 132 | response = self.client.get(url) 133 | self.assertEqual(response.status_code, status.HTTP_200_OK) 134 | serializer = CommentSerializer([self.comment2, self.comment1], many=True) 135 | self.assertEqual(response.data["results"], serializer.data) 136 | 137 | def test_list_nonexistent_post_comments(self): 138 | url = reverse("v1:post-comment", kwargs={"pk": 9999}) 139 | response = self.client.get(url) 140 | self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) 141 | 142 | 143 | class CategoryViewSetTestCase(APITestCase): 144 | def setUp(self) -> None: 145 | self.cate1 = Category.objects.create(name="category 1") 146 | self.cate2 = Category.objects.create(name="category 2") 147 | 148 | def test_list_categories(self): 149 | url = reverse("v1:category-list") 150 | response = self.client.get(url) 151 | self.assertEqual(response.status_code, status.HTTP_200_OK) 152 | serializer = CategorySerializer([self.cate1, self.cate2], many=True) 153 | self.assertEqual(response.data, serializer.data) 154 | 155 | 156 | class TagViewSetTestCase(APITestCase): 157 | def setUp(self) -> None: 158 | self.tag1 = Tag.objects.create(name="tag1") 159 | self.tag2 = Tag.objects.create(name="tag2") 160 | 161 | def test_list_tags(self): 162 | url = reverse("v1:tag-list") 163 | response = self.client.get(url) 164 | self.assertEqual(response.status_code, status.HTTP_200_OK) 165 | serializer = CategorySerializer([self.tag1, self.tag2], many=True) 166 | self.assertEqual(response.data, serializer.data) 167 | -------------------------------------------------------------------------------- /blog/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.contrib.auth.models import User 3 | from django.test import TestCase 4 | from django.urls import reverse 5 | 6 | from ..models import Category, Post, Tag 7 | from ..search_indexes import PostIndex 8 | 9 | 10 | class CategoryModelTestCase(TestCase): 11 | def setUp(self): 12 | self.cate = Category.objects.create(name="测试") 13 | 14 | def test_str_representation(self): 15 | self.assertEqual(self.cate.__str__(), self.cate.name) 16 | 17 | 18 | class TagModelTestCase(TestCase): 19 | def setUp(self): 20 | self.tag = Tag.objects.create(name="测试") 21 | 22 | def test_str_representation(self): 23 | self.assertEqual(self.tag.__str__(), self.tag.name) 24 | 25 | 26 | class PostModelTestCase(TestCase): 27 | def setUp(self): 28 | # 断开 haystack 的 signal,测试生成的文章无需生成索引 29 | apps.get_app_config("haystack").signal_processor.teardown() 30 | user = User.objects.create_superuser( 31 | username="admin", email="admin@hellogithub.com", password="admin" 32 | ) 33 | cate = Category.objects.create(name="测试") 34 | self.post = Post.objects.create( 35 | title="测试标题", body="测试内容", category=cate, author=user, 36 | ) 37 | 38 | def test_str_representation(self): 39 | self.assertEqual(self.post.__str__(), self.post.title) 40 | 41 | def test_auto_populate_modified_time(self): 42 | self.assertIsNotNone(self.post.modified_time) 43 | 44 | old_post_modified_time = self.post.modified_time 45 | self.post.body = "新的测试内容" 46 | self.post.save() 47 | self.post.refresh_from_db() 48 | self.assertTrue(self.post.modified_time > old_post_modified_time) 49 | 50 | def test_auto_populate_excerpt(self): 51 | self.assertIsNotNone(self.post.excerpt) 52 | self.assertTrue(0 < len(self.post.excerpt) <= 54) 53 | 54 | def test_get_absolute_url(self): 55 | expected_url = reverse("blog:detail", kwargs={"pk": self.post.pk}) 56 | self.assertEqual(self.post.get_absolute_url(), expected_url) 57 | 58 | def test_increase_views(self): 59 | self.post.increase_views() 60 | self.post.refresh_from_db() 61 | self.assertEqual(self.post.views, 1) 62 | 63 | self.post.increase_views() 64 | self.post.refresh_from_db() 65 | self.assertEqual(self.post.views, 2) 66 | 67 | 68 | class SearchIndexesTestCase(TestCase): 69 | def setUp(self): 70 | apps.get_app_config("haystack").signal_processor.teardown() 71 | user = User.objects.create_superuser( 72 | username="admin", email="admin@hellogithub.com", password="admin" 73 | ) 74 | cate = Category.objects.create(name="测试") 75 | Post.objects.create( 76 | title="测试标题", body="测试内容", category=cate, author=user, 77 | ) 78 | another_cate = Category.objects.create(name="另一个测试") 79 | Post.objects.create( 80 | title="另一个测试标题", body="另一个测试内容", category=another_cate, author=user, 81 | ) 82 | self.index_instance = PostIndex() 83 | 84 | def test_get_model(self): 85 | self.assertTrue(issubclass(self.index_instance.get_model(), Post)) 86 | 87 | def test_index_queryset(self): 88 | expected_qs = Post.objects.all() 89 | self.assertQuerysetEqual( 90 | self.index_instance.index_queryset(), [repr(p) for p in expected_qs] 91 | ) 92 | -------------------------------------------------------------------------------- /blog/tests/test_serializers.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from blog.serializers import HighlightedCharField 4 | from django.test import RequestFactory 5 | from rest_framework.request import Request 6 | 7 | 8 | class HighlightedCharFieldTestCase(unittest.TestCase): 9 | def test_to_representation(self): 10 | field = HighlightedCharField() 11 | request = RequestFactory().get("/", {"text": "关键词"}) 12 | drf_request = Request(request=request) 13 | setattr(field, "_context", {"request": drf_request}) 14 | document = "无关文本关键词无关文本,其他别的关键词别的无关的词。" 15 | result = field.to_representation(document) 16 | expected = ( 17 | '无关文本关键词无关文本,' 18 | '其他别的关键词别的无关的词。' 19 | ) 20 | self.assertEqual(result, expected) 21 | -------------------------------------------------------------------------------- /blog/tests/test_smoke.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | 4 | class SmokeTestCase(TestCase): 5 | def test_smoke(self): 6 | self.assertEqual(1 + 1, 2) 7 | -------------------------------------------------------------------------------- /blog/tests/test_templatetags.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.apps import apps 4 | from django.contrib.auth.models import User 5 | from django.template import Context, Template 6 | from django.test import TestCase 7 | from django.urls import reverse 8 | from django.utils import timezone 9 | 10 | from ..models import Category, Post, Tag 11 | from ..templatetags.blog_extras import ( 12 | show_archives, 13 | show_categories, 14 | show_recent_posts, 15 | show_tags, 16 | ) 17 | 18 | 19 | class BlogExtrasTestCase(TestCase): 20 | def setUp(self): 21 | apps.get_app_config("haystack").signal_processor.teardown() 22 | self.user = User.objects.create_superuser( 23 | username="admin", email="admin@hellogithub.com", password="admin" 24 | ) 25 | self.cate = Category.objects.create(name="测试") 26 | self.ctx = Context() 27 | 28 | def test_show_recent_posts_without_any_post(self): 29 | template = Template("{% load blog_extras %}" "{% show_recent_posts %}") 30 | expected_html = template.render(self.ctx) 31 | self.assertInHTML('

最新文章

', expected_html) 32 | self.assertInHTML("暂无文章!", expected_html) 33 | 34 | def test_show_recent_posts_with_posts(self): 35 | post = Post.objects.create( 36 | title="测试标题", body="测试内容", category=self.cate, author=self.user, 37 | ) 38 | context = Context(show_recent_posts(self.ctx)) 39 | template = Template("{% load blog_extras %}" "{% show_recent_posts %}") 40 | expected_html = template.render(context) 41 | self.assertInHTML('

最新文章

', expected_html) 42 | self.assertInHTML( 43 | '{}'.format(post.get_absolute_url(), post.title), 44 | expected_html, 45 | ) 46 | 47 | def test_show_recent_posts_nums_specified(self): 48 | post_list = [] 49 | for i in range(7): 50 | post = Post.objects.create( 51 | title="测试标题-{}".format(i), 52 | body="测试内容", 53 | category=self.cate, 54 | author=self.user, 55 | ) 56 | post_list.insert(0, post) 57 | context = Context(show_recent_posts(self.ctx, 3)) 58 | template = Template("{% load blog_extras %}" "{% show_recent_posts %}") 59 | expected_html = template.render(context) 60 | self.assertInHTML('

最新文章

', expected_html) 61 | self.assertInHTML( 62 | '{}'.format( 63 | post_list[0].get_absolute_url(), post_list[0].title 64 | ), 65 | expected_html, 66 | ) 67 | self.assertInHTML( 68 | '{}'.format( 69 | post_list[1].get_absolute_url(), post_list[1].title 70 | ), 71 | expected_html, 72 | ) 73 | self.assertInHTML( 74 | '{}'.format( 75 | post_list[2].get_absolute_url(), post_list[2].title 76 | ), 77 | expected_html, 78 | ) 79 | 80 | def test_show_categories_without_any_category(self): 81 | self.cate.delete() 82 | context = Context(show_categories(self.ctx)) 83 | template = Template("{% load blog_extras %}" "{% show_categories %}") 84 | expected_html = template.render(context) 85 | self.assertInHTML('

分类

', expected_html) 86 | self.assertInHTML("暂无分类!", expected_html) 87 | 88 | def test_show_categories_with_categories(self): 89 | cate_with_posts = Category.objects.create(name="有文章的分类") 90 | Post.objects.create( 91 | title="测试标题-1", body="测试内容", category=cate_with_posts, author=self.user, 92 | ) 93 | another_cate_with_posts = Category.objects.create(name="另一个有文章的分类") 94 | Post.objects.create( 95 | title="测试标题-2", 96 | body="测试内容", 97 | category=another_cate_with_posts, 98 | author=self.user, 99 | ) 100 | context = Context(show_categories(self.ctx)) 101 | template = Template("{% load blog_extras %}" "{% show_categories %}") 102 | expected_html = template.render(context) 103 | self.assertInHTML('

分类

', expected_html) 104 | 105 | url = reverse("blog:category", kwargs={"pk": cate_with_posts.pk}) 106 | num_posts = cate_with_posts.post_set.count() 107 | frag = '{} ({})'.format( 108 | url, cate_with_posts.name, num_posts 109 | ) 110 | self.assertInHTML(frag, expected_html) 111 | 112 | url = reverse("blog:category", kwargs={"pk": another_cate_with_posts.pk}) 113 | num_posts = another_cate_with_posts.post_set.count() 114 | frag = '{} ({})'.format( 115 | url, another_cate_with_posts.name, num_posts 116 | ) 117 | self.assertInHTML(frag, expected_html) 118 | 119 | def test_show_tags_without_any_tag(self): 120 | context = Context(show_tags(self.ctx)) 121 | template = Template("{% load blog_extras %}" "{% show_tags %}") 122 | expected_html = template.render(context) 123 | self.assertInHTML('

标签云

', expected_html) 124 | self.assertInHTML("暂无标签!", expected_html) 125 | 126 | def test_show_tags_with_tags(self): 127 | tag1 = Tag.objects.create(name="测试1") 128 | tag2 = Tag.objects.create(name="测试2") 129 | tag3 = Tag.objects.create(name="测试3") 130 | tag2_post = Post.objects.create( 131 | title="测试标题", body="测试内容", category=self.cate, author=self.user, 132 | ) 133 | tag2_post.tags.add(tag2) 134 | tag2_post.save() 135 | 136 | another_tag2_post = Post.objects.create( 137 | title="测试标题", body="测试内容", category=self.cate, author=self.user, 138 | ) 139 | another_tag2_post.tags.add(tag2) 140 | another_tag2_post.save() 141 | 142 | tag3_post = Post.objects.create( 143 | title="测试标题", body="测试内容", category=self.cate, author=self.user, 144 | ) 145 | tag3_post.tags.add(tag3) 146 | tag3_post.save() 147 | 148 | context = Context(show_tags(self.ctx)) 149 | template = Template("{% load blog_extras %}" "{% show_tags %}") 150 | expected_html = template.render(context) 151 | self.assertInHTML('

标签云

', expected_html) 152 | 153 | tag2_url = reverse("blog:tag", kwargs={"pk": tag2.pk}) 154 | tag2_num_posts = tag2.post_set.count() 155 | frag = '{} ({})'.format( 156 | tag2_url, tag2.name, tag2_num_posts 157 | ) 158 | self.assertInHTML(frag, expected_html) 159 | 160 | tag3_url = reverse("blog:tag", kwargs={"pk": tag3.pk}) 161 | tag3_num_posts = tag3.post_set.count() 162 | frag = '{} ({})'.format( 163 | tag3_url, tag3.name, tag3_num_posts 164 | ) 165 | self.assertInHTML(frag, expected_html) 166 | 167 | def test_show_archives_without_any_post(self): 168 | context = Context(show_archives(self.ctx)) 169 | template = Template("{% load blog_extras %}" "{% show_archives %}") 170 | expected_html = template.render(context) 171 | self.assertInHTML('

归档

', expected_html) 172 | self.assertInHTML("暂无归档!", expected_html) 173 | 174 | def test_show_archives_with_post(self): 175 | post1 = Post.objects.create( 176 | title="测试标题-1", 177 | body="测试内容", 178 | category=self.cate, 179 | author=self.user, 180 | created_time=timezone.now(), 181 | ) 182 | post2 = Post.objects.create( 183 | title="测试标题-1", 184 | body="测试内容", 185 | category=self.cate, 186 | author=self.user, 187 | created_time=timezone.now() - timedelta(days=50), 188 | ) 189 | 190 | context = Context(show_archives(self.ctx)) 191 | template = Template("{% load blog_extras %}" "{% show_archives %}") 192 | expected_html = template.render(context) 193 | self.assertInHTML('

归档

', expected_html) 194 | 195 | created_time = post1.created_time 196 | url = reverse( 197 | "blog:archive", 198 | kwargs={"year": created_time.year, "month": created_time.month}, 199 | ) 200 | frag = '{} 年 {} 月'.format( 201 | url, created_time.year, created_time.month 202 | ) 203 | self.assertInHTML(frag, expected_html) 204 | 205 | created_time = post2.created_time 206 | url = reverse( 207 | "blog:archive", 208 | kwargs={"year": created_time.year, "month": created_time.month}, 209 | ) 210 | frag = '{} 年 {} 月'.format( 211 | url, created_time.year, created_time.month 212 | ) 213 | self.assertInHTML(frag, expected_html) 214 | -------------------------------------------------------------------------------- /blog/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import datetime 3 | 4 | from django.core.cache import cache 5 | 6 | from ..utils import Highlighter, UpdatedAtKeyBit 7 | 8 | 9 | class HighlighterTestCase(unittest.TestCase): 10 | def test_highlight(self): 11 | document = "这是一个比较长的标题,用于测试关键词高亮但不被截断。" 12 | highlighter = Highlighter("标题") 13 | expected = '这是一个比较长的标题,用于测试关键词高亮但不被截断。' 14 | self.assertEqual(highlighter.highlight(document), expected) 15 | 16 | highlighter = Highlighter("关键词高亮") 17 | expected = '这是一个比较长的标题,用于测试关键词高亮但不被截断。' 18 | self.assertEqual(highlighter.highlight(document), expected) 19 | 20 | highlighter = Highlighter("标题") 21 | document = "这是一个长度超过 200 的标题,应该被截断。" + "HelloDjangoTutorial" * 200 22 | self.assertTrue( 23 | highlighter.highlight(document).startswith( 24 | '...标题,应该被截断。' 25 | ) 26 | ) 27 | 28 | 29 | class UpdatedAtKeyBitTestCase(unittest.TestCase): 30 | def test_get_data(self): 31 | # 未缓存的情况 32 | key_bit = UpdatedAtKeyBit() 33 | data = key_bit.get_data() 34 | self.assertEqual(data, str(cache.get(key_bit.key))) 35 | 36 | # 已缓存的情况 37 | cache.clear() 38 | now = datetime.utcnow() 39 | now_str = str(now) 40 | cache.set(key_bit.key, now) 41 | self.assertEqual(key_bit.get_data(), now_str) 42 | -------------------------------------------------------------------------------- /blog/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.apps import apps 4 | from django.contrib.auth.models import User 5 | from django.test import TestCase 6 | from django.urls import reverse 7 | from django.utils import timezone 8 | 9 | from ..feeds import AllPostsRssFeed 10 | from ..models import Category, Post, Tag 11 | 12 | 13 | class BlogDataTestCase(TestCase): 14 | def setUp(self): 15 | apps.get_app_config("haystack").signal_processor.teardown() 16 | 17 | # User 18 | self.user = User.objects.create_superuser( 19 | username="admin", email="admin@hellogithub.com", password="admin" 20 | ) 21 | 22 | # 分类 23 | self.cate1 = Category.objects.create(name="测试分类一") 24 | self.cate2 = Category.objects.create(name="测试分类二") 25 | 26 | # 标签 27 | self.tag1 = Tag.objects.create(name="测试标签一") 28 | self.tag2 = Tag.objects.create(name="测试标签二") 29 | 30 | # 文章 31 | self.post1 = Post.objects.create( 32 | title="测试标题一", body="测试内容一", category=self.cate1, author=self.user, 33 | ) 34 | self.post1.tags.add(self.tag1) 35 | self.post1.save() 36 | 37 | self.post2 = Post.objects.create( 38 | title="测试标题二", 39 | body="测试内容二", 40 | category=self.cate2, 41 | author=self.user, 42 | created_time=timezone.now() - timedelta(days=100), 43 | ) 44 | 45 | 46 | class IndexViewTestCase(BlogDataTestCase): 47 | def setUp(self): 48 | super().setUp() 49 | self.url = reverse("blog:index") 50 | 51 | def test_without_any_post(self): 52 | Post.objects.all().delete() 53 | response = self.client.get(self.url) 54 | self.assertEqual(response.status_code, 200) 55 | self.assertTemplateUsed("blog/index.html") 56 | self.assertContains(response, "暂时还没有发布的文章!") 57 | 58 | def test_with_posts(self): 59 | response = self.client.get(self.url) 60 | self.assertEqual(response.status_code, 200) 61 | self.assertTemplateUsed("blog/index.html") 62 | self.assertContains(response, self.post1.title) 63 | self.assertContains(response, self.post2.title) 64 | self.assertIn("post_list", response.context) 65 | self.assertIn("is_paginated", response.context) 66 | self.assertIn("page_obj", response.context) 67 | 68 | expected_qs = Post.objects.all().order_by("-created_time") 69 | self.assertQuerysetEqual( 70 | response.context["post_list"], [repr(p) for p in expected_qs] 71 | ) 72 | 73 | 74 | class CategoryViewTestCase(BlogDataTestCase): 75 | def setUp(self): 76 | super().setUp() 77 | self.url = reverse("blog:category", kwargs={"pk": self.cate1.pk}) 78 | self.url2 = reverse("blog:category", kwargs={"pk": self.cate2.pk}) 79 | 80 | def test_visit_a_nonexistent_category(self): 81 | url = reverse("blog:category", kwargs={"pk": 100}) 82 | response = self.client.get(url) 83 | self.assertEqual(response.status_code, 404) 84 | 85 | def test_without_any_post(self): 86 | Post.objects.all().delete() 87 | response = self.client.get(self.url2) 88 | self.assertEqual(response.status_code, 200) 89 | self.assertTemplateUsed("blog/index.html") 90 | self.assertContains(response, "暂时还没有发布的文章!") 91 | 92 | def test_with_posts(self): 93 | response = self.client.get(self.url) 94 | self.assertEqual(response.status_code, 200) 95 | self.assertTemplateUsed("blog/index.html") 96 | self.assertContains(response, self.post1.title) 97 | self.assertIn("post_list", response.context) 98 | self.assertIn("is_paginated", response.context) 99 | self.assertIn("page_obj", response.context) 100 | self.assertEqual(response.context["post_list"].count(), 1) 101 | expected_qs = self.cate1.post_set.all().order_by("-created_time") 102 | self.assertQuerysetEqual( 103 | response.context["post_list"], [repr(p) for p in expected_qs] 104 | ) 105 | 106 | 107 | class ArchiveViewTestCase(BlogDataTestCase): 108 | def setUp(self): 109 | super().setUp() 110 | self.url = reverse( 111 | "blog:archive", 112 | kwargs={ 113 | "year": self.post1.created_time.year, 114 | "month": self.post1.created_time.month, 115 | }, 116 | ) 117 | 118 | def test_without_any_post(self): 119 | Post.objects.all().delete() 120 | 121 | response = self.client.get(self.url) 122 | self.assertEqual(response.status_code, 200) 123 | self.assertTemplateUsed("blog/index.html") 124 | self.assertContains(response, "暂时还没有发布的文章!") 125 | 126 | def test_with_posts(self): 127 | response = self.client.get(self.url) 128 | self.assertEqual(response.status_code, 200) 129 | self.assertTemplateUsed("blog/index.html") 130 | self.assertContains(response, self.post1.title) 131 | self.assertIn("post_list", response.context) 132 | self.assertIn("is_paginated", response.context) 133 | self.assertIn("page_obj", response.context) 134 | 135 | self.assertEqual(response.context["post_list"].count(), 1) 136 | now = timezone.now() 137 | expected_qs = Post.objects.filter( 138 | created_time__year=now.year, created_time__month=now.month 139 | ) 140 | self.assertQuerysetEqual( 141 | response.context["post_list"], [repr(p) for p in expected_qs] 142 | ) 143 | 144 | 145 | class TagViewTestCase(BlogDataTestCase): 146 | def setUp(self): 147 | super().setUp() 148 | self.url1 = reverse("blog:tag", kwargs={"pk": self.tag1.pk}) 149 | self.url2 = reverse("blog:tag", kwargs={"pk": self.tag2.pk}) 150 | 151 | def test_visit_a_nonexistent_tag(self): 152 | url = reverse("blog:tag", kwargs={"pk": 100}) 153 | response = self.client.get(url) 154 | self.assertEqual(response.status_code, 404) 155 | 156 | def test_without_any_post(self): 157 | response = self.client.get(self.url2) 158 | self.assertEqual(response.status_code, 200) 159 | self.assertTemplateUsed("blog/index.html") 160 | self.assertContains(response, "暂时还没有发布的文章!") 161 | 162 | def test_with_posts(self): 163 | response = self.client.get(self.url1) 164 | self.assertEqual(response.status_code, 200) 165 | self.assertTemplateUsed("blog/index.html") 166 | self.assertContains(response, self.post1.title) 167 | self.assertIn("post_list", response.context) 168 | self.assertIn("is_paginated", response.context) 169 | self.assertIn("page_obj", response.context) 170 | 171 | self.assertEqual(response.context["post_list"].count(), 1) 172 | expected_qs = self.tag1.post_set.all() 173 | self.assertQuerysetEqual( 174 | response.context["post_list"], [repr(p) for p in expected_qs] 175 | ) 176 | 177 | 178 | class PostDetailViewTestCase(BlogDataTestCase): 179 | def setUp(self): 180 | super().setUp() 181 | self.md_post = Post.objects.create( 182 | title="Markdown 测试标题", body="# 标题", category=self.cate1, author=self.user, 183 | ) 184 | self.url = reverse("blog:detail", kwargs={"pk": self.md_post.pk}) 185 | 186 | def test_good_view(self): 187 | response = self.client.get(self.url) 188 | self.assertEqual(response.status_code, 200) 189 | self.assertTemplateUsed("blog/detail.html") 190 | self.assertContains(response, self.md_post.title) 191 | self.assertIn("post", response.context) 192 | 193 | def test_visit_a_nonexistent_post(self): 194 | url = reverse("blog:detail", kwargs={"pk": 100}) 195 | response = self.client.get(url) 196 | self.assertEqual(response.status_code, 404) 197 | 198 | def test_increase_views(self): 199 | self.client.get(self.url) 200 | self.md_post.refresh_from_db() 201 | self.assertEqual(self.md_post.views, 1) 202 | 203 | self.client.get(self.url) 204 | self.md_post.refresh_from_db() 205 | self.assertEqual(self.md_post.views, 2) 206 | 207 | def test_markdownify_post_body_and_set_toc(self): 208 | response = self.client.get(self.url) 209 | self.assertContains(response, "文章目录") 210 | self.assertContains(response, self.md_post.title) 211 | 212 | post_template_var = response.context["post"] 213 | self.assertHTMLEqual(post_template_var.body_html, "

标题

") 214 | self.assertHTMLEqual(post_template_var.toc, '
  • 标题
  • ') 215 | 216 | 217 | class AdminTestCase(BlogDataTestCase): 218 | def setUp(self): 219 | super().setUp() 220 | self.url = reverse("admin:blog_post_add") 221 | 222 | def test_set_author_after_publishing_the_post(self): 223 | data = { 224 | "title": "测试标题", 225 | "body": "测试内容", 226 | "category": self.cate1.pk, 227 | } 228 | self.client.login(username=self.user.username, password="admin") 229 | response = self.client.post(self.url, data=data) 230 | self.assertEqual(response.status_code, 302) 231 | 232 | post = Post.objects.all().latest("created_time") 233 | self.assertEqual(post.author, self.user) 234 | self.assertEqual(post.title, data.get("title")) 235 | self.assertEqual(post.category, self.cate1) 236 | 237 | 238 | class RSSTestCase(BlogDataTestCase): 239 | def setUp(self): 240 | super().setUp() 241 | self.url = reverse("rss") 242 | 243 | def test_rss_subscription_content(self): 244 | response = self.client.get(self.url) 245 | self.assertContains(response, AllPostsRssFeed.title) 246 | self.assertContains(response, AllPostsRssFeed.description) 247 | self.assertContains(response, self.post1.title) 248 | self.assertContains(response, self.post2.title) 249 | self.assertContains( 250 | response, "[%s] %s" % (self.post1.category, self.post1.title) 251 | ) 252 | self.assertContains( 253 | response, "[%s] %s" % (self.post2.category, self.post2.title) 254 | ) 255 | self.assertContains(response, self.post1.body) 256 | self.assertContains(response, self.post2.body) 257 | -------------------------------------------------------------------------------- /blog/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | app_name = "blog" 6 | urlpatterns = [ 7 | path("", views.IndexView.as_view(), name="index"), 8 | path("posts//", views.PostDetailView.as_view(), name="detail"), 9 | path( 10 | "archives///", views.ArchiveView.as_view(), name="archive" 11 | ), 12 | path("categories//", views.CategoryView.as_view(), name="category"), 13 | path("tags//", views.TagView.as_view(), name="tag"), 14 | ] 15 | -------------------------------------------------------------------------------- /blog/utils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from django.core.cache import cache 4 | from django.utils.html import strip_tags 5 | from rest_framework_extensions.key_constructor.bits import KeyBitBase 6 | 7 | from haystack.utils import Highlighter as HaystackHighlighter 8 | 9 | 10 | class Highlighter(HaystackHighlighter): 11 | """ 12 | 自定义关键词高亮器,不截断过短的文本(例如文章标题) 13 | """ 14 | 15 | def highlight(self, text_block): 16 | self.text_block = strip_tags(text_block) 17 | highlight_locations = self.find_highlightable_words() 18 | start_offset, end_offset = self.find_window(highlight_locations) 19 | if len(text_block) < self.max_length: 20 | start_offset = 0 21 | return self.render_html(highlight_locations, start_offset, end_offset) 22 | 23 | 24 | class UpdatedAtKeyBit(KeyBitBase): 25 | key = "updated_at" 26 | 27 | def get_data(self, **kwargs): 28 | value = cache.get(self.key, None) 29 | if not value: 30 | value = datetime.utcnow() 31 | cache.set(self.key, value=value) 32 | return str(value) 33 | -------------------------------------------------------------------------------- /blog/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import get_object_or_404 2 | from django.utils.decorators import method_decorator 3 | from django.views.generic import DetailView, ListView 4 | from django_filters.rest_framework import DjangoFilterBackend 5 | from drf_haystack.viewsets import HaystackViewSet 6 | from drf_yasg import openapi 7 | from drf_yasg.inspectors import FilterInspector 8 | from drf_yasg.utils import swagger_auto_schema 9 | from pure_pagination.mixins import PaginationMixin 10 | from rest_framework import mixins, status, viewsets 11 | from rest_framework.decorators import action 12 | from rest_framework.generics import ListAPIView 13 | from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination 14 | from rest_framework.permissions import AllowAny 15 | from rest_framework.response import Response 16 | from rest_framework.serializers import DateField 17 | from rest_framework.throttling import AnonRateThrottle 18 | from rest_framework_extensions.cache.decorators import cache_response 19 | from rest_framework_extensions.key_constructor.bits import ListSqlQueryKeyBit, PaginationKeyBit, RetrieveSqlQueryKeyBit 20 | from rest_framework_extensions.key_constructor.constructors import DefaultKeyConstructor 21 | 22 | from comments.serializers import CommentSerializer 23 | 24 | from .filters import PostFilter 25 | from .models import Category, Post, Tag 26 | from .serializers import ( 27 | CategorySerializer, PostHaystackSerializer, PostListSerializer, PostRetrieveSerializer, TagSerializer) 28 | from .utils import UpdatedAtKeyBit 29 | 30 | 31 | class IndexView(PaginationMixin, ListView): 32 | model = Post 33 | template_name = "blog/index.html" 34 | context_object_name = "post_list" 35 | paginate_by = 10 36 | 37 | 38 | class CategoryView(IndexView): 39 | def get_queryset(self): 40 | cate = get_object_or_404(Category, pk=self.kwargs.get("pk")) 41 | return super().get_queryset().filter(category=cate) 42 | 43 | 44 | class ArchiveView(IndexView): 45 | def get_queryset(self): 46 | year = self.kwargs.get("year") 47 | month = self.kwargs.get("month") 48 | return ( 49 | super() 50 | .get_queryset() 51 | .filter(created_time__year=year, created_time__month=month) 52 | ) 53 | 54 | 55 | class TagView(IndexView): 56 | def get_queryset(self): 57 | t = get_object_or_404(Tag, pk=self.kwargs.get("pk")) 58 | return super().get_queryset().filter(tags=t) 59 | 60 | 61 | # 记得在顶部导入 DetailView 62 | class PostDetailView(DetailView): 63 | # 这些属性的含义和 ListView 是一样的 64 | model = Post 65 | template_name = "blog/detail.html" 66 | context_object_name = "post" 67 | 68 | def get(self, request, *args, **kwargs): 69 | # 覆写 get 方法的目的是因为每当文章被访问一次,就得将文章阅读量 +1 70 | # get 方法返回的是一个 HttpResponse 实例 71 | # 之所以需要先调用父类的 get 方法,是因为只有当 get 方法被调用后, 72 | # 才有 self.object 属性,其值为 Post 模型实例,即被访问的文章 post 73 | response = super().get(request, *args, **kwargs) 74 | 75 | # 将文章阅读量 +1 76 | # 注意 self.object 的值就是被访问的文章 post 77 | self.object.increase_views() 78 | 79 | # 视图必须返回一个 HttpResponse 对象 80 | return response 81 | 82 | 83 | # --------------------------------------------------------------------------- 84 | # Django REST framework 接口 85 | # --------------------------------------------------------------------------- 86 | 87 | 88 | class PostUpdatedAtKeyBit(UpdatedAtKeyBit): 89 | key = "post_updated_at" 90 | 91 | 92 | class CommentUpdatedAtKeyBit(UpdatedAtKeyBit): 93 | key = "comment_updated_at" 94 | 95 | 96 | class PostListKeyConstructor(DefaultKeyConstructor): 97 | list_sql = ListSqlQueryKeyBit() 98 | pagination = PaginationKeyBit() 99 | updated_at = PostUpdatedAtKeyBit() 100 | 101 | 102 | class PostObjectKeyConstructor(DefaultKeyConstructor): 103 | retrieve_sql = RetrieveSqlQueryKeyBit() 104 | updated_at = PostUpdatedAtKeyBit() 105 | 106 | 107 | class CommentListKeyConstructor(DefaultKeyConstructor): 108 | list_sql = ListSqlQueryKeyBit() 109 | pagination = PaginationKeyBit() 110 | updated_at = CommentUpdatedAtKeyBit() 111 | 112 | 113 | class IndexPostListAPIView(ListAPIView): 114 | serializer_class = PostListSerializer 115 | queryset = Post.objects.all() 116 | pagination_class = PageNumberPagination 117 | permission_classes = [AllowAny] 118 | 119 | 120 | class PostViewSet( 121 | mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet 122 | ): 123 | """ 124 | 博客文章视图集 125 | 126 | list: 127 | 返回博客文章列表 128 | 129 | retrieve: 130 | 返回博客文章详情 131 | 132 | list_comments: 133 | 返回博客文章下的评论列表 134 | 135 | list_archive_dates: 136 | 返回博客文章归档日期列表 137 | """ 138 | 139 | serializer_class = PostListSerializer 140 | queryset = Post.objects.all() 141 | permission_classes = [AllowAny] 142 | serializer_class_table = { 143 | "list": PostListSerializer, 144 | "retrieve": PostRetrieveSerializer, 145 | } 146 | filter_backends = [DjangoFilterBackend] 147 | filterset_class = PostFilter 148 | 149 | def get_serializer_class(self): 150 | return self.serializer_class_table.get( 151 | self.action, super().get_serializer_class() 152 | ) 153 | 154 | @cache_response(timeout=5 * 60, key_func=PostListKeyConstructor()) 155 | def list(self, request, *args, **kwargs): 156 | return super().list(request, *args, **kwargs) 157 | 158 | @cache_response(timeout=5 * 60, key_func=PostObjectKeyConstructor()) 159 | def retrieve(self, request, *args, **kwargs): 160 | return super().retrieve(request, *args, **kwargs) 161 | 162 | @swagger_auto_schema(responses={200: "归档日期列表,时间倒序排列。例如:['2020-08', '2020-06']。"}) 163 | @action( 164 | methods=["GET"], 165 | detail=False, 166 | url_path="archive/dates", 167 | url_name="archive-date", 168 | filter_backends=None, 169 | pagination_class=None, 170 | ) 171 | def list_archive_dates(self, request, *args, **kwargs): 172 | dates = Post.objects.dates("created_time", "month", order="DESC") 173 | date_field = DateField() 174 | data = [date_field.to_representation(date)[:7] for date in dates] 175 | return Response(data=data, status=status.HTTP_200_OK) 176 | 177 | @cache_response(timeout=5 * 60, key_func=CommentListKeyConstructor()) 178 | @action( 179 | methods=["GET"], 180 | detail=True, 181 | url_path="comments", 182 | url_name="comment", 183 | filter_backends=None, # 移除从 PostViewSet 自动继承的 filter_backends,这样 drf-yasg 就不会生成过滤参数 184 | suffix="List", # 将这个 action 返回的结果标记为列表,否则 drf-yasg 会根据 detail=True 将结果误判为单个对象 185 | pagination_class=LimitOffsetPagination, 186 | serializer_class=CommentSerializer, 187 | ) 188 | def list_comments(self, request, *args, **kwargs): 189 | # 根据 URL 传入的参数值(文章 id)获取到博客文章记录 190 | post = self.get_object() 191 | # 获取文章下关联的全部评论 192 | queryset = post.comment_set.all().order_by("-created_time") 193 | # 对评论列表进行分页,根据 URL 传入的参数获取指定页的评论 194 | page = self.paginate_queryset(queryset) 195 | # 序列化评论 196 | serializer = self.get_serializer(page, many=True) 197 | # 返回分页后的评论列表 198 | return self.get_paginated_response(serializer.data) 199 | 200 | 201 | index = PostViewSet.as_view({"get": "list"}) 202 | 203 | 204 | class CategoryViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): 205 | """ 206 | 博客文章分类视图集 207 | 208 | list: 209 | 返回博客文章分类列表 210 | """ 211 | 212 | serializer_class = CategorySerializer 213 | # 关闭分页 214 | pagination_class = None 215 | 216 | def get_queryset(self): 217 | return Category.objects.all().order_by("name") 218 | 219 | 220 | class TagViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): 221 | """ 222 | 博客文章标签视图集 223 | 224 | list: 225 | 返回博客文章标签列表 226 | """ 227 | 228 | serializer_class = TagSerializer 229 | # 关闭分页 230 | pagination_class = None 231 | 232 | def get_queryset(self): 233 | return Tag.objects.all().order_by("name") 234 | 235 | 236 | class PostSearchAnonRateThrottle(AnonRateThrottle): 237 | THROTTLE_RATES = {"anon": "5/min"} 238 | 239 | 240 | class PostSearchFilterInspector(FilterInspector): 241 | def get_filter_parameters(self, filter_backend): 242 | return [ 243 | openapi.Parameter( 244 | name="text", 245 | in_=openapi.IN_QUERY, 246 | required=True, 247 | description="搜索关键词", 248 | type=openapi.TYPE_STRING, 249 | ) 250 | ] 251 | 252 | 253 | @method_decorator( 254 | name="retrieve", 255 | decorator=swagger_auto_schema( 256 | auto_schema=None, 257 | ), 258 | ) 259 | # @method_decorator( 260 | # name="list", 261 | # decorator=swagger_auto_schema( 262 | # operation_description="返回关键词搜索结果", 263 | # filter_inspectors=[PostSearchFilterInspector], 264 | # ), 265 | # ) 266 | class PostSearchView(HaystackViewSet): 267 | """ 268 | 搜索视图集 269 | 270 | list: 271 | 返回搜索结果列表 272 | """ 273 | 274 | index_models = [Post] 275 | serializer_class = PostHaystackSerializer 276 | throttle_classes = [PostSearchAnonRateThrottle] 277 | 278 | 279 | class ApiVersionTestViewSet(viewsets.ViewSet): # pragma: no cover 280 | swagger_schema = None 281 | 282 | @action( 283 | methods=["GET"], 284 | detail=False, 285 | url_path="test", 286 | url_name="test", 287 | ) 288 | def test(self, request, *args, **kwargs): 289 | if request.version == "v1": 290 | return Response( 291 | data={ 292 | "version": request.version, 293 | "warning": "该接口的 v1 版本已废弃,请尽快迁移至 v2 版本", 294 | } 295 | ) 296 | return Response(data={"version": request.version}) 297 | -------------------------------------------------------------------------------- /blogproject/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelloGitHub-Team/HelloDjango-REST-framework-tutorial/36f3534856e643f8ba6b6090728208f5c0c19cd2/blogproject/__init__.py -------------------------------------------------------------------------------- /blogproject/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelloGitHub-Team/HelloDjango-REST-framework-tutorial/36f3534856e643f8ba6b6090728208f5c0c19cd2/blogproject/settings/__init__.py -------------------------------------------------------------------------------- /blogproject/settings/common.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for blogproject project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.2.3. 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 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | back = os.path.dirname 17 | 18 | BASE_DIR = back(back(back(os.path.abspath(__file__)))) 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ 22 | 23 | # Application definition 24 | 25 | INSTALLED_APPS = [ 26 | "django.contrib.admin", 27 | "django.contrib.auth", 28 | "django.contrib.contenttypes", 29 | "django.contrib.sessions", 30 | "django.contrib.messages", 31 | "django.contrib.staticfiles", 32 | "pure_pagination", # 分页 33 | "haystack", # 搜索 34 | "drf_yasg", # 文档 35 | "rest_framework", 36 | "django_filters", 37 | "blog.apps.BlogConfig", # 注册 blog 应用 38 | "comments.apps.CommentsConfig", # 注册 comments 应用 39 | ] 40 | 41 | MIDDLEWARE = [ 42 | "django.middleware.security.SecurityMiddleware", 43 | "django.contrib.sessions.middleware.SessionMiddleware", 44 | "django.middleware.common.CommonMiddleware", 45 | "django.middleware.csrf.CsrfViewMiddleware", 46 | "django.contrib.auth.middleware.AuthenticationMiddleware", 47 | "django.contrib.messages.middleware.MessageMiddleware", 48 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 49 | ] 50 | 51 | ROOT_URLCONF = "blogproject.urls" 52 | 53 | TEMPLATES = [ 54 | { 55 | "BACKEND": "django.template.backends.django.DjangoTemplates", 56 | "DIRS": [os.path.join(BASE_DIR, "templates")], 57 | "APP_DIRS": True, 58 | "OPTIONS": { 59 | "context_processors": [ 60 | "django.template.context_processors.debug", 61 | "django.template.context_processors.request", 62 | "django.contrib.auth.context_processors.auth", 63 | "django.contrib.messages.context_processors.messages", 64 | ], 65 | }, 66 | }, 67 | ] 68 | 69 | WSGI_APPLICATION = "blogproject.wsgi.application" 70 | 71 | # Database 72 | # https://docs.djangoproject.com/en/2.2/ref/settings/#databases 73 | 74 | DATABASES = { 75 | "default": { 76 | "ENGINE": "django.db.backends.sqlite3", 77 | "NAME": os.path.join(BASE_DIR, "database", "db.sqlite3"), 78 | } 79 | } 80 | 81 | # Password validation 82 | # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators 83 | 84 | AUTH_PASSWORD_VALIDATORS = [ 85 | { 86 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 87 | }, 88 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",}, 89 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",}, 90 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",}, 91 | ] 92 | 93 | # Internationalization 94 | # https://docs.djangoproject.com/en/2.2/topics/i18n/ 95 | 96 | LANGUAGE_CODE = "zh-hans" 97 | 98 | TIME_ZONE = "Asia/Shanghai" 99 | 100 | USE_I18N = True 101 | 102 | USE_L10N = True 103 | 104 | USE_TZ = True 105 | 106 | # Static files (CSS, JavaScript, Images) 107 | # https://docs.djangoproject.com/en/2.2/howto/static-files/ 108 | 109 | STATIC_URL = "/static/" 110 | STATIC_ROOT = os.path.join(BASE_DIR, "static") 111 | 112 | # 分页设置 113 | PAGINATION_SETTINGS = { 114 | "PAGE_RANGE_DISPLAYED": 4, 115 | "MARGIN_PAGES_DISPLAYED": 2, 116 | "SHOW_FIRST_PAGE_WHEN_INVALID": True, 117 | } 118 | 119 | # 搜索设置 120 | HAYSTACK_CONNECTIONS = { 121 | "default": { 122 | "ENGINE": "blog.elasticsearch2_ik_backend.Elasticsearch2IkSearchEngine", 123 | "URL": "", 124 | "INDEX_NAME": "hellodjango_blog_tutorial", 125 | }, 126 | } 127 | HAYSTACK_SEARCH_RESULTS_PER_PAGE = 10 128 | 129 | enable = os.environ.get("ENABLE_HAYSTACK_REALTIME_SIGNAL_PROCESSOR", "yes") 130 | if enable in {"true", "True", "yes"}: 131 | HAYSTACK_SIGNAL_PROCESSOR = "haystack.signals.RealtimeSignalProcessor" 132 | 133 | HAYSTACK_CUSTOM_HIGHLIGHTER = "blog.utils.Highlighter" 134 | # HAYSTACK_DEFAULT_OPERATOR = 'AND' 135 | # HAYSTACK_FUZZY_MIN_SIM = 0.1 136 | 137 | # django-rest-framework 138 | # ------------------------------------------------------------------------------ 139 | REST_FRAMEWORK = { 140 | # 设置 DEFAULT_PAGINATION_CLASS 后,将全局启用分页,所有 List 接口的返回结果都会被分页。 141 | # 如果想单独控制每个接口的分页情况,可不设置这个选项,而是在视图函数中进行配置 142 | "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", 143 | # 这个选项控制分页后每页的资源个数 144 | "PAGE_SIZE": 10, 145 | # API 版本控制 146 | "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.NamespaceVersioning", 147 | "DEFAULT_VERSION": "v1", 148 | # 限流 149 | "DEFAULT_THROTTLE_CLASSES": [ 150 | "rest_framework.throttling.AnonRateThrottle", 151 | ], 152 | "DEFAULT_THROTTLE_RATES": {"anon": "10/min"}, 153 | } 154 | -------------------------------------------------------------------------------- /blogproject/settings/local.py: -------------------------------------------------------------------------------- 1 | from .common import * 2 | 3 | SECRET_KEY = 'development-secret-key' 4 | 5 | DEBUG = True 6 | 7 | ALLOWED_HOSTS = ['*'] 8 | 9 | # 搜索设置 10 | HAYSTACK_CONNECTIONS['default']['URL'] = 'http://elasticsearch.local:9200/' 11 | -------------------------------------------------------------------------------- /blogproject/settings/production.py: -------------------------------------------------------------------------------- 1 | from .common import * 2 | 3 | SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] 4 | 5 | DEBUG = True 6 | 7 | ALLOWED_HOSTS = [ 8 | "hellodjango-blog-tutorial-demo.zmrenwu.com", 9 | "127.0.0.1", 10 | "192.168.10.73", 11 | ] 12 | HAYSTACK_CONNECTIONS["default"]["URL"] = "http://elasticsearch:9200/" 13 | 14 | CACHES = { 15 | "default": { 16 | "BACKEND": "redis_cache.RedisCache", 17 | "LOCATION": "redis://:UJaoRZlNrH40BDaWU6fi@redis:6379/0", 18 | "OPTIONS": { 19 | "CONNECTION_POOL_CLASS": "redis.BlockingConnectionPool", 20 | "CONNECTION_POOL_CLASS_KWARGS": {"max_connections": 50, "timeout": 20}, 21 | "MAX_CONNECTIONS": 1000, 22 | "PICKLE_VERSION": -1, 23 | }, 24 | }, 25 | } 26 | -------------------------------------------------------------------------------- /blogproject/urls.py: -------------------------------------------------------------------------------- 1 | """blogproject URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import include, path, re_path 18 | from drf_yasg import openapi 19 | from drf_yasg.views import get_schema_view 20 | from rest_framework import permissions, routers 21 | 22 | import blog.views 23 | import comments.views 24 | from blog.feeds import AllPostsRssFeed 25 | 26 | router = routers.DefaultRouter() 27 | router.register(r"posts", blog.views.PostViewSet, basename="post") 28 | router.register(r"categories", blog.views.CategoryViewSet, basename="category") 29 | router.register(r"tags", blog.views.TagViewSet, basename="tag") 30 | router.register(r"comments", comments.views.CommentViewSet, basename="comment") 31 | router.register(r"search", blog.views.PostSearchView, basename="search") 32 | # 仅用于 API 版本管理测试 33 | router.register( 34 | r"api-version", blog.views.ApiVersionTestViewSet, basename="api-version" 35 | ) 36 | 37 | schema_view = get_schema_view( 38 | openapi.Info( 39 | title="HelloDjango REST framework tutorial API", 40 | default_version="v1", 41 | description="HelloDjango REST framework tutorial AP", 42 | terms_of_service="", 43 | contact=openapi.Contact(email="zmrenwu@163.com"), 44 | license=openapi.License(name="GPLv3 License"), 45 | ), 46 | public=True, 47 | permission_classes=(permissions.AllowAny,), 48 | ) 49 | 50 | urlpatterns = [ 51 | path("admin/", admin.site.urls), 52 | path("search/", include("haystack.urls")), 53 | path("", include("blog.urls")), 54 | path("", include("comments.urls")), 55 | # 记得在顶部引入 AllPostsRssFeed 56 | path("all/rss/", AllPostsRssFeed(), name="rss"), 57 | path("api/v1/", include((router.urls, "api"), namespace="v1")), 58 | path("api/v2/", include((router.urls, "api"), namespace="v2")), 59 | path("api/auth/", include("rest_framework.urls", namespace="rest_framework")), 60 | # 文档 61 | re_path( 62 | r"swagger(?P\.json|\.yaml)", 63 | schema_view.without_ui(cache_timeout=0), 64 | name="schema-json", 65 | ), 66 | path( 67 | "swagger/", 68 | schema_view.with_ui("swagger", cache_timeout=0), 69 | name="schema-swagger-ui", 70 | ), 71 | path("redoc/", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc"), 72 | ] 73 | -------------------------------------------------------------------------------- /blogproject/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for blogproject 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', 'blogproject.settings.production') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /comments/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelloGitHub-Team/HelloDjango-REST-framework-tutorial/36f3534856e643f8ba6b6090728208f5c0c19cd2/comments/__init__.py -------------------------------------------------------------------------------- /comments/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import Comment 3 | 4 | 5 | class CommentAdmin(admin.ModelAdmin): 6 | list_display = ['name', 'email', 'url', 'post', 'created_time'] 7 | fields = ['name', 'email', 'url', 'text', 'post'] 8 | 9 | 10 | admin.site.register(Comment, CommentAdmin) 11 | -------------------------------------------------------------------------------- /comments/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CommentsConfig(AppConfig): 5 | name = 'comments' 6 | verbose_name = '评论' 7 | -------------------------------------------------------------------------------- /comments/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from .models import Comment 4 | 5 | 6 | class CommentForm(forms.ModelForm): 7 | class Meta: 8 | model = Comment 9 | fields = ['name', 'email', 'url', 'text'] 10 | -------------------------------------------------------------------------------- /comments/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.10 on 2020-04-12 13:30 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import django.utils.timezone 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ('blog', '0001_initial'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Comment', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('name', models.CharField(max_length=50, verbose_name='名字')), 22 | ('email', models.EmailField(max_length=254, verbose_name='邮箱')), 23 | ('url', models.URLField(blank=True, verbose_name='网址')), 24 | ('text', models.TextField(verbose_name='内容')), 25 | ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), 26 | ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.Post', verbose_name='文章')), 27 | ], 28 | options={ 29 | 'verbose_name': '评论', 30 | 'verbose_name_plural': '评论', 31 | 'ordering': ['-created_time'], 32 | }, 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /comments/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelloGitHub-Team/HelloDjango-REST-framework-tutorial/36f3534856e643f8ba6b6090728208f5c0c19cd2/comments/migrations/__init__.py -------------------------------------------------------------------------------- /comments/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from django.core.cache import cache 4 | from django.db import models 5 | from django.db.models.signals import post_delete, post_save 6 | from django.utils import timezone 7 | 8 | 9 | class Comment(models.Model): 10 | name = models.CharField("名字", max_length=50) 11 | email = models.EmailField("邮箱") 12 | url = models.URLField("网址", blank=True) 13 | text = models.TextField("内容") 14 | created_time = models.DateTimeField("创建时间", default=timezone.now) 15 | post = models.ForeignKey("blog.Post", verbose_name="文章", on_delete=models.CASCADE) 16 | 17 | class Meta: 18 | verbose_name = "评论" 19 | verbose_name_plural = verbose_name 20 | ordering = ["-created_time"] 21 | 22 | def __str__(self): 23 | return "{}: {}".format(self.name, self.text[:20]) 24 | 25 | 26 | def change_comment_updated_at(sender=None, instance=None, *args, **kwargs): 27 | cache.set("comment_updated_at", datetime.utcnow()) 28 | 29 | 30 | post_save.connect(receiver=change_comment_updated_at, sender=Comment) 31 | post_delete.connect(receiver=change_comment_updated_at, sender=Comment) 32 | -------------------------------------------------------------------------------- /comments/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from .models import Comment 4 | 5 | 6 | class CommentSerializer(serializers.ModelSerializer): 7 | class Meta: 8 | model = Comment 9 | fields = [ 10 | "name", 11 | "email", 12 | "url", 13 | "text", 14 | "created_time", 15 | "post", 16 | ] 17 | read_only_fields = [ 18 | "created_time", 19 | ] 20 | extra_kwargs = {"post": {"write_only": True}} 21 | -------------------------------------------------------------------------------- /comments/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelloGitHub-Team/HelloDjango-REST-framework-tutorial/36f3534856e643f8ba6b6090728208f5c0c19cd2/comments/templatetags/__init__.py -------------------------------------------------------------------------------- /comments/templatetags/comments_extras.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from ..forms import CommentForm 3 | 4 | register = template.Library() 5 | 6 | 7 | @register.inclusion_tag('comments/inclusions/_form.html', takes_context=True) 8 | def show_comment_form(context, post, form=None): 9 | if form is None: 10 | form = CommentForm() 11 | return { 12 | 'form': form, 13 | 'post': post, 14 | } 15 | 16 | 17 | @register.inclusion_tag('comments/inclusions/_list.html', takes_context=True) 18 | def show_comments(context, post): 19 | comment_list = post.comment_set.all() 20 | comment_count = comment_list.count() 21 | return { 22 | 'comment_count': comment_count, 23 | 'comment_list': comment_list, 24 | } 25 | -------------------------------------------------------------------------------- /comments/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelloGitHub-Team/HelloDjango-REST-framework-tutorial/36f3534856e643f8ba6b6090728208f5c0c19cd2/comments/tests/__init__.py -------------------------------------------------------------------------------- /comments/tests/base.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.contrib.auth.models import User 3 | from django.test import TestCase 4 | 5 | from blog.models import Category, Post 6 | 7 | 8 | class CommentDataTestCase(TestCase): 9 | def setUp(self): 10 | apps.get_app_config("haystack").signal_processor.teardown() 11 | self.user = User.objects.create_superuser( 12 | username="admin", email="admin@hellogithub.com", password="admin" 13 | ) 14 | self.cate = Category.objects.create(name="测试") 15 | self.post = Post.objects.create( 16 | title="测试标题", body="测试内容", category=self.cate, author=self.user, 17 | ) 18 | -------------------------------------------------------------------------------- /comments/tests/test_api.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.contrib.auth.models import User 3 | from rest_framework import status 4 | from rest_framework.reverse import reverse 5 | from rest_framework.test import APITestCase 6 | 7 | from blog.models import Category, Post 8 | from comments.models import Comment 9 | 10 | 11 | class CommentViewSetTestCase(APITestCase): 12 | def setUp(self): 13 | self.url = reverse("v1:comment-list") 14 | # 断开 haystack 的 signal,测试生成的文章无需生成索引 15 | apps.get_app_config("haystack").signal_processor.teardown() 16 | user = User.objects.create_superuser( 17 | username="admin", email="admin@hellogithub.com", password="admin" 18 | ) 19 | cate = Category.objects.create(name="测试") 20 | self.post = Post.objects.create( 21 | title="测试标题", body="测试内容", category=cate, author=user, 22 | ) 23 | 24 | def test_create_valid_comment(self): 25 | data = { 26 | "name": "user", 27 | "email": "user@example.com", 28 | "text": "test comment text", 29 | "post": self.post.pk, 30 | } 31 | response = self.client.post(self.url, data) 32 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 33 | 34 | comment = Comment.objects.first() 35 | self.assertEqual(comment.name, data["name"]) 36 | self.assertEqual(comment.email, data["email"]) 37 | self.assertEqual(comment.text, data["text"]) 38 | self.assertEqual(comment.post, self.post) 39 | 40 | def test_create_invalid_comment(self): 41 | invalid_data = { 42 | "name": "user", 43 | "email": "user@example.com", 44 | "text": "test comment text", 45 | "post": 999, 46 | } 47 | response = self.client.post(self.url, invalid_data) 48 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 49 | self.assertEqual(Comment.objects.count(), 0) 50 | -------------------------------------------------------------------------------- /comments/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from .base import CommentDataTestCase 2 | from ..models import Comment 3 | 4 | 5 | class CommentModelTestCase(CommentDataTestCase): 6 | def setUp(self) -> None: 7 | super().setUp() 8 | self.comment = Comment.objects.create( 9 | name="评论者", email="a@a.com", text="评论内容", post=self.post, 10 | ) 11 | 12 | def test_str_representation(self): 13 | self.assertEqual(self.comment.__str__(), "评论者: 评论内容") 14 | -------------------------------------------------------------------------------- /comments/tests/test_templatetags.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.apps import apps 4 | from django.contrib.auth.models import User 5 | from django.template import Context, Template 6 | from django.utils import timezone 7 | 8 | from blog.models import Category, Post 9 | from .base import CommentDataTestCase 10 | from ..forms import CommentForm 11 | from ..models import Comment 12 | from ..templatetags.comments_extras import show_comment_form, show_comments 13 | 14 | 15 | class CommentExtraTestCase(CommentDataTestCase): 16 | def setUp(self) -> None: 17 | super().setUp() 18 | self.ctx = Context() 19 | 20 | def test_show_comment_form_with_empty_form(self): 21 | template = Template("{% load comments_extras %}" "{% show_comment_form post %}") 22 | form = CommentForm() 23 | context = Context(show_comment_form(self.ctx, self.post)) 24 | expected_html = template.render(context) 25 | for field in form: 26 | label = ''.format( 27 | field.id_for_label, field.label 28 | ) 29 | self.assertInHTML(label, expected_html) 30 | self.assertInHTML(str(field), expected_html) 31 | 32 | def test_show_comment_form_with_invalid_bound_form(self): 33 | template = Template( 34 | "{% load comments_extras %}" "{% show_comment_form post form %}" 35 | ) 36 | invalid_data = { 37 | "email": "invalid_email", 38 | } 39 | form = CommentForm(data=invalid_data) 40 | self.assertFalse(form.is_valid()) 41 | context = Context(show_comment_form(self.ctx, self.post, form=form)) 42 | expected_html = template.render(context) 43 | for field in form: 44 | label = ''.format( 45 | field.id_for_label, field.label 46 | ) 47 | self.assertInHTML(label, expected_html) 48 | self.assertInHTML(str(field), expected_html) 49 | self.assertInHTML(str(field.errors), expected_html) 50 | 51 | def test_show_comments_without_any_comment(self): 52 | template = Template("{% load comments_extras %}" "{% show_comments post %}") 53 | ctx_dict = show_comments(self.ctx, self.post) 54 | ctx_dict["post"] = self.post 55 | context = Context(ctx_dict) 56 | expected_html = template.render(context) 57 | self.assertInHTML("

    评论列表,共 0 条评论

    ", expected_html) 58 | self.assertInHTML("暂无评论", expected_html) 59 | 60 | def test_show_comments_with_comments(self): 61 | comment1 = Comment.objects.create( 62 | name="评论者1", email="a@a.com", text="评论内容1", post=self.post, 63 | ) 64 | comment2 = Comment.objects.create( 65 | name="评论者2", 66 | email="a@a.com", 67 | text="评论内容2", 68 | post=self.post, 69 | created_time=timezone.now() - timedelta(days=1), 70 | ) 71 | template = Template("{% load comments_extras %}" "{% show_comments post %}") 72 | ctx_dict = show_comments(self.ctx, self.post) 73 | ctx_dict["post"] = self.post 74 | context = Context(ctx_dict) 75 | expected_html = template.render(context) 76 | self.assertInHTML("

    评论列表,共 2 条评论

    ", expected_html) 77 | self.assertInHTML(comment1.name, expected_html) 78 | self.assertInHTML(comment1.text, expected_html) 79 | self.assertInHTML(comment2.name, expected_html) 80 | self.assertInHTML(comment2.text, expected_html) 81 | self.assertQuerysetEqual( 82 | ctx_dict["comment_list"], [repr(c) for c in [comment1, comment2]] 83 | ) 84 | -------------------------------------------------------------------------------- /comments/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.contrib.auth.models import User 3 | from django.urls import reverse 4 | 5 | from blog.models import Category, Post 6 | 7 | from ..models import Comment 8 | from .base import CommentDataTestCase 9 | 10 | 11 | class CommentViewTestCase(CommentDataTestCase): 12 | def setUp(self) -> None: 13 | super().setUp() 14 | self.url = reverse("comments:comment", kwargs={"post_pk": self.post.pk}) 15 | 16 | def test_comment_a_nonexistent_post(self): 17 | url = reverse("comments:comment", kwargs={"post_pk": 100}) 18 | response = self.client.post(url, {}) 19 | self.assertEqual(response.status_code, 404) 20 | 21 | def test_invalid_comment_data(self): 22 | invalid_data = { 23 | "email": "invalid_email", 24 | } 25 | response = self.client.post(self.url, invalid_data) 26 | self.assertTemplateUsed(response, "comments/preview.html") 27 | self.assertIn("post", response.context) 28 | self.assertIn("form", response.context) 29 | form = response.context["form"] 30 | for field_name, errors in form.errors.items(): 31 | for err in errors: 32 | self.assertContains(response, err) 33 | self.assertContains(response, "评论发表失败!请修改表单中的错误后重新提交。") 34 | 35 | def test_valid_comment_data(self): 36 | valid_data = { 37 | "name": "评论者", 38 | "email": "a@a.com", 39 | "text": "评论内容", 40 | } 41 | response = self.client.post(self.url, valid_data, follow=True) 42 | self.assertRedirects(response, self.post.get_absolute_url()) 43 | self.assertContains(response, "评论发表成功!") 44 | self.assertEqual(Comment.objects.count(), 1) 45 | comment = Comment.objects.first() 46 | self.assertEqual(comment.name, valid_data["name"]) 47 | self.assertEqual(comment.text, valid_data["text"]) 48 | -------------------------------------------------------------------------------- /comments/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | app_name = 'comments' 6 | urlpatterns = [ 7 | path('comment/', views.comment, name='comment'), 8 | ] 9 | -------------------------------------------------------------------------------- /comments/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib import messages 2 | from django.shortcuts import get_object_or_404, redirect, render 3 | from django.views.decorators.http import require_POST 4 | from rest_framework import mixins, viewsets 5 | 6 | from blog.models import Post 7 | 8 | from .forms import CommentForm 9 | from .models import Comment 10 | from .serializers import CommentSerializer 11 | 12 | 13 | @require_POST 14 | def comment(request, post_pk): 15 | # 先获取被评论的文章,因为后面需要把评论和被评论的文章关联起来。 16 | # 这里我们使用了 Django 提供的一个快捷函数 get_object_or_404, 17 | # 这个函数的作用是当获取的文章(Post)存在时,则获取;否则返回 404 页面给用户。 18 | post = get_object_or_404(Post, pk=post_pk) 19 | 20 | # django 将用户提交的数据封装在 request.POST 中,这是一个类字典对象。 21 | # 我们利用这些数据构造了 CommentForm 的实例,这样就生成了一个绑定了用户提交数据的表单。 22 | form = CommentForm(request.POST) 23 | 24 | # 当调用 form.is_valid() 方法时,Django 自动帮我们检查表单的数据是否符合格式要求。 25 | if form.is_valid(): 26 | # 检查到数据是合法的,调用表单的 save 方法保存数据到数据库, 27 | # commit=False 的作用是仅仅利用表单的数据生成 Comment 模型类的实例,但还不保存评论数据到数据库。 28 | comment = form.save(commit=False) 29 | 30 | # 将评论和被评论的文章关联起来。 31 | comment.post = post 32 | 33 | # 最终将评论数据保存进数据库,调用模型实例的 save 方法 34 | comment.save() 35 | 36 | messages.add_message(request, messages.SUCCESS, "评论发表成功!", extra_tags="success") 37 | 38 | # 重定向到 post 的详情页,实际上当 redirect 函数接收一个模型的实例时,它会调用这个模型实例的 get_absolute_url 方法, 39 | # 然后重定向到 get_absolute_url 方法返回的 URL。 40 | return redirect(post) 41 | 42 | # 检查到数据不合法,我们渲染一个预览页面,用于展示表单的错误。 43 | # 注意这里被评论的文章 post 也传给了模板,因为我们需要根据 post 来生成表单的提交地址。 44 | context = { 45 | "post": post, 46 | "form": form, 47 | } 48 | messages.add_message( 49 | request, messages.ERROR, "评论发表失败!请修改表单中的错误后重新提交。", extra_tags="danger" 50 | ) 51 | 52 | return render(request, "comments/preview.html", context=context) 53 | 54 | 55 | class CommentViewSet(mixins.CreateModelMixin, viewsets.GenericViewSet): 56 | """ 57 | 博客评论视图集 58 | 59 | create: 60 | 创建博客评论 61 | """ 62 | 63 | serializer_class = CommentSerializer 64 | 65 | def get_queryset(self): # pragma: no cover 66 | return Comment.objects.all() 67 | -------------------------------------------------------------------------------- /compose/local/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6-alpine 2 | 3 | ENV PYTHONUNBUFFERED 1 4 | 5 | # 替换为国内源 6 | RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories 7 | 8 | RUN apk update \ 9 | # Pillow dependencies 10 | && apk add jpeg-dev zlib-dev freetype-dev lcms2-dev openjpeg-dev tiff-dev tk-dev tcl-dev 11 | 12 | WORKDIR /app 13 | 14 | RUN pip install pipenv -i https://pypi.douban.com/simple 15 | 16 | COPY Pipfile /app/Pipfile 17 | COPY Pipfile.lock /app/Pipfile.lock 18 | RUN pipenv install --system --deploy --ignore-pipfile 19 | 20 | COPY ./compose/local/start.sh /start.sh 21 | RUN sed -i 's/\r//' /start.sh 22 | RUN chmod +x /start.sh -------------------------------------------------------------------------------- /compose/local/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | python manage.py migrate 4 | python manage.py runserver 0.0.0.0:8000 -------------------------------------------------------------------------------- /compose/production/django/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6-alpine 2 | 3 | ENV PYTHONUNBUFFERED 1 4 | 5 | # 替换为国内源 6 | RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories 7 | 8 | RUN apk update \ 9 | # Pillow dependencies 10 | && apk add jpeg-dev zlib-dev freetype-dev lcms2-dev openjpeg-dev tiff-dev tk-dev tcl-dev 11 | 12 | WORKDIR /app 13 | 14 | RUN pip install pipenv -i https://pypi.douban.com/simple 15 | 16 | COPY Pipfile /app/Pipfile 17 | COPY Pipfile.lock /app/Pipfile.lock 18 | RUN pipenv install --system --deploy --ignore-pipfile 19 | 20 | COPY . /app 21 | 22 | COPY ./compose/production/django/start.sh /start.sh 23 | RUN sed -i 's/\r//' /start.sh 24 | RUN chmod +x /start.sh -------------------------------------------------------------------------------- /compose/production/django/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | python manage.py migrate 4 | python manage.py collectstatic --noinput 5 | gunicorn blogproject.wsgi:application -w 4 -k gthread -b 0.0.0.0:8000 --chdir=/app -------------------------------------------------------------------------------- /compose/production/elasticsearch/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM elasticsearch:2.4.6-alpine 2 | 3 | COPY ./compose/production/elasticsearch/elasticsearch-analysis-ik-1.10.6.zip /usr/share/elasticsearch/plugins/ 4 | RUN cd /usr/share/elasticsearch/plugins/ && mkdir ik && unzip elasticsearch-analysis-ik-1.10.6.zip -d ik/ 5 | RUN rm /usr/share/elasticsearch/plugins/elasticsearch-analysis-ik-1.10.6.zip 6 | 7 | USER root 8 | COPY ./compose/production/elasticsearch/elasticsearch.yml /usr/share/elasticsearch/config/ 9 | RUN chown elasticsearch:elasticsearch /usr/share/elasticsearch/config/elasticsearch.yml 10 | 11 | USER elasticsearch 12 | 13 | -------------------------------------------------------------------------------- /compose/production/elasticsearch/elasticsearch-analysis-ik-1.10.6.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelloGitHub-Team/HelloDjango-REST-framework-tutorial/36f3534856e643f8ba6b6090728208f5c0c19cd2/compose/production/elasticsearch/elasticsearch-analysis-ik-1.10.6.zip -------------------------------------------------------------------------------- /compose/production/elasticsearch/elasticsearch-analysis-ik-5.6.16.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelloGitHub-Team/HelloDjango-REST-framework-tutorial/36f3534856e643f8ba6b6090728208f5c0c19cd2/compose/production/elasticsearch/elasticsearch-analysis-ik-5.6.16.zip -------------------------------------------------------------------------------- /compose/production/elasticsearch/elasticsearch.yml: -------------------------------------------------------------------------------- 1 | bootstrap.memory_lock: true 2 | network.host: 0.0.0.0 -------------------------------------------------------------------------------- /compose/production/nginx/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:1.17.1 2 | 3 | # 替换为国内源 4 | RUN mv /etc/apt/sources.list /etc/apt/sources.list.bak 5 | COPY ./compose/production/nginx/sources.list /etc/apt/ 6 | RUN apt-get update && apt-get install -y --allow-unauthenticated certbot python-certbot-nginx 7 | 8 | RUN rm /etc/nginx/conf.d/default.conf 9 | COPY ./compose/production/nginx/hellodjango-rest-framework-tutorial.conf /etc/nginx/conf.d/hellodjango-rest-framework-tutorial.conf 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /compose/production/nginx/hellodjango-rest-framework-tutorial.conf-tmpl: -------------------------------------------------------------------------------- 1 | upstream hellodjango_rest_framework_tutorial { 2 | server hellodjango_rest_framework_tutorial:8000; 3 | } 4 | 5 | server { 6 | server_name hellodjango_rest_framework-tutorial-demo.zmrenwu.com; 7 | 8 | location /static { 9 | alias /apps/hellodjango_rest_framework_tutorial/static; 10 | } 11 | 12 | location / { 13 | proxy_set_header Host $host; 14 | proxy_set_header X-Real-IP $remote_addr; 15 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 16 | proxy_set_header X-Forwarded-Proto $scheme; 17 | 18 | proxy_pass http://hellodjango_rest_framework_tutorial; 19 | } 20 | 21 | listen 80; 22 | } -------------------------------------------------------------------------------- /compose/production/nginx/sources.list: -------------------------------------------------------------------------------- 1 | deb-src http://archive.ubuntu.com/ubuntu xenial main restricted #Added by software-properties 2 | deb http://mirrors.aliyun.com/ubuntu/ xenial main restricted 3 | deb-src http://mirrors.aliyun.com/ubuntu/ xenial main restricted multiverse universe #Added by software-properties 4 | deb http://mirrors.aliyun.com/ubuntu/ xenial-updates main restricted 5 | deb-src http://mirrors.aliyun.com/ubuntu/ xenial-updates main restricted multiverse universe #Added by software-properties 6 | deb http://mirrors.aliyun.com/ubuntu/ xenial universe 7 | deb http://mirrors.aliyun.com/ubuntu/ xenial-updates universe 8 | deb http://mirrors.aliyun.com/ubuntu/ xenial multiverse 9 | deb http://mirrors.aliyun.com/ubuntu/ xenial-updates multiverse 10 | deb http://mirrors.aliyun.com/ubuntu/ xenial-backports main restricted universe multiverse 11 | deb-src http://mirrors.aliyun.com/ubuntu/ xenial-backports main restricted universe multiverse #Added by software-properties 12 | deb http://archive.canonical.com/ubuntu xenial partner 13 | deb-src http://archive.canonical.com/ubuntu xenial partner 14 | deb http://mirrors.aliyun.com/ubuntu/ xenial-security main restricted 15 | deb-src http://mirrors.aliyun.com/ubuntu/ xenial-security main restricted multiverse universe #Added by software-properties 16 | deb http://mirrors.aliyun.com/ubuntu/ xenial-security universe 17 | deb http://mirrors.aliyun.com/ubuntu/ xenial-security multiverse 18 | 19 | deb http://mirrors.tencentyun.com/debian/ jessie main non-free contrib 20 | deb http://mirrors.tencentyun.com/debian/ jessie-updates main non-free contrib 21 | deb http://mirrors.tencentyun.com/debian/ jessie-backports main non-free contrib 22 | deb-src http://mirrors.tencentyun.com/debian/ jessie main non-free contrib 23 | deb-src http://mirrors.tencentyun.com/debian/ jessie-updates main non-free contrib 24 | deb-src http://mirrors.tencentyun.com/debian/ jessie-backports main non-free contrib 25 | deb http://mirrors.tencentyun.com/debian-security/ jessie/updates main non-free contrib 26 | deb-src http://mirrors.tencentyun.com/debian-security/ jessie/updates main non-free contrib 27 | 28 | deb http://deb.debian.org/debian stretch main 29 | deb http://security.debian.org/debian-security stretch/updates main 30 | deb http://deb.debian.org/debian stretch-updates main 31 | -------------------------------------------------------------------------------- /cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelloGitHub-Team/HelloDjango-REST-framework-tutorial/36f3534856e643f8ba6b6090728208f5c0c19cd2/cover.jpg -------------------------------------------------------------------------------- /database/readme.md: -------------------------------------------------------------------------------- 1 | 为了兼容 Docker,默认的 sqlite 数据库生成在项目根目录的 database 目录下,因此在生成数据库之前需要确保项目根目录下 database 文件夹的存在。否则在生成数据库时会报错: 2 | 3 | ``` 4 | django.db.utils.OperationalError: unable to open database file 5 | ``` 6 | 7 | 如果使用 MySQL、PostgreSQL 等数据库引擎,则 database 文件夹可有可无。 -------------------------------------------------------------------------------- /fabfile.py: -------------------------------------------------------------------------------- 1 | from fabric import task 2 | from invoke import Responder 3 | 4 | from _credentials import github_password, github_username 5 | 6 | 7 | def _get_github_auth_responders(): 8 | """ 9 | 返回 GitHub 用户名密码自动填充器 10 | """ 11 | username_responder = Responder( 12 | pattern="Username for 'https://github.com':", 13 | response='{}\n'.format(github_username) 14 | ) 15 | password_responder = Responder( 16 | pattern="Password for 'https://{}@github.com':".format(github_username), 17 | response='{}\n'.format(github_password) 18 | ) 19 | return [username_responder, password_responder] 20 | 21 | 22 | @task() 23 | def deploy(c): 24 | supervisor_conf_path = '~/etc/' 25 | supervisor_program_name = 'hellodjango-blog-tutorial' 26 | 27 | project_root_path = '~/apps/HelloDjango-blog-tutorial/' 28 | 29 | # 先停止应用 30 | with c.cd(supervisor_conf_path): 31 | cmd = 'supervisorctl stop {}'.format(supervisor_program_name) 32 | c.run(cmd) 33 | 34 | # 进入项目根目录,从 Git 拉取最新代码 35 | with c.cd(project_root_path): 36 | cmd = 'git pull' 37 | responders = _get_github_auth_responders() 38 | c.run(cmd, watchers=responders) 39 | 40 | # 重新启动应用 41 | with c.cd(supervisor_conf_path): 42 | cmd = 'supervisorctl start {}'.format(supervisor_program_name) 43 | c.run(cmd) 44 | -------------------------------------------------------------------------------- /local.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | volumes: 4 | database_local: 5 | esdata_local: 6 | 7 | services: 8 | hellodjango.rest.framework.tutorial.local: 9 | build: 10 | context: . 11 | dockerfile: ./compose/local/Dockerfile 12 | image: hellodjango_rest_framework_tutorial_local 13 | container_name: hellodjango_rest_framework_tutorial_local 14 | working_dir: /app 15 | volumes: 16 | - database_local:/app/database 17 | - .:/app 18 | ports: 19 | - "8000:8000" 20 | command: /start.sh 21 | depends_on: 22 | - elasticsearch.local 23 | 24 | elasticsearch.local: 25 | build: 26 | context: . 27 | dockerfile: ./compose/production/elasticsearch/Dockerfile 28 | image: hellodjango_rest_framework_tutorial_elasticsearch_local 29 | container_name: hellodjango_rest_framework_tutorial_elasticsearch_local 30 | volumes: 31 | - esdata_local:/usr/share/elasticsearch/data 32 | ports: 33 | - "9200:9200" 34 | environment: 35 | - "ES_JAVA_OPTS=-Xms512m -Xmx512m" 36 | ulimits: 37 | memlock: 38 | soft: -1 39 | hard: -1 40 | nproc: 65536 41 | nofile: 42 | soft: 65536 43 | hard: 65536 -------------------------------------------------------------------------------- /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', 'blogproject.settings.local') 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 | -------------------------------------------------------------------------------- /production.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | volumes: 4 | static: 5 | database: 6 | esdata: 7 | redis_data: 8 | 9 | services: 10 | hellodjango.rest.framework.tutorial: 11 | build: 12 | context: . 13 | dockerfile: compose/production/django/Dockerfile 14 | image: hellodjango_rest_framework_tutorial 15 | container_name: hellodjango_rest_framework_tutorial 16 | working_dir: /app 17 | volumes: 18 | - database:/app/database 19 | - static:/app/static 20 | env_file: 21 | - .envs/.production 22 | expose: 23 | - "8000" 24 | command: /start.sh 25 | depends_on: 26 | - elasticsearch 27 | - redis 28 | 29 | nginx: 30 | build: 31 | context: . 32 | dockerfile: compose/production/nginx/Dockerfile 33 | image: hellodjango_rest_framework_tutorial_nginx 34 | container_name: hellodjango_rest_framework_tutorial_nginx 35 | volumes: 36 | - static:/apps/hellodjango_rest_framework_tutorial/static 37 | ports: 38 | - "80:80" 39 | - "443:443" 40 | depends_on: 41 | - hellodjango.rest.framework.tutorial 42 | 43 | elasticsearch: 44 | build: 45 | context: . 46 | dockerfile: ./compose/production/elasticsearch/Dockerfile 47 | image: hellodjango_rest_framework_tutorial_elasticsearch 48 | container_name: hellodjango_rest_framework_tutorial_elasticsearch 49 | volumes: 50 | - esdata:/usr/share/elasticsearch/data 51 | ports: 52 | - "9200:9200" 53 | environment: 54 | - "ES_JAVA_OPTS=-Xms512m -Xmx512m" 55 | ulimits: 56 | memlock: 57 | soft: -1 58 | hard: -1 59 | nproc: 65536 60 | nofile: 61 | soft: 65536 62 | hard: 65536 63 | 64 | redis: 65 | image: 'bitnami/redis:5.0' 66 | container_name: hellodjango_rest_framework_tutorial_redis 67 | ports: 68 | - '6379:6379' 69 | volumes: 70 | - 'redis_data:/bitnami/redis/data' 71 | env_file: 72 | - .envs/.production 73 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fabric 2 | coverage 3 | django~=2.2 4 | markdown 5 | gunicorn 6 | faker 7 | django-pure-pagination 8 | elasticsearch>=2,<3 9 | django-haystack 10 | djangorestframework 11 | django-filter 12 | -------------------------------------------------------------------------------- /scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelloGitHub-Team/HelloDjango-REST-framework-tutorial/36f3534856e643f8ba6b6090728208f5c0c19cd2/scripts/__init__.py -------------------------------------------------------------------------------- /scripts/fake.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | import random 4 | import sys 5 | from datetime import timedelta 6 | 7 | import django 8 | from django.apps import apps 9 | from django.utils import timezone 10 | 11 | import faker 12 | 13 | # 将项目根目录添加到 Python 的模块搜索路径中 14 | back = os.path.dirname 15 | BASE_DIR = back(back(os.path.abspath(__file__))) 16 | sys.path.append(BASE_DIR) 17 | 18 | if __name__ == "__main__": 19 | # 启动 django 20 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "blogproject.settings.local") 21 | django.setup() 22 | 23 | from blog.models import Category, Post, Tag 24 | from comments.models import Comment 25 | from django.contrib.auth.models import User 26 | 27 | # 取消实时索引生成,因为本地运行 fake 脚本时可能并未启动 Elasticsearch 服务。 28 | apps.get_app_config("haystack").signal_processor.teardown() 29 | 30 | print("clean database") 31 | Post.objects.all().delete() 32 | Category.objects.all().delete() 33 | Tag.objects.all().delete() 34 | Comment.objects.all().delete() 35 | User.objects.all().delete() 36 | 37 | print("create a blog user") 38 | user = User.objects.create_superuser("admin", "admin@hellogithub.com", "admin") 39 | 40 | category_list = ["Python学习笔记", "开源项目", "工具资源", "程序员生活感悟", "test category"] 41 | tag_list = [ 42 | "django", 43 | "Python", 44 | "Pipenv", 45 | "Docker", 46 | "Nginx", 47 | "Elasticsearch", 48 | "Gunicorn", 49 | "Supervisor", 50 | "test tag", 51 | ] 52 | a_year_ago = timezone.now() - timedelta(days=365) 53 | 54 | print("create categories and tags") 55 | for cate in category_list: 56 | Category.objects.create(name=cate) 57 | 58 | for tag in tag_list: 59 | Tag.objects.create(name=tag) 60 | 61 | print("create a markdown sample post") 62 | Post.objects.create( 63 | title="Markdown 与代码高亮测试", 64 | body=pathlib.Path(BASE_DIR) 65 | .joinpath("scripts", "md.sample") 66 | .read_text(encoding="utf-8"), 67 | category=Category.objects.create(name="Markdown测试"), 68 | author=user, 69 | ) 70 | 71 | print("create some faked posts published within the past year") 72 | fake = faker.Faker() # English 73 | for _ in range(100): 74 | tags = Tag.objects.order_by("?") 75 | tag1 = tags.first() 76 | tag2 = tags.last() 77 | cate = Category.objects.order_by("?").first() 78 | created_time = fake.date_time_between( 79 | start_date="-1y", end_date="now", tzinfo=timezone.get_current_timezone() 80 | ) 81 | post = Post.objects.create( 82 | title=fake.sentence().rstrip("."), 83 | body="\n\n".join(fake.paragraphs(10)), 84 | created_time=created_time, 85 | category=cate, 86 | author=user, 87 | ) 88 | post.tags.add(tag1, tag2) 89 | post.save() 90 | 91 | fake = faker.Faker("zh_CN") 92 | for _ in range(100): # Chinese 93 | tags = Tag.objects.order_by("?") 94 | tag1 = tags.first() 95 | tag2 = tags.last() 96 | cate = Category.objects.order_by("?").first() 97 | created_time = fake.date_time_between( 98 | start_date="-1y", end_date="now", tzinfo=timezone.get_current_timezone() 99 | ) 100 | post = Post.objects.create( 101 | title=fake.sentence().rstrip("."), 102 | body="\n\n".join(fake.paragraphs(10)), 103 | created_time=created_time, 104 | category=cate, 105 | author=user, 106 | ) 107 | post.tags.add(tag1, tag2) 108 | post.save() 109 | 110 | print("create some comments") 111 | for post in Post.objects.all()[:20]: 112 | post_created_time = post.created_time 113 | delta_in_days = "-" + str((timezone.now() - post_created_time).days) + "d" 114 | for _ in range(random.randrange(3, 15)): 115 | Comment.objects.create( 116 | name=fake.name(), 117 | email=fake.email(), 118 | url=fake.uri(), 119 | text=fake.paragraph(), 120 | created_time=fake.date_time_between( 121 | start_date=delta_in_days, 122 | end_date="now", 123 | tzinfo=timezone.get_current_timezone(), 124 | ), 125 | post=post, 126 | ) 127 | 128 | print("done!") 129 | -------------------------------------------------------------------------------- /scripts/md.sample: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | # 欢迎使用马克飞象 5 | 6 | @(示例笔记本)[马克飞象|帮助|Markdown] 7 | 8 | **马克飞象**是一款专为印象笔记(Evernote)打造的Markdown编辑器,通过精心的设计与技术实现,配合印象笔记强大的存储和同步功能,带来前所未有的书写体验。特点概述: 9 | 10 | - **功能丰富** :支持高亮代码块、*LaTeX* 公式、流程图,本地图片以及附件上传,甚至截图粘贴,工作学习好帮手; 11 | - **得心应手** :简洁高效的编辑器,提供[桌面客户端][1]以及[离线Chrome App][2],支持移动端 Web; 12 | - **深度整合** :支持选择笔记本和添加标签,支持从印象笔记跳转编辑,轻松管理。 13 | 14 | ------------------- 15 | 16 | [TOC] 17 | 18 | ## Markdown简介 19 | 20 | > Markdown 是一种轻量级标记语言,它允许人们使用易读易写的纯文本格式编写文档,然后转换成格式丰富的HTML页面。 —— [维基百科](https://zh.wikipedia.org/wiki/Markdown) 21 | 22 | 正如您在阅读的这份文档,它使用简单的符号标识不同的标题,将某些文字标记为**粗体**或者*斜体*,创建一个[链接](http://www.example.com)或一个脚注[^demo]。下面列举了几个高级功能,更多语法请按`Ctrl + /`查看帮助。 23 | 24 | ### 代码块 25 | ``` python 26 | @requires_authorization 27 | def somefunc(param1='', param2=0): 28 | '''A docstring''' 29 | if param1 > param2: # interesting 30 | print 'Greater' 31 | return (param2 - param1 + 1) or None 32 | class SomeClass: 33 | pass 34 | >>> message = '''interpreter 35 | ... prompt''' 36 | ``` 37 | ### LaTeX 公式 38 | 39 | 可以创建行内公式,例如 $\Gamma(n) = (n-1)!\quad\forall n\in\mathbb N$。或者块级公式: 40 | 41 | $$ x = \dfrac{-b \pm \sqrt{b^2 - 4ac}}{2a} $$ 42 | 43 | ### 表格 44 | | Item | Value | Qty | 45 | | :-------- | --------:| :--: | 46 | | Computer | 1600 USD | 5 | 47 | | Phone | 12 USD | 12 | 48 | | Pipe | 1 USD | 234 | 49 | 50 | ### 流程图 51 | ```flow 52 | st=>start: Start 53 | e=>end 54 | op=>operation: My Operation 55 | cond=>condition: Yes or No? 56 | 57 | st->op->cond 58 | cond(yes)->e 59 | cond(no)->op 60 | ``` 61 | 62 | 以及时序图: 63 | 64 | ```sequence 65 | Alice->Bob: Hello Bob, how are you? 66 | Note right of Bob: Bob thinks 67 | Bob-->Alice: I am good thanks! 68 | ``` 69 | 70 | > **提示:**想了解更多,请查看**流程图**[语法][3]以及**时序图**[语法][4]。 71 | 72 | ### 复选框 73 | 74 | 使用 `- [ ]` 和 `- [x]` 语法可以创建复选框,实现 todo-list 等功能。例如: 75 | 76 | - [x] 已完成事项 77 | - [ ] 待办事项1 78 | - [ ] 待办事项2 79 | 80 | > **注意:**目前支持尚不完全,在印象笔记中勾选复选框是无效、不能同步的,所以必须在**马克飞象**中修改 Markdown 原文才可生效。下个版本将会全面支持。 81 | 82 | 83 | ## 印象笔记相关 84 | 85 | ### 笔记本和标签 86 | **马克飞象**增加了`@(笔记本)[标签A|标签B]`语法, 以选择笔记本和添加标签。 **绑定账号后**, 输入`(`自动会出现笔记本列表,请从中选择。 87 | 88 | ### 笔记标题 89 | **马克飞象**会自动使用文档内出现的第一个标题作为笔记标题。例如本文,就是第一行的 `欢迎使用马克飞象`。 90 | 91 | ### 快捷编辑 92 | 保存在印象笔记中的笔记,右上角会有一个红色的编辑按钮,点击后会回到**马克飞象**中打开并编辑该笔记。 93 | >**注意:**目前用户在印象笔记中单方面做的任何修改,马克飞象是无法自动感知和更新的。所以请务必回到马克飞象编辑。 94 | 95 | ### 数据同步 96 | **马克飞象**通过**将Markdown原文以隐藏内容保存在笔记中**的精妙设计,实现了对Markdown的存储和再次编辑。既解决了其他产品只是单向导出HTML的单薄,又规避了服务端存储Markdown带来的隐私安全问题。这样,服务端仅作为对印象笔记 API调用和数据转换之用。 97 | 98 | >**隐私声明:用户所有的笔记数据,均保存在印象笔记中。马克飞象不存储用户的任何笔记数据。** 99 | 100 | ### 离线存储 101 | **马克飞象**使用浏览器离线存储将内容实时保存在本地,不必担心网络断掉或浏览器崩溃。为了节省空间和避免冲突,已同步至印象笔记并且不再修改的笔记将删除部分本地缓存,不过依然可以随时通过`文档管理`打开。 102 | 103 | > **注意:**虽然浏览器存储大部分时候都比较可靠,但印象笔记作为专业云存储,更值得信赖。以防万一,**请务必经常及时同步到印象笔记**。 104 | 105 | ## 编辑器相关 106 | ### 设置 107 | 右侧系统菜单(快捷键`Ctrl + M`)的`设置`中,提供了界面字体、字号、自定义CSS、vim/emacs 键盘模式等高级选项。 108 | 109 | ### 快捷键 110 | 111 | 帮助 `Ctrl + /` 112 | 同步文档 `Ctrl + S` 113 | 创建文档 `Ctrl + Alt + N` 114 | 最大化编辑器 `Ctrl + Enter` 115 | 预览文档 `Ctrl + Alt + Enter` 116 | 文档管理 `Ctrl + O` 117 | 系统菜单 `Ctrl + M` 118 | 119 | 加粗 `Ctrl + B` 120 | 插入图片 `Ctrl + G` 121 | 插入链接 `Ctrl + L` 122 | 提升标题 `Ctrl + H` 123 | 124 | ## 关于收费 125 | 126 | **马克飞象**为新用户提供 10 天的试用期,试用期过后需要[续费](maxiang.info/vip.html)才能继续使用。未购买或者未及时续费,将不能同步新的笔记。之前保存过的笔记依然可以编辑。 127 | 128 | 129 | ## 反馈与建议 130 | - 微博:[@马克飞象](http://weibo.com/u/2788354117),[@GGock](http://weibo.com/ggock "开发者个人账号") 131 | - 邮箱: 132 | 133 | --------- 134 | 感谢阅读这份帮助文档。请点击右上角,绑定印象笔记账号,开启全新的记录与分享体验吧。 135 | 136 | 137 | 138 | 139 | [^demo]: 这是一个示例脚注。请查阅 [MultiMarkdown 文档](https://github.com/fletcher/MultiMarkdown/wiki/MultiMarkdown-Syntax-Guide#footnotes) 关于脚注的说明。 **限制:** 印象笔记的笔记内容使用 [ENML][5] 格式,基于 HTML,但是不支持某些标签和属性,例如id,这就导致`脚注`和`TOC`无法正常点击。 140 | 141 | 142 | [1]: http://maxiang.info/client_zh 143 | [2]: https://chrome.google.com/webstore/detail/kidnkfckhbdkfgbicccmdggmpgogehop 144 | [3]: http://adrai.github.io/flowchart.js/ 145 | [4]: http://bramp.github.io/js-sequence-diagrams/ 146 | [5]: https://dev.yinxiang.com/doc/articles/enml.php 147 | 148 | 149 | 欢迎使用马克飞象 150 | 示例笔记本 马克飞象 帮助 Markdown 151 | 152 | 153 | 马克飞象是一款专为印象笔记(Evernote)打造的Markdown编辑器,通过精心的设计与技术实现,配合印象笔记强大的存储和同步功能,带来前所未有的书写体验。特点概述: 154 | 155 | 功能丰富 :支持高亮代码块、LaTeX 公式、流程图,本地图片以及附件上传,甚至截图粘贴,工作学习好帮手; 156 | 157 | 得心应手 :简洁高效的编辑器,提供桌面客户端以及离线Chrome App,支持移动端 Web; 158 | 159 | 深度整合 :支持选择笔记本和添加标签,支持从印象笔记跳转编辑,轻松管理。 160 | 161 | 欢迎使用马克飞象 162 | Markdown简介 163 | 代码块 164 | LaTeX 公式 165 | 表格 166 | 流程图 167 | 复选框 168 | 印象笔记相关 169 | 笔记本和标签 170 | 笔记标题 171 | 快捷编辑 172 | 数据同步 173 | 离线存储 174 | 编辑器相关 175 | 设置 176 | 快捷键 177 | 关于收费 178 | 反馈与建议 179 | Markdown简介 180 | Markdown 是一种轻量级标记语言,它允许人们使用易读易写的纯文本格式编写文档,然后转换成格式丰富的HTML页面。 —— 维基百科 181 | 182 | 正如您在阅读的这份文档,它使用简单的符号标识不同的标题,将某些文字标记为粗体或者斜体,创建一个链接或一个脚注1。下面列举了几个高级功能,更多语法请按Ctrl + /查看帮助。 183 | 184 | 代码块 185 | @requires_authorization 186 | def somefunc(param1='', param2=0): 187 | '''A docstring''' 188 | if param1 > param2: # interesting 189 | print 'Greater' 190 | return (param2 - param1 + 1) or None 191 | class SomeClass: 192 | pass 193 | >>> message = '''interpreter 194 | ... prompt''' 195 | LaTeX 公式 196 | 可以创建行内公式,例如 。或者块级公式: 197 | 198 | 表格 199 | Item Value Qty 200 | Computer 1600 USD 5 201 | Phone 12 USD 12 202 | Pipe 1 USD 234 203 | 流程图 204 | Start 205 | My Operation 206 | Yes or No? 207 | End 208 | yes 209 | no 210 | 以及时序图: 211 | 212 | Alice 213 | Alice 214 | Bob 215 | Bob 216 | Hello Bob, how are you? 217 | Bob thinks 218 | I am good thanks! 219 | 提示:想了解更多,请查看流程图语法以及时序图语法。 220 | 221 | 复选框 222 | 使用 - [ ] 和 - [x] 语法可以创建复选框,实现 todo-list 等功能。例如: 223 | 224 | 已完成事项 225 | 待办事项1 226 | 待办事项2 227 | 注意:目前支持尚不完全,在印象笔记中勾选复选框是无效、不能同步的,所以必须在马克飞象中修改 Markdown 原文才可生效。下个版本将会全面支持。 228 | 229 | 印象笔记相关 230 | 笔记本和标签 231 | 马克飞象增加了@(笔记本)[标签A|标签B]语法, 以选择笔记本和添加标签。 绑定账号后, 输入(自动会出现笔记本列表,请从中选择。 232 | 233 | 笔记标题 234 | 马克飞象会自动使用文档内出现的第一个标题作为笔记标题。例如本文,就是第一行的 欢迎使用马克飞象。 235 | 236 | 快捷编辑 237 | 保存在印象笔记中的笔记,右上角会有一个红色的编辑按钮,点击后会回到马克飞象中打开并编辑该笔记。 238 | 239 | 注意:目前用户在印象笔记中单方面做的任何修改,马克飞象是无法自动感知和更新的。所以请务必回到马克飞象编辑。 240 | 241 | 数据同步 242 | 马克飞象通过将Markdown原文以隐藏内容保存在笔记中的精妙设计,实现了对Markdown的存储和再次编辑。既解决了其他产品只是单向导出HTML的单薄,又规避了服务端存储Markdown带来的隐私安全问题。这样,服务端仅作为对印象笔记 API调用和数据转换之用。 243 | 244 | 隐私声明:用户所有的笔记数据,均保存在印象笔记中。马克飞象不存储用户的任何笔记数据。 245 | 246 | 离线存储 247 | 马克飞象使用浏览器离线存储将内容实时保存在本地,不必担心网络断掉或浏览器崩溃。为了节省空间和避免冲突,已同步至印象笔记并且不再修改的笔记将删除部分本地缓存,不过依然可以随时通过文档管理打开。 248 | 249 | 注意:虽然浏览器存储大部分时候都比较可靠,但印象笔记作为专业云存储,更值得信赖。以防万一,请务必经常及时同步到印象笔记。 250 | 251 | 编辑器相关 252 | 设置 253 | 右侧系统菜单(快捷键Ctrl + M)的设置中,提供了界面字体、字号、自定义CSS、vim/emacs 键盘模式等高级选项。 254 | 255 | 快捷键 256 | 帮助 Ctrl + / 257 | 同步文档 Ctrl + S 258 | 创建文档 Ctrl + Alt + N 259 | 最大化编辑器 Ctrl + Enter 260 | 预览文档 Ctrl + Alt + Enter 261 | 文档管理 Ctrl + O 262 | 系统菜单 Ctrl + M 263 | 264 | 加粗 Ctrl + B 265 | 插入图片 Ctrl + G 266 | 插入链接 Ctrl + L 267 | 提升标题 Ctrl + H 268 | 269 | 关于收费 270 | 马克飞象为新用户提供 10 天的试用期,试用期过后需要续费才能继续使用。未购买或者未及时续费,将不能同步新的笔记。之前保存过的笔记依然可以编辑。 271 | 272 | 反馈与建议 273 | 微博:@马克飞象,@GGock 274 | 275 | 邮箱:hustgock@gmail.com 276 | 277 | 感谢阅读这份帮助文档。请点击右上角,绑定印象笔记账号,开启全新的记录与分享体验吧。 278 | 279 | 这是一个示例脚注。请查阅 MultiMarkdown 文档 关于脚注的说明。 限制: 印象笔记的笔记内容使用 ENML 格式,基于 HTML,但是不支持某些标签和属性,例如id,这就导致脚注和TOC无法正常点击。 ↩ 280 | 绑定印象笔记账号 281 | 绑定 Evernote International 账号 282 | 当前文档 283 | 恢复至上次同步状态 284 | 删除文档 285 | 导出... 286 | 预览文档 287 | 分享链接 288 | 系统 289 | 设置 290 | 下载桌面客户端 291 | 下载离线Chrome App 292 | 使用说明 293 | 快捷帮助 294 | 常见问题 295 | 关于 296 | 297 | 搜索文件 298 | 强调 299 | *斜体* **粗体** 300 | CtrlI/B 301 | 链接 302 | [描述](http://example.com) 303 | CtrlL 304 | 图片 305 | ![描述](example.jpg) 306 | CtrlG 307 | 笔记本 308 | @(笔记本)[标签1,标签2,标签3] 309 | 标题 310 | 标题1 标题2 311 | ======== -------- 312 | 313 | ## 标题2 ###### 标题6 314 | Ctrl1~5 315 | 列表 316 | 1. 有序列表 - 无序列表 - [ ] 复选框 317 | 2. 有序列表 - 无序列表 - [x] 复选框 318 | 引用 319 | > 这是引用的文字 320 | > 引用内可以嵌套标题、列表等 321 | 代码 322 | 这是一句行内代码 `var a=1` , 以下是代码区块: 323 | ```ruby 324 | print 'Hello world' 325 | ``` 326 | CtrlK 327 | LaTex 公式 328 | 这是一句行内公式 $ y = x + 1 $ , 以下是整行公式: 329 | $$ a^2 + b^2 = c^2 $$ 330 | 表格 331 | | Item | Value | Qty | 332 | | :-------- | --------:| :--: | 333 | | Computer | 1600 USD | 5 | 334 | CtrlAltT 335 | 文档管理 CtrlO 336 | 帮助 Ctrl/ 337 | 最大化编辑器 CtrlEnter 338 | 预览文档 CtrlAltEnter 339 | 同步文档 CtrlS 340 | 创建文档 CtrlAltN 341 | 系统菜单 CtrlM 342 | 图片管道指令 343 | ![@描述 | left | 300x0](a.jpg) 344 | @描述 @会将描述显示在图片下方 345 | left 左侧对齐 346 | right 右侧对齐 347 | center 居中 348 | 300x200 宽x高, 0代表自适应 -------------------------------------------------------------------------------- /templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | {% load blog_extras %} 3 | 4 | 5 | 6 | Black & White 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 |
    65 | 101 | 102 | {% if messages %} 103 | {% for message in messages %} 104 | 109 | {% endfor %} 110 | {% endif %} 111 |
    112 |
    Collect from 网页模板
    113 | 114 | 115 |
    116 |
    117 |
    118 |
    119 | {% block main %} 120 | {% endblock main %} 121 |
    122 | 135 |
    136 |
    137 |
    138 | 150 | 151 | 152 |
    153 | 154 | 162 |
    163 | 164 | 165 | 166 | 167 | 171 | 172 | 173 | -------------------------------------------------------------------------------- /templates/blog/detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load comments_extras %} 3 | 4 | {% block main %} 5 | 21 |
    22 |
    23 |

    发表评论

    24 | {% show_comment_form post %} 25 |
    26 | {% show_comments post %} 27 |
    28 |
    29 | {% endblock main %} 30 | 31 | {% block toc %} 32 | {% if post.toc %} 33 |
    34 |

    文章目录

    35 |
    36 |
      37 | {{ post.toc|safe }} 38 |
    39 |
    40 |
    41 | {% endif %} 42 | {% endblock toc %} 43 | -------------------------------------------------------------------------------- /templates/blog/inclusions/_archives.html: -------------------------------------------------------------------------------- 1 |
    2 |

    归档

    3 | 12 |
    -------------------------------------------------------------------------------- /templates/blog/inclusions/_categories.html: -------------------------------------------------------------------------------- 1 |
    2 |

    分类

    3 | 12 |
    -------------------------------------------------------------------------------- /templates/blog/inclusions/_recent_posts.html: -------------------------------------------------------------------------------- 1 |
    2 |

    最新文章

    3 |
      4 | {% for post in recent_post_list %} 5 |
    • 6 | {{ post.title }} 7 |
    • 8 | {% empty %} 9 | 暂无文章! 10 | {% endfor %} 11 |
    12 |
    -------------------------------------------------------------------------------- /templates/blog/inclusions/_tags.html: -------------------------------------------------------------------------------- 1 |
    2 |

    标签云

    3 | 12 |
    -------------------------------------------------------------------------------- /templates/blog/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block main %} 4 | {% for post in post_list %} 5 | 26 | {% empty %} 27 |
    暂时还没有发布的文章!
    28 | {% endfor %} 29 | 30 | {% if is_paginated %} 31 | {{ page_obj.render }} 32 | {% endif %} 33 | 34 | {% endblock main %} 35 | -------------------------------------------------------------------------------- /templates/comments/inclusions/_form.html: -------------------------------------------------------------------------------- 1 |
    2 | {% csrf_token %} 3 |
    4 |
    5 | 6 | {{ form.name }} 7 | {{ form.name.errors }} 8 |
    9 |
    10 | 11 | {{ form.email }} 12 | {{ form.email.errors }} 13 |
    14 |
    15 | 16 | {{ form.url }} 17 | {{ form.url.errors }} 18 |
    19 |
    20 | 21 | {{ form.text }} 22 | {{ form.text.errors }} 23 | 24 |
    25 |
    26 |
    -------------------------------------------------------------------------------- /templates/comments/inclusions/_list.html: -------------------------------------------------------------------------------- 1 |

    评论列表,共 {{ comment_count }} 条评论

    2 |
      3 | {% for comment in comment_list %} 4 |
    • 5 | {{ comment.name }} 6 | 7 |
      8 | {{ comment.text|linebreaks }} 9 |
      10 |
    • 11 | {% empty %} 12 | 暂无评论 13 | {% endfor %} 14 |
    -------------------------------------------------------------------------------- /templates/comments/preview.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load comments_extras %} 3 | 4 | {% block main %} 5 | {% show_comment_form post form %} 6 | {% endblock main %} -------------------------------------------------------------------------------- /templates/pure_pagination/pagination.html: -------------------------------------------------------------------------------- 1 | 28 | -------------------------------------------------------------------------------- /templates/search/indexes/blog/post_text.txt: -------------------------------------------------------------------------------- 1 | {{ object.title }} 2 | {{ object.body }} -------------------------------------------------------------------------------- /templates/search/search.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load highlight %} 3 | 4 | {% block main %} 5 | {% if query %} 6 | {% for result in page.object_list %} 7 | 35 | {% empty %} 36 |
    没有搜索到你想要的结果!
    37 | {% endfor %} 38 | 39 | {% if page.has_previous or page.has_next %} 40 |
    41 | {% if page.has_previous %} 42 | {% endif %}« Previous 43 | {% if page.has_previous %}{% endif %} 44 | | 45 | {% if page.has_next %}{% endif %}Next 46 | »{% if page.has_next %}{% endif %} 47 |
    48 | {% endif %} 49 | {% else %} 50 | 请输入搜索关键词,例如 django 51 | {% endif %} 52 | {% endblock main %} --------------------------------------------------------------------------------