diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..8ef8a69 Binary files /dev/null and b/.DS_Store differ diff --git a/.env.example b/.env.example index 8f1c625..d8e374c 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,6 @@ SECRET_KEY= DEBUG=True ALLOWED_HOSTS=127.0.0.1,localhost -DEVELOPMENT_MODE=False # Website details SITE_URL=SITE URL @@ -43,6 +42,11 @@ EMAIL_HOST_PASSWORD= EMAIL_USE_TLS=True DEFAULT_FROM_EMAIL= +POST_TYPE_CHOICES=article:Article,quiz:Quiz,lesson:Lesson +SHOW_EMTPY_CATEGORIES=False + #ads.txt file content -MY_ADS_TXT_CONTENT= \ No newline at end of file +LOAD_GOOGLE_TAG_MANAGER=True +LOAD_GOOGLE_ADS=True +MY_ADS_TXT_CONTENT= diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..6e1473a --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +github: [nilandev] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..68606dc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,20 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[BUG]" +labels: waiting for triage +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..19171e1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[New Feature] " +labels: waiting for triage +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml new file mode 100644 index 0000000..f198fa8 --- /dev/null +++ b/.github/workflows/pylint.yml @@ -0,0 +1,24 @@ +name: Pylint + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10"] + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pylint + - name: Analysing the code with pylint + run: | + pylint -d C0114,C0115,C0116,C0301,E1101,R0903,R0901,W0613 $(git ls-files '*.py') --fail-under=9 diff --git a/.gitignore b/.gitignore index 525bf51..088a14b 100644 --- a/.gitignore +++ b/.gitignore @@ -64,16 +64,8 @@ docs/_build/ /bloggy_frontend/node_modules/ /bloggy/static/ /bloggy/static -/bloggy/static/debug_toolbar -/bloggy/static/hitcount -/bloggy/static/media -/bloggy/static/rest_framework -/bloggy/static/summernote -/bloggy/static/colorfield -/bloggy/static/admin/ -media/uploads/default_avatar.png -media/uploads/articles/* -media/uploads/categories/* -media/uploads/course/* -media/uploads/quiz/* -media/uploads/user/* \ No newline at end of file +media/uploads/* +media/uploads/ + +# Virtual Environments Ignore +.venv \ No newline at end of file diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..0111a6b --- /dev/null +++ b/.pylintrc @@ -0,0 +1,2 @@ +[MASTER] +ignore=migrations \ No newline at end of file diff --git a/.readthedocs.yaml b/.readthedocs.yaml deleted file mode 100644 index 4b249b7..0000000 --- a/.readthedocs.yaml +++ /dev/null @@ -1,32 +0,0 @@ -# .readthedocs.yaml -# Read the Docs configuration file -# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details - -# Required -version: 2 - -# Set the OS, Python version and other tools you might need -build: - os: ubuntu-22.04 - tools: - python: "3.12" - # You can also specify other tool versions: - # nodejs: "19" - # rust: "1.64" - # golang: "1.19" - -# Build documentation in the "docs/" directory with Sphinx -sphinx: - configuration: docs/conf.py - -# Optionally build your docs in additional formats such as PDF and ePub -# formats: -# - pdf -# - epub - -# Optional but recommended, declare the Python requirements required -# to build your documentation -# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html -# python: -# install: -# - requirements: docs/requirements.txt \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..0c87cf0 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,47 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Djanog:Run", + "type": "python", + "request": "launch", + "stopOnEntry": false, + "python": "${workspaceRoot}/venv/bin/python3", + "program": "${workspaceFolder}/manage.py", + "args": [ + "runserver" + ], + "django": false, + "justMyCode": true, + "autoReload": { + "enable": true + } + }, + { + "name": "Extension", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}" + ] + }, + { + "name": "Djanog:Debug", + "type": "python", + "request": "launch", + "stopOnEntry": false, + "python": "${workspaceRoot}/venv/bin/python3", + "program": "${workspaceFolder}/manage.py", + "args": [ + "runserver" + ], + "django": true, + "justMyCode": true + }, + ], + "compounds": [] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5d23c7f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "editor.wordWrapColumn": 120, + "python.analysis.typeCheckingMode": "basic", + "python.formatting.provider": "autopep8", + "editor.formatOnSave": false, + "python.linting.enabled": true, + "python.linting.lintOnSave": true, + // "editor.fontFamily": "Dank Mono, JetBrains Mono NL, Fira Code, Menlo, Monaco, 'Courier New', monospace", + // "editor.fontSize": 14, +} \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..68179aa --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,131 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of + any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Project Maintainers are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Project Maintainers have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported (this can be done anonymously) to the Project Maintainers responsible for enforcement at https://stacktips.com/contact. +All complaints will be reviewed and investigated promptly and fairly. + +All Project Maintainers are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Project Maintainers will follow these Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from Project Maintainers, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][mozilla coc]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][faq]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[mozilla coc]: https://github.com/mozilla/diversity +[faq]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e457f24 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,42 @@ +# Contributing Guide + +## Creating an Issue +Before **creating** an Issue for `features`/`bugs`/`improvements` please follow these steps: +1. Search existing Issues before creating a new issue (has someone raised this already) +1. If it doesn't exist create a new issue giving as much context as possible +1. All issues are automatically given the label `status: waiting for triage` and are automatically locked so no comments can be made +1. If you wish to work on the Issue once it has been triaged and label changed to `status: ready for dev`, please include this in your Issue description + +## Working on an Issue (get it assigned to you) + +Before working on an existing Issue please follow these steps: + +1. Only ask to be assigned 1 **open** issue at a time +1. Look out for the Issue label `status: ready for dev` +1. Comment asking for the issue to be assigned to you. +1. After the Issue is assigned to you, you can start working on it +1. **Only** start working on this Issue (and open a Pull Request) when it has been assigned to you - this will prevent confusion, multiple people working on the same issue, and work not being used +1. reference the Issue in your Pull Request (for example `closes #123`) + +### Notes: +- check the `Assignees` box at the top of the page to see if the issue has been assigned to someone else before requesting this be assigned to you +- if an Issue is unclear, ask questions to get more clarity before asking to have the Issue assigned to you +- only request to be assigned an Issue if you know how to work on it +- an Issue can be assigned to multiple people if you all agree to collaborate on the issue (the Pull Request can contain commits from different collaborators) +- any Issues that have no activity after 2 weeks will be unassigned and re-assigned to someone else + +## Reviewing Pull Requests + +We welcome everyone to review Pull Requests, it is a great way to learn, network, and support each other. + +### DOs + +- be kind and respectful +- use inline comments to explain your suggestions +- use inline suggestions to propose changes + +### DON'Ts + +- do not be rude, disrespectful, or aggressive +- do not repeat feedback, this creates more noise than value (check the existing conversation), use GitHub reactions if you agree/disagree with a comment +- do not blindly approve pull requests to improve your GitHub contributor's graph diff --git a/LICENSE b/LICENSE index d6a9326..77b4268 100644 --- a/LICENSE +++ b/LICENSE @@ -1,340 +1,21 @@ -GNU GENERAL PUBLIC LICENSE - Version 2, June 1991 - - Copyright (C) 1989, 1991 Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The licenses for most software are designed to take away your -freedom to share and change it. By contrast, the GNU General Public -License is intended to guarantee your freedom to share and change free -software--to make sure the software is free for all its users. This -General Public License applies to most of the Free Software -Foundation's software and to any other program whose authors commit to -using it. (Some other Free Software Foundation software is covered by -the GNU Lesser General Public License instead.) You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -this service if you wish), that you receive source code or can get it -if you want it, that you can change the software or use pieces of it -in new free programs; and that you know you can do these things. - - To protect your rights, we need to make restrictions that forbid -anyone to deny you these rights or to ask you to surrender the rights. -These restrictions translate to certain responsibilities for you if you -distribute copies of the software, or if you modify it. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must give the recipients all the rights that -you have. You must make sure that they, too, receive or can get the -source code. And you must show them these terms so they know their -rights. - - We protect your rights with two steps: (1) copyright the software, and -(2) offer you this license which gives you legal permission to copy, -distribute and/or modify the software. - - Also, for each author's protection and ours, we want to make certain -that everyone understands that there is no warranty for this free -software. If the software is modified by someone else and passed on, we -want its recipients to know that what they have is not the original, so -that any problems introduced by others will not reflect on the original -authors' reputations. - - Finally, any free program is threatened constantly by software -patents. We wish to avoid the danger that redistributors of a free -program will individually obtain patent licenses, in effect making the -program proprietary. To prevent this, we have made it clear that any -patent must be licensed for everyone's free use or not licensed at all. - - The precise terms and conditions for copying, distribution and -modification follow. - - GNU GENERAL PUBLIC LICENSE - TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - - 0. This License applies to any program or other work which contains -a notice placed by the copyright holder saying it may be distributed -under the terms of this General Public License. The "Program", below, -refers to any such program or work, and a "work based on the Program" -means either the Program or any derivative work under copyright law: -that is to say, a work containing the Program or a portion of it, -either verbatim or with modifications and/or translated into another -language. (Hereinafter, translation is included without limitation in -the term "modification".) Each licensee is addressed as "you". - -Activities other than copying, distribution and modification are not -covered by this License; they are outside its scope. The act of -running the Program is not restricted, and the output from the Program -is covered only if its contents constitute a work based on the -Program (independent of having been made by running the Program). -Whether that is true depends on what the Program does. - - 1. You may copy and distribute verbatim copies of the Program's -source code as you receive it, in any medium, provided that you -conspicuously and appropriately publish on each copy an appropriate -copyright notice and disclaimer of warranty; keep intact all the -notices that refer to this License and to the absence of any warranty; -and give any other recipients of the Program a copy of this License -along with the Program. - -You may charge a fee for the physical act of transferring a copy, and -you may at your option offer warranty protection in exchange for a fee. - - 2. You may modify your copy or copies of the Program or any portion -of it, thus forming a work based on the Program, and copy and -distribute such modifications or work under the terms of Section 1 -above, provided that you also meet all of these conditions: - - a) You must cause the modified files to carry prominent notices - stating that you changed the files and the date of any change. - - b) You must cause any work that you distribute or publish, that in - whole or in part contains or is derived from the Program or any - part thereof, to be licensed as a whole at no charge to all third - parties under the terms of this License. - - c) If the modified program normally reads commands interactively - when run, you must cause it, when started running for such - interactive use in the most ordinary way, to print or display an - announcement including an appropriate copyright notice and a - notice that there is no warranty (or else, saying that you provide - a warranty) and that users may redistribute the program under - these conditions, and telling the user how to view a copy of this - License. (Exception: if the Program itself is interactive but - does not normally print such an announcement, your work based on - the Program is not required to print an announcement.) - -These requirements apply to the modified work as a whole. If -identifiable sections of that work are not derived from the Program, -and can be reasonably considered independent and separate works in -themselves, then this License, and its terms, do not apply to those -sections when you distribute them as separate works. But when you -distribute the same sections as part of a whole which is a work based -on the Program, the distribution of the whole must be on the terms of -this License, whose permissions for other licensees extend to the -entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest -your rights to work written entirely by you; rather, the intent is to -exercise the right to control the distribution of derivative or -collective works based on the Program. - -In addition, mere aggregation of another work not based on the Program -with the Program (or with a work based on the Program) on a volume of -a storage or distribution medium does not bring the other work under -the scope of this License. - - 3. You may copy and distribute the Program (or a work based on it, -under Section 2) in object code or executable form under the terms of -Sections 1 and 2 above provided that you also do one of the following: - - a) Accompany it with the complete corresponding machine-readable - source code, which must be distributed under the terms of Sections - 1 and 2 above on a medium customarily used for software interchange; or, - - b) Accompany it with a written offer, valid for at least three - years, to give any third party, for a charge no more than your - cost of physically performing source distribution, a complete - machine-readable copy of the corresponding source code, to be - distributed under the terms of Sections 1 and 2 above on a medium - customarily used for software interchange; or, - - c) Accompany it with the information you received as to the offer - to distribute corresponding source code. (This alternative is - allowed only for noncommercial distribution and only if you - received the program in object code or executable form with such - an offer, in accord with Subsection b above.) - -The source code for a work means the preferred form of the work for -making modifications to it. For an executable work, complete source -code means all the source code for all modules it contains, plus any -associated interface definition files, plus the scripts used to -control compilation and installation of the executable. However, as a -special exception, the source code distributed need not include -anything that is normally distributed (in either source or binary -form) with the major components (compiler, kernel, and so on) of the -operating system on which the executable runs, unless that component -itself accompanies the executable. - -If distribution of executable or object code is made by offering -access to copy from a designated place, then offering equivalent -access to copy the source code from the same place counts as -distribution of the source code, even though third parties are not -compelled to copy the source along with the object code. - - 4. You may not copy, modify, sublicense, or distribute the Program -except as expressly provided under this License. Any attempt -otherwise to copy, modify, sublicense or distribute the Program is -void, and will automatically terminate your rights under this License. -However, parties who have received copies, or rights, from you under -this License will not have their licenses terminated so long as such -parties remain in full compliance. - - 5. You are not required to accept this License, since you have not -signed it. However, nothing else grants you permission to modify or -distribute the Program or its derivative works. These actions are -prohibited by law if you do not accept this License. Therefore, by -modifying or distributing the Program (or any work based on the -Program), you indicate your acceptance of this License to do so, and -all its terms and conditions for copying, distributing or modifying -the Program or works based on it. - - 6. Each time you redistribute the Program (or any work based on the -Program), the recipient automatically receives a license from the -original licensor to copy, distribute or modify the Program subject to -these terms and conditions. You may not impose any further -restrictions on the recipients' exercise of the rights granted herein. -You are not responsible for enforcing compliance by third parties to -this License. - - 7. If, as a consequence of a court judgment or allegation of patent -infringement or for any other reason (not limited to patent issues), -conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot -distribute so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you -may not distribute the Program at all. For example, if a patent -license would not permit royalty-free redistribution of the Program by -all those who receive copies directly or indirectly through you, then -the only way you could satisfy both it and this License would be to -refrain entirely from distribution of the Program. - -If any portion of this section is held invalid or unenforceable under -any particular circumstance, the balance of the section is intended to -apply and the section as a whole is intended to apply in other -circumstances. - -It is not the purpose of this section to induce you to infringe any -patents or other property right claims or to contest validity of any -such claims; this section has the sole purpose of protecting the -integrity of the free software distribution system, which is -implemented by public license practices. Many people have made -generous contributions to the wide range of software distributed -through that system in reliance on consistent application of that -system; it is up to the author/donor to decide if he or she is willing -to distribute software through any other system and a licensee cannot -impose that choice. - -This section is intended to make thoroughly clear what is believed to -be a consequence of the rest of this License. - - 8. If the distribution and/or use of the Program is restricted in -certain countries either by patents or by copyrighted interfaces, the -original copyright holder who places the Program under this License -may add an explicit geographical distribution limitation excluding -those countries, so that distribution is permitted only in or among -countries not thus excluded. In such case, this License incorporates -the limitation as if written in the body of this License. - - 9. The Free Software Foundation may publish revised and/or new versions -of the General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - -Each version is given a distinguishing version number. If the Program -specifies a version number of this License which applies to it and "any -later version", you have the option of following the terms and conditions -either of that version or of any later version published by the Free -Software Foundation. If the Program does not specify a version number of -this License, you may choose any version ever published by the Free Software -Foundation. - - 10. If you wish to incorporate parts of the Program into other free -programs whose distribution conditions are different, write to the author -to ask for permission. For software which is copyrighted by the Free -Software Foundation, write to the Free Software Foundation; we sometimes -make exceptions for this. Our decision will be guided by the two goals -of preserving the free status of all derivatives of our free software and -of promoting the sharing and reuse of software generally. - - NO WARRANTY - - 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY -FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN -OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES -PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED -OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS -TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE -PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, -REPAIR OR CORRECTION. - - 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR -REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, -INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING -OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED -TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY -YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER -PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE -POSSIBILITY OF SUCH DAMAGES. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -convey the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - {description} - Copyright (C) {year} {fullname} - - This program is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along - with this program; if not, write to the Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -Also add information on how to contact you by electronic and paper mail. - -If the program is interactive, make it output a short notice like this -when it starts in an interactive mode: - - Gnomovision version 69, Copyright (C) year name of author - Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, the commands you use may -be called something other than `show w' and `show c'; they could even be -mouse-clicks or menu items--whatever suits your program. - -You should also get your employer (if you work as a programmer) or your -school, if any, to sign a "copyright disclaimer" for the program, if -necessary. Here is a sample; alter the names: - - Yoyodyne, Inc., hereby disclaims all copyright interest in the program - `Gnomovision' (which makes passes at compilers) written by James Hacker. - - {signature of Ty Coon}, 1 April 1989 - Ty Coon, President of Vice - -This General Public License does not permit incorporating your program into -proprietary programs. If your program is a subroutine library, you may -consider it more useful to permit linking proprietary applications with the -library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. - +MIT License + +Copyright (c) 2021 - 2023 Nilanchala Panigrahy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE diff --git a/README.md b/README.md index ab3913a..e72a29c 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,44 @@ # Bloggy -Introducing Bloggy! The Open-Source Blogging Platform is Built on Python and Django framework that powers stacktips.com blog. +Introducing Bloggy! The Open-Source Blogging Platform for developers. It is Built on Python and Django framework and +powers [stacktips.com](https://stacktips.com) blog. + +If you are a new contributor to this project, have a look out for issues that have +the [Hacktoberfest](https://github.com/StackTipsLab/Bloggy/issues?q=is%3Aissue+is%3Aopen+label%3Ahacktoberfest) label. ## Key Features -Along with tons of features aimed at enhancing the development and blogging experience. + +Along with tons of features aimed at enhancing the development and blogging experience. + * **Signup and Login**: Seamlessly create your account and log in to access exclusive content. * **Magic Link Sign-In**: Forget passwords! We've streamlined the login process with magic link sign-in. -* **Create and Publish**: Share your knowledge with the world by creating and publishing articles, courses, and quizzes effortlessly. -* **Customized Admin Dashboard**: Manage your content efficiently with a user-friendly admin dashboard designed with you in mind. +* **Create and Publish**: Share your knowledge with the world by creating and publishing articles, courses, and quizzes + effortlessly. +* **Customized Admin Dashboard**: Manage your content efficiently with a user-friendly admin dashboard designed with you + in mind. * **Sitemaps**: Enhance discoverability with built-in sitemaps that improve search engine ranking. -* **Webmaster Notifications**: Get noticed! StackTips automates Google and Bing webmaster notifications to ensure your content reaches a wider audience. +* **Webmaster Notifications**: Get noticed! StackTips automates Google and Bing webmaster notifications to ensure your + content reaches a wider audience. ![](https://res.cloudinary.com/practicaldev/image/fetch/s--ahvrJ22X--/c_imagga_scale,f_auto,fl_progressive,h_420,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/djjung54zz5kanrsk9v2.jpeg) - ## Get Involved! -Are you a developer looking to enhance your skills, share your knowledge, or simply be curious about the inner workings of a developer-centric blog platform? Now's your chance! -Want to contribute right away? +Are you a developer looking to enhance your skills, share your knowledge, or simply be curious about the inner workings +of a developer-centric blog platform? Now's your chance! + +Want to contribute right away? Check out the issues section. https://github.com/StackTipsLab/bloggy/issues ## Installation Guide + Checkout the code from our git repository git clone git@github.com:StackTipsLab/bloggy.git Create a virtual env - + ```shell python3 -m venv .venv source .venv/bin/activate @@ -39,12 +50,13 @@ Install python dependencies pip3 install -r requirements.txt ``` -Rename the `.env.example` file to `.env` and provide all the configuration details including Database, and Email Configurations. Bare minimum, you will need these properties to get started. +Rename the `.env.example` file to `.env` and provide all the configuration details including Database, and Email +Configurations. Bare minimum, you will need these properties to get started. + ```properties SECRET_KEY= DEBUG=True ALLOWED_HOSTS=127.0.0.1,localhost -DEVELOPMENT_MODE=True # Your database configuration details DB_NAME=bloggy @@ -67,7 +79,6 @@ Create superuser python3 manage.py createsuperuser ``` - Collect static files before publishing or development. ```shell @@ -80,7 +91,6 @@ Start the application python3 manage.py runserver ``` - ## Bloggy Frontend Module For building frontend code, you will need the following node version. @@ -99,9 +109,21 @@ Once you have the above node version installed, install node dependencies using npm install ``` -Now, you can build +Now, you can build ```shell npm run start npm run build # to generate a production build ``` + +## Importing Demo Content + +We currently supports importing the categories from CSV file. This can be done using the `runseed` command. All you need +to do is to provide the base path where your `.csv` files are located. + +The sample CSV files are located in `bloggy/demo_content` directory. + +```shell +python3 manage.py runseed --dir=demo_content +``` + diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..1fc9e96 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,15 @@ +# Reporting Security Issues + +The StackTips community take security bugs in seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions. + +To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/StackTipsLab/bloggy/security/advisories/new) tab. + +We will review the issue and take the necessary steps in handling your report. During the issue investigation we may ask for additional information or guidance. + +## Third party security issues +Report security bugs in third-party modules to the person or team maintaining the module. + + +If you find security issues in node module, you can report them directly through the [npm contact form](https://www.npmjs.com/support) + +To report a vulnerability in Django, you [visit this link here](https://docs.djangoproject.com/en/dev/internals/security/#reporting-security-issues) \ No newline at end of file diff --git a/bloggy/.DS_Store b/bloggy/.DS_Store index a580bdf..8e1f43b 100644 Binary files a/bloggy/.DS_Store and b/bloggy/.DS_Store differ diff --git a/bloggy/admin/__init__.py b/bloggy/admin/__init__.py index 3fbbd2b..118d098 100644 --- a/bloggy/admin/__init__.py +++ b/bloggy/admin/__init__.py @@ -1,7 +1,9 @@ -from .misc_admin import * -from .article_admin import * +from .admin import * +from .post_admin import * from .category_admin import * from .course_admin import * -from .quiz_admin import * -from .user_admin import * +from .misc_admin import * from .subscriber_admin import * +from .user_admin import * +from .page_admin import * +from .quiz_admin import * diff --git a/bloggy/admin/admin.py b/bloggy/admin/admin.py new file mode 100644 index 0000000..5a81ae8 --- /dev/null +++ b/bloggy/admin/admin.py @@ -0,0 +1,62 @@ +from django import forms +from django_summernote.admin import SummernoteModelAdmin + +seo_fieldsets = ('SEO Settings', { + 'fields': ('meta_title', 'meta_description', 'meta_keywords'), +}) + +publication_fieldsets = ('Publication options', { + 'fields': ('publish_status', 'published_date',), +}) + + +def publish(model_admin, request, queryset): + queryset.update(publish_status='LIVE') + + +publish.short_description = "Publish" + + +def unpublish(model_admin, request, queryset): + queryset.update(publish_status='DRAFT') + + +unpublish.short_description = "Unpublish" + + +class BloggyAdminForm(forms.ModelForm): + excerpt = forms.CharField(widget=forms.Textarea(attrs={'rows': 3, 'cols': 105})) + title = forms.CharField(widget=forms.TextInput(attrs={'size': 105})) + meta_title = forms.CharField(required=False, widget=forms.Textarea(attrs={'rows': 1, 'cols': 100})) + meta_description = forms.CharField(required=False, widget=forms.Textarea(attrs={'rows': 2, 'cols': 100})) + meta_keywords = forms.CharField(required=False, widget=forms.Textarea(attrs={'rows': 2, 'cols': 100})) + + class Meta: + abstract = True + + +class BloggyAdmin(SummernoteModelAdmin): + actions = [publish, unpublish] + list_per_page = 50 + + def published_date_display(self, obj): + if obj.published_date: + return obj.published_date.strftime("%b %d, %Y") + return "-" + + published_date_display.short_description = "Date Published" + + def author_display(self, obj): + return '' + obj.author.name + + author_display.short_description = "Author" + + def is_published(self, queryset): + if queryset.publish_status == 'LIVE': + return True + return False + + is_published.boolean = True + + class Meta: + abstract = True diff --git a/bloggy/admin/article_admin.py b/bloggy/admin/article_admin.py deleted file mode 100644 index 07bd013..0000000 --- a/bloggy/admin/article_admin.py +++ /dev/null @@ -1,174 +0,0 @@ -from django import forms -from django.contrib import admin -from django.contrib.auth import get_user_model -from django.db.models import TextField -from django.forms import Textarea, BaseInlineFormSet -from django.urls import reverse -from django.utils import timezone -from django.utils.html import format_html -from django.utils.safestring import mark_safe -from django_summernote.admin import SummernoteModelAdmin - -from bloggy.admin.misc_admin import publish, unpublish -from bloggy.models import Article, PostMeta - - -class ArticleForm(forms.ModelForm): - excerpt = forms.CharField(widget=forms.Textarea(attrs={'rows': 2, 'cols': 100})) - title = forms.CharField(widget=forms.TextInput(attrs={'size': 105})) - model = Article - keywords = forms.CharField(widget=forms.Textarea(attrs={'rows': 2, 'cols': 100})) - - -class PostMetaInlineFormSet(BaseInlineFormSet): - def __init__(self, *args, **kwargs): - self.initial = [ - {'meta_key': 'template_type', 'meta_value': "standard"}, - {'meta_key': 'seo_title', 'meta_value': ""}, - {'meta_key': 'seo_description', 'meta_value': ""}, - {'meta_key': 'seo_keywords', 'meta_value': ""}, - ] - super(PostMetaInlineFormSet, self).__init__(*args, **kwargs) - - -class PostMetaInline(admin.TabularInline): - model = PostMeta - extra = 0 - formfield_overrides = { - TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 105})}, - } - formset = PostMetaInlineFormSet - - -@admin.register(Article) -class ArticleAdmin(SummernoteModelAdmin): - def get_form(self, request, obj=None, **kwargs): - form = super(ArticleAdmin, self).get_form(request, obj, **kwargs) - form.base_fields["author"].queryset = get_user_model().objects.filter(is_staff=True) - return form - - prepopulated_fields = {"slug": ("title",)} - list_display = ( - 'id', - 'title', - 'category_display', - 'post_type', - 'template_type', - 'is_published', - 'author_link', - 'display_order', - 'published_date_display', - 'updated_date_display' - ) - list_filter = ( - 'publish_status', - ('post_type', admin.ChoicesFieldListFilter), - ('template_type', admin.ChoicesFieldListFilter), - ("category", admin.RelatedOnlyFieldListFilter), - ("author", admin.RelatedOnlyFieldListFilter), - ("course", admin.RelatedOnlyFieldListFilter), - 'is_featured', - ("video_id", admin.BooleanFieldListFilter), - ) - - fieldsets = ( - (None, { - 'fields': ('title', 'excerpt', 'keywords', 'slug', 'content', 'thumbnail', 'author', 'category',) - }), - ('Publication options', { - 'fields': ('publish_status', 'published_date',), - }), - ('Advanced options', { - 'fields': ('post_type', 'template_type', 'course', 'duration', 'difficulty', 'video_id', 'is_featured', - 'display_order'), - }), - ) - - search_fields = ['title'] - summernote_fields = ('content',) - readonly_fields = ['updated_date', 'created_date'] - date_hierarchy = 'published_date' - form = ArticleForm - ordering = ('-created_date',) - list_display_links = ['title'] - list_per_page = 50 - actions = [publish, unpublish] - inlines = [PostMetaInline] - - def published_date_display(self, obj): - return format_html( - '{}'.format(obj.published_date.strftime("%m/%d/%Y") if obj.published_date else "-")) - - published_date_display.short_description = "Published on" - - def has_video(self, obj): - return self.videoId - - def updated_date_display(self, obj): - return format_html( - '{}'.format(obj.updated_date.strftime("%m/%d/%Y") if obj.published_date else "-")) - - updated_date_display.short_description = "Updated on" - - def category_display(self, obj): - tags = "
".join([ - "" + cat.title + "
" for cat in obj.category.all() - ]) - return format_html(tags) - - category_display.short_description = "Categories" - - def author_link(self, obj): - url = reverse("admin:bloggy_myuser_change", args=[obj.author.id]) - if obj.author.name: - link = '%s' % (url, obj.author.name) - else: - link = '%s' % (url, obj.author.username) - return mark_safe(link) - - author_link.short_description = 'Author' - - def view_on_site(self, obj): - url = reverse('article_single', kwargs={'slug': obj.slug}) - return url + "?context=preview" - - def save_model(self, request, obj, form, change): - if "publish_status" in form.changed_data and obj.publish_status == "LIVE" and not obj.published_date: - obj.published_date = timezone.now() - if not obj.pk: - obj.author = request.user - - super().save_model(request, obj, form, change) - - def live_category(self, queryset): - if queryset.publish_status == 'LIVE': - return True - return False - - def is_published(self, queryset): - if queryset.publish_status == 'LIVE': - return True - return False - - is_published.boolean = True - is_published.short_description = "Status" - - def has_excerpt(self, queryset): - if queryset.excerpt is None: - return False - return True - - has_excerpt.boolean = True - - -@admin.register(PostMeta) -class PostMetaAdmin(admin.ModelAdmin): - list_display = ['id', 'article_link', 'meta_key', 'meta_value'] - list_filter = ['meta_key'] - - def article_link(self, obj): - url = reverse("admin:bloggy_article_change", args=[obj.article.id]) - link = '%s' % (url, obj.article.title) - return mark_safe(link) - - article_link.short_description = 'Author' diff --git a/bloggy/admin/category_admin.py b/bloggy/admin/category_admin.py index 84c8745..b1af394 100644 --- a/bloggy/admin/category_admin.py +++ b/bloggy/admin/category_admin.py @@ -2,14 +2,18 @@ from django.contrib import admin from django.utils.html import format_html +from bloggy.admin import seo_fieldsets +from bloggy.admin.admin import publish, unpublish from bloggy.models import Category -from bloggy.admin.misc_admin import publish, unpublish class CategoryForm(forms.ModelForm): - description = forms.CharField(widget=forms.Textarea(attrs={'rows': 3, 'cols': 105})) + excerpt = forms.CharField(widget=forms.Textarea(attrs={'rows': 3, 'cols': 105})) title = forms.CharField(widget=forms.TextInput(attrs={'size': 105})) slug = forms.CharField(widget=forms.TextInput(attrs={'size': 105})) + meta_title = forms.CharField(required=False, widget=forms.Textarea(attrs={'rows': 1, 'cols': 100})) + meta_description = forms.CharField(required=False, widget=forms.Textarea(attrs={'rows': 2, 'cols': 100})) + meta_keywords = forms.CharField(required=False, widget=forms.Textarea(attrs={'rows': 2, 'cols': 100})) model = Category @@ -18,16 +22,24 @@ class CategoryAdmin(admin.ModelAdmin): list_display = ( 'title', 'id', - 'is_published', - 'logo_tag', + 'is_category_published', + 'thumbnail_tag', 'article_count', 'slug', 'display_updated_date', 'display_color', ) + + fieldsets = ( + (None, { + 'fields': ('title', 'slug', 'excerpt', 'thumbnail', 'color', 'publish_status') + }), + seo_fieldsets + ) + show_change_link = True form = CategoryForm - readonly_fields = ['logo_tag', 'article_count'] + readonly_fields = ['thumbnail_tag', 'article_count'] prepopulated_fields = {"slug": ("title",)} list_per_page = 50 ordering = ('-article_count',) @@ -35,12 +47,12 @@ class CategoryAdmin(admin.ModelAdmin): search_fields = ['title', 'slug'] actions = [publish, unpublish] - def is_published(self, queryset): + def is_category_published(self, queryset): if queryset.publish_status == 'LIVE': return True return False - is_published.boolean = True + is_category_published.boolean = True def display_updated_date(self, obj): return obj.updated_date.strftime("%m/%d/%Y") if obj.updated_date else "-" @@ -48,6 +60,6 @@ def display_updated_date(self, obj): display_updated_date.short_description = "Published on" def display_color(self, obj): - return format_html('
'.format(obj.color)) + return format_html(f'
') display_color.short_description = "Color" diff --git a/bloggy/admin/comment_admin.py b/bloggy/admin/comment_admin.py index 016213a..1711552 100644 --- a/bloggy/admin/comment_admin.py +++ b/bloggy/admin/comment_admin.py @@ -1,6 +1,6 @@ +from django.contrib import admin from django_summernote.admin import SummernoteModelAdmin -from django.contrib import admin from bloggy.models.comment import Comment @@ -21,4 +21,4 @@ class CommentAdmin(SummernoteModelAdmin): 'active') list_filter = ('active', 'comment_date') search_fields = ('user', 'user', 'comment_content') - actions = ['approve_comments'] \ No newline at end of file + actions = ['approve_comments'] diff --git a/bloggy/admin/course_admin.py b/bloggy/admin/course_admin.py index 2e6bd9f..138c5b0 100644 --- a/bloggy/admin/course_admin.py +++ b/bloggy/admin/course_admin.py @@ -1,46 +1,49 @@ -from django import forms from django.contrib import admin -from django_summernote.admin import SummernoteModelAdmin +from bloggy.admin import BloggyAdmin, BloggyAdminForm, seo_fieldsets, publication_fieldsets from bloggy.models.course import Course -from bloggy.admin.misc_admin import publish, unpublish -class CourseForm(forms.ModelForm): - excerpt = forms.CharField( - widget=forms.Textarea(attrs={'rows': 3, 'cols': 105})) - title = forms.CharField(widget=forms.TextInput(attrs={'size': 105})) +class CourseForm(BloggyAdminForm): model = Course @admin.register(Course) -class CourseAdmin(SummernoteModelAdmin): - prepopulated_fields = {"slug": ("title",)} - list_display = ('id', 'title', 'is_published', 'thumbnail_tag', - 'display_order', 'author_display', 'published_date_display', 'display_order') - list_filter = ('difficulty', - ("category", admin.RelatedOnlyFieldListFilter), - ) +class CourseAdmin(BloggyAdmin): + prepopulated_fields = { + "slug": ("title",) + } + list_display = ( + 'id', + 'title', + 'is_published', + 'thumbnail_tag', + 'display_order', + 'author_display', + 'published_date_display', + 'display_order') + + list_filter = ( + 'difficulty', + ("category", admin.RelatedOnlyFieldListFilter), + ) + + fieldsets = ((None, {'fields': ( + 'title', + 'excerpt', + 'slug', + 'description', + 'display_order', + 'thumbnail', + 'category', + 'difficulty', + 'is_featured') + }), publication_fieldsets, seo_fieldsets) + summernote_fields = ('description',) readonly_fields = ['thumbnail_tag'] ordering = ('-display_order',) list_display_links = ['title'] - - def published_date_display(self, obj): - return obj.published_date.strftime("%b %d, %Y") - - published_date_display.short_description = "Date Published" - - def author_display(self, obj): - return '' + obj.author.name - - author_display.short_description = "Author" - form = CourseForm - actions = [publish, unpublish] - def is_published(self, queryset): - if queryset.publish_status == 'LIVE': - return True - return False - is_published.boolean = True + diff --git a/bloggy/admin/misc_admin.py b/bloggy/admin/misc_admin.py index 9a0b1bf..88f58d7 100644 --- a/bloggy/admin/misc_admin.py +++ b/bloggy/admin/misc_admin.py @@ -1,21 +1,20 @@ from django.contrib import admin + import bloggy.models.option +from bloggy import settings +from bloggy.models import RedirectRule -admin.site.site_header = "STACKTIPS" -admin.site.site_title = "StackTips" +admin.site.site_header = settings.SITE_TITLE.upper() +admin.site.site_title = settings.SITE_TITLE admin.site.index_title = "Dashboard" admin.site.register(bloggy.models.option.Option) -def publish(modelAdmin, request, queryset): - queryset.update(publish_status='LIVE') - - -publish.short_description = "Publish" - - -def unpublish(modelAdmin, request, queryset): - queryset.update(publish_status='DRAFT') - - -unpublish.short_description = "Unpublish" +@admin.register(RedirectRule) +class RedirectRuleAdmin(admin.ModelAdmin): + list_display = ( + 'source', + 'destination', + 'status_code', + 'note', + ) diff --git a/bloggy/admin/page_admin.py b/bloggy/admin/page_admin.py new file mode 100644 index 0000000..63ad13d --- /dev/null +++ b/bloggy/admin/page_admin.py @@ -0,0 +1,36 @@ +from django.contrib import admin + +from bloggy.admin import BloggyAdminForm, BloggyAdmin, publication_fieldsets, seo_fieldsets +from bloggy.models.page import Page + + +class PageForm(BloggyAdminForm): + model = Page + + +@admin.register(Page) +class PageAdmin(BloggyAdmin): + prepopulated_fields = {"url": ("title",)} + list_display = ( + 'id', + 'title', + 'url', + 'excerpt', + 'publish_status', + ) + + fieldsets = ( + (None, { + 'fields': ('title', 'excerpt', 'url', 'content',) + }), publication_fieldsets, seo_fieldsets) + + search_fields = ['title'] + summernote_fields = ('content',) + readonly_fields = ['updated_date', 'created_date'] + date_hierarchy = 'published_date' + form = PageForm + ordering = ('-created_date',) + list_display_links = ['title'] + + def get_form(self, request, obj=None, change=False, **kwargs): + return super().get_form(request, obj, change, **kwargs) diff --git a/bloggy/admin/post_admin.py b/bloggy/admin/post_admin.py new file mode 100644 index 0000000..ac04af1 --- /dev/null +++ b/bloggy/admin/post_admin.py @@ -0,0 +1,117 @@ +from django.contrib import admin +from django.contrib.auth import get_user_model +from django.urls import reverse +from django.utils import timezone +from django.utils.html import format_html +from django.utils.safestring import mark_safe + +from bloggy.admin import BloggyAdmin, BloggyAdminForm, publication_fieldsets, seo_fieldsets +from bloggy.models import Post + + +class PostForm(BloggyAdminForm): + model = Post + + +@admin.register(Post) +class PostAdmin(BloggyAdmin): + prepopulated_fields = {"slug": ("title",)} + search_fields = ['title'] + summernote_fields = ('content',) + readonly_fields = ['updated_date', 'created_date'] + date_hierarchy = 'published_date' + form = PostForm + ordering = ('-created_date',) + list_display_links = ['title'] + list_display = ( + 'id', + 'title', + 'category_display', + 'post_type', + 'template_type', + 'is_published', + 'author_link', + 'display_order', + 'published_date_display', + 'updated_date_display' + ) + list_filter = ( + 'publish_status', + ('post_type', admin.ChoicesFieldListFilter), + ('template_type', admin.ChoicesFieldListFilter), + ("category", admin.RelatedOnlyFieldListFilter), + ("author", admin.RelatedOnlyFieldListFilter), + ("course", admin.RelatedOnlyFieldListFilter), + 'is_featured', + ("video_id", admin.BooleanFieldListFilter), + ) + + fieldsets = ( + (None, { + 'fields': ('title', 'excerpt', 'slug', 'content', 'thumbnail', 'author', 'category',) + }), + publication_fieldsets, + ('Advanced options', { + 'fields': ('post_type', 'template_type', 'course', 'difficulty', 'video_id', 'github_link', 'is_featured', + 'display_order'), + }), + seo_fieldsets) + + def get_changeform_initial_data(self, request): + return { + 'meta_title': '{title}', + 'meta_description': '{excerpt}' + } + + def get_form(self, request, obj=None, change=False, **kwargs): + form = super().get_form(request, obj, change, **kwargs) + form.base_fields["author"].queryset = get_user_model().objects.filter(is_staff=True) + return form + + def updated_date_display(self, obj): + return format_html( + f'{obj.updated_date.strftime("%m/%d/%Y") if obj.published_date else "-"}') + + updated_date_display.short_description = "Updated on" + + def category_display(self, obj): + tags = "
".join([ + "" + cat.title + "
" for cat in obj.category.all() + ]) + return format_html(tags) + + category_display.short_description = "Categories" + + def author_link(self, obj): + url = reverse("admin:bloggy_user_change", args=[obj.author.id]) + if obj.author.name: + link = f'{obj.author.name}' + else: + link = f'{obj.author.username}' + return mark_safe(link) + + author_link.short_description = 'Author' + + def view_on_site(self, obj): + url = reverse('post_single', kwargs={'slug': obj.slug}) + return url + "?context=preview" + + def save_model(self, request, obj, form, change): + if "publish_status" in form.changed_data and obj.publish_status == "LIVE" and not obj.published_date: + obj.published_date = timezone.now() + if not obj.pk: + obj.author = request.user + + super().save_model(request, obj, form, change) + + def live_category(self, queryset): + if queryset.publish_status == 'LIVE': + return True + return False + + def has_excerpt(self, queryset): + if queryset.excerpt is None: + return False + return True + + has_excerpt.boolean = True diff --git a/bloggy/admin/quiz_admin.py b/bloggy/admin/quiz_admin.py index 236686f..17def79 100644 --- a/bloggy/admin/quiz_admin.py +++ b/bloggy/admin/quiz_admin.py @@ -1,7 +1,54 @@ from django import forms from django.contrib import admin -from bloggy.models import Article -from bloggy.models.quizzes import QuizAnswer, QuizQuestion, UserQuizScore +from django.utils.html import format_html + +from bloggy.admin import BloggyAdminForm, BloggyAdmin, publication_fieldsets, seo_fieldsets +from bloggy.models.quizzes import QuizAnswer, QuizQuestion, Quiz, UserQuizScore + + +class QuizForm(BloggyAdminForm): + model = Quiz + + +@admin.register(Quiz) +class QuizAdmin(BloggyAdmin): + prepopulated_fields = { + "slug": ("title",) + } + list_display = ( + 'id', + 'title', + 'category', + 'is_published', + 'display_order', + 'published_date_display') + + list_filter = ( + 'publish_status', + ("category", admin.RelatedOnlyFieldListFilter), + ) + + fieldsets = ( + (None, { + 'fields': ( + 'title', 'excerpt', 'slug', 'content', 'thumbnail', 'category', 'difficulty', 'is_featured', 'duration') + }), + publication_fieldsets, seo_fieldsets) + + summernote_fields = ('description',) + readonly_fields = ['thumbnail_tag'] + ordering = ('-display_order',) + list_display_links = ['title'] + form = QuizForm + + + def thumbnail_tag(self): + if self.thumbnail: + return format_html(f'') + return "" + + thumbnail_tag.short_description = 'Logo' + thumbnail_tag.allow_tags = True class QuizAnswerInLine(admin.TabularInline): @@ -18,12 +65,6 @@ class QuizQuestionForm(forms.ModelForm): @admin.register(QuizQuestion) class QuizQuestionAdmin(admin.ModelAdmin): - - def get_form(self, request, obj=None, change=False, **kwargs): - form = super().get_form(request, obj, **kwargs) - form.base_fields["article"].queryset = Article.objects.filter(post_type__exact="quiz") - return form - inlines = [QuizAnswerInLine] list_display = ('title', 'type') form = QuizQuestionForm @@ -43,7 +84,7 @@ class QuizQuestionAdmin(admin.ModelAdmin): def display_question_id(self, obj): return obj.question.id - display_question_id.short_description = "Question ID" + display_question_id.short_description = "Question id" def display_question_title(self, obj): return obj.question.title diff --git a/bloggy/admin/subscriber_admin.py b/bloggy/admin/subscriber_admin.py index 6d9ceb0..29d46e1 100644 --- a/bloggy/admin/subscriber_admin.py +++ b/bloggy/admin/subscriber_admin.py @@ -1,4 +1,3 @@ -from django.contrib import admin from django import forms from django.contrib import admin diff --git a/bloggy/admin/user_admin.py b/bloggy/admin/user_admin.py index b958ca5..e6763a1 100644 --- a/bloggy/admin/user_admin.py +++ b/bloggy/admin/user_admin.py @@ -1,19 +1,27 @@ -from django import forms from django.contrib import admin -from django.utils.html import format_html -from bloggy.models import MyUser from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.contrib.auth.forms import UserChangeForm, UserCreationForm +from django.utils.html import format_html +from bloggy.models import User -@admin.register(MyUser) + +@admin.register(User) class MyUserAdmin(BaseUserAdmin): form = UserChangeForm add_form = UserCreationForm list_display = ( - 'username', 'email_display', 'full_name_display', 'is_staff', 'is_active', 'date_joined', 'last_login', - 'profile_photo_tag', 'articles_count_display',) + 'username', + 'email_display', + 'full_name_display', + 'is_staff', + 'is_active', + 'date_joined', + 'last_login', + 'profile_photo_tag', + 'posts_count_display' + ) list_filter = ('is_staff', 'is_superuser', 'groups', 'is_active') search_fields = ('username',) ordering = ('-date_joined',) @@ -37,16 +45,16 @@ class MyUserAdmin(BaseUserAdmin): ) def email_display(self, queryset): - return format_html('{}'.format(queryset.email)) + return format_html(f'{queryset.email}') email_display.short_description = "Email" def full_name_display(self, queryset): - return format_html('{}'.format(queryset.name)) + return format_html(f'{queryset.name}') full_name_display.short_description = "Name" - def articles_count_display(self, queryset): - return queryset.articles.count() + def posts_count_display(self, queryset): + return queryset.posts.count() - articles_count_display.short_description = "Articles" + posts_count_display.short_description = "Articles" diff --git a/bloggy/apps.py b/bloggy/apps.py index 52eb721..1d36b76 100644 --- a/bloggy/apps.py +++ b/bloggy/apps.py @@ -5,4 +5,4 @@ class MyAppConfig(AppConfig): name = 'bloggy' def ready(self): - import bloggy.signals + pass diff --git a/bloggy/context_processors.py b/bloggy/context_processors.py index 226976f..33b6f51 100644 --- a/bloggy/context_processors.py +++ b/bloggy/context_processors.py @@ -1,3 +1,5 @@ +import json + from django.http import HttpRequest from bloggy import settings @@ -8,87 +10,30 @@ def seo_attrs(request: HttpRequest): Arguments: request {HttpRequest} -- request object """ - - seo = { - 'seo_site_name': settings.SITE_TITLE, - 'seo_title': settings.SITE_TAGLINE, - 'seo_description': 'Elevate your coding skills with StackTips. Learn through articles, courses, and quizzes on StackTips', - 'seo_keywords': 'stacktips, stack tips, python course, java tutorials, spring tutorials, spring boot tutorials, maven course for bignners, practice quiz, aws practice quiz, aws practice test', - 'og_image': '', - 'seo_image': settings.SITE_LOGO - } - - if request.path == "/quizzes": - seo["seo_title"] = "Interactive Quizzes" - seo['seo_description'] = "Use bloggy to test your coding skills and gauge your learning progress in a fun way" - seo['seo_keywords'] = 'stacktips quizzes, stack tips bloggy, quizlzet, take a quiz, practice test, aws quiz, python test, advance python test, aws practitioner test, CLF-C01 AWS test, aws CLF-C01 quiz' - - elif request.path == "/courses": - seo["seo_title"] = "Courses" - seo['seo_description'] = "StackTips offers free Programming Courses to teach you the fundamentals of popular programming languages such as Java, Python, HTML, JavaScript and CSS" - seo['seo_keywords'] = 'stacktips courses, stack tips courses, free courses, learn python for free, python course, maven for beginner, maven courses, maven basics courrse' - - elif request.path == "/articles": - seo["seo_title"] = "Tutorials" - seo['seo_description'] = "Learn from beginner basics to advanced tutorials on Java, Spring, Python, Android and Git." - seo['seo_keywords'] = 'stacktips tutorials, stack tips, free tutorials, java tutorials, spring tutorials, spring boot tutorials, django tutorials, python tutorials, git tutorials, maven tutorials, android tutorials ' - - elif request.path == "/topics": - seo["seo_title"] = "Categories" - seo['seo_description'] = "Browse from collection of tutorials and courses organized by categories to make learning easy and accessible." - seo['seo_keywords'] = "stacktips categories, stack tips topics, browse by topics, stacktips categories, tutorial categories, tutorial categories, all tutorials by category, android, django, python, django, spring, spring boot, git" - - elif request.path == "/cookie-policy": - seo["seo_title"] = "Cookie policy" - seo['seo_description'] = "Our Cookies Policy explains what Cookies are and how We use them." - - elif request.path == "/privacy": - seo["seo_title"] = "Privacy policy" - seo['seo_description'] = "This page explains our privacy policy" - - elif request.path == "/terms-of-service": - seo["seo_title"] = "Terms of service" - seo['seo_description'] = "This page outlines the legal terms of stacktips.com, its sub-domains, and all associated web and/or mobile apps." - - elif request.path == "/comment-policy": - seo["seo_title"] = "Comment policy" - seo['seo_description'] = "This page lists some of the simple ground rules for commenting on this site" - - elif request.path == "/contact": - seo["seo_title"] = "Get in touch" - seo['seo_description'] = "If you have any questions or have suggestions to improve the quality, let us know." - - elif request.path == "/about": - seo["seo_title"] = "About us" - seo['seo_description'] = "We aim to provide developer-friendly ways to learn programming. With articles, programming course, and quizzes, we aim to teach in the ways developers learn best." - - elif request.path == "/guestbook": - seo["seo_title"] = "Guestbook" - seo['seo_description'] = "You have a chance to be creative. Indulge me by leaving a greeting below." - - elif request.path == "/newsletter": - seo["seo_title"] = "Newsletter" - seo['seo_description'] = "Get access to our fortnightly newsletter with articles, courses, and quizzes. You may unsubscribe at any time using the link in our newsletter." - - elif request.path == "/login": - seo["seo_title"] = "Login" - seo['seo_description'] = "To keep connected with us, please log in with your email address and account password." - seo['seo_keywords'] = "stacktips account, login to stacktips, join stacktips, your stack tips account, join for free, login, sign in, login stack tips, signin to stacktips.com, login to stacktips" - - elif request.path == "/register": - seo["seo_title"] = "Create your free account!" - seo['seo_description'] = "Register to get exclusive access to articles, live-demos, or courses, We aim to teach in the ways developers learn best." - seo['seo_keywords'] = "stacktips account, create free stacktips account, signup new account, free account, stack tips account free, join stacktips, stacktips free account, register free account, new account stack tips" - - elif request.path == "/authors": - seo["seo_title"] = "Our Authors @ StackTips" - seo['seo_description'] = "List of the authors on StackTips website." + with open('seo_settings.json', 'r', encoding='utf-8') as seo_file: + seo_settings = json.load(seo_file) + # Default SEO attributes + seo = { + 'site_name': settings.SITE_TITLE, + 'meta_title': settings.SITE_TAGLINE, + 'meta_description': settings.SITE_DESCRIPTION, + 'meta_image': settings.SITE_LOGO + } + + # Get SEO attributes based on the request path + request_path = request.path + if request_path in seo_settings: + seo.update(seo_settings[request_path]) return seo def app_settings(request: HttpRequest): + """ + returns app settings + """ return { - "DEVELOPMENT_MODE": settings.DEVELOPMENT_MODE, + "LOAD_GOOGLE_TAG_MANAGER": settings.LOAD_GOOGLE_TAG_MANAGER, + "LOAD_GOOGLE_ADS": settings.LOAD_GOOGLE_ADS, "ASSETS_DOMAIN": settings.ASSETS_DOMAIN } diff --git a/bloggy/forms/comment_form.py b/bloggy/forms/comment_form.py index 5dfc355..2421e06 100644 --- a/bloggy/forms/comment_form.py +++ b/bloggy/forms/comment_form.py @@ -1,8 +1,10 @@ -from bloggy.models.comment import Comment from django import forms +from bloggy.models.comment import Comment + class CommentForm(forms.ModelForm): + class Meta: model = Comment - fields = ('use_name', 'user_email', 'comment_content') \ No newline at end of file + fields = ('use_name', 'user_email', 'comment_content') diff --git a/bloggy/forms/edit_profile_form.py b/bloggy/forms/edit_profile_form.py index dd79cbd..68c789b 100644 --- a/bloggy/forms/edit_profile_form.py +++ b/bloggy/forms/edit_profile_form.py @@ -1,6 +1,7 @@ from django import forms from django.forms import ClearableFileInput -from bloggy.models import MyUser + +from bloggy.models import User class NonClearableFileInput(ClearableFileInput): @@ -9,7 +10,7 @@ class NonClearableFileInput(ClearableFileInput): class EditProfileForm(forms.ModelForm): class Meta: - model = MyUser + model = User fields = [ 'profile_photo', 'name', diff --git a/bloggy/forms/signup_form.py b/bloggy/forms/signup_form.py index 564cce6..139efb7 100644 --- a/bloggy/forms/signup_form.py +++ b/bloggy/forms/signup_form.py @@ -1,33 +1,46 @@ +import logging from django.contrib.auth.forms import UserCreationForm - -from bloggy.models import MyUser +from django.core.exceptions import ValidationError +from bloggy.models import User from django import forms +logger = logging.getLogger(__name__) + class SignUpForm(UserCreationForm): + honeypot = forms.CharField(required=False, widget=forms.HiddenInput) + class Meta: - model = MyUser + model = User fields = ('name', 'email', 'password1', 'password2') def save(self, commit=True): user = super().save(commit=False) # Call the parent class's save method # Generate the username based on the user's name (you can use your custom function here) user.username = self.generate_unique_username(self.cleaned_data['name']) - user.is_active = True + user.is_active = False user.is_staff = False if commit: user.save() return user - def generate_unique_username(self, name): + def clean_honeypot(self): + honeypot_value = self.cleaned_data.get('honeypot') + if honeypot_value: + logger.error("ERROR: Honeypot validation error!") + raise ValidationError("Oops! Looks like you're not a human!") + return honeypot_value + + @staticmethod + def generate_unique_username(name): # Convert the user's name to a lowercase username with underscores base_username = name.lower().replace(' ', '_') # Check if the base_username is unique, if not, append a number until it is username = base_username count = 1 - while MyUser.objects.filter(username=username).exists(): + while User.objects.filter(username=username).exists(): username = f"{base_username}{count}" count += 1 diff --git a/bloggy/forms/update_password_form.py b/bloggy/forms/update_password_form.py index 50021cb..2b1db31 100644 --- a/bloggy/forms/update_password_form.py +++ b/bloggy/forms/update_password_form.py @@ -1,11 +1,11 @@ from django import forms from django.forms import CharField, PasswordInput -from bloggy.models import MyUser +from bloggy.models import User class UpdatePasswordForm(forms.BaseForm): - model = MyUser + model = User error_css_class = 'has-error' error_messages = {'password_incorrect': "The old password is not correct. Try again."} diff --git a/bloggy/management/commands/.DS_Store b/bloggy/management/commands/.DS_Store new file mode 100644 index 0000000..a22a8b6 Binary files /dev/null and b/bloggy/management/commands/.DS_Store differ diff --git a/bloggy/management/commands/runseed.py b/bloggy/management/commands/runseed.py new file mode 100644 index 0000000..b8658bc --- /dev/null +++ b/bloggy/management/commands/runseed.py @@ -0,0 +1,32 @@ +from django.core.management import call_command +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + help = 'Importing demo contents' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def add_arguments(self, parser): + parser.add_argument('--dir', type=str, help="File path to import, e.g. ~/bloggy/demo_content") + + def handle(self, *args, **options): + file_path = options['dir'] + + commands = [ + ('seed_users', 'users.csv'), + ('seed_categories', 'categories.csv'), + ('seed_posts', 'posts.csv'), + ('seed_pages', 'pages.csv'), + ('seed_redirectrules', 'redirect_rules.csv'), + ('update_category_count', None), + ] + + for command, file in commands: + if file: + call_command(command, f'--file={file_path}/{file}') + else: + call_command(command) + + self.stdout.write(self.style.SUCCESS("Import Complete!")) diff --git a/bloggy/management/commands/seed_categories.py b/bloggy/management/commands/seed_categories.py new file mode 100644 index 0000000..b1e2e10 --- /dev/null +++ b/bloggy/management/commands/seed_categories.py @@ -0,0 +1,36 @@ +import csv + +from django.core.management.base import BaseCommand +from django.utils.text import slugify + +from bloggy.models import Category + + +class Command(BaseCommand): + help = 'Importing categories' + + def add_arguments(self, parser): + parser.add_argument('-f', '--file', type=str, + help="File path to import, e.g. ~/bloggy/demo_content/categories.csv") + + def handle(self, *args, **options): + file_path = options['file'] + + counter = 0 + with open(file_path, encoding="utf-8") as f: + reader = csv.reader(f) + print('Importing categories from file', file_path) + + for index, row in enumerate(reader): + if index > 0: + counter = counter + 1 + Category.objects.get_or_create( + title=row[0], + slug=slugify(row[1]), + description=row[2], + logo=row[3], + color=row[4], + publish_status=row[5] + ) + + self.stdout.write(self.style.SUCCESS(f"Imported %s categories" % counter)) diff --git a/bloggy/management/commands/seed_pages.py b/bloggy/management/commands/seed_pages.py new file mode 100644 index 0000000..936823b --- /dev/null +++ b/bloggy/management/commands/seed_pages.py @@ -0,0 +1,35 @@ +import csv + +from django.core.management.base import BaseCommand +from bloggy.models.page import Page + + +class Command(BaseCommand): + help = 'Importing pages' + + def add_arguments(self, parser): + parser.add_argument('-f', '--file', type=str, + help="File path to import, e.g. ~/bloggy/demo_content/pages.csv") + + def handle(self, *args, **options): + file_path = options['file'] + + counter = 0 + with open(file_path, encoding="utf-8") as f: + reader = csv.reader(f) + print('Importing pages from file', file_path) + for index, row in enumerate(reader): + if index > 0: + counter = counter + 1 + Page.objects.get_or_create( + title=row[0], + url=row[1], + publish_status=row[2], + meta_title=row[3], + meta_description=row[4], + meta_keywords=row[5], + excerpt=row[6], + content=row[7], + ) + + self.stdout.write(self.style.SUCCESS(f"Imported %s pages" % counter)) diff --git a/bloggy/management/commands/seed_posts.py b/bloggy/management/commands/seed_posts.py new file mode 100644 index 0000000..024ad2d --- /dev/null +++ b/bloggy/management/commands/seed_posts.py @@ -0,0 +1,51 @@ +import csv + +from django.core.management.base import BaseCommand +from django.utils import timezone +from django.utils.text import slugify + +from bloggy.models import Category, Post, User + + +class Command(BaseCommand): + help = 'Importing posts' + + def __init__(self, *args, **kwargs): + super().__init__() + + def add_arguments(self, parser): + parser.add_argument('-f', '--file', type=str, + help="File path to import, e.g. ~/bloggy/demo_content/posts.csv") + + def handle(self, *args, **options): + file_path = options['file'] + + counter = 0 + with open(file_path, encoding="utf-8") as f: + reader = csv.reader(f) + print('Importing articles from file', file_path) + for index, row in enumerate(reader): + if index > 0: + counter = counter + 1 + slug = slugify(row[0]) + article = Post.objects.get_or_create( + title=row[0], + slug=slug, + publish_status=row[1], + excerpt=row[2], + difficulty=row[3], + is_featured=row[4], + content=row[5], + video_id=row[8], + post_type=row[9], + template_type=row[10], + published_date=timezone.now(), + author=User.objects.get(id=row[7]), + ) + + categories = Category.objects.filter(slug__in=row[11].split(",")).all() + saved_article = Post.objects.get(slug=slug) + saved_article.category.set(categories) + saved_article.save() + + self.stdout.write(self.style.SUCCESS(f"Imported %s articles" % counter)) diff --git a/bloggy/management/commands/seed_redirectrules.py b/bloggy/management/commands/seed_redirectrules.py new file mode 100644 index 0000000..a37e6a7 --- /dev/null +++ b/bloggy/management/commands/seed_redirectrules.py @@ -0,0 +1,34 @@ +import csv + +from django.core.management.base import BaseCommand +from django.utils.text import slugify + +from bloggy.models import Category, RedirectRule + + +class Command(BaseCommand): + help = 'Importing redirect rules' + + def add_arguments(self, parser): + parser.add_argument('-f', '--file', type=str, + help="File path to import, e.g. ~/bloggy/demo_content/redirect_rules.csv") + + def handle(self, *args, **options): + file_path = options['file'] + + counter = 0 + with open(file_path, encoding="utf-8") as f: + reader = csv.reader(f) + print('Importing redirect rules', file_path) + + for index, row in enumerate(reader): + if index > 0: + counter = counter + 1 + RedirectRule.objects.get_or_create( + from_url=row[0], + to_url=row[1], + status_code=row[2], + note=row[3] + ) + + self.stdout.write(self.style.SUCCESS(f"%s redirect rules imported" % counter)) diff --git a/bloggy/management/commands/seed_users.py b/bloggy/management/commands/seed_users.py new file mode 100644 index 0000000..d3be481 --- /dev/null +++ b/bloggy/management/commands/seed_users.py @@ -0,0 +1,59 @@ +import csv + +from django.contrib.auth.hashers import make_password +from django.core.management.base import BaseCommand +from django.db import IntegrityError +from django.utils.text import slugify + +from bloggy.models import User + + +class Command(BaseCommand): + help = 'Importing users' + + def add_arguments(self, parser): + parser.add_argument('-f', '--file', type=str, + help="File path to import, e.g. ~/bloggy/demo_content/users.csv") + + def handle(self, *args, **options): + file_path = options['file'] + + counter = 0 + with open(file_path, encoding="utf-8") as f: + reader = csv.reader(f) + print('Importing categories from file', file_path) + + for index, row in enumerate(reader): + if index > 0: + counter = counter + 1 + + username = self.format_username(row[1]) + try: + new_user = User.objects.create_user( + name=row[3], + password=make_password(row[0]), + username=username, + email=row[2]) + + except IntegrityError: + print("user exist") + new_user = User.objects.get(email=row[2]) + finally: + print("user updating") + + new_user.is_staff = row[4] + new_user.is_active = row[5] + new_user.website = row[6] + new_user.twitter = row[7] + new_user.linkedin = row[8] + new_user.youtube = row[9] + new_user.github = row[10] + new_user.bio = row[11] + new_user.save() + print("User updated {}", new_user) + + self.stdout.write(self.style.SUCCESS(f"Imported %s users" % counter)) + + def format_username(self, username): + formatted_username = slugify(username.replace(".", "_").lower()) + return formatted_username.replace("-", "_") diff --git a/bloggy/management/commands/update_category_count.py b/bloggy/management/commands/update_category_count.py index 829e6ef..af3138e 100644 --- a/bloggy/management/commands/update_category_count.py +++ b/bloggy/management/commands/update_category_count.py @@ -1,25 +1,22 @@ from django.core.management.base import BaseCommand from django.db import transaction -from bloggy.models import Category, Article +from bloggy.models import Category, Post class Command(BaseCommand): help = 'Update Category Count' def __init__(self, *args, **kwargs): - super(Command, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def handle(self, *args, **options): categories = Category.objects.select_for_update().all() with transaction.atomic(): for category in categories: - article_count = Article.objects.all().filter(category=category).count() + article_count = Post.objects.all().filter(category=category).count() if article_count > 0: category.article_count = article_count category.save() - print("{\"" + category.title + "\": {\"article_count\":" + str(article_count) + "}}") - pass - - self.stdout.write(self.style.SUCCESS(f'Successfully updated')) + self.stdout.write(self.style.SUCCESS('Updated category count.')) diff --git a/bloggy/middleware/page_not_found.py b/bloggy/middleware/page_not_found.py deleted file mode 100644 index bd619d6..0000000 --- a/bloggy/middleware/page_not_found.py +++ /dev/null @@ -1,31 +0,0 @@ -import logging - -from django.http import HttpResponsePermanentRedirect -from django.utils.deprecation import MiddlewareMixin -from bloggy import settings - -logger = logging.getLogger(__name__) - -fallback_404_url_mapping_new_site = { - "/articles/how-to-configure-pojos-with-java-collection-attributes": "/articles/configure-pojos-with-java-collection-attributes-in-spring", - "/courses/maven": "/courses/maven-for-beginners" -} - - -class PageNotFoundMiddleware(MiddlewareMixin): - - def process_response(self, request, response): - request_path = request.path - - # Don't do anything for /api endpoints - if request_path.startswith("/api/"): - return response - - if response.status_code == 404: - logger.warning("ERROR 404:: {}".format(request_path)) - if request_path in fallback_404_url_mapping_new_site: - new_path = fallback_404_url_mapping_new_site[request_path] - logger.warning("ERROR 404 Fallback:: {} ==> {}".format(request_path, new_path)) - return HttpResponsePermanentRedirect(settings.SITE_URL + new_path) - - return response diff --git a/bloggy/middleware/redirect.py b/bloggy/middleware/redirect.py new file mode 100644 index 0000000..19d6044 --- /dev/null +++ b/bloggy/middleware/redirect.py @@ -0,0 +1,30 @@ +import logging + +from django.http import HttpResponsePermanentRedirect +from django.utils.deprecation import MiddlewareMixin + +from bloggy import settings +from bloggy.models import RedirectRule + +logger = logging.getLogger(__name__) + + +class RedirectMiddleware(MiddlewareMixin): + + def process_response(self, request, response): + request_path = request.path + + # Don't do anything for /api endpoints + if request_path.startswith("/api/"): + return response + + if response.status_code == 404: + logger.warning("ERROR 404:: %s", request_path) + redirect_rule = RedirectRule.objects.filter(source__exact=request_path).first() + + if redirect_rule: + logger.warning("Explicit redirect rule found %s ==> %s", redirect_rule.source, + redirect_rule.destination) + return HttpResponsePermanentRedirect(settings.SITE_URL + redirect_rule.destination) + + return response diff --git a/bloggy/middleware/slash_middleware.py b/bloggy/middleware/slash_middleware.py index 2292af1..d208c4a 100644 --- a/bloggy/middleware/slash_middleware.py +++ b/bloggy/middleware/slash_middleware.py @@ -1,8 +1,9 @@ +from urllib.parse import quote + from django import http from django import urls from django.conf import settings from django.utils.deprecation import MiddlewareMixin -from urllib.parse import quote class AppendOrRemoveSlashMiddleware(MiddlewareMixin): @@ -57,7 +58,7 @@ def process_response(self, request, response): def generate_url(request, path): if request.get_host(): - new_url = "%s://%s%s" % (request.is_secure() and 'https' or 'http', request.get_host(), quote(path)) + new_url = f"{request.is_secure() and 'https' or 'http'}://{request.get_host()}{quote(path)}" else: new_url = quote(path) if request.GET: diff --git a/bloggy/middleware/wp_redirect.py b/bloggy/middleware/wp_redirect.py deleted file mode 100644 index d755f7f..0000000 --- a/bloggy/middleware/wp_redirect.py +++ /dev/null @@ -1,84 +0,0 @@ -# myproject.middleware.py -import logging -from django.http import HttpResponsePermanentRedirect -from django.urls import reverse -from django.utils.deprecation import MiddlewareMixin -from bloggy import settings - -logger = logging.getLogger(__name__) - -fallback_404_url_mapping_old_site = { - "/tutorials/spring/how-to-configure-pojos-with-java-collection-attributes": "/articles/configure-pojos-with-java-collection-attributes-in-spring", - "/android/download-and-display-image-in-android-gridview": "/articles/download-and-display-image-in-android-gridview", - "/article/how-to-format-your-post": "/how-to-format-your-post", - "/android/android-framelayout-example": "/articles/android-framelayout-example", - "/interview-questions": "/articles", - "/courses/maven": "/courses/maven-for-beginners", - "/how-to-format-your-post": "/contribute", - "/article/download-and-display-image-in-android-gridview": "/articles/download-and-display-image-in-android-gridview" -} - -paths_to_replace = [ - "/tutorials/java/spring/", - "/tutorials/android/", - "/tutorials/spring-boot/", - "/tutorials/spring/", - "/tutorials/laravel/", - "/tutorials/php/", - "/tutorials/git/", - "/tutorials/html5/", - "/tutorials/bootstrap/", - "/tutorials/java/", - "/tutorials/xamarin/", - "/tutorials/react/", - "/tutorials/react-native/", - "/tutorials/wordpress/", - "/tutorials/json/", - "/tutorials/design-patterns/", - "/tutorials/sencha-touch/", - "/tutorials/ibm-worklight/", - "/tutorials/phonegap/", - "/tutorials/ios/", - "/tutorials/seo/", - "/tutorials/se-concepts/", - "/tutorials/servlets/", - "/tutorials/struts/", - "/tutorials/c/", - "/tutorials/struts/", - "/tutorials/libgdx/", - "/how-to/", - "/blog/", -] - - -class OldUrlRedirectMiddleware(MiddlewareMixin): - - def process_response(self, request, response): - request_path = request.path - - # Don't do anything for /api endpoints - if request_path.startswith("/api/"): - return response - - if request_path.startswith("/tutorials/blackberry/") or request_path.startswith("/tutorials/j2me/"): - new_path = reverse("articles") - logger.warning("WPRedirect:: {}->{}".format(request_path, new_path)) - return HttpResponsePermanentRedirect(settings.SITE_URL + new_path) - - if request_path in fallback_404_url_mapping_old_site: - new_path = fallback_404_url_mapping_old_site[request_path] - logger.warning("WPRedirect:: {}->{}".format(request_path, new_path)) - return HttpResponsePermanentRedirect(settings.SITE_URL + new_path) - - if request_path.startswith("/courses/maven/"): - new_path = request_path.replace(request_path, "/courses/maven-for-beginners/") - logger.warning("WPRedirect:: {}->{}".format(request_path, new_path)) - return HttpResponsePermanentRedirect(settings.SITE_URL + new_path) - - for ptr in paths_to_replace: - if request_path.startswith(ptr): - new_path = request_path.replace(ptr, "/articles/") - logger.warning("WPRedirect:: {}->{}".format(request_path, new_path)) - return HttpResponsePermanentRedirect(settings.SITE_URL + new_path) - - return response diff --git a/bloggy/migrations/0001_initial.py b/bloggy/migrations/0001_initial.py index a6255ea..914d7e5 100644 --- a/bloggy/migrations/0001_initial.py +++ b/bloggy/migrations/0001_initial.py @@ -1,9 +1,10 @@ -# Generated by Django 4.2.6 on 2023-10-21 21:44 +# Generated by Django 4.2.6 on 2023-11-07 22:41 -import bloggy.models.article import bloggy.models.categories import bloggy.models.course import bloggy.models.mixin.ResizeImageMixin +import bloggy.models.post +import bloggy.models.quizzes import bloggy.models.user import colorfield.fields from django.conf import settings @@ -24,7 +25,7 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='MyUser', + name='User', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('password', models.CharField(max_length=128, verbose_name='password')), @@ -32,7 +33,7 @@ class Migration(migrations.Migration): ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), ('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, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), ('email', models.EmailField(max_length=254, unique=True, verbose_name='email address')), - ('name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('name', models.CharField(blank=True, max_length=150, verbose_name='full name')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), ('profile_photo', models.ImageField(blank=True, null=True, storage=bloggy.models.user.select_storage, upload_to=bloggy.models.user.upload_profile_image)), ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), @@ -48,57 +49,60 @@ class Migration(migrations.Migration): ], options={ 'verbose_name_plural': 'Users', + 'db_table': 'bloggy_user', 'ordering': ['username'], }, bases=(bloggy.models.mixin.ResizeImageMixin.ResizeImageMixin, models.Model), ), migrations.CreateModel( - name='Article', + name='Category', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('created_date', models.DateTimeField(auto_created=True, auto_now_add=True, null=True)), + ('meta_title', models.CharField(blank=True, help_text='Meta Title', max_length=120, null=True)), + ('meta_description', models.TextField(blank=True, help_text='Meta Description', null=True)), + ('meta_keywords', models.CharField(blank=True, help_text='Meta Keywords', max_length=300, null=True)), ('updated_date', models.DateTimeField(auto_now=True, null=True)), - ('display_order', models.IntegerField(default=0, help_text='Display order', null=True)), - ('title', models.CharField(help_text='Enter title', max_length=300)), - ('keywords', models.CharField(blank=True, help_text='Enter title', max_length=300, null=True)), - ('publish_status', models.CharField(blank=True, choices=[('DRAFT', 'DRAFT'), ('LIVE', 'LIVE'), ('DELETED', 'DELETED')], default='DRAFT', help_text='Select publish status', max_length=20, null=True, verbose_name='Publish status')), - ('published_date', models.DateTimeField(blank=True, null=True)), + ('title', models.CharField(help_text='Enter title', max_length=150)), + ('article_count', models.IntegerField(default=0)), ('slug', models.SlugField(help_text='Enter slug', max_length=150, unique=True)), - ('excerpt', models.CharField(blank=True, help_text='Enter excerpt', max_length=500, null=True)), - ('video_id', models.CharField(blank=True, help_text='YouTube Video ID', max_length=100, null=True)), - ('difficulty', models.CharField(blank=True, choices=[('beginner', 'Beginner'), ('intermediate', 'Intermediate'), ('advance', 'Advance')], default='beginner', help_text='Select difficulty', max_length=20, null=True, verbose_name='Difficulty level')), - ('post_type', models.CharField(blank=True, choices=[('article', 'Article'), ('quiz', 'Quiz'), ('lesson', 'Lesson')], default='article', help_text='Post type', max_length=20, null=True, verbose_name='Post type')), - ('template_type', models.CharField(blank=True, choices=[('standard', 'Standard'), ('cover', 'Cover'), ('naked', 'Naked'), ('full', 'Full')], default='standard', help_text='Template type', max_length=20, null=True, verbose_name='Template type')), - ('duration', models.IntegerField(default='1', help_text='Duration in minutes. For articles, it will be calculated automatically.')), - ('is_featured', models.BooleanField(default=False, help_text='Should this story be featured on site?')), - ('content', models.TextField(help_text='Post content', null=True)), - ('thumbnail', models.ImageField(blank=True, null=True, upload_to=bloggy.models.article.upload_thumbnail_image)), - ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='articles', to=settings.AUTH_USER_MODEL)), + ('description', models.TextField(blank=True, help_text='Enter description', max_length=1000, null=True)), + ('logo', models.ImageField(null=True, upload_to=bloggy.models.categories.upload_logo_image)), + ('color', colorfield.fields.ColorField(default='#1976D2', image_field=None, max_length=25, samples=None)), + ('publish_status', models.CharField(blank=True, choices=[('DRAFT', 'DRAFT'), ('LIVE', 'LIVE')], default='DRAFT', help_text='Select publish status', max_length=20, null=True, verbose_name='Publish status')), ], options={ - 'verbose_name': 'article', - 'verbose_name_plural': 'articles', + 'verbose_name': 'category', + 'verbose_name_plural': 'categories', 'ordering': ['title'], }, ), migrations.CreateModel( - name='Category', + name='Course', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('created_date', models.DateTimeField(auto_created=True, auto_now_add=True, null=True)), + ('meta_title', models.CharField(blank=True, help_text='Meta Title', max_length=120, null=True)), + ('meta_description', models.TextField(blank=True, help_text='Meta Description', null=True)), + ('meta_keywords', models.CharField(blank=True, help_text='Meta Keywords', max_length=300, null=True)), ('updated_date', models.DateTimeField(auto_now=True, null=True)), - ('title', models.CharField(help_text='Enter title', max_length=150)), - ('article_count', models.IntegerField(default=0)), + ('title', models.CharField(help_text='Enter title', max_length=300)), ('slug', models.SlugField(help_text='Enter slug', max_length=150, unique=True)), - ('description', models.TextField(blank=True, help_text='Enter description', max_length=1000, null=True)), - ('logo', models.ImageField(null=True, upload_to=bloggy.models.categories.upload_logo_image)), - ('color', colorfield.fields.ColorField(default='#1976D2', image_field=None, max_length=25, samples=None)), - ('publish_status', models.CharField(blank=True, choices=[('DRAFT', 'DRAFT'), ('LIVE', 'LIVE')], default='DRAFT', help_text='Select publish status', max_length=20, null=True, verbose_name='Publish status')), + ('excerpt', models.CharField(blank=True, help_text='Enter excerpt', max_length=500, null=True)), + ('display_order', models.IntegerField(default=0, help_text='Display order', null=True)), + ('published_date', models.DateTimeField(blank=True, null=True)), + ('publish_status', models.CharField(blank=True, choices=[('DRAFT', 'DRAFT'), ('LIVE', 'LIVE'), ('DELETED', 'DELETED')], default='DRAFT', help_text='Select publish status', max_length=20, null=True, verbose_name='Publish status')), + ('difficulty', models.CharField(blank=True, choices=[('beginner', 'Beginner'), ('intermediate', 'Intermediate'), ('advance', 'advance')], default='easy', help_text='Select difficulty', max_length=20, null=True, verbose_name='Difficulty level')), + ('is_featured', models.BooleanField(default=False, help_text='Should this story be featured on site?')), + ('description', models.TextField(help_text='Enter answer', null=True)), + ('thumbnail', models.ImageField(blank=True, null=True, upload_to=bloggy.models.course.upload_thumbnail_image)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='courses', to=settings.AUTH_USER_MODEL)), + ('category', models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='courses', to='bloggy.category')), ], options={ - 'verbose_name': 'category', - 'verbose_name_plural': 'categories', - 'ordering': ['title'], + 'verbose_name': 'course', + 'verbose_name_plural': 'courses', + 'ordering': ['-display_order'], }, ), migrations.CreateModel( @@ -130,24 +134,95 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( - name='PostViews', + name='Page', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('created_date', models.DateTimeField(auto_created=True, auto_now_add=True, null=True)), - ('ip_address', models.GenericIPAddressField(default='0.0.0.0')), + ('meta_title', models.CharField(blank=True, help_text='Meta Title', max_length=120, null=True)), + ('meta_description', models.TextField(blank=True, help_text='Meta Description', null=True)), + ('meta_keywords', models.CharField(blank=True, help_text='Meta Keywords', max_length=300, null=True)), ('updated_date', models.DateTimeField(auto_now=True, null=True)), + ('title', models.CharField(help_text='Enter title', max_length=300)), + ('excerpt', models.CharField(blank=True, help_text='Enter excerpt', max_length=500, null=True)), + ('url', models.CharField(help_text='Enter url', max_length=150, unique=True)), + ('content', models.TextField(help_text='Post content', null=True)), + ('published_date', models.DateTimeField(blank=True, null=True)), + ('publish_status', models.CharField(blank=True, choices=[('DRAFT', 'DRAFT'), ('LIVE', 'LIVE'), ('DELETED', 'DELETED')], default='DRAFT', help_text='Select publish status', max_length=20, null=True, verbose_name='Publish status')), ], + options={ + 'verbose_name': 'Page', + 'verbose_name_plural': 'Pages', + }, ), migrations.CreateModel( - name='Votes', + name='Quiz', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_date', models.DateTimeField(auto_created=True, auto_now_add=True, null=True)), + ('meta_title', models.CharField(blank=True, help_text='Meta Title', max_length=120, null=True)), + ('meta_description', models.TextField(blank=True, help_text='Meta Description', null=True)), + ('meta_keywords', models.CharField(blank=True, help_text='Meta Keywords', max_length=300, null=True)), + ('updated_date', models.DateTimeField(auto_now=True, null=True)), + ('title', models.CharField(help_text='Enter title', max_length=300)), + ('slug', models.SlugField(help_text='Enter slug', max_length=150, unique=True)), + ('excerpt', models.CharField(blank=True, help_text='Enter excerpt', max_length=500, null=True)), + ('display_order', models.IntegerField(default=0, help_text='Display order', null=True)), + ('published_date', models.DateTimeField(blank=True, null=True)), + ('publish_status', models.CharField(blank=True, choices=[('DRAFT', 'DRAFT'), ('LIVE', 'LIVE'), ('DELETED', 'DELETED')], default='DRAFT', help_text='Select publish status', max_length=20, null=True, verbose_name='Publish status')), + ('difficulty', models.CharField(blank=True, choices=[('beginner', 'Beginner'), ('intermediate', 'Intermediate'), ('advance', 'advance')], default='easy', help_text='Select difficulty', max_length=20, null=True, verbose_name='Difficulty level')), + ('is_featured', models.BooleanField(default=False, help_text='Should this story be featured on site?')), + ('content', models.TextField(help_text='Post content', null=True)), + ('thumbnail', models.ImageField(blank=True, null=True, upload_to=bloggy.models.quizzes.upload_thumbnail_image)), + ('duration', models.IntegerField(default='1', help_text='Duration in minutes. For articles, it will be calculated automatically.')), + ('category', models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='quizzes', to='bloggy.category')), + ], + options={ + 'verbose_name': 'Quiz', + 'verbose_name_plural': 'Quizzes', + 'ordering': ['title'], + }, + ), + migrations.CreateModel( + name='RedirectRule', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_date', models.DateTimeField(auto_created=True, auto_now_add=True, null=True)), + ('updated_date', models.DateTimeField(auto_now=True, null=True)), + ('source', models.CharField(help_text='Enter from url', max_length=300)), + ('destination', models.CharField(help_text='Enter to url', max_length=300)), + ('status_code', models.IntegerField(blank=True, choices=[(301, '301 Moved Permanently'), (307, '307 Temporary Redirect')], default='standard', help_text='Redirect type', null=True, verbose_name='Redirect type')), + ('is_regx', models.BooleanField(default=False, help_text='Is this regx?', null=True)), + ('note', models.CharField(blank=True, help_text='Enter note', max_length=500, null=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Vote', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('created_date', models.DateTimeField(auto_created=True, auto_now_add=True, null=True)), - ('post_id', models.IntegerField(help_text='Post id')), - ('post_type', models.CharField(choices=[('question', 'question'), ('article', 'article')], help_text='Select content type', max_length=20, verbose_name='Content type')), ('updated_date', models.DateTimeField(auto_now=True, null=True)), + ('post_id', models.IntegerField(help_text='Post id')), + ('post_type', models.CharField(choices=[['post', 'Post'], ['lesson', 'Lesson']], help_text='Select content type', max_length=20, verbose_name='Content type')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], + options={ + 'verbose_name': 'Vote', + 'verbose_name_plural': 'Votes', + }, + ), + migrations.CreateModel( + name='Bookmark', + fields=[ + ('vote_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bloggy.vote')), + ], + options={ + 'verbose_name': 'Bookmark', + 'verbose_name_plural': 'Bookmarks', + }, + bases=('bloggy.vote',), ), migrations.CreateModel( name='VerificationToken', @@ -172,7 +247,7 @@ class Migration(migrations.Migration): ('created_date', models.DateTimeField(auto_created=True, auto_now_add=True, null=True)), ('updated_date', models.DateTimeField(auto_now=True, null=True)), ('score', models.FloatField()), - ('quiz', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bloggy.article')), + ('quiz', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bloggy.quiz')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], options={ @@ -204,7 +279,7 @@ class Migration(migrations.Migration): ('description', models.TextField(blank=True, help_text='Enter description', null=True)), ('explanation', models.TextField(blank=True, help_text='Enter explanation', null=True)), ('type', models.CharField(blank=True, choices=[('binary', 'binary'), ('multiple', 'multiple')], default='binary', help_text='Select type of question', max_length=20, null=True, verbose_name='Question type')), - ('article', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='bloggy.article')), + ('quiz', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='bloggy.quiz')), ], ), migrations.CreateModel( @@ -217,42 +292,36 @@ class Migration(migrations.Migration): ], ), migrations.CreateModel( - name='PostMeta', - fields=[ - ('id', models.AutoField(primary_key=True, serialize=False)), - ('meta_key', models.SlugField(help_text='Enter key', max_length=150)), - ('meta_value', models.TextField(help_text='Enter value', null=True)), - ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bloggy.article')), - ], - options={ - 'verbose_name': 'Post metadata', - 'verbose_name_plural': 'Post metadata', - }, - ), - migrations.CreateModel( - name='Course', + name='Post', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('created_date', models.DateTimeField(auto_created=True, auto_now_add=True, null=True)), + ('meta_title', models.CharField(blank=True, help_text='Meta Title', max_length=120, null=True)), + ('meta_description', models.TextField(blank=True, help_text='Meta Description', null=True)), + ('meta_keywords', models.CharField(blank=True, help_text='Meta Keywords', max_length=300, null=True)), ('updated_date', models.DateTimeField(auto_now=True, null=True)), - ('display_order', models.IntegerField(default=0, help_text='Display order', null=True)), ('title', models.CharField(help_text='Enter title', max_length=300)), - ('keywords', models.CharField(blank=True, help_text='Enter title', max_length=300, null=True)), - ('publish_status', models.CharField(blank=True, choices=[('DRAFT', 'DRAFT'), ('LIVE', 'LIVE'), ('DELETED', 'DELETED')], default='DRAFT', help_text='Select publish status', max_length=20, null=True, verbose_name='Publish status')), - ('published_date', models.DateTimeField(blank=True, null=True)), ('slug', models.SlugField(help_text='Enter slug', max_length=150, unique=True)), ('excerpt', models.CharField(blank=True, help_text='Enter excerpt', max_length=500, null=True)), + ('display_order', models.IntegerField(default=0, help_text='Display order', null=True)), + ('published_date', models.DateTimeField(blank=True, null=True)), + ('publish_status', models.CharField(blank=True, choices=[('DRAFT', 'DRAFT'), ('LIVE', 'LIVE'), ('DELETED', 'DELETED')], default='DRAFT', help_text='Select publish status', max_length=20, null=True, verbose_name='Publish status')), ('difficulty', models.CharField(blank=True, choices=[('beginner', 'Beginner'), ('intermediate', 'Intermediate'), ('advance', 'advance')], default='easy', help_text='Select difficulty', max_length=20, null=True, verbose_name='Difficulty level')), - ('is_featured', models.BooleanField(default=False, help_text='Is featured')), - ('description', models.TextField(help_text='Enter answer', null=True)), - ('thumbnail', models.ImageField(blank=True, null=True, upload_to=bloggy.models.course.upload_thumbnail_image)), - ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='courses', to=settings.AUTH_USER_MODEL)), - ('category', models.ManyToManyField(blank=True, to='bloggy.category')), + ('is_featured', models.BooleanField(default=False, help_text='Should this story be featured on site?')), + ('video_id', models.CharField(blank=True, help_text='YouTube Video ID', max_length=100, null=True)), + ('github_link', models.CharField(blank=True, help_text='Github project link', max_length=200, null=True)), + ('post_type', models.CharField(blank=True, choices=[['post', 'Post'], ['lesson', 'Lesson']], default='article', help_text='Post type', max_length=20, null=True, verbose_name='Post type')), + ('template_type', models.CharField(blank=True, choices=[('standard', 'Standard'), ('cover', 'Cover'), ('naked', 'Naked'), ('full', 'Full')], default='standard', help_text='Template type', max_length=20, null=True, verbose_name='Template type')), + ('content', models.TextField(help_text='Post content', null=True)), + ('thumbnail', models.ImageField(blank=True, null=True, upload_to=bloggy.models.post.upload_thumbnail_image)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='posts', to=settings.AUTH_USER_MODEL)), + ('category', models.ManyToManyField(to='bloggy.category')), + ('course', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='bloggy.course')), ], options={ - 'verbose_name': 'course', - 'verbose_name_plural': 'courses', - 'ordering': ['-display_order'], + 'verbose_name': 'Post', + 'verbose_name_plural': 'Posts', + 'ordering': ['title'], }, ), migrations.CreateModel( @@ -267,7 +336,7 @@ class Migration(migrations.Migration): ('comment_date', models.DateTimeField(auto_now_add=True)), ('active', models.BooleanField(default=False)), ('parent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='reply_set', to='bloggy.comment')), - ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='bloggy.article')), + ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='bloggy.post')), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='comments', to=settings.AUTH_USER_MODEL)), ], options={ @@ -276,33 +345,16 @@ class Migration(migrations.Migration): 'ordering': ['comment_date'], }, ), - migrations.CreateModel( - name='Bookmarks', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_date', models.DateTimeField(auto_created=True, auto_now_add=True, null=True)), - ('post_id', models.IntegerField(help_text='Post id')), - ('post_type', models.CharField(choices=[('question', 'question'), ('article', 'article')], help_text='Select content type', max_length=20, verbose_name='Content type')), - ('updated_date', models.DateTimeField(auto_now=True, null=True)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - ), - migrations.AddField( - model_name='article', - name='category', - field=models.ManyToManyField(to='bloggy.category'), + migrations.AddIndex( + model_name='quiz', + index=models.Index(fields=['slug', 'publish_status'], name='bloggy_quiz_slug_d36048_idx'), ), - migrations.AddField( - model_name='article', - name='course', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='bloggy.course'), + migrations.AddIndex( + model_name='post', + index=models.Index(fields=['slug', 'publish_status', 'post_type', 'published_date'], name='bloggy_post_slug_560290_idx'), ), migrations.AddIndex( model_name='course', index=models.Index(fields=['slug', 'publish_status', 'published_date'], name='bloggy_cour_slug_7b04ce_idx'), ), - migrations.AddIndex( - model_name='article', - index=models.Index(fields=['slug', 'publish_status', 'post_type', 'published_date'], name='bloggy_arti_slug_0a0597_idx'), - ), ] diff --git a/bloggy/migrations/0002_rename_description_category_excerpt.py b/bloggy/migrations/0002_rename_description_category_excerpt.py new file mode 100644 index 0000000..01b634b --- /dev/null +++ b/bloggy/migrations/0002_rename_description_category_excerpt.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.6 on 2023-11-11 16:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('bloggy', '0001_initial'), + ] + + operations = [ + migrations.RenameField( + model_name='category', + old_name='description', + new_name='excerpt', + ), + ] diff --git a/bloggy/migrations/0003_rename_logo_category_thumbnail_page_thumbnail.py b/bloggy/migrations/0003_rename_logo_category_thumbnail_page_thumbnail.py new file mode 100644 index 0000000..b8d1741 --- /dev/null +++ b/bloggy/migrations/0003_rename_logo_category_thumbnail_page_thumbnail.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.6 on 2023-11-11 16:48 + +import bloggy.models.page +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bloggy', '0002_rename_description_category_excerpt'), + ] + + operations = [ + migrations.RenameField( + model_name='category', + old_name='logo', + new_name='thumbnail', + ), + migrations.AddField( + model_name='page', + name='thumbnail', + field=models.ImageField(blank=True, null=True, upload_to=bloggy.models.page.image_upload_path), + ), + ] diff --git a/bloggy/migrations/0004_remove_redirectrule_is_regx.py b/bloggy/migrations/0004_remove_redirectrule_is_regx.py new file mode 100644 index 0000000..aaa6c52 --- /dev/null +++ b/bloggy/migrations/0004_remove_redirectrule_is_regx.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.7 on 2023-11-17 16:54 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('bloggy', '0003_rename_logo_category_thumbnail_page_thumbnail'), + ] + + operations = [ + migrations.RemoveField( + model_name='redirectrule', + name='is_regx', + ), + ] diff --git a/bloggy/models/.DS_Store b/bloggy/models/.DS_Store new file mode 100644 index 0000000..38734ca Binary files /dev/null and b/bloggy/models/.DS_Store differ diff --git a/bloggy/models/__init__.py b/bloggy/models/__init__.py index ec30c72..53a3f97 100644 --- a/bloggy/models/__init__.py +++ b/bloggy/models/__init__.py @@ -1,14 +1,12 @@ from .media import Media from .categories import Category -from .user import MyUser -from .post_views import PostViews -from .post_bookmark import Bookmarks -from .post_vote import Votes +from .user import User +from .post_actions import Bookmark, Vote from .comment import Comment -from bloggy.models.course import Course from .option import Option -from .post_meta import PostMeta +from .post import Post +from .course import Course +from .quizzes import Quiz, QuizQuestion, QuizAnswer, UserQuizScore +from .redirect_rule import RedirectRule from .verification_token import VerificationToken -from .article import Article - diff --git a/bloggy/models/article.py b/bloggy/models/article.py deleted file mode 100644 index 2d899bd..0000000 --- a/bloggy/models/article.py +++ /dev/null @@ -1,147 +0,0 @@ -from django.contrib.contenttypes.fields import GenericRelation -from django.db import models -from django.db.models import TextField -from django.urls import reverse -from django.utils.html import format_html -from django.utils.text import slugify -from hitcount.models import HitCount - -import bloggy -from bloggy import settings -from bloggy.models import Bookmarks, Category, Votes -from bloggy.models.course import Course -from bloggy.models.post import Post -from bloggy.services.quizz_service import get_questions_json -from bloggy.utils.string_utils import StringUtils - - -def upload_thumbnail_image(self, post_id): - return 'uploads/articles/{post_id}' - - -DIFFICULTY_TYPE = [ - ('beginner', 'Beginner'), - ('intermediate', 'Intermediate'), - ('advance', 'Advance'), -] - -POST_TYPE = [ - ('article', 'Article'), - ('quiz', 'Quiz'), - ('lesson', 'Lesson'), -] - -TEMPLATE_TYPE = [ - ('standard', 'Standard'), - ('cover', 'Cover'), - ('naked', 'Naked'), - ('full', 'Full'), -] - - -class Article(Post): - excerpt = models.CharField(max_length=500, help_text='Enter excerpt', null=True, blank=True) - video_id = models.CharField(max_length=100, help_text='YouTube Video ID', null=True, blank=True) - - difficulty = models.CharField( - max_length=20, choices=DIFFICULTY_TYPE, - default='beginner', blank=True, null=True, - help_text="Select difficulty", - verbose_name="Difficulty level") - - post_type = models.CharField( - max_length=20, choices=POST_TYPE, - default='article', blank=True, null=True, - help_text="Post type", - verbose_name="Post type") - - template_type = models.CharField( - max_length=20, choices=TEMPLATE_TYPE, - default='standard', blank=True, null=True, - help_text="Template type", - verbose_name="Template type") - - duration = models.IntegerField(help_text="Duration in minutes. For articles, it will be calculated automatically.", - default="1") - is_featured = models.BooleanField(default=False, help_text="Should this story be featured on site?") - content = TextField(null=True, help_text='Post content') - thumbnail = models.ImageField(upload_to=upload_thumbnail_image, blank=True, null=True) - - # This comes from Django HitCount - view_count = GenericRelation(HitCount, object_id_field='object_pk', related_query_name='hit_count_generic_relation') - author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='articles') - category = models.ManyToManyField(Category) - course = models.ForeignKey(Course, on_delete=models.PROTECT, blank=True, null=True) - - class Meta: - ordering = ['title'] - verbose_name = "article" - verbose_name_plural = "articles" - indexes = [ - models.Index(fields=['slug', 'publish_status', 'post_type', 'published_date']), - ] - - def get_comments_count(self): - return bloggy.models.Comment.objects.filter(post_id=self.id).count() - - def get_admin_url(self): - return reverse('admin:%s_%s_change' % (self._meta.app_label, self._meta.model_name), args=[self.id]) - - def get_absolute_url(self): - if self.post_type == "quiz": - return reverse("quiz_single", kwargs={"slug": str(self.slug)}) - elif self.post_type == "lesson": - return reverse("lesson_single", kwargs={"course": str(self.course.slug), "slug": str(self.slug)}) - else: - return reverse("article_single", kwargs={"slug": str(self.slug)}) - - @staticmethod - def get_excerpt(self): - return self.excerpt[0, 10] - - def get_postmeta(self): - dictionary = {} - for postmeta in self.postmeta_set.all(): - dictionary[postmeta.meta_key] = postmeta.meta_value - return dictionary - - @property - def get_votes_count(self): - return Votes.objects.all().filter(post_id=self.id).filter(post_type="article").count() - - @property - def get_read_count(self): - words_per_minute = 150 - word_count = round(len(self.content.split()) / words_per_minute) - return str(max(word_count, 5)) + " minutes" - - def get_bookmarks_count(self): - return Bookmarks.objects.all().filter(post_id=self.id).count() - - def get_questions(self): - return self.quizquestion_set.all() - - def get_lessons(self): - return self.lesson_set.filter(publish_status="LIVE").order_by("display_order").all() - - @property - def get_questions_json(self): - return get_questions_json(self) - - def thumbnail_tag(self): - if self.thumbnail: - return format_html(''.format(self.thumbnail.url)) - - thumbnail_tag.short_description = 'Logo' - thumbnail_tag.allow_tags = True - - def save(self, *args, **kwargs): - if StringUtils.is_blank(self.slug): - self.slug = slugify(self.title) - super(Article, self).save(*args, **kwargs) - - def __str__(self): - return self.title - - def get_template_path(self): - return "pages/{}-{}.html".format(self.article, ) diff --git a/bloggy/models/categories.py b/bloggy/models/categories.py index 45c8120..5ea3204 100644 --- a/bloggy/models/categories.py +++ b/bloggy/models/categories.py @@ -1,33 +1,31 @@ from colorfield.fields import ColorField from django.db import models from django.urls import reverse +from django.utils.html import format_html from django.utils.text import slugify -from bloggy.models.updatable import Updatable +from bloggy.models.mixin.SeoAware import SeoAware +from bloggy.models.mixin.updatable import Updatable from bloggy.utils.string_utils import StringUtils -from django.utils.html import format_html def upload_logo_image(self, filename): return f'uploads/categories/{filename}' -PUBLISH_STATUS = [ - ('DRAFT', 'DRAFT'), - ('LIVE', 'LIVE') -] - - -class Category(Updatable): +class Category(Updatable, SeoAware): title = models.CharField(max_length=150, help_text='Enter title') article_count = models.IntegerField(default=0) slug = models.SlugField(max_length=150, help_text='Enter slug', unique=True) - description = models.TextField(max_length=1000, help_text='Enter description', null=True, blank=True) - logo = models.ImageField(upload_to=upload_logo_image, null=True) + excerpt = models.TextField(max_length=1000, help_text='Enter description', null=True, blank=True) + thumbnail = models.ImageField(upload_to=upload_logo_image, null=True) color = ColorField(default='#1976D2') publish_status = models.CharField( - max_length=20, choices=PUBLISH_STATUS, + max_length=20, choices=[ + ('DRAFT', 'DRAFT'), + ('LIVE', 'LIVE') + ], default='DRAFT', blank=True, null=True, help_text="Select publish status", verbose_name="Publish status") @@ -40,17 +38,18 @@ class Meta: def save(self, *args, **kwargs): if StringUtils.is_blank(self.slug): self.slug = slugify(self.title) - super(Category, self).save(*args, **kwargs) + super().save(*args, **kwargs) def get_absolute_url(self): return reverse('categories_single', args=[str(self.slug)]) - def logo_tag(self): - if self.logo: - return format_html(''.format(self.logo.url)) + def thumbnail_tag(self): + if self.thumbnail_tag: + return format_html(f'') + return "" - logo_tag.short_description = 'Logo' - logo_tag.allow_tags = True + thumbnail_tag.short_description = 'Thumbnail' + thumbnail_tag.allow_tags = True def __str__(self): - return self.title + return str(self.title) diff --git a/bloggy/models/comment.py b/bloggy/models/comment.py index e63f53e..b390c90 100644 --- a/bloggy/models/comment.py +++ b/bloggy/models/comment.py @@ -4,7 +4,7 @@ class Comment(models.Model): - post = models.ForeignKey('bloggy.Article', on_delete=models.CASCADE, related_name='comments') + post = models.ForeignKey('bloggy.Post', on_delete=models.CASCADE, related_name='comments') user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='comments', blank=True, null=True) parent = models.ForeignKey('self', related_name='reply_set', null=True, on_delete=models.PROTECT) diff --git a/bloggy/models/course.py b/bloggy/models/course.py index 6b33baf..5550186 100644 --- a/bloggy/models/course.py +++ b/bloggy/models/course.py @@ -1,23 +1,19 @@ from django.contrib.contenttypes.fields import GenericRelation -from django.utils.html import format_html -from django.utils.text import slugify from django.db import models +from django.urls import reverse +from django.utils.html import format_html from hitcount.models import HitCount from bloggy import settings from bloggy.models import Category -from bloggy.models.post import Post -from bloggy.utils.string_utils import StringUtils -from django.urls import reverse +from bloggy.models.mixin.Content import Content def upload_thumbnail_image(self, post_id): return f'uploads/course/{post_id}' -class Course(Post): - excerpt = models.CharField(max_length=500, help_text='Enter excerpt', null=True, blank=True) - +class Course(Content): difficulty = models.CharField( max_length=20, choices=[ ('beginner', 'Beginner'), @@ -28,11 +24,15 @@ class Course(Post): help_text="Select difficulty", verbose_name="Difficulty level") - is_featured = models.BooleanField(default=False, help_text="Is featured") + is_featured = models.BooleanField( + default=False, + help_text="Should this story be featured on site?" + ) + description = models.TextField(null=True, help_text='Enter answer') author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='courses') thumbnail = models.ImageField(upload_to=upload_thumbnail_image, null=True, blank=True) - category = models.ManyToManyField(Category, blank=True) + category = models.ForeignKey(Category, blank=True, on_delete=models.CASCADE, related_name='courses') view_count = GenericRelation(HitCount, object_id_field='object_pk', related_query_name='hit_count_generic_relation') class Meta: @@ -43,29 +43,17 @@ class Meta: models.Index(fields=['slug', 'publish_status', 'published_date']), ] - @staticmethod - def get_excerpt(self): - return self.excerpt[0, 10] - def get_absolute_url(self): return reverse("courses_single", kwargs={"slug": str(self.slug)}) @property def get_lessons(self): - return self.article_set.filter(post_type="lesson").filter(publish_status="LIVE") \ - .order_by("display_order").all() + return self.post_set.filter(publish_status="LIVE").order_by("display_order").all() def thumbnail_tag(self): if self.thumbnail: - return format_html(''.format(self.thumbnail.url)) + return format_html(f'') + return "" thumbnail_tag.short_description = 'Logo' thumbnail_tag.allow_tags = True - - def save(self, *args, **kwargs): - if StringUtils.is_blank(self.slug): - self.slug = slugify(self.title) - super(Course, self).save(*args, **kwargs) - - def __str__(self): - return self.title diff --git a/bloggy/models/media.py b/bloggy/models/media.py index 9dc2026..8679aaa 100644 --- a/bloggy/models/media.py +++ b/bloggy/models/media.py @@ -16,7 +16,7 @@ class Media(AbstractAttachment): def save(self, *args, **kwargs): get_config()['attachment_upload_to'] = f'uploads/{self.post_type}/{self.post_id}' - super(Media, self).save(*args, **kwargs) + super().save(*args, **kwargs) def get_attachment_upload_to(self): return f'uploads/{self.post_type}/{self.post_id}' diff --git a/bloggy/models/mixin/Content.py b/bloggy/models/mixin/Content.py new file mode 100644 index 0000000..3819874 --- /dev/null +++ b/bloggy/models/mixin/Content.py @@ -0,0 +1,42 @@ +from django.db import models +from django.utils.text import slugify +from bloggy.models.mixin.SeoAware import SeoAware +from bloggy.models.mixin.updatable import Updatable +from bloggy.utils.string_utils import StringUtils + + +class Content(Updatable, SeoAware): + title = models.CharField(max_length=300, help_text='Enter title') + slug = models.SlugField(max_length=150, help_text='Enter slug', unique=True) + excerpt = models.CharField( + max_length=500, + help_text='Enter excerpt', + null=True, + blank=True + ) + + display_order = models.IntegerField(null=True, help_text='Display order', default=0) + published_date = models.DateTimeField(null=True, blank=True) + publish_status = models.CharField( + max_length=20, choices=[ + ('DRAFT', 'DRAFT'), + ('LIVE', 'LIVE'), + ('DELETED', 'DELETED') + ], + default='DRAFT', blank=True, null=True, + help_text="Select publish status", + verbose_name="Publish status") + + def get_excerpt(self): + return self.excerpt[:10] + + def save(self, *args, **kwargs): + if StringUtils.is_blank(self.slug): + self.slug = slugify(self.title) + super().save(*args, **kwargs) + + def __str__(self): + return str(self.title) + + class Meta: + abstract = True diff --git a/bloggy/models/mixin/ResizeImageMixin.py b/bloggy/models/mixin/ResizeImageMixin.py index 9e8bdd3..573864b 100644 --- a/bloggy/models/mixin/ResizeImageMixin.py +++ b/bloggy/models/mixin/ResizeImageMixin.py @@ -1,14 +1,16 @@ import uuid -from PIL import Image from io import BytesIO + +from PIL import Image from django.core.files import File from django.core.files.base import ContentFile from django.db import models + # Not used atm class ResizeImageMixin: - def resize(self, imageField: models.ImageField, size: tuple): - im = Image.open(imageField) # Catch original + def resize(self, image_field: models.ImageField, size: tuple): + im = Image.open(image_field) # Catch original source_image = im.convert('RGB') source_image.thumbnail(size) # Resize to size output = BytesIO() @@ -19,4 +21,4 @@ def resize(self, imageField: models.ImageField, size: tuple): file = File(content_file) random_name = f'{uuid.uuid4()}.jpeg' - imageField.save(random_name, file, save=False) + image_field.save(random_name, file, save=False) diff --git a/bloggy/models/mixin/SeoAware.py b/bloggy/models/mixin/SeoAware.py new file mode 100644 index 0000000..81f0072 --- /dev/null +++ b/bloggy/models/mixin/SeoAware.py @@ -0,0 +1,10 @@ +from django.db import models + + +class SeoAware(models.Model): + meta_title = models.CharField(max_length=120, help_text='Meta Title', blank=True, null=True) + meta_description = models.TextField(help_text='Meta Description', blank=True, null=True) + meta_keywords = models.CharField(max_length=300, help_text='Meta Keywords', blank=True, null=True) + + class Meta: + abstract = True diff --git a/bloggy/models/updatable.py b/bloggy/models/mixin/updatable.py similarity index 99% rename from bloggy/models/updatable.py rename to bloggy/models/mixin/updatable.py index d3ea2e9..380d627 100644 --- a/bloggy/models/updatable.py +++ b/bloggy/models/mixin/updatable.py @@ -10,5 +10,3 @@ def get_model_type(self): class Meta: abstract = True - - diff --git a/bloggy/models/option.py b/bloggy/models/option.py index 7069b57..eee523c 100644 --- a/bloggy/models/option.py +++ b/bloggy/models/option.py @@ -1,7 +1,7 @@ from django.db import models from django.db.models import TextField -from bloggy.models.updatable import Updatable +from bloggy.models.mixin.updatable import Updatable class Option(Updatable): @@ -10,4 +10,4 @@ class Option(Updatable): value = TextField(null=True, help_text='Enter value') def __str__(self): - return self.key + return str(self.key) diff --git a/bloggy/models/page.py b/bloggy/models/page.py new file mode 100644 index 0000000..87143cb --- /dev/null +++ b/bloggy/models/page.py @@ -0,0 +1,44 @@ +from django.db import models +from django.db.models import TextField + +from bloggy.models.mixin.SeoAware import SeoAware +from bloggy.models.mixin.updatable import Updatable + + +def image_upload_path(self, page_id): + return f'uploads/pages/{page_id}' + + +class Page(Updatable, SeoAware): + """ + Stores page data. + """ + + title = models.CharField(max_length=300, help_text='Enter title') + excerpt = models.CharField( + max_length=500, + help_text='Enter excerpt', + null=True, + blank=True + ) + + url = models.CharField(max_length=150, help_text='Enter url', unique=True) + thumbnail = models.ImageField(upload_to=image_upload_path, blank=True, null=True) + content = TextField(null=True, help_text='Post content') + published_date = models.DateTimeField(null=True, blank=True) + publish_status = models.CharField( + max_length=20, choices=[ + ('DRAFT', 'DRAFT'), + ('LIVE', 'LIVE'), + ('DELETED', 'DELETED') + ], + default='DRAFT', blank=True, null=True, + help_text="Select publish status", + verbose_name="Publish status") + + def __str__(self): + return str(self.title) + + class Meta: + verbose_name = 'Page' + verbose_name_plural = 'Pages' diff --git a/bloggy/models/post.py b/bloggy/models/post.py index 0f313cb..235fdd6 100644 --- a/bloggy/models/post.py +++ b/bloggy/models/post.py @@ -1,24 +1,132 @@ +from django.contrib.contenttypes.fields import GenericRelation from django.db import models -from bloggy.models.updatable import Updatable +from django.db.models import TextField +from django.urls import reverse +from django.utils.html import format_html +from django.utils.text import slugify +from hitcount.models import HitCount +import bloggy +from bloggy import settings +from bloggy.models import Category, Bookmark +from bloggy.models.course import Course +from bloggy.models.mixin.Content import Content +from bloggy.utils.string_utils import StringUtils -class Post(Updatable): - display_order = models.IntegerField(null=True, help_text='Display order', default=0) - title = models.CharField(max_length=300, help_text='Enter title') - keywords = models.CharField(max_length=300, help_text='Enter title', null=True, blank=True) - publish_status = models.CharField( +def upload_thumbnail_image(self, post_id): + return f'uploads/posts/{post_id}' + + +class Post(Content): + difficulty = models.CharField( + max_length=20, choices=[ + ('beginner', 'Beginner'), + ('intermediate', 'Intermediate'), + ('advance', 'advance'), + ], + default='easy', blank=True, null=True, + help_text="Select difficulty", + verbose_name="Difficulty level") + + is_featured = models.BooleanField( + default=False, + help_text="Should this story be featured on site?" + ) + + video_id = models.CharField( + max_length=100, + help_text='YouTube Video ID', + null=True, + blank=True + ) + + github_link = models.CharField( + max_length=200, + help_text='Github project link', + null=True, + blank=True + ) + + post_type = models.CharField( + max_length=20, choices=settings.get_post_types(), + default='article', blank=True, null=True, + help_text="Post type", + verbose_name="Post type") + + template_type = models.CharField( max_length=20, choices=[ - ('DRAFT', 'DRAFT'), - ('LIVE', 'LIVE'), - ('DELETED', 'DELETED') + ('standard', 'Standard'), + ('cover', 'Cover'), + ('naked', 'Naked'), + ('full', 'Full'), ], - default='DRAFT', blank=True, null=True, - help_text="Select publish status", - verbose_name="Publish status") + default='standard', blank=True, null=True, + help_text="Template type", + verbose_name="Template type") + + content = TextField(null=True, help_text='Post content') + thumbnail = models.ImageField(upload_to=upload_thumbnail_image, blank=True, null=True) - published_date = models.DateTimeField(null=True, blank=True) - slug = models.SlugField(max_length=150, help_text='Enter slug', unique=True) + # This comes from Django HitCount + view_count = GenericRelation( + HitCount, + object_id_field='object_pk', + related_query_name='hit_count_generic_relation' + ) + author = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='posts' + ) + category = models.ManyToManyField(Category) + course = models.ForeignKey(Course, on_delete=models.PROTECT, blank=True, null=True) class Meta: - abstract = True + ordering = ['title'] + verbose_name = "Post" + verbose_name_plural = "Posts" + indexes = [ + models.Index(fields=['slug', 'publish_status', 'post_type', 'published_date']), + ] + + def get_comments_count(self): + return bloggy.models.Comment.objects.filter(post_id=self.id).count() + + def get_admin_url(self): + return reverse(f'admin:{self._meta.app_label}_{self._meta.model_name}_change', args=[self.id]) + + def get_absolute_url(self): + if self.post_type == "lesson": + return reverse("lesson_single", kwargs={"course": str(self.course.slug), "slug": str(self.slug)}) + else: + return reverse("post_single", kwargs={"slug": str(self.slug)}) + + @property + def get_votes_count(self): + return Vote.objects.all().filter(post_id=self.id).filter(post_type="post").count() + + @property + def get_read_count(self): + words_per_minute = 150 + word_count = round(len(self.content.split()) / words_per_minute) + return str(max(word_count, 5)) + " minutes" + + def get_bookmarks_count(self): + return Bookmark.objects.all().filter(post_id=self.id).count() + + def thumbnail_tag(self): + if self.thumbnail: + return format_html(f'') + return '' + + thumbnail_tag.short_description = 'Logo' + thumbnail_tag.allow_tags = True + + def save(self, *args, **kwargs): + if StringUtils.is_blank(self.slug): + self.slug = slugify(self.title) + super().save(*args, **kwargs) + + def get_template_path(self): + return f"pages/{self.post_type}-{self.template_type}.html" diff --git a/bloggy/models/post_actions.py b/bloggy/models/post_actions.py new file mode 100644 index 0000000..21cc2d9 --- /dev/null +++ b/bloggy/models/post_actions.py @@ -0,0 +1,26 @@ + +from django.db import models + +from bloggy import settings +from bloggy.models.mixin.updatable import Updatable + + +class Vote(Updatable): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + post_id = models.IntegerField(null=False, help_text='Post id') + post_type = models.CharField( + null=False, + max_length=20, + choices=settings.get_post_types(), + help_text="Select content type", + verbose_name="Content type" + ) + + class Meta: + verbose_name = "Vote" + verbose_name_plural = "Votes" + +class Bookmark(Vote): + class Meta: + verbose_name = "Bookmark" + verbose_name_plural = "Bookmarks" diff --git a/bloggy/models/post_bookmark.py b/bloggy/models/post_bookmark.py deleted file mode 100644 index a4a0545..0000000 --- a/bloggy/models/post_bookmark.py +++ /dev/null @@ -1,18 +0,0 @@ -from django.db import models - -from bloggy import settings - - -class Bookmarks(models.Model): - CONTENT_TYPES = [ - ('question', 'question'), - ('article', 'article'), - ] - - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - post_id = models.IntegerField(null=False, help_text='Post id') - post_type = models.CharField(null=False, max_length=20, choices=CONTENT_TYPES, - help_text="Select content type", verbose_name="Content type") - - created_date = models.DateTimeField(auto_created=True, null=True, auto_now_add=True) - updated_date = models.DateTimeField(auto_now=True, null=True) diff --git a/bloggy/models/post_meta.py b/bloggy/models/post_meta.py deleted file mode 100644 index a22304c..0000000 --- a/bloggy/models/post_meta.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.db import models -from django.db.models import TextField - - -class PostMeta(models.Model): - id = models.AutoField(primary_key=True) - article = models.ForeignKey('bloggy.Article', on_delete=models.CASCADE) - meta_key = models.SlugField(max_length=150, help_text='Enter key') - meta_value = TextField(null=True, help_text='Enter value') - - def __str__(self): - return 'id:{}, meta_key:{}, meta_value:{}'.format(self.id, self.meta_key, self.meta_value) - - class Meta: - verbose_name = "Post metadata" - verbose_name_plural = "Post metadata" diff --git a/bloggy/models/post_views.py b/bloggy/models/post_views.py deleted file mode 100644 index 1ca0570..0000000 --- a/bloggy/models/post_views.py +++ /dev/null @@ -1,15 +0,0 @@ -from django.db import models - - -class PostViews(models.Model): - CONTENT_TYPES = [ - ('question', 'question'), - ('article', 'article'), - ] - - ip_address = models.GenericIPAddressField(default="0.0.0.0") - created_date = models.DateTimeField(auto_created=True, null=True, auto_now_add=True) - updated_date = models.DateTimeField(auto_now=True, null=True) - - def __str__(self): - return '{0} in {1} post'.format(self.ip_address, self.post.title) diff --git a/bloggy/models/post_vote.py b/bloggy/models/post_vote.py deleted file mode 100644 index 6c48c74..0000000 --- a/bloggy/models/post_vote.py +++ /dev/null @@ -1,18 +0,0 @@ -from django.db import models - -from bloggy import settings - - -class Votes(models.Model): - CONTENT_TYPES = [ - ('question', 'question'), - ('article', 'article'), - ] - - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - post_id = models.IntegerField(null=False, help_text='Post id') - post_type = models.CharField(null=False, max_length=20, choices=CONTENT_TYPES, - help_text="Select content type", verbose_name="Content type") - - created_date = models.DateTimeField(auto_created=True, null=True, auto_now_add=True) - updated_date = models.DateTimeField(auto_now=True, null=True) diff --git a/bloggy/models/quizzes.py b/bloggy/models/quizzes.py index bfb8085..30da1c8 100644 --- a/bloggy/models/quizzes.py +++ b/bloggy/models/quizzes.py @@ -1,28 +1,97 @@ +from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.db.models import TextField +from hitcount.models import HitCount -from bloggy.models import MyUser, Article -from bloggy.models.updatable import Updatable +from bloggy import settings +from bloggy.models import Category +from bloggy.models.mixin.Content import Content +from bloggy.models.mixin.updatable import Updatable +from bloggy.services.quiz_service import get_questions_json -def upload_logo_image(self, filename): - return f'uploads/quiz/{filename}' +def upload_thumbnail_image(self, post_id): + return f'uploads/quizzes/{post_id}' -QUESTION_TYPE = [ - ('binary', 'binary'), - ('multiple', 'multiple'), -] +class Quiz(Content): + difficulty = models.CharField( + max_length=20, + choices=[ + ('beginner', 'Beginner'), + ('intermediate', 'Intermediate'), + ('advance', 'advance'), + ], + default='easy', blank=True, null=True, + help_text="Select difficulty", + verbose_name="Difficulty level") + + is_featured = models.BooleanField( + default=False, + help_text="Should this story be featured on site?" + ) + content = TextField( + null=True, + help_text='Post content' + ) + thumbnail = models.ImageField( + upload_to=upload_thumbnail_image, + null=True, + blank=True) + category = models.ForeignKey( + Category, + blank=True, + on_delete=models.CASCADE, + related_name='quizzes' + ) + duration = models.IntegerField( + help_text="Duration in minutes. For articles, it will be calculated automatically.", + default="1" + ) + view_count = GenericRelation( + HitCount, + object_id_field='object_pk', + related_query_name='hit_count_generic_relation' + ) + + @property + def get_questions_json(self): + return get_questions_json(self) + + def get_questions(self): + return self.quizquestion_set.all() + + class Meta: + ordering = ['title'] + verbose_name = "Quiz" + verbose_name_plural = "Quizzes" + indexes = [ + models.Index(fields=['slug', 'publish_status']), + ] class QuizQuestion(models.Model): id = models.AutoField(primary_key=True) - title = models.CharField(max_length=500) - description = TextField(null=True, blank=True, help_text='Enter description') - explanation = TextField(null=True, blank=True, help_text='Enter explanation', ) - article = models.ForeignKey(Article, on_delete=models.CASCADE, null=True, blank=True) + title = models.CharField( + max_length=500) + description = TextField( + null=True, blank=True, + help_text='Enter description') + explanation = TextField( + null=True, + blank=True, + help_text='Enter explanation', ) + quiz = models.ForeignKey( + Quiz, + on_delete=models.CASCADE, + null=True, + blank=True) type = models.CharField( - max_length=20, choices=QUESTION_TYPE, + max_length=20, + choices=[ + ('binary', 'binary'), + ('multiple', 'multiple'), + ], default='binary', blank=True, null=True, help_text="Select type of question", verbose_name="Question type") @@ -45,8 +114,8 @@ def __str__(self): class UserQuizScore(Updatable): - quiz = models.ForeignKey(Article, on_delete=models.CASCADE) - user = models.ForeignKey(MyUser, on_delete=models.CASCADE) + quiz = models.ForeignKey(Quiz, on_delete=models.CASCADE) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) score = models.FloatField() def __str__(self): diff --git a/bloggy/models/redirect_rule.py b/bloggy/models/redirect_rule.py new file mode 100644 index 0000000..102f060 --- /dev/null +++ b/bloggy/models/redirect_rule.py @@ -0,0 +1,31 @@ +from django.db import models + +from bloggy.models.mixin.updatable import Updatable + + +class RedirectRule(Updatable): + source = models.CharField(max_length=300, help_text='Enter from url') + destination = models.CharField(max_length=300, help_text='Enter to url') + status_code = models.IntegerField( + default='standard', blank=True, null=True, + choices=[ + (301, '301 Moved Permanently'), + (307, '307 Temporary Redirect'), + ], + help_text="Redirect type", + verbose_name="Redirect type") + + note = models.CharField( + max_length=500, + help_text='Enter note', + null=True, + blank=True + ) + + def __str__(self): + return f"{self.status_code}::{self.source}" + + +class Meta: + verbose_name = "Redirect" + verbose_name_plural = "Redirects" diff --git a/bloggy/models/subscriber.py b/bloggy/models/subscriber.py index 0a66b65..e94948e 100644 --- a/bloggy/models/subscriber.py +++ b/bloggy/models/subscriber.py @@ -1,6 +1,6 @@ from django.db import models + from bloggy import settings -from bloggy.services import token_generator from bloggy.services.token_generator import TOKEN_LENGTH @@ -10,7 +10,6 @@ class Subscribers(models.Model): confirmed = models.BooleanField(null=True, default=False) confirmation_code = models.CharField( default=None, - # default=token_generator.generate_verification_code(), max_length=TOKEN_LENGTH, help_text="The random token identifying the verification request.", null=True, @@ -18,8 +17,13 @@ class Subscribers(models.Model): verbose_name="token", ) created_date = models.DateTimeField(auto_created=True, null=True, auto_now_add=True) - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='user', blank=True, - null=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name='user', + blank=True, + null=True + ) def __str__(self): return self.email + " (" + ("not " if not self.confirmed else "") + "confirmed)" diff --git a/bloggy/models/user.py b/bloggy/models/user.py index c847a0f..77133f4 100644 --- a/bloggy/models/user.py +++ b/bloggy/models/user.py @@ -1,11 +1,11 @@ -from django.contrib.auth.validators import UnicodeUsernameValidator -from django.utils import timezone - from django.contrib.auth.base_user import AbstractBaseUser -from django.contrib.auth.models import AbstractUser, PermissionsMixin +from django.contrib.auth.models import PermissionsMixin +from django.contrib.auth.validators import UnicodeUsernameValidator from django.db import models from django.urls import reverse +from django.utils import timezone from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ import bloggy from bloggy import settings @@ -13,7 +13,6 @@ from bloggy.services.account_manager import NewUserAccountManager from bloggy.services.gravtar import get_gravatar from bloggy.storage_backends import PublicMediaStorage, StaticStorage -from django.utils.translation import gettext_lazy as _ def upload_profile_image(self, filename): @@ -24,7 +23,7 @@ def select_storage(): return PublicMediaStorage() if settings.USE_SPACES else StaticStorage() -class MyUser(AbstractBaseUser, ResizeImageMixin, PermissionsMixin): +class User(AbstractBaseUser, ResizeImageMixin, PermissionsMixin): username_validator = UnicodeUsernameValidator() username = models.CharField( _("username"), @@ -39,11 +38,10 @@ class MyUser(AbstractBaseUser, ResizeImageMixin, PermissionsMixin): ) email = models.EmailField('email address', unique=True) - name = models.CharField(_("first name"), max_length=150, blank=True) + name = models.CharField(_("full name"), max_length=150, blank=True) date_joined = models.DateTimeField(_("date joined"), default=timezone.now) profile_photo = models.ImageField(null=True, blank=True, upload_to=upload_profile_image, storage=select_storage) # , storage=PublicMediaStorage() - is_staff = models.BooleanField( "staff status", default=False, @@ -70,6 +68,7 @@ class MyUser(AbstractBaseUser, ResizeImageMixin, PermissionsMixin): bio = models.TextField(max_length=250, null=True, blank=True) class Meta: + db_table = "bloggy_user" ordering = ['username'] verbose_name_plural = "Users" @@ -77,24 +76,24 @@ def get_absolute_url(self): return reverse('user_profile', args=[str(self.username)]) def get_bookmarks_count(self): - return bloggy.models.Bookmarks.objects.filter(user_id=self.id).count() + return bloggy.models.Bookmark.objects.filter(user_id=self.id).count() def get_full_name_or_username(self): if self.name: return self.name - else: - return self.username + + return self.username def get_full_name(self): - full_name = "%s" % self.name + full_name = f"{self.name}" return full_name.strip() def get_avatar(self): if self.profile_photo: return self.profile_photo.url - else: - return get_gravatar(self.email) + return get_gravatar(self.email) def profile_photo_tag(self): if self.profile_photo: - return format_html(''.format(self.profile_photo.url)) + return format_html(f'') + return "" diff --git a/bloggy/models/verification_token.py b/bloggy/models/verification_token.py index b5c7050..198aac8 100644 --- a/bloggy/models/verification_token.py +++ b/bloggy/models/verification_token.py @@ -1,8 +1,8 @@ import uuid + from django.db import models from bloggy import settings -from bloggy.services import token_generator from bloggy.services.token_generator import TOKEN_LENGTH TOKEN_TYPE = [ @@ -26,7 +26,6 @@ class VerificationToken(models.Model): verbose_name="uuid", ) - # TODO do we need this? user = models.ForeignKey( settings.AUTH_USER_MODEL, help_text="The user who owns the email address.", @@ -50,7 +49,6 @@ class VerificationToken(models.Model): token = models.CharField( help_text="The random token identifying the verification request.", - # default=token_generator.generate_verification_code(), max_length=TOKEN_LENGTH, unique=True, verbose_name="token", @@ -66,4 +64,4 @@ def __repr__(self): return build_repr( self, ["uuid", "time_created", "code"], - ) \ No newline at end of file + ) diff --git a/bloggy/services/__init__.py b/bloggy/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bloggy/services/email_service.py b/bloggy/services/email_service.py index 7de33ae..a0a556f 100644 --- a/bloggy/services/email_service.py +++ b/bloggy/services/email_service.py @@ -18,7 +18,7 @@ def send_custom_email(subject, recipients, template, args, from_email=settings.D def send_newsletter_verification_token(request, email, uuid, token): - subject = "Confirm to StackTips newsletter" + subject = f'Confirm to {settings.SITE_TITLE} newsletter' args = { "email_subject": subject, @@ -30,7 +30,7 @@ def send_newsletter_verification_token(request, email, uuid, token): def email_verification_token(request, new_user, token): - subject = "{} confirmation code: {}".format(settings.SITE_TITLE, token.code) + subject = f"{settings.SITE_TITLE} confirmation code: {token.code}" args = { "email_subject": subject, "verification_code": token.code, @@ -41,7 +41,7 @@ def email_verification_token(request, new_user, token): def email_registration_token(request, new_user, verification_token): - subject = "Welcome to StackTips! Complete your registration and get started!" + subject = f'Welcome to {settings.SITE_TITLE}!' args = { "email_subject": subject, "user_name": new_user.name, diff --git a/bloggy/services/gravtar.py b/bloggy/services/gravtar.py index c35e8d3..8a27087 100644 --- a/bloggy/services/gravtar.py +++ b/bloggy/services/gravtar.py @@ -3,9 +3,6 @@ def get_gravatar(email, size=400): - default = "identicon" # "https://media.stacktips.com/media/uploads/default_avatar.png" + default = "identicon" params = urllib.parse.urlencode({'d': default, 's': str(size)}) - return "https://www.gravatar.com/avatar/{}?{}".format( - hashlib.md5(email.lower().encode('utf-8')).hexdigest(), - params - ) + return f"https://www.gravatar.com/avatar/{hashlib.md5(email.lower().encode('utf-8')).hexdigest()}?{params}" diff --git a/bloggy/services/post_service.py b/bloggy/services/post_service.py index 2087a62..e3ec89c 100644 --- a/bloggy/services/post_service.py +++ b/bloggy/services/post_service.py @@ -1,29 +1,28 @@ from django.core.paginator import Paginator, PageNotAnInteger, EmptyPage -from bloggy import models +from bloggy.models import Post, Quiz +from bloggy.utils.string_utils import StringUtils DEFAULT_PAGE_SIZE = 20 def get_recent_feed(publish_status="LIVE", page=1, page_size=DEFAULT_PAGE_SIZE): - articles = models.Article.objects.prefetch_related("category") \ + posts = Post.objects.prefetch_related("category") \ .filter(publish_status=publish_status) \ - .filter(post_type__in=["article", "quiz", 'lesson']) \ .order_by("-published_date") - paginator = Paginator(articles, page_size) + paginator = Paginator(posts, page_size) try: - articles = paginator.page(page) + posts = paginator.page(page) except PageNotAnInteger: - articles = paginator.page(1) + posts = paginator.page(1) except EmptyPage: - articles = paginator.page(paginator.num_pages) - - return articles + posts = paginator.page(paginator.num_pages) + return posts def get_recent_posts(publish_status="LIVE", page=1, page_size=DEFAULT_PAGE_SIZE): - articles = models.Article.objects.prefetch_related("category") \ + articles = Post.objects.prefetch_related("category") \ .filter(publish_status=publish_status).filter(post_type__in=["article"]) \ .order_by("-published_date") @@ -34,39 +33,34 @@ def get_recent_posts(publish_status="LIVE", page=1, page_size=DEFAULT_PAGE_SIZE) articles = paginator.page(1) except EmptyPage: articles = paginator.page(paginator.num_pages) - return articles def get_recent_quizzes(publish_status="LIVE", page=1): - articles = models.Article.objects.prefetch_related("category") \ - .filter(publish_status=publish_status).filter(post_type__in=["quiz"]) \ - .order_by("-published_date") - paginator = Paginator(articles, DEFAULT_PAGE_SIZE) + quizzes = Quiz.objects.prefetch_related("category") \ + .filter(publish_status=publish_status).order_by("-published_date") + paginator = Paginator(quizzes, DEFAULT_PAGE_SIZE) try: - articles = paginator.page(page) + quizzes = paginator.page(page) except PageNotAnInteger: - articles = paginator.page(1) + quizzes = paginator.page(1) except EmptyPage: - articles = paginator.page(paginator.num_pages) - - return articles + quizzes = paginator.page(paginator.num_pages) + return quizzes -def get_recent_quizzes(publish_status="LIVE", page=1): - articles = models.Article.objects.filter(publish_status=publish_status, post_type="quiz") \ - .prefetch_related("category", "quizquestion_set") \ - .order_by("-display_order") - paginator = Paginator(articles, DEFAULT_PAGE_SIZE) - try: - articles = paginator.page(page) - except PageNotAnInteger: - articles = paginator.page(1) - except EmptyPage: - articles = paginator.page(paginator.num_pages) - return articles +def set_seo_settings(post, context): + if StringUtils.is_blank(post.meta_title): + context["meta_title"] = post.title + else: + context["meta_title"] = post.meta_title + if StringUtils.is_blank(post.meta_description): + context["meta_description"] = post.excerpt + else: + context["meta_description"] = post.meta_description + context['meta_keywords'] = post.meta_keywords -def get_quiz_by_id(pk): - return models.Article.objects.get(pk=pk) + if post.thumbnail: + context['meta_image'] = post.thumbnail.url diff --git a/bloggy/services/quizz_service.py b/bloggy/services/quiz_service.py similarity index 90% rename from bloggy/services/quizz_service.py rename to bloggy/services/quiz_service.py index 0cbb8ea..49df05f 100644 --- a/bloggy/services/quizz_service.py +++ b/bloggy/services/quiz_service.py @@ -12,7 +12,6 @@ def get_questions_json(post): question_answers = question.get_answers() for index, answer in enumerate(question_answers): key_char = chr(index + 97) - answers.append({ "value": answer.content, "key": key_char @@ -30,15 +29,7 @@ def get_questions_json(post): "correctAnswer": correct_answer }) - category = post.category.first() - category_json = None - if category: - category_json = { - "title": category.title, - "slug": category.slug, - "id": category.id - } - + category = post.category return { 'id': post.id, 'title': post.title, @@ -49,6 +40,9 @@ def get_questions_json(post): 'logo': post.thumbnail.url if post.thumbnail else "", 'questions': questions, 'time': post.duration, - 'category': category_json + 'category': { + "title": category.title, + "slug": category.slug, + "id": category.id + } } - diff --git a/bloggy/services/seo_service.py b/bloggy/services/seo_service.py index 5e19d79..5d44e93 100644 --- a/bloggy/services/seo_service.py +++ b/bloggy/services/seo_service.py @@ -31,18 +31,20 @@ def remove_stopwords(input_string, stopwords): def get_keywords(content, *extras): - Soup = BeautifulSoup(content, 'html5lib') + soup = BeautifulSoup(content, 'html5lib') heading_tags = ["h1", "h2", "h3"] keywords = [] - for tags in Soup.find_all(heading_tags): + for tags in soup.find_all(heading_tags): keywords.append(tags.text.strip()) if extras: keywords_str = (','.join(extras)) + "," + (','.join(keywords)) else: keywords_str = ','.join(keywords) - print("Keywords:%s" % keywords_str) + print('Keywords: %s', keywords_str) filtered_keywords_str = remove_stopwords(keywords_str, stopwords_list) - print("Keywords:%s" % filtered_keywords_str) + print(f"Keywords:{filtered_keywords_str}") return keywords_str.lower() + + diff --git a/bloggy/services/sitemaps.py b/bloggy/services/sitemaps.py index 7d33033..e1b5ba0 100644 --- a/bloggy/services/sitemaps.py +++ b/bloggy/services/sitemaps.py @@ -1,9 +1,9 @@ from django.contrib import sitemaps from django.contrib.sitemaps import GenericSitemap from django.urls import reverse - -from ..models import Article, Category, MyUser +from bloggy.models import Post, Category, User from bloggy.models.course import Course +from bloggy.models.page import Page class StaticPagesSitemap(sitemaps.Sitemap): @@ -11,39 +11,30 @@ class StaticPagesSitemap(sitemaps.Sitemap): changefreq = 'daily' def items(self): - return [ + items = [] + staticPages = [ 'index', 'courses', - 'quizzes', - 'articles', + 'posts', 'categories', - 'authors', - 'pages.privacy', - 'pages.code_of_conduct', - 'pages.contribute', - 'pages.about', - 'pages.contact', - 'pages.terms_of_service', - 'pages.cookie_policy', - 'resources', - 'resources.status_codes', - 'resources.base64_converter', - 'resources.url_encoder', - 'resources.analyze_http_header', - 'resources.website_link_analyzer', - ] + 'authors'] - def location(self, item): - if item.startswith("/"): - return item + for staticPage in staticPages: + items.append(reverse(staticPage)) - return reverse(item) + pages = Page.objects.filter(publish_status="LIVE").all() + for page in pages: + items.append(f'/{page.url}') + return items + + def location(self, item): + return item sitemaps_list = { 'pages': StaticPagesSitemap, 'articles': GenericSitemap({ - 'queryset': Article.objects.filter(publish_status="LIVE").order_by("-published_date").all(), + 'queryset': Post.objects.filter(publish_status="LIVE").order_by("-published_date").all(), 'date_field': 'published_date' }, priority=0.6, changefreq='daily'), @@ -57,7 +48,7 @@ def location(self, item): }, priority=0.6, changefreq='daily'), 'users': GenericSitemap({ - 'queryset': MyUser.objects.exclude(username__in=["siteadmin", "superadmin", "admin"]).filter(is_staff=True).all() + 'queryset': User.objects.exclude(username__in=["siteadmin", "superadmin", "admin"]).filter(is_staff=True).all() }, priority=0.6, changefreq='daily'), } diff --git a/bloggy/services/token_generator.py b/bloggy/services/token_generator.py index 41b298d..f94da1d 100644 --- a/bloggy/services/token_generator.py +++ b/bloggy/services/token_generator.py @@ -1,6 +1,3 @@ -import random -import string - import six from django.contrib.auth.tokens import PasswordResetTokenGenerator @@ -14,10 +11,3 @@ def _make_hash_value(self, user, timestamp): six.text_type(user.pk) + six.text_type(timestamp) + six.text_type(user.is_active) ) - - -def generate_verification_code(): - # Generate a random 20-digit alphanumeric code similar to "JtM8t-MaV8y" - code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=10)) + '-' + ''.join( - random.choices(string.ascii_uppercase + string.digits, k=10)) - return code.lower() diff --git a/bloggy/services/token_service.py b/bloggy/services/token_service.py index 7d1afe5..347f98a 100644 --- a/bloggy/services/token_service.py +++ b/bloggy/services/token_service.py @@ -1,3 +1,5 @@ +import random +import string from datetime import timedelta from django.utils.timezone import now @@ -19,10 +21,10 @@ def create_token(user, token_type): # return the existing token, if it is not expired if time_difference_in_minutes < TOKEN_VALIDITY: return token - else: - token.delete() - return VerificationToken.objects.create(user=user, token_type=token_type) + token.delete() + + return VerificationToken.objects.create(user=user, token_type=token_type, token=generate_verification_code()) def get_token(uuid, verification_token, token_type): @@ -45,3 +47,10 @@ def is_token_expired(token): def delete_token_by_uuid(uuid): return VerificationToken.objects.filter(uuid=uuid).delete() + + +def generate_verification_code(): + # Generate a random 20-digit alphanumeric code similar to "JtM8t-MaV8y" + code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=10)) + '-' + ''.join( + random.choices(string.ascii_uppercase + string.digits, k=10)) + return code.lower() diff --git a/bloggy/services/url_shortener.py b/bloggy/services/url_shortener.py index efe5548..3f34a35 100644 --- a/bloggy/services/url_shortener.py +++ b/bloggy/services/url_shortener.py @@ -1,9 +1,10 @@ import json -import os import logging +import os import requests +from requests import RequestException logger = logging.getLogger(__name__) @@ -12,28 +13,28 @@ class UrlShortener: FIREBASE_API_KEY = os.getenv('FIREBASE_API_KEY') FIREBASE_DYNAMIC_LINKS_DOMAIN = os.getenv('FIREBASE_DYNAMIC_LINKS_DOMAIN') - def shorten_url(self, originalLink): - response_json = self.firebase_api(originalLink) - logger.debug("Response from Firebase dynamic link", response_json) + def shorten_url(self, original_link): + response_json = self.firebase_api(original_link) + logger.debug("Response from Firebase dynamic link %s", response_json) if response_json: response = json.loads(response_json) return response["shortLink"] - return originalLink + return original_link - def firebase_api(self, originalLink): + def firebase_api(self, original_link): try: url = f"https://firebasedynamiclinks.googleapis.com/v1/shortLinks?key={UrlShortener.FIREBASE_API_KEY}" headers = {'Content-Type': 'application/json'} payload = json.dumps({ - "longDynamicLink": f"{UrlShortener.FIREBASE_DYNAMIC_LINKS_DOMAIN}?link={originalLink}" + "longDynamicLink": f"{UrlShortener.FIREBASE_DYNAMIC_LINKS_DOMAIN}?link={original_link}" }) response = requests.post(url, data=payload, headers=headers) if response.status_code == 200: return response.text - except Exception: + except RequestException: print("ERROR: while shorting the url") return None diff --git a/bloggy/settings.py b/bloggy/settings.py index 31b7309..900ec43 100644 --- a/bloggy/settings.py +++ b/bloggy/settings.py @@ -29,10 +29,6 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = os.getenv("DEBUG", "False") == "True" - -# enable special cases like tag manager enabled in dev mode. Used to override the default DEBUG behaviour -DEVELOPMENT_MODE = os.getenv("DEVELOPMENT_MODE", "False") == "True" - ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "127.0.0.1, localhost").split(",") INTERNAL_IPS = ['127.0.0.1'] @@ -71,7 +67,6 @@ 'django.middleware.gzip.GZipMiddleware', "whitenoise.middleware.WhiteNoiseMiddleware", 'django.contrib.sessions.middleware.SessionMiddleware', - 'bloggy.middleware.wp_redirect.OldUrlRedirectMiddleware', # redirect for old wp site urls 'bloggy.middleware.slash_middleware.AppendOrRemoveSlashMiddleware', # Remove slash from url # Cache @@ -88,7 +83,7 @@ # Social login # 'social_django.middleware.SocialAuthExceptionMiddleware', 'debug_toolbar.middleware.DebugToolbarMiddleware', - 'bloggy.middleware.page_not_found.PageNotFoundMiddleware', # new articles mismatch url redirect + 'bloggy.middleware.redirect.RedirectMiddleware', # new articles mismatch url redirect ] ROOT_URLCONF = 'bloggy.urls' @@ -180,19 +175,17 @@ STATIC_URL = f'{os.getenv("ASSETS_DOMAIN")}/static/' STATICFILES_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' - MEDIA_URL = f'/media/' + MEDIA_URL = '/media/' DEFAULT_FILE_STORAGE = 'bloggy.storage_backends.PublicMediaStorage' # DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' PRIVATE_MEDIA_LOCATION = 'private' PRIVATE_FILE_STORAGE = 'bloggy.storage_backends.PrivateMediaStorage' - AWS_S3_CUSTOM_DOMAIN = 'media.stacktips.com' - else: STATIC_URL = '/static/' - STATIC_ROOT = os.path.join(BASE_DIR, 'bloggy/') + STATIC_ROOT = os.path.join(BASE_DIR, 'bloggy/static') MEDIA_URL = '/media/' MEDIA_ROOT = os.path.join(BASE_DIR, 'media') @@ -207,7 +200,7 @@ LOGIN_REDIRECT_URL = '/' LOGOUT_REDIRECT_URL = '/' -AUTH_USER_MODEL = 'bloggy.MyUser' +AUTH_USER_MODEL = 'bloggy.User' AUTH_USER_DEFAULT_GROUP = 'bloggy-members' SUMMERNOTE_THEME = 'bs4' @@ -263,8 +256,8 @@ MESSAGE_STORAGE = "django.contrib.messages.storage.cookie.CookieStorage" -SITE_TITLE = os.getenv("SITE_TITLE", "Demo Site") -SITE_TAGLINE = os.getenv("SITE_TAGLINE", "Demo Site") +SITE_TITLE = os.getenv("SITE_TITLE", "Bloggy") +SITE_TAGLINE = os.getenv("SITE_TAGLINE", "A perfectly crafted blog that developers love.") SITE_DESCRIPTION = os.getenv("SITE_DESCRIPTION") SITE_LOGO = os.getenv("SITE_LOGO") ASSETS_DOMAIN = os.getenv("ASSETS_DOMAIN") @@ -289,19 +282,10 @@ CACHE_TTL = 60 * 15 CACHE_MIDDLEWARE_ALIAS = 'default' # which cache alias to use CACHE_MIDDLEWARE_SECONDS = CACHE_TTL # number of seconds to cache a page for (TTL) -CACHE_MIDDLEWARE_KEY_PREFIX = '' # should be used if the cache is shared across multiple sites that use the same Django instance +CACHE_MIDDLEWARE_KEY_PREFIX = '' # should be used if the cache is shared across multiple sites that use the same -if DEBUG: - CACHES = { - 'default': { - 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', - }, 'redis': { - 'BACKEND': 'django.core.cache.backends.redis.RedisCache', - 'LOCATION': 'redis://127.0.0.1:6379', - "KEY_PREFIX": "bloggy" - }, - } -else: +ENABLE_CACHING = os.getenv("ENABLE_CACHING", "False") == "True" +if ENABLE_CACHING: CACHES = { 'default': { 'BACKEND': 'django.core.cache.backends.memcached.PyLibMCCache', @@ -316,6 +300,12 @@ }, } } +else: + CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', + } + } # Django HitCount HITCOUNT_KEEP_HIT_ACTIVE = {'days': 0} @@ -326,8 +316,8 @@ SHORTCODES_YOUTUBE_JQUERY = False # SEO related -PING_INDEX_NOW_POST_UPDATE = os.getenv("PING_INDEX_NOW_POST_UPDATE", True) -PING_GOOGLE_POST_UPDATE = os.getenv("PING_GOOGLE_POST_UPDATE", True) +PING_INDEX_NOW_POST_UPDATE = os.getenv("PING_INDEX_NOW_POST_UPDATE", "True") +PING_GOOGLE_POST_UPDATE = os.getenv("PING_GOOGLE_POST_UPDATE", "True") INDEX_NOW_API_KEY = os.getenv("INDEX_NOW_API_KEY", ) # Email configs @@ -336,9 +326,42 @@ EMAIL_PORT = os.getenv('EMAIL_PORT') EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER') EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD') -EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS', True) +EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS', "True") DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL') EMAIL_FILE_PATH = os.getenv('EMAIL_FILE_PATH', os.path.join(BASE_DIR, 'test-emails')) -# ads.txt file content +# Read the POST_TYPE_CHOICES environment variable from the .env file +POST_TYPE_CHOICES = os.getenv('POST_TYPE_CHOICES') +SHOW_EMTPY_CATEGORIES = os.getenv("SHOW_EMTPY_CATEGORIES", "False") == "True" + + +def get_post_types(): + post_type = [choice.split(':') for choice in POST_TYPE_CHOICES.split(',')] + return list(post_type) + + +# enable special cases like tag manager, google ads +LOAD_GOOGLE_TAG_MANAGER = os.getenv("LOAD_GOOGLE_TAG_MANAGER", "False") == "True" +LOAD_GOOGLE_ADS = os.getenv("LOAD_GOOGLE_ADS", "False") == "True" MY_ADS_TXT_CONTENT = os.getenv('MY_ADS_TXT_CONTENT') + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + }, + }, + "root": { + "handlers": ["console"], + "level": "DEBUG", + }, + "loggers": { + "django": { + "handlers": ["console"], + "level": os.getenv("DJANGO_LOG_LEVEL", "INFO"), + "propagate": False, + }, + }, +} \ No newline at end of file diff --git a/bloggy/shortcodes/parser.py b/bloggy/shortcodes/parser.py index 6b4cb5d..e98af7a 100644 --- a/bloggy/shortcodes/parser.py +++ b/bloggy/shortcodes/parser.py @@ -1,4 +1,5 @@ import re + from django.template import Template, Context @@ -41,7 +42,6 @@ def parse(value): parsed = re.sub(r'\[' + item + r'\]', result, parsed) except ImportError: print("Console error while loading module") - pass return parsed diff --git a/bloggy/signals.py b/bloggy/signals.py index 8fd533d..7ef0936 100644 --- a/bloggy/signals.py +++ b/bloggy/signals.py @@ -1,22 +1,24 @@ +import urllib.parse from urllib import request +from urllib.error import URLError +import django.core from django.db.models.signals import post_save from django.dispatch import receiver + +import bloggy.models from bloggy import settings -from django.core import management -from bloggy.models import Article -from urllib.parse import urlencode PING_GOOGLE_URL = "https://www.google.com/webmasters/tools/ping" INDEX_NOW = "https://www.bing.com/indexnow?url={}&key={}" -@receiver(post_save, sender=Article) +@receiver(post_save, sender=bloggy.models.Post) def post_saved_action_signal(sender, instance, created, **kwargs): # Update category count everytime three is a new object added if created: - print("Sendr:{}, kwargs:{}".format(sender, kwargs)) - management.call_command('update_category_count') + print(f"Sendr:{sender}, kwargs:{kwargs}") + django.core.management.call_command('update_category_count') if instance.publish_status == "PUBLISHED": if settings.PING_GOOGLE_POST_UPDATE: @@ -28,19 +30,21 @@ def post_saved_action_signal(sender, instance, created, **kwargs): def ping_google(): try: - params = urlencode({"sitemap": settings.SITE_URL + "/sitemap.xml"}) - response = request.urlopen("%s?%s" % (PING_GOOGLE_URL, params)) - if response.code == 200: - print("Successfully pinged this page for Google!") - except Exception: - print("Error while pinging google") + sitemap_url = f"{settings.SITE_URL}/sitemap.xml" + params = urllib.parse.urlencode({"sitemap": sitemap_url}) + + with request.urlopen(f"{PING_GOOGLE_URL}?{params}") as response: + if response.code == 200: + print("Successfully pinged this page for Google!") + except URLError as e: + print(f"Error while pinging Google: {e}") def ping_index_now(article): try: - response = request.urlopen( - INDEX_NOW.format(article.get_absolute_url(), settings.INDEX_NOW_API_KEY)) - if response.code == 200: - print("Successfully pinged this page for IndexNow!") - except Exception: - print("Error while pinging google") + url = INDEX_NOW.format(article.get_absolute_url(), settings.INDEX_NOW_API_KEY) + with request.urlopen(url) as response: + if response.code == 200: + print("Successfully pinged this page for IndexNow!") + except URLError as e: + print(f"Error while pinging IndexNow: {e}") diff --git a/bloggy/storage_backends.py b/bloggy/storage_backends.py index b123eaa..c6897b1 100644 --- a/bloggy/storage_backends.py +++ b/bloggy/storage_backends.py @@ -8,6 +8,7 @@ class StaticStorage(FileSystemStorage): class PublicMediaStorage(S3Boto3Storage): + location = 'media' default_acl = 'public-read' file_overwrite = False diff --git a/bloggy/templates/220764bdee4b4ff297c588217aaaafa3.txt b/bloggy/templates/220764bdee4b4ff297c588217aaaafa3.txt deleted file mode 100644 index dd75559..0000000 --- a/bloggy/templates/220764bdee4b4ff297c588217aaaafa3.txt +++ /dev/null @@ -1 +0,0 @@ -220764bdee4b4ff297c588217aaaafa3 \ No newline at end of file diff --git a/bloggy/templates/admin/base_site.html b/bloggy/templates/admin/base_site.html index ec04a68..adfd9f4 100644 --- a/bloggy/templates/admin/base_site.html +++ b/bloggy/templates/admin/base_site.html @@ -32,7 +32,7 @@

