├── .gitignore ├── LICENSE ├── README.md ├── accounts ├── __init__.py ├── admin.py ├── apps.py ├── forms.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20190914_1247.py │ ├── 0003_auto_20190914_2031.py │ ├── 0004_auto_20190915_1105.py │ ├── 0005_emailverification_verification_code.py │ ├── 0006_auto_20190915_1321.py │ ├── 0007_auto_20190923_1323.py │ └── __init__.py ├── models.py ├── receivers.py ├── templates │ └── accounts │ │ ├── __base.html │ │ ├── create_invite.html │ │ ├── invite.html │ │ ├── login.html │ │ ├── logout.html │ │ ├── my_profile.html │ │ ├── password_change_done.html │ │ ├── password_change_form.html │ │ ├── password_forgotten_form.html │ │ ├── profile.html │ │ ├── register.html │ │ ├── register_closed.html │ │ ├── resend_verification.html │ │ └── user_tree.html ├── tests.py ├── urls.py └── views.py ├── emaildigest ├── __init__.py ├── admin.py ├── apps.py ├── forms.py ├── mailing.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20190923_2028.py │ ├── 0003_auto_20190923_2120.py │ ├── 0004_auto_20190926_2118.py │ └── __init__.py ├── models.py ├── receivers.py ├── templates │ └── emaildigest │ │ ├── __base.html │ │ ├── _subscription_form_tag.html │ │ ├── my_subscriptions.html │ │ ├── subscribe.html │ │ ├── subscribe_thankyou_a.html │ │ ├── subscribe_thankyou_u.html │ │ ├── subscribe_verification_done.html │ │ ├── unsubscribe.html │ │ ├── unsubscribe_confirm.html │ │ └── unsubscribe_done.html ├── templatetags │ ├── __init__.py │ └── emaildigest_extra.py ├── tests.py ├── urls.py └── views.py ├── hnclone ├── __init__.py ├── context_processors.py ├── middleware.py ├── settings.py ├── urls.py └── wsgi.py ├── manage.py ├── news ├── __init__.py ├── admin.py ├── apps.py ├── feeds.py ├── forms.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_story_title.py │ ├── 0003_auto_20190908_1642.py │ ├── 0004_auto_20190908_2249.py │ ├── 0005_auto_20190908_2250.py │ ├── 0006_auto_20190908_2251.py │ ├── 0007_auto_20190908_2256.py │ ├── 0008_story_duplicate_of.py │ ├── 0009_story_domain.py │ ├── 0010_auto_20190930_1620.py │ ├── 0011_auto_20190930_1623.py │ ├── 0012_auto_20190930_1625.py │ └── __init__.py ├── models.py ├── receivers.py ├── templates │ └── news │ │ ├── __base.html │ │ ├── _item_content_tag.html │ │ ├── _item_control_tag.html │ │ ├── _item_tag.html │ │ ├── _link_user_tag.html │ │ ├── _more_link_tag.html │ │ ├── bookmarklet.html │ │ ├── formatting_help.html │ │ ├── index.html │ │ ├── item.html │ │ ├── item_delete.html │ │ ├── item_edit.html │ │ ├── submit.html │ │ └── zen.html ├── templatetags │ ├── __init__.py │ └── news_extra.py ├── tests.py ├── urls.py └── views.py ├── requirements.txt └── static ├── grayarrow2x.gif ├── icon.png ├── news.css └── news.js /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | # Created by https://www.gitignore.io/api/code,macos,python,django,visualstudiocode 5 | # Edit at https://www.gitignore.io/?templates=code,macos,python,django,visualstudiocode 6 | 7 | ### Code ### 8 | .vscode/* 9 | !.vscode/settings.json 10 | !.vscode/tasks.json 11 | !.vscode/launch.json 12 | !.vscode/extensions.json 13 | 14 | ### Django ### 15 | *.log 16 | *.pot 17 | *.pyc 18 | __pycache__/ 19 | local_settings.py 20 | db.sqlite3 21 | media 22 | 23 | # If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ 24 | # in your Git repository. Update and uncomment the following line accordingly. 25 | # /staticfiles/ 26 | 27 | ### Django.Python Stack ### 28 | # Byte-compiled / optimized / DLL files 29 | *.py[cod] 30 | *$py.class 31 | 32 | # C extensions 33 | *.so 34 | 35 | # Distribution / packaging 36 | .Python 37 | build/ 38 | develop-eggs/ 39 | dist/ 40 | downloads/ 41 | eggs/ 42 | .eggs/ 43 | lib/ 44 | lib64/ 45 | parts/ 46 | sdist/ 47 | var/ 48 | wheels/ 49 | pip-wheel-metadata/ 50 | share/python-wheels/ 51 | *.egg-info/ 52 | .installed.cfg 53 | *.egg 54 | MANIFEST 55 | 56 | # PyInstaller 57 | # Usually these files are written by a python script from a template 58 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 59 | *.manifest 60 | *.spec 61 | 62 | # Installer logs 63 | pip-log.txt 64 | pip-delete-this-directory.txt 65 | 66 | # Unit test / coverage reports 67 | htmlcov/ 68 | .tox/ 69 | .nox/ 70 | .coverage 71 | .coverage.* 72 | .cache 73 | nosetests.xml 74 | coverage.xml 75 | *.cover 76 | .hypothesis/ 77 | .pytest_cache/ 78 | 79 | # Translations 80 | *.mo 81 | 82 | # Django stuff: 83 | db.sqlite3-journal 84 | 85 | # Flask stuff: 86 | instance/ 87 | .webassets-cache 88 | 89 | # Scrapy stuff: 90 | .scrapy 91 | 92 | # Sphinx documentation 93 | docs/_build/ 94 | 95 | # PyBuilder 96 | target/ 97 | 98 | # Jupyter Notebook 99 | .ipynb_checkpoints 100 | 101 | # IPython 102 | profile_default/ 103 | ipython_config.py 104 | 105 | # pyenv 106 | .python-version 107 | 108 | # pipenv 109 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 110 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 111 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 112 | # install all needed dependencies. 113 | #Pipfile.lock 114 | 115 | # celery beat schedule file 116 | celerybeat-schedule 117 | 118 | # SageMath parsed files 119 | *.sage.py 120 | 121 | # Environments 122 | .env 123 | .venv 124 | env/ 125 | venv/ 126 | ENV/ 127 | env.bak/ 128 | venv.bak/ 129 | 130 | # Spyder project settings 131 | .spyderproject 132 | .spyproject 133 | 134 | # Rope project settings 135 | .ropeproject 136 | 137 | # mkdocs documentation 138 | /site 139 | 140 | # mypy 141 | .mypy_cache/ 142 | .dmypy.json 143 | dmypy.json 144 | 145 | # Pyre type checker 146 | .pyre/ 147 | 148 | ### macOS ### 149 | # General 150 | .DS_Store 151 | .AppleDouble 152 | .LSOverride 153 | 154 | # Icon must end with two \r 155 | Icon 156 | 157 | # Thumbnails 158 | ._* 159 | 160 | # Files that might appear in the root of a volume 161 | .DocumentRevisions-V100 162 | .fseventsd 163 | .Spotlight-V100 164 | .TemporaryItems 165 | .Trashes 166 | .VolumeIcon.icns 167 | .com.apple.timemachine.donotpresent 168 | 169 | # Directories potentially created on remote AFP share 170 | .AppleDB 171 | .AppleDesktop 172 | Network Trash Folder 173 | Temporary Items 174 | .apdisk 175 | 176 | ### Python ### 177 | # Byte-compiled / optimized / DLL files 178 | 179 | # C extensions 180 | 181 | # Distribution / packaging 182 | 183 | # PyInstaller 184 | # Usually these files are written by a python script from a template 185 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 186 | 187 | # Installer logs 188 | 189 | # Unit test / coverage reports 190 | 191 | # Translations 192 | 193 | # Django stuff: 194 | 195 | # Flask stuff: 196 | 197 | # Scrapy stuff: 198 | 199 | # Sphinx documentation 200 | 201 | # PyBuilder 202 | 203 | # Jupyter Notebook 204 | 205 | # IPython 206 | 207 | # pyenv 208 | 209 | # pipenv 210 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 211 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 212 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 213 | # install all needed dependencies. 214 | 215 | # celery beat schedule file 216 | 217 | # SageMath parsed files 218 | 219 | # Environments 220 | 221 | # Spyder project settings 222 | 223 | # Rope project settings 224 | 225 | # mkdocs documentation 226 | 227 | # mypy 228 | 229 | # Pyre type checker 230 | 231 | ### VisualStudioCode ### 232 | 233 | ### VisualStudioCode Patch ### 234 | # Ignore all local history of files 235 | .history 236 | 237 | # End of https://www.gitignore.io/api/code,macos,python,django,visualstudiocode -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # pythonic-news 3 | A Hacker News lookalike written in Python/Django, powering [https://news.python.sc](https://news.python.sc) 4 | 5 | 6 | 7 | [![screenshot](http://cdn.sebastiansteins.com/screenshot-news-python-sc.png "Screenshot")](https://news.python.sc) 8 | 9 | 10 | ## Setup for local development 11 | 12 | ### Set up virtual environment 13 | ```shell script 14 | python -m venv venv/ 15 | source venv/bin/activate 16 | ``` 17 | 18 | ### Install Dependencies 19 | ```shell script 20 | pip install -r requirements.txt 21 | ``` 22 | 23 | ### Migrate Database 24 | ```shell script 25 | python manage.py migrate 26 | ``` 27 | 28 | ### Extra setup work 29 | * Set ```DEBUG=True``` if necessary 30 | * Add ```127.0.0.1``` to ```ALLOWED_HOSTS``` 31 | 32 | ### Run Django Server 33 | ```shell script 34 | python manage.py runserver 35 | ``` 36 | Now you can access the website at ```127.0.0.1:8000```. 37 | -------------------------------------------------------------------------------- /accounts/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'accounts.apps.AccountsConfig' 2 | -------------------------------------------------------------------------------- /accounts/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /accounts/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AccountsConfig(AppConfig): 5 | name = 'accounts' 6 | 7 | def ready(self): 8 | from . import receivers -------------------------------------------------------------------------------- /accounts/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from .models import CustomUser, Invitation 4 | 5 | class ProfileForm(forms.ModelForm): 6 | class Meta: 7 | model = CustomUser 8 | fields = ['about', 'email'] 9 | 10 | def clean_email(self): 11 | data = self.cleaned_data['email'] 12 | if data: 13 | data = data.lower() 14 | return data 15 | 16 | 17 | class RegisterForm(forms.ModelForm): 18 | MIN_LENGTH = 8 19 | class Meta: 20 | model = CustomUser 21 | fields = ['username', 'password', 'email'] 22 | widgets = { 23 | 'password': forms.PasswordInput(), 24 | } 25 | 26 | def clean_password(self): 27 | password = self.cleaned_data.get('password') 28 | if len(password) < self.MIN_LENGTH: 29 | raise forms.ValidationError("Your password must be at least %d characters long." % self.MIN_LENGTH) 30 | return password 31 | 32 | class CreateInviteForm(forms.ModelForm): 33 | class Meta: 34 | model = Invitation 35 | fields = ['invited_email_address'] 36 | 37 | def clean_invited_email_address(self): 38 | invited_email_address = self.cleaned_data['invited_email_address'] 39 | if invited_email_address: 40 | invited_email_address = invited_email_address.lower() 41 | return invited_email_address 42 | 43 | 44 | class PasswordForgottenForm(forms.Form): 45 | username = forms.CharField() 46 | 47 | class PasswortResetForm(forms.Form): 48 | password = forms.CharField() -------------------------------------------------------------------------------- /accounts/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-07 12:14 2 | 3 | import django.contrib.auth.models 4 | import django.contrib.auth.validators 5 | from django.db import migrations, models 6 | import django.utils.timezone 7 | import uuid 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | ('auth', '0011_update_proxy_permissions'), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='CustomUser', 21 | fields=[ 22 | ('password', models.CharField(max_length=128, verbose_name='password')), 23 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), 24 | ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), 25 | ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), 26 | ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')), 27 | ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), 28 | ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), 29 | ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), 30 | ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), 31 | ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), 32 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 33 | ('karma', models.IntegerField(default=1)), 34 | ('about', models.TextField(default='')), 35 | ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), 36 | ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), 37 | ], 38 | options={ 39 | 'verbose_name': 'user', 40 | 'verbose_name_plural': 'users', 41 | 'abstract': False, 42 | }, 43 | managers=[ 44 | ('objects', django.contrib.auth.models.UserManager()), 45 | ], 46 | ), 47 | ] 48 | -------------------------------------------------------------------------------- /accounts/migrations/0002_auto_20190914_1247.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-14 12:47 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import uuid 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('accounts', '0001_initial'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='customuser', 18 | name='karma', 19 | field=models.IntegerField(default=0), 20 | ), 21 | migrations.CreateModel( 22 | name='EmailVerification', 23 | fields=[ 24 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 25 | ('created_at', models.DateTimeField(auto_now_add=True)), 26 | ('changed_at', models.DateTimeField(auto_now=True)), 27 | ('verified', models.BooleanField(default=False)), 28 | ('verified_at', models.DateTimeField(null=True)), 29 | ('email', models.EmailField(max_length=254)), 30 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 31 | ], 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /accounts/migrations/0003_auto_20190914_2031.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-14 20:31 2 | 3 | from django.conf import settings 4 | import django.contrib.auth.models 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import django.db.models.manager 8 | import mptt.fields 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | dependencies = [ 14 | ('accounts', '0002_auto_20190914_1247'), 15 | ] 16 | 17 | operations = [ 18 | migrations.AlterModelManagers( 19 | name='customuser', 20 | managers=[ 21 | ('_tree_manager', django.db.models.manager.Manager()), 22 | ('objects', django.contrib.auth.models.UserManager()), 23 | ], 24 | ), 25 | migrations.AddField( 26 | model_name='customuser', 27 | name='level', 28 | field=models.PositiveIntegerField(default=1, editable=False), 29 | preserve_default=False, 30 | ), 31 | migrations.AddField( 32 | model_name='customuser', 33 | name='lft', 34 | field=models.PositiveIntegerField(default=1, editable=False), 35 | preserve_default=False, 36 | ), 37 | migrations.AddField( 38 | model_name='customuser', 39 | name='parent', 40 | field=mptt.fields.TreeForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='invitees', to=settings.AUTH_USER_MODEL), 41 | ), 42 | migrations.AddField( 43 | model_name='customuser', 44 | name='rght', 45 | field=models.PositiveIntegerField(default=2, editable=False), 46 | preserve_default=False, 47 | ), 48 | migrations.AddField( 49 | model_name='customuser', 50 | name='tree_id', 51 | field=models.PositiveIntegerField(db_index=True, default=1, editable=False), 52 | preserve_default=False, 53 | ), 54 | ] 55 | -------------------------------------------------------------------------------- /accounts/migrations/0004_auto_20190915_1105.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-15 11:05 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import uuid 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('accounts', '0003_auto_20190914_2031'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Invitation', 18 | fields=[ 19 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 20 | ('created_at', models.DateTimeField(auto_now_add=True)), 21 | ('changed_at', models.DateTimeField(auto_now=True)), 22 | ('num_signups', models.PositiveIntegerField(default=1, null=True)), 23 | ('invited_email_address', models.EmailField(default=None, max_length=254, null=True)), 24 | ('invite_code', models.UUIDField(default=uuid.uuid4, editable=False)), 25 | ('inviting_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 26 | ], 27 | ), 28 | migrations.AddField( 29 | model_name='customuser', 30 | name='used_invitation', 31 | field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='accounts.Invitation'), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /accounts/migrations/0005_emailverification_verification_code.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-15 13:16 2 | 3 | from django.db import migrations, models 4 | import uuid 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('accounts', '0004_auto_20190915_1105'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='emailverification', 16 | name='verification_code', 17 | field=models.UUIDField(default=uuid.uuid4, editable=False), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /accounts/migrations/0006_auto_20190915_1321.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-15 13:21 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('accounts', '0005_emailverification_verification_code'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='customuser', 15 | options={}, 16 | ), 17 | migrations.AlterModelManagers( 18 | name='customuser', 19 | managers=[ 20 | ], 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /accounts/migrations/0007_auto_20190923_1323.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-23 13:23 2 | 3 | from django.conf import settings 4 | import django.contrib.auth.models 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import uuid 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('accounts', '0006_auto_20190915_1321'), 14 | ] 15 | 16 | operations = [ 17 | migrations.AlterModelOptions( 18 | name='customuser', 19 | options={'default_manager_name': 'objects'}, 20 | ), 21 | migrations.AlterModelManagers( 22 | name='customuser', 23 | managers=[ 24 | ('objects', django.contrib.auth.models.UserManager()), 25 | ], 26 | ), 27 | migrations.CreateModel( 28 | name='PasswordResetRequest', 29 | fields=[ 30 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 31 | ('created_at', models.DateTimeField(auto_now_add=True)), 32 | ('changed_at', models.DateTimeField(auto_now=True)), 33 | ('verification_code', models.UUIDField(default=uuid.uuid4, editable=False)), 34 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 35 | ], 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /accounts/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebst/pythonic-news/3a6d03985f405b427d6e88d3462b9703ec2908e6/accounts/migrations/__init__.py -------------------------------------------------------------------------------- /accounts/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.contrib.auth.models import AbstractUser 4 | from django.urls import reverse 5 | 6 | from django.db import models 7 | 8 | from django.utils import timezone 9 | import datetime 10 | 11 | import hashlib 12 | from urllib.parse import urlencode 13 | 14 | from mptt.models import MPTTModel, TreeForeignKey 15 | 16 | 17 | #class CustomUser(MPTTModel, AbstractUser): 18 | class CustomUser(AbstractUser, MPTTModel): 19 | class Meta: 20 | default_manager_name = 'objects' 21 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 22 | karma = models.IntegerField(default=0) 23 | about = models.TextField(default='') 24 | # api_key = models.CharField(max_length=100) 25 | 26 | parent = TreeForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='invitees', editable=False) 27 | 28 | used_invitation = models.ForeignKey('Invitation', null=True, default=None, on_delete=models.CASCADE) 29 | 30 | def get_absolute_url(self): 31 | return reverse("accounts_profile", kwargs={"username": self.username}) 32 | 33 | @property 34 | def is_green(self): 35 | return timezone.now() - self.date_joined < datetime.timedelta(days=30) 36 | 37 | def gravatar_url(self, size=80): 38 | if self.email: 39 | default = "https://www.example.com/default.jpg" 40 | url = "https://www.gravatar.com/avatar/" + hashlib.md5(self.email.lower()).hexdigest() + "?" 41 | url += urlencode({'d':default, 's':str(size)}) 42 | return url 43 | 44 | @property 45 | def latest_verified_email(self): 46 | verifications = EmailVerification.objects.filter(user=self, verified=True).order_by('-verified_at') 47 | if verifications.count(): 48 | return verifications[0].email 49 | 50 | 51 | 52 | class Invitation(models.Model): 53 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 54 | created_at = models.DateTimeField(auto_now_add=True) 55 | changed_at = models.DateTimeField(auto_now=True) 56 | 57 | inviting_user = models.ForeignKey(CustomUser, on_delete=models.CASCADE) 58 | num_signups = models.PositiveIntegerField(null=True, default=1) 59 | invited_email_address = models.EmailField(null=True, default=None) 60 | 61 | invite_code = models.UUIDField(primary_key=False, default=uuid.uuid4, editable=False) 62 | 63 | def get_absolute_url(self): 64 | return reverse("accounts_invite", kwargs={"pk": self.pk}) 65 | 66 | def get_register_url(self): 67 | return reverse("accounts_register") + '?invite=' + str(self.invite_code) 68 | 69 | @property 70 | def active(self): 71 | return True 72 | pass # TODO 73 | 74 | 75 | class EmailVerification(models.Model): 76 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 77 | created_at = models.DateTimeField(auto_now_add=True) 78 | changed_at = models.DateTimeField(auto_now=True) 79 | verified = models.BooleanField(default=False) 80 | verified_at = models.DateTimeField(null=True) 81 | 82 | email = models.EmailField() 83 | user = models.ForeignKey(CustomUser, on_delete=models.CASCADE) 84 | 85 | verification_code = models.UUIDField(primary_key=False, default=uuid.uuid4, editable=False) 86 | 87 | def get_verify_url(self): 88 | return reverse("accounts_verify", kwargs={"verification_code": self.verification_code}) 89 | 90 | 91 | class PasswordResetRequest(models.Model): 92 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 93 | created_at = models.DateTimeField(auto_now_add=True) 94 | changed_at = models.DateTimeField(auto_now=True) 95 | 96 | user = models.ForeignKey(CustomUser, on_delete=models.CASCADE) 97 | verification_code = models.UUIDField(primary_key=False, default=uuid.uuid4, editable=False) 98 | 99 | 100 | def get_verify_url(self): 101 | return reverse("password_forgotten", kwargs={"verification_code": self.verification_code}) 102 | -------------------------------------------------------------------------------- /accounts/receivers.py: -------------------------------------------------------------------------------- 1 | #from django.core.signals import request_finished 2 | from django.db.models.signals import pre_save, post_save, post_delete 3 | from django.dispatch import receiver 4 | from django.core.mail import send_mail 5 | from django.core.mail import EmailMultiAlternatives 6 | 7 | from django.conf import settings 8 | 9 | 10 | 11 | from .models import CustomUser, Invitation, EmailVerification, PasswordResetRequest 12 | 13 | 14 | @receiver(pre_save) 15 | def lower_email_addresses(sender, instance, **kwargs): 16 | if isinstance(instance, CustomUser): 17 | email = getattr(instance, 'email', None) 18 | if email: 19 | instance.email = email.lower() 20 | 21 | 22 | @receiver(post_save) 23 | def send_invitation_email(sender, instance, created, **kwargs): 24 | if created and isinstance(instance, Invitation): 25 | subject, from_email, to = 'You have been invited to %s'%(settings.SITE_DOMAIN), 'bot@python.sc', instance.invited_email_address 26 | text_content = """ 27 | You have been invited to news.python.sc. 28 | 29 | Would you like to accept {inviting_user}'s invite? 30 | 31 | Please sign up here: https://news.python.sc{url} 32 | 33 | -- 34 | news.python.sc - A social news aggregator for the Python community. 35 | 36 | """.format(inviting_user=instance.inviting_user.username, url=instance.get_register_url()) 37 | #html_content = '

