From 93dadc5dd56c489331f13263191e6c5dbb126727 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=BC=A0=E6=81=92=E7=A5=BA?= <1581408258@qq.com>
Date: Tue, 25 Nov 2025 02:56:52 +0800
Subject: [PATCH] =?UTF-8?q?=E6=96=87=E6=A1=A3=E6=8F=90=E4=BA=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.coveragerc | 10 +
.dockerignore | 11 +
.gitattributes | 6 +
.github/ISSUE_TEMPLATE.md | 18 +
.github/workflows/codeql-analysis.yml | 47 ++
.github/workflows/django.yml | 136 ++++
.github/workflows/docker.yml | 43 ++
.github/workflows/publish-release.yml | 39 +
.gitignore | 80 ++
Dockerfile | 15 +
LICENSE | 20 +
README.md | 158 ++++
accounts/__init__.py | 0
accounts/admin.py | 59 ++
accounts/apps.py | 5 +
accounts/forms.py | 117 +++
accounts/migrations/0001_initial.py | 49 ++
...s_remove_bloguser_created_time_and_more.py | 46 ++
accounts/migrations/__init__.py | 0
accounts/models.py | 35 +
accounts/templatetags/__init__.py | 0
accounts/tests.py | 207 ++++++
accounts/urls.py | 28 +
accounts/user_login_backend.py | 26 +
accounts/utils.py | 49 ++
accounts/views.py | 204 ++++++
blog/__init__.py | 0
blog/admin.py | 112 +++
blog/apps.py | 5 +
blog/context_processors.py | 43 ++
blog/documents.py | 213 ++++++
blog/forms.py | 19 +
blog/management/__init__.py | 0
blog/management/commands/__init__.py | 0
blog/management/commands/build_index.py | 18 +
.../management/commands/build_search_words.py | 13 +
blog/management/commands/clear_cache.py | 11 +
blog/management/commands/create_testdata.py | 40 +
blog/management/commands/ping_baidu.py | 50 ++
blog/management/commands/sync_user_avatar.py | 47 ++
blog/middleware.py | 42 ++
blog/migrations/0001_initial.py | 137 ++++
...002_blogsettings_global_footer_and_more.py | 23 +
.../0003_blogsettings_comment_need_review.py | 17 +
...de_blogsettings_analytics_code_and_more.py | 27 +
...options_alter_category_options_and_more.py | 300 ++++++++
.../0006_alter_blogsettings_options.py | 17 +
blog/migrations/__init__.py | 0
blog/models.py | 376 ++++++++++
blog/search_indexes.py | 13 +
blog/templatetags/__init__.py | 0
blog/templatetags/blog_tags.py | 344 +++++++++
blog/tests.py | 232 ++++++
blog/urls.py | 62 ++
blog/views.py | 379 ++++++++++
comments/__init__.py | 0
comments/admin.py | 47 ++
comments/apps.py | 5 +
comments/forms.py | 13 +
comments/migrations/0001_initial.py | 38 +
.../0002_alter_comment_is_enable.py | 18 +
...ns_remove_comment_created_time_and_more.py | 60 ++
comments/migrations/__init__.py | 0
comments/models.py | 39 +
comments/templatetags/__init__.py | 0
comments/templatetags/comments_tags.py | 30 +
comments/tests.py | 109 +++
comments/urls.py | 11 +
comments/utils.py | 38 +
comments/views.py | 63 ++
deploy/docker-compose/docker-compose.es.yml | 48 ++
deploy/docker-compose/docker-compose.yml | 60 ++
deploy/entrypoint.sh | 31 +
deploy/k8s/configmap.yaml | 119 +++
deploy/k8s/deployment.yaml | 274 +++++++
deploy/k8s/gateway.yaml | 17 +
deploy/k8s/pv.yaml | 94 +++
deploy/k8s/pvc.yaml | 60 ++
deploy/k8s/service.yaml | 80 ++
deploy/k8s/storageclass.yaml | 10 +
deploy/nginx.conf | 50 ++
...泛读、标注和维护报告文档.docx | Bin 0 -> 2875664 bytes
docs/README-en.md | 158 ++++
docs/config-en.md | 64 ++
docs/config.md | 58 ++
docs/docker-en.md | 114 +++
docs/docker.md | 114 +++
docs/es.md | 28 +
docs/imgs/alipay.jpg | Bin 0 -> 17961 bytes
docs/imgs/pycharm_logo.png | Bin 0 -> 132045 bytes
docs/imgs/wechat.jpg | Bin 0 -> 24722 bytes
docs/k8s-en.md | 141 ++++
docs/k8s.md | 141 ++++
locale/en/LC_MESSAGES/django.mo | Bin 0 -> 11097 bytes
locale/en/LC_MESSAGES/django.po | 685 ++++++++++++++++++
locale/zh_Hans/LC_MESSAGES/django.mo | Bin 0 -> 10321 bytes
locale/zh_Hans/LC_MESSAGES/django.po | 667 +++++++++++++++++
locale/zh_Hant/LC_MESSAGES/django.mo | Bin 0 -> 10268 bytes
locale/zh_Hant/LC_MESSAGES/django.po | 668 +++++++++++++++++
manage.py | 22 +
oauth/__init__.py | 0
oauth/admin.py | 54 ++
oauth/apps.py | 5 +
oauth/forms.py | 12 +
oauth/migrations/0001_initial.py | 57 ++
...ptions_alter_oauthuser_options_and_more.py | 86 +++
.../0003_alter_oauthuser_nickname.py | 18 +
oauth/migrations/__init__.py | 0
oauth/models.py | 67 ++
oauth/oauthmanager.py | 504 +++++++++++++
oauth/templatetags/__init__.py | 1 +
oauth/templatetags/oauth_tags.py | 22 +
oauth/tests.py | 249 +++++++
oauth/urls.py | 25 +
oauth/views.py | 253 +++++++
owntracks/__init__.py | 0
owntracks/admin.py | 7 +
owntracks/apps.py | 5 +
owntracks/migrations/0001_initial.py | 31 +
...0002_alter_owntracklog_options_and_more.py | 22 +
owntracks/migrations/__init__.py | 0
owntracks/models.py | 20 +
owntracks/tests.py | 64 ++
owntracks/urls.py | 12 +
owntracks/views.py | 127 ++++
plugins/__init__.py | 1 +
plugins/article_copyright/__init__.py | 1 +
plugins/article_copyright/plugin.py | 32 +
plugins/external_links/__init__.py | 1 +
plugins/external_links/plugin.py | 48 ++
plugins/reading_time/__init__.py | 1 +
plugins/reading_time/plugin.py | 43 ++
plugins/seo_optimizer/__init__.py | 1 +
plugins/seo_optimizer/plugin.py | 142 ++++
plugins/view_count/__init__.py | 1 +
plugins/view_count/plugin.py | 18 +
requirements.txt | Bin 0 -> 2554 bytes
servermanager/MemcacheStorage.py | 32 +
servermanager/__init__.py | 0
servermanager/admin.py | 19 +
servermanager/api/__init__.py | 1 +
servermanager/api/blogapi.py | 27 +
servermanager/api/commonapi.py | 64 ++
servermanager/apps.py | 5 +
servermanager/migrations/0001_initial.py | 45 ++
...002_alter_emailsendlog_options_and_more.py | 32 +
servermanager/migrations/__init__.py | 0
servermanager/models.py | 33 +
servermanager/robot.py | 187 +++++
servermanager/tests.py | 79 ++
servermanager/urls.py | 10 +
servermanager/views.py | 1 +
src/README.md | 158 ++++
templates/account/forget_password.html | 30 +
templates/account/login.html | 46 ++
templates/account/registration_form.html | 29 +
templates/account/result.html | 27 +
templates/blog/article_archives.html | 60 ++
templates/blog/article_detail.html | 52 ++
templates/blog/article_index.html | 42 ++
templates/blog/error_page.html | 45 ++
templates/blog/links_list.html | 44 ++
templates/blog/tags/article_info.html | 74 ++
templates/blog/tags/article_meta_info.html | 59 ++
templates/blog/tags/article_pagination.html | 17 +
templates/blog/tags/article_tag_list.html | 19 +
templates/blog/tags/breadcrumb.html | 19 +
templates/blog/tags/sidebar.html | 136 ++++
templates/comments/tags/comment_item.html | 34 +
.../comments/tags/comment_item_tree.html | 54 ++
templates/comments/tags/comment_list.html | 45 ++
templates/comments/tags/post_comment.html | 33 +
templates/oauth/bindsuccess.html | 22 +
templates/oauth/oauth_applications.html | 13 +
templates/oauth/require_email.html | 46 ++
templates/owntracks/show_log_dates.html | 17 +
templates/owntracks/show_maps.html | 135 ++++
.../search/indexes/blog/article_text.txt | 3 +
templates/search/search.html | 66 ++
templates/share_layout/adsense.html | 6 +
templates/share_layout/base.html | 123 ++++
templates/share_layout/base_account.html | 47 ++
templates/share_layout/footer.html | 56 ++
templates/share_layout/nav.html | 30 +
templates/share_layout/nav_node.html | 19 +
185 files changed, 12193 insertions(+)
create mode 100644 .coveragerc
create mode 100644 .dockerignore
create mode 100644 .gitattributes
create mode 100644 .github/ISSUE_TEMPLATE.md
create mode 100644 .github/workflows/codeql-analysis.yml
create mode 100644 .github/workflows/django.yml
create mode 100644 .github/workflows/docker.yml
create mode 100644 .github/workflows/publish-release.yml
create mode 100644 .gitignore
create mode 100644 Dockerfile
create mode 100644 LICENSE
create mode 100644 README.md
create mode 100644 accounts/__init__.py
create mode 100644 accounts/admin.py
create mode 100644 accounts/apps.py
create mode 100644 accounts/forms.py
create mode 100644 accounts/migrations/0001_initial.py
create mode 100644 accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py
create mode 100644 accounts/migrations/__init__.py
create mode 100644 accounts/models.py
create mode 100644 accounts/templatetags/__init__.py
create mode 100644 accounts/tests.py
create mode 100644 accounts/urls.py
create mode 100644 accounts/user_login_backend.py
create mode 100644 accounts/utils.py
create mode 100644 accounts/views.py
create mode 100644 blog/__init__.py
create mode 100644 blog/admin.py
create mode 100644 blog/apps.py
create mode 100644 blog/context_processors.py
create mode 100644 blog/documents.py
create mode 100644 blog/forms.py
create mode 100644 blog/management/__init__.py
create mode 100644 blog/management/commands/__init__.py
create mode 100644 blog/management/commands/build_index.py
create mode 100644 blog/management/commands/build_search_words.py
create mode 100644 blog/management/commands/clear_cache.py
create mode 100644 blog/management/commands/create_testdata.py
create mode 100644 blog/management/commands/ping_baidu.py
create mode 100644 blog/management/commands/sync_user_avatar.py
create mode 100644 blog/middleware.py
create mode 100644 blog/migrations/0001_initial.py
create mode 100644 blog/migrations/0002_blogsettings_global_footer_and_more.py
create mode 100644 blog/migrations/0003_blogsettings_comment_need_review.py
create mode 100644 blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py
create mode 100644 blog/migrations/0005_alter_article_options_alter_category_options_and_more.py
create mode 100644 blog/migrations/0006_alter_blogsettings_options.py
create mode 100644 blog/migrations/__init__.py
create mode 100644 blog/models.py
create mode 100644 blog/search_indexes.py
create mode 100644 blog/templatetags/__init__.py
create mode 100644 blog/templatetags/blog_tags.py
create mode 100644 blog/tests.py
create mode 100644 blog/urls.py
create mode 100644 blog/views.py
create mode 100644 comments/__init__.py
create mode 100644 comments/admin.py
create mode 100644 comments/apps.py
create mode 100644 comments/forms.py
create mode 100644 comments/migrations/0001_initial.py
create mode 100644 comments/migrations/0002_alter_comment_is_enable.py
create mode 100644 comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py
create mode 100644 comments/migrations/__init__.py
create mode 100644 comments/models.py
create mode 100644 comments/templatetags/__init__.py
create mode 100644 comments/templatetags/comments_tags.py
create mode 100644 comments/tests.py
create mode 100644 comments/urls.py
create mode 100644 comments/utils.py
create mode 100644 comments/views.py
create mode 100644 deploy/docker-compose/docker-compose.es.yml
create mode 100644 deploy/docker-compose/docker-compose.yml
create mode 100644 deploy/entrypoint.sh
create mode 100644 deploy/k8s/configmap.yaml
create mode 100644 deploy/k8s/deployment.yaml
create mode 100644 deploy/k8s/gateway.yaml
create mode 100644 deploy/k8s/pv.yaml
create mode 100644 deploy/k8s/pvc.yaml
create mode 100644 deploy/k8s/service.yaml
create mode 100644 deploy/k8s/storageclass.yaml
create mode 100644 deploy/nginx.conf
create mode 100644 doc/19组开源软件泛读、标注和维护报告文档.docx
create mode 100644 docs/README-en.md
create mode 100644 docs/config-en.md
create mode 100644 docs/config.md
create mode 100644 docs/docker-en.md
create mode 100644 docs/docker.md
create mode 100644 docs/es.md
create mode 100644 docs/imgs/alipay.jpg
create mode 100644 docs/imgs/pycharm_logo.png
create mode 100644 docs/imgs/wechat.jpg
create mode 100644 docs/k8s-en.md
create mode 100644 docs/k8s.md
create mode 100644 locale/en/LC_MESSAGES/django.mo
create mode 100644 locale/en/LC_MESSAGES/django.po
create mode 100644 locale/zh_Hans/LC_MESSAGES/django.mo
create mode 100644 locale/zh_Hans/LC_MESSAGES/django.po
create mode 100644 locale/zh_Hant/LC_MESSAGES/django.mo
create mode 100644 locale/zh_Hant/LC_MESSAGES/django.po
create mode 100644 manage.py
create mode 100644 oauth/__init__.py
create mode 100644 oauth/admin.py
create mode 100644 oauth/apps.py
create mode 100644 oauth/forms.py
create mode 100644 oauth/migrations/0001_initial.py
create mode 100644 oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py
create mode 100644 oauth/migrations/0003_alter_oauthuser_nickname.py
create mode 100644 oauth/migrations/__init__.py
create mode 100644 oauth/models.py
create mode 100644 oauth/oauthmanager.py
create mode 100644 oauth/templatetags/__init__.py
create mode 100644 oauth/templatetags/oauth_tags.py
create mode 100644 oauth/tests.py
create mode 100644 oauth/urls.py
create mode 100644 oauth/views.py
create mode 100644 owntracks/__init__.py
create mode 100644 owntracks/admin.py
create mode 100644 owntracks/apps.py
create mode 100644 owntracks/migrations/0001_initial.py
create mode 100644 owntracks/migrations/0002_alter_owntracklog_options_and_more.py
create mode 100644 owntracks/migrations/__init__.py
create mode 100644 owntracks/models.py
create mode 100644 owntracks/tests.py
create mode 100644 owntracks/urls.py
create mode 100644 owntracks/views.py
create mode 100644 plugins/__init__.py
create mode 100644 plugins/article_copyright/__init__.py
create mode 100644 plugins/article_copyright/plugin.py
create mode 100644 plugins/external_links/__init__.py
create mode 100644 plugins/external_links/plugin.py
create mode 100644 plugins/reading_time/__init__.py
create mode 100644 plugins/reading_time/plugin.py
create mode 100644 plugins/seo_optimizer/__init__.py
create mode 100644 plugins/seo_optimizer/plugin.py
create mode 100644 plugins/view_count/__init__.py
create mode 100644 plugins/view_count/plugin.py
create mode 100644 requirements.txt
create mode 100644 servermanager/MemcacheStorage.py
create mode 100644 servermanager/__init__.py
create mode 100644 servermanager/admin.py
create mode 100644 servermanager/api/__init__.py
create mode 100644 servermanager/api/blogapi.py
create mode 100644 servermanager/api/commonapi.py
create mode 100644 servermanager/apps.py
create mode 100644 servermanager/migrations/0001_initial.py
create mode 100644 servermanager/migrations/0002_alter_emailsendlog_options_and_more.py
create mode 100644 servermanager/migrations/__init__.py
create mode 100644 servermanager/models.py
create mode 100644 servermanager/robot.py
create mode 100644 servermanager/tests.py
create mode 100644 servermanager/urls.py
create mode 100644 servermanager/views.py
create mode 100644 src/README.md
create mode 100644 templates/account/forget_password.html
create mode 100644 templates/account/login.html
create mode 100644 templates/account/registration_form.html
create mode 100644 templates/account/result.html
create mode 100644 templates/blog/article_archives.html
create mode 100644 templates/blog/article_detail.html
create mode 100644 templates/blog/article_index.html
create mode 100644 templates/blog/error_page.html
create mode 100644 templates/blog/links_list.html
create mode 100644 templates/blog/tags/article_info.html
create mode 100644 templates/blog/tags/article_meta_info.html
create mode 100644 templates/blog/tags/article_pagination.html
create mode 100644 templates/blog/tags/article_tag_list.html
create mode 100644 templates/blog/tags/breadcrumb.html
create mode 100644 templates/blog/tags/sidebar.html
create mode 100644 templates/comments/tags/comment_item.html
create mode 100644 templates/comments/tags/comment_item_tree.html
create mode 100644 templates/comments/tags/comment_list.html
create mode 100644 templates/comments/tags/post_comment.html
create mode 100644 templates/oauth/bindsuccess.html
create mode 100644 templates/oauth/oauth_applications.html
create mode 100644 templates/oauth/require_email.html
create mode 100644 templates/owntracks/show_log_dates.html
create mode 100644 templates/owntracks/show_maps.html
create mode 100644 templates/search/indexes/blog/article_text.txt
create mode 100644 templates/search/search.html
create mode 100644 templates/share_layout/adsense.html
create mode 100644 templates/share_layout/base.html
create mode 100644 templates/share_layout/base_account.html
create mode 100644 templates/share_layout/footer.html
create mode 100644 templates/share_layout/nav.html
create mode 100644 templates/share_layout/nav_node.html
diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 0000000..9757484
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,10 @@
+[run]
+source = .
+include = *.py
+omit =
+ *migrations*
+ *tests*
+ *.html
+ *whoosh_cn_backend*
+ *settings.py*
+ *venv*
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..2818c38
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,11 @@
+bin/data/
+# virtualenv
+venv/
+collectedstatic/
+djangoblog/whoosh_index/
+uploads/
+settings_production.py
+*.md
+docs/
+logs/
+static/
\ No newline at end of file
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..fd52ece
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,6 @@
+blog/static/* linguist-vendored
+*.js linguist-vendored
+*.css linguist-vendored
+* text=auto
+*.sh text eol=lf
+*.conf text eol=lf
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md
new file mode 100644
index 0000000..2b5b7aa
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE.md
@@ -0,0 +1,18 @@
+
+
+**我确定我已经查看了** (标注`[ ]`为`[x]`)
+
+- [ ] [DjangoBlog的readme](https://github.com/liangliangyy/DjangoBlog/blob/master/README.md)
+- [ ] [配置说明](https://github.com/liangliangyy/DjangoBlog/blob/master/bin/config.md)
+- [ ] [其他 Issues](https://github.com/liangliangyy/DjangoBlog/issues)
+
+----
+
+**我要申请** (标注`[ ]`为`[x]`)
+
+- [ ] BUG 反馈
+- [ ] 添加新的特性或者功能
+- [ ] 请求技术支持
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 0000000..6b76522
--- /dev/null
+++ b/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,47 @@
+name: "CodeQL"
+
+on:
+ push:
+ branches:
+ - master
+ - dev
+ paths-ignore:
+ - '**/*.md'
+ - '**/*.css'
+ - '**/*.js'
+ - '**/*.yml'
+ - '**/*.txt'
+ pull_request:
+ branches:
+ - master
+ - dev
+ paths-ignore:
+ - '**/*.md'
+ - '**/*.css'
+ - '**/*.js'
+ - '**/*.yml'
+ - '**/*.txt'
+ schedule:
+ - cron: '30 1 * * 0'
+
+
+jobs:
+ CodeQL-Build:
+ runs-on: ubuntu-latest
+ permissions:
+ security-events: write
+ actions: read
+ contents: read
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v3
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v2
+
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v2
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v2
\ No newline at end of file
diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml
new file mode 100644
index 0000000..94baea9
--- /dev/null
+++ b/.github/workflows/django.yml
@@ -0,0 +1,136 @@
+name: Django CI
+
+on:
+ push:
+ branches:
+ - master
+ - dev
+ paths-ignore:
+ - '**/*.md'
+ - '**/*.css'
+ - '**/*.js'
+ pull_request:
+ branches:
+ - master
+ - dev
+ paths-ignore:
+ - '**/*.md'
+ - '**/*.css'
+ - '**/*.js'
+
+jobs:
+ build-normal:
+ runs-on: ubuntu-latest
+ strategy:
+ max-parallel: 4
+ matrix:
+ python-version: ["3.10","3.11" ]
+
+ steps:
+ - name: Start MySQL
+ uses: samin/mysql-action@v1.3
+ with:
+ host port: 3306
+ container port: 3306
+ character set server: utf8mb4
+ collation server: utf8mb4_general_ci
+ mysql version: latest
+ mysql root password: root
+ mysql database: djangoblog
+ mysql user: root
+ mysql password: root
+
+ - uses: actions/checkout@v3
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v4
+ with:
+ python-version: ${{ matrix.python-version }}
+ cache: 'pip'
+ - name: Install Dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r requirements.txt
+ - name: Run Tests
+ env:
+ DJANGO_MYSQL_PASSWORD: root
+ DJANGO_MYSQL_HOST: 127.0.0.1
+ run: |
+ python manage.py makemigrations
+ python manage.py migrate
+ python manage.py test
+
+ build-with-es:
+ runs-on: ubuntu-latest
+ strategy:
+ max-parallel: 4
+ matrix:
+ python-version: ["3.10","3.11" ]
+
+ steps:
+ - name: Start MySQL
+ uses: samin/mysql-action@v1.3
+ with:
+ host port: 3306
+ container port: 3306
+ character set server: utf8mb4
+ collation server: utf8mb4_general_ci
+ mysql version: latest
+ mysql root password: root
+ mysql database: djangoblog
+ mysql user: root
+ mysql password: root
+
+ - name: Configure sysctl limits
+ run: |
+ sudo swapoff -a
+ sudo sysctl -w vm.swappiness=1
+ sudo sysctl -w fs.file-max=262144
+ sudo sysctl -w vm.max_map_count=262144
+
+ - uses: miyataka/elasticsearch-github-actions@1
+
+ with:
+ stack-version: '7.12.1'
+ plugins: 'https://release.infinilabs.com/analysis-ik/stable/elasticsearch-analysis-ik-7.12.1.zip'
+
+
+ - uses: actions/checkout@v3
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v4
+ with:
+ python-version: ${{ matrix.python-version }}
+ cache: 'pip'
+ - name: Install Dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r requirements.txt
+ - name: Run Tests
+ env:
+ DJANGO_MYSQL_PASSWORD: root
+ DJANGO_MYSQL_HOST: 127.0.0.1
+ DJANGO_ELASTICSEARCH_HOST: 127.0.0.1:9200
+ run: |
+ python manage.py makemigrations
+ python manage.py migrate
+ coverage run manage.py test
+ coverage xml
+
+ - name: Upload coverage to Codecov
+ uses: codecov/codecov-action@v1
+
+ docker:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v2
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v2
+
+ - name: Build and push
+ uses: docker/build-push-action@v3
+ with:
+ context: .
+ push: false
+ tags: djangoblog/djangoblog:dev
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
new file mode 100644
index 0000000..a312e2f
--- /dev/null
+++ b/.github/workflows/docker.yml
@@ -0,0 +1,43 @@
+name: docker
+
+on:
+ push:
+ paths-ignore:
+ - '**/*.md'
+ - '**/*.yml'
+ branches:
+ - 'master'
+ - 'dev'
+
+jobs:
+ docker:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Set env to docker dev tag
+ if: endsWith(github.ref, '/dev')
+ run: |
+ echo "DOCKER_TAG=test" >> $GITHUB_ENV
+ - name: Set env to docker latest tag
+ if: endsWith(github.ref, '/master')
+ run: |
+ echo "DOCKER_TAG=latest" >> $GITHUB_ENV
+ - name: Checkout
+ uses: actions/checkout@v3
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v2
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v2
+
+ - name: Login to DockerHub
+ uses: docker/login-action@v2
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Build and push
+ uses: docker/build-push-action@v3
+ with:
+ context: .
+ push: true
+ tags: ${{ secrets.DOCKERHUB_USERNAME }}/djangoblog:${{env.DOCKER_TAG}}
+
+
diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml
new file mode 100644
index 0000000..5eb0853
--- /dev/null
+++ b/.github/workflows/publish-release.yml
@@ -0,0 +1,39 @@
+name: publish release
+
+on:
+ release:
+ types: [ published ]
+
+jobs:
+ docker:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - name: Docker meta
+ id: meta
+ uses: docker/metadata-action@v3
+ with:
+ images: name/app
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v2
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v2
+ - name: Login to DockerHub
+ uses: docker/login-action@v2
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Build and push
+ uses: docker/build-push-action@v3
+ with:
+ context: .
+ push: true
+ platforms: |
+ linux/amd64
+ linux/arm64
+ linux/arm/v7
+ linux/arm/v6
+ linux/386
+ tags: ${{ secrets.DOCKERHUB_USERNAME }}/djangoblog:${{ github.event.release.tag_name }}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3015816
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,80 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+env/
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*,cover
+
+# Translations
+*.pot
+
+# Django stuff:
+*.log
+logs/
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+
+# PyCharm
+# http://www.jetbrains.com/pycharm/webhelp/project.html
+.idea
+.iml
+static/
+# virtualenv
+venv/
+
+collectedstatic/
+djangoblog/whoosh_index/
+google93fd32dbd906620a.html
+baidu_verify_FlHL7cUyC9.html
+BingSiteAuth.xml
+cb9339dbe2ff86a5aa169d28dba5f615.txt
+werobot_session.*
+django.jpg
+uploads/
+settings_production.py
+werobot_session.db
+bin/datas/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..80b46ac
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,15 @@
+FROM python:3.11
+ENV PYTHONUNBUFFERED 1
+WORKDIR /code/djangoblog/
+RUN apt-get update && \
+ apt-get install default-libmysqlclient-dev gettext -y && \
+ rm -rf /var/lib/apt/lists/*
+ADD requirements.txt requirements.txt
+RUN pip install --upgrade pip && \
+ pip install --no-cache-dir -r requirements.txt && \
+ pip install --no-cache-dir gunicorn[gevent] && \
+ pip cache purge
+
+ADD . .
+RUN chmod +x /code/djangoblog/deploy/entrypoint.sh
+ENTRYPOINT ["/code/djangoblog/deploy/entrypoint.sh"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..3b08474
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,20 @@
+The MIT License (MIT)
+
+Copyright (c) 2025 车亮亮
+
+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.
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..56aa4cc
--- /dev/null
+++ b/README.md
@@ -0,0 +1,158 @@
+# DjangoBlog
+
+
+
+
+
+
+
+
+
+ 一款功能强大、设计优雅的现代化博客系统
+
+ English • 简体中文
+
+
+---
+
+DjangoBlog 是一款基于 Python 3.10 和 Django 4.0 构建的高性能博客平台。它不仅提供了传统博客的所有核心功能,还通过一个灵活的插件系统,让您可以轻松扩展和定制您的网站。无论您是个人博主、技术爱好者还是内容创作者,DjangoBlog 都旨在为您提供一个稳定、高效且易于维护的写作和发布环境。
+
+## ✨ 特性亮点
+
+- **强大的内容管理**: 支持文章、独立页面、分类和标签的完整管理。内置强大的 Markdown 编辑器,支持代码语法高亮。
+- **全文搜索**: 集成搜索引擎,提供快速、精准的文章内容搜索。
+- **互动评论系统**: 支持回复、邮件提醒等功能,评论内容同样支持 Markdown。
+- **灵活的侧边栏**: 可自定义展示最新文章、最多阅读、标签云等模块。
+- **社交化登录**: 内置 OAuth 支持,已集成 Google, GitHub, Facebook, 微博, QQ 等主流平台。
+- **高性能缓存**: 原生支持 Redis 缓存,并提供自动刷新机制,确保网站高速响应。
+- **SEO 友好**: 具备基础 SEO 功能,新内容发布后可自动通知 Google 和百度。
+- **便捷的插件系统**: 通过创建独立的插件来扩展博客功能,代码解耦,易于维护。我们已经通过插件实现了文章浏览计数、SEO 优化等功能!
+- **集成图床**: 内置简单的图床功能,方便图片上传和管理。
+- **自动化前端**: 集成 `django-compressor`,自动压缩和优化 CSS 及 JavaScript 文件。
+- **健壮的运维**: 内置网站异常邮件提醒和微信公众号管理功能。
+
+## 🛠️ 技术栈
+
+- **后端**: Python 3.10, Django 4.0
+- **数据库**: MySQL, SQLite (可配置)
+- **缓存**: Redis
+- **前端**: HTML5, CSS3, JavaScript
+- **搜索**: Whoosh, Elasticsearch (可配置)
+- **编辑器**: Markdown (mdeditor)
+
+## 🚀 快速开始
+
+### 1. 环境准备
+
+确保您的系统中已安装 Python 3.10+ 和 MySQL/MariaDB。
+
+### 2. 克隆与安装
+
+```bash
+# 克隆项目到本地
+git clone https://github.com/liangliangyy/DjangoBlog.git
+cd DjangoBlog
+
+# 安装依赖
+pip install -r requirements.txt
+```
+
+### 3. 项目配置
+
+- **数据库**:
+ 打开 `djangoblog/settings.py` 文件,找到 `DATABASES` 配置项,修改为您的 MySQL 连接信息。
+
+ ```python
+ DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.mysql',
+ 'NAME': 'djangoblog',
+ 'USER': 'root',
+ 'PASSWORD': 'your_password',
+ 'HOST': '127.0.0.1',
+ 'PORT': 3306,
+ }
+ }
+ ```
+ 在 MySQL 中创建数据库:
+ ```sql
+ CREATE DATABASE `djangoblog` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+ ```
+
+- **更多配置**:
+ 关于邮件发送、OAuth 登录、缓存等更多高级配置,请参阅我们的 [详细配置文档](/docs/config.md)。
+
+### 4. 初始化数据库
+
+```bash
+python manage.py makemigrations
+python manage.py migrate
+
+# 创建一个超级管理员账户
+python manage.py createsuperuser
+```
+
+### 5. 运行项目
+
+```bash
+# (可选) 生成一些测试数据
+python manage.py create_testdata
+
+# (可选) 收集和压缩静态文件
+python manage.py collectstatic --noinput
+python manage.py compress --force
+
+# 启动开发服务器
+python manage.py runserver
+```
+
+现在,在您的浏览器中访问 `http://127.0.0.1:8000/`,您应该能看到 DjangoBlog 的首页了!
+
+## 部署
+
+- **传统部署**: 我们为您准备了非常详细的 [服务器部署教程](https://www.lylinux.net/article/2019/8/5/58.html)。
+- **Docker 部署**: 项目已全面支持 Docker。如果您熟悉容器化技术,请参考 [Docker 部署文档](/docs/docker.md) 来快速启动。
+- **Kubernetes 部署**: 我们也提供了完整的 [Kubernetes 部署指南](/docs/k8s.md),助您轻松上云。
+
+## 🧩 插件系统
+
+插件系统是 DjangoBlog 的核心特色之一。它允许您在不修改核心代码的情况下,通过编写独立的插件来为您的博客添加新功能。
+
+- **工作原理**: 插件通过在预定义的“钩子”上注册回调函数来工作。例如,当一篇文章被渲染时,`after_article_body_get` 钩子会被触发,所有注册到此钩子的函数都会被执行。
+- **现有插件**: `view_count`(浏览计数), `seo_optimizer`(SEO优化)等都是通过插件系统实现的。
+- **开发您自己的插件**: 只需在 `plugins` 目录下创建一个新的文件夹,并编写您的 `plugin.py`。欢迎探索并为 DjangoBlog 社区贡献您的创意!
+
+## 🤝 贡献指南
+
+我们热烈欢迎任何形式的贡献!如果您有好的想法或发现了 Bug,请随时提交 Issue 或 Pull Request。
+
+## 📄 许可证
+
+本项目基于 [MIT License](LICENSE) 开源。
+
+---
+
+## ❤️ 支持与赞助
+
+如果您觉得这个项目对您有帮助,并且希望支持我继续维护和开发新功能,欢迎请我喝杯咖啡!您的每一份支持都是我前进的最大动力。
+
+
+
+
+
+
+ (左) 支付宝 / (右) 微信
+
+
+## 🙏 鸣谢
+
+特别感谢 **JetBrains** 为本项目提供的免费开源许可证。
+
+
+
+
+
+
+
+---
+> 如果本项目帮助到了你,请在[这里](https://github.com/liangliangyy/DjangoBlog/issues/214)留下你的网址,让更多的人看到。您的回复将会是我继续更新维护下去的动力。
diff --git a/accounts/__init__.py b/accounts/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/accounts/admin.py b/accounts/admin.py
new file mode 100644
index 0000000..32e483c
--- /dev/null
+++ b/accounts/admin.py
@@ -0,0 +1,59 @@
+from django import forms
+from django.contrib.auth.admin import UserAdmin
+from django.contrib.auth.forms import UserChangeForm
+from django.contrib.auth.forms import UsernameField
+from django.utils.translation import gettext_lazy as _
+
+# Register your models here.
+from .models import BlogUser
+
+
+class BlogUserCreationForm(forms.ModelForm):
+ password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput)
+ password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput)
+
+ class Meta:
+ model = BlogUser
+ fields = ('email',)
+
+ def clean_password2(self):
+ # Check that the two password entries match
+ password1 = self.cleaned_data.get("password1")
+ password2 = self.cleaned_data.get("password2")
+ if password1 and password2 and password1 != password2:
+ raise forms.ValidationError(_("passwords do not match"))
+ return password2
+
+ def save(self, commit=True):
+ # Save the provided password in hashed format
+ user = super().save(commit=False)
+ user.set_password(self.cleaned_data["password1"])
+ if commit:
+ user.source = 'adminsite'
+ user.save()
+ return user
+
+
+class BlogUserChangeForm(UserChangeForm):
+ class Meta:
+ model = BlogUser
+ fields = '__all__'
+ field_classes = {'username': UsernameField}
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+
+class BlogUserAdmin(UserAdmin):
+ form = BlogUserChangeForm
+ add_form = BlogUserCreationForm
+ list_display = (
+ 'id',
+ 'nickname',
+ 'username',
+ 'email',
+ 'last_login',
+ 'date_joined',
+ 'source')
+ list_display_links = ('id', 'username')
+ ordering = ('-id',)
diff --git a/accounts/apps.py b/accounts/apps.py
new file mode 100644
index 0000000..9b3fc5a
--- /dev/null
+++ b/accounts/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class AccountsConfig(AppConfig):
+ name = 'accounts'
diff --git a/accounts/forms.py b/accounts/forms.py
new file mode 100644
index 0000000..fce4137
--- /dev/null
+++ b/accounts/forms.py
@@ -0,0 +1,117 @@
+from django import forms
+from django.contrib.auth import get_user_model, password_validation
+from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
+from django.core.exceptions import ValidationError
+from django.forms import widgets
+from django.utils.translation import gettext_lazy as _
+from . import utils
+from .models import BlogUser
+
+
+class LoginForm(AuthenticationForm):
+ def __init__(self, *args, **kwargs):
+ super(LoginForm, self).__init__(*args, **kwargs)
+ self.fields['username'].widget = widgets.TextInput(
+ attrs={'placeholder': "username", "class": "form-control"})
+ self.fields['password'].widget = widgets.PasswordInput(
+ attrs={'placeholder': "password", "class": "form-control"})
+
+
+class RegisterForm(UserCreationForm):
+ def __init__(self, *args, **kwargs):
+ super(RegisterForm, self).__init__(*args, **kwargs)
+
+ self.fields['username'].widget = widgets.TextInput(
+ attrs={'placeholder': "username", "class": "form-control"})
+ self.fields['email'].widget = widgets.EmailInput(
+ attrs={'placeholder': "email", "class": "form-control"})
+ self.fields['password1'].widget = widgets.PasswordInput(
+ attrs={'placeholder': "password", "class": "form-control"})
+ self.fields['password2'].widget = widgets.PasswordInput(
+ attrs={'placeholder': "repeat password", "class": "form-control"})
+
+ def clean_email(self):
+ email = self.cleaned_data['email']
+ if get_user_model().objects.filter(email=email).exists():
+ raise ValidationError(_("email already exists"))
+ return email
+
+ class Meta:
+ model = get_user_model()
+ fields = ("username", "email")
+
+
+class ForgetPasswordForm(forms.Form):
+ new_password1 = forms.CharField(
+ label=_("New password"),
+ widget=forms.PasswordInput(
+ attrs={
+ "class": "form-control",
+ 'placeholder': _("New password")
+ }
+ ),
+ )
+
+ new_password2 = forms.CharField(
+ label="确认密码",
+ widget=forms.PasswordInput(
+ attrs={
+ "class": "form-control",
+ 'placeholder': _("Confirm password")
+ }
+ ),
+ )
+
+ email = forms.EmailField(
+ label='邮箱',
+ widget=forms.TextInput(
+ attrs={
+ 'class': 'form-control',
+ 'placeholder': _("Email")
+ }
+ ),
+ )
+
+ code = forms.CharField(
+ label=_('Code'),
+ widget=forms.TextInput(
+ attrs={
+ 'class': 'form-control',
+ 'placeholder': _("Code")
+ }
+ ),
+ )
+
+ def clean_new_password2(self):
+ password1 = self.data.get("new_password1")
+ password2 = self.data.get("new_password2")
+ if password1 and password2 and password1 != password2:
+ raise ValidationError(_("passwords do not match"))
+ password_validation.validate_password(password2)
+
+ return password2
+
+ def clean_email(self):
+ user_email = self.cleaned_data.get("email")
+ if not BlogUser.objects.filter(
+ email=user_email
+ ).exists():
+ # todo 这里的报错提示可以判断一个邮箱是不是注册过,如果不想暴露可以修改
+ raise ValidationError(_("email does not exist"))
+ return user_email
+
+ def clean_code(self):
+ code = self.cleaned_data.get("code")
+ error = utils.verify(
+ email=self.cleaned_data.get("email"),
+ code=code,
+ )
+ if error:
+ raise ValidationError(error)
+ return code
+
+
+class ForgetPasswordCodeForm(forms.Form):
+ email = forms.EmailField(
+ label=_('Email'),
+ )
diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py
new file mode 100644
index 0000000..d2fbcab
--- /dev/null
+++ b/accounts/migrations/0001_initial.py
@@ -0,0 +1,49 @@
+# Generated by Django 4.1.7 on 2023-03-02 07:14
+
+import django.contrib.auth.models
+import django.contrib.auth.validators
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('auth', '0012_alter_user_first_name_max_length'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='BlogUser',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('password', models.CharField(max_length=128, verbose_name='password')),
+ ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
+ ('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, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
+ ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
+ ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
+ ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
+ ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
+ ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
+ ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
+ ('nickname', models.CharField(blank=True, max_length=100, verbose_name='昵称')),
+ ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
+ ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
+ ('source', models.CharField(blank=True, max_length=100, verbose_name='创建来源')),
+ ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
+ ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
+ ],
+ options={
+ 'verbose_name': '用户',
+ 'verbose_name_plural': '用户',
+ 'ordering': ['-id'],
+ 'get_latest_by': 'id',
+ },
+ managers=[
+ ('objects', django.contrib.auth.models.UserManager()),
+ ],
+ ),
+ ]
diff --git a/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py b/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py
new file mode 100644
index 0000000..1a9f509
--- /dev/null
+++ b/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py
@@ -0,0 +1,46 @@
+# Generated by Django 4.2.5 on 2023-09-06 13:13
+
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('accounts', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='bloguser',
+ options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'user', 'verbose_name_plural': 'user'},
+ ),
+ migrations.RemoveField(
+ model_name='bloguser',
+ name='created_time',
+ ),
+ migrations.RemoveField(
+ model_name='bloguser',
+ name='last_mod_time',
+ ),
+ migrations.AddField(
+ model_name='bloguser',
+ name='creation_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
+ ),
+ migrations.AddField(
+ model_name='bloguser',
+ name='last_modify_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
+ ),
+ migrations.AlterField(
+ model_name='bloguser',
+ name='nickname',
+ field=models.CharField(blank=True, max_length=100, verbose_name='nick name'),
+ ),
+ migrations.AlterField(
+ model_name='bloguser',
+ name='source',
+ field=models.CharField(blank=True, max_length=100, verbose_name='create source'),
+ ),
+ ]
diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/accounts/models.py b/accounts/models.py
new file mode 100644
index 0000000..3baddbb
--- /dev/null
+++ b/accounts/models.py
@@ -0,0 +1,35 @@
+from django.contrib.auth.models import AbstractUser
+from django.db import models
+from django.urls import reverse
+from django.utils.timezone import now
+from django.utils.translation import gettext_lazy as _
+from djangoblog.utils import get_current_site
+
+
+# Create your models here.
+
+class BlogUser(AbstractUser):
+ nickname = models.CharField(_('nick name'), max_length=100, blank=True)
+ creation_time = models.DateTimeField(_('creation time'), default=now)
+ last_modify_time = models.DateTimeField(_('last modify time'), default=now)
+ source = models.CharField(_('create source'), max_length=100, blank=True)
+
+ def get_absolute_url(self):
+ return reverse(
+ 'blog:author_detail', kwargs={
+ 'author_name': self.username})
+
+ def __str__(self):
+ return self.email
+
+ def get_full_url(self):
+ site = get_current_site().domain
+ url = "https://{site}{path}".format(site=site,
+ path=self.get_absolute_url())
+ return url
+
+ class Meta:
+ ordering = ['-id']
+ verbose_name = _('user')
+ verbose_name_plural = verbose_name
+ get_latest_by = 'id'
diff --git a/accounts/templatetags/__init__.py b/accounts/templatetags/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/accounts/tests.py b/accounts/tests.py
new file mode 100644
index 0000000..6893411
--- /dev/null
+++ b/accounts/tests.py
@@ -0,0 +1,207 @@
+from django.test import Client, RequestFactory, TestCase
+from django.urls import reverse
+from django.utils import timezone
+from django.utils.translation import gettext_lazy as _
+
+from accounts.models import BlogUser
+from blog.models import Article, Category
+from djangoblog.utils import *
+from . import utils
+
+
+# Create your tests here.
+
+class AccountTest(TestCase):
+ def setUp(self):
+ self.client = Client()
+ self.factory = RequestFactory()
+ self.blog_user = BlogUser.objects.create_user(
+ username="test",
+ email="admin@admin.com",
+ password="12345678"
+ )
+ self.new_test = "xxx123--="
+
+ def test_validate_account(self):
+ site = get_current_site().domain
+ user = BlogUser.objects.create_superuser(
+ email="liangliangyy1@gmail.com",
+ username="liangliangyy1",
+ password="qwer!@#$ggg")
+ testuser = BlogUser.objects.get(username='liangliangyy1')
+
+ loginresult = self.client.login(
+ username='liangliangyy1',
+ password='qwer!@#$ggg')
+ self.assertEqual(loginresult, True)
+ response = self.client.get('/admin/')
+ self.assertEqual(response.status_code, 200)
+
+ category = Category()
+ category.name = "categoryaaa"
+ category.creation_time = timezone.now()
+ category.last_modify_time = timezone.now()
+ category.save()
+
+ article = Article()
+ article.title = "nicetitleaaa"
+ article.body = "nicecontentaaa"
+ article.author = user
+ article.category = category
+ article.type = 'a'
+ article.status = 'p'
+ article.save()
+
+ response = self.client.get(article.get_admin_url())
+ self.assertEqual(response.status_code, 200)
+
+ def test_validate_register(self):
+ self.assertEquals(
+ 0, len(
+ BlogUser.objects.filter(
+ email='user123@user.com')))
+ response = self.client.post(reverse('account:register'), {
+ 'username': 'user1233',
+ 'email': 'user123@user.com',
+ 'password1': 'password123!q@wE#R$T',
+ 'password2': 'password123!q@wE#R$T',
+ })
+ self.assertEquals(
+ 1, len(
+ BlogUser.objects.filter(
+ email='user123@user.com')))
+ user = BlogUser.objects.filter(email='user123@user.com')[0]
+ sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
+ path = reverse('accounts:result')
+ url = '{path}?type=validation&id={id}&sign={sign}'.format(
+ path=path, id=user.id, sign=sign)
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+
+ self.client.login(username='user1233', password='password123!q@wE#R$T')
+ user = BlogUser.objects.filter(email='user123@user.com')[0]
+ user.is_superuser = True
+ user.is_staff = True
+ user.save()
+ delete_sidebar_cache()
+ category = Category()
+ category.name = "categoryaaa"
+ category.creation_time = timezone.now()
+ category.last_modify_time = timezone.now()
+ category.save()
+
+ article = Article()
+ article.category = category
+ article.title = "nicetitle333"
+ article.body = "nicecontentttt"
+ article.author = user
+
+ article.type = 'a'
+ article.status = 'p'
+ article.save()
+
+ response = self.client.get(article.get_admin_url())
+ self.assertEqual(response.status_code, 200)
+
+ response = self.client.get(reverse('account:logout'))
+ self.assertIn(response.status_code, [301, 302, 200])
+
+ response = self.client.get(article.get_admin_url())
+ self.assertIn(response.status_code, [301, 302, 200])
+
+ response = self.client.post(reverse('account:login'), {
+ 'username': 'user1233',
+ 'password': 'password123'
+ })
+ self.assertIn(response.status_code, [301, 302, 200])
+
+ response = self.client.get(article.get_admin_url())
+ self.assertIn(response.status_code, [301, 302, 200])
+
+ def test_verify_email_code(self):
+ to_email = "admin@admin.com"
+ code = generate_code()
+ utils.set_code(to_email, code)
+ utils.send_verify_email(to_email, code)
+
+ err = utils.verify("admin@admin.com", code)
+ self.assertEqual(err, None)
+
+ err = utils.verify("admin@123.com", code)
+ self.assertEqual(type(err), str)
+
+ def test_forget_password_email_code_success(self):
+ resp = self.client.post(
+ path=reverse("account:forget_password_code"),
+ data=dict(email="admin@admin.com")
+ )
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertEqual(resp.content.decode("utf-8"), "ok")
+
+ def test_forget_password_email_code_fail(self):
+ resp = self.client.post(
+ path=reverse("account:forget_password_code"),
+ data=dict()
+ )
+ self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
+
+ resp = self.client.post(
+ path=reverse("account:forget_password_code"),
+ data=dict(email="admin@com")
+ )
+ self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
+
+ def test_forget_password_email_success(self):
+ code = generate_code()
+ utils.set_code(self.blog_user.email, code)
+ data = dict(
+ new_password1=self.new_test,
+ new_password2=self.new_test,
+ email=self.blog_user.email,
+ code=code,
+ )
+ resp = self.client.post(
+ path=reverse("account:forget_password"),
+ data=data
+ )
+ self.assertEqual(resp.status_code, 302)
+
+ # 验证用户密码是否修改成功
+ blog_user = BlogUser.objects.filter(
+ email=self.blog_user.email,
+ ).first() # type: BlogUser
+ self.assertNotEqual(blog_user, None)
+ self.assertEqual(blog_user.check_password(data["new_password1"]), True)
+
+ def test_forget_password_email_not_user(self):
+ data = dict(
+ new_password1=self.new_test,
+ new_password2=self.new_test,
+ email="123@123.com",
+ code="123456",
+ )
+ resp = self.client.post(
+ path=reverse("account:forget_password"),
+ data=data
+ )
+
+ self.assertEqual(resp.status_code, 200)
+
+
+ def test_forget_password_email_code_error(self):
+ code = generate_code()
+ utils.set_code(self.blog_user.email, code)
+ data = dict(
+ new_password1=self.new_test,
+ new_password2=self.new_test,
+ email=self.blog_user.email,
+ code="111111",
+ )
+ resp = self.client.post(
+ path=reverse("account:forget_password"),
+ data=data
+ )
+
+ self.assertEqual(resp.status_code, 200)
+
diff --git a/accounts/urls.py b/accounts/urls.py
new file mode 100644
index 0000000..107a801
--- /dev/null
+++ b/accounts/urls.py
@@ -0,0 +1,28 @@
+from django.urls import path
+from django.urls import re_path
+
+from . import views
+from .forms import LoginForm
+
+app_name = "accounts"
+
+urlpatterns = [re_path(r'^login/$',
+ views.LoginView.as_view(success_url='/'),
+ name='login',
+ kwargs={'authentication_form': LoginForm}),
+ re_path(r'^register/$',
+ views.RegisterView.as_view(success_url="/"),
+ name='register'),
+ re_path(r'^logout/$',
+ views.LogoutView.as_view(),
+ name='logout'),
+ path(r'account/result.html',
+ views.account_result,
+ name='result'),
+ re_path(r'^forget_password/$',
+ views.ForgetPasswordView.as_view(),
+ name='forget_password'),
+ re_path(r'^forget_password_code/$',
+ views.ForgetPasswordEmailCode.as_view(),
+ name='forget_password_code'),
+ ]
diff --git a/accounts/user_login_backend.py b/accounts/user_login_backend.py
new file mode 100644
index 0000000..73cdca1
--- /dev/null
+++ b/accounts/user_login_backend.py
@@ -0,0 +1,26 @@
+from django.contrib.auth import get_user_model
+from django.contrib.auth.backends import ModelBackend
+
+
+class EmailOrUsernameModelBackend(ModelBackend):
+ """
+ 允许使用用户名或邮箱登录
+ """
+
+ def authenticate(self, request, username=None, password=None, **kwargs):
+ if '@' in username:
+ kwargs = {'email': username}
+ else:
+ kwargs = {'username': username}
+ try:
+ user = get_user_model().objects.get(**kwargs)
+ if user.check_password(password):
+ return user
+ except get_user_model().DoesNotExist:
+ return None
+
+ def get_user(self, username):
+ try:
+ return get_user_model().objects.get(pk=username)
+ except get_user_model().DoesNotExist:
+ return None
diff --git a/accounts/utils.py b/accounts/utils.py
new file mode 100644
index 0000000..4b94bdf
--- /dev/null
+++ b/accounts/utils.py
@@ -0,0 +1,49 @@
+import typing
+from datetime import timedelta
+
+from django.core.cache import cache
+from django.utils.translation import gettext
+from django.utils.translation import gettext_lazy as _
+
+from djangoblog.utils import send_email
+
+_code_ttl = timedelta(minutes=5)
+
+
+def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")):
+ """发送重设密码验证码
+ Args:
+ to_mail: 接受邮箱
+ subject: 邮件主题
+ code: 验证码
+ """
+ html_content = _(
+ "You are resetting the password, the verification code is:%(code)s, valid within 5 minutes, please keep it "
+ "properly") % {'code': code}
+ send_email([to_mail], subject, html_content)
+
+
+def verify(email: str, code: str) -> typing.Optional[str]:
+ """验证code是否有效
+ Args:
+ email: 请求邮箱
+ code: 验证码
+ Return:
+ 如果有错误就返回错误str
+ Node:
+ 这里的错误处理不太合理,应该采用raise抛出
+ 否测调用方也需要对error进行处理
+ """
+ cache_code = get_code(email)
+ if cache_code != code:
+ return gettext("Verification code error")
+
+
+def set_code(email: str, code: str):
+ """设置code"""
+ cache.set(email, code, _code_ttl.seconds)
+
+
+def get_code(email: str) -> typing.Optional[str]:
+ """获取code"""
+ return cache.get(email)
diff --git a/accounts/views.py b/accounts/views.py
new file mode 100644
index 0000000..ae67aec
--- /dev/null
+++ b/accounts/views.py
@@ -0,0 +1,204 @@
+import logging
+from django.utils.translation import gettext_lazy as _
+from django.conf import settings
+from django.contrib import auth
+from django.contrib.auth import REDIRECT_FIELD_NAME
+from django.contrib.auth import get_user_model
+from django.contrib.auth import logout
+from django.contrib.auth.forms import AuthenticationForm
+from django.contrib.auth.hashers import make_password
+from django.http import HttpResponseRedirect, HttpResponseForbidden
+from django.http.request import HttpRequest
+from django.http.response import HttpResponse
+from django.shortcuts import get_object_or_404
+from django.shortcuts import render
+from django.urls import reverse
+from django.utils.decorators import method_decorator
+from django.utils.http import url_has_allowed_host_and_scheme
+from django.views import View
+from django.views.decorators.cache import never_cache
+from django.views.decorators.csrf import csrf_protect
+from django.views.decorators.debug import sensitive_post_parameters
+from django.views.generic import FormView, RedirectView
+
+from djangoblog.utils import send_email, get_sha256, get_current_site, generate_code, delete_sidebar_cache
+from . import utils
+from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm
+from .models import BlogUser
+
+logger = logging.getLogger(__name__)
+
+
+# Create your views here.
+
+class RegisterView(FormView):
+ form_class = RegisterForm
+ template_name = 'account/registration_form.html'
+
+ @method_decorator(csrf_protect)
+ def dispatch(self, *args, **kwargs):
+ return super(RegisterView, self).dispatch(*args, **kwargs)
+
+ def form_valid(self, form):
+ if form.is_valid():
+ user = form.save(False)
+ user.is_active = False
+ user.source = 'Register'
+ user.save(True)
+ site = get_current_site().domain
+ sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
+
+ if settings.DEBUG:
+ site = '127.0.0.1:8000'
+ path = reverse('account:result')
+ url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format(
+ site=site, path=path, id=user.id, sign=sign)
+
+ content = """
+ 请点击下面链接验证您的邮箱
+
+ {url}
+
+ 再次感谢您!
+
+ 如果上面链接无法打开,请将此链接复制至浏览器。
+ {url}
+ """.format(url=url)
+ send_email(
+ emailto=[
+ user.email,
+ ],
+ title='验证您的电子邮箱',
+ content=content)
+
+ url = reverse('accounts:result') + \
+ '?type=register&id=' + str(user.id)
+ return HttpResponseRedirect(url)
+ else:
+ return self.render_to_response({
+ 'form': form
+ })
+
+
+class LogoutView(RedirectView):
+ url = '/login/'
+
+ @method_decorator(never_cache)
+ def dispatch(self, request, *args, **kwargs):
+ return super(LogoutView, self).dispatch(request, *args, **kwargs)
+
+ def get(self, request, *args, **kwargs):
+ logout(request)
+ delete_sidebar_cache()
+ return super(LogoutView, self).get(request, *args, **kwargs)
+
+
+class LoginView(FormView):
+ form_class = LoginForm
+ template_name = 'account/login.html'
+ success_url = '/'
+ redirect_field_name = REDIRECT_FIELD_NAME
+ login_ttl = 2626560 # 一个月的时间
+
+ @method_decorator(sensitive_post_parameters('password'))
+ @method_decorator(csrf_protect)
+ @method_decorator(never_cache)
+ def dispatch(self, request, *args, **kwargs):
+
+ return super(LoginView, self).dispatch(request, *args, **kwargs)
+
+ def get_context_data(self, **kwargs):
+ redirect_to = self.request.GET.get(self.redirect_field_name)
+ if redirect_to is None:
+ redirect_to = '/'
+ kwargs['redirect_to'] = redirect_to
+
+ return super(LoginView, self).get_context_data(**kwargs)
+
+ def form_valid(self, form):
+ form = AuthenticationForm(data=self.request.POST, request=self.request)
+
+ if form.is_valid():
+ delete_sidebar_cache()
+ logger.info(self.redirect_field_name)
+
+ auth.login(self.request, form.get_user())
+ if self.request.POST.get("remember"):
+ self.request.session.set_expiry(self.login_ttl)
+ return super(LoginView, self).form_valid(form)
+ # return HttpResponseRedirect('/')
+ else:
+ return self.render_to_response({
+ 'form': form
+ })
+
+ def get_success_url(self):
+
+ redirect_to = self.request.POST.get(self.redirect_field_name)
+ if not url_has_allowed_host_and_scheme(
+ url=redirect_to, allowed_hosts=[
+ self.request.get_host()]):
+ redirect_to = self.success_url
+ return redirect_to
+
+
+def account_result(request):
+ type = request.GET.get('type')
+ id = request.GET.get('id')
+
+ user = get_object_or_404(get_user_model(), id=id)
+ logger.info(type)
+ if user.is_active:
+ return HttpResponseRedirect('/')
+ if type and type in ['register', 'validation']:
+ if type == 'register':
+ content = '''
+ 恭喜您注册成功,一封验证邮件已经发送到您的邮箱,请验证您的邮箱后登录本站。
+ '''
+ title = '注册成功'
+ else:
+ c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
+ sign = request.GET.get('sign')
+ if sign != c_sign:
+ return HttpResponseForbidden()
+ user.is_active = True
+ user.save()
+ content = '''
+ 恭喜您已经成功的完成邮箱验证,您现在可以使用您的账号来登录本站。
+ '''
+ title = '验证成功'
+ return render(request, 'account/result.html', {
+ 'title': title,
+ 'content': content
+ })
+ else:
+ return HttpResponseRedirect('/')
+
+
+class ForgetPasswordView(FormView):
+ form_class = ForgetPasswordForm
+ template_name = 'account/forget_password.html'
+
+ def form_valid(self, form):
+ if form.is_valid():
+ blog_user = BlogUser.objects.filter(email=form.cleaned_data.get("email")).get()
+ blog_user.password = make_password(form.cleaned_data["new_password2"])
+ blog_user.save()
+ return HttpResponseRedirect('/login/')
+ else:
+ return self.render_to_response({'form': form})
+
+
+class ForgetPasswordEmailCode(View):
+
+ def post(self, request: HttpRequest):
+ form = ForgetPasswordCodeForm(request.POST)
+ if not form.is_valid():
+ return HttpResponse("错误的邮箱")
+ to_email = form.cleaned_data["email"]
+
+ code = generate_code()
+ utils.send_verify_email(to_email, code)
+ utils.set_code(to_email, code)
+
+ return HttpResponse("ok")
diff --git a/blog/__init__.py b/blog/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/blog/admin.py b/blog/admin.py
new file mode 100644
index 0000000..46c3420
--- /dev/null
+++ b/blog/admin.py
@@ -0,0 +1,112 @@
+from django import forms
+from django.contrib import admin
+from django.contrib.auth import get_user_model
+from django.urls import reverse
+from django.utils.html import format_html
+from django.utils.translation import gettext_lazy as _
+
+# Register your models here.
+from .models import Article
+
+
+class ArticleForm(forms.ModelForm):
+ # body = forms.CharField(widget=AdminPagedownWidget())
+
+ class Meta:
+ model = Article
+ fields = '__all__'
+
+
+def makr_article_publish(modeladmin, request, queryset):
+ queryset.update(status='p')
+
+
+def draft_article(modeladmin, request, queryset):
+ queryset.update(status='d')
+
+
+def close_article_commentstatus(modeladmin, request, queryset):
+ queryset.update(comment_status='c')
+
+
+def open_article_commentstatus(modeladmin, request, queryset):
+ queryset.update(comment_status='o')
+
+
+makr_article_publish.short_description = _('Publish selected articles')
+draft_article.short_description = _('Draft selected articles')
+close_article_commentstatus.short_description = _('Close article comments')
+open_article_commentstatus.short_description = _('Open article comments')
+
+
+class ArticlelAdmin(admin.ModelAdmin):
+ list_per_page = 20
+ search_fields = ('body', 'title')
+ form = ArticleForm
+ list_display = (
+ 'id',
+ 'title',
+ 'author',
+ 'link_to_category',
+ 'creation_time',
+ 'views',
+ 'status',
+ 'type',
+ 'article_order')
+ list_display_links = ('id', 'title')
+ list_filter = ('status', 'type', 'category')
+ filter_horizontal = ('tags',)
+ exclude = ('creation_time', 'last_modify_time')
+ view_on_site = True
+ actions = [
+ makr_article_publish,
+ draft_article,
+ close_article_commentstatus,
+ open_article_commentstatus]
+
+ def link_to_category(self, obj):
+ info = (obj.category._meta.app_label, obj.category._meta.model_name)
+ link = reverse('admin:%s_%s_change' % info, args=(obj.category.id,))
+ return format_html(u'%s' % (link, obj.category.name))
+
+ link_to_category.short_description = _('category')
+
+ def get_form(self, request, obj=None, **kwargs):
+ form = super(ArticlelAdmin, self).get_form(request, obj, **kwargs)
+ form.base_fields['author'].queryset = get_user_model(
+ ).objects.filter(is_superuser=True)
+ return form
+
+ def save_model(self, request, obj, form, change):
+ super(ArticlelAdmin, self).save_model(request, obj, form, change)
+
+ def get_view_on_site_url(self, obj=None):
+ if obj:
+ url = obj.get_full_url()
+ return url
+ else:
+ from djangoblog.utils import get_current_site
+ site = get_current_site().domain
+ return site
+
+
+class TagAdmin(admin.ModelAdmin):
+ exclude = ('slug', 'last_mod_time', 'creation_time')
+
+
+class CategoryAdmin(admin.ModelAdmin):
+ list_display = ('name', 'parent_category', 'index')
+ exclude = ('slug', 'last_mod_time', 'creation_time')
+
+
+class LinksAdmin(admin.ModelAdmin):
+ exclude = ('last_mod_time', 'creation_time')
+
+
+class SideBarAdmin(admin.ModelAdmin):
+ list_display = ('name', 'content', 'is_enable', 'sequence')
+ exclude = ('last_mod_time', 'creation_time')
+
+
+class BlogSettingsAdmin(admin.ModelAdmin):
+ pass
diff --git a/blog/apps.py b/blog/apps.py
new file mode 100644
index 0000000..7930587
--- /dev/null
+++ b/blog/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class BlogConfig(AppConfig):
+ name = 'blog'
diff --git a/blog/context_processors.py b/blog/context_processors.py
new file mode 100644
index 0000000..73e3088
--- /dev/null
+++ b/blog/context_processors.py
@@ -0,0 +1,43 @@
+import logging
+
+from django.utils import timezone
+
+from djangoblog.utils import cache, get_blog_setting
+from .models import Category, Article
+
+logger = logging.getLogger(__name__)
+
+
+def seo_processor(requests):
+ key = 'seo_processor'
+ value = cache.get(key)
+ if value:
+ return value
+ else:
+ logger.info('set processor cache.')
+ setting = get_blog_setting()
+ value = {
+ 'SITE_NAME': setting.site_name,
+ 'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense,
+ 'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes,
+ 'SITE_SEO_DESCRIPTION': setting.site_seo_description,
+ 'SITE_DESCRIPTION': setting.site_description,
+ 'SITE_KEYWORDS': setting.site_keywords,
+ 'SITE_BASE_URL': requests.scheme + '://' + requests.get_host() + '/',
+ 'ARTICLE_SUB_LENGTH': setting.article_sub_length,
+ 'nav_category_list': Category.objects.all(),
+ 'nav_pages': Article.objects.filter(
+ type='p',
+ status='p'),
+ 'OPEN_SITE_COMMENT': setting.open_site_comment,
+ 'BEIAN_CODE': setting.beian_code,
+ 'ANALYTICS_CODE': setting.analytics_code,
+ "BEIAN_CODE_GONGAN": setting.gongan_beiancode,
+ "SHOW_GONGAN_CODE": setting.show_gongan_code,
+ "CURRENT_YEAR": timezone.now().year,
+ "GLOBAL_HEADER": setting.global_header,
+ "GLOBAL_FOOTER": setting.global_footer,
+ "COMMENT_NEED_REVIEW": setting.comment_need_review,
+ }
+ cache.set(key, value, 60 * 60 * 10)
+ return value
diff --git a/blog/documents.py b/blog/documents.py
new file mode 100644
index 0000000..0f1db7b
--- /dev/null
+++ b/blog/documents.py
@@ -0,0 +1,213 @@
+import time
+
+import elasticsearch.client
+from django.conf import settings
+from elasticsearch_dsl import Document, InnerDoc, Date, Integer, Long, Text, Object, GeoPoint, Keyword, Boolean
+from elasticsearch_dsl.connections import connections
+
+from blog.models import Article
+
+ELASTICSEARCH_ENABLED = hasattr(settings, 'ELASTICSEARCH_DSL')
+
+if ELASTICSEARCH_ENABLED:
+ connections.create_connection(
+ hosts=[settings.ELASTICSEARCH_DSL['default']['hosts']])
+ from elasticsearch import Elasticsearch
+
+ es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
+ from elasticsearch.client import IngestClient
+
+ c = IngestClient(es)
+ try:
+ c.get_pipeline('geoip')
+ except elasticsearch.exceptions.NotFoundError:
+ c.put_pipeline('geoip', body='''{
+ "description" : "Add geoip info",
+ "processors" : [
+ {
+ "geoip" : {
+ "field" : "ip"
+ }
+ }
+ ]
+ }''')
+
+
+class GeoIp(InnerDoc):
+ continent_name = Keyword()
+ country_iso_code = Keyword()
+ country_name = Keyword()
+ location = GeoPoint()
+
+
+class UserAgentBrowser(InnerDoc):
+ Family = Keyword()
+ Version = Keyword()
+
+
+class UserAgentOS(UserAgentBrowser):
+ pass
+
+
+class UserAgentDevice(InnerDoc):
+ Family = Keyword()
+ Brand = Keyword()
+ Model = Keyword()
+
+
+class UserAgent(InnerDoc):
+ browser = Object(UserAgentBrowser, required=False)
+ os = Object(UserAgentOS, required=False)
+ device = Object(UserAgentDevice, required=False)
+ string = Text()
+ is_bot = Boolean()
+
+
+class ElapsedTimeDocument(Document):
+ url = Keyword()
+ time_taken = Long()
+ log_datetime = Date()
+ ip = Keyword()
+ geoip = Object(GeoIp, required=False)
+ useragent = Object(UserAgent, required=False)
+
+ class Index:
+ name = 'performance'
+ settings = {
+ "number_of_shards": 1,
+ "number_of_replicas": 0
+ }
+
+ class Meta:
+ doc_type = 'ElapsedTime'
+
+
+class ElaspedTimeDocumentManager:
+ @staticmethod
+ def build_index():
+ from elasticsearch import Elasticsearch
+ client = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
+ res = client.indices.exists(index="performance")
+ if not res:
+ ElapsedTimeDocument.init()
+
+ @staticmethod
+ def delete_index():
+ from elasticsearch import Elasticsearch
+ es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
+ es.indices.delete(index='performance', ignore=[400, 404])
+
+ @staticmethod
+ def create(url, time_taken, log_datetime, useragent, ip):
+ ElaspedTimeDocumentManager.build_index()
+ ua = UserAgent()
+ ua.browser = UserAgentBrowser()
+ ua.browser.Family = useragent.browser.family
+ ua.browser.Version = useragent.browser.version_string
+
+ ua.os = UserAgentOS()
+ ua.os.Family = useragent.os.family
+ ua.os.Version = useragent.os.version_string
+
+ ua.device = UserAgentDevice()
+ ua.device.Family = useragent.device.family
+ ua.device.Brand = useragent.device.brand
+ ua.device.Model = useragent.device.model
+ ua.string = useragent.ua_string
+ ua.is_bot = useragent.is_bot
+
+ doc = ElapsedTimeDocument(
+ meta={
+ 'id': int(
+ round(
+ time.time() *
+ 1000))
+ },
+ url=url,
+ time_taken=time_taken,
+ log_datetime=log_datetime,
+ useragent=ua, ip=ip)
+ doc.save(pipeline="geoip")
+
+
+class ArticleDocument(Document):
+ body = Text(analyzer='ik_max_word', search_analyzer='ik_smart')
+ title = Text(analyzer='ik_max_word', search_analyzer='ik_smart')
+ author = Object(properties={
+ 'nickname': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
+ 'id': Integer()
+ })
+ category = Object(properties={
+ 'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
+ 'id': Integer()
+ })
+ tags = Object(properties={
+ 'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
+ 'id': Integer()
+ })
+
+ pub_time = Date()
+ status = Text()
+ comment_status = Text()
+ type = Text()
+ views = Integer()
+ article_order = Integer()
+
+ class Index:
+ name = 'blog'
+ settings = {
+ "number_of_shards": 1,
+ "number_of_replicas": 0
+ }
+
+ class Meta:
+ doc_type = 'Article'
+
+
+class ArticleDocumentManager():
+
+ def __init__(self):
+ self.create_index()
+
+ def create_index(self):
+ ArticleDocument.init()
+
+ def delete_index(self):
+ from elasticsearch import Elasticsearch
+ es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
+ es.indices.delete(index='blog', ignore=[400, 404])
+
+ def convert_to_doc(self, articles):
+ return [
+ ArticleDocument(
+ meta={
+ 'id': article.id},
+ body=article.body,
+ title=article.title,
+ author={
+ 'nickname': article.author.username,
+ 'id': article.author.id},
+ category={
+ 'name': article.category.name,
+ 'id': article.category.id},
+ tags=[
+ {
+ 'name': t.name,
+ 'id': t.id} for t in article.tags.all()],
+ pub_time=article.pub_time,
+ status=article.status,
+ comment_status=article.comment_status,
+ type=article.type,
+ views=article.views,
+ article_order=article.article_order) for article in articles]
+
+ def rebuild(self, articles=None):
+ ArticleDocument.init()
+ articles = articles if articles else Article.objects.all()
+ docs = self.convert_to_doc(articles)
+ for doc in docs:
+ doc.save()
+
+ def update_docs(self, docs):
+ for doc in docs:
+ doc.save()
diff --git a/blog/forms.py b/blog/forms.py
new file mode 100644
index 0000000..715be76
--- /dev/null
+++ b/blog/forms.py
@@ -0,0 +1,19 @@
+import logging
+
+from django import forms
+from haystack.forms import SearchForm
+
+logger = logging.getLogger(__name__)
+
+
+class BlogSearchForm(SearchForm):
+ querydata = forms.CharField(required=True)
+
+ def search(self):
+ datas = super(BlogSearchForm, self).search()
+ if not self.is_valid():
+ return self.no_query_found()
+
+ if self.cleaned_data['querydata']:
+ logger.info(self.cleaned_data['querydata'])
+ return datas
diff --git a/blog/management/__init__.py b/blog/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/blog/management/commands/__init__.py b/blog/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/blog/management/commands/build_index.py b/blog/management/commands/build_index.py
new file mode 100644
index 0000000..3c4acd7
--- /dev/null
+++ b/blog/management/commands/build_index.py
@@ -0,0 +1,18 @@
+from django.core.management.base import BaseCommand
+
+from blog.documents import ElapsedTimeDocument, ArticleDocumentManager, ElaspedTimeDocumentManager, \
+ ELASTICSEARCH_ENABLED
+
+
+# TODO 参数化
+class Command(BaseCommand):
+ help = 'build search index'
+
+ def handle(self, *args, **options):
+ if ELASTICSEARCH_ENABLED:
+ ElaspedTimeDocumentManager.build_index()
+ manager = ElapsedTimeDocument()
+ manager.init()
+ manager = ArticleDocumentManager()
+ manager.delete_index()
+ manager.rebuild()
diff --git a/blog/management/commands/build_search_words.py b/blog/management/commands/build_search_words.py
new file mode 100644
index 0000000..cfe7e0d
--- /dev/null
+++ b/blog/management/commands/build_search_words.py
@@ -0,0 +1,13 @@
+from django.core.management.base import BaseCommand
+
+from blog.models import Tag, Category
+
+
+# TODO 参数化
+class Command(BaseCommand):
+ help = 'build search words'
+
+ def handle(self, *args, **options):
+ datas = set([t.name for t in Tag.objects.all()] +
+ [t.name for t in Category.objects.all()])
+ print('\n'.join(datas))
diff --git a/blog/management/commands/clear_cache.py b/blog/management/commands/clear_cache.py
new file mode 100644
index 0000000..0d66172
--- /dev/null
+++ b/blog/management/commands/clear_cache.py
@@ -0,0 +1,11 @@
+from django.core.management.base import BaseCommand
+
+from djangoblog.utils import cache
+
+
+class Command(BaseCommand):
+ help = 'clear the whole cache'
+
+ def handle(self, *args, **options):
+ cache.clear()
+ self.stdout.write(self.style.SUCCESS('Cleared cache\n'))
diff --git a/blog/management/commands/create_testdata.py b/blog/management/commands/create_testdata.py
new file mode 100644
index 0000000..675d2ba
--- /dev/null
+++ b/blog/management/commands/create_testdata.py
@@ -0,0 +1,40 @@
+from django.contrib.auth import get_user_model
+from django.contrib.auth.hashers import make_password
+from django.core.management.base import BaseCommand
+
+from blog.models import Article, Tag, Category
+
+
+class Command(BaseCommand):
+ help = 'create test datas'
+
+ def handle(self, *args, **options):
+ user = get_user_model().objects.get_or_create(
+ email='test@test.com', username='测试用户', password=make_password('test!q@w#eTYU'))[0]
+
+ pcategory = Category.objects.get_or_create(
+ name='我是父类目', parent_category=None)[0]
+
+ category = Category.objects.get_or_create(
+ name='子类目', parent_category=pcategory)[0]
+
+ category.save()
+ basetag = Tag()
+ basetag.name = "标签"
+ basetag.save()
+ for i in range(1, 20):
+ article = Article.objects.get_or_create(
+ category=category,
+ title='nice title ' + str(i),
+ body='nice content ' + str(i),
+ author=user)[0]
+ tag = Tag()
+ tag.name = "标签" + str(i)
+ tag.save()
+ article.tags.add(tag)
+ article.tags.add(basetag)
+ article.save()
+
+ from djangoblog.utils import cache
+ cache.clear()
+ self.stdout.write(self.style.SUCCESS('created test datas \n'))
diff --git a/blog/management/commands/ping_baidu.py b/blog/management/commands/ping_baidu.py
new file mode 100644
index 0000000..2c7fbdd
--- /dev/null
+++ b/blog/management/commands/ping_baidu.py
@@ -0,0 +1,50 @@
+from django.core.management.base import BaseCommand
+
+from djangoblog.spider_notify import SpiderNotify
+from djangoblog.utils import get_current_site
+from blog.models import Article, Tag, Category
+
+site = get_current_site().domain
+
+
+class Command(BaseCommand):
+ help = 'notify baidu url'
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ 'data_type',
+ type=str,
+ choices=[
+ 'all',
+ 'article',
+ 'tag',
+ 'category'],
+ help='article : all article,tag : all tag,category: all category,all: All of these')
+
+ def get_full_url(self, path):
+ url = "https://{site}{path}".format(site=site, path=path)
+ return url
+
+ def handle(self, *args, **options):
+ type = options['data_type']
+ self.stdout.write('start get %s' % type)
+
+ urls = []
+ if type == 'article' or type == 'all':
+ for article in Article.objects.filter(status='p'):
+ urls.append(article.get_full_url())
+ if type == 'tag' or type == 'all':
+ for tag in Tag.objects.all():
+ url = tag.get_absolute_url()
+ urls.append(self.get_full_url(url))
+ if type == 'category' or type == 'all':
+ for category in Category.objects.all():
+ url = category.get_absolute_url()
+ urls.append(self.get_full_url(url))
+
+ self.stdout.write(
+ self.style.SUCCESS(
+ 'start notify %d urls' %
+ len(urls)))
+ SpiderNotify.baidu_notify(urls)
+ self.stdout.write(self.style.SUCCESS('finish notify'))
diff --git a/blog/management/commands/sync_user_avatar.py b/blog/management/commands/sync_user_avatar.py
new file mode 100644
index 0000000..d0f4612
--- /dev/null
+++ b/blog/management/commands/sync_user_avatar.py
@@ -0,0 +1,47 @@
+import requests
+from django.core.management.base import BaseCommand
+from django.templatetags.static import static
+
+from djangoblog.utils import save_user_avatar
+from oauth.models import OAuthUser
+from oauth.oauthmanager import get_manager_by_type
+
+
+class Command(BaseCommand):
+ help = 'sync user avatar'
+
+ def test_picture(self, url):
+ try:
+ if requests.get(url, timeout=2).status_code == 200:
+ return True
+ except:
+ pass
+
+ def handle(self, *args, **options):
+ static_url = static("../")
+ users = OAuthUser.objects.all()
+ self.stdout.write(f'开始同步{len(users)}个用户头像')
+ for u in users:
+ self.stdout.write(f'开始同步:{u.nickname}')
+ url = u.picture
+ if url:
+ if url.startswith(static_url):
+ if self.test_picture(url):
+ continue
+ else:
+ if u.metadata:
+ manage = get_manager_by_type(u.type)
+ url = manage.get_picture(u.metadata)
+ url = save_user_avatar(url)
+ else:
+ url = static('blog/img/avatar.png')
+ else:
+ url = save_user_avatar(url)
+ else:
+ url = static('blog/img/avatar.png')
+ if url:
+ self.stdout.write(
+ f'结束同步:{u.nickname}.url:{url}')
+ u.picture = url
+ u.save()
+ self.stdout.write('结束同步')
diff --git a/blog/middleware.py b/blog/middleware.py
new file mode 100644
index 0000000..94dd70c
--- /dev/null
+++ b/blog/middleware.py
@@ -0,0 +1,42 @@
+import logging
+import time
+
+from ipware import get_client_ip
+from user_agents import parse
+
+from blog.documents import ELASTICSEARCH_ENABLED, ElaspedTimeDocumentManager
+
+logger = logging.getLogger(__name__)
+
+
+class OnlineMiddleware(object):
+ def __init__(self, get_response=None):
+ self.get_response = get_response
+ super().__init__()
+
+ def __call__(self, request):
+ ''' page render time '''
+ start_time = time.time()
+ response = self.get_response(request)
+ http_user_agent = request.META.get('HTTP_USER_AGENT', '')
+ ip, _ = get_client_ip(request)
+ user_agent = parse(http_user_agent)
+ if not response.streaming:
+ try:
+ cast_time = time.time() - start_time
+ if ELASTICSEARCH_ENABLED:
+ time_taken = round((cast_time) * 1000, 2)
+ url = request.path
+ from django.utils import timezone
+ ElaspedTimeDocumentManager.create(
+ url=url,
+ time_taken=time_taken,
+ log_datetime=timezone.now(),
+ useragent=user_agent,
+ ip=ip)
+ response.content = response.content.replace(
+ b'', str.encode(str(cast_time)[:5]))
+ except Exception as e:
+ logger.error("Error OnlineMiddleware: %s" % e)
+
+ return response
diff --git a/blog/migrations/0001_initial.py b/blog/migrations/0001_initial.py
new file mode 100644
index 0000000..3d391b6
--- /dev/null
+++ b/blog/migrations/0001_initial.py
@@ -0,0 +1,137 @@
+# Generated by Django 4.1.7 on 2023-03-02 07:14
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import mdeditor.fields
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='BlogSettings',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('sitename', models.CharField(default='', max_length=200, verbose_name='网站名称')),
+ ('site_description', models.TextField(default='', max_length=1000, verbose_name='网站描述')),
+ ('site_seo_description', models.TextField(default='', max_length=1000, verbose_name='网站SEO描述')),
+ ('site_keywords', models.TextField(default='', max_length=1000, verbose_name='网站关键字')),
+ ('article_sub_length', models.IntegerField(default=300, verbose_name='文章摘要长度')),
+ ('sidebar_article_count', models.IntegerField(default=10, verbose_name='侧边栏文章数目')),
+ ('sidebar_comment_count', models.IntegerField(default=5, verbose_name='侧边栏评论数目')),
+ ('article_comment_count', models.IntegerField(default=5, verbose_name='文章页面默认显示评论数目')),
+ ('show_google_adsense', models.BooleanField(default=False, verbose_name='是否显示谷歌广告')),
+ ('google_adsense_codes', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='广告内容')),
+ ('open_site_comment', models.BooleanField(default=True, verbose_name='是否打开网站评论功能')),
+ ('beiancode', models.CharField(blank=True, default='', max_length=2000, null=True, verbose_name='备案号')),
+ ('analyticscode', models.TextField(default='', max_length=1000, verbose_name='网站统计代码')),
+ ('show_gongan_code', models.BooleanField(default=False, verbose_name='是否显示公安备案号')),
+ ('gongan_beiancode', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='公安备案号')),
+ ],
+ options={
+ 'verbose_name': '网站配置',
+ 'verbose_name_plural': '网站配置',
+ },
+ ),
+ migrations.CreateModel(
+ name='Links',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=30, unique=True, verbose_name='链接名称')),
+ ('link', models.URLField(verbose_name='链接地址')),
+ ('sequence', models.IntegerField(unique=True, verbose_name='排序')),
+ ('is_enable', models.BooleanField(default=True, verbose_name='是否显示')),
+ ('show_type', models.CharField(choices=[('i', '首页'), ('l', '列表页'), ('p', '文章页面'), ('a', '全站'), ('s', '友情链接页面')], default='i', max_length=1, verbose_name='显示类型')),
+ ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
+ ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
+ ],
+ options={
+ 'verbose_name': '友情链接',
+ 'verbose_name_plural': '友情链接',
+ 'ordering': ['sequence'],
+ },
+ ),
+ migrations.CreateModel(
+ name='SideBar',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=100, verbose_name='标题')),
+ ('content', models.TextField(verbose_name='内容')),
+ ('sequence', models.IntegerField(unique=True, verbose_name='排序')),
+ ('is_enable', models.BooleanField(default=True, verbose_name='是否启用')),
+ ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
+ ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
+ ],
+ options={
+ 'verbose_name': '侧边栏',
+ 'verbose_name_plural': '侧边栏',
+ 'ordering': ['sequence'],
+ },
+ ),
+ migrations.CreateModel(
+ name='Tag',
+ fields=[
+ ('id', models.AutoField(primary_key=True, serialize=False)),
+ ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
+ ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
+ ('name', models.CharField(max_length=30, unique=True, verbose_name='标签名')),
+ ('slug', models.SlugField(blank=True, default='no-slug', max_length=60)),
+ ],
+ options={
+ 'verbose_name': '标签',
+ 'verbose_name_plural': '标签',
+ 'ordering': ['name'],
+ },
+ ),
+ migrations.CreateModel(
+ name='Category',
+ fields=[
+ ('id', models.AutoField(primary_key=True, serialize=False)),
+ ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
+ ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
+ ('name', models.CharField(max_length=30, unique=True, verbose_name='分类名')),
+ ('slug', models.SlugField(blank=True, default='no-slug', max_length=60)),
+ ('index', models.IntegerField(default=0, verbose_name='权重排序-越大越靠前')),
+ ('parent_category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='父级分类')),
+ ],
+ options={
+ 'verbose_name': '分类',
+ 'verbose_name_plural': '分类',
+ 'ordering': ['-index'],
+ },
+ ),
+ migrations.CreateModel(
+ name='Article',
+ fields=[
+ ('id', models.AutoField(primary_key=True, serialize=False)),
+ ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
+ ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
+ ('title', models.CharField(max_length=200, unique=True, verbose_name='标题')),
+ ('body', mdeditor.fields.MDTextField(verbose_name='正文')),
+ ('pub_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='发布时间')),
+ ('status', models.CharField(choices=[('d', '草稿'), ('p', '发表')], default='p', max_length=1, verbose_name='文章状态')),
+ ('comment_status', models.CharField(choices=[('o', '打开'), ('c', '关闭')], default='o', max_length=1, verbose_name='评论状态')),
+ ('type', models.CharField(choices=[('a', '文章'), ('p', '页面')], default='a', max_length=1, verbose_name='类型')),
+ ('views', models.PositiveIntegerField(default=0, verbose_name='浏览量')),
+ ('article_order', models.IntegerField(default=0, verbose_name='排序,数字越大越靠前')),
+ ('show_toc', models.BooleanField(default=False, verbose_name='是否显示toc目录')),
+ ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')),
+ ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='分类')),
+ ('tags', models.ManyToManyField(blank=True, to='blog.tag', verbose_name='标签集合')),
+ ],
+ options={
+ 'verbose_name': '文章',
+ 'verbose_name_plural': '文章',
+ 'ordering': ['-article_order', '-pub_time'],
+ 'get_latest_by': 'id',
+ },
+ ),
+ ]
diff --git a/blog/migrations/0002_blogsettings_global_footer_and_more.py b/blog/migrations/0002_blogsettings_global_footer_and_more.py
new file mode 100644
index 0000000..adbaa36
--- /dev/null
+++ b/blog/migrations/0002_blogsettings_global_footer_and_more.py
@@ -0,0 +1,23 @@
+# Generated by Django 4.1.7 on 2023-03-29 06:08
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('blog', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='blogsettings',
+ name='global_footer',
+ field=models.TextField(blank=True, default='', null=True, verbose_name='公共尾部'),
+ ),
+ migrations.AddField(
+ model_name='blogsettings',
+ name='global_header',
+ field=models.TextField(blank=True, default='', null=True, verbose_name='公共头部'),
+ ),
+ ]
diff --git a/blog/migrations/0003_blogsettings_comment_need_review.py b/blog/migrations/0003_blogsettings_comment_need_review.py
new file mode 100644
index 0000000..e9f5502
--- /dev/null
+++ b/blog/migrations/0003_blogsettings_comment_need_review.py
@@ -0,0 +1,17 @@
+# Generated by Django 4.2.1 on 2023-05-09 07:45
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('blog', '0002_blogsettings_global_footer_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='blogsettings',
+ name='comment_need_review',
+ field=models.BooleanField(default=False, verbose_name='评论是否需要审核'),
+ ),
+ ]
diff --git a/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py b/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py
new file mode 100644
index 0000000..ceb1398
--- /dev/null
+++ b/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py
@@ -0,0 +1,27 @@
+# Generated by Django 4.2.1 on 2023-05-09 07:51
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('blog', '0003_blogsettings_comment_need_review'),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name='blogsettings',
+ old_name='analyticscode',
+ new_name='analytics_code',
+ ),
+ migrations.RenameField(
+ model_name='blogsettings',
+ old_name='beiancode',
+ new_name='beian_code',
+ ),
+ migrations.RenameField(
+ model_name='blogsettings',
+ old_name='sitename',
+ new_name='site_name',
+ ),
+ ]
diff --git a/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py b/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py
new file mode 100644
index 0000000..d08e853
--- /dev/null
+++ b/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py
@@ -0,0 +1,300 @@
+# Generated by Django 4.2.5 on 2023-09-06 13:13
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import mdeditor.fields
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('blog', '0004_rename_analyticscode_blogsettings_analytics_code_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='article',
+ options={'get_latest_by': 'id', 'ordering': ['-article_order', '-pub_time'], 'verbose_name': 'article', 'verbose_name_plural': 'article'},
+ ),
+ migrations.AlterModelOptions(
+ name='category',
+ options={'ordering': ['-index'], 'verbose_name': 'category', 'verbose_name_plural': 'category'},
+ ),
+ migrations.AlterModelOptions(
+ name='links',
+ options={'ordering': ['sequence'], 'verbose_name': 'link', 'verbose_name_plural': 'link'},
+ ),
+ migrations.AlterModelOptions(
+ name='sidebar',
+ options={'ordering': ['sequence'], 'verbose_name': 'sidebar', 'verbose_name_plural': 'sidebar'},
+ ),
+ migrations.AlterModelOptions(
+ name='tag',
+ options={'ordering': ['name'], 'verbose_name': 'tag', 'verbose_name_plural': 'tag'},
+ ),
+ migrations.RemoveField(
+ model_name='article',
+ name='created_time',
+ ),
+ migrations.RemoveField(
+ model_name='article',
+ name='last_mod_time',
+ ),
+ migrations.RemoveField(
+ model_name='category',
+ name='created_time',
+ ),
+ migrations.RemoveField(
+ model_name='category',
+ name='last_mod_time',
+ ),
+ migrations.RemoveField(
+ model_name='links',
+ name='created_time',
+ ),
+ migrations.RemoveField(
+ model_name='sidebar',
+ name='created_time',
+ ),
+ migrations.RemoveField(
+ model_name='tag',
+ name='created_time',
+ ),
+ migrations.RemoveField(
+ model_name='tag',
+ name='last_mod_time',
+ ),
+ migrations.AddField(
+ model_name='article',
+ name='creation_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
+ ),
+ migrations.AddField(
+ model_name='article',
+ name='last_modify_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
+ ),
+ migrations.AddField(
+ model_name='category',
+ name='creation_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
+ ),
+ migrations.AddField(
+ model_name='category',
+ name='last_modify_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
+ ),
+ migrations.AddField(
+ model_name='links',
+ name='creation_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
+ ),
+ migrations.AddField(
+ model_name='sidebar',
+ name='creation_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
+ ),
+ migrations.AddField(
+ model_name='tag',
+ name='creation_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
+ ),
+ migrations.AddField(
+ model_name='tag',
+ name='last_modify_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='article_order',
+ field=models.IntegerField(default=0, verbose_name='order'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='author',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='body',
+ field=mdeditor.fields.MDTextField(verbose_name='body'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='category',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='category'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='comment_status',
+ field=models.CharField(choices=[('o', 'Open'), ('c', 'Close')], default='o', max_length=1, verbose_name='comment status'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='pub_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='publish time'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='show_toc',
+ field=models.BooleanField(default=False, verbose_name='show toc'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='status',
+ field=models.CharField(choices=[('d', 'Draft'), ('p', 'Published')], default='p', max_length=1, verbose_name='status'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='tags',
+ field=models.ManyToManyField(blank=True, to='blog.tag', verbose_name='tag'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='title',
+ field=models.CharField(max_length=200, unique=True, verbose_name='title'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='type',
+ field=models.CharField(choices=[('a', 'Article'), ('p', 'Page')], default='a', max_length=1, verbose_name='type'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='views',
+ field=models.PositiveIntegerField(default=0, verbose_name='views'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='article_comment_count',
+ field=models.IntegerField(default=5, verbose_name='article comment count'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='article_sub_length',
+ field=models.IntegerField(default=300, verbose_name='article sub length'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='google_adsense_codes',
+ field=models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='adsense code'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='open_site_comment',
+ field=models.BooleanField(default=True, verbose_name='open site comment'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='show_google_adsense',
+ field=models.BooleanField(default=False, verbose_name='show adsense'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='sidebar_article_count',
+ field=models.IntegerField(default=10, verbose_name='sidebar article count'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='sidebar_comment_count',
+ field=models.IntegerField(default=5, verbose_name='sidebar comment count'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='site_description',
+ field=models.TextField(default='', max_length=1000, verbose_name='site description'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='site_keywords',
+ field=models.TextField(default='', max_length=1000, verbose_name='site keywords'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='site_name',
+ field=models.CharField(default='', max_length=200, verbose_name='site name'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='site_seo_description',
+ field=models.TextField(default='', max_length=1000, verbose_name='site seo description'),
+ ),
+ migrations.AlterField(
+ model_name='category',
+ name='index',
+ field=models.IntegerField(default=0, verbose_name='index'),
+ ),
+ migrations.AlterField(
+ model_name='category',
+ name='name',
+ field=models.CharField(max_length=30, unique=True, verbose_name='category name'),
+ ),
+ migrations.AlterField(
+ model_name='category',
+ name='parent_category',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='parent category'),
+ ),
+ migrations.AlterField(
+ model_name='links',
+ name='is_enable',
+ field=models.BooleanField(default=True, verbose_name='is show'),
+ ),
+ migrations.AlterField(
+ model_name='links',
+ name='last_mod_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
+ ),
+ migrations.AlterField(
+ model_name='links',
+ name='link',
+ field=models.URLField(verbose_name='link'),
+ ),
+ migrations.AlterField(
+ model_name='links',
+ name='name',
+ field=models.CharField(max_length=30, unique=True, verbose_name='link name'),
+ ),
+ migrations.AlterField(
+ model_name='links',
+ name='sequence',
+ field=models.IntegerField(unique=True, verbose_name='order'),
+ ),
+ migrations.AlterField(
+ model_name='links',
+ name='show_type',
+ field=models.CharField(choices=[('i', 'index'), ('l', 'list'), ('p', 'post'), ('a', 'all'), ('s', 'slide')], default='i', max_length=1, verbose_name='show type'),
+ ),
+ migrations.AlterField(
+ model_name='sidebar',
+ name='content',
+ field=models.TextField(verbose_name='content'),
+ ),
+ migrations.AlterField(
+ model_name='sidebar',
+ name='is_enable',
+ field=models.BooleanField(default=True, verbose_name='is enable'),
+ ),
+ migrations.AlterField(
+ model_name='sidebar',
+ name='last_mod_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
+ ),
+ migrations.AlterField(
+ model_name='sidebar',
+ name='name',
+ field=models.CharField(max_length=100, verbose_name='title'),
+ ),
+ migrations.AlterField(
+ model_name='sidebar',
+ name='sequence',
+ field=models.IntegerField(unique=True, verbose_name='order'),
+ ),
+ migrations.AlterField(
+ model_name='tag',
+ name='name',
+ field=models.CharField(max_length=30, unique=True, verbose_name='tag name'),
+ ),
+ ]
diff --git a/blog/migrations/0006_alter_blogsettings_options.py b/blog/migrations/0006_alter_blogsettings_options.py
new file mode 100644
index 0000000..e36feb4
--- /dev/null
+++ b/blog/migrations/0006_alter_blogsettings_options.py
@@ -0,0 +1,17 @@
+# Generated by Django 4.2.7 on 2024-01-26 02:41
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('blog', '0005_alter_article_options_alter_category_options_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='blogsettings',
+ options={'verbose_name': 'Website configuration', 'verbose_name_plural': 'Website configuration'},
+ ),
+ ]
diff --git a/blog/migrations/__init__.py b/blog/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/blog/models.py b/blog/models.py
new file mode 100644
index 0000000..083788b
--- /dev/null
+++ b/blog/models.py
@@ -0,0 +1,376 @@
+import logging
+import re
+from abc import abstractmethod
+
+from django.conf import settings
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.urls import reverse
+from django.utils.timezone import now
+from django.utils.translation import gettext_lazy as _
+from mdeditor.fields import MDTextField
+from uuslug import slugify
+
+from djangoblog.utils import cache_decorator, cache
+from djangoblog.utils import get_current_site
+
+logger = logging.getLogger(__name__)
+
+
+class LinkShowType(models.TextChoices):
+ I = ('i', _('index'))
+ L = ('l', _('list'))
+ P = ('p', _('post'))
+ A = ('a', _('all'))
+ S = ('s', _('slide'))
+
+
+class BaseModel(models.Model):
+ id = models.AutoField(primary_key=True)
+ creation_time = models.DateTimeField(_('creation time'), default=now)
+ last_modify_time = models.DateTimeField(_('modify time'), default=now)
+
+ def save(self, *args, **kwargs):
+ is_update_views = isinstance(
+ self,
+ Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views']
+ if is_update_views:
+ Article.objects.filter(pk=self.pk).update(views=self.views)
+ else:
+ if 'slug' in self.__dict__:
+ slug = getattr(
+ self, 'title') if 'title' in self.__dict__ else getattr(
+ self, 'name')
+ setattr(self, 'slug', slugify(slug))
+ super().save(*args, **kwargs)
+
+ def get_full_url(self):
+ site = get_current_site().domain
+ url = "https://{site}{path}".format(site=site,
+ path=self.get_absolute_url())
+ return url
+
+ class Meta:
+ abstract = True
+
+ @abstractmethod
+ def get_absolute_url(self):
+ pass
+
+
+class Article(BaseModel):
+ """文章"""
+ STATUS_CHOICES = (
+ ('d', _('Draft')),
+ ('p', _('Published')),
+ )
+ COMMENT_STATUS = (
+ ('o', _('Open')),
+ ('c', _('Close')),
+ )
+ TYPE = (
+ ('a', _('Article')),
+ ('p', _('Page')),
+ )
+ title = models.CharField(_('title'), max_length=200, unique=True)
+ body = MDTextField(_('body'))
+ pub_time = models.DateTimeField(
+ _('publish time'), blank=False, null=False, default=now)
+ status = models.CharField(
+ _('status'),
+ max_length=1,
+ choices=STATUS_CHOICES,
+ default='p')
+ comment_status = models.CharField(
+ _('comment status'),
+ max_length=1,
+ choices=COMMENT_STATUS,
+ default='o')
+ type = models.CharField(_('type'), max_length=1, choices=TYPE, default='a')
+ views = models.PositiveIntegerField(_('views'), default=0)
+ author = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ verbose_name=_('author'),
+ blank=False,
+ null=False,
+ on_delete=models.CASCADE)
+ article_order = models.IntegerField(
+ _('order'), blank=False, null=False, default=0)
+ show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False)
+ category = models.ForeignKey(
+ 'Category',
+ verbose_name=_('category'),
+ on_delete=models.CASCADE,
+ blank=False,
+ null=False)
+ tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True)
+
+ def body_to_string(self):
+ return self.body
+
+ def __str__(self):
+ return self.title
+
+ class Meta:
+ ordering = ['-article_order', '-pub_time']
+ verbose_name = _('article')
+ verbose_name_plural = verbose_name
+ get_latest_by = 'id'
+
+ def get_absolute_url(self):
+ return reverse('blog:detailbyid', kwargs={
+ 'article_id': self.id,
+ 'year': self.creation_time.year,
+ 'month': self.creation_time.month,
+ 'day': self.creation_time.day
+ })
+
+ @cache_decorator(60 * 60 * 10)
+ def get_category_tree(self):
+ tree = self.category.get_category_tree()
+ names = list(map(lambda c: (c.name, c.get_absolute_url()), tree))
+
+ return names
+
+ def save(self, *args, **kwargs):
+ super().save(*args, **kwargs)
+
+ def viewed(self):
+ self.views += 1
+ self.save(update_fields=['views'])
+
+ def comment_list(self):
+ cache_key = 'article_comments_{id}'.format(id=self.id)
+ value = cache.get(cache_key)
+ if value:
+ logger.info('get article comments:{id}'.format(id=self.id))
+ return value
+ else:
+ comments = self.comment_set.filter(is_enable=True).order_by('-id')
+ cache.set(cache_key, comments, 60 * 100)
+ logger.info('set article comments:{id}'.format(id=self.id))
+ return comments
+
+ def get_admin_url(self):
+ info = (self._meta.app_label, self._meta.model_name)
+ return reverse('admin:%s_%s_change' % info, args=(self.pk,))
+
+ @cache_decorator(expiration=60 * 100)
+ def next_article(self):
+ # 下一篇
+ return Article.objects.filter(
+ id__gt=self.id, status='p').order_by('id').first()
+
+ @cache_decorator(expiration=60 * 100)
+ def prev_article(self):
+ # 前一篇
+ return Article.objects.filter(id__lt=self.id, status='p').first()
+
+ def get_first_image_url(self):
+ """
+ Get the first image url from article.body.
+ :return:
+ """
+ match = re.search(r'!\[.*?\]\((.+?)\)', self.body)
+ if match:
+ return match.group(1)
+ return ""
+
+
+class Category(BaseModel):
+ """文章分类"""
+ name = models.CharField(_('category name'), max_length=30, unique=True)
+ parent_category = models.ForeignKey(
+ 'self',
+ verbose_name=_('parent category'),
+ blank=True,
+ null=True,
+ on_delete=models.CASCADE)
+ slug = models.SlugField(default='no-slug', max_length=60, blank=True)
+ index = models.IntegerField(default=0, verbose_name=_('index'))
+
+ class Meta:
+ ordering = ['-index']
+ verbose_name = _('category')
+ verbose_name_plural = verbose_name
+
+ def get_absolute_url(self):
+ return reverse(
+ 'blog:category_detail', kwargs={
+ 'category_name': self.slug})
+
+ def __str__(self):
+ return self.name
+
+ @cache_decorator(60 * 60 * 10)
+ def get_category_tree(self):
+ """
+ 递归获得分类目录的父级
+ :return:
+ """
+ categorys = []
+
+ def parse(category):
+ categorys.append(category)
+ if category.parent_category:
+ parse(category.parent_category)
+
+ parse(self)
+ return categorys
+
+ @cache_decorator(60 * 60 * 10)
+ def get_sub_categorys(self):
+ """
+ 获得当前分类目录所有子集
+ :return:
+ """
+ categorys = []
+ all_categorys = Category.objects.all()
+
+ def parse(category):
+ if category not in categorys:
+ categorys.append(category)
+ childs = all_categorys.filter(parent_category=category)
+ for child in childs:
+ if category not in categorys:
+ categorys.append(child)
+ parse(child)
+
+ parse(self)
+ return categorys
+
+
+class Tag(BaseModel):
+ """文章标签"""
+ name = models.CharField(_('tag name'), max_length=30, unique=True)
+ slug = models.SlugField(default='no-slug', max_length=60, blank=True)
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return reverse('blog:tag_detail', kwargs={'tag_name': self.slug})
+
+ @cache_decorator(60 * 60 * 10)
+ def get_article_count(self):
+ return Article.objects.filter(tags__name=self.name).distinct().count()
+
+ class Meta:
+ ordering = ['name']
+ verbose_name = _('tag')
+ verbose_name_plural = verbose_name
+
+
+class Links(models.Model):
+ """友情链接"""
+
+ name = models.CharField(_('link name'), max_length=30, unique=True)
+ link = models.URLField(_('link'))
+ sequence = models.IntegerField(_('order'), unique=True)
+ is_enable = models.BooleanField(
+ _('is show'), default=True, blank=False, null=False)
+ show_type = models.CharField(
+ _('show type'),
+ max_length=1,
+ choices=LinkShowType.choices,
+ default=LinkShowType.I)
+ creation_time = models.DateTimeField(_('creation time'), default=now)
+ last_mod_time = models.DateTimeField(_('modify time'), default=now)
+
+ class Meta:
+ ordering = ['sequence']
+ verbose_name = _('link')
+ verbose_name_plural = verbose_name
+
+ def __str__(self):
+ return self.name
+
+
+class SideBar(models.Model):
+ """侧边栏,可以展示一些html内容"""
+ name = models.CharField(_('title'), max_length=100)
+ content = models.TextField(_('content'))
+ sequence = models.IntegerField(_('order'), unique=True)
+ is_enable = models.BooleanField(_('is enable'), default=True)
+ creation_time = models.DateTimeField(_('creation time'), default=now)
+ last_mod_time = models.DateTimeField(_('modify time'), default=now)
+
+ class Meta:
+ ordering = ['sequence']
+ verbose_name = _('sidebar')
+ verbose_name_plural = verbose_name
+
+ def __str__(self):
+ return self.name
+
+
+class BlogSettings(models.Model):
+ """blog的配置"""
+ site_name = models.CharField(
+ _('site name'),
+ max_length=200,
+ null=False,
+ blank=False,
+ default='')
+ site_description = models.TextField(
+ _('site description'),
+ max_length=1000,
+ null=False,
+ blank=False,
+ default='')
+ site_seo_description = models.TextField(
+ _('site seo description'), max_length=1000, null=False, blank=False, default='')
+ site_keywords = models.TextField(
+ _('site keywords'),
+ max_length=1000,
+ null=False,
+ blank=False,
+ default='')
+ article_sub_length = models.IntegerField(_('article sub length'), default=300)
+ sidebar_article_count = models.IntegerField(_('sidebar article count'), default=10)
+ sidebar_comment_count = models.IntegerField(_('sidebar comment count'), default=5)
+ article_comment_count = models.IntegerField(_('article comment count'), default=5)
+ show_google_adsense = models.BooleanField(_('show adsense'), default=False)
+ google_adsense_codes = models.TextField(
+ _('adsense code'), max_length=2000, null=True, blank=True, default='')
+ open_site_comment = models.BooleanField(_('open site comment'), default=True)
+ global_header = models.TextField("公共头部", null=True, blank=True, default='')
+ global_footer = models.TextField("公共尾部", null=True, blank=True, default='')
+ beian_code = models.CharField(
+ '备案号',
+ max_length=2000,
+ null=True,
+ blank=True,
+ default='')
+ analytics_code = models.TextField(
+ "网站统计代码",
+ max_length=1000,
+ null=False,
+ blank=False,
+ default='')
+ show_gongan_code = models.BooleanField(
+ '是否显示公安备案号', default=False, null=False)
+ gongan_beiancode = models.TextField(
+ '公安备案号',
+ max_length=2000,
+ null=True,
+ blank=True,
+ default='')
+ comment_need_review = models.BooleanField(
+ '评论是否需要审核', default=False, null=False)
+
+ class Meta:
+ verbose_name = _('Website configuration')
+ verbose_name_plural = verbose_name
+
+ def __str__(self):
+ return self.site_name
+
+ def clean(self):
+ if BlogSettings.objects.exclude(id=self.id).count():
+ raise ValidationError(_('There can only be one configuration'))
+
+ def save(self, *args, **kwargs):
+ super().save(*args, **kwargs)
+ from djangoblog.utils import cache
+ cache.clear()
diff --git a/blog/search_indexes.py b/blog/search_indexes.py
new file mode 100644
index 0000000..7f1dfac
--- /dev/null
+++ b/blog/search_indexes.py
@@ -0,0 +1,13 @@
+from haystack import indexes
+
+from blog.models import Article
+
+
+class ArticleIndex(indexes.SearchIndex, indexes.Indexable):
+ text = indexes.CharField(document=True, use_template=True)
+
+ def get_model(self):
+ return Article
+
+ def index_queryset(self, using=None):
+ return self.get_model().objects.filter(status='p')
diff --git a/blog/templatetags/__init__.py b/blog/templatetags/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/blog/templatetags/blog_tags.py b/blog/templatetags/blog_tags.py
new file mode 100644
index 0000000..d6cd5d5
--- /dev/null
+++ b/blog/templatetags/blog_tags.py
@@ -0,0 +1,344 @@
+import hashlib
+import logging
+import random
+import urllib
+
+from django import template
+from django.conf import settings
+from django.db.models import Q
+from django.shortcuts import get_object_or_404
+from django.template.defaultfilters import stringfilter
+from django.templatetags.static import static
+from django.urls import reverse
+from django.utils.safestring import mark_safe
+
+from blog.models import Article, Category, Tag, Links, SideBar, LinkShowType
+from comments.models import Comment
+from djangoblog.utils import CommonMarkdown, sanitize_html
+from djangoblog.utils import cache
+from djangoblog.utils import get_current_site
+from oauth.models import OAuthUser
+from djangoblog.plugin_manage import hooks
+
+logger = logging.getLogger(__name__)
+
+register = template.Library()
+
+
+@register.simple_tag(takes_context=True)
+def head_meta(context):
+ return mark_safe(hooks.apply_filters('head_meta', '', context))
+
+
+@register.simple_tag
+def timeformat(data):
+ try:
+ return data.strftime(settings.TIME_FORMAT)
+ except Exception as e:
+ logger.error(e)
+ return ""
+
+
+@register.simple_tag
+def datetimeformat(data):
+ try:
+ return data.strftime(settings.DATE_TIME_FORMAT)
+ except Exception as e:
+ logger.error(e)
+ return ""
+
+
+@register.filter()
+@stringfilter
+def custom_markdown(content):
+ return mark_safe(CommonMarkdown.get_markdown(content))
+
+
+@register.simple_tag
+def get_markdown_toc(content):
+ from djangoblog.utils import CommonMarkdown
+ body, toc = CommonMarkdown.get_markdown_with_toc(content)
+ return mark_safe(toc)
+
+
+@register.filter()
+@stringfilter
+def comment_markdown(content):
+ content = CommonMarkdown.get_markdown(content)
+ return mark_safe(sanitize_html(content))
+
+
+@register.filter(is_safe=True)
+@stringfilter
+def truncatechars_content(content):
+ """
+ 获得文章内容的摘要
+ :param content:
+ :return:
+ """
+ from django.template.defaultfilters import truncatechars_html
+ from djangoblog.utils import get_blog_setting
+ blogsetting = get_blog_setting()
+ return truncatechars_html(content, blogsetting.article_sub_length)
+
+
+@register.filter(is_safe=True)
+@stringfilter
+def truncate(content):
+ from django.utils.html import strip_tags
+
+ return strip_tags(content)[:150]
+
+
+@register.inclusion_tag('blog/tags/breadcrumb.html')
+def load_breadcrumb(article):
+ """
+ 获得文章面包屑
+ :param article:
+ :return:
+ """
+ names = article.get_category_tree()
+ from djangoblog.utils import get_blog_setting
+ blogsetting = get_blog_setting()
+ site = get_current_site().domain
+ names.append((blogsetting.site_name, '/'))
+ names = names[::-1]
+
+ return {
+ 'names': names,
+ 'title': article.title,
+ 'count': len(names) + 1
+ }
+
+
+@register.inclusion_tag('blog/tags/article_tag_list.html')
+def load_articletags(article):
+ """
+ 文章标签
+ :param article:
+ :return:
+ """
+ tags = article.tags.all()
+ tags_list = []
+ for tag in tags:
+ url = tag.get_absolute_url()
+ count = tag.get_article_count()
+ tags_list.append((
+ url, count, tag, random.choice(settings.BOOTSTRAP_COLOR_TYPES)
+ ))
+ return {
+ 'article_tags_list': tags_list
+ }
+
+
+@register.inclusion_tag('blog/tags/sidebar.html')
+def load_sidebar(user, linktype):
+ """
+ 加载侧边栏
+ :return:
+ """
+ value = cache.get("sidebar" + linktype)
+ if value:
+ value['user'] = user
+ return value
+ else:
+ logger.info('load sidebar')
+ from djangoblog.utils import get_blog_setting
+ blogsetting = get_blog_setting()
+ recent_articles = Article.objects.filter(
+ status='p')[:blogsetting.sidebar_article_count]
+ sidebar_categorys = Category.objects.all()
+ extra_sidebars = SideBar.objects.filter(
+ is_enable=True).order_by('sequence')
+ most_read_articles = Article.objects.filter(status='p').order_by(
+ '-views')[:blogsetting.sidebar_article_count]
+ dates = Article.objects.datetimes('creation_time', 'month', order='DESC')
+ links = Links.objects.filter(is_enable=True).filter(
+ Q(show_type=str(linktype)) | Q(show_type=LinkShowType.A))
+ commment_list = Comment.objects.filter(is_enable=True).order_by(
+ '-id')[:blogsetting.sidebar_comment_count]
+ # 标签云 计算字体大小
+ # 根据总数计算出平均值 大小为 (数目/平均值)*步长
+ increment = 5
+ tags = Tag.objects.all()
+ sidebar_tags = None
+ if tags and len(tags) > 0:
+ s = [t for t in [(t, t.get_article_count()) for t in tags] if t[1]]
+ count = sum([t[1] for t in s])
+ dd = 1 if (count == 0 or not len(tags)) else count / len(tags)
+ import random
+ sidebar_tags = list(
+ map(lambda x: (x[0], x[1], (x[1] / dd) * increment + 10), s))
+ random.shuffle(sidebar_tags)
+
+ value = {
+ 'recent_articles': recent_articles,
+ 'sidebar_categorys': sidebar_categorys,
+ 'most_read_articles': most_read_articles,
+ 'article_dates': dates,
+ 'sidebar_comments': commment_list,
+ 'sidabar_links': links,
+ 'show_google_adsense': blogsetting.show_google_adsense,
+ 'google_adsense_codes': blogsetting.google_adsense_codes,
+ 'open_site_comment': blogsetting.open_site_comment,
+ 'show_gongan_code': blogsetting.show_gongan_code,
+ 'sidebar_tags': sidebar_tags,
+ 'extra_sidebars': extra_sidebars
+ }
+ cache.set("sidebar" + linktype, value, 60 * 60 * 60 * 3)
+ logger.info('set sidebar cache.key:{key}'.format(key="sidebar" + linktype))
+ value['user'] = user
+ return value
+
+
+@register.inclusion_tag('blog/tags/article_meta_info.html')
+def load_article_metas(article, user):
+ """
+ 获得文章meta信息
+ :param article:
+ :return:
+ """
+ return {
+ 'article': article,
+ 'user': user
+ }
+
+
+@register.inclusion_tag('blog/tags/article_pagination.html')
+def load_pagination_info(page_obj, page_type, tag_name):
+ previous_url = ''
+ next_url = ''
+ if page_type == '':
+ if page_obj.has_next():
+ next_number = page_obj.next_page_number()
+ next_url = reverse('blog:index_page', kwargs={'page': next_number})
+ if page_obj.has_previous():
+ previous_number = page_obj.previous_page_number()
+ previous_url = reverse(
+ 'blog:index_page', kwargs={
+ 'page': previous_number})
+ if page_type == '分类标签归档':
+ tag = get_object_or_404(Tag, name=tag_name)
+ if page_obj.has_next():
+ next_number = page_obj.next_page_number()
+ next_url = reverse(
+ 'blog:tag_detail_page',
+ kwargs={
+ 'page': next_number,
+ 'tag_name': tag.slug})
+ if page_obj.has_previous():
+ previous_number = page_obj.previous_page_number()
+ previous_url = reverse(
+ 'blog:tag_detail_page',
+ kwargs={
+ 'page': previous_number,
+ 'tag_name': tag.slug})
+ if page_type == '作者文章归档':
+ if page_obj.has_next():
+ next_number = page_obj.next_page_number()
+ next_url = reverse(
+ 'blog:author_detail_page',
+ kwargs={
+ 'page': next_number,
+ 'author_name': tag_name})
+ if page_obj.has_previous():
+ previous_number = page_obj.previous_page_number()
+ previous_url = reverse(
+ 'blog:author_detail_page',
+ kwargs={
+ 'page': previous_number,
+ 'author_name': tag_name})
+
+ if page_type == '分类目录归档':
+ category = get_object_or_404(Category, name=tag_name)
+ if page_obj.has_next():
+ next_number = page_obj.next_page_number()
+ next_url = reverse(
+ 'blog:category_detail_page',
+ kwargs={
+ 'page': next_number,
+ 'category_name': category.slug})
+ if page_obj.has_previous():
+ previous_number = page_obj.previous_page_number()
+ previous_url = reverse(
+ 'blog:category_detail_page',
+ kwargs={
+ 'page': previous_number,
+ 'category_name': category.slug})
+
+ return {
+ 'previous_url': previous_url,
+ 'next_url': next_url,
+ 'page_obj': page_obj
+ }
+
+
+@register.inclusion_tag('blog/tags/article_info.html')
+def load_article_detail(article, isindex, user):
+ """
+ 加载文章详情
+ :param article:
+ :param isindex:是否列表页,若是列表页只显示摘要
+ :return:
+ """
+ from djangoblog.utils import get_blog_setting
+ blogsetting = get_blog_setting()
+
+ return {
+ 'article': article,
+ 'isindex': isindex,
+ 'user': user,
+ 'open_site_comment': blogsetting.open_site_comment,
+ }
+
+
+# return only the URL of the gravatar
+# TEMPLATE USE: {{ email|gravatar_url:150 }}
+@register.filter
+def gravatar_url(email, size=40):
+ """获得gravatar头像"""
+ cachekey = 'gravatat/' + email
+ url = cache.get(cachekey)
+ if url:
+ return url
+ else:
+ usermodels = OAuthUser.objects.filter(email=email)
+ if usermodels:
+ o = list(filter(lambda x: x.picture is not None, usermodels))
+ if o:
+ return o[0].picture
+ email = email.encode('utf-8')
+
+ default = static('blog/img/avatar.png')
+
+ url = "https://www.gravatar.com/avatar/%s?%s" % (hashlib.md5(
+ email.lower()).hexdigest(), urllib.parse.urlencode({'d': default, 's': str(size)}))
+ cache.set(cachekey, url, 60 * 60 * 10)
+ logger.info('set gravatar cache.key:{key}'.format(key=cachekey))
+ return url
+
+
+@register.filter
+def gravatar(email, size=40):
+ """获得gravatar头像"""
+ url = gravatar_url(email, size)
+ return mark_safe(
+ '
' %
+ (url, size, size))
+
+
+@register.simple_tag
+def query(qs, **kwargs):
+ """ template tag which allows queryset filtering. Usage:
+ {% query books author=author as mybooks %}
+ {% for book in mybooks %}
+ ...
+ {% endfor %}
+ """
+ return qs.filter(**kwargs)
+
+
+@register.filter
+def addstr(arg1, arg2):
+ """concatenate arg1 & arg2"""
+ return str(arg1) + str(arg2)
diff --git a/blog/tests.py b/blog/tests.py
new file mode 100644
index 0000000..ee13505
--- /dev/null
+++ b/blog/tests.py
@@ -0,0 +1,232 @@
+import os
+
+from django.conf import settings
+from django.core.files.uploadedfile import SimpleUploadedFile
+from django.core.management import call_command
+from django.core.paginator import Paginator
+from django.templatetags.static import static
+from django.test import Client, RequestFactory, TestCase
+from django.urls import reverse
+from django.utils import timezone
+
+from accounts.models import BlogUser
+from blog.forms import BlogSearchForm
+from blog.models import Article, Category, Tag, SideBar, Links
+from blog.templatetags.blog_tags import load_pagination_info, load_articletags
+from djangoblog.utils import get_current_site, get_sha256
+from oauth.models import OAuthUser, OAuthConfig
+
+
+# Create your tests here.
+
+class ArticleTest(TestCase):
+ def setUp(self):
+ self.client = Client()
+ self.factory = RequestFactory()
+
+ def test_validate_article(self):
+ site = get_current_site().domain
+ user = BlogUser.objects.get_or_create(
+ email="liangliangyy@gmail.com",
+ username="liangliangyy")[0]
+ user.set_password("liangliangyy")
+ user.is_staff = True
+ user.is_superuser = True
+ user.save()
+ response = self.client.get(user.get_absolute_url())
+ self.assertEqual(response.status_code, 200)
+ response = self.client.get('/admin/servermanager/emailsendlog/')
+ response = self.client.get('admin/admin/logentry/')
+ s = SideBar()
+ s.sequence = 1
+ s.name = 'test'
+ s.content = 'test content'
+ s.is_enable = True
+ s.save()
+
+ category = Category()
+ category.name = "category"
+ category.creation_time = timezone.now()
+ category.last_mod_time = timezone.now()
+ category.save()
+
+ tag = Tag()
+ tag.name = "nicetag"
+ tag.save()
+
+ article = Article()
+ article.title = "nicetitle"
+ article.body = "nicecontent"
+ article.author = user
+ article.category = category
+ article.type = 'a'
+ article.status = 'p'
+
+ article.save()
+ self.assertEqual(0, article.tags.count())
+ article.tags.add(tag)
+ article.save()
+ self.assertEqual(1, article.tags.count())
+
+ for i in range(20):
+ article = Article()
+ article.title = "nicetitle" + str(i)
+ article.body = "nicetitle" + str(i)
+ article.author = user
+ article.category = category
+ article.type = 'a'
+ article.status = 'p'
+ article.save()
+ article.tags.add(tag)
+ article.save()
+ from blog.documents import ELASTICSEARCH_ENABLED
+ if ELASTICSEARCH_ENABLED:
+ call_command("build_index")
+ response = self.client.get('/search', {'q': 'nicetitle'})
+ self.assertEqual(response.status_code, 200)
+
+ response = self.client.get(article.get_absolute_url())
+ self.assertEqual(response.status_code, 200)
+ from djangoblog.spider_notify import SpiderNotify
+ SpiderNotify.notify(article.get_absolute_url())
+ response = self.client.get(tag.get_absolute_url())
+ self.assertEqual(response.status_code, 200)
+
+ response = self.client.get(category.get_absolute_url())
+ self.assertEqual(response.status_code, 200)
+
+ response = self.client.get('/search', {'q': 'django'})
+ self.assertEqual(response.status_code, 200)
+ s = load_articletags(article)
+ self.assertIsNotNone(s)
+
+ self.client.login(username='liangliangyy', password='liangliangyy')
+
+ response = self.client.get(reverse('blog:archives'))
+ self.assertEqual(response.status_code, 200)
+
+ p = Paginator(Article.objects.all(), settings.PAGINATE_BY)
+ self.check_pagination(p, '', '')
+
+ p = Paginator(Article.objects.filter(tags=tag), settings.PAGINATE_BY)
+ self.check_pagination(p, '分类标签归档', tag.slug)
+
+ p = Paginator(
+ Article.objects.filter(
+ author__username='liangliangyy'), settings.PAGINATE_BY)
+ self.check_pagination(p, '作者文章归档', 'liangliangyy')
+
+ p = Paginator(Article.objects.filter(category=category), settings.PAGINATE_BY)
+ self.check_pagination(p, '分类目录归档', category.slug)
+
+ f = BlogSearchForm()
+ f.search()
+ # self.client.login(username='liangliangyy', password='liangliangyy')
+ from djangoblog.spider_notify import SpiderNotify
+ SpiderNotify.baidu_notify([article.get_full_url()])
+
+ from blog.templatetags.blog_tags import gravatar_url, gravatar
+ u = gravatar_url('liangliangyy@gmail.com')
+ u = gravatar('liangliangyy@gmail.com')
+
+ link = Links(
+ sequence=1,
+ name="lylinux",
+ link='https://wwww.lylinux.net')
+ link.save()
+ response = self.client.get('/links.html')
+ self.assertEqual(response.status_code, 200)
+
+ response = self.client.get('/feed/')
+ self.assertEqual(response.status_code, 200)
+
+ response = self.client.get('/sitemap.xml')
+ self.assertEqual(response.status_code, 200)
+
+ self.client.get("/admin/blog/article/1/delete/")
+ self.client.get('/admin/servermanager/emailsendlog/')
+ self.client.get('/admin/admin/logentry/')
+ self.client.get('/admin/admin/logentry/1/change/')
+
+ def check_pagination(self, p, type, value):
+ for page in range(1, p.num_pages + 1):
+ s = load_pagination_info(p.page(page), type, value)
+ self.assertIsNotNone(s)
+ if s['previous_url']:
+ response = self.client.get(s['previous_url'])
+ self.assertEqual(response.status_code, 200)
+ if s['next_url']:
+ response = self.client.get(s['next_url'])
+ self.assertEqual(response.status_code, 200)
+
+ def test_image(self):
+ import requests
+ rsp = requests.get(
+ 'https://www.python.org/static/img/python-logo.png')
+ imagepath = os.path.join(settings.BASE_DIR, 'python.png')
+ with open(imagepath, 'wb') as file:
+ file.write(rsp.content)
+ rsp = self.client.post('/upload')
+ self.assertEqual(rsp.status_code, 403)
+ sign = get_sha256(get_sha256(settings.SECRET_KEY))
+ with open(imagepath, 'rb') as file:
+ imgfile = SimpleUploadedFile(
+ 'python.png', file.read(), content_type='image/jpg')
+ form_data = {'python.png': imgfile}
+ rsp = self.client.post(
+ '/upload?sign=' + sign, form_data, follow=True)
+ self.assertEqual(rsp.status_code, 200)
+ os.remove(imagepath)
+ from djangoblog.utils import save_user_avatar, send_email
+ send_email(['qq@qq.com'], 'testTitle', 'testContent')
+ save_user_avatar(
+ 'https://www.python.org/static/img/python-logo.png')
+
+ def test_errorpage(self):
+ rsp = self.client.get('/eee')
+ self.assertEqual(rsp.status_code, 404)
+
+ def test_commands(self):
+ user = BlogUser.objects.get_or_create(
+ email="liangliangyy@gmail.com",
+ username="liangliangyy")[0]
+ user.set_password("liangliangyy")
+ user.is_staff = True
+ user.is_superuser = True
+ user.save()
+
+ c = OAuthConfig()
+ c.type = 'qq'
+ c.appkey = 'appkey'
+ c.appsecret = 'appsecret'
+ c.save()
+
+ u = OAuthUser()
+ u.type = 'qq'
+ u.openid = 'openid'
+ u.user = user
+ u.picture = static("/blog/img/avatar.png")
+ u.metadata = '''
+{
+"figureurl": "https://qzapp.qlogo.cn/qzapp/101513904/C740E30B4113EAA80E0D9918ABC78E82/30"
+}'''
+ u.save()
+
+ u = OAuthUser()
+ u.type = 'qq'
+ u.openid = 'openid1'
+ u.picture = 'https://qzapp.qlogo.cn/qzapp/101513904/C740E30B4113EAA80E0D9918ABC78E82/30'
+ u.metadata = '''
+ {
+ "figureurl": "https://qzapp.qlogo.cn/qzapp/101513904/C740E30B4113EAA80E0D9918ABC78E82/30"
+ }'''
+ u.save()
+
+ from blog.documents import ELASTICSEARCH_ENABLED
+ if ELASTICSEARCH_ENABLED:
+ call_command("build_index")
+ call_command("ping_baidu", "all")
+ call_command("create_testdata")
+ call_command("clear_cache")
+ call_command("sync_user_avatar")
+ call_command("build_search_words")
diff --git a/blog/urls.py b/blog/urls.py
new file mode 100644
index 0000000..adf2703
--- /dev/null
+++ b/blog/urls.py
@@ -0,0 +1,62 @@
+from django.urls import path
+from django.views.decorators.cache import cache_page
+
+from . import views
+
+app_name = "blog"
+urlpatterns = [
+ path(
+ r'',
+ views.IndexView.as_view(),
+ name='index'),
+ path(
+ r'page//',
+ views.IndexView.as_view(),
+ name='index_page'),
+ path(
+ r'article////.html',
+ views.ArticleDetailView.as_view(),
+ name='detailbyid'),
+ path(
+ r'category/.html',
+ views.CategoryDetailView.as_view(),
+ name='category_detail'),
+ path(
+ r'category//.html',
+ views.CategoryDetailView.as_view(),
+ name='category_detail_page'),
+ path(
+ r'author/.html',
+ views.AuthorDetailView.as_view(),
+ name='author_detail'),
+ path(
+ r'author//.html',
+ views.AuthorDetailView.as_view(),
+ name='author_detail_page'),
+ path(
+ r'tag/.html',
+ views.TagDetailView.as_view(),
+ name='tag_detail'),
+ path(
+ r'tag//.html',
+ views.TagDetailView.as_view(),
+ name='tag_detail_page'),
+ path(
+ 'archives.html',
+ cache_page(
+ 60 * 60)(
+ views.ArchivesView.as_view()),
+ name='archives'),
+ path(
+ 'links.html',
+ views.LinkListView.as_view(),
+ name='links'),
+ path(
+ r'upload',
+ views.fileupload,
+ name='upload'),
+ path(
+ r'clean',
+ views.clean_cache_view,
+ name='clean'),
+]
diff --git a/blog/views.py b/blog/views.py
new file mode 100644
index 0000000..d5dc7ec
--- /dev/null
+++ b/blog/views.py
@@ -0,0 +1,379 @@
+import logging
+import os
+import uuid
+
+from django.conf import settings
+from django.core.paginator import Paginator
+from django.http import HttpResponse, HttpResponseForbidden
+from django.shortcuts import get_object_or_404
+from django.shortcuts import render
+from django.templatetags.static import static
+from django.utils import timezone
+from django.utils.translation import gettext_lazy as _
+from django.views.decorators.csrf import csrf_exempt
+from django.views.generic.detail import DetailView
+from django.views.generic.list import ListView
+from haystack.views import SearchView
+
+from blog.models import Article, Category, LinkShowType, Links, Tag
+from comments.forms import CommentForm
+from djangoblog.plugin_manage import hooks
+from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
+from djangoblog.utils import cache, get_blog_setting, get_sha256
+
+logger = logging.getLogger(__name__)
+
+
+class ArticleListView(ListView):
+ # template_name属性用于指定使用哪个模板进行渲染
+ template_name = 'blog/article_index.html'
+
+ # context_object_name属性用于给上下文变量取名(在模板中使用该名字)
+ context_object_name = 'article_list'
+
+ # 页面类型,分类目录或标签列表等
+ page_type = ''
+ paginate_by = settings.PAGINATE_BY
+ page_kwarg = 'page'
+ link_type = LinkShowType.L
+
+ def get_view_cache_key(self):
+ return self.request.get['pages']
+
+ @property
+ def page_number(self):
+ page_kwarg = self.page_kwarg
+ page = self.kwargs.get(
+ page_kwarg) or self.request.GET.get(page_kwarg) or 1
+ return page
+
+ def get_queryset_cache_key(self):
+ """
+ 子类重写.获得queryset的缓存key
+ """
+ raise NotImplementedError()
+
+ def get_queryset_data(self):
+ """
+ 子类重写.获取queryset的数据
+ """
+ raise NotImplementedError()
+
+ def get_queryset_from_cache(self, cache_key):
+ '''
+ 缓存页面数据
+ :param cache_key: 缓存key
+ :return:
+ '''
+ value = cache.get(cache_key)
+ if value:
+ logger.info('get view cache.key:{key}'.format(key=cache_key))
+ return value
+ else:
+ article_list = self.get_queryset_data()
+ cache.set(cache_key, article_list)
+ logger.info('set view cache.key:{key}'.format(key=cache_key))
+ return article_list
+
+ def get_queryset(self):
+ '''
+ 重写默认,从缓存获取数据
+ :return:
+ '''
+ key = self.get_queryset_cache_key()
+ value = self.get_queryset_from_cache(key)
+ return value
+
+ def get_context_data(self, **kwargs):
+ kwargs['linktype'] = self.link_type
+ return super(ArticleListView, self).get_context_data(**kwargs)
+
+
+class IndexView(ArticleListView):
+ '''
+ 首页
+ '''
+ # 友情链接类型
+ link_type = LinkShowType.I
+
+ def get_queryset_data(self):
+ article_list = Article.objects.filter(type='a', status='p')
+ return article_list
+
+ def get_queryset_cache_key(self):
+ cache_key = 'index_{page}'.format(page=self.page_number)
+ return cache_key
+
+
+class ArticleDetailView(DetailView):
+ '''
+ 文章详情页面
+ '''
+ template_name = 'blog/article_detail.html'
+ model = Article
+ pk_url_kwarg = 'article_id'
+ context_object_name = "article"
+
+ def get_context_data(self, **kwargs):
+ comment_form = CommentForm()
+
+ article_comments = self.object.comment_list()
+ parent_comments = article_comments.filter(parent_comment=None)
+ blog_setting = get_blog_setting()
+ paginator = Paginator(parent_comments, blog_setting.article_comment_count)
+ page = self.request.GET.get('comment_page', '1')
+ if not page.isnumeric():
+ page = 1
+ else:
+ page = int(page)
+ if page < 1:
+ page = 1
+ if page > paginator.num_pages:
+ page = paginator.num_pages
+
+ p_comments = paginator.page(page)
+ next_page = p_comments.next_page_number() if p_comments.has_next() else None
+ prev_page = p_comments.previous_page_number() if p_comments.has_previous() else None
+
+ if next_page:
+ kwargs[
+ 'comment_next_page_url'] = self.object.get_absolute_url() + f'?comment_page={next_page}#commentlist-container'
+ if prev_page:
+ kwargs[
+ 'comment_prev_page_url'] = self.object.get_absolute_url() + f'?comment_page={prev_page}#commentlist-container'
+ kwargs['form'] = comment_form
+ kwargs['article_comments'] = article_comments
+ kwargs['p_comments'] = p_comments
+ kwargs['comment_count'] = len(
+ article_comments) if article_comments else 0
+
+ kwargs['next_article'] = self.object.next_article
+ kwargs['prev_article'] = self.object.prev_article
+
+ context = super(ArticleDetailView, self).get_context_data(**kwargs)
+ article = self.object
+ # Action Hook, 通知插件"文章详情已获取"
+ hooks.run_action('after_article_body_get', article=article, request=self.request)
+ # # Filter Hook, 允许插件修改文章正文
+ article.body = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, article.body, article=article,
+ request=self.request)
+
+ return context
+
+
+class CategoryDetailView(ArticleListView):
+ '''
+ 分类目录列表
+ '''
+ page_type = "分类目录归档"
+
+ def get_queryset_data(self):
+ slug = self.kwargs['category_name']
+ category = get_object_or_404(Category, slug=slug)
+
+ categoryname = category.name
+ self.categoryname = categoryname
+ categorynames = list(
+ map(lambda c: c.name, category.get_sub_categorys()))
+ article_list = Article.objects.filter(
+ category__name__in=categorynames, status='p')
+ return article_list
+
+ def get_queryset_cache_key(self):
+ slug = self.kwargs['category_name']
+ category = get_object_or_404(Category, slug=slug)
+ categoryname = category.name
+ self.categoryname = categoryname
+ cache_key = 'category_list_{categoryname}_{page}'.format(
+ categoryname=categoryname, page=self.page_number)
+ return cache_key
+
+ def get_context_data(self, **kwargs):
+
+ categoryname = self.categoryname
+ try:
+ categoryname = categoryname.split('/')[-1]
+ except BaseException:
+ pass
+ kwargs['page_type'] = CategoryDetailView.page_type
+ kwargs['tag_name'] = categoryname
+ return super(CategoryDetailView, self).get_context_data(**kwargs)
+
+
+class AuthorDetailView(ArticleListView):
+ '''
+ 作者详情页
+ '''
+ page_type = '作者文章归档'
+
+ def get_queryset_cache_key(self):
+ from uuslug import slugify
+ author_name = slugify(self.kwargs['author_name'])
+ cache_key = 'author_{author_name}_{page}'.format(
+ author_name=author_name, page=self.page_number)
+ return cache_key
+
+ def get_queryset_data(self):
+ author_name = self.kwargs['author_name']
+ article_list = Article.objects.filter(
+ author__username=author_name, type='a', status='p')
+ return article_list
+
+ def get_context_data(self, **kwargs):
+ author_name = self.kwargs['author_name']
+ kwargs['page_type'] = AuthorDetailView.page_type
+ kwargs['tag_name'] = author_name
+ return super(AuthorDetailView, self).get_context_data(**kwargs)
+
+
+class TagDetailView(ArticleListView):
+ '''
+ 标签列表页面
+ '''
+ page_type = '分类标签归档'
+
+ def get_queryset_data(self):
+ slug = self.kwargs['tag_name']
+ tag = get_object_or_404(Tag, slug=slug)
+ tag_name = tag.name
+ self.name = tag_name
+ article_list = Article.objects.filter(
+ tags__name=tag_name, type='a', status='p')
+ return article_list
+
+ def get_queryset_cache_key(self):
+ slug = self.kwargs['tag_name']
+ tag = get_object_or_404(Tag, slug=slug)
+ tag_name = tag.name
+ self.name = tag_name
+ cache_key = 'tag_{tag_name}_{page}'.format(
+ tag_name=tag_name, page=self.page_number)
+ return cache_key
+
+ def get_context_data(self, **kwargs):
+ # tag_name = self.kwargs['tag_name']
+ tag_name = self.name
+ kwargs['page_type'] = TagDetailView.page_type
+ kwargs['tag_name'] = tag_name
+ return super(TagDetailView, self).get_context_data(**kwargs)
+
+
+class ArchivesView(ArticleListView):
+ '''
+ 文章归档页面
+ '''
+ page_type = '文章归档'
+ paginate_by = None
+ page_kwarg = None
+ template_name = 'blog/article_archives.html'
+
+ def get_queryset_data(self):
+ return Article.objects.filter(status='p').all()
+
+ def get_queryset_cache_key(self):
+ cache_key = 'archives'
+ return cache_key
+
+
+class LinkListView(ListView):
+ model = Links
+ template_name = 'blog/links_list.html'
+
+ def get_queryset(self):
+ return Links.objects.filter(is_enable=True)
+
+
+class EsSearchView(SearchView):
+ def get_context(self):
+ paginator, page = self.build_page()
+ context = {
+ "query": self.query,
+ "form": self.form,
+ "page": page,
+ "paginator": paginator,
+ "suggestion": None,
+ }
+ if hasattr(self.results, "query") and self.results.query.backend.include_spelling:
+ context["suggestion"] = self.results.query.get_spelling_suggestion()
+ context.update(self.extra_context())
+
+ return context
+
+
+@csrf_exempt
+def fileupload(request):
+ """
+ 该方法需自己写调用端来上传图片,该方法仅提供图床功能
+ :param request:
+ :return:
+ """
+ if request.method == 'POST':
+ sign = request.GET.get('sign', None)
+ if not sign:
+ return HttpResponseForbidden()
+ if not sign == get_sha256(get_sha256(settings.SECRET_KEY)):
+ return HttpResponseForbidden()
+ response = []
+ for filename in request.FILES:
+ timestr = timezone.now().strftime('%Y/%m/%d')
+ imgextensions = ['jpg', 'png', 'jpeg', 'bmp']
+ fname = u''.join(str(filename))
+ isimage = len([i for i in imgextensions if fname.find(i) >= 0]) > 0
+ base_dir = os.path.join(settings.STATICFILES, "files" if not isimage else "image", timestr)
+ if not os.path.exists(base_dir):
+ os.makedirs(base_dir)
+ savepath = os.path.normpath(os.path.join(base_dir, f"{uuid.uuid4().hex}{os.path.splitext(filename)[-1]}"))
+ if not savepath.startswith(base_dir):
+ return HttpResponse("only for post")
+ with open(savepath, 'wb+') as wfile:
+ for chunk in request.FILES[filename].chunks():
+ wfile.write(chunk)
+ if isimage:
+ from PIL import Image
+ image = Image.open(savepath)
+ image.save(savepath, quality=20, optimize=True)
+ url = static(savepath)
+ response.append(url)
+ return HttpResponse(response)
+
+ else:
+ return HttpResponse("only for post")
+
+
+def page_not_found_view(
+ request,
+ exception,
+ template_name='blog/error_page.html'):
+ if exception:
+ logger.error(exception)
+ url = request.get_full_path()
+ return render(request,
+ template_name,
+ {'message': _('Sorry, the page you requested is not found, please click the home page to see other?'),
+ 'statuscode': '404'},
+ status=404)
+
+
+def server_error_view(request, template_name='blog/error_page.html'):
+ return render(request,
+ template_name,
+ {'message': _('Sorry, the server is busy, please click the home page to see other?'),
+ 'statuscode': '500'},
+ status=500)
+
+
+def permission_denied_view(
+ request,
+ exception,
+ template_name='blog/error_page.html'):
+ if exception:
+ logger.error(exception)
+ return render(
+ request, template_name, {
+ 'message': _('Sorry, you do not have permission to access this page?'),
+ 'statuscode': '403'}, status=403)
+
+
+def clean_cache_view(request):
+ cache.clear()
+ return HttpResponse('ok')
diff --git a/comments/__init__.py b/comments/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/comments/admin.py b/comments/admin.py
new file mode 100644
index 0000000..a814f3f
--- /dev/null
+++ b/comments/admin.py
@@ -0,0 +1,47 @@
+from django.contrib import admin
+from django.urls import reverse
+from django.utils.html import format_html
+from django.utils.translation import gettext_lazy as _
+
+
+def disable_commentstatus(modeladmin, request, queryset):
+ queryset.update(is_enable=False)
+
+
+def enable_commentstatus(modeladmin, request, queryset):
+ queryset.update(is_enable=True)
+
+
+disable_commentstatus.short_description = _('Disable comments')
+enable_commentstatus.short_description = _('Enable comments')
+
+
+class CommentAdmin(admin.ModelAdmin):
+ list_per_page = 20
+ list_display = (
+ 'id',
+ 'body',
+ 'link_to_userinfo',
+ 'link_to_article',
+ 'is_enable',
+ 'creation_time')
+ list_display_links = ('id', 'body', 'is_enable')
+ list_filter = ('is_enable',)
+ exclude = ('creation_time', 'last_modify_time')
+ actions = [disable_commentstatus, enable_commentstatus]
+
+ def link_to_userinfo(self, obj):
+ info = (obj.author._meta.app_label, obj.author._meta.model_name)
+ link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,))
+ return format_html(
+ u'%s' %
+ (link, obj.author.nickname if obj.author.nickname else obj.author.email))
+
+ def link_to_article(self, obj):
+ info = (obj.article._meta.app_label, obj.article._meta.model_name)
+ link = reverse('admin:%s_%s_change' % info, args=(obj.article.id,))
+ return format_html(
+ u'%s' % (link, obj.article.title))
+
+ link_to_userinfo.short_description = _('User')
+ link_to_article.short_description = _('Article')
diff --git a/comments/apps.py b/comments/apps.py
new file mode 100644
index 0000000..ff01b77
--- /dev/null
+++ b/comments/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class CommentsConfig(AppConfig):
+ name = 'comments'
diff --git a/comments/forms.py b/comments/forms.py
new file mode 100644
index 0000000..e83737d
--- /dev/null
+++ b/comments/forms.py
@@ -0,0 +1,13 @@
+from django import forms
+from django.forms import ModelForm
+
+from .models import Comment
+
+
+class CommentForm(ModelForm):
+ parent_comment_id = forms.IntegerField(
+ widget=forms.HiddenInput, required=False)
+
+ class Meta:
+ model = Comment
+ fields = ['body']
diff --git a/comments/migrations/0001_initial.py b/comments/migrations/0001_initial.py
new file mode 100644
index 0000000..61d1e53
--- /dev/null
+++ b/comments/migrations/0001_initial.py
@@ -0,0 +1,38 @@
+# Generated by Django 4.1.7 on 2023-03-02 07:14
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('blog', '0001_initial'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Comment',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('body', models.TextField(max_length=300, verbose_name='正文')),
+ ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
+ ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
+ ('is_enable', models.BooleanField(default=True, verbose_name='是否显示')),
+ ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='文章')),
+ ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')),
+ ('parent_comment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='上级评论')),
+ ],
+ options={
+ 'verbose_name': '评论',
+ 'verbose_name_plural': '评论',
+ 'ordering': ['-id'],
+ 'get_latest_by': 'id',
+ },
+ ),
+ ]
diff --git a/comments/migrations/0002_alter_comment_is_enable.py b/comments/migrations/0002_alter_comment_is_enable.py
new file mode 100644
index 0000000..17c44db
--- /dev/null
+++ b/comments/migrations/0002_alter_comment_is_enable.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.1.7 on 2023-04-24 13:48
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('comments', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='comment',
+ name='is_enable',
+ field=models.BooleanField(default=False, verbose_name='是否显示'),
+ ),
+ ]
diff --git a/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py b/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py
new file mode 100644
index 0000000..a1ca970
--- /dev/null
+++ b/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py
@@ -0,0 +1,60 @@
+# Generated by Django 4.2.5 on 2023-09-06 13:13
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('blog', '0005_alter_article_options_alter_category_options_and_more'),
+ ('comments', '0002_alter_comment_is_enable'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='comment',
+ options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'comment', 'verbose_name_plural': 'comment'},
+ ),
+ migrations.RemoveField(
+ model_name='comment',
+ name='created_time',
+ ),
+ migrations.RemoveField(
+ model_name='comment',
+ name='last_mod_time',
+ ),
+ migrations.AddField(
+ model_name='comment',
+ name='creation_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
+ ),
+ migrations.AddField(
+ model_name='comment',
+ name='last_modify_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
+ ),
+ migrations.AlterField(
+ model_name='comment',
+ name='article',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='article'),
+ ),
+ migrations.AlterField(
+ model_name='comment',
+ name='author',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'),
+ ),
+ migrations.AlterField(
+ model_name='comment',
+ name='is_enable',
+ field=models.BooleanField(default=False, verbose_name='enable'),
+ ),
+ migrations.AlterField(
+ model_name='comment',
+ name='parent_comment',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='parent comment'),
+ ),
+ ]
diff --git a/comments/migrations/__init__.py b/comments/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/comments/models.py b/comments/models.py
new file mode 100644
index 0000000..7c3bbc8
--- /dev/null
+++ b/comments/models.py
@@ -0,0 +1,39 @@
+from django.conf import settings
+from django.db import models
+from django.utils.timezone import now
+from django.utils.translation import gettext_lazy as _
+
+from blog.models import Article
+
+
+# Create your models here.
+
+class Comment(models.Model):
+ body = models.TextField('正文', max_length=300)
+ creation_time = models.DateTimeField(_('creation time'), default=now)
+ last_modify_time = models.DateTimeField(_('last modify time'), default=now)
+ author = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ verbose_name=_('author'),
+ on_delete=models.CASCADE)
+ article = models.ForeignKey(
+ Article,
+ verbose_name=_('article'),
+ on_delete=models.CASCADE)
+ parent_comment = models.ForeignKey(
+ 'self',
+ verbose_name=_('parent comment'),
+ blank=True,
+ null=True,
+ on_delete=models.CASCADE)
+ is_enable = models.BooleanField(_('enable'),
+ default=False, blank=False, null=False)
+
+ class Meta:
+ ordering = ['-id']
+ verbose_name = _('comment')
+ verbose_name_plural = verbose_name
+ get_latest_by = 'id'
+
+ def __str__(self):
+ return self.body
diff --git a/comments/templatetags/__init__.py b/comments/templatetags/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/comments/templatetags/comments_tags.py b/comments/templatetags/comments_tags.py
new file mode 100644
index 0000000..fde02b4
--- /dev/null
+++ b/comments/templatetags/comments_tags.py
@@ -0,0 +1,30 @@
+from django import template
+
+register = template.Library()
+
+
+@register.simple_tag
+def parse_commenttree(commentlist, comment):
+ """获得当前评论子评论的列表
+ 用法: {% parse_commenttree article_comments comment as childcomments %}
+ """
+ datas = []
+
+ def parse(c):
+ childs = commentlist.filter(parent_comment=c, is_enable=True)
+ for child in childs:
+ datas.append(child)
+ parse(child)
+
+ parse(comment)
+ return datas
+
+
+@register.inclusion_tag('comments/tags/comment_item.html')
+def show_comment_item(comment, ischild):
+ """评论"""
+ depth = 1 if ischild else 2
+ return {
+ 'comment_item': comment,
+ 'depth': depth
+ }
diff --git a/comments/tests.py b/comments/tests.py
new file mode 100644
index 0000000..2a7f55f
--- /dev/null
+++ b/comments/tests.py
@@ -0,0 +1,109 @@
+from django.test import Client, RequestFactory, TransactionTestCase
+from django.urls import reverse
+
+from accounts.models import BlogUser
+from blog.models import Category, Article
+from comments.models import Comment
+from comments.templatetags.comments_tags import *
+from djangoblog.utils import get_max_articleid_commentid
+
+
+# Create your tests here.
+
+class CommentsTest(TransactionTestCase):
+ def setUp(self):
+ self.client = Client()
+ self.factory = RequestFactory()
+ from blog.models import BlogSettings
+ value = BlogSettings()
+ value.comment_need_review = True
+ value.save()
+
+ self.user = BlogUser.objects.create_superuser(
+ email="liangliangyy1@gmail.com",
+ username="liangliangyy1",
+ password="liangliangyy1")
+
+ def update_article_comment_status(self, article):
+ comments = article.comment_set.all()
+ for comment in comments:
+ comment.is_enable = True
+ comment.save()
+
+ def test_validate_comment(self):
+ self.client.login(username='liangliangyy1', password='liangliangyy1')
+
+ category = Category()
+ category.name = "categoryccc"
+ category.save()
+
+ article = Article()
+ article.title = "nicetitleccc"
+ article.body = "nicecontentccc"
+ article.author = self.user
+ article.category = category
+ article.type = 'a'
+ article.status = 'p'
+ article.save()
+
+ comment_url = reverse(
+ 'comments:postcomment', kwargs={
+ 'article_id': article.id})
+
+ response = self.client.post(comment_url,
+ {
+ 'body': '123ffffffffff'
+ })
+
+ self.assertEqual(response.status_code, 302)
+
+ article = Article.objects.get(pk=article.pk)
+ self.assertEqual(len(article.comment_list()), 0)
+ self.update_article_comment_status(article)
+
+ self.assertEqual(len(article.comment_list()), 1)
+
+ response = self.client.post(comment_url,
+ {
+ 'body': '123ffffffffff',
+ })
+
+ self.assertEqual(response.status_code, 302)
+
+ article = Article.objects.get(pk=article.pk)
+ self.update_article_comment_status(article)
+ self.assertEqual(len(article.comment_list()), 2)
+ parent_comment_id = article.comment_list()[0].id
+
+ response = self.client.post(comment_url,
+ {
+ 'body': '''
+ # Title1
+
+ ```python
+ import os
+ ```
+
+ [url](https://www.lylinux.net/)
+
+ [ddd](http://www.baidu.com)
+
+
+ ''',
+ 'parent_comment_id': parent_comment_id
+ })
+
+ self.assertEqual(response.status_code, 302)
+ self.update_article_comment_status(article)
+ article = Article.objects.get(pk=article.pk)
+ self.assertEqual(len(article.comment_list()), 3)
+ comment = Comment.objects.get(id=parent_comment_id)
+ tree = parse_commenttree(article.comment_list(), comment)
+ self.assertEqual(len(tree), 1)
+ data = show_comment_item(comment, True)
+ self.assertIsNotNone(data)
+ s = get_max_articleid_commentid()
+ self.assertIsNotNone(s)
+
+ from comments.utils import send_comment_email
+ send_comment_email(comment)
diff --git a/comments/urls.py b/comments/urls.py
new file mode 100644
index 0000000..7df3fab
--- /dev/null
+++ b/comments/urls.py
@@ -0,0 +1,11 @@
+from django.urls import path
+
+from . import views
+
+app_name = "comments"
+urlpatterns = [
+ path(
+ 'article//postcomment',
+ views.CommentPostView.as_view(),
+ name='postcomment'),
+]
diff --git a/comments/utils.py b/comments/utils.py
new file mode 100644
index 0000000..f01dba7
--- /dev/null
+++ b/comments/utils.py
@@ -0,0 +1,38 @@
+import logging
+
+from django.utils.translation import gettext_lazy as _
+
+from djangoblog.utils import get_current_site
+from djangoblog.utils import send_email
+
+logger = logging.getLogger(__name__)
+
+
+def send_comment_email(comment):
+ site = get_current_site().domain
+ subject = _('Thanks for your comment')
+ article_url = f"https://{site}{comment.article.get_absolute_url()}"
+ html_content = _("""Thank you very much for your comments on this site
+ You can visit %(article_title)s
+ to review your comments,
+ Thank you again!
+
+ If the link above cannot be opened, please copy this link to your browser.
+ %(article_url)s""") % {'article_url': article_url, 'article_title': comment.article.title}
+ tomail = comment.author.email
+ send_email([tomail], subject, html_content)
+ try:
+ if comment.parent_comment:
+ html_content = _("""Your comment on %(article_title)s
has
+ received a reply.
%(comment_body)s
+
+ go check it out!
+
+ If the link above cannot be opened, please copy this link to your browser.
+ %(article_url)s
+ """) % {'article_url': article_url, 'article_title': comment.article.title,
+ 'comment_body': comment.parent_comment.body}
+ tomail = comment.parent_comment.author.email
+ send_email([tomail], subject, html_content)
+ except Exception as e:
+ logger.error(e)
diff --git a/comments/views.py b/comments/views.py
new file mode 100644
index 0000000..ad9b2b9
--- /dev/null
+++ b/comments/views.py
@@ -0,0 +1,63 @@
+# Create your views here.
+from django.core.exceptions import ValidationError
+from django.http import HttpResponseRedirect
+from django.shortcuts import get_object_or_404
+from django.utils.decorators import method_decorator
+from django.views.decorators.csrf import csrf_protect
+from django.views.generic.edit import FormView
+
+from accounts.models import BlogUser
+from blog.models import Article
+from .forms import CommentForm
+from .models import Comment
+
+
+class CommentPostView(FormView):
+ form_class = CommentForm
+ template_name = 'blog/article_detail.html'
+
+ @method_decorator(csrf_protect)
+ def dispatch(self, *args, **kwargs):
+ return super(CommentPostView, self).dispatch(*args, **kwargs)
+
+ def get(self, request, *args, **kwargs):
+ article_id = self.kwargs['article_id']
+ article = get_object_or_404(Article, pk=article_id)
+ url = article.get_absolute_url()
+ return HttpResponseRedirect(url + "#comments")
+
+ def form_invalid(self, form):
+ article_id = self.kwargs['article_id']
+ article = get_object_or_404(Article, pk=article_id)
+
+ return self.render_to_response({
+ 'form': form,
+ 'article': article
+ })
+
+ def form_valid(self, form):
+ """提交的数据验证合法后的逻辑"""
+ user = self.request.user
+ author = BlogUser.objects.get(pk=user.pk)
+ article_id = self.kwargs['article_id']
+ article = get_object_or_404(Article, pk=article_id)
+
+ if article.comment_status == 'c' or article.status == 'c':
+ raise ValidationError("该文章评论已关闭.")
+ comment = form.save(False)
+ comment.article = article
+ from djangoblog.utils import get_blog_setting
+ settings = get_blog_setting()
+ if not settings.comment_need_review:
+ comment.is_enable = True
+ comment.author = author
+
+ if form.cleaned_data['parent_comment_id']:
+ parent_comment = Comment.objects.get(
+ pk=form.cleaned_data['parent_comment_id'])
+ comment.parent_comment = parent_comment
+
+ comment.save(True)
+ return HttpResponseRedirect(
+ "%s#div-comment-%d" %
+ (article.get_absolute_url(), comment.pk))
diff --git a/deploy/docker-compose/docker-compose.es.yml b/deploy/docker-compose/docker-compose.es.yml
new file mode 100644
index 0000000..83e35ff
--- /dev/null
+++ b/deploy/docker-compose/docker-compose.es.yml
@@ -0,0 +1,48 @@
+version: '3'
+
+services:
+ es:
+ image: liangliangyy/elasticsearch-analysis-ik:8.6.1
+ container_name: es
+ restart: always
+ environment:
+ - discovery.type=single-node
+ - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
+ ports:
+ - 9200:9200
+ volumes:
+ - ./bin/datas/es/:/usr/share/elasticsearch/data/
+
+ kibana:
+ image: kibana:8.6.1
+ restart: always
+ container_name: kibana
+ ports:
+ - 5601:5601
+ environment:
+ - ELASTICSEARCH_HOSTS=http://es:9200
+
+ djangoblog:
+ build: .
+ restart: always
+ command: bash -c 'sh /code/djangoblog/bin/docker_start.sh'
+ ports:
+ - "8000:8000"
+ volumes:
+ - ./collectedstatic:/code/djangoblog/collectedstatic
+ - ./uploads:/code/djangoblog/uploads
+ environment:
+ - DJANGO_MYSQL_DATABASE=djangoblog
+ - DJANGO_MYSQL_USER=root
+ - DJANGO_MYSQL_PASSWORD=DjAnGoBlOg!2!Q@W#E
+ - DJANGO_MYSQL_HOST=db
+ - DJANGO_MYSQL_PORT=3306
+ - DJANGO_MEMCACHED_LOCATION=memcached:11211
+ - DJANGO_ELASTICSEARCH_HOST=es:9200
+ links:
+ - db
+ - memcached
+ depends_on:
+ - db
+ container_name: djangoblog
+
diff --git a/deploy/docker-compose/docker-compose.yml b/deploy/docker-compose/docker-compose.yml
new file mode 100644
index 0000000..9609af3
--- /dev/null
+++ b/deploy/docker-compose/docker-compose.yml
@@ -0,0 +1,60 @@
+version: '3'
+
+services:
+ db:
+ image: mysql:latest
+ restart: always
+ environment:
+ - MYSQL_DATABASE=djangoblog
+ - MYSQL_ROOT_PASSWORD=DjAnGoBlOg!2!Q@W#E
+ ports:
+ - 3306:3306
+ volumes:
+ - ./bin/datas/mysql/:/var/lib/mysql
+ depends_on:
+ - redis
+ container_name: db
+
+ djangoblog:
+ build:
+ context: ../../
+ restart: always
+ command: bash -c 'sh /code/djangoblog/bin/docker_start.sh'
+ ports:
+ - "8000:8000"
+ volumes:
+ - ./collectedstatic:/code/djangoblog/collectedstatic
+ - ./logs:/code/djangoblog/logs
+ - ./uploads:/code/djangoblog/uploads
+ environment:
+ - DJANGO_MYSQL_DATABASE=djangoblog
+ - DJANGO_MYSQL_USER=root
+ - DJANGO_MYSQL_PASSWORD=DjAnGoBlOg!2!Q@W#E
+ - DJANGO_MYSQL_HOST=db
+ - DJANGO_MYSQL_PORT=3306
+ - DJANGO_REDIS_URL=redis:6379
+ links:
+ - db
+ - redis
+ depends_on:
+ - db
+ container_name: djangoblog
+ nginx:
+ restart: always
+ image: nginx:latest
+ ports:
+ - "80:80"
+ - "443:443"
+ volumes:
+ - ./bin/nginx.conf:/etc/nginx/nginx.conf
+ - ./collectedstatic:/code/djangoblog/collectedstatic
+ links:
+ - djangoblog:djangoblog
+ container_name: nginx
+
+ redis:
+ restart: always
+ image: redis:latest
+ container_name: redis
+ ports:
+ - "6379:6379"
diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh
new file mode 100644
index 0000000..2fb6491
--- /dev/null
+++ b/deploy/entrypoint.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+NAME="djangoblog"
+DJANGODIR=/code/djangoblog
+USER=root
+GROUP=root
+NUM_WORKERS=1
+DJANGO_WSGI_MODULE=djangoblog.wsgi
+
+
+echo "Starting $NAME as `whoami`"
+
+cd $DJANGODIR
+
+export PYTHONPATH=$DJANGODIR:$PYTHONPATH
+
+python manage.py makemigrations && \
+ python manage.py migrate && \
+ python manage.py collectstatic --noinput && \
+ python manage.py compress --force && \
+ python manage.py build_index && \
+ python manage.py compilemessages || exit 1
+
+exec gunicorn ${DJANGO_WSGI_MODULE}:application \
+--name $NAME \
+--workers $NUM_WORKERS \
+--user=$USER --group=$GROUP \
+--bind 0.0.0.0:8000 \
+--log-level=debug \
+--log-file=- \
+--worker-class gevent \
+--threads 4
diff --git a/deploy/k8s/configmap.yaml b/deploy/k8s/configmap.yaml
new file mode 100644
index 0000000..835d4ad
--- /dev/null
+++ b/deploy/k8s/configmap.yaml
@@ -0,0 +1,119 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: web-nginx-config
+ namespace: djangoblog
+data:
+ nginx.conf: |
+ user nginx;
+ worker_processes auto;
+ error_log /var/log/nginx/error.log notice;
+ pid /var/run/nginx.pid;
+
+ events {
+ worker_connections 1024;
+ multi_accept on;
+ use epoll;
+ }
+
+ http {
+ include /etc/nginx/mime.types;
+ default_type application/octet-stream;
+
+ log_format main '$remote_addr - $remote_user [$time_local] "$request" '
+ '$status $body_bytes_sent "$http_referer" '
+ '"$http_user_agent" "$http_x_forwarded_for"';
+
+ access_log /var/log/nginx/access.log main;
+
+ sendfile on;
+ keepalive_timeout 65;
+ gzip on;
+ gzip_disable "msie6";
+
+ gzip_vary on;
+ gzip_proxied any;
+ gzip_comp_level 8;
+ gzip_buffers 16 8k;
+ gzip_http_version 1.1;
+ gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png;
+
+ # Include server configurations
+ include /etc/nginx/conf.d/*.conf;
+ }
+ djangoblog.conf: |
+ server {
+ server_name lylinux.net;
+ root /code/djangoblog/collectedstatic/;
+ listen 80;
+ keepalive_timeout 70;
+ location /static/ {
+ expires max;
+ alias /code/djangoblog/collectedstatic/;
+ }
+
+ location ~* (robots\.txt|ads\.txt|favicon\.ico|favion\.ico|crossdomain\.xml|google93fd32dbd906620a\.html|BingSiteAuth\.xml|baidu_verify_Ijeny6KrmS\.html)$ {
+ root /resource/djangopub;
+ expires 1d;
+ access_log off;
+ error_log off;
+ }
+
+ location / {
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header Host $http_host;
+ proxy_set_header X-NginX-Proxy true;
+ proxy_redirect off;
+ if (!-f $request_filename) {
+ proxy_pass http://djangoblog:8000;
+ break;
+ }
+ }
+ }
+ server {
+ server_name www.lylinux.net;
+ listen 80;
+ return 301 https://lylinux.net$request_uri;
+ }
+ resource.lylinux.net.conf: |
+ server {
+ index index.html index.htm;
+ server_name resource.lylinux.net;
+ root /resource/;
+
+ location /djangoblog/ {
+ alias /code/djangoblog/collectedstatic/;
+ }
+
+ access_log off;
+ error_log off;
+ include lylinux/resource.conf;
+ }
+ lylinux.resource.conf: |
+ expires max;
+ access_log off;
+ log_not_found off;
+ add_header Pragma public;
+ add_header Cache-Control "public";
+ add_header "Access-Control-Allow-Origin" "*";
+
+---
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: djangoblog-env
+ namespace: djangoblog
+data:
+ DJANGO_MYSQL_DATABASE: djangoblog
+ DJANGO_MYSQL_USER: db_user
+ DJANGO_MYSQL_PASSWORD: db_password
+ DJANGO_MYSQL_HOST: db_host
+ DJANGO_MYSQL_PORT: db_port
+ DJANGO_REDIS_URL: "redis:6379"
+ DJANGO_DEBUG: "False"
+ MYSQL_ROOT_PASSWORD: db_password
+ MYSQL_DATABASE: djangoblog
+ MYSQL_PASSWORD: db_password
+ DJANGO_SECRET_KEY: xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+
diff --git a/deploy/k8s/deployment.yaml b/deploy/k8s/deployment.yaml
new file mode 100644
index 0000000..414fdcc
--- /dev/null
+++ b/deploy/k8s/deployment.yaml
@@ -0,0 +1,274 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: djangoblog
+ namespace: djangoblog
+ labels:
+ app: djangoblog
+spec:
+ replicas: 3
+ selector:
+ matchLabels:
+ app: djangoblog
+ template:
+ metadata:
+ labels:
+ app: djangoblog
+ spec:
+ containers:
+ - name: djangoblog
+ image: liangliangyy/djangoblog:latest
+ imagePullPolicy: Always
+ ports:
+ - containerPort: 8000
+ envFrom:
+ - configMapRef:
+ name: djangoblog-env
+ readinessProbe:
+ httpGet:
+ path: /
+ port: 8000
+ initialDelaySeconds: 10
+ periodSeconds: 30
+ livenessProbe:
+ httpGet:
+ path: /
+ port: 8000
+ initialDelaySeconds: 10
+ periodSeconds: 30
+ resources:
+ requests:
+ cpu: 10m
+ memory: 100Mi
+ limits:
+ cpu: "2"
+ memory: 2Gi
+ volumeMounts:
+ - name: djangoblog
+ mountPath: /code/djangoblog/collectedstatic
+ - name: resource
+ mountPath: /resource
+ volumes:
+ - name: djangoblog
+ persistentVolumeClaim:
+ claimName: djangoblog-pvc
+ - name: resource
+ persistentVolumeClaim:
+ claimName: resource-pvc
+
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: redis
+ namespace: djangoblog
+ labels:
+ app: redis
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: redis
+ template:
+ metadata:
+ labels:
+ app: redis
+ spec:
+ containers:
+ - name: redis
+ image: redis:latest
+ imagePullPolicy: IfNotPresent
+ ports:
+ - containerPort: 6379
+ resources:
+ requests:
+ cpu: 10m
+ memory: 100Mi
+ limits:
+ cpu: 200m
+ memory: 2Gi
+
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: db
+ namespace: djangoblog
+ labels:
+ app: db
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: db
+ template:
+ metadata:
+ labels:
+ app: db
+ spec:
+ containers:
+ - name: db
+ image: mysql:latest
+ imagePullPolicy: IfNotPresent
+ ports:
+ - containerPort: 3306
+ envFrom:
+ - configMapRef:
+ name: djangoblog-env
+ readinessProbe:
+ exec:
+ command:
+ - mysqladmin
+ - ping
+ - "-h"
+ - "127.0.0.1"
+ - "-u"
+ - "root"
+ - "-p$MYSQL_ROOT_PASSWORD"
+ initialDelaySeconds: 10
+ periodSeconds: 10
+ livenessProbe:
+ exec:
+ command:
+ - mysqladmin
+ - ping
+ - "-h"
+ - "127.0.0.1"
+ - "-u"
+ - "root"
+ - "-p$MYSQL_ROOT_PASSWORD"
+ initialDelaySeconds: 10
+ periodSeconds: 10
+ resources:
+ requests:
+ cpu: 10m
+ memory: 100Mi
+ limits:
+ cpu: "2"
+ memory: 2Gi
+ volumeMounts:
+ - name: db-data
+ mountPath: /var/lib/mysql
+ volumes:
+ - name: db-data
+ persistentVolumeClaim:
+ claimName: db-pvc
+
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: nginx
+ namespace: djangoblog
+ labels:
+ app: nginx
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: nginx
+ template:
+ metadata:
+ labels:
+ app: nginx
+ spec:
+ containers:
+ - name: nginx
+ image: nginx:latest
+ imagePullPolicy: IfNotPresent
+ ports:
+ - containerPort: 80
+ resources:
+ requests:
+ cpu: 10m
+ memory: 100Mi
+ limits:
+ cpu: "2"
+ memory: 2Gi
+ volumeMounts:
+ - name: nginx-config
+ mountPath: /etc/nginx/nginx.conf
+ subPath: nginx.conf
+ - name: nginx-config
+ mountPath: /etc/nginx/conf.d/default.conf
+ subPath: djangoblog.conf
+ - name: nginx-config
+ mountPath: /etc/nginx/conf.d/resource.lylinux.net.conf
+ subPath: resource.lylinux.net.conf
+ - name: nginx-config
+ mountPath: /etc/nginx/lylinux/resource.conf
+ subPath: lylinux.resource.conf
+ - name: djangoblog-pvc
+ mountPath: /code/djangoblog/collectedstatic
+ - name: resource-pvc
+ mountPath: /resource
+ volumes:
+ - name: nginx-config
+ configMap:
+ name: web-nginx-config
+ - name: djangoblog-pvc
+ persistentVolumeClaim:
+ claimName: djangoblog-pvc
+ - name: resource-pvc
+ persistentVolumeClaim:
+ claimName: resource-pvc
+
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: elasticsearch
+ namespace: djangoblog
+ labels:
+ app: elasticsearch
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: elasticsearch
+ template:
+ metadata:
+ labels:
+ app: elasticsearch
+ spec:
+ containers:
+ - name: elasticsearch
+ image: liangliangyy/elasticsearch-analysis-ik:8.6.1
+ imagePullPolicy: IfNotPresent
+ env:
+ - name: discovery.type
+ value: single-node
+ - name: ES_JAVA_OPTS
+ value: "-Xms256m -Xmx256m"
+ - name: xpack.security.enabled
+ value: "false"
+ - name: xpack.monitoring.templates.enabled
+ value: "false"
+ ports:
+ - containerPort: 9200
+ resources:
+ requests:
+ cpu: 10m
+ memory: 100Mi
+ limits:
+ cpu: "2"
+ memory: 2Gi
+ readinessProbe:
+ httpGet:
+ path: /
+ port: 9200
+ initialDelaySeconds: 15
+ periodSeconds: 30
+ livenessProbe:
+ httpGet:
+ path: /
+ port: 9200
+ initialDelaySeconds: 15
+ periodSeconds: 30
+ volumeMounts:
+ - name: elasticsearch-data
+ mountPath: /usr/share/elasticsearch/data/
+ volumes:
+ - name: elasticsearch-data
+ persistentVolumeClaim:
+ claimName: elasticsearch-pvc
diff --git a/deploy/k8s/gateway.yaml b/deploy/k8s/gateway.yaml
new file mode 100644
index 0000000..a8de073
--- /dev/null
+++ b/deploy/k8s/gateway.yaml
@@ -0,0 +1,17 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: nginx
+ namespace: djangoblog
+spec:
+ ingressClassName: nginx
+ rules:
+ - http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: nginx
+ port:
+ number: 80
\ No newline at end of file
diff --git a/deploy/k8s/pv.yaml b/deploy/k8s/pv.yaml
new file mode 100644
index 0000000..874b72f
--- /dev/null
+++ b/deploy/k8s/pv.yaml
@@ -0,0 +1,94 @@
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+ name: local-pv-db
+spec:
+ capacity:
+ storage: 10Gi
+ volumeMode: Filesystem
+ accessModes:
+ - ReadWriteOnce
+ persistentVolumeReclaimPolicy: Retain
+ storageClassName: local-storage
+ local:
+ path: /mnt/local-storage-db
+ nodeAffinity:
+ required:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: kubernetes.io/hostname
+ operator: In
+ values:
+ - master
+---
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+ name: local-pv-djangoblog
+spec:
+ capacity:
+ storage: 5Gi
+ volumeMode: Filesystem
+ accessModes:
+ - ReadWriteOnce
+ persistentVolumeReclaimPolicy: Retain
+ storageClassName: local-storage
+ local:
+ path: /mnt/local-storage-djangoblog
+ nodeAffinity:
+ required:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: kubernetes.io/hostname
+ operator: In
+ values:
+ - master
+
+
+---
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+ name: local-pv-resource
+spec:
+ capacity:
+ storage: 5Gi
+ volumeMode: Filesystem
+ accessModes:
+ - ReadWriteOnce
+ persistentVolumeReclaimPolicy: Retain
+ storageClassName: local-storage
+ local:
+ path: /mnt/resource/
+ nodeAffinity:
+ required:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: kubernetes.io/hostname
+ operator: In
+ values:
+ - master
+
+---
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+ name: local-pv-elasticsearch
+spec:
+ capacity:
+ storage: 5Gi
+ volumeMode: Filesystem
+ accessModes:
+ - ReadWriteOnce
+ persistentVolumeReclaimPolicy: Retain
+ storageClassName: local-storage
+ local:
+ path: /mnt/local-storage-elasticsearch
+ nodeAffinity:
+ required:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: kubernetes.io/hostname
+ operator: In
+ values:
+ - master
\ No newline at end of file
diff --git a/deploy/k8s/pvc.yaml b/deploy/k8s/pvc.yaml
new file mode 100644
index 0000000..ef238c5
--- /dev/null
+++ b/deploy/k8s/pvc.yaml
@@ -0,0 +1,60 @@
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: db-pvc
+ namespace: djangoblog
+spec:
+ storageClassName: local-storage
+ volumeName: local-pv-db
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 10Gi
+
+
+---
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: djangoblog-pvc
+ namespace: djangoblog
+spec:
+ volumeName: local-pv-djangoblog
+ storageClassName: local-storage
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 5Gi
+
+---
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: resource-pvc
+ namespace: djangoblog
+spec:
+ volumeName: local-pv-resource
+ storageClassName: local-storage
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 5Gi
+
+---
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: elasticsearch-pvc
+ namespace: djangoblog
+spec:
+ volumeName: local-pv-elasticsearch
+ storageClassName: local-storage
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 5Gi
+
\ No newline at end of file
diff --git a/deploy/k8s/service.yaml b/deploy/k8s/service.yaml
new file mode 100644
index 0000000..4ef2931
--- /dev/null
+++ b/deploy/k8s/service.yaml
@@ -0,0 +1,80 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: djangoblog
+ namespace: djangoblog
+ labels:
+ app: djangoblog
+spec:
+ selector:
+ app: djangoblog
+ ports:
+ - protocol: TCP
+ port: 8000
+ targetPort: 8000
+ type: ClusterIP
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: nginx
+ namespace: djangoblog
+ labels:
+ app: nginx
+spec:
+ selector:
+ app: nginx
+ ports:
+ - protocol: TCP
+ port: 80
+ targetPort: 80
+ type: ClusterIP
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: redis
+ namespace: djangoblog
+ labels:
+ app: redis
+spec:
+ selector:
+ app: redis
+ ports:
+ - protocol: TCP
+ port: 6379
+ targetPort: 6379
+ type: ClusterIP
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: db
+ namespace: djangoblog
+ labels:
+ app: db
+spec:
+ selector:
+ app: db
+ ports:
+ - protocol: TCP
+ port: 3306
+ targetPort: 3306
+ type: ClusterIP
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: elasticsearch
+ namespace: djangoblog
+ labels:
+ app: elasticsearch
+spec:
+ selector:
+ app: elasticsearch
+ ports:
+ - protocol: TCP
+ port: 9200
+ targetPort: 9200
+ type: ClusterIP
+
diff --git a/deploy/k8s/storageclass.yaml b/deploy/k8s/storageclass.yaml
new file mode 100644
index 0000000..5d5a14c
--- /dev/null
+++ b/deploy/k8s/storageclass.yaml
@@ -0,0 +1,10 @@
+apiVersion: storage.k8s.io/v1
+kind: StorageClass
+metadata:
+ name: local-storage
+ annotations:
+ storageclass.kubernetes.io/is-default-class: "true"
+provisioner: kubernetes.io/no-provisioner
+volumeBindingMode: Immediate
+
+
diff --git a/deploy/nginx.conf b/deploy/nginx.conf
new file mode 100644
index 0000000..32161d8
--- /dev/null
+++ b/deploy/nginx.conf
@@ -0,0 +1,50 @@
+user nginx;
+worker_processes auto;
+
+error_log /var/log/nginx/error.log notice;
+pid /var/run/nginx.pid;
+
+
+events {
+ worker_connections 1024;
+}
+
+
+http {
+ include /etc/nginx/mime.types;
+ default_type application/octet-stream;
+
+ log_format main '$remote_addr - $remote_user [$time_local] "$request" '
+ '$status $body_bytes_sent "$http_referer" '
+ '"$http_user_agent" "$http_x_forwarded_for"';
+
+ access_log /var/log/nginx/access.log main;
+
+ sendfile on;
+ #tcp_nopush on;
+
+ keepalive_timeout 65;
+
+ #gzip on;
+
+ server {
+ root /code/djangoblog/collectedstatic/;
+ listen 80;
+ keepalive_timeout 70;
+ location /static/ {
+ expires max;
+ alias /code/djangoblog/collectedstatic/;
+ }
+ location / {
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header Host $http_host;
+ proxy_set_header X-NginX-Proxy true;
+ proxy_redirect off;
+ if (!-f $request_filename) {
+ proxy_pass http://djangoblog:8000;
+ break;
+ }
+ }
+ }
+}
diff --git a/doc/19组开源软件泛读、标注和维护报告文档.docx b/doc/19组开源软件泛读、标注和维护报告文档.docx
new file mode 100644
index 0000000000000000000000000000000000000000..cf21d5472af687550a99c5261df8dc9ac935c2ae
GIT binary patch
literal 2875664
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