StackTips diff --git a/bloggy/templates/auth/register.html b/bloggy/templates/auth/register.html index 09df011..13f0f70 100644 --- a/bloggy/templates/auth/register.html +++ b/bloggy/templates/auth/register.html @@ -6,7 +6,8 @@

Create your free account!

- Register to get exclusive access to articles, live-demos, or courses, We aim to teach in the ways developers learn best. + Register to get exclusive access to articles, live-demos, or courses, We aim to teach in the ways + developers learn best.

Create your free account!

{{ form.name.errors }} -
{{ form.email|add_class:"form-control" }} @@ -39,15 +39,20 @@

Create your free account!

{{ form.password2|add_class:"form-control" }} {{ form.password2.errors }}
-
\ No newline at end of file diff --git a/bloggy/templates/pages/static/about.html b/bloggy/templates/pages/about.html similarity index 82% rename from bloggy/templates/pages/static/about.html rename to bloggy/templates/pages/about.html index 6f362b3..c5009ab 100644 --- a/bloggy/templates/pages/static/about.html +++ b/bloggy/templates/pages/about.html @@ -10,16 +10,7 @@

Hi there, we're STACKTIPS

-

We aim to provide developer-friendly ways to learn - programming. - With articles, - programming course, and quizzes, we aim to teach in the ways developers learn - best.

