├── .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 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
27 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/profiles_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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 |
12 |
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'', 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',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 | 
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 | 
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 |
105 |
107 | {{ message }}
108 |
109 | {% endfor %}
110 | {% endif %}
111 |
112 |
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 |
6 |
17 |
18 | {{ post.body_html|safe }}
19 |
20 |
21 |
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 |
--------------------------------------------------------------------------------
/templates/blog/inclusions/_categories.html:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/templates/blog/index.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 |
3 | {% block main %}
4 | {% for post in post_list %}
5 |
6 |
19 |
20 |
{{ post.excerpt }}...
21 |
24 |
25 |
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 |
--------------------------------------------------------------------------------
/templates/comments/inclusions/_list.html:
--------------------------------------------------------------------------------
1 | 评论列表,共 {{ comment_count }} 条评论
2 |
--------------------------------------------------------------------------------
/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 |
8 |
27 |
28 |
{% highlight result.object.body with query %}
29 |
33 |
34 |
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 %}
--------------------------------------------------------------------------------
23 |
发表评论
24 | {% show_comment_form post %} 25 |