This is an important message.

' 38 | msg = EmailMultiAlternatives(subject, text_content, from_email, [to]) 39 | #msg.attach_alternative(html_content, "text/html") 40 | msg.send() 41 | 42 | 43 | @receiver(post_save) 44 | def create_verification(sender, instance, created, **kwargs): 45 | if isinstance(instance, CustomUser): 46 | if instance.email: 47 | verifications = EmailVerification.objects.filter(user=instance, email=instance.email) 48 | if not verifications.count(): 49 | create_v = True 50 | else: 51 | verified = any([i.verified for i in verifications]) 52 | # create_v = not verified 53 | create_v = False 54 | 55 | if create_v: 56 | verification = EmailVerification(user=instance, email=instance.email) 57 | verification.save() 58 | 59 | 60 | @receiver(post_save) 61 | def send_verification_email(sender, instance, created, **kwargs): 62 | if created and isinstance(instance, EmailVerification): 63 | subject, from_email, to = 'Please confirm your account on news.python.sc', 'bot@python.sc', instance.email 64 | text_content = """ 65 | Please confirm your email address here: 66 | 67 | https://news.python.sc{url} 68 | 69 | -- 70 | news.python.sc - A social news aggregator for the Python community. 71 | 72 | """.format(url=instance.get_verify_url()) 73 | #html_content = '

This is an important message.

' 74 | msg = EmailMultiAlternatives(subject, text_content, from_email, [to]) 75 | #msg.attach_alternative(html_content, "text/html") 76 | msg.send() 77 | 78 | 79 | @receiver(post_save) 80 | def send_password_reset_email(sender, instance, created, **kwargs): 81 | if created and isinstance(instance, PasswordResetRequest): 82 | subject, from_email, to = 'Reset password for your account on news.python.sc', 'bot@python.sc', instance.email 83 | text_content = """ 84 | Please confirm your email address here: 85 | 86 | https://news.python.sc{url} 87 | 88 | -- 89 | news.python.sc - A social news aggregator for the Python community. 90 | 91 | """.format(url=instance.get_verify_url()) 92 | #html_content = '

This is an important message.

' 93 | msg = EmailMultiAlternatives(subject, text_content, from_email, [to]) 94 | #msg.attach_alternative(html_content, "text/html") 95 | msg.send() -------------------------------------------------------------------------------- /accounts/templates/accounts/__base.html: -------------------------------------------------------------------------------- 1 | {% extends 'news/__base.html' %} -------------------------------------------------------------------------------- /accounts/templates/accounts/create_invite.html: -------------------------------------------------------------------------------- 1 | {% extends 'accounts/__base.html' %} 2 | 3 | {% block content %} 4 | 5 |
{% csrf_token %} 6 | {{form}} 7 | 8 |
9 | 10 | {% endblock content %} -------------------------------------------------------------------------------- /accounts/templates/accounts/invite.html: -------------------------------------------------------------------------------- 1 | {% extends 'accounts/__base.html' %} 2 | 3 | {% block content %} 4 | 5 | 6 | You have created an invite for {{invitation.invited_email_address}}. 7 | Create anotther? 8 | 9 | {% endblock content %} -------------------------------------------------------------------------------- /accounts/templates/accounts/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'accounts/__base.html' %} 2 | 3 | {% block content %} 4 | {% if user.is_authenticated %} 5 | Signed in. 6 | {% else %} 7 |

Login

8 |
9 | {% csrf_token %} 10 | {{ form.as_p }} 11 | 12 |
13 | password forgotten? 14 | {% endif %} 15 | {% endblock content %} -------------------------------------------------------------------------------- /accounts/templates/accounts/logout.html: -------------------------------------------------------------------------------- 1 | {% extends 'accounts/__base.html' %} 2 | 3 | {% block content %} 4 | 5 |

Logout

6 |
7 | {% csrf_token %} 8 | 9 |
10 | 11 | {% endblock content %} -------------------------------------------------------------------------------- /accounts/templates/accounts/my_profile.html: -------------------------------------------------------------------------------- 1 | {% extends 'accounts/__base.html' %} 2 | 3 | {% block content %} 4 | 31 | {% endblock content %} -------------------------------------------------------------------------------- /accounts/templates/accounts/password_change_done.html: -------------------------------------------------------------------------------- 1 | {% extends 'accounts/__base.html' %} 2 | 3 | {% block content %} 4 | Password changed successfully. 5 | {% endblock content %} -------------------------------------------------------------------------------- /accounts/templates/accounts/password_change_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'accounts/__base.html' %} 2 | 3 | {% block content %} 4 |

Change Password

5 |
6 | {% csrf_token %} 7 | {{ form.as_p }} 8 | 9 |
10 | {% endblock content %} -------------------------------------------------------------------------------- /accounts/templates/accounts/password_forgotten_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'accounts/__base.html' %} 2 | 3 | {% block content %} 4 |

Password forgotten?

5 |
6 | {% csrf_token %} 7 | {{ form.as_p }} 8 | 9 |
10 | 11 | 12 | {% endblock content %} -------------------------------------------------------------------------------- /accounts/templates/accounts/profile.html: -------------------------------------------------------------------------------- 1 | {% extends 'accounts/__base.html' %} 2 | 3 | {% block content %} 4 |
5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
user:{{profile}}
created:{{profile.date_joined}}
karma:{{profile.karma}}
about:{{profile.about}}
24 | 25 | 26 | submissions
27 | comments
28 |
29 | 30 | 31 | {% endblock content %} -------------------------------------------------------------------------------- /accounts/templates/accounts/register.html: -------------------------------------------------------------------------------- 1 | {% extends 'accounts/__base.html' %} 2 | 3 | {% block content %} 4 |
5 |

Create Account

6 |

While you don't need to supply an email address, it is strongly recommended to do so. Without an email address, we won't be able to restore your account 7 | if you've lost your password.

8 |
9 | {% csrf_token %} 10 | 11 | {{ form.as_table }} 12 |
13 |
14 |

By signing up, you agree to the Zen of {{SITE_NAME}}.

15 | 16 |
17 |
18 | {% endblock content %} -------------------------------------------------------------------------------- /accounts/templates/accounts/register_closed.html: -------------------------------------------------------------------------------- 1 | {% extends 'accounts/__base.html' %} 2 | 3 | {% block content %} 4 | Closed. 5 | 6 |

7 | You want an invite? Please leave your email address here: Request an invite 8 |

9 | 10 | {% endblock content %} -------------------------------------------------------------------------------- /accounts/templates/accounts/resend_verification.html: -------------------------------------------------------------------------------- 1 | {% extends 'accounts/__base.html' %} 2 | 3 | {% block content %} 4 | A new email has been sent. 5 | {% endblock content %} -------------------------------------------------------------------------------- /accounts/templates/accounts/user_tree.html: -------------------------------------------------------------------------------- 1 | {% extends 'accounts/__base.html' %} 2 | 3 | {% block content %} 4 | {% load mptt_tags %} 5 | 6 |
    7 | {% recursetree users %} 8 |
  • 9 | {{ node.username }} 10 | {% if not node.is_leaf_node %} 11 |
      12 | {{ invitees }} 13 |
    14 | {% endif %} 15 |
  • 16 | {% endrecursetree %} 17 |