-

StackTips brings developer-friendly ways to learn - programming. - With - articles, programming - course, and quizzes, we aim to teach in the ways developers learn best.

+

StackTips brings developer-friendly ways to learn programming. With courses, articles, and how-to guides, we aim to teach in the ways developers learn best.

StackTips is created and maintained by Hi there, we're STACKTIPS<

Story so far..

-
diff --git a/bloggy/templates/pages/archive/categories.html b/bloggy/templates/pages/archive/categories.html index ba5e8a6..b225810 100644 --- a/bloggy/templates/pages/archive/categories.html +++ b/bloggy/templates/pages/archive/categories.html @@ -1,13 +1,11 @@ {% extends "base-with-header-footer.html" %} {% load static %} {% spaceless %} - {# {% block base_css_class %}bg-light{% endblock base_css_class %}#} {% block content %} -

@@ -19,8 +17,6 @@

journey towards being a better developer.

- -
@@ -40,10 +36,10 @@

{{ category.title }}

{{ category.article_count }} article{{ category.article_count | pluralize }}
- {% if category.logo %} + {% if category.thumbnail %}
{{ category.title }}
{% endif %}
- {% if category.description %} + {% if category.excerpt %} {{ category.description }} + style="line-clamp: 6;-webkit-line-clamp: 6">{{ category.excerpt }} {% endif %}
diff --git a/bloggy/templates/pages/archive/articles.html b/bloggy/templates/pages/archive/posts.html similarity index 80% rename from bloggy/templates/pages/archive/articles.html rename to bloggy/templates/pages/archive/posts.html index 59d6190..3854362 100644 --- a/bloggy/templates/pages/archive/articles.html +++ b/bloggy/templates/pages/archive/posts.html @@ -9,23 +9,23 @@
- {% categories_widget count=99 content_type="article" widgetStyle="list" %} + {% categories_widget count=99 content_type="post" widget_style="list" %}
- {% if articles|length <= 0 %} + {% if posts|length <= 0 %}

No contents found!

{% endif %} - {% for article in articles %} + {% for post in posts %}
- {% include "partials/article_row_grid.html" with article=article cssClass="card-rounded" %} + {% include "partials/post_list_item.html" with post=post cssClass="card-rounded" %}
{% if forloop.counter == 3 %} {% include "partials/course_row_grid.html" with courses=courses %} {% endif %} {% endfor %} - {% include "partials/paging.html" with posts=articles %} + {% include "partials/paging.html" with posts=posts %}
@@ -33,7 +33,6 @@
diff --git a/bloggy/templates/pages/archive/quizzes.html b/bloggy/templates/pages/archive/quizzes.html index 7f4e270..9b05255 100644 --- a/bloggy/templates/pages/archive/quizzes.html +++ b/bloggy/templates/pages/archive/quizzes.html @@ -15,14 +15,10 @@

- {% if posts|length <= 0 %} -

No contents found!

- {% endif %} -
- {% for quiz in posts %} + {% for quiz in quizzes %}
{% if quiz.thumbnail %} @@ -32,7 +28,7 @@

{{ quiz.title }} + class="quiz-link">{{ quiz.title }}

{{ quiz.excerpt }}

diff --git a/bloggy/templates/pages/authors.html b/bloggy/templates/pages/authors.html index 6c46d23..7bd3ddf 100644 --- a/bloggy/templates/pages/authors.html +++ b/bloggy/templates/pages/authors.html @@ -8,35 +8,34 @@

Our Authors

-
+
{% for author in authors %} -
-
+
+
{{ author.get_full_name }}
-

{{ author.get_full_name }}

+

{{ author.get_full_name }}

{% if author.bio %} -

{{ author.bio |striptags }}

+ {{ author.bio |striptags }} {% endif %}
- {% include 'partials/user_profile_social_media_links.html' with userProfile=author %} + {% include 'partials/user_profile_social_media_links.html' with user=author %}
{% endfor %} -
- +
{% endblock %} \ No newline at end of file diff --git a/bloggy/templates/pages/contact.html b/bloggy/templates/pages/contact.html new file mode 100644 index 0000000..816288f --- /dev/null +++ b/bloggy/templates/pages/contact.html @@ -0,0 +1,37 @@ +{% extends "base-with-header-footer.html" %} +{% load custom_widgets %} +{% load static %} +{% block content %} +
+
+ +
+
+

Get in touch

+ +
+

+ We are here to answer any questions you may have about this website experience or have + suggestions to improve the quality, let us know. +

+

+ Please note, we do not respond to questions on any specific tutorial or code error via + this + contact form. If you have any such questions, post your comment on the tutorial in the + comments + section. +

+ +
+ +
+
+ + +
+
+
+
+{% endblock content %} diff --git a/bloggy/templates/pages/home.html b/bloggy/templates/pages/home.html index 8e05579..f267d55 100644 --- a/bloggy/templates/pages/home.html +++ b/bloggy/templates/pages/home.html @@ -3,25 +3,70 @@ {% spaceless %} {% block content %} {% load static %} -
+

Developer Friendly Way to Learn Programming.

- StackTips brings developer-friendly ways to learn programming. With articles, programming - course, - and - quizzes, we aim to teach in the ways developers learn best. + StackTips brings developer-friendly ways to learn programming. With courses, articles, and + how-to guides, we aim to teach in the ways developers learn best.

- Create free account! + Create + free account!
+
+ +
+
+

Recent posts

+
+ {% if posts|length <= 0 %} +

No contents found!

+ {% endif %} + + {% for post in posts %} +
+ {% include "partials/post_list_item.html" with post=post cssClass="card-rounded" %} +
+ {% endfor %} +
+ +
+
- {% include "partials/section_home_recent_articles.html" %} - {% include "partials/section_home_courses.html" %} - {% include "partials/section_home_quizs.html" %} +
+ {% load static %} +
+

+ Browse by Categories +

+ {% categories_widget count=15 widget_style="home" %} +
+
+ +
+ {% load static %} +
+

+ Popular Courses +

+ +
+ {% if courses|length <= 0 %} +

No contents found!

+ {% endif %} + + {% for course in courses %} + {% include 'partials/course_grid_column.html' %} + {% endfor %} +
+
{% endblock content %} {% endspaceless %} \ No newline at end of file diff --git a/bloggy/templates/static-page.html b/bloggy/templates/pages/page.html similarity index 63% rename from bloggy/templates/static-page.html rename to bloggy/templates/pages/page.html index 4fd25b8..b3f6115 100644 --- a/bloggy/templates/static-page.html +++ b/bloggy/templates/pages/page.html @@ -1,6 +1,7 @@ {% extends "base-with-header-footer.html" %} {% load static %} {% load custom_widgets %} +{% load define_action %} {% block content %}
@@ -9,16 +10,13 @@
- {% block static_page_content %}{% endblock static_page_content %} +

{{ page.title }}

+ {% if page.excerpt %} +

{{ page.excerpt }}

+ {% endif %} + {{ page.content|expand_media_url }}
-
{% endblock %} \ No newline at end of file diff --git a/bloggy/templates/pages/search_result.html b/bloggy/templates/pages/search_result.html index f245fa0..82c1eb0 100644 --- a/bloggy/templates/pages/search_result.html +++ b/bloggy/templates/pages/search_result.html @@ -23,8 +23,6 @@

- -
{% for category in categories %} @@ -33,14 +31,14 @@

- {% if category.logo %} + {% if category.thumbnail %}

{{ category.title }}

- {{ category.article_count }} articles + {{ category.article_count }} posts
@@ -65,10 +63,34 @@

{{ category.title }}

- {% for article in articles %} - {% include 'partials/search_result_row.html' with post=article search_query=search_query %} + {% for post in posts %} +
+
+
+ + +

{{ post.title | highlight_search:search_query }}

+
+ {% if post.excerpt %} +

{{ post.excerpt |slice:"0:180" |safe }}

+ {% elif post.answer %} +

{{ post.answer |slice:"0:180" |striptags }}

+ {% elif post.description %} +

{{ post.description |slice:"0:200" |striptags }}

+ {% endif %} + {{ post.updated_date|date:"M j, Y" }} + · + {{ post.post_type | capfirst }} +
+
+ {% if post.thumbnail and post.is_featured %} + + {% endif %} +
+
+
{% endfor %} - {% include 'partials/paging.html' with posts=article %} + {% include 'partials/paging.html' with posts=post %}

- {{ article.title }}

diff --git a/bloggy/templates/partials/article_row_mini_grid.html b/bloggy/templates/partials/article_row_mini_grid.html index 4a6451e..6620e38 100644 --- a/bloggy/templates/partials/article_row_mini_grid.html +++ b/bloggy/templates/partials/article_row_mini_grid.html @@ -1,39 +1,37 @@ {% load static %} +{% load custom_widgets %} +{% load define_action %} {% spaceless %} - {% load custom_widgets %} - {% load define_action %} - -
+
- {% pretty_date article.updated_date %} + {% pretty_date post.updated_date %}
-

- {{ article.title }} +

+ {{ post.title }}

- -{# {% if article.video_id %}#} -{#
#} -{#
#} -{# #} -{#
#} -{#
#} -{# {% elif article.thumbnail %}#} -{#
#} -{#
#} -{# #} -{#
#} -{#
#} -{# {% endif %}#} + {% if post.video_id %} +
+
+ +
+
+ {% elif post.thumbnail %} +
+
+ +
+
+ {% endif %}
{% endspaceless %} \ No newline at end of file diff --git a/bloggy/templates/partials/author_widget.html b/bloggy/templates/partials/author_widget.html index d59410c..2dfc882 100644 --- a/bloggy/templates/partials/author_widget.html +++ b/bloggy/templates/partials/author_widget.html @@ -1,38 +1,33 @@ {% load static %} {% load custom_widgets %} -{% if author and author.username not in 'siteadmin' and author.username not in 'stacktips' %} -
-
-
-
- {{ author.username }} avtar +
+
+
+ {{ author.username }} avtar -
-
-
-

{{ author.get_full_name_or_username }}

-

{{ author.bio | safe }}

-
- {% include 'partials/user_profile_social_media_links.html' with userProfile=author %} +
+
+
+

{{ author.get_full_name_or_username }}

+

{{ author.bio | safe }}

+
+ {% include 'partials/user_profile_social_media_links.html' with user=author %} - {% if author.username %} - Read all articles → - {% endif %} -
-
+ {% if author.username %} + Read all articles → + {% endif %}
-{% endif %} - +
diff --git a/bloggy/templates/partials/category_archive_banner.html b/bloggy/templates/partials/category_archive_banner.html index bfe8dc7..a19285a 100644 --- a/bloggy/templates/partials/category_archive_banner.html +++ b/bloggy/templates/partials/category_archive_banner.html @@ -1,18 +1,18 @@ {% load static %} {% spaceless %} {% if category %} -
+
- {% if category.logo %} -
- {{ category.title }} + {% if category.thumbnail %} +
+ {{ category.title }}
{% endif %}

{{ category.title }}

- {% if category.description %}

{{ category.description }}

{% endif %} + {% if category.excerpt %}

{{ category.excerpt }}

{% endif %}
diff --git a/bloggy/templates/partials/course_grid_column.html b/bloggy/templates/partials/course_grid_column.html index 9930678..2214d8d 100644 --- a/bloggy/templates/partials/course_grid_column.html +++ b/bloggy/templates/partials/course_grid_column.html @@ -7,17 +7,13 @@

{{ course.title }} + class="quiz-link">{{ course.title }}

- {# {{ course.excerpt|slice:"0:100" }}#} - {{ course.excerpt }} - - + {{ course.excerpt|truncatechars:110 }} Start Course →
-