18 | 19 | 20 | {% endblock content %} -------------------------------------------------------------------------------- /accounts/tests.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AnonymousUser 2 | from accounts.models import CustomUser 3 | from django.test import RequestFactory, TestCase 4 | 5 | from .views import * 6 | from .models import * 7 | 8 | class BasicAccountsTest(TestCase): 9 | """Tests the basic functionality of the accounts app.""" 10 | def setUp(self): 11 | # Every test needs access to the request factory. 12 | self.factory = RequestFactory() 13 | self.user = CustomUser.objects.create_user( 14 | username='sebst', email='hi@seb.st', password='top_secret') 15 | self.other_user = CustomUser.objects.create_user( 16 | username='bla1', email='two@seb.st', password='top_secret') 17 | 18 | 19 | class ReceiversAccountsTest(TestCase): 20 | """Tests the basic functionality of the accounts app.""" 21 | def setUp(self): 22 | # Every test needs access to the request factory. 23 | self.factory = RequestFactory() 24 | self.user = CustomUser.objects.create_user( 25 | username='sebst', email='hi@seb.st', password='top_secret') 26 | self.other_user = CustomUser.objects.create_user( 27 | username='bla1', email='two@seb.st', password='top_secret') 28 | 29 | 30 | def test_lower_email_addresses(self): 31 | user = CustomUser.objects.create_user( 32 | username='johndoe', email='J.Doe@exAmple.org', password='top_secret') 33 | user = CustomUser.objects.get(pk=user.pk) 34 | self.assertEqual(user.email, 'j.doe@example.org') 35 | 36 | 37 | def test_send_invitation_email(self): 38 | self.skipTest() 39 | self.assertTrue(False) 40 | 41 | 42 | def test_create_verification(self): 43 | user = CustomUser.objects.create_user( 44 | username='johndoe', email='J.Doe@exAmple.org', password='top_secret') 45 | verification = EmailVerification.objects.get(user=user, email=user.email) 46 | self.assertEqual(verification.email, user.email) 47 | self.assertEqual(verification.email, 'j.doe@example.org') 48 | 49 | 50 | 51 | def test_send_verification_email(self): 52 | self.skipTest() 53 | self.assertTrue(False) 54 | 55 | 56 | def test_send_password_reset_email(self): 57 | self.skipTest() 58 | self.assertTrue(False) 59 | -------------------------------------------------------------------------------- /accounts/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | 3 | from . import views 4 | from django.contrib.auth import views as auth_views 5 | 6 | 7 | 8 | urlpatterns = [ 9 | path('profile', views.profile, name="accounts_my_profile"), 10 | path('profile/', views.profile, name="accounts_profile"), 11 | 12 | path('user-tree', views.user_tree, name="accounts_user_tree"), 13 | 14 | path('create-invite', views.create_invite, name="accounts_create_invite"), 15 | path('invite/', views.invite, name="accounts_invite"), 16 | 17 | path('verify/', views.verify, name="accounts_verify"), 18 | path('resend-verification', views.resend_verification, name="accounts_resend_verification"), 19 | 20 | path('register', views.register, name="accounts_register"), 21 | 22 | # path('accounts/', include('django.contrib.auth.urls')), # new 23 | path('accounts/login/', auth_views.LoginView.as_view(template_name='accounts/login.html'), name="login"), 24 | #path('accounts/logout/', auth_views.LogoutView.as_view(template_name='accounts/logout.html'), name="logout"), 25 | path('accounts/logout/', views.logout, name="logout"), 26 | path('accounts/password-change/', auth_views.PasswordChangeView.as_view(template_name='accounts/password_change_form.html'), name="password_change"), 27 | path('accounts/password-change-done/', auth_views.PasswordChangeDoneView.as_view(template_name='accounts/password_change_done.html'), name="password_change_done"), 28 | 29 | path('accounts/password-forgotten/', views.password_forgotten, name="password_forgotten"), 30 | path('accounts/password-forgotten/', views.password_forgotten, name="password_forgotten"), 31 | 32 | 33 | ] 34 | -------------------------------------------------------------------------------- /accounts/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render, get_object_or_404 2 | from django.http import HttpResponseRedirect, HttpResponse 3 | from django.contrib.auth.decorators import login_required 4 | from django.contrib.auth import authenticate, login 5 | from django.contrib.auth import logout as do_logout 6 | from django.urls import reverse 7 | from django.conf import settings 8 | from django.utils import timezone 9 | 10 | 11 | from .models import CustomUser, Invitation, EmailVerification 12 | from .forms import ProfileForm, RegisterForm, CreateInviteForm, PasswordForgottenForm, PasswortResetForm 13 | 14 | 15 | def profile(request, username=None): 16 | if username is None: 17 | return my_profile(request) 18 | profile = get_object_or_404(CustomUser, username=username) 19 | if profile == request.user: 20 | return my_profile(request) 21 | return render(request, 'accounts/profile.html', {'profile': profile}) 22 | 23 | 24 | @login_required 25 | def my_profile(request): 26 | instance = request.user 27 | form = ProfileForm(request.POST or None, instance=instance) 28 | if request.user.email: 29 | verifications = EmailVerification.objects.filter(user=request.user, email=request.user.email) 30 | verified = any([i.verified for i in verifications]) 31 | else: 32 | verified = False 33 | if request.method == 'POST': 34 | if form.is_valid(): 35 | instance = form.save() 36 | return HttpResponseRedirect(instance.get_absolute_url()) 37 | return render(request, 'accounts/my_profile.html', {'form': form, 'verified': verified}) 38 | 39 | 40 | @login_required 41 | def create_invite(request): 42 | instance = Invitation(inviting_user = request.user) 43 | form = CreateInviteForm(request.POST or None, instance=instance) 44 | if request.method=="POST": 45 | if form.is_valid(): 46 | instance = form.save() 47 | return HttpResponseRedirect(instance.get_absolute_url()) 48 | return render(request, 'accounts/create_invite.html', {'form': form}) 49 | 50 | 51 | @login_required 52 | def invite(request, pk): 53 | invitation = get_object_or_404(Invitation, pk=pk) 54 | return render(request, 'accounts/invite.html', {'invitation': invitation}) 55 | 56 | 57 | def register(request): 58 | invite_code = request.GET.get('invite') 59 | try: 60 | invitation = Invitation.objects.get(invite_code=invite_code) 61 | except: 62 | invitation = None 63 | instance = CustomUser(used_invitation=invitation, 64 | parent=getattr(invitation, 'inviting_user', None), 65 | email=getattr(invitation, 'invited_email_address', None)) 66 | if not settings.ACCEPT_UNINVITED_REGISTRATIONS and (invitation is None or not getattr(invitation, 'active', False)): 67 | return render(request, 'accounts/register_closed.html') 68 | form = RegisterForm(request.POST or None, instance=instance) 69 | if request.method == 'POST': 70 | if form.is_valid(): 71 | instance = form.save() 72 | instance.set_password(form.cleaned_data['password']) 73 | instance.is_active = True 74 | instance.save() 75 | login(request, instance) 76 | return HttpResponseRedirect(instance.get_absolute_url()) 77 | return render(request, 'accounts/register.html', {'form': form}) 78 | 79 | 80 | def verify(request, verification_code): 81 | verification = get_object_or_404(EmailVerification, verification_code=verification_code) 82 | assert verification.user.email == verification.email 83 | verification.verified = True 84 | verification.verified_at = timezone.now() 85 | verification.save() 86 | return render(request, 'accounts/verify.html') 87 | 88 | @login_required 89 | def resend_verification(request): 90 | if request.method=="POST": 91 | assert request.user.email 92 | verification = EmailVerification(user=request.user, email=request.user.email) 93 | verification.save() 94 | return render(request, 'accounts/resend_verification.html') 95 | 96 | 97 | def user_tree(request): 98 | users = CustomUser.objects.all() 99 | return render(request, 'accounts/user_tree.html', {'users': users}) 100 | 101 | 102 | def password_forgotten(request, verification_code=None): 103 | assert not request.user.is_authenticated 104 | error = None 105 | if verification_code is None: 106 | if 'sent' in request.GET.keys(): 107 | return render(request, 'accounts/password_forgotten_sent.html', {}) 108 | form = PasswordForgottenForm(request.POST or None) 109 | if form.is_valid(): 110 | user = None 111 | try: 112 | username = form.cleaned_data['username'] 113 | user = CustomUser.objects.get_by_natural_key(username) 114 | except: 115 | pass 116 | if user: 117 | if user.email == user.latest_verified_email: 118 | reset_request = PasswordResetRequest(user=user) 119 | reset_request.save() 120 | return HttpResponseRedirect(reverse('password_forgotten')+'?sent') 121 | else: 122 | error = 'This user does not have a verified email. Please contact support.' 123 | else: 124 | error = 'User not found.' 125 | return render(request, 'accounts/password_forgotten_form.html', {'form': form, 'error': error}) 126 | else: 127 | reset_request = get_object_or_404(PasswordResetRequest, verification_code=verification_code) 128 | form = PasswortResetForm(request.POST or None) 129 | if request.method=="POST": 130 | if form.is_valid(): 131 | reset_request.user.set_password(form.cleaned_data['password']) # TODO: confirm password, password rules 132 | reset_request.user.save() 133 | return HttpResponseRedirect(reverse('/login')) 134 | return render(request, 'accounts/password_forgotten_form.html', {'form': form}) 135 | 136 | 137 | @login_required 138 | def logout(request): 139 | if request.method=="POST": 140 | do_logout(request) 141 | redirect_url = settings.LOGOUT_REDIRECT_URL or '/' 142 | return HttpResponseRedirect(redirect_url) 143 | else: 144 | return render(request, 'accounts/logout.html') 145 | 146 | -------------------------------------------------------------------------------- /emaildigest/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'emaildigest.apps.EmaildigestConfig' 2 | -------------------------------------------------------------------------------- /emaildigest/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /emaildigest/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class EmaildigestConfig(AppConfig): 5 | name = 'emaildigest' 6 | 7 | def ready(self): 8 | from . import receivers -------------------------------------------------------------------------------- /emaildigest/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from .models import UserSubscription, AnonymousSubscription, Subscription 4 | 5 | 6 | class UserSubscriptionForm(forms.ModelForm): 7 | thankyou = 'u' 8 | class Meta: 9 | model = UserSubscription 10 | fields = [] 11 | 12 | 13 | class AnonymousSubscriptionForm(forms.ModelForm): 14 | thankyou = 'a' 15 | class Meta: 16 | model = AnonymousSubscription 17 | fields = ['email'] 18 | 19 | def clean_email(self): 20 | data = self.cleaned_data['email'] 21 | if data: 22 | data = data.lower() 23 | return data 24 | 25 | 26 | def validate_active_email(email): 27 | qs = Subscription.objects.filter(verfied_email=email, is_active=True) 28 | if not qs.count(): 29 | raise forms.ValidationError( 30 | ('No subscription found for email %(email)s'), 31 | code='invalid', 32 | params={'email': email},) 33 | 34 | class UnsunscribeForm(forms.Form): 35 | email = forms.EmailField(validators=[validate_active_email]) 36 | 37 | def clean_email(self): 38 | data = self.cleaned_data['email'] 39 | if data: 40 | data = data.lower() 41 | return data 42 | 43 | 44 | def get_subscription_form(user, *args, **kwargs): 45 | return AnonymousSubscriptionForm(*args, **kwargs) 46 | if user.is_authenticated: 47 | return UserSubscriptionForm(*args, **kwargs) 48 | else: 49 | return AnonymousSubscriptionForm(*args, **kwargs) -------------------------------------------------------------------------------- /emaildigest/mailing.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from .models import Subscription, EmailDigest 3 | 4 | def create_and_send_digest(frequency): 5 | pass 6 | # Get list of objects 7 | stories = [] # TODO: Find from _font_page 8 | # make templates and trigger sending 9 | subscriptions = Subscription.objects.filter(is_active=True, frequency=frequency) 10 | for subscription in subscriptions: 11 | tpl = 'TODO: Here is your list' # TODO 12 | subject = settings.SITE_NAME + " " + frequency + " Digest" 13 | send_mail(subscription, tpl, subject) 14 | 15 | def send_mail(subscription, template, subject): 16 | pass -------------------------------------------------------------------------------- /emaildigest/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-23 13:23 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import uuid 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ('news', '0008_story_duplicate_of'), 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='Subscription', 21 | fields=[ 22 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 23 | ('created_at', models.DateTimeField(auto_now_add=True)), 24 | ('changed_at', models.DateTimeField(auto_now=True)), 25 | ], 26 | ), 27 | migrations.CreateModel( 28 | name='AnonymousSubscription', 29 | fields=[ 30 | ('subscription_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='emaildigest.Subscription')), 31 | ('email', models.EmailField(blank=True, max_length=254, null=True)), 32 | ('verified', models.BooleanField(default=False)), 33 | ('verified_at', models.DateTimeField(null=True)), 34 | ('verification_code', models.UUIDField(default=uuid.uuid4, editable=False)), 35 | ], 36 | bases=('emaildigest.subscription',), 37 | ), 38 | migrations.CreateModel( 39 | name='EmailDigest', 40 | fields=[ 41 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 42 | ('created_at', models.DateTimeField(auto_now_add=True)), 43 | ('changed_at', models.DateTimeField(auto_now=True)), 44 | ('frequency', models.CharField(choices=[('weekly', 'weekly'), ('daily', 'daily')], max_length=16)), 45 | ('weekly_weekday', models.CharField(blank=True, choices=[('Sun', 'Sun'), ('Mon', 'Mon'), ('Tue', 'Tue'), ('Wed', 'Wed'), ('Thu', 'Thu'), ('Fri', 'Fri'), ('Sat', 'Sat')], max_length=16, null=True)), 46 | ('stories', models.ManyToManyField(to='news.Story')), 47 | ], 48 | ), 49 | migrations.CreateModel( 50 | name='UserSubscription', 51 | fields=[ 52 | ('subscription_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='emaildigest.Subscription')), 53 | ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 54 | ], 55 | bases=('emaildigest.subscription',), 56 | ), 57 | ] 58 | -------------------------------------------------------------------------------- /emaildigest/migrations/0002_auto_20190923_2028.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-23 20:28 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('emaildigest', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='subscription', 15 | name='frequency', 16 | field=models.CharField(choices=[('weekly', 'weekly'), ('daily', 'daily')], default='daily', max_length=16), 17 | preserve_default=False, 18 | ), 19 | migrations.AddField( 20 | model_name='subscription', 21 | name='weekly_weekday', 22 | field=models.CharField(blank=True, choices=[('Sun', 'Sun'), ('Mon', 'Mon'), ('Tue', 'Tue'), ('Wed', 'Wed'), ('Thu', 'Thu'), ('Fri', 'Fri'), ('Sat', 'Sat')], max_length=16, null=True), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /emaildigest/migrations/0003_auto_20190923_2120.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-23 21:20 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('emaildigest', '0002_auto_20190923_2028'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name='anonymoussubscription', 18 | name='logged_in_user', 19 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), 20 | ), 21 | migrations.AddField( 22 | model_name='subscription', 23 | name='verfied_email', 24 | field=models.EmailField(blank=True, max_length=254, null=True), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /emaildigest/migrations/0004_auto_20190926_2118.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-26 21:18 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import uuid 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('emaildigest', '0003_auto_20190923_2120'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='subscription', 17 | name='is_active', 18 | field=models.BooleanField(default=False), 19 | ), 20 | migrations.CreateModel( 21 | name='UnSubscription', 22 | fields=[ 23 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 24 | ('created_at', models.DateTimeField(auto_now_add=True)), 25 | ('changed_at', models.DateTimeField(auto_now=True)), 26 | ('from_digest', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='emaildigest.EmailDigest')), 27 | ('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='emaildigest.Subscription')), 28 | ], 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /emaildigest/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebst/pythonic-news/3a6d03985f405b427d6e88d3462b9703ec2908e6/emaildigest/migrations/__init__.py -------------------------------------------------------------------------------- /emaildigest/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from django.db import models 3 | 4 | class EmailDigest(models.Model): 5 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 6 | created_at = models.DateTimeField(auto_now_add=True) 7 | changed_at = models.DateTimeField(auto_now=True) 8 | 9 | frequency = models.CharField(max_length=16, choices=(('weekly', 'weekly'), ('daily', 'daily'))) 10 | weekly_weekday = models.CharField(max_length=16, null=True, blank=True, choices=(('Sun', 'Sun'), 11 | ('Mon', 'Mon'), 12 | ('Tue', 'Tue'), 13 | ('Wed', 'Wed'), 14 | ('Thu', 'Thu'), 15 | ('Fri', 'Fri'), 16 | ('Sat', 'Sat'))) 17 | stories = models.ManyToManyField('news.Story') 18 | 19 | class Subscription(models.Model): 20 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 21 | created_at = models.DateTimeField(auto_now_add=True) 22 | changed_at = models.DateTimeField(auto_now=True) 23 | frequency = models.CharField(max_length=16, choices=(('weekly', 'weekly'), ('daily', 'daily'))) 24 | weekly_weekday = models.CharField(max_length=16, null=True, blank=True, choices=(('Sun', 'Sun'), 25 | ('Mon', 'Mon'), 26 | ('Tue', 'Tue'), 27 | ('Wed', 'Wed'), 28 | ('Thu', 'Thu'), 29 | ('Fri', 'Fri'), 30 | ('Sat', 'Sat'))) 31 | verfied_email = models.EmailField(null=True, blank=True) 32 | is_active = models.BooleanField(default=False) 33 | 34 | 35 | class UserSubscription(Subscription): 36 | user = models.ForeignKey('accounts.CustomUser', on_delete=models.CASCADE, null=True) 37 | 38 | 39 | class AnonymousSubscription(Subscription): 40 | email = models.EmailField(null=True, blank=True) 41 | verified = models.BooleanField(default=False) 42 | verified_at = models.DateTimeField(null=True) 43 | verification_code = models.UUIDField(primary_key=False, default=uuid.uuid4, editable=False) 44 | logged_in_user = models.ForeignKey('accounts.CustomUser', on_delete=models.CASCADE, null=True) 45 | 46 | 47 | class UnSubscription(models.Model): 48 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 49 | created_at = models.DateTimeField(auto_now_add=True) 50 | changed_at = models.DateTimeField(auto_now=True) 51 | subscription = models.ForeignKey(Subscription, on_delete=models.CASCADE) 52 | from_digest = models.ForeignKey(EmailDigest, on_delete=models.CASCADE, null=True) -------------------------------------------------------------------------------- /emaildigest/receivers.py: -------------------------------------------------------------------------------- 1 | #from django.core.signals import request_finished 2 | from django.db.models.signals import pre_save, post_save, post_delete 3 | from django.dispatch import receiver 4 | from django.core.mail import send_mail 5 | from django.core.mail import EmailMultiAlternatives 6 | 7 | 8 | 9 | from .models import UserSubscription, AnonymousSubscription, Subscription, UnSubscription 10 | 11 | 12 | @receiver(pre_save) 13 | def lower_email_addresses(sender, instance, **kwargs): 14 | if isinstance(instance, (UserSubscription, AnonymousSubscription)): 15 | if isinstance(instance, AnonymousSubscription): 16 | instance.email = instance.email.lower() 17 | if isinstance(instance, Subscription): 18 | if instance.verfied_email: 19 | instance.verfied_email = instance.verfied_email.lower() 20 | 21 | 22 | @receiver(post_save) 23 | def activate_subscription_on_verification(sender, instance, created, **kwargs): 24 | if isinstance(instance, AnonymousSubscription): 25 | if instance.verified: 26 | subscription = instance.subscription_ptr 27 | subscription.is_active = True 28 | subscription.verfied_email = instance.email 29 | subscription.save() 30 | 31 | @receiver(post_save) 32 | def on_subscription_created(sender, instance, created, **kwargs): 33 | if created and isinstance(instance, (UserSubscription, AnonymousSubscription)): 34 | subscription = instance 35 | 36 | 37 | @receiver(post_save) 38 | def on_unsubscription_created(sender, instance, created, **kwargs): 39 | if created and isinstance(instance, (UnSubscription)): 40 | unsubscription = instance 41 | unsubscription.subscription.is_active = False 42 | unsubscription.subscription.save() -------------------------------------------------------------------------------- /emaildigest/templates/emaildigest/__base.html: -------------------------------------------------------------------------------- 1 | {% extends 'news/__base.html' %} -------------------------------------------------------------------------------- /emaildigest/templates/emaildigest/_subscription_form_tag.html: -------------------------------------------------------------------------------- 1 | {% if False and subscription_form %} 2 |

Don't miss interesting Python stories by subscribing to our digest

3 | 4 |
5 | {% csrf_token %} 6 | {{subscription_form}} 7 | 8 |
9 | {% endif %} -------------------------------------------------------------------------------- /emaildigest/templates/emaildigest/my_subscriptions.html: -------------------------------------------------------------------------------- 1 | {% extends 'emaildigest/__base.html' %} 2 | 3 | {% block content %} 4 | 5 |
6 | my_subscriptions.html 7 |
8 | 9 | 10 | {% endblock content %} -------------------------------------------------------------------------------- /emaildigest/templates/emaildigest/subscribe.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebst/pythonic-news/3a6d03985f405b427d6e88d3462b9703ec2908e6/emaildigest/templates/emaildigest/subscribe.html -------------------------------------------------------------------------------- /emaildigest/templates/emaildigest/subscribe_thankyou_a.html: -------------------------------------------------------------------------------- 1 | {% extends 'emaildigest/__base.html' %} 2 | 3 | {% block content %} 4 | 5 |
6 | Thank you!
7 | You will receive a confirmation email soon 8 |
9 | 10 | 11 | {% endblock content %} -------------------------------------------------------------------------------- /emaildigest/templates/emaildigest/subscribe_thankyou_u.html: -------------------------------------------------------------------------------- 1 | {% extends 'emaildigest/__base.html' %} 2 | 3 | {% block content %} 4 | 5 |
6 | Thank you!
7 | You are now subscribed. 8 |
9 | 10 | 11 | {% endblock content %} -------------------------------------------------------------------------------- /emaildigest/templates/emaildigest/subscribe_verification_done.html: -------------------------------------------------------------------------------- 1 | {% extends 'emaildigest/__base.html' %} 2 | 3 | {% block content %} 4 | 5 |
6 | Thank you!
7 | You are now subscribed. 8 |
9 | 10 | 11 | {% endblock content %} -------------------------------------------------------------------------------- /emaildigest/templates/emaildigest/unsubscribe.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends 'news/__base.html' %} 3 | {% load humanize %} 4 | {% load mptt_tags %} 5 | 6 | 7 | 8 | {% block content %} 9 | 10 | 11 | 12 | 13 |
14 | 15 |
{% csrf_token %} 16 | {{form}} 17 | 18 |
19 | 20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 | {% endblock content %} -------------------------------------------------------------------------------- /emaildigest/templates/emaildigest/unsubscribe_confirm.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends 'news/__base.html' %} 3 | {% load humanize %} 4 | {% load mptt_tags %} 5 | 6 | 7 | 8 | {% block content %} 9 | 10 | 11 | 12 | 13 |
14 | 15 |
{% csrf_token %} 16 | Do yo really want to unsubscribe?
17 | 18 |
19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 | {% endblock content %} -------------------------------------------------------------------------------- /emaildigest/templates/emaildigest/unsubscribe_done.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends 'news/__base.html' %} 3 | {% load humanize %} 4 | {% load mptt_tags %} 5 | 6 | 7 | 8 | {% block content %} 9 | 10 | 11 | 12 | 13 |
14 | 15 | You are unsubscribed. 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | {% endblock content %} -------------------------------------------------------------------------------- /emaildigest/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebst/pythonic-news/3a6d03985f405b427d6e88d3462b9703ec2908e6/emaildigest/templatetags/__init__.py -------------------------------------------------------------------------------- /emaildigest/templatetags/emaildigest_extra.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | register = template.Library() 4 | 5 | from emaildigest.forms import get_subscription_form 6 | 7 | @register.inclusion_tag('emaildigest/_subscription_form_tag.html') 8 | def digest_subscription_form(user, **kwargs): 9 | form = get_subscription_form(user) 10 | return {'subscription_form': form} 11 | -------------------------------------------------------------------------------- /emaildigest/tests.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AnonymousUser 2 | from accounts.models import CustomUser 3 | from django.test import RequestFactory, TestCase 4 | 5 | from .views import * 6 | from .models import * 7 | from .forms import * 8 | 9 | class BasicEmailDigestTest(TestCase): 10 | """Tests the basic functionality of the emaildigest app.""" 11 | def setUp(self): 12 | # Every test needs access to the request factory. 13 | self.factory = RequestFactory() 14 | self.user = CustomUser.objects.create_user( 15 | username='sebst', email='hi@seb.st', password='top_secret') 16 | self.other_user = CustomUser.objects.create_user( 17 | username='bla1', email='two@seb.st', password='top_secret') 18 | 19 | def _subscribe(self, i=0): 20 | self.assertEqual(Subscription.objects.all().count(), 0+i) 21 | 22 | request = self.factory.post('/digest/subscribe', {'email': 'test@example.org'}) 23 | request.user = self.user 24 | response = subscribe(request) 25 | 26 | #self.assertContains(response.url, 'thankyou') 27 | self.assertRegex(response.url, r'.*thankyou.*') 28 | 29 | self.assertEqual(Subscription.objects.all().count(), 1+i) 30 | 31 | subscription = Subscription.objects.all().order_by('-created_at')[0] 32 | self.assertFalse(subscription.is_active) 33 | 34 | verification_code = subscription.anonymoussubscription.verification_code 35 | 36 | return subscription 37 | 38 | def _confirm(self, subscription): 39 | subscription = Subscription.objects.get(pk=subscription.pk) 40 | self.assertFalse(subscription.anonymoussubscription.verified) 41 | self.assertFalse(subscription.is_active) 42 | verification_code = subscription.anonymoussubscription.verification_code 43 | 44 | url = '/digest/subscribe?v=' + str(verification_code) 45 | 46 | request = self.factory.get(url) 47 | request.user = self.user 48 | response = subscribe(request) 49 | 50 | subscription = Subscription.objects.get(pk=subscription.pk) 51 | anonymoussubscription = AnonymousSubscription.objects.get(pk=subscription.pk) 52 | self.assertTrue(anonymoussubscription.verified) 53 | 54 | 55 | def _unsubscribe_via_mail(self, subscription, assert_form_error=False): 56 | subscription = Subscription.objects.get(pk=subscription.pk) 57 | 58 | self.assertEqual(UnSubscription.objects.all().count(), 0) 59 | 60 | url = '/digest/unsubscribe' 61 | request = self.factory.post(url, {'email': subscription.anonymoussubscription.email}) 62 | request.user = self.user 63 | response = unsubscribe(request) 64 | if assert_form_error: 65 | # self.assertFormError(response, UnsunscribeForm, 'email', 'No subscription found for email ' + subscription.anonymoussubscription.email) 66 | self.assertContains(response, 'No subscription found for email') 67 | return None 68 | self.assertEqual(response.status_code, 302) 69 | self.assertRegex(response.url, r'.*done.*') 70 | 71 | self.assertEqual(UnSubscription.objects.all().count(), 1) 72 | 73 | unsubscription = UnSubscription.objects.get() 74 | return unsubscription 75 | 76 | 77 | def test_subscribe(self): 78 | subscription = self._subscribe() 79 | self.assertFalse(subscription.is_active) 80 | 81 | 82 | def test_subscribe_confirm(self): 83 | subscription = self._subscribe() 84 | self._confirm(subscription) 85 | subscription = Subscription.objects.get(pk=subscription.pk) 86 | self.assertTrue(subscription.is_active) 87 | 88 | 89 | def test_subscribe_unsubscribe(self): 90 | subscription = self._subscribe() 91 | unsubscription = self._unsubscribe_via_mail(subscription, assert_form_error=True) 92 | subscription = Subscription.objects.get(pk=subscription.pk) 93 | self.assertFalse(subscription.is_active) 94 | 95 | 96 | def test_subscribe_confirm_unsubscribe(self): 97 | subscription = self._subscribe() 98 | self._confirm(subscription) 99 | unsubscription = self._unsubscribe_via_mail(subscription) 100 | subscription = Subscription.objects.get(pk=subscription.pk) 101 | self.assertFalse(subscription.is_active) 102 | 103 | 104 | def test_subscribe_unsubscribe_confirm(self): 105 | subscription = self._subscribe() 106 | unsubscription = self._unsubscribe_via_mail(subscription, assert_form_error=True) 107 | subscription = Subscription.objects.get(pk=subscription.pk) 108 | self.assertFalse(subscription.is_active) 109 | 110 | subscription = Subscription.objects.get(pk=subscription.pk) 111 | self.assertFalse(subscription.is_active) 112 | 113 | self._confirm(subscription) 114 | subscription = Subscription.objects.get(pk=subscription.pk) 115 | self.assertTrue(subscription.is_active) 116 | 117 | 118 | def test_subscribe_confirm_unsubscribe_subscribe(self): 119 | subscription = self._subscribe() 120 | self._confirm(subscription) 121 | unsubscription = self._unsubscribe_via_mail(subscription) 122 | 123 | subscription2 = self._subscribe(i=1) 124 | subscription2 = Subscription.objects.get(pk=subscription2.pk) 125 | self.assertFalse(subscription.is_active) 126 | 127 | 128 | 129 | def test_subscribe_confirm_unsubscribe_subscribe_confirm(self): 130 | subscription = self._subscribe() 131 | self._confirm(subscription) 132 | 133 | unsubscription = self._unsubscribe_via_mail(subscription) 134 | 135 | subscription = Subscription.objects.get(pk=subscription.pk) 136 | self.assertFalse(subscription.is_active) 137 | 138 | re_subscription = self._subscribe(i=1) 139 | self._confirm(re_subscription) 140 | 141 | re_subscription = Subscription.objects.get(pk=re_subscription.pk) 142 | subscription = Subscription.objects.get(pk=subscription.pk) 143 | self.assertTrue(re_subscription.is_active) 144 | self.assertFalse(subscription.is_active) 145 | 146 | 147 | 148 | # class ReceiversEmailDigestTest(TestCase): 149 | # """Tests the basic receivers functionality of the emaildigest app.""" 150 | # def setUp(self): 151 | # # Every test needs access to the request factory. 152 | # self.factory = RequestFactory() 153 | # self.user = CustomUser.objects.create_user( 154 | # username='sebst', email='hi@seb.st', password='top_secret') 155 | # self.other_user = CustomUser.objects.create_user( 156 | # username='bla1', email='two@seb.st', password='top_secret') 157 | 158 | 159 | # def test_lower_email_addresses(self): 160 | # self.fail() 161 | 162 | # def activate_subscription_on_verification(self): 163 | # self.fail() 164 | 165 | # def test_on_subscription_created(self): 166 | # self.fail() 167 | 168 | # def test_on_unsubscription_created(self): 169 | # self.fail() -------------------------------------------------------------------------------- /emaildigest/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | 3 | from . import views 4 | 5 | 6 | 7 | 8 | urlpatterns = [ 9 | #path('profile', views.profile, name="accounts_my_profile"), 10 | path('subscribe', views.subscribe, name="emaildigest_subscribe"), 11 | path('subscriptions', views.my_subscriptions, name="emaildigest_subscriptions"), 12 | 13 | path('unsubscribe', views.unsubscribe, name='emaildigest_unsubscribe'), 14 | path('unsubscribe//', views.unsubscribe, name='emaildigest_unsubscribe'), 15 | 16 | ] 17 | -------------------------------------------------------------------------------- /emaildigest/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | from django.shortcuts import get_object_or_404 3 | from django.urls import reverse 4 | from django.http import HttpResponse, HttpResponseRedirect 5 | from django.utils import timezone 6 | 7 | from .forms import get_subscription_form, UnsunscribeForm 8 | 9 | from .models import AnonymousSubscription, UnSubscription, Subscription, EmailDigest 10 | 11 | 12 | def subscribe(request): 13 | form = get_subscription_form(request.user, request.POST or None) 14 | thankyou = request.GET.get('thankyou', None) 15 | if thankyou: 16 | return render(request, 'emaildigest/subscribe_thankyou_%s.html'%(thankyou), {'prevent_footer_subscription_form': True}) 17 | verification_code = request.GET.get('v', None) 18 | if verification_code: 19 | subscription = get_object_or_404(AnonymousSubscription, verification_code=verification_code) 20 | if not subscription.verified: 21 | subscription.verified = True 22 | subscription.verified_at = timezone.now() 23 | subscription.save() 24 | # TODO: Verify User account if needed # TODO: Activate usage of get_subsciption_form 25 | return render(request, 'emaildigest/subscribe_verification_done.html', {'prevent_footer_subscription_form': True}) 26 | if request.method=="POST": 27 | if form.is_valid(): 28 | subscription = form.save() 29 | if request.user.is_authenticated: 30 | subscription.logged_in_user = request.user 31 | subscription.save() 32 | return HttpResponseRedirect(reverse('emaildigest_subscribe') + '?thankyou=' + form.thankyou) 33 | return render(request, 'emaildigest/subscribe.html', {'subscription_form': form, 'prevent_footer_subscription_form': True}) 34 | 35 | 36 | def unsubscribe(request, subscription_id=None, digest_id=None): 37 | if 'done' in request.GET.keys(): 38 | email = request.GET.get('email', None) 39 | suscription = request.GET.get('subscription', None) 40 | return render(request, 'emaildigest/unsubscribe_done.html', {'email': email, 'subscription': subscription, 'prevent_footer_subscription_form': True}) 41 | if subscription_id is None and digest_id is None: 42 | form = UnsunscribeForm(request.POST or None) 43 | if request.method=="POST": 44 | if form.is_valid(): 45 | email = form.cleaned_data['email'] 46 | subscriptions = Subscription.objects.filter(verfied_email=email, is_active=True) 47 | for subscription in subscriptions: 48 | unsubscription = UnSubscription(subscription=subscription) 49 | unsubscription.save() 50 | return HttpResponseRedirect(reverse('emaildigest_unsubscribe') + "?done&email="+email) 51 | return render(request, 'emaildigest/unsubscribe.html', {'form': form, 'prevent_footer_subscription_form': True}) 52 | elif subscription_id is not None and digest_id is not None: 53 | subscription = get_object_or_404(Subscription, pk=subscription_id) 54 | digest = get_object_or_404(EmailDigest, pk=digest_id) 55 | if request.method=="GET": 56 | return render(request, 'emaildigest/unsubscribe_confirm.html', {}) 57 | elif request.method=="POST": 58 | unsubscription = UnSubscription(subscription=subscription, from_digest=digest) 59 | unsubscription.save() 60 | return HttpResponseRedirect(reverse('emaildigest_unsubscribe') + "?done&subscription="+subscription_id) 61 | else: 62 | return HttpResponseRedirect(reverse('emaildigest_unsubscribe')) 63 | else: 64 | return HttpResponseRedirect(reverse('emaildigest_unsubscribe')) 65 | pass 66 | 67 | 68 | def my_subscriptions(request): 69 | return render(request, 'emaildigest/my_subscriptions.html') -------------------------------------------------------------------------------- /hnclone/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebst/pythonic-news/3a6d03985f405b427d6e88d3462b9703ec2908e6/hnclone/__init__.py -------------------------------------------------------------------------------- /hnclone/context_processors.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | def settings_context_processor(request): 3 | return { 4 | 'SITE_NAME': settings.SITE_NAME, 5 | 'SITE_DOMAIN': settings.SITE_DOMAIN, 6 | 'SITE_URL': settings.SITE_URL 7 | } -------------------------------------------------------------------------------- /hnclone/middleware.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebst/pythonic-news/3a6d03985f405b427d6e88d3462b9703ec2908e6/hnclone/middleware.py -------------------------------------------------------------------------------- /hnclone/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for hnclone project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.2.5. 5 | 6 | Modified by me 2019-09-30 15.50 CEST 7 | 8 | For more information on this file, see 9 | https://docs.djangoproject.com/en/2.2/topics/settings/ 10 | 11 | For the full list of settings and their values, see 12 | https://docs.djangoproject.com/en/2.2/ref/settings/ 13 | """ 14 | 15 | import os 16 | 17 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 18 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 19 | 20 | 21 | # Quick-start development settings - unsuitable for production 22 | # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ 23 | 24 | # SECURITY WARNING: keep the secret key used in production secret! 25 | SECRET_KEY = 'TODO' # TODO 26 | 27 | # SECURITY WARNING: don't run with debug turned on in production! 28 | DEBUG = False 29 | 30 | 31 | USE_X_FORWARDED_HOST = True 32 | ALLOWED_HOSTS = [ 33 | 'news.python.sc', 34 | 'localhost', 35 | ] 36 | 37 | 38 | # Application definition 39 | 40 | INSTALLED_APPS = [ 41 | 'django.contrib.admin', 42 | 'django.contrib.auth', 43 | 'django.contrib.contenttypes', 44 | 'django.contrib.sessions', 45 | 'django.contrib.messages', 46 | 'django.contrib.staticfiles', 47 | 'django.contrib.humanize', 48 | 49 | 'mptt', 50 | 'debug_toolbar', 51 | 52 | 'accounts', 53 | 'news', 54 | 'emaildigest', 55 | 56 | 57 | ] 58 | 59 | MIDDLEWARE = [ 60 | 'debug_toolbar.middleware.DebugToolbarMiddleware', 61 | 62 | 'django.middleware.security.SecurityMiddleware', 63 | 'django.contrib.sessions.middleware.SessionMiddleware', 64 | 'django.middleware.common.CommonMiddleware', 65 | 'django.middleware.csrf.CsrfViewMiddleware', 66 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 67 | 'django.contrib.messages.middleware.MessageMiddleware', 68 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 69 | # 'django.middleware.gzip.GZipMiddleware', 70 | #'htmlmin.middleware.HtmlMinifyMiddleware', # TODO: When activated, Django Debug Toolbar has JS issues 71 | 'htmlmin.middleware.MarkRequestMiddleware', 72 | ] 73 | 74 | ROOT_URLCONF = 'hnclone.urls' 75 | 76 | TEMPLATES = [ 77 | { 78 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 79 | 'DIRS': [], 80 | 'APP_DIRS': True, 81 | 'OPTIONS': { 82 | 'context_processors': [ 83 | 'django.template.context_processors.debug', 84 | 'django.template.context_processors.request', 85 | 'django.contrib.auth.context_processors.auth', 86 | 'django.contrib.messages.context_processors.messages', 87 | 'hnclone.context_processors.settings_context_processor', 88 | ], 89 | }, 90 | }, 91 | ] 92 | 93 | WSGI_APPLICATION = 'hnclone.wsgi.application' 94 | 95 | 96 | # Database 97 | # https://docs.djangoproject.com/en/2.2/ref/settings/#databases 98 | 99 | DATABASES = { 100 | 'default': { 101 | 'ENGINE': 'django.db.backends.sqlite3', 102 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 103 | } 104 | } 105 | 106 | 107 | # Password validation 108 | # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators 109 | 110 | AUTH_PASSWORD_VALIDATORS = [ 111 | { 112 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 113 | }, 114 | { 115 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 116 | }, 117 | { 118 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 119 | }, 120 | { 121 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 122 | }, 123 | ] 124 | 125 | 126 | # Internationalization 127 | # https://docs.djangoproject.com/en/2.2/topics/i18n/ 128 | 129 | LANGUAGE_CODE = 'en-us' 130 | 131 | TIME_ZONE = 'UTC' 132 | 133 | USE_I18N = True 134 | 135 | USE_L10N = True 136 | 137 | USE_TZ = True 138 | 139 | 140 | # Static files (CSS, JavaScript, Images) 141 | # https://docs.djangoproject.com/en/2.2/howto/static-files/ 142 | 143 | STATIC_URL = '/static/' 144 | STATICFILES_DIRS = [ 145 | os.path.join(BASE_DIR, "static"), 146 | ] 147 | 148 | 149 | AUTH_USER_MODEL = 'accounts.CustomUser' 150 | 151 | 152 | INTERNAL_IPS = [ 153 | '127.0.0.1', 154 | ] 155 | 156 | 157 | PAGING_SIZE = 30 158 | 159 | 160 | HTML_MINIFY = True 161 | 162 | 163 | 164 | LOGOUT_REDIRECT_URL = '/' 165 | LOGIN_REDIRECT_URL = '/' 166 | 167 | 168 | 169 | ACCEPT_UNINVITED_REGISTRATIONS = False 170 | 171 | 172 | SITE_NAME = 'Pythonic News' 173 | SITE_URL = 'https://news.python.sc' 174 | SITE_DOMAIN = 'news.python.sc' 175 | -------------------------------------------------------------------------------- /hnclone/urls.py: -------------------------------------------------------------------------------- 1 | """hnclone URL Configuration 2 | """ 3 | from django.contrib import admin 4 | from django.urls import path, include 5 | from django.conf import settings 6 | from django.conf.urls.static import static 7 | from django.http import HttpResponse 8 | 9 | urlpatterns = [ 10 | path('', include('news.urls')), 11 | path('', include('accounts.urls')), 12 | path('digest/', include('emaildigest.urls')), 13 | path('admin/', admin.site.urls), 14 | ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 15 | 16 | if settings.DEBUG: 17 | import debug_toolbar 18 | urlpatterns = [ 19 | path('__debug__/', include(debug_toolbar.urls)), 20 | ] + urlpatterns 21 | 22 | 23 | from ratelimit.exceptions import Ratelimited 24 | def handler403(request, exception=None): 25 | if isinstance(exception, Ratelimited): 26 | return HttpResponse("
Sorry, we're not able to serve your requests this quickly.", status=429) 27 | return HttpResponseForbidden('Forbidden') 28 | -------------------------------------------------------------------------------- /hnclone/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for hnclone 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, sys 11 | 12 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 13 | sys.path.append(BASE_DIR ) 14 | sys.path.append(BASE_DIR + '/../') 15 | 16 | from django.core.wsgi import get_wsgi_application 17 | 18 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'hnclone.settings') 19 | 20 | application = get_wsgi_application() 21 | -------------------------------------------------------------------------------- /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', 'hnclone.settings') 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == '__main__': 21 | main() 22 | -------------------------------------------------------------------------------- /news/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'news.apps.NewsConfig' 2 | -------------------------------------------------------------------------------- /news/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | 4 | from .models import Story, Comment 5 | 6 | admin.site.register(Story) 7 | admin.site.register(Comment) -------------------------------------------------------------------------------- /news/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class NewsConfig(AppConfig): 5 | name = 'news' 6 | 7 | def ready(self): 8 | from . import receivers -------------------------------------------------------------------------------- /news/feeds.py: -------------------------------------------------------------------------------- 1 | from django.contrib.syndication.views import Feed 2 | from .models import Story 3 | from .views import _newest, _front_page 4 | 5 | from django.conf import settings 6 | 7 | class NewestFeed(Feed): 8 | title = "%s: Latest"%(settings.SITE_NAME) 9 | link = "/newest/" 10 | description = "Latest stories" 11 | 12 | def items(self): 13 | return _newest(30, 0) 14 | 15 | def item_title(self, item): 16 | return item.title 17 | 18 | def item_pubdate(self, item): 19 | return item.created_at 20 | 21 | def item_updateddate(self, item): 22 | return item.changed_at 23 | 24 | def item_author_name(self, item): 25 | return str(item.user) 26 | 27 | def item_author_link(self, item): 28 | return settings.SITE_URL + item.user.get_absolute_url() 29 | 30 | def item_description(self, item): 31 | return "TODO" # TODO 32 | return item.url 33 | 34 | 35 | class FrontPageFeed(NewestFeed): 36 | title = "%s: Front Page" % (settings.SITE_NAME) 37 | link = "/feed" 38 | description = "Front Page stories" 39 | 40 | def items(self): 41 | return _front_page(30, 0) -------------------------------------------------------------------------------- /news/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from .models import Comment, Story 4 | 5 | class CommentForm(forms.ModelForm): 6 | class Meta: 7 | model = Comment 8 | fields = ['text'] 9 | 10 | 11 | class AddStoryForm(forms.ModelForm): 12 | class Meta: 13 | model = Story 14 | fields = ['title', 'url', 'text'] 15 | 16 | def clean(self): 17 | cleaned_data = super().clean() 18 | 19 | title = self.cleaned_data.get('title') 20 | text = self.cleaned_data.get('text') 21 | url = self.cleaned_data.get('url') 22 | if not title: 23 | raise forms.ValidationError("Please provide a title.") 24 | if (not text) and (not url): 25 | raise forms.ValidationError("Please provide either a text or a URL.") 26 | 27 | 28 | 29 | class StoryForm(forms.ModelForm): 30 | class Meta: 31 | model = Story 32 | fields = ['title', 'text'] 33 | 34 | 35 | -------------------------------------------------------------------------------- /news/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-07 12:14 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import mptt.fields 7 | import uuid 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='Item', 21 | fields=[ 22 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 23 | ('created_at', models.DateTimeField(auto_now_add=True)), 24 | ('changed_at', models.DateTimeField(auto_now=True)), 25 | ('upvotes', models.PositiveIntegerField(default=0)), 26 | ('downvotes', models.PositiveIntegerField(default=0)), 27 | ('points', models.IntegerField(default=0)), 28 | ('num_comments', models.PositiveIntegerField(default=0)), 29 | ('lft', models.PositiveIntegerField(editable=False)), 30 | ('rght', models.PositiveIntegerField(editable=False)), 31 | ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), 32 | ('level', models.PositiveIntegerField(editable=False)), 33 | ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='news.Item')), 34 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 35 | ], 36 | options={ 37 | 'abstract': False, 38 | }, 39 | ), 40 | migrations.CreateModel( 41 | name='Story', 42 | fields=[ 43 | ('item_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='news.Item')), 44 | ('url', models.URLField(null=True)), 45 | ('text', models.TextField(null=True)), 46 | ], 47 | options={ 48 | 'abstract': False, 49 | }, 50 | bases=('news.item',), 51 | ), 52 | migrations.CreateModel( 53 | name='Vote', 54 | fields=[ 55 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 56 | ('created_at', models.DateTimeField(auto_now_add=True)), 57 | ('changed_at', models.DateTimeField(auto_now=True)), 58 | ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='news.Item')), 59 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 60 | ], 61 | ), 62 | migrations.CreateModel( 63 | name='Comment', 64 | fields=[ 65 | ('item_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='news.Item')), 66 | ('text', models.TextField()), 67 | ('to_story', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='news.Story')), 68 | ], 69 | options={ 70 | 'abstract': False, 71 | }, 72 | bases=('news.item',), 73 | ), 74 | ] 75 | -------------------------------------------------------------------------------- /news/migrations/0002_story_title.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-07 13:25 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('news', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='story', 15 | name='title', 16 | field=models.CharField(default='-', max_length=255), 17 | preserve_default=False, 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /news/migrations/0003_auto_20190908_1642.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-08 16:42 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import mptt.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('news', '0002_story_title'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='vote', 17 | name='vote', 18 | field=models.SmallIntegerField(default=1), 19 | ), 20 | migrations.AlterField( 21 | model_name='comment', 22 | name='to_story', 23 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='news.Story'), 24 | ), 25 | migrations.AlterField( 26 | model_name='item', 27 | name='downvotes', 28 | field=models.PositiveIntegerField(default=0, editable=False), 29 | ), 30 | migrations.AlterField( 31 | model_name='item', 32 | name='num_comments', 33 | field=models.PositiveIntegerField(default=0, editable=False), 34 | ), 35 | migrations.AlterField( 36 | model_name='item', 37 | name='parent', 38 | field=mptt.fields.TreeForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='news.Item'), 39 | ), 40 | migrations.AlterField( 41 | model_name='item', 42 | name='points', 43 | field=models.IntegerField(default=0, editable=False), 44 | ), 45 | migrations.AlterField( 46 | model_name='item', 47 | name='upvotes', 48 | field=models.PositiveIntegerField(default=0, editable=False), 49 | ), 50 | migrations.AlterField( 51 | model_name='story', 52 | name='text', 53 | field=models.TextField(blank=True, null=True), 54 | ), 55 | migrations.AlterField( 56 | model_name='story', 57 | name='title', 58 | field=models.CharField(blank=True, max_length=255), 59 | ), 60 | migrations.AlterField( 61 | model_name='story', 62 | name='url', 63 | field=models.URLField(blank=True, null=True), 64 | ), 65 | ] 66 | -------------------------------------------------------------------------------- /news/migrations/0004_auto_20190908_2249.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-08 22:49 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('news', '0003_auto_20190908_1642'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddIndex( 14 | model_name='item', 15 | index=models.Index(fields=['points', 'created_at'], name='news_item_points_d03885_idx'), 16 | ), 17 | migrations.AddIndex( 18 | model_name='item', 19 | index=models.Index(fields=['created_at', 'points'], name='news_item_created_f2b812_idx'), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /news/migrations/0005_auto_20190908_2250.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-08 22:50 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('news', '0004_auto_20190908_2249'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveIndex( 14 | model_name='item', 15 | name='news_item_points_d03885_idx', 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /news/migrations/0006_auto_20190908_2251.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-08 22:51 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('news', '0005_auto_20190908_2250'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveIndex( 14 | model_name='item', 15 | name='news_item_created_f2b812_idx', 16 | ), 17 | migrations.AddIndex( 18 | model_name='item', 19 | index=models.Index(fields=['points', 'created_at'], name='news_item_points_d03885_idx'), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /news/migrations/0007_auto_20190908_2256.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-08 22:56 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('news', '0006_auto_20190908_2251'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveIndex( 14 | model_name='item', 15 | name='news_item_points_d03885_idx', 16 | ), 17 | migrations.AddField( 18 | model_name='item', 19 | name='is_ask', 20 | field=models.BooleanField(default=False), 21 | ), 22 | migrations.AddField( 23 | model_name='item', 24 | name='is_show', 25 | field=models.BooleanField(default=False), 26 | ), 27 | migrations.AddIndex( 28 | model_name='item', 29 | index=models.Index(fields=['created_at', 'points'], name='news_item_created_f2b812_idx'), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /news/migrations/0008_story_duplicate_of.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-23 13:23 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('news', '0007_auto_20190908_2256'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='story', 16 | name='duplicate_of', 17 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='news.Story'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /news/migrations/0009_story_domain.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-30 16:17 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('news', '0008_story_duplicate_of'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='story', 15 | name='domain', 16 | field=models.CharField(blank=True, max_length=255, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /news/migrations/0010_auto_20190930_1620.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-30 16:20 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('news', '0009_story_domain'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='story', 15 | name='domain', 16 | field=models.CharField(blank=True, db_index=True, max_length=255, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /news/migrations/0011_auto_20190930_1623.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-30 16:23 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('news', '0010_auto_20190930_1620'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddIndex( 14 | model_name='story', 15 | index=models.Index(fields=['domain', 'duplicate_of'], name='news_story_domain_07db78_idx'), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /news/migrations/0012_auto_20190930_1625.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-30 16:25 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('news', '0011_auto_20190930_1623'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddIndex( 14 | model_name='item', 15 | index=models.Index(fields=['created_at', 'id'], name='news_item_created_10dd57_idx'), 16 | ), 17 | migrations.AddIndex( 18 | model_name='item', 19 | index=models.Index(fields=['id', 'created_at'], name='news_item_id_b557ce_idx'), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /news/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebst/pythonic-news/3a6d03985f405b427d6e88d3462b9703ec2908e6/news/migrations/__init__.py -------------------------------------------------------------------------------- /news/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from accounts.models import CustomUser 4 | 5 | from django.db import models 6 | from mptt.models import MPTTModel, TreeForeignKey 7 | from django.urls import reverse 8 | 9 | from urllib.parse import urlparse 10 | 11 | 12 | 13 | class Item(MPTTModel): 14 | class Meta: 15 | indexes = [ 16 | #models.Index(fields=['points', 'created_at']), 17 | models.Index(fields=['created_at', 'points']), 18 | models.Index(fields=['created_at', 'id']), 19 | models.Index(fields=['id', 'created_at']), 20 | ] 21 | # ordering = ['-created_at'] 22 | 23 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 24 | created_at = models.DateTimeField(auto_now_add=True) 25 | changed_at = models.DateTimeField(auto_now=True) 26 | upvotes = models.PositiveIntegerField(default=0, editable=False) 27 | downvotes = models.PositiveIntegerField(default=0, editable=False) 28 | points = models.IntegerField(default=0, editable=False) 29 | num_comments = models.PositiveIntegerField(default=0, editable=False) 30 | parent = TreeForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children', editable=False) 31 | user = models.ForeignKey(to=CustomUser, on_delete=models.CASCADE) 32 | 33 | is_ask = models.BooleanField(default=False) 34 | is_show = models.BooleanField(default=False) 35 | 36 | 37 | def get_absolute_url(self): 38 | return reverse("item", kwargs={"pk": self.pk}) 39 | 40 | 41 | def can_be_upvoted_by(self, user): 42 | if not user.is_authenticated: 43 | return False 44 | if user == self.user: 45 | return False 46 | if Vote.objects.filter(user=user, item=self).count(): 47 | return False 48 | return True 49 | 50 | def can_be_downvoted_by(self, user): 51 | if not user.is_authenticated: 52 | return False 53 | if user == self.user: 54 | return False 55 | else: 56 | if user.karma > 1: 57 | if not Vote.objects.filter(user=user, item=self).count(): 58 | return True 59 | return False 60 | 61 | def can_be_edited_by(self, user): 62 | return self.user == user and self.num_comments == 0 63 | 64 | def can_be_deleted_by(self, user): 65 | return self.can_be_edited_by(user) 66 | 67 | 68 | 69 | class Story(Item): 70 | class Meta: 71 | indexes = [ 72 | models.Index(fields=['domain', 'duplicate_of']), 73 | ] 74 | is_story = True 75 | 76 | # class Meta: 77 | # ordering = ['-pk'] 78 | title = models.CharField(max_length=255, blank=True) 79 | url = models.URLField(null=True, blank=True) 80 | text = models.TextField(null=True, blank=True) 81 | duplicate_of = models.ForeignKey('Story', on_delete=models.CASCADE, null=True) 82 | domain = models.CharField(max_length=255, null=True, blank=True, db_index=True) 83 | 84 | 85 | def __str__(self): 86 | return self.title 87 | 88 | # @property 89 | # def domain(self): 90 | # o = urlparse(self.url) 91 | # return o.hostname 92 | 93 | def can_be_downvoted_by(self, user): 94 | return False 95 | 96 | # def Kcomments(self): 97 | # return self.comments 98 | # return { 99 | # 'all': self.get_descendants() # lambda: [i.comment for i in self.get_descendants().select_related('comment')] 100 | # } 101 | # return self.comments 102 | 103 | 104 | class Comment(Item): 105 | is_comment = True 106 | 107 | text = models.TextField() 108 | to_story = models.ForeignKey(Story, on_delete=models.CASCADE, related_name="comments") 109 | 110 | def __str__(self): 111 | return self.text[:255] 112 | 113 | def comments(self): 114 | return { 115 | #'all': lambda: [i.comment for i in self.get_descendants().select_related('comment')] 116 | 'all': lambda: [i.comment for i in self.get_descendants()] 117 | } 118 | 119 | 120 | class Vote(models.Model): 121 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 122 | created_at = models.DateTimeField(auto_now_add=True) 123 | changed_at = models.DateTimeField(auto_now=True) 124 | item = models.ForeignKey(Item, on_delete=models.CASCADE) 125 | # vote = None # -1 | 0 | 1 --> BooleanField(default=None, null=True)?? 126 | vote = models.SmallIntegerField(default=1) 127 | user = models.ForeignKey(to=CustomUser, on_delete=models.CASCADE) 128 | 129 | 130 | -------------------------------------------------------------------------------- /news/receivers.py: -------------------------------------------------------------------------------- 1 | #from django.core.signals import request_finished 2 | from django.db.models.signals import pre_save, post_save, post_delete 3 | from django.dispatch import receiver 4 | 5 | from urllib.parse import urlparse 6 | 7 | from .models import Item, Vote, Comment, Story 8 | 9 | @receiver(pre_save) 10 | def mark_show_and_ask(sender, instance, **kwargs): 11 | if isinstance(instance, Story): 12 | if instance.title.lower().startswith('ask'): 13 | instance.is_ask = True 14 | if instance.title.lower().startswith('show'): 15 | instance.is_show = True 16 | 17 | 18 | @receiver(post_save) 19 | def create_self_upvote_for_submission(sender, instance, created, **kwargs): 20 | if created and isinstance(instance, Item): 21 | vote = Vote(item=instance, user=instance.user) 22 | vote.save() 23 | 24 | 25 | @receiver(post_save) 26 | def check_for_duplicates(sender, instance, created, **kwargs): 27 | if created and isinstance(instance, Story): 28 | story = instance 29 | if story.url and story.duplicate_of is None: 30 | other_stories = Story.objects.filter(url=story.url).exclude(pk=story.pk).order_by('-changed_at') 31 | c = other_stories.count() 32 | if c > 0: 33 | new_vote = Vote(item=other_stories[0], vote=1, user=story.user) 34 | new_vote.save() 35 | story.duplicate_of = other_stories[0] 36 | story.save() 37 | 38 | 39 | @receiver(post_save) 40 | def update_votes_count_on_submission(sender, instance, created, **kwargs): 41 | if created and isinstance(instance, Vote): 42 | vote = instance 43 | item = instance.item 44 | other_votes = Vote.objects.filter(item=vote.item, user=vote.user, vote=vote.vote).exclude(pk=vote.pk) 45 | if other_votes.count(): 46 | return 47 | if instance.vote > 0: 48 | item.upvotes += instance.vote 49 | else: 50 | item.downvotes += (-1)*instance.vote 51 | item.points += instance.vote 52 | item.save() 53 | 54 | 55 | @receiver(post_save) 56 | def update_user_karma_on_vote(sender, instance, created, **kwargs): 57 | if created and isinstance(instance, Vote): 58 | vote = instance 59 | other_votes = Vote.objects.filter(item=vote.item, user=vote.user, vote=vote.vote).exclude(pk=vote.pk) 60 | if other_votes.count(): 61 | return 62 | item = instance.item 63 | if item.user != instance.user: 64 | item.user.karma += instance.vote 65 | item.user.save() 66 | 67 | 68 | def _recount_comments(instance, val=1): 69 | assert isinstance(instance, Comment) 70 | instance.to_story.num_comments += val 71 | instance.to_story.save() 72 | parent = instance.parent 73 | while parent is not None: 74 | parent.num_comments += val 75 | parent.save() 76 | parent = parent.parent 77 | 78 | 79 | @receiver(post_save) 80 | def update_comments_count_on_submission(sender, instance, created, **kwargs): 81 | if created and isinstance(instance, Comment): 82 | _recount_comments(instance, 1) 83 | 84 | 85 | @receiver(post_delete) 86 | def update_comments_count_on_deletion(sender, instance, **kwargs): 87 | if isinstance(instance, Comment): 88 | _recount_comments(instance, -1) 89 | 90 | 91 | @receiver(post_delete) 92 | def update_item_votes_on_unvote(sender, instance, **kwargs): 93 | if isinstance(instance, Vote): 94 | vote = instance 95 | item = vote.item 96 | if vote.user == item.user: 97 | return 98 | item.points -= vote.vote 99 | if vote.vote > 0: 100 | item.upvotes -= abs(vote.vote) 101 | else: 102 | item.downvotes -= abs(vote.vote) 103 | item.save() 104 | 105 | 106 | @receiver(post_delete) 107 | def update_user_karma_on_unvote(sender, instance, **kwargs): 108 | if isinstance(instance, Vote): 109 | vote = instance 110 | item = vote.item 111 | if vote.user == item.user: 112 | return 113 | item.user.karma -= vote.vote 114 | item.user.save() 115 | 116 | 117 | @receiver(pre_save) 118 | def add_domain_to_link_stories(sender, instance, **kwargs): 119 | if isinstance(instance, Story): 120 | if instance.url: 121 | o = urlparse(instance.url) 122 | instance.domain = o.hostname.lower() -------------------------------------------------------------------------------- /news/templates/news/__base.html: -------------------------------------------------------------------------------- 1 | {% load static %}{% load humanize %}{% load emaildigest_extra %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 31 | 32 | {% block title %}{% endblock title %}{{SITE_NAME}} 33 | 34 | 35 | 36 | 37 |
38 | 78 | 79 | {% block content %}{% endblock content %} 80 | 81 | 87 | 88 | 89 | 92 |
93 | 94 | 95 | -------------------------------------------------------------------------------- /news/templates/news/_item_content_tag.html: -------------------------------------------------------------------------------- 1 | {% load news_extra %} 2 | 3 |
4 | {% if item.title %} 5 | 6 | {% if item.url %} 7 | {{item.title}} {% if item.domain %}({{item.domain}}){% endif %} 8 | {% else %} 9 | {{item.title}} 10 | {% endif %} 11 | 12 | {% endif %} 13 | {% if item.text and not hide_text %} 14 | {{ item.text | comment_markdown }} 15 | {% endif %} 16 |
17 | -------------------------------------------------------------------------------- /news/templates/news/_item_control_tag.html: -------------------------------------------------------------------------------- 1 | {% load humanize %} 2 | {% load news_extra %} 3 | 4 | 5 | {{item.points}} point{{item.points|pluralize}} by {%link_user item.user%} {{item.created_at|naturaltime}} 6 | | flag 7 | | hide 8 | | {{item.num_comments}} comments 9 | {% if item.num_comments == 0 and item.user == request_user %}| edit{% endif %} 10 | {% if item.num_comments == 0 and item.user == request_user %}| delete{% endif %} 11 | -------------------------------------------------------------------------------- /news/templates/news/_item_tag.html: -------------------------------------------------------------------------------- 1 | {% load humanize %} 2 | {% load static %} 3 | {% load news_extra %} 4 | 5 | 6 | 7 | {%if rank %}{{rank}}.{% endif %} 8 |
9 | 10 | {% user_arrows user=user item=item as assignment_options %} 11 | 12 | {% if 'star' in assignment_options %} 13 | * 14 | {% endif %} 15 | {% if 'up' in assignment_options %} 16 |
{% csrf_token %}
17 | {% endif %} 18 | {% if 'down' in assignment_options %} 19 |
{% csrf_token %}
20 | {% endif %} 21 | {% if not assignment_options %} 22 |   23 | {% endif %} 24 | 25 |
26 | {% if item.is_comment %}{% item_control item=item request_user=request_user %} {% else%} {% item_content item=item hide_text=hide_text %} {% endif %} 27 | 28 | 29 | {% if item.is_comment %}{% item_content item=item hide_text=hide_text %} {% else %}{% item_control item=item request_user=request_user %}{% endif %} 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /news/templates/news/_link_user_tag.html: -------------------------------------------------------------------------------- 1 | {{user}} -------------------------------------------------------------------------------- /news/templates/news/_more_link_tag.html: -------------------------------------------------------------------------------- 1 | More -------------------------------------------------------------------------------- /news/templates/news/bookmarklet.html: -------------------------------------------------------------------------------- 1 | {% extends "news/__base.html" %} 2 | {% load humanize %} 3 | {% load static %} 4 | {% load mptt_tags %} 5 | {% load news_extra %} 6 | 7 | 8 | {% block content %} 9 |
10 | 11 | 12 |

When you click on the bookmarklet, it will submit the page you're on. To install, drag this link to your browser toolbar:

13 | 14 |
15 | post to 🐍 16 |
17 | 18 | 19 |
20 | 21 | {% endblock content %} -------------------------------------------------------------------------------- /news/templates/news/formatting_help.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 32 | 39 | 40 | 41 | 46 | 53 | 54 | 55 | 58 | 61 | 62 | 63 |
MarkdownOutput
# Heading level 1

Heading level 1

## Heading level 2

Heading level 2

### Heading level 3
Heading level 3
#### Heading level 4
Heading level 4
28 | * unordered item 1
29 | * unordered item 2
30 | * unordered item 3
31 |                 
33 |
    34 |
  • unordered item 1
  • 35 |
  • unordered item 2
  • 36 |
  • unordered item 3
  • 37 |
38 |
42 | 1. ordered item 1
43 | 2. ordered item 2
44 | 3. ordered item 3
45 |                 
47 |
    48 |
  1. ordered item 1
  2. 49 |
  3. ordered item 2
  4. 50 |
  5. ordered item 3
  6. 51 |
52 |
56 | [title](https://www.google.com/)
57 |                 
59 | title 60 |
64 |
65 | 73 | -------------------------------------------------------------------------------- /news/templates/news/index.html: -------------------------------------------------------------------------------- 1 | {% extends "news/__base.html" %} 2 | {% load humanize %} 3 | {% load static %} 4 | {% load mptt_tags %} 5 | {% load news_extra %} 6 | 7 | 8 | {% block content %} 9 |
10 | 11 | {% for story in stories %} 12 | {% news_item item=story show_text=False hide_text=hide_text rank=forloop.counter|add:rank_start user=user %} 13 | {% if show_children %} 14 | 26 | {% endif %} 27 | {% endfor %} 28 |
15 | {% recursetree story.comments.all %} 16 | 17 | {% news_item item=node show_text=True hide_text=hide_text rank=None user=user %} 18 | {% if not node.is_leaf_node %} 19 | 22 | {% endif %} 23 |
20 | {{ children }} 21 |
24 | {% endrecursetree %} 25 |
29 | 30 | 31 | 32 | 33 | {% more_link %} 34 | 35 |
36 | 37 | {% endblock content %} -------------------------------------------------------------------------------- /news/templates/news/item.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends 'news/__base.html' %} 3 | {% load humanize %} 4 | {% load mptt_tags %} 5 | {% load news_extra %} 6 | 7 | 8 | 9 | 10 | 11 | {% block content %} 12 |
13 | 14 | 15 | 16 | 17 | {% news_item item=item rank=None hide_text=False user=user %} 18 |
19 | 20 | 21 | 22 | 23 | {% if comment_form %} 24 |
{% csrf_token %} 25 | {{ comment_form.text }} 26 |

27 | 28 | 32 |
33 | 34 | {% include "news/formatting_help.html" %} 35 | 36 | {% endif %} 37 | 38 | 39 |

40 | 41 | 42 | 43 | {% recursetree item.comments.all %} 44 | {% news_item item=node rank=None show_text=True hide_text=False user=user %} 45 | {% if not node.is_leaf_node %} 46 | 51 | {% endif %} 52 | {% endrecursetree %} 53 |
47 | 48 | {{ children }} 49 |
50 |
54 | 55 | 56 | 57 | 58 |
59 | {% endblock content %} 60 | -------------------------------------------------------------------------------- /news/templates/news/item_delete.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends 'news/__base.html' %} 3 | {% load humanize %} 4 | {% load mptt_tags %} 5 | {% load news_extra %} 6 | 7 | 8 | 9 | 10 | 11 | {% block content %} 12 |
13 | 14 | Are you sure, you want to delete this:
15 | 16 | 17 |
18 | 19 | {% news_item item=item rank=None hide_text=False user=user %} 20 |
21 |
22 | 23 |
{% csrf_token %} 24 | 25 |
26 | 27 | 28 | 29 |
30 | {% endblock content %} -------------------------------------------------------------------------------- /news/templates/news/item_edit.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends 'news/__base.html' %} 3 | {% load humanize %} 4 | {% load mptt_tags %} 5 | {% load news_extra %} 6 | 7 | 8 | 9 | 10 | 11 | {% block content %} 12 |
13 | 14 | 15 | 16 | 17 | 18 | {% news_item item=item rank=None hide_text=False user=user %} 19 | 20 |
21 | 22 | 23 | 24 | 25 | {% if edit_form %} 26 |
{% csrf_token %} 27 | {{ edit_form.text }}guidelines 28 |
29 | 30 |
31 | {% endif %} 32 | 33 | 34 |
35 | {% endblock content %} -------------------------------------------------------------------------------- /news/templates/news/submit.html: -------------------------------------------------------------------------------- 1 | {% extends 'news/__base.html' %} 2 | 3 | {% block content %} 4 |
5 |
{% csrf_token %} 6 | 7 | {{ form.as_table }} 8 |
9 | 10 |
11 | 12 |

13 | Leave url blank to submit a question for discussion. If there is no url, the text (if any) will appear at the top of the thread. 14 |

15 | You can also submit via bookmarklet. 16 |

17 | 18 | 19 |
20 | {% endblock content %} -------------------------------------------------------------------------------- /news/templates/news/zen.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends 'news/__base.html' %} 3 | {% load humanize %} 4 | {% load mptt_tags %} 5 | 6 | 7 | 8 | {% block content %} 9 | 10 | 11 | 12 | 13 |
14 | 15 |

The Zen of {{SITE_NAME}} (aka Guidelines)

16 | 17 |

Based on The Zen of Python

18 | 19 |

{{SITE_NAME}} is a platform for friends of Python 20 | (the programming language).
21 | It shall enable people to discover, share and discuss interesting thoughts, publications or web pieces about 22 | Python and its related fields.
23 | On the platform, anybody shall feel welcomed. 24 |

25 | 26 | 27 |

Beautiful is better than ugly.

28 |

29 | Be beautiful to each other. Always remember, {{SITE_NAME}} is a community built around Python stuff, which 30 | is mostly technical. When discussing this kind of stuff, there is no need to offend someone personally. 31 |

32 | 33 |

Explicit is better than implicit.

34 |

Be on-topic. We are here to discuss the Python programming language, its ecosystem, its applications and 35 | its community. For off-topic discussions there are plenty of forums out there on the net.
36 | This community regards political discussions as off-topic, unless discussion the walrus operator (just kidding).

37 | 38 |

In the face of ambiguity, refuse the temptation to guess.

39 |

Please think of a contribution of another member of this community as well-intended. We should all assume 40 | that people here behave nicely and respectful.

41 | 42 | 47 | 48 |

Errors should never pass silently.

49 |

We value transparency. If there is any action taken by moderators on submissions, comments or user accounts, 50 | we try to be as clear about what happened as possible. For now, there is no moderation system in place. This means 51 | that we might be forced to delete contributions silently when we notice off-topic, disrespectful or otherwise abusive 52 | content without further notice. However, a moderation audit trail is on the roadmap to be implemented soon

53 | 54 | 55 |

Code of Conduct

56 |

This is a news aggregator and we all want to be nice to each other. Rude behaviour in form of continiously spamming, 57 | posting off-topic content, offending people ad-hominem, will result in a ban.

58 | 59 | 78 | 79 | 80 |
81 | 82 | 83 | 84 | 85 | 86 | 87 | {% endblock content %} -------------------------------------------------------------------------------- /news/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebst/pythonic-news/3a6d03985f405b427d6e88d3462b9703ec2908e6/news/templatetags/__init__.py -------------------------------------------------------------------------------- /news/templatetags/news_extra.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.utils.safestring import mark_safe 3 | 4 | import mistune 5 | 6 | register = template.Library() 7 | 8 | @register.inclusion_tag('news/_item_tag.html', takes_context=True) 9 | def news_item(context, item, **kwargs): 10 | kwargs['item'] = item 11 | kwargs['request_user'] = context['user'] 12 | return kwargs 13 | 14 | @register.inclusion_tag('news/_link_user_tag.html') 15 | def link_user(user): 16 | return { 17 | 'user': user 18 | } 19 | 20 | @register.simple_tag 21 | def user_arrows(user, item): 22 | if user == item.user: 23 | return ['star'] 24 | else: 25 | res = [] 26 | if item.can_be_upvoted_by(user=user): 27 | res.append('up') 28 | if item.can_be_downvoted_by(user=user): 29 | res.append('down') 30 | return res 31 | 32 | 33 | @register.inclusion_tag('news/_more_link_tag.html', takes_context=True) 34 | def more_link(context): 35 | request = context.request 36 | page = int(request.GET.get('p', 0)) 37 | query_dict = request.GET.copy() 38 | query_dict['p'] = page + 1 39 | _more_link=request.path_info + '?' + query_dict.urlencode() 40 | return {'more_link': _more_link} 41 | 42 | 43 | @register.inclusion_tag('news/_item_content_tag.html') 44 | def item_content(**kwargs): 45 | return kwargs 46 | 47 | 48 | @register.inclusion_tag('news/_item_control_tag.html') 49 | def item_control(**kwargs): 50 | return kwargs 51 | 52 | 53 | class MarkdownRenderer(mistune.Renderer): 54 | 55 | @classmethod 56 | def setup(cls): 57 | renderer = cls(escape=True, hard_wrap=True) 58 | return mistune.Markdown(renderer=renderer) 59 | 60 | def header(self, text, level, raw=None): 61 | level = min(level + 2, 6) 62 | return super().header(text, level, raw) 63 | 64 | 65 | markdown = MarkdownRenderer.setup() 66 | 67 | @register.filter 68 | def comment_markdown(value): 69 | return mark_safe(markdown(value)) 70 | -------------------------------------------------------------------------------- /news/tests.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AnonymousUser 2 | from accounts.models import CustomUser 3 | from django.test import RequestFactory, TestCase 4 | 5 | from .views import * 6 | from .models import * 7 | 8 | class BasicNewsTest(TestCase): 9 | """Tests the basic functionality of the news app.""" 10 | def setUp(self): 11 | # Every test needs access to the request factory. 12 | self.factory = RequestFactory() 13 | self.user = CustomUser.objects.create_user( 14 | username='sebst', email='hi@seb.st', password='top_secret') 15 | self.other_user = CustomUser.objects.create_user( 16 | username='bla1', email='two@seb.st', password='top_secret') 17 | 18 | def test_submit_get(self): 19 | """The submit form is displayed.""" 20 | request = self.factory.get('/submit') 21 | request.user = self.user 22 | response = submit(request) 23 | self.assertEqual(response.status_code, 200) 24 | self.assertContains(response, '', views.item, name="item"), 18 | path('item//upvote', views.upvote, name="upvote"), # TODO 19 | path('item//downvote', views.downvote, name="downvote"), # TODO 20 | path('item//edit', views.item_edit, name="edit"), 21 | path('item//delete', views.item_delete, name="delete"), 22 | path('submit', views.submit, name="submit"), 23 | 24 | path('newest/feed/', NewestFeed()), 25 | path('feed/', FrontPageFeed()), 26 | 27 | path('robots.txt', views.robots_txt, name="robots_txt"), 28 | path('humans.txt', views.humans_txt, name="humans_txt"), 29 | path('bookmarklet', views.bookmarklet, name="bookmarklet"), 30 | ] 31 | -------------------------------------------------------------------------------- /news/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | from django.shortcuts import get_object_or_404 3 | from django.http import HttpResponseRedirect, HttpResponse, Http404, HttpResponseForbidden 4 | from django.conf import settings 5 | from django.contrib.auth.decorators import login_required 6 | from django.urls import reverse 7 | 8 | from .models import Item, Story, Comment, Vote 9 | from accounts.models import CustomUser 10 | from .forms import CommentForm, AddStoryForm, StoryForm 11 | 12 | from ratelimit.decorators import ratelimit 13 | 14 | 15 | # create_story 16 | # add_comment 17 | # upvote / downvote / unvote 18 | # flag / hide 19 | # Tags? 20 | # suggest changes 21 | # save 22 | 23 | 24 | DEFAULT_GET_RATE = "2/s" 25 | DEFAULT_VOTES_RATE = "10/m" 26 | DEFAULT_POST_RATE = "5/m" 27 | 28 | TIMEOUT_MEDIUM = 0# 1*60 # one minute 29 | TIMEOUT_SHORT = 0# 1*2 # two seconds 30 | 31 | 32 | from django.db.models.functions import Power, Now, Cast, Extract #, Min 33 | from django.db.models import Value, F, Func, ExpressionWrapper, fields, Q, Min 34 | from django.db.models import OuterRef, Subquery 35 | from django.db import models 36 | from django.utils import timezone 37 | import datetime 38 | 39 | from django.core.cache import cache 40 | from django.views.decorators.vary import vary_on_cookie 41 | from django.views.decorators.cache import cache_page 42 | 43 | from django.db import connection 44 | 45 | 46 | def _one_page_back(request): 47 | page = int(request.GET.get('p', 0)) 48 | query_dict = request.GET.copy() 49 | page = page - 1 50 | if page >= 0: 51 | query_dict['p'] = page 52 | else: 53 | return None 54 | _more_link=request.path_info + '?' + query_dict.urlencode() 55 | return HttpResponseRedirect(_more_link) 56 | 57 | def _front_page(paging_size=settings.PAGING_SIZE, page=0, add_filter={}, add_q=[], as_of=None, days_back=50): 58 | # TODO: weighting https://medium.com/hacking-and-gonzo/how-hacker-news-ranking-algorithm-works-1d9b0cf2c08d 59 | # (P-1) / (T+2)^G 60 | if as_of is None: 61 | now = timezone.now() 62 | else: 63 | now = as_of 64 | if connection.vendor == 'postgresql': 65 | now_value = Value(now, output_field=fields.DateTimeField()) 66 | submission_age_float = ExpressionWrapper( ( now_value - F('created_at')), output_field=fields.DurationField()) 67 | submission_age_hours = ExpressionWrapper(Extract(F('tf'), 'epoch') / 60 / 60 + 2.1 , output_field=fields.FloatField()) 68 | real_p = ExpressionWrapper(F('points') - 1, output_field=fields.FloatField()) 69 | formula = ExpressionWrapper( F('p') / ( Power(F('tfh'), F('g')) +0.001) , output_field=fields.FloatField()) 70 | return Story.objects.select_related('user')\ 71 | .filter(duplicate_of__isnull=True)\ 72 | .filter(points__gte=1) \ 73 | .filter(created_at__gte=now - datetime.timedelta(days=days_back)) \ 74 | .filter(created_at__lte=now) \ 75 | .filter(**add_filter) \ 76 | .annotate(tf=submission_age_float) \ 77 | .annotate(tfh=submission_age_hours) \ 78 | .annotate(p=real_p) \ 79 | .annotate(g=Value(1.8, output_field=fields.FloatField())) \ 80 | .annotate(formula=formula) \ 81 | .order_by('-formula')[(page*paging_size):(page+1)*(paging_size)] 82 | elif connection.vendor == 'sqlite': 83 | now_value = Value(now, output_field=fields.DateTimeField()) 84 | submission_age_float = ExpressionWrapper( ( now_value - F('created_at')), output_field=fields.FloatField()) 85 | submission_age_hours = ExpressionWrapper(F('tf') / 60 / 60 / 1000000 + 2.1 , output_field=fields.FloatField()) 86 | real_p = ExpressionWrapper(F('points') - 1, output_field=fields.FloatField()) 87 | formula = ExpressionWrapper( F('p') / ( Power(F('tfh'), F('g')) +0.001) , output_field=fields.FloatField()) 88 | return Story.objects.select_related('user')\ 89 | .filter(duplicate_of__isnull=True)\ 90 | .filter(points__gte=1) \ 91 | .filter(created_at__gte=now - datetime.timedelta(days=days_back)) \ 92 | .filter(created_at__lte=now) \ 93 | .filter(**add_filter) \ 94 | .annotate(tf=submission_age_float) \ 95 | .annotate(tfh=submission_age_hours) \ 96 | .annotate(p=real_p) \ 97 | .annotate(g=Value(1.8, output_field=fields.FloatField())) \ 98 | .annotate(formula=formula) \ 99 | .order_by('-formula')[(page*paging_size):(page+1)*(paging_size)] 100 | else: 101 | raise NotImplementedError("No frontpage magic for database engine %s implemented"%(connection.vendor)) 102 | 103 | 104 | def _newest(paging_size=settings.PAGING_SIZE, page=0, add_filter={}, add_q=[]): 105 | return Story.objects \ 106 | .select_related('user') \ 107 | .filter(duplicate_of__isnull=True) \ 108 | .filter(**add_filter) \ 109 | .filter(*add_q) \ 110 | .order_by('-created_at')[(page*paging_size):(page+1)*(paging_size)] 111 | 112 | 113 | @ratelimit(key="user_or_ip", group="news-get", rate=DEFAULT_GET_RATE, block=True) 114 | def index(request): 115 | page = int(request.GET.get('p', 0)) 116 | stories = cache.get_or_set("news-index-%s"%(page), lambda: list(_front_page(page=page)), timeout=TIMEOUT_MEDIUM) # one minute 117 | if len(stories) < 1 and page != 0: 118 | back = _one_page_back(request) 119 | if back: 120 | return back 121 | return render(request, 'news/index.html', {'stories': stories, 'hide_text':True, 'page': page, 'rank_start': page*settings.PAGING_SIZE}) 122 | 123 | 124 | @ratelimit(key="user_or_ip", group="news-get", rate=DEFAULT_GET_RATE, block=True) 125 | def show(request): 126 | page = int(request.GET.get('p', 0)) 127 | stories = cache.get_or_set("news-show-%s"%(page), lambda: list(_front_page(page=page, add_filter={'is_show': True})), timeout=TIMEOUT_MEDIUM) # one minute 128 | if len(stories) < 1 and page != 0: 129 | back = _one_page_back(request) 130 | if back: 131 | return back 132 | return render(request, 'news/index.html', {'stories': stories, 'hide_text':True, 'page': page, 'rank_start': page*settings.PAGING_SIZE}) 133 | 134 | 135 | @ratelimit(key="user_or_ip", group="news-get", rate=DEFAULT_GET_RATE, block=True) 136 | def ask(request): 137 | page = int(request.GET.get('p', 0)) 138 | stories = lambda: list(_front_page(page=page, add_filter={'is_ask': True})) 139 | stories = cache.get_or_set("news-ask-%s"%(page), stories, timeout=TIMEOUT_MEDIUM) # one minute 140 | if len(stories) < 1 and page != 0: 141 | back = _one_page_back(request) 142 | if back: 143 | return back 144 | return render(request, 'news/index.html', {'stories': stories, 'hide_text':True, 'page': page, 'rank_start': page*settings.PAGING_SIZE}) 145 | 146 | 147 | @ratelimit(key="user_or_ip", group="news-get", rate=DEFAULT_GET_RATE, block=True) 148 | def newest(request): # Done 149 | page = int(request.GET.get('p', 0)) 150 | add_filter = {} 151 | add_q = [] 152 | if 'submitted_by' in request.GET.keys(): 153 | try: 154 | submitted_by = CustomUser.objects.get_by_natural_key(request.GET['submitted_by']) 155 | add_filter['user'] = submitted_by 156 | except CustomUser.DoesNotExist: 157 | raise Http404() 158 | if 'upvoted_by' in request.GET.keys(): 159 | try: 160 | assert request.user.is_authenticated 161 | assert request.user.username == request.GET['upvoted_by'] 162 | except AssertionError: 163 | return HttpResponseForbidden() 164 | add_filter['pk__in'] = Vote.objects.filter(vote=1, user=request.user).values('item') 165 | add_q.append(~Q(user=request.user)) 166 | if 'site' in request.GET.keys(): 167 | add_filter['domain'] = request.GET['site'] 168 | stories = lambda: list(_newest(page=page, add_filter=add_filter, add_q=add_q)) 169 | stories = cache.get_or_set("news-newest-%s"%(page), stories, timeout=TIMEOUT_SHORT) # two seconds 170 | if len(stories) < 1 and page != 0: 171 | back = _one_page_back(request) 172 | if back: 173 | return back 174 | return render(request, 'news/index.html', {'stories': stories, 'hide_text':True, 'page': page, 'rank_start': page*settings.PAGING_SIZE}) 175 | 176 | 177 | @login_required 178 | @ratelimit(key="user_or_ip", group="news-get", rate=DEFAULT_GET_RATE, block=True) 179 | @cache_page(TIMEOUT_SHORT) 180 | @vary_on_cookie 181 | def threads(request): 182 | page = int(request.GET.get('p', 0)) 183 | paging_size = settings.PAGING_SIZE 184 | tree = Comment.objects.filter( tree_id=OuterRef('tree_id'), user=OuterRef('user')).values('tree_id', 'user__pk').annotate(min_level=Min('level')).order_by() 185 | stories = Comment.objects.filter( 186 | user=request.user 187 | ).filter( 188 | Q(level__in=Subquery(tree.values('min_level'), output_field=models.IntegerField())) # TODO: level= or level__in= ??? 189 | ).select_related( 190 | 'user', 'parent', 'to_story' 191 | ).order_by( 192 | '-created_at' 193 | )[(page*paging_size):(page+1)*(paging_size)] 194 | if len(stories) < 1 and page != 0: 195 | back = _one_page_back(request) 196 | if back: 197 | return back 198 | return render(request, 'news/index.html', {'stories': stories, 'hide_text':False, 'page': page, 'rank_start': None, 'show_children': True}) 199 | 200 | 201 | @ratelimit(key="user_or_ip", group="news-get", rate=DEFAULT_GET_RATE, block=True) 202 | @cache_page(TIMEOUT_SHORT) 203 | @vary_on_cookie 204 | def comments(request): # TODO 205 | page = int(request.GET.get('p', 0)) 206 | paging_size = settings.PAGING_SIZE 207 | add_filter = {} 208 | if 'submitted_by' in request.GET.keys(): 209 | try: 210 | submitted_by = CustomUser.objects.get_by_natural_key(request.GET['submitted_by']) 211 | add_filter['user'] = submitted_by 212 | except CustomUser.DoesNotExist: 213 | raise Http404() 214 | if 'upvoted_by' in request.GET.keys(): 215 | try: 216 | assert request.user.is_authenticated 217 | assert request.user.username == request.GET['upvoted_by'] 218 | except AssertionError: 219 | return HttpResponseForbidden() 220 | add_filter['pk__in'] = Vote.objects.filter(vote=1, user=request.user).values('item') 221 | stories = Comment.objects.filter( 222 | parent=None 223 | ).filter( 224 | **add_filter 225 | ).select_related( 226 | 'user', 'parent', 'to_story' 227 | ).order_by( 228 | 'created_at' 229 | )[(page*paging_size):(page+1)*(paging_size)] 230 | if len(stories) < 1 and page != 0: 231 | back = _one_page_back(request) 232 | if back: 233 | return back 234 | return render(request, 'news/index.html', {'stories': stories, 'hide_text':False, 'page': page, 'rank_start': page*paging_size}) 235 | 236 | 237 | @ratelimit(key="user_or_ip", group="news-get", rate=DEFAULT_GET_RATE, block=True) 238 | def zen(request): 239 | return render(request, 'news/zen.html') 240 | 241 | 242 | def _vote(request, pk, vote=None, unvote=False): 243 | assert not unvote and vote is not None or unvote and vote is None 244 | item = get_object_or_404(Item, pk=pk) 245 | if (not unvote) and (vote is not None): 246 | votes = Vote.objects.filter(item=item, user=request.user) 247 | if request.method=="POST": 248 | if vote > 0: 249 | if not item.can_be_upvoted_by(request.user): 250 | return HttpResponseForbidden() 251 | else: 252 | if not item.can_be_downvoted_by(request.user): 253 | return HttpResponseForbidden() 254 | vote = Vote(vote=vote, item=item, user=request.user) 255 | vote.save() 256 | return HttpResponse("OK %s"%(vote.pk)) 257 | if unvote: 258 | if request.method=="POST": 259 | Vote.objects.filter(item=item, user=request.user).delete() 260 | return HttpResponse("OK") 261 | 262 | 263 | @login_required 264 | @ratelimit(key="user_or_ip", group="news-votes", rate=DEFAULT_VOTES_RATE, block=True) 265 | def upvote(request, pk): 266 | return _vote(request, pk, vote=1) 267 | 268 | 269 | @login_required 270 | @ratelimit(key="user_or_ip", group="news-votes", rate=DEFAULT_VOTES_RATE, block=True) 271 | def downvote(request, pk): 272 | return _vote(request, pk, vote=-1) 273 | 274 | 275 | @login_required 276 | @ratelimit(key="user_or_ip", group="news-votes", rate=DEFAULT_VOTES_RATE, block=True) 277 | def unvote(request, pk): 278 | return _vote(request, pk, vote=None, unvote=True) 279 | 280 | 281 | def flag(request): 282 | pass 283 | 284 | 285 | def save(request): 286 | pass 287 | 288 | 289 | def _item_story_comment(pk): 290 | try: 291 | # .prefetch_related('children', 'parent') 292 | item = Item.objects.select_related('story', 'comment', 'user', 'parent').prefetch_related('children').get(pk=pk) 293 | except Exception as e: 294 | raise e 295 | try: 296 | story = item.story 297 | comment = None 298 | item = story 299 | except Item.story.RelatedObjectDoesNotExist: 300 | story = None 301 | comment = None 302 | try: 303 | comment = item.comment 304 | story = comment.to_story 305 | item = comment 306 | except Item.comment.RelatedObjectDoesNotExist: 307 | pass 308 | assert story is not None 309 | return item, story, comment 310 | 311 | 312 | @ratelimit(key="user_or_ip", group="news-get", rate=DEFAULT_GET_RATE, method=['GET'], block=True) 313 | @ratelimit(key="user_or_ip", group="news-post", rate=DEFAULT_POST_RATE, method=['POST'], block=True) 314 | def item(request, pk): # DONE 315 | item, story, comment = _item_story_comment(pk) 316 | if story == item: 317 | if story.duplicate_of is not None: 318 | return HttpResponseRedirect(story.duplicate_of.get_absolute_url()) 319 | if request.user.is_authenticated: 320 | parent = None if story==item else item 321 | comment_instance = Comment(user=request.user, to_story=story, parent=parent) 322 | comment_form = CommentForm(request.POST or None, instance=comment_instance) 323 | if request.method == 'POST': 324 | if comment_form.is_valid(): 325 | comment = comment_form.save() 326 | return HttpResponseRedirect(story.get_absolute_url() + '#' + str(comment.pk)) 327 | else: 328 | comment_form = None 329 | return render(request, 'news/item.html', {'item': item, 'comment_form': comment_form}) 330 | 331 | 332 | @login_required 333 | @ratelimit(key="user_or_ip", group="news-get", rate=DEFAULT_GET_RATE, method=['GET'], block=True) 334 | @ratelimit(key="user_or_ip", group="news-post", rate=DEFAULT_POST_RATE, method=['POST'], block=True) 335 | def item_edit(request, pk): 336 | item, story, comment = _item_story_comment(pk) 337 | if not item.can_be_edited_by(request.user): 338 | return HttpResponseForbidden() 339 | if story == item: 340 | if story.duplicate_of is not None: 341 | return HttpResponseRedirect(story.duplicate_of.get_absolute_url()) 342 | if comment is not None: 343 | form = CommentForm(request.POST or None, instance=item) 344 | else: 345 | assert story is not None 346 | form = StoryForm(request.POST or None, instance=item) 347 | assert form is not None 348 | if request.method=="POST": 349 | if form.is_valid(): 350 | item = form.save() 351 | return HttpResponseRedirect(story.get_absolute_url() + '#' + str(item.pk)) 352 | return render(request, 'news/item_edit.html', {'item': item, 'edit_form': form}) 353 | 354 | 355 | @login_required 356 | @ratelimit(key="user_or_ip", group="news-get", rate=DEFAULT_GET_RATE, method=['GET'], block=True) 357 | @ratelimit(key="user_or_ip", group="news-post", rate=DEFAULT_POST_RATE, method=['POST'], block=True) 358 | def item_delete(request, pk): 359 | item, story, comment = _item_story_comment(pk) 360 | if not item.can_be_deleted_by(request.user): 361 | return HttpResponseForbidden() 362 | if request.method == "POST": 363 | redirect_url = '/' 364 | if comment is not None: 365 | redirect_url = item.to_story.get_absolute_url() 366 | item.delete() 367 | return HttpResponseRedirect(redirect_url) 368 | return render(request, 'news/item_delete.html', {'item': item}) 369 | 370 | 371 | @login_required 372 | @ratelimit(key="user_or_ip", group="news-get", rate=DEFAULT_GET_RATE, method=['GET'], block=True) 373 | @ratelimit(key="user_or_ip", group="news-post", rate=DEFAULT_POST_RATE, method=['POST'], block=True) 374 | def submit(request): # DONE 375 | instance = Story(user=request.user) 376 | form = AddStoryForm(request.POST or None, initial={ 377 | 'title': request.GET.get('t'), 378 | 'url': request.GET.get('u'), 379 | 'text': request.GET.get('x'), 380 | }, instance=instance) 381 | if request.method=="POST": 382 | if form.is_valid(): 383 | instance = form.save() 384 | return HttpResponseRedirect(instance.get_absolute_url()) 385 | return render(request, 'news/submit.html', {'form': form}) 386 | 387 | 388 | def robots_txt(request): 389 | return HttpResponse(""" 390 | User-agent: * 391 | Disallow: 392 | """, content_type='text/plain') 393 | 394 | def humans_txt(request): 395 | return HttpResponse(""" 396 | 🐍 397 | """, content_type='text/plain', charset='utf-8') 398 | 399 | 400 | def bookmarklet(request): 401 | return render(request, 'news/bookmarklet.html') 402 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | appnope==0.1.0 2 | aws-psycopg2==1.1.1 3 | awsebcli==3.15.3 4 | backcall==0.1.0 5 | beautifulsoup4==4.8.0 6 | blessed==1.15.0 7 | botocore==1.12.238 8 | cached-property==1.5.1 9 | cement==2.8.2 10 | certifi==2019.9.11 11 | chardet==3.0.4 12 | colorama==0.3.9 13 | coverage==4.5.4 14 | decorator==4.4.0 15 | Django==2.2.5 16 | django-debug-toolbar==2.0 17 | django-htmlmin==0.11.0 18 | django-js-asset==1.2.2 19 | django-mptt==0.10.0 20 | django-ratelimit==2.0.0 21 | docker==3.7.3 22 | docker-compose==1.23.2 23 | docker-pycreds==0.4.0 24 | dockerpty==0.4.1 25 | docopt==0.6.2 26 | docutils==0.15.2 27 | future==0.16.0 28 | html5lib==1.0.1 29 | idna==2.7 30 | ipython==7.8.0 31 | ipython-genutils==0.2.0 32 | jedi==0.15.1 33 | jmespath==0.9.4 34 | jsonschema==2.6.0 35 | Markdown==3.1.1 36 | mistune==0.8.4 37 | parso==0.5.1 38 | pathspec==0.5.9 39 | pexpect==4.7.0 40 | pickleshare==0.7.5 41 | prompt-toolkit==2.0.9 42 | ptyprocess==0.6.0 43 | Pygments==2.4.2 44 | python-dateutil==2.8.0 45 | pytz==2019.2 46 | PyYAML==3.13 47 | requests==2.20.1 48 | semantic-version==2.5.0 49 | six==1.11.0 50 | soupsieve==1.9.3 51 | sqlparse==0.3.0 52 | termcolor==1.1.0 53 | texttable==0.9.1 54 | traitlets==4.3.2 55 | urllib3==1.24.3 56 | wcwidth==0.1.7 57 | webencodings==0.5.1 58 | websocket-client==0.56.0 59 | -------------------------------------------------------------------------------- /static/grayarrow2x.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebst/pythonic-news/3a6d03985f405b427d6e88d3462b9703ec2908e6/static/grayarrow2x.gif -------------------------------------------------------------------------------- /static/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebst/pythonic-news/3a6d03985f405b427d6e88d3462b9703ec2908e6/static/icon.png -------------------------------------------------------------------------------- /static/news.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | https://www.schemecolor.com/python-logo-colors.php 4 | Cyan-Blue Azure #4B8BBE 5 | Lapis Lazuli #306998 6 | Shandy #FFE873 7 | Sunglow #FFD43B 8 | Granite Gray #646464 9 | 10 | 11 | */ 12 | /* https://encycolorpedia.com/646464 */ 13 | 14 | /* General formatting */ 15 | 16 | body { font-family:Verdana, Geneva, sans-serif; font-size:10pt; color:#646464; } 17 | td { font-family:Verdana, Geneva, sans-serif; font-size:10pt; color:#646464; } 18 | .smaller { font-size:7pt; } 19 | 20 | input { font-family:monospace; font-size:10pt; } 21 | input[type=\"submit\"] { font-family:Verdana, Geneva, sans-serif; } 22 | textarea { font-family:monospace; font-size:10pt; } 23 | 24 | .clearfix { overflow: hidden; clear: both } 25 | 26 | p.small { margin: 5px 0; } 27 | 28 | ul.horizontal-list { 29 | list-style: none; 30 | margin: 0; 31 | padding: 0; 32 | } 33 | 34 | ul.horizontal-list li { 35 | display: inline-block; 36 | } 37 | 38 | button { padding: 0.25em; } 39 | 40 | a { color: inherit; } 41 | .green { color:green; } 42 | 43 | nav#top-bar { background-color:#306998; } 44 | nav#footer-bar { background-color:#FFD43B; } 45 | main { background-color: #EDF3F9; } 46 | nav#top-bar * { color:#FFE873; } 47 | 48 | .site-content { margin: 3%; } 49 | .site-content-dense { margin: 1%; } 50 | td {padding-right:8px; vertical-align: text-top;} 51 | #top-bar td {vertical-align: middle;} 52 | tr.spacer {height:1em;} 53 | 54 | .highlight {background-color:#FFE873;} 55 | .self-item {color: #4B8BBE;} 56 | 57 | .title > a { 58 | text-decoration: none; 59 | } 60 | 61 | .title > a:visited { 62 | color: #999; 63 | } 64 | 65 | /* td {border:1px solid red;} */ 66 | 67 | /* Site layout */ 68 | 69 | nav#top-bar *.active { 70 | color: #FFD43B; 71 | font-weight: bold; 72 | } 73 | 74 | #pnmain { 75 | width: 85%; 76 | margin: auto; 77 | } 78 | 79 | nav#pre-footer-bar > hr { 80 | border-top: 2px solid #306998; 81 | width: 95%; 82 | } 83 | 84 | 85 | 86 | /* Item Layout */ 87 | 88 | 89 | 90 | /* Forms */ 91 | 92 | form.logout-form { 93 | border: 0; 94 | margin:0; 95 | padding:0; 96 | display:inline; 97 | } 98 | 99 | .logout-button { 100 | margin:0; 101 | border:0; 102 | padding:0; 103 | background: transparent; 104 | text-decoration: underline; 105 | font-family:Verdana, Geneva, sans-serif; font-size:10pt; 106 | cursor: pointer; 107 | } 108 | 109 | form.comment-form { 110 | padding: 20px; 111 | } 112 | 113 | form.comment-form textarea { 114 | width: 50%; 115 | resize: vertical; 116 | } 117 | 118 | form.comment-form button { 119 | float: left; 120 | margin-right: 10px; 121 | } 122 | 123 | form.comment-form ul { 124 | float: left; 125 | } 126 | 127 | .vote-form { 128 | border: 0; 129 | margin:0; 130 | padding:0; 131 | } 132 | 133 | .vote-button { 134 | background:transparent; 135 | display:block; 136 | padding:0; 137 | padding-top:4px; 138 | /* border: 1px solid red; */ 139 | border:0; 140 | } 141 | 142 | /* http://apps.eky.hk/css-triangle-generator/ */ 143 | div.arrow-up { 144 | position:relative; 145 | top:-2px; 146 | width: 0; 147 | height: 0; 148 | border-style: solid; 149 | border-width: 0 0.3em 0.7em 0.3em; 150 | border-color: transparent transparent #646464 transparent; 151 | } 152 | 153 | div.arrow-down { 154 | width: 0; 155 | height: 0; 156 | border-style: solid; 157 | border-width: 0.7em 0.3em 0 0.3em; 158 | border-color: #646464 transparent transparent transparent; 159 | } 160 | 161 | 162 | .controls { line-height:6pt; vertical-align: text-top; } 163 | 164 | .formatting-help { 165 | border: 2px solid #ddd; 166 | padding: 0.25em; 167 | margin: 0 20px; 168 | box-sizing: border-box; 169 | } 170 | 171 | .formatting-help.closed { 172 | display: none; 173 | } 174 | 175 | .formatting-help h3 { 176 | display: block; 177 | margin: 0; 178 | padding: 0.5em 0 0.25em 0; 179 | font-size: 13px; 180 | font-weight: 600; 181 | } 182 | 183 | .formatting-help h3:first-child { 184 | padding-top: 0; 185 | } 186 | 187 | 188 | /* mobile device */ 189 | @media only screen 190 | and (min-width : 300px) 191 | and (max-width : 750px) { 192 | #pnmain { width: 100%; margin:0; } 193 | body { padding: 0; margin: 0; width: 100%; -webkit-text-size-adjust: none; } 194 | td { height: inherit !important; } 195 | .title, .comment { font-size: inherit; } 196 | span.pagetop { display: block; margin: 3px 5px; font-size: 12px; } 197 | span.pagetop b { display: block; font-size: 15px; } 198 | 199 | .vote-form { 200 | padding:0.2em; 201 | transform: scale(1.8); 202 | } 203 | 204 | body { font-family:Verdana, Geneva, sans-serif; font-size:12pt; color:#646464; } 205 | td { font-family:Verdana, Geneva, sans-serif; font-size:12pt; color:#646464; } 206 | .smaller { font-size:10pt; } 207 | 208 | button { 209 | background-color: #e3e3e3; 210 | border: #646464; 211 | color: #535353; 212 | text-align: center; 213 | text-decoration: none; 214 | display: inline-block; 215 | } 216 | 217 | form.comment-form textarea { 218 | width: 100%; 219 | } 220 | 221 | div.arrow-up { 222 | position:relative; 223 | top:-2px; 224 | width: 0; 225 | height: 0; 226 | border-style: solid; 227 | border-width: 0 0.2em 0.6em 0.2em; 228 | border-color: transparent transparent #646464 transparent; 229 | } 230 | 231 | div.arrow-down { 232 | width: 0; 233 | height: 0; 234 | border-style: solid; 235 | border-width: 0.6em 0.2em 0 0.2em; 236 | border-color: #646464 transparent transparent transparent; 237 | } 238 | 239 | 240 | } 241 | -------------------------------------------------------------------------------- /static/news.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", function() { 2 | 3 | // Highlight the posted comment, if comment-id is in url hash 4 | if(e = document.getElementById(window.location.hash.substring(1))) e.classList.add('highlight'); 5 | 6 | function getFormData(form) { 7 | var data = {}; 8 | for (var i = 0, ii = form.length; i < ii; ++i) { 9 | var input = form[i]; 10 | if (input.name) { 11 | data[input.name] = input.value; 12 | } 13 | } 14 | return data; 15 | } 16 | // AJAX-ify the upvote button 17 | document.querySelectorAll(".vote-form").forEach(function (elem_form) { 18 | elem_form.addEventListener("submit", function(e){ 19 | e.preventDefault(); 20 | var form = e.srcElement; 21 | var data = getFormData(form); 22 | var xhr = new XMLHttpRequest(); 23 | xhr.open(form.method, form.action, true); 24 | var form_data = new FormData(); 25 | for ( var key in data ) { 26 | form_data.append(key, data[key]); 27 | } 28 | xhr.send(form_data); 29 | // form.parentNode.removeChild(form); 30 | var parent = form.parentNode; 31 | parent.parentNode.removeChild(parent); 32 | }); 33 | }); 34 | 35 | }); --------------------------------------------------------------